{"id":"bd-10f","title":"Update orchestrator for MR ingestion","description":"## Background\nOrchestrator coordinates MR ingestion followed by dependent discussion sync. Discussion sync targets are queried from DB (not collected in-memory) to handle large projects without memory growth. This is critical for projects with 10k+ MRs where collecting sync targets in memory during ingestion would cause unbounded growth.\n\n## Approach\nUpdate `src/ingestion/orchestrator.rs` to:\n1. Support `merge_requests` resource type in `run_ingestion()` match arm\n2. Query DB for MRs needing discussion sync after MR ingestion completes\n3. Execute discussion sync with bounded concurrency using `futures::stream::buffer_unordered`\n\n## Files\n- `src/ingestion/orchestrator.rs` - Update existing orchestrator\n\n## Acceptance Criteria\n- [ ] `run_ingestion()` handles `resource_type == \"merge_requests\"`\n- [ ] After MR ingestion, queries DB for MRs where `updated_at > discussions_synced_for_updated_at`\n- [ ] Discussion sync uses `dependent_concurrency` from config (default 5)\n- [ ] Each MR's discussion sync is independent (partial failures don't block others)\n- [ ] Results aggregated from MR ingestion + all discussion ingestion results\n- [ ] `cargo test orchestrator` passes\n\n## TDD Loop\nRED: `cargo test orchestrator_mr` -> merge_requests not handled\nGREEN: Add MR branch to orchestrator\nVERIFY: `cargo test orchestrator`\n\n## Struct Definition\n```rust\n/// Lightweight struct for DB query results - only fields needed for discussion sync\nstruct MrForDiscussionSync {\n    local_mr_id: i64,\n    iid: i64,\n    updated_at: i64,\n}\n```\n\n## DB Query for Discussion Sync Targets\n```sql\nSELECT id, iid, updated_at\nFROM merge_requests\nWHERE project_id = ?\n  AND (discussions_synced_for_updated_at IS NULL\n       OR updated_at > discussions_synced_for_updated_at)\nORDER BY updated_at ASC;\n```\n\n## Orchestrator Flow\n```rust\npub async fn run_ingestion(\n    &self,\n    resource_type: &str,\n    full_sync: bool,\n) -> Result<IngestResult> {\n    match resource_type {\n        \"issues\" => self.run_issue_ingestion(full_sync).await,\n        \"merge_requests\" => self.run_mr_ingestion(full_sync).await,\n        _ => Err(GiError::InvalidArgument {\n            name: \"type\".to_string(),\n            value: resource_type.to_string(),\n            expected: \"issues or merge_requests\".to_string(),\n        }),\n    }\n}\n\nasync fn run_mr_ingestion(&self, full_sync: bool) -> Result<IngestResult> {\n    // 1. Ingest MRs (handles cursor reset if full_sync)\n    let mr_result = ingest_merge_requests(\n        &self.conn, &self.client, &self.config,\n        self.project_id, self.gitlab_project_id, full_sync,\n    ).await?;\n    \n    // 2. Query DB for MRs needing discussion sync\n    // CRITICAL: Do this AFTER ingestion, not during, to avoid memory growth\n    let mrs_needing_sync: Vec<MrForDiscussionSync> = {\n        let mut stmt = self.conn.prepare(\n            \"SELECT id, iid, updated_at FROM merge_requests\n             WHERE project_id = ? AND (discussions_synced_for_updated_at IS NULL\n                   OR updated_at > discussions_synced_for_updated_at)\n             ORDER BY updated_at ASC\"\n        )?;\n        stmt.query_map([self.project_id], |row| {\n            Ok(MrForDiscussionSync {\n                local_mr_id: row.get(0)?,\n                iid: row.get(1)?,\n                updated_at: row.get(2)?,\n            })\n        })?.collect::<Result<Vec<_>, _>>()?\n    };\n    \n    let total_needing_sync = mrs_needing_sync.len();\n    info!(\"Discussion sync needed for {} MRs\", total_needing_sync);\n    \n    // 3. Execute discussion sync with bounded concurrency\n    let concurrency = self.config.sync.dependent_concurrency.unwrap_or(5);\n    \n    let discussion_results: Vec<Result<IngestMrDiscussionsResult>> = \n        futures::stream::iter(mrs_needing_sync)\n            .map(|mr| {\n                let conn = &self.conn;\n                let client = &self.client;\n                let config = &self.config;\n                let project_id = self.project_id;\n                let gitlab_project_id = self.gitlab_project_id;\n                async move {\n                    ingest_mr_discussions(\n                        conn, client, config,\n                        project_id, gitlab_project_id,\n                        mr.iid, mr.local_mr_id, mr.updated_at,\n                    ).await\n                }\n            })\n            .buffer_unordered(concurrency)\n            .collect()\n            .await;\n    \n    // 4. Aggregate results\n    let mut total_discussions = 0;\n    let mut total_notes = 0;\n    let mut total_diffnotes = 0;\n    let mut failed_syncs = 0;\n    \n    for result in discussion_results {\n        match result {\n            Ok(r) => {\n                total_discussions += r.discussions_upserted;\n                total_notes += r.notes_upserted;\n                total_diffnotes += r.diffnotes_count;\n            }\n            Err(e) => {\n                warn!(\"Discussion sync failed: {}\", e);\n                failed_syncs += 1;\n            }\n        }\n    }\n    \n    Ok(IngestResult {\n        mrs_fetched: mr_result.fetched,\n        mrs_upserted: mr_result.upserted,\n        labels_created: mr_result.labels_created,\n        assignees_linked: mr_result.assignees_linked,\n        reviewers_linked: mr_result.reviewers_linked,\n        discussions_synced: total_discussions,\n        notes_synced: total_notes,\n        diffnotes_count: total_diffnotes,\n        mrs_skipped_discussion_sync: (mr_result.fetched as usize).saturating_sub(total_needing_sync),\n        failed_discussion_syncs: failed_syncs,\n    })\n}\n```\n\n## Required Imports\n```rust\nuse futures::stream::StreamExt;\nuse crate::ingestion::merge_requests::ingest_merge_requests;\nuse crate::ingestion::mr_discussions::{ingest_mr_discussions, IngestMrDiscussionsResult};\n```\n\n## Config Reference\n```rust\n// In config.rs or similar\npub struct SyncConfig {\n    pub dependent_concurrency: Option<usize>,  // Default 5\n    // ... other fields\n}\n```\n\n## Edge Cases\n- Large projects: 10k+ MRs may need discussion sync - DB-driven query avoids memory growth\n- Partial failures: Each MR's discussion sync is independent; failures logged but don't stop others\n- Concurrency: Too high (>10) may hit GitLab rate limits; default 5 balances throughput with safety\n- Empty result: If no MRs need sync, discussion phase completes immediately with zero counts","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-26T22:06:42.731140Z","created_by":"tayloreernisse","updated_at":"2026-01-27T00:25:13.472341Z","closed_at":"2026-01-27T00:25:13.472281Z","close_reason":"Updated orchestrator for MR ingestion:\n- Added IngestMrProjectResult struct with all MR-specific metrics\n- Added ingest_project_merge_requests() and ingest_project_merge_requests_with_progress()\n- Queries DB for MRs needing discussion sync AFTER ingestion (memory-safe for large projects)\n- Added MR-specific progress events (MrsFetchStarted, MrFetched, etc.)\n- Sequential discussion sync using dependent_concurrency config\n- All 164 tests passing","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-10f","depends_on_id":"bd-20h","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-10f","depends_on_id":"bd-ser","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-10i","title":"Epic: CP2 Gate D - Resumability Proof","description":"## Background\nGate D validates resumability and crash recovery. Proves that cursor and watermark mechanics prevent massive refetch after interruption. This is critical for large projects where a full refetch would take hours.\n\n## Acceptance Criteria (Pass/Fail)\n- [ ] Kill mid-run, rerun -> bounded redo (not full refetch from beginning)\n- [ ] Cursor saved at page boundary (not item boundary)\n- [ ] No redundant discussion refetch after crash recovery\n- [ ] No watermark advancement on partial pagination failure\n- [ ] Single-flight lock prevents concurrent ingest runs\n- [ ] `--full` flag resets MR cursor to NULL\n- [ ] `--full` flag resets ALL `discussions_synced_for_updated_at` to NULL\n- [ ] `--force` bypasses single-flight lock\n\n## Validation Script\n```bash\n#!/bin/bash\nset -e\n\nDB_PATH=\"${XDG_DATA_HOME:-$HOME/.local/share}/gitlab-inbox/db.sqlite3\"\n\necho \"=== Gate D: Resumability Proof ===\"\n\n# 1. Test single-flight lock\necho \"Step 1: Test single-flight lock...\"\ngi ingest --type=merge_requests &\nFIRST_PID=$!\nsleep 1\n\n# Try second ingest - should fail with lock error\nif gi ingest --type=merge_requests 2>&1 | grep -q \"lock\\|already running\"; then\n  echo \"  PASS: Second ingest blocked by lock\"\nelse\n  echo \"  FAIL: Lock not working\"\nfi\nwait $FIRST_PID 2>/dev/null || true\n\n# 2. Test --force bypasses lock\necho \"Step 2: Test --force flag...\"\ngi ingest --type=merge_requests &\nFIRST_PID=$!\nsleep 1\nif gi ingest --type=merge_requests --force 2>&1; then\n  echo \"  PASS: --force bypassed lock\"\nelse\n  echo \"  Note: --force test inconclusive\"\nfi\nwait $FIRST_PID 2>/dev/null || true\n\n# 3. Check cursor state\necho \"Step 3: Check cursor state...\"\nsqlite3 \"$DB_PATH\" \"\n  SELECT resource_type, updated_at, gitlab_id\n  FROM sync_cursors \n  WHERE resource_type = 'merge_requests';\n\"\n\n# 4. Test crash recovery\necho \"Step 4: Test crash recovery...\"\n\n# Record current cursor\nCURSOR_BEFORE=$(sqlite3 \"$DB_PATH\" \"\n  SELECT updated_at FROM sync_cursors WHERE resource_type = 'merge_requests';\n\")\necho \"  Cursor before: $CURSOR_BEFORE\"\n\n# Force full sync and kill\necho \"  Starting full sync then killing...\"\ngi ingest --type=merge_requests --full &\nPID=$!\nsleep 5 && kill -9 $PID 2>/dev/null || true\nwait $PID 2>/dev/null || true\n\n# Check cursor was saved (should be non-null if any page completed)\nCURSOR_AFTER=$(sqlite3 \"$DB_PATH\" \"\n  SELECT updated_at FROM sync_cursors WHERE resource_type = 'merge_requests';\n\")\necho \"  Cursor after kill: $CURSOR_AFTER\"\n\n# Re-run and verify bounded redo\necho \"  Re-running (should resume from cursor)...\"\ntime gi ingest --type=merge_requests\n# Should be faster than first full sync\n\n# 5. Test --full reset\necho \"Step 5: Test --full resets watermarks...\"\n\n# Check watermarks before\nWATERMARKS_BEFORE=$(sqlite3 \"$DB_PATH\" \"\n  SELECT COUNT(*) FROM merge_requests \n  WHERE discussions_synced_for_updated_at IS NOT NULL;\n\")\necho \"  Watermarks set before --full: $WATERMARKS_BEFORE\"\n\n# Record cursor before\nCURSOR_BEFORE_FULL=$(sqlite3 \"$DB_PATH\" \"\n  SELECT updated_at, gitlab_id FROM sync_cursors WHERE resource_type = 'merge_requests';\n\")\necho \"  Cursor before --full: $CURSOR_BEFORE_FULL\"\n\n# Run --full\ngi ingest --type=merge_requests --full\n\n# Check cursor was reset then rebuilt\nCURSOR_AFTER_FULL=$(sqlite3 \"$DB_PATH\" \"\n  SELECT updated_at, gitlab_id FROM sync_cursors WHERE resource_type = 'merge_requests';\n\")\necho \"  Cursor after --full: $CURSOR_AFTER_FULL\"\n\n# Watermarks should be set again (sync completed)\nWATERMARKS_AFTER=$(sqlite3 \"$DB_PATH\" \"\n  SELECT COUNT(*) FROM merge_requests \n  WHERE discussions_synced_for_updated_at IS NOT NULL;\n\")\necho \"  Watermarks set after --full: $WATERMARKS_AFTER\"\n\necho \"\"\necho \"=== Gate D: PASSED ===\"\n```\n\n## Watermark Safety Test (Simulated Network Failure)\n```bash\n# This tests that watermark doesn't advance on partial failure\n# Requires ability to simulate network issues\n\n# 1. Get an MR that needs discussion sync\nMR_ID=$(sqlite3 \"$DB_PATH\" \"\n  SELECT id FROM merge_requests \n  WHERE discussions_synced_for_updated_at IS NULL \n     OR updated_at > discussions_synced_for_updated_at\n  LIMIT 1;\n\")\n\n# 2. Note current watermark\nWATERMARK_BEFORE=$(sqlite3 \"$DB_PATH\" \"\n  SELECT discussions_synced_for_updated_at FROM merge_requests WHERE id = $MR_ID;\n\")\necho \"Watermark before: $WATERMARK_BEFORE\"\n\n# 3. Simulate network failure (requires network manipulation)\n# Option A: Block GitLab API temporarily\n# Option B: Run in a container with network limits\n# Option C: Use the automated test instead:\ncargo test does_not_advance_discussion_watermark_on_partial_failure\n\n# 4. Verify watermark unchanged after failure\nWATERMARK_AFTER=$(sqlite3 \"$DB_PATH\" \"\n  SELECT discussions_synced_for_updated_at FROM merge_requests WHERE id = $MR_ID;\n\")\necho \"Watermark after failure: $WATERMARK_AFTER\"\n[ \"$WATERMARK_BEFORE\" = \"$WATERMARK_AFTER\" ] && echo \"PASS: Watermark preserved\"\n```\n\n## Test Commands (Quick Verification)\n```bash\n# Check cursor state:\nsqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 \"\n  SELECT * FROM sync_cursors WHERE resource_type = 'merge_requests';\n\"\n\n# Check watermark distribution:\nsqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 \"\n  SELECT \n    SUM(CASE WHEN discussions_synced_for_updated_at IS NULL THEN 1 ELSE 0 END) as needs_sync,\n    SUM(CASE WHEN discussions_synced_for_updated_at IS NOT NULL THEN 1 ELSE 0 END) as synced\n  FROM merge_requests;\n\"\n\n# Test --full resets (check before/after):\nsqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 \"SELECT COUNT(*) FROM merge_requests WHERE discussions_synced_for_updated_at IS NOT NULL;\"\ngi ingest --type=merge_requests --full\n# During full sync, watermarks should be NULL, then repopulated\n```\n\n## Critical Automated Tests\nThese tests MUST pass for Gate D:\n```bash\ncargo test does_not_advance_discussion_watermark_on_partial_failure\ncargo test full_sync_resets_discussion_watermarks\ncargo test cursor_saved_at_page_boundary\n```\n\n## Dependencies\nThis gate requires:\n- bd-mk3 (ingest command with --full and --force support)\n- bd-ser (MR ingestion with cursor mechanics)\n- bd-20h (MR discussion ingestion with watermark safety)\n- Gates A, B, C must pass first\n\n## Edge Cases\n- Very fast sync: May complete before kill signal reaches; retest with larger project\n- Lock file stale: If previous run crashed, lock file may exist; --force handles this\n- Clock skew: Cursor timestamps should use server time, not local time","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-26T22:06:02.124186Z","created_by":"tayloreernisse","updated_at":"2026-01-27T00:48:21.060596Z","closed_at":"2026-01-27T00:48:21.060555Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-10i","depends_on_id":"bd-mk3","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-11mg","title":"Add CLI flags: --as-of, --explain-score, --include-bots, --all-history","description":"## Background\nThe who command needs new CLI flags for reproducible scoring (--as-of), score transparency (--explain-score), bot inclusion (--include-bots), and full-history mode (--all-history). The default --since for expert mode changes from 6m to 24m. At this point query_expert() already accepts the new params (from bd-13q8).\n\n## Approach\nModify WhoArgs struct and run_who() (who.rs:276). The command uses clap derive macros.\n\n### New clap fields on WhoArgs:\n```rust\n#[arg(long, value_name = \"TIMESTAMP\")]\npub as_of: Option<String>,\n\n#[arg(long, conflicts_with = \"detail\")]\npub explain_score: bool,\n\n#[arg(long)]\npub include_bots: bool,\n\n#[arg(long, conflicts_with = \"since\")]\npub all_history: bool,\n```\n\n### run_who() changes (who.rs:276):\n1. Default --since: \"6m\" -> \"24m\" for expert mode\n2. **Path canonicalization**: Call normalize_query_path() on raw path input at top of run_who(), before build_path_query(). Store both original and normalized for robot JSON.\n3. Parse --as-of: RFC3339 or YYYY-MM-DD (append T23:59:59.999Z for end-of-day UTC) -> i64 millis. Default: now_ms()\n4. Parse --all-history: set since_ms = 0\n5. Thread as_of_ms, explain_score, include_bots through to query_expert()\n6. Update the production query_expert() callsite (line ~311) from the default values bd-13q8 set to the actual parsed flag values\n\n### Robot JSON resolved_input additions:\n- scoring_model_version: 2\n- path_input_original: raw user input\n- path_input_normalized: after normalize_query_path()\n- as_of_ms/as_of_iso\n- window_start_iso/window_end_iso/window_end_exclusive: true\n- since_mode: \"all\" | \"24m\" | user value\n- excluded_usernames_applied: true|false\n\n### Robot JSON per-expert (explain_score): score_raw + components object\n### Human output (explain_score): parenthetical after score: `42 (author:28.5 review:10.0 notes:3.5)`\n\n## TDD Loop\n\n### RED (write these 8 tests first):\n- test_explain_score_components_sum_to_total: components sum == score_raw within tolerance\n- test_as_of_produces_deterministic_results: two runs with same as_of -> identical\n- test_as_of_excludes_future_events: event after as_of excluded entirely\n- test_as_of_exclusive_upper_bound: event at exactly as_of_ms excluded (strict <)\n- test_since_relative_to_as_of_clock: since window from as_of, not wall clock\n- test_explain_and_detail_are_mutually_exclusive: clap parse error\n- test_excluded_usernames_filters_bots: renovate-bot filtered, jsmith present\n- test_include_bots_flag_disables_filtering: both appear with --include-bots\n\n### GREEN: Add clap args, parse logic, robot JSON fields, human output format.\n### VERIFY: cargo test -p lore -- test_explain_score test_as_of test_since_relative test_excluded test_include_bots\n\n## Acceptance Criteria\n- [ ] All 8 tests pass green\n- [ ] --as-of parses RFC3339 and YYYY-MM-DD (end-of-day UTC)\n- [ ] --explain-score conflicts with --detail (clap error at parse time)\n- [ ] --all-history conflicts with --since (clap error at parse time)\n- [ ] Default --since is 24m for expert mode\n- [ ] Robot JSON includes scoring_model_version: 2\n- [ ] Robot JSON includes path_input_original AND path_input_normalized\n- [ ] Robot JSON includes score_raw + components when --explain-score\n- [ ] Human output appends component parenthetical when --explain-score\n\n## Files\n- MODIFY: src/cli/commands/who.rs (WhoArgs struct, run_who at line 276, robot/human output rendering)\n\n## Edge Cases\n- YYYY-MM-DD parsing: chrono NaiveDate then to end-of-day UTC (T23:59:59.999Z)\n- as_of in the past with --since: since window = as_of_ms - duration, not now - duration\n- since_mode in robot JSON: \"all\" for --all-history, \"24m\" default, user value otherwise\n- Path normalization runs BEFORE path resolution","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-09T17:00:11.115322Z","created_by":"tayloreernisse","updated_at":"2026-02-12T20:43:04.413786Z","closed_at":"2026-02-12T20:43:04.413729Z","close_reason":"Implemented by time-decay swarm: 3 agents, 12 tasks, 621 tests passing, all quality gates green","compaction_level":0,"original_size":0,"labels":["cli","scoring"],"dependencies":[{"issue_id":"bd-11mg","depends_on_id":"bd-13q8","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-11mg","depends_on_id":"bd-18dn","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-12ae","title":"OBSERV: Add structured tracing fields to rate-limit/retry handling","description":"## Background\nRate limit and retry events are currently logged at WARN with minimal context (src/gitlab/client.rs:~157). This enriches them with structured fields so MetricsLayer can count them and -v mode shows actionable retry information.\n\n## Approach\n### src/gitlab/client.rs - request() method (line ~119-171)\n\nCurrent 429 handling (~line 155-158):\n```rust\nif response.status() == StatusCode::TOO_MANY_REQUESTS && attempt < Self::MAX_RETRIES {\n    let retry_after = Self::parse_retry_after(&response);\n    tracing::warn!(retry_after_secs = retry_after, attempt, path, \"Rate limited by GitLab, retrying\");\n    sleep(Duration::from_secs(retry_after)).await;\n    continue;\n}\n```\n\nReplace with INFO-level structured log:\n```rust\nif response.status() == StatusCode::TOO_MANY_REQUESTS && attempt < Self::MAX_RETRIES {\n    let retry_after = Self::parse_retry_after(&response);\n    tracing::info!(\n        path = %path,\n        attempt = attempt,\n        retry_after_secs = retry_after,\n        status_code = 429u16,\n        \"Rate limited, retrying\"\n    );\n    sleep(Duration::from_secs(retry_after)).await;\n    continue;\n}\n```\n\nFor transient errors (network errors, 5xx responses), add similar structured logging:\n```rust\ntracing::info!(\n    path = %path,\n    attempt = attempt,\n    error = %e,\n    \"Retrying after transient error\"\n);\n```\n\nKey changes:\n- Level: WARN -> INFO (visible in -v mode, not alarming in default mode)\n- Added: status_code field for 429\n- Added: structured path, attempt fields for all retry events\n- These structured fields enable MetricsLayer (bd-3vqk) to count rate_limit_hits and retries\n\n## Acceptance Criteria\n- [ ] 429 responses log at INFO with fields: path, attempt, retry_after_secs, status_code=429\n- [ ] Transient error retries log at INFO with fields: path, attempt, error\n- [ ] lore -v sync shows retry activity on stderr (INFO is visible in -v mode)\n- [ ] Default mode (no -v) does NOT show retry lines on stderr (INFO filtered out)\n- [ ] File layer captures all retry events (always at DEBUG+)\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/gitlab/client.rs (modify request() method, lines ~119-171)\n\n## TDD Loop\nRED:\n  - test_rate_limit_log_fields: mock 429 response, capture log output, parse JSON, assert fields\n  - test_retry_log_fields: mock network error + retry, assert structured fields\nGREEN: Change log level and add structured fields\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- parse_retry_after returns 0 or very large values: the existing logic handles this\n- All retries exhausted: the final attempt returns the error normally. No special logging needed (the error propagates).\n- path may contain sensitive data (project IDs): project IDs are not sensitive in this context","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T15:55:02.448070Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:21:42.304259Z","closed_at":"2026-02-04T17:21:42.304213Z","close_reason":"Changed 429 rate-limit logging from WARN to INFO with structured fields: path, attempt, retry_after_secs, status_code=429 in both request() and request_with_headers()","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-12ae","depends_on_id":"bd-3pk","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-12um","title":"Phase 3f-Step2: Cx threading — command layer + embedding pipeline","description":"## What\nWiden Cx threading to the remaining async command handlers and embedding pipeline.\n\n## Why\nLower-risk step after Step 1 validates the pattern. These modules don't have the same fan-out patterns as the orchestrator, so they're simpler to migrate.\n\n## Rearchitecture Context (2026-03-06)\n- cli/commands/embed.rs remains a single file (not split)\n- embedding/pipeline.rs remains unchanged\n- Command dispatch handlers are in src/app/handlers.rs (include\\!'d into main.rs scope)\n\n## Functions That Need cx: &Cx Added\n\n| Module | Functions |\n|--------|-----------|\n| cli/commands/embed.rs | run_embed() |\n| embedding/pipeline.rs | embed_documents(), embed_batch_group() |\n\n## Notes\n- embed_batch_group likely has its own join_all for concurrent embedding requests — same region-wrapping pattern as orchestrator\n- Embedding pipeline has configurable concurrency (config.embedding.concurrency) — verify region respects this\n- If Step 1 surfaces problems, this step hasn't been started yet (risk reduction)\n\n## Files Changed\n- src/cli/commands/embed.rs (~5 LOC)\n- src/embedding/pipeline.rs (~10 LOC)\n\n## Testing\n- cargo check --all-targets\n- cargo test\n- Manual: lore embed with Ctrl+C — verify graceful shutdown\n\n## Depends On\n- Phase 3f-Step1 (orchestration path must work first)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-06T18:41:16.721761Z","created_by":"tayloreernisse","updated_at":"2026-03-06T21:11:12.650925Z","closed_at":"2026-03-06T21:11:12.650877Z","close_reason":"asupersync migration complete — 5 commits pushed to asupersync-migration branch","compaction_level":0,"original_size":0,"labels":["asupersync","phase-3"],"dependencies":[{"issue_id":"bd-12um","depends_on_id":"bd-1lj5","type":"parent-child","created_at":"2026-03-06T18:41:16.723082Z","created_by":"tayloreernisse"},{"issue_id":"bd-12um","depends_on_id":"bd-zgke","type":"blocks","created_at":"2026-03-06T18:42:54.658467Z","created_by":"tayloreernisse"}]}
{"id":"bd-13b","title":"[CP0] CLI entry point with Commander.js","description":"## Background\n\nCommander.js provides the CLI framework. The main entry point sets up the program with all subcommands. Uses ESM with proper shebang for npx/global installation.\n\nReference: docs/prd/checkpoint-0.md section \"CLI Commands\"\n\n## Approach\n\n**src/cli/index.ts:**\n```typescript\n#!/usr/bin/env node\n\nimport { Command } from 'commander';\nimport { version } from '../../package.json' with { type: 'json' };\nimport { initCommand } from './commands/init';\nimport { authTestCommand } from './commands/auth-test';\nimport { doctorCommand } from './commands/doctor';\nimport { versionCommand } from './commands/version';\nimport { backupCommand } from './commands/backup';\nimport { resetCommand } from './commands/reset';\nimport { syncStatusCommand } from './commands/sync-status';\n\nconst program = new Command();\n\nprogram\n  .name('gi')\n  .description('GitLab Inbox - Unified notification management')\n  .version(version);\n\n// Global --config flag available to all commands\nprogram.option('-c, --config <path>', 'Path to config file');\n\n// Register subcommands\nprogram.addCommand(initCommand);\nprogram.addCommand(authTestCommand);\nprogram.addCommand(doctorCommand);\nprogram.addCommand(versionCommand);\nprogram.addCommand(backupCommand);\nprogram.addCommand(resetCommand);\nprogram.addCommand(syncStatusCommand);\n\nprogram.parse();\n```\n\nEach command file exports a Command instance:\n```typescript\n// src/cli/commands/version.ts\nimport { Command } from 'commander';\n\nexport const versionCommand = new Command('version')\n  .description('Show version information')\n  .action(() => {\n    console.log(`gi version ${version}`);\n  });\n```\n\n## Acceptance Criteria\n\n- [ ] `gi --help` shows all commands and global options\n- [ ] `gi --version` shows version from package.json\n- [ ] `gi <command> --help` shows command-specific help\n- [ ] `gi --config ./path` passes config path to commands\n- [ ] Unknown command shows error and suggests --help\n- [ ] Exit code 0 on success, non-zero on error\n- [ ] Shebang line works for npx execution\n\n## Files\n\nCREATE:\n- src/cli/index.ts (main entry point)\n- src/cli/commands/version.ts (simple command as template)\n\nMODIFY (later beads):\n- package.json (add \"bin\" field pointing to dist/cli/index.js)\n\n## TDD Loop\n\nN/A for CLI entry point - verify with manual testing:\n\n```bash\nnpm run build\nnode dist/cli/index.js --help\nnode dist/cli/index.js version\nnode dist/cli/index.js unknown-command  # should error\n```\n\n## Edge Cases\n\n- package.json import requires Node 20+ with { type: 'json' } assertion\n- Alternative: read version from package.json with readFileSync\n- Command registration order affects help display - alphabetical preferred\n- Global options must be defined before subcommands","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-24T16:09:50.499023Z","created_by":"tayloreernisse","updated_at":"2026-01-25T03:10:49.224627Z","closed_at":"2026-01-25T03:10:49.224499Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-13b","depends_on_id":"bd-gg1","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-13lp","title":"Epic: CLI Intelligence & Market Position (CLI-IMP)","description":"## Strategic Context\n\nAnalysis of glab (GitLab CLI) vs lore reveals a clean architectural split: lore = ALL reads (issues, MRs, search, who, timeline, intelligence); glab = ALL writes (create, update, approve, merge, CI/CD).\n\nLore is NOT duplicating glab. The overlap is minimal (both list issues/MRs), but lore output is curated, flatter, and richer (closing MRs, work-item status, discussions pre-joined). Agents reach for glab by default because they have been trained on it — a discovery problem, not a capability problem.\n\nThree layers: (1) Foundation — make lore the definitive read path; (2) Intelligence — ship half-built features (hybrid search, timeline, per-note search); (3) Alien Artifact — novel intelligence (explain, related, brief, drift).\n\n## Progress (as of 2026-02-12)\n\n### Shipped\n- Timeline CLI (bd-2wpf): CLOSED. 5-stage pipeline with human and robot renderers working end-to-end.\n- `who` command: Expert, Workload, Reviews, Active, Overlap modes all functional.\n- Search infrastructure: hybrid.rs, vector.rs, rrf.rs all implemented and tested (not yet wired to CLI).\n\n### In Progress\n- Foundation: bd-kvij (skill rewrite), bd-91j1 (robot-docs), bd-2g50 (data gaps)\n- Intelligence: bd-1ksf (hybrid search wiring), bd-2l3s (per-note search)\n- Alien Artifact: bd-1n5q (brief), bd-8con (related), bd-9lbr (explain), bd-1cjx (drift)\n\n## Success Criteria\n- [x] Timeline CLI shipped with human and robot renderers (bd-2wpf CLOSED)\n- [ ] Zero agent skill files reference glab for read operations\n- [ ] robot-docs comprehensive enough for zero-training agent bootstrap\n- [ ] Hybrid search (FTS + vector + RRF) wired to CLI and default\n- [ ] Per-note search operational at note granularity\n- [ ] At least one Tier 3 alien artifact feature prototyped (brief, related, explain, or drift)\n\n## Architecture Notes\n- main.rs is 2579 lines with all subcommand handlers\n- CLI commands in src/cli/commands/ (16 modules: auth_test, count, doctor, embed, generate_docs, init, ingest, list, search, show, stats, sync, sync_status, timeline, who, plus mod.rs)\n- Database: 21 migrations wired (001-021), LATEST_SCHEMA_VERSION = 21\n- Raw payloads for issues store 15 fields: assignees, author, closed_at, created_at, description, due_date, id, iid, labels, milestone, project_id, state, title, updated_at, web_url\n- Missing from raw payloads: closed_by, confidential, upvotes, downvotes, weight, issue_type, time_stats, health_status (ingestion pipeline doesn't capture these)\n- robot-docs current output keys: name, version, description, activation, commands, aliases, exit_codes, clap_error_codes, error_format, workflows","status":"open","priority":1,"issue_type":"epic","created_at":"2026-02-12T15:44:23.993267Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:08:36.417919Z","compaction_level":0,"original_size":0,"labels":["cli-imp","epic"]}
{"id":"bd-13pt","title":"Display closing MRs in lore issues <id> output","description":"## Background\nThe `entity_references` table stores MR->Issue 'closes' relationships (from the closes_issues API), but this data is never displayed when viewing an issue. This is the 'Development' section in GitLab UI showing which MRs will close an issue when merged.\n\n**System fit**: Data already flows through `fetch_mr_closes_issues()` -> `store_closes_issues_refs()` -> `entity_references` table. We just need to query and display it.\n\n## Approach\n\nAll changes in `src/cli/commands/show.rs`:\n\n### 1. Add ClosingMrRef struct (after DiffNotePosition ~line 57)\n```rust\n#[derive(Debug, Clone, Serialize)]\npub struct ClosingMrRef {\n    pub iid: i64,\n    pub title: String,\n    pub state: String,\n    pub web_url: Option<String>,\n}\n```\n\n### 2. Update IssueDetail struct (line ~59)\n```rust\npub struct IssueDetail {\n    // ... existing fields ...\n    pub closing_merge_requests: Vec<ClosingMrRef>,  // NEW - add after discussions\n}\n```\n\n### 3. Add ClosingMrRefJson struct (after NoteDetailJson ~line 797)\n```rust\n#[derive(Serialize)]\npub struct ClosingMrRefJson {\n    pub iid: i64,\n    pub title: String,\n    pub state: String,\n    pub web_url: Option<String>,\n}\n```\n\n### 4. Update IssueDetailJson struct (line ~770)\n```rust\npub struct IssueDetailJson {\n    // ... existing fields ...\n    pub closing_merge_requests: Vec<ClosingMrRefJson>,  // NEW\n}\n```\n\n### 5. Add get_closing_mrs() function (after get_issue_discussions ~line 245)\n```rust\nfn get_closing_mrs(conn: &Connection, issue_id: i64) -> Result<Vec<ClosingMrRef>> {\n    let mut stmt = conn.prepare(\n        \"SELECT mr.iid, mr.title, mr.state, mr.web_url\n         FROM entity_references er\n         JOIN merge_requests mr ON mr.id = er.source_entity_id\n         WHERE er.target_entity_type = 'issue'\n           AND er.target_entity_id = ?\n           AND er.source_entity_type = 'merge_request'\n           AND er.reference_type = 'closes'\n         ORDER BY mr.iid\"\n    )?;\n    \n    let mrs = stmt\n        .query_map([issue_id], |row| {\n            Ok(ClosingMrRef {\n                iid: row.get(0)?,\n                title: row.get(1)?,\n                state: row.get(2)?,\n                web_url: row.get(3)?,\n            })\n        })?\n        .collect::<std::result::Result<Vec<_>, _>>()?;\n    \n    Ok(mrs)\n}\n```\n\n### 6. Update run_show_issue() (line ~89)\n```rust\nlet closing_mrs = get_closing_mrs(&conn, issue.id)?;\n// In return struct:\nclosing_merge_requests: closing_mrs,\n```\n\n### 7. Update print_show_issue() (after Labels section ~line 556)\n```rust\nif !issue.closing_merge_requests.is_empty() {\n    println!(\"Development:\");\n    for mr in &issue.closing_merge_requests {\n        let state_indicator = match mr.state.as_str() {\n            \"merged\" => style(\"merged\").green(),\n            \"opened\" => style(\"opened\").cyan(),\n            \"closed\" => style(\"closed\").red(),\n            _ => style(&mr.state).dim(),\n        };\n        println!(\"  !{} {} ({})\", mr.iid, mr.title, state_indicator);\n    }\n}\n```\n\n### 8. Update From<&IssueDetail> for IssueDetailJson (line ~799)\n```rust\nclosing_merge_requests: issue.closing_merge_requests.iter().map(|mr| ClosingMrRefJson {\n    iid: mr.iid,\n    title: mr.title.clone(),\n    state: mr.state.clone(),\n    web_url: mr.web_url.clone(),\n}).collect(),\n```\n\n## Acceptance Criteria\n- [ ] `cargo test test_get_closing_mrs` passes (4 tests)\n- [ ] `lore issues <id>` shows Development section when closing MRs exist\n- [ ] Development section shows MR iid, title, and state\n- [ ] State is color-coded (green=merged, cyan=opened, red=closed)\n- [ ] `lore -J issues <id>` includes closing_merge_requests array\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n- `src/cli/commands/show.rs` - ALL changes\n\n## TDD Loop\n\n**RED** - Add tests to `src/cli/commands/show.rs` `#[cfg(test)] mod tests`:\n\n```rust\nfn seed_issue_with_closing_mr(conn: &Connection) -> (i64, i64) {\n    conn.execute(\n        \"INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at)\n         VALUES (1, 100, 'group/repo', 'https://gitlab.example.com', 1000, 2000)\", []\n    ).unwrap();\n    conn.execute(\n        \"INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, author_username,\n         created_at, updated_at, last_seen_at) VALUES (1, 200, 10, 1, 'Bug fix', 'opened', 'dev', 1000, 2000, 2000)\", []\n    ).unwrap();\n    conn.execute(\n        \"INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, author_username,\n         source_branch, target_branch, created_at, updated_at, last_seen_at)\n         VALUES (1, 300, 5, 1, 'Fix the bug', 'merged', 'dev', 'fix', 'main', 1000, 2000, 2000)\", []\n    ).unwrap();\n    conn.execute(\n        \"INSERT INTO entity_references (project_id, source_entity_type, source_entity_id,\n         target_entity_type, target_entity_id, reference_type, source_method, created_at)\n         VALUES (1, 'merge_request', 1, 'issue', 1, 'closes', 'api', 3000)\", []\n    ).unwrap();\n    (1, 1) // (issue_id, mr_id)\n}\n\n#[test]\nfn test_get_closing_mrs_empty() {\n    let conn = setup_test_db();\n    // seed project + issue with no closing MRs\n    conn.execute(\"INSERT INTO projects ...\", []).unwrap();\n    conn.execute(\"INSERT INTO issues ...\", []).unwrap();\n    let result = get_closing_mrs(&conn, 1).unwrap();\n    assert!(result.is_empty());\n}\n\n#[test]\nfn test_get_closing_mrs_single() {\n    let conn = setup_test_db();\n    seed_issue_with_closing_mr(&conn);\n    let result = get_closing_mrs(&conn, 1).unwrap();\n    assert_eq!(result.len(), 1);\n    assert_eq!(result[0].iid, 5);\n    assert_eq!(result[0].title, \"Fix the bug\");\n    assert_eq!(result[0].state, \"merged\");\n}\n\n#[test]\nfn test_get_closing_mrs_ignores_mentioned() {\n    let conn = setup_test_db();\n    seed_issue_with_closing_mr(&conn);\n    // Add a 'mentioned' reference that should be ignored\n    conn.execute(\n        \"INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, author_username,\n         source_branch, target_branch, created_at, updated_at, last_seen_at)\n         VALUES (2, 301, 6, 1, 'Other MR', 'opened', 'dev', 'other', 'main', 1000, 2000, 2000)\", []\n    ).unwrap();\n    conn.execute(\n        \"INSERT INTO entity_references (project_id, source_entity_type, source_entity_id,\n         target_entity_type, target_entity_id, reference_type, source_method, created_at)\n         VALUES (1, 'merge_request', 2, 'issue', 1, 'mentioned', 'note_parse', 3000)\", []\n    ).unwrap();\n    let result = get_closing_mrs(&conn, 1).unwrap();\n    assert_eq!(result.len(), 1); // Only the 'closes' ref\n}\n\n#[test]\nfn test_get_closing_mrs_multiple_sorted() {\n    let conn = setup_test_db();\n    seed_issue_with_closing_mr(&conn);\n    // Add second closing MR with higher iid\n    conn.execute(\n        \"INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, author_username,\n         source_branch, target_branch, created_at, updated_at, last_seen_at)\n         VALUES (2, 301, 8, 1, 'Another fix', 'opened', 'dev', 'fix2', 'main', 1000, 2000, 2000)\", []\n    ).unwrap();\n    conn.execute(\n        \"INSERT INTO entity_references (project_id, source_entity_type, source_entity_id,\n         target_entity_type, target_entity_id, reference_type, source_method, created_at)\n         VALUES (1, 'merge_request', 2, 'issue', 1, 'closes', 'api', 3000)\", []\n    ).unwrap();\n    let result = get_closing_mrs(&conn, 1).unwrap();\n    assert_eq!(result.len(), 2);\n    assert_eq!(result[0].iid, 5); // Lower iid first\n    assert_eq!(result[1].iid, 8);\n}\n```\n\n**GREEN** - Implement get_closing_mrs() and struct updates\n\n**VERIFY**: `cargo test test_get_closing_mrs && cargo clippy --all-targets -- -D warnings`\n\n## Edge Cases\n- Empty closing MRs -> don't print Development section\n- MR in different states -> color-coded appropriately  \n- Cross-project closes (target_entity_id IS NULL) -> not displayed (unresolved refs)\n- Multiple MRs closing same issue -> all shown, ordered by iid","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-02-05T15:15:37.598249Z","created_by":"tayloreernisse","updated_at":"2026-02-05T15:26:09.522557Z","closed_at":"2026-02-05T15:26:09.522506Z","close_reason":"Implemented: closing MRs (Development section) now display in lore issues <id>. All 4 new tests pass.","compaction_level":0,"original_size":0,"labels":["ISSUE"]}
{"id":"bd-13q8","title":"Implement Rust-side decay aggregation with reviewer split","description":"## Background\nThe current accumulation (who.rs ~line 780-810) maps SQL rows directly to Expert structs with integer scores computed in SQL. The new model receives per-signal rows from build_expert_sql() (bd-1hoq) and needs Rust-side decay computation, reviewer split, closed MR multiplier, and deterministic f64 ordering. This bead wires the new SQL into query_expert() and replaces the accumulation logic.\n\n## Approach\nModify query_expert() (who.rs:641) to:\n1. Call build_expert_sql() instead of the inline SQL\n2. Bind 6 params: path, since_ms, project_id, as_of_ms, closed_mr_multiplier, reviewer_min_note_chars\n3. Execute and iterate rows: (username, signal, mr_id, qty, ts, state_mult)\n4. Accumulate into per-user UserAccum structs\n5. Compute decayed scores with deterministic ordering\n6. Build Expert structs from accumulators\n\n### Updated query_expert() signature:\n```rust\n#[allow(clippy::too_many_arguments)]\nfn query_expert(\n    conn: &Connection,\n    path: &str,\n    project_id: Option<i64>,\n    since_ms: i64,\n    as_of_ms: i64,\n    limit: usize,\n    scoring: &ScoringConfig,\n    detail: bool,\n    explain_score: bool,\n    include_bots: bool,\n) -> Result<ExpertResult>\n```\n\n### CRITICAL: Existing callsite updates\nChanging the signature from 7 to 10 params breaks ALL existing callers. There are 17 callsites that must be updated:\n\n**Production (1):**\n- run_who() at line ~311: Updated by bd-11mg (CLI flags bead), not this bead. To keep code compiling between bd-13q8 and bd-11mg, update this callsite with default values: `query_expert(conn, path, project_id, since_ms, now_ms(), limit, scoring, detail, false, false)`\n\n**Tests (16):**\nUpdate ALL test callsites to the new 10-param signature. The new params use defaults that preserve current behavior:\n- `as_of_ms` = `now_ms() + 1000` (slightly in future, ensures all test data is within window)\n- `explain_score` = `false`\n- `include_bots` = `false`\n\nLines to update (current line numbers):\n2879, 3127, 3208, 3214, 3226, 3252, 3291, 3325, 3345, 3398, 3563, 3572, 3588, 3625, 3651, 3658\n\nPattern: replace `query_expert(&conn, path, None, 0, limit, &scoring, detail)` with `query_expert(&conn, path, None, 0, now_ms() + 1000, limit, &scoring, detail, false, false)`\n\n### Per-user accumulator:\n```rust\nstruct UserAccum {\n    author_mrs: HashMap<i64, (i64, f64)>,            // mr_id -> (max_ts, state_mult)\n    reviewer_participated: HashMap<i64, (i64, f64)>,   // mr_id -> (max_ts, state_mult)\n    reviewer_assigned: HashMap<i64, (i64, f64)>,       // mr_id -> (max_ts, state_mult)\n    notes_per_mr: HashMap<i64, (u32, i64, f64)>,       // mr_id -> (count, max_ts, state_mult)\n    last_seen: i64,\n    components: Option<[f64; 4]>,                       // when explain_score: [author, participated, assigned, notes]\n}\n```\n\n**Key**: state_mult is f64 from SQL (computed in mr_activity CTE), NOT computed from mr_state string in Rust.\n\n### Signal routing:\n- `diffnote_author` / `file_author` -> author_mrs (max ts + state_mult per mr_id)\n- `diffnote_reviewer` / `file_reviewer_participated` -> reviewer_participated\n- `file_reviewer_assigned` -> reviewer_assigned (skip if mr_id already in reviewer_participated)\n- `note_group` -> notes_per_mr (qty from SQL row, max ts + state_mult)\n\n### Deterministic score computation:\nSort each HashMap entries into a Vec sorted by mr_id ASC, then sum:\n```\nraw_score =\n    sum(author_weight * state_mult * decay(as_of_ms - ts, author_hl) for (mr, ts, sm) in author_mrs sorted)\n  + sum(reviewer_weight * state_mult * decay(as_of_ms - ts, reviewer_hl) for ... sorted)\n  + sum(reviewer_assignment_weight * state_mult * decay(as_of_ms - ts, reviewer_assignment_hl) for ... sorted)\n  + sum(note_bonus * state_mult * log2(1 + count) * decay(as_of_ms - ts, note_hl) for ... sorted)\n```\n\n### Expert struct additions (who.rs:141-154):\n```rust\npub score_raw: Option<f64>,            // unrounded f64, only when explain_score\npub components: Option<ScoreComponents>, // only when explain_score\n```\n\nAdd new struct:\n```rust\npub struct ScoreComponents {\n    pub author: f64,\n    pub reviewer_participated: f64,\n    pub reviewer_assigned: f64,\n    pub notes: f64,\n}\n```\n\n### Bot filtering:\nPost-query: if !include_bots, filter out usernames in scoring.excluded_usernames (case-insensitive via .to_lowercase() comparison).\n\n## TDD Loop\n\n### RED (write these 13 tests first):\n\n**Core decay integration:**\n- test_expert_scores_decay_with_time: recent (10d) vs old (360d), recent scores ~24, old ~6\n- test_expert_reviewer_decays_faster_than_author: same MR at 90d, author > reviewer\n- test_reviewer_participated_vs_assigned_only: participated ~10*decay vs assigned ~3*decay\n- test_note_diminishing_returns_per_mr: 20-note/1-note ratio ~4.4x not 20x\n- test_file_change_timestamp_uses_merged_at: merged MR uses merged_at not updated_at\n- test_open_mr_uses_updated_at: opened MR uses updated_at\n- test_old_path_match_credits_expertise: query old path -> author appears\n- test_closed_mr_multiplier: closed MR at 0.5x merged (state_mult from SQL)\n- test_trivial_note_does_not_count_as_participation: 4-char LGTM -> assigned-only\n- test_null_timestamp_fallback_to_created_at: merged with NULL merged_at\n- test_row_order_independence: different insert order -> identical rankings\n- test_reviewer_split_is_exhaustive: every reviewer in exactly one bucket\n- test_deterministic_accumulation_order: 100 runs, bit-identical f64\n\nAll tests use insert_mr_at/insert_diffnote_at from bd-2yu5 for timestamp control, and call the NEW query_expert() with 10 params.\n\n### GREEN: Wire build_expert_sql into query_expert, implement UserAccum + scoring loop, update all 17 existing callsites.\n### VERIFY: cargo test -p lore -- test_expert_scores test_reviewer_participated test_note_diminishing\n\n## Acceptance Criteria\n- [ ] All 13 new tests pass green\n- [ ] All 16 existing test callsites updated to 10-param signature\n- [ ] Production caller (run_who at ~line 311) updated with default values\n- [ ] Existing who tests pass unchanged (decay ~1.0 for now_ms() data)\n- [ ] state_mult comes from SQL f64 column, NOT from string matching on mr_state\n- [ ] reviewer_assigned excludes mr_ids already in reviewer_participated\n- [ ] Deterministic: 100 runs produce bit-identical f64 (sorted by mr_id)\n- [ ] Bot filtering applied when include_bots=false\n- [ ] cargo check --all-targets passes (no broken callers)\n\n## Files\n- MODIFY: src/cli/commands/who.rs (query_expert at line 641, Expert struct at line 141, all test callsites)\n\n## Edge Cases\n- log2(1.0 + 0) = 0.0 — zero notes contribute nothing\n- f64 NaN: half_life_decay guards hl=0\n- HashMap to sorted Vec for deterministic summing\n- as_of_ms: use passed value, not now_ms()\n- state_mult is always 1.0 or closed_mr_multiplier (from SQL) — no other values possible\n- Production caller uses now_ms() as as_of_ms default until bd-11mg adds --as-of flag","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-09T17:00:01.764110Z","created_by":"tayloreernisse","updated_at":"2026-02-12T20:43:04.412694Z","closed_at":"2026-02-12T20:43:04.412646Z","close_reason":"Implemented by time-decay swarm: 3 agents, 12 tasks, 621 tests passing, all quality gates green","compaction_level":0,"original_size":0,"labels":["scoring"],"dependencies":[{"issue_id":"bd-13q8","depends_on_id":"bd-1hoq","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-13q8","depends_on_id":"bd-1soz","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-13q8","depends_on_id":"bd-2yu5","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-140","title":"[CP1] Database migration 002_issues.sql","description":"Create migration file with tables for issues, labels, issue_labels, discussions, and notes.\n\nTables to create:\n- issues: gitlab_id, project_id, iid, title, description, state, author_username, timestamps, web_url, raw_payload_id\n- labels: gitlab_id, project_id, name, color, description (unique on project_id+name)\n- issue_labels: junction table\n- discussions: gitlab_discussion_id, project_id, issue_id, noteable_type, individual_note, timestamps, resolvable/resolved\n- notes: gitlab_id, discussion_id, project_id, type, is_system, author_username, body, timestamps, position, resolution fields, DiffNote position fields\n\nInclude appropriate indexes:\n- idx_issues_project_updated, idx_issues_author, uq_issues_project_iid\n- uq_labels_project_name, idx_labels_name\n- idx_issue_labels_label\n- uq_discussions_project_discussion_id, idx_discussions_issue/mr/last_note\n- idx_notes_discussion/author/system\n\nFiles: migrations/002_issues.sql\nDone when: Migration applies cleanly on top of 001_initial.sql","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:18:53.954039Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.154936Z","closed_at":"2026-01-25T15:21:35.154936Z","deleted_at":"2026-01-25T15:21:35.154934Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-14hv","title":"Implement soak test + concurrent pagination/write race tests","description":"## Background\nThe 30-minute soak test verifies no panic, deadlock, or memory leak under sustained use. Concurrent pagination/write race tests prove browse snapshot fences prevent duplicate or skipped rows during sync writes.\n\n## Approach\nSoak test:\n- Automated script that drives the TUI for 30 minutes: random navigation, filter changes, sync starts/cancels, search queries\n- Monitors: no panic (exit code), no deadlock (watchdog timer), memory growth < 5% (RSS sampling)\n- Uses FakeClock with accelerated time for time-dependent features\n\nConcurrent pagination/write race:\n- Thread A: paginating through Issue List (fetching pages via keyset cursor)\n- Thread B: writing new issues to DB (simulating sync)\n- Assert: no duplicate rows across pages, no skipped rows within a browse snapshot fence\n- BrowseSnapshot token ensures stable ordering until explicit refresh\n\n## Acceptance Criteria\n- [ ] 30-min soak: no panic\n- [ ] 30-min soak: no deadlock (watchdog detects)\n- [ ] 30-min soak: memory growth < 5%\n- [ ] Concurrent pagination: no duplicate rows across pages\n- [ ] Concurrent pagination: no skipped rows within snapshot fence\n- [ ] BrowseSnapshot invalidated on manual refresh, not on background writes\n\n## Files\n- CREATE: crates/lore-tui/tests/soak_test.rs\n- CREATE: crates/lore-tui/tests/pagination_race_test.rs\n\n## TDD Anchor\nRED: Write test_pagination_no_duplicates that runs paginator and writer concurrently for 1000 iterations, collects all returned row IDs, asserts no duplicates.\nGREEN: Implement browse snapshot fence in keyset pagination.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_pagination_no_duplicates\n\n## Edge Cases\n- Soak test needs headless mode (no real terminal) — use ftui test harness\n- Memory sampling on macOS: use mach_task_info or /proc equivalent\n- Writer must use WAL mode to not block readers\n- Snapshot fence: deferred read transaction holds snapshot until page sequence completes\n\n## Dependency Context\nUses DbManager from \"Implement DbManager\" task.\nUses BrowseSnapshot from \"Implement NavigationStack\" task.\nUses keyset pagination from \"Implement Issue List\" task.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T17:05:28.130516Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:21.661021Z","deleted_at":"2026-03-11T18:34:21.661014Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-14q","title":"Epic: Gate 4 - File Decision History (lore file-history)","description":"## Background\n\nGate 4 implements `lore file-history` — answers \"Which MRs touched this file, and why?\" by linking files to MRs via a new mr_file_changes table and resolving rename chains.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Gate 4 (Sections 4.1-4.7).\n\n## Prerequisites\n\n- Gates 1-2 COMPLETE: entity_references populated, resource events fetched\n- Migration 015 exists on disk (commit SHAs + closes watermark) — registered by bd-1oo\n- pending_dependent_fetches has job_type='mr_diffs' in CHECK constraint (migration 011)\n\n## Architecture\n\n- **New table:** mr_file_changes (migration 016) stores file paths per MR\n- **New config:** fetchMrFileChanges (default true) gates the API calls\n- **API source:** GET /projects/:id/merge_requests/:iid/diffs — extract paths only, discard diff content\n- **Rename resolution:** BFS both directions on mr_file_changes WHERE change_type='renamed', bounded at 10 hops\n- **Query:** Join mr_file_changes -> merge_requests, optionally enrich with entity_references and discussions\n\n## Children (Execution Order)\n\n1. **bd-1oo** — Register migration 015 + create migration 016 (mr_file_changes table)\n2. **bd-jec** — Add fetchMrFileChanges config flag\n3. **bd-2yo** — Fetch MR diffs API and populate mr_file_changes\n4. **bd-1yx** — Implement rename chain resolution (BFS algorithm)\n5. **bd-z94** — Implement lore file-history CLI command (human + robot output)\n\n## Gate Completion Criteria\n\n- [ ] mr_file_changes table populated from GitLab diffs API\n- [ ] merge_commit_sha and squash_commit_sha captured in merge_requests (already done in code, needs migration 015 registered)\n- [ ] `lore file-history <path>` returns MRs ordered by merge/creation date\n- [ ] Output includes: MR title, state, author, change type, discussion count\n- [ ] --discussions shows inline discussion snippets from DiffNotes on the file\n- [ ] Rename chains resolved with bounded hop count (default 10) and cycle detection\n- [ ] --no-follow-renames disables chain resolution\n- [ ] Robot mode JSON includes rename_chain when renames detected\n- [ ] -p required when path in multiple projects (exit 18 Ambiguous)\n","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-02-02T21:31:01.094024Z","created_by":"tayloreernisse","updated_at":"2026-02-18T21:44:16.542509Z","closed_at":"2026-02-18T21:44:16.542457Z","close_reason":"Gate 4 complete. file-history command shipped, all impl children closed.","compaction_level":0,"original_size":0,"labels":["epic","gate-4","phase-b"],"dependencies":[{"issue_id":"bd-14q","depends_on_id":"bd-1se","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-14q","depends_on_id":"bd-2zl","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-157","title":"[CP1] Issue transformer with label extraction","description":"Transform GitLab issue payloads to normalized database schema.\n\n## Module\nsrc/gitlab/transformers/issue.rs\n\n## Structs\n\n### NormalizedIssue\n- gitlab_id: i64\n- project_id: i64 (local DB project ID)\n- iid: i64\n- title: String\n- description: Option<String>\n- state: String\n- author_username: String\n- created_at, updated_at, last_seen_at: i64 (ms epoch)\n- web_url: String\n\n### NormalizedLabel (CP1: name-only)\n- project_id: i64\n- name: String\n\n## Functions\n\n### transform_issue(gitlab_issue: &GitLabIssue, local_project_id: i64) -> NormalizedIssue\n- Convert ISO timestamps to ms epoch using iso_to_ms()\n- Set last_seen_at to now_ms()\n- Clone string fields\n\n### extract_labels(gitlab_issue: &GitLabIssue, local_project_id: i64) -> Vec<NormalizedLabel>\n- Map labels vec to NormalizedLabel structs\n\nFiles: \n- src/gitlab/transformers/mod.rs\n- src/gitlab/transformers/issue.rs\nTests: tests/issue_transformer_tests.rs\nDone when: Unit tests pass for payload transformation and label extraction","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:42:47.719562Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.736142Z","closed_at":"2026-01-25T17:02:01.736142Z","deleted_at":"2026-01-25T17:02:01.736129Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-159p","title":"Add get_issue_by_iid and get_mr_by_iid to GitLabClient with wiremock tests","description":"## Background\nSurgical sync needs to fetch a single issue or MR by its project-scoped IID from GitLab REST API during the preflight phase. The existing `GitLabClient` has `paginate_issues` and `paginate_merge_requests` for bulk streaming, but no single-entity fetch by IID. The GitLab v4 API provides `/api/v4/projects/:id/issues/:iid` and `/api/v4/projects/:id/merge_requests/:iid` endpoints that return exactly one entity or 404.\n\nThese methods are used by the surgical preflight (bd-3sez) to validate that requested IIDs actually exist on GitLab before committing to the ingest phase. They must return the full `GitLabIssue` / `GitLabMergeRequest` structs (same as the paginated endpoints return) so they can be passed directly to `process_single_issue` / `process_single_mr`.\n\n## Approach\n\n### Step 1: Add `get_issue_by_iid` method (src/gitlab/client.rs)\n\nAdd after the existing `get_version` method (~line 112):\n\n```rust\npub async fn get_issue_by_iid(\n    &self,\n    project_id: u64,\n    iid: u64,\n) -> Result<GitLabIssue> {\n    self.request(&format!(\"/api/v4/projects/{project_id}/issues/{iid}\"))\n        .await\n}\n```\n\nThis reuses the existing `request()` method which already handles:\n- Rate limiting (via `RateLimiter`)\n- Retry on 429 (up to `MAX_RETRIES`)\n- 404 → `LoreError::GitLabNotFound { resource }`\n- 401 → `LoreError::GitLabAuthFailed`\n- JSON deserialization into `GitLabIssue`\n\n### Step 2: Add `get_mr_by_iid` method (src/gitlab/client.rs)\n\n```rust\npub async fn get_mr_by_iid(\n    &self,\n    project_id: u64,\n    iid: u64,\n) -> Result<GitLabMergeRequest> {\n    self.request(&format!(\"/api/v4/projects/{project_id}/merge_requests/{iid}\"))\n        .await\n}\n```\n\n### Step 3: Add wiremock tests (src/gitlab/client_tests.rs or inline #[cfg(test)])\n\nFour tests using the same wiremock pattern as `src/gitlab/graphql_tests.rs`:\n1. `get_issue_by_iid_success` — mock 200 with full GitLabIssue JSON, verify deserialized fields\n2. `get_issue_by_iid_not_found` — mock 404, verify `LoreError::GitLabNotFound`\n3. `get_mr_by_iid_success` — mock 200 with full GitLabMergeRequest JSON, verify deserialized fields\n4. `get_mr_by_iid_not_found` — mock 404, verify `LoreError::GitLabNotFound`\n\n## Acceptance Criteria\n- [ ] `GitLabClient::get_issue_by_iid(project_id, iid)` returns `Result<GitLabIssue>`\n- [ ] `GitLabClient::get_mr_by_iid(project_id, iid)` returns `Result<GitLabMergeRequest>`\n- [ ] 404 response maps to `LoreError::GitLabNotFound`\n- [ ] 401 response maps to `LoreError::GitLabAuthFailed` (inherited from `handle_response`)\n- [ ] Successful responses deserialize into the correct struct types\n- [ ] All 4 wiremock tests pass\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n- MODIFY: src/gitlab/client.rs (add two pub async methods)\n- CREATE: src/gitlab/client_tests.rs (wiremock tests, referenced via `#[cfg(test)] #[path = \"client_tests.rs\"] mod tests;` at bottom of client.rs)\n\n## TDD Anchor\nRED: Write 4 wiremock tests in `src/gitlab/client_tests.rs`:\n\n```rust\nuse super::*;\nuse crate::core::error::LoreError;\nuse wiremock::matchers::{header, method, path};\nuse wiremock::{Mock, MockServer, ResponseTemplate};\n\n#[tokio::test]\nasync fn get_issue_by_iid_success() {\n    let server = MockServer::start().await;\n    let issue_json = serde_json::json!({\n        \"id\": 1001,\n        \"iid\": 42,\n        \"project_id\": 5,\n        \"title\": \"Fix login bug\",\n        \"state\": \"opened\",\n        \"created_at\": \"2026-01-15T10:00:00Z\",\n        \"updated_at\": \"2026-02-01T14:30:00Z\",\n        \"author\": { \"id\": 1, \"username\": \"dev1\", \"name\": \"Developer One\", \"avatar_url\": null, \"web_url\": \"https://gitlab.example.com/dev1\" },\n        \"web_url\": \"https://gitlab.example.com/group/repo/-/issues/42\",\n        \"labels\": [],\n        \"milestone\": null,\n        \"assignees\": [],\n        \"closed_at\": null,\n        \"closed_by\": null,\n        \"description\": \"Login fails on mobile\"\n    });\n\n    Mock::given(method(\"GET\"))\n        .and(path(\"/api/v4/projects/5/issues/42\"))\n        .and(header(\"PRIVATE-TOKEN\", \"test-token\"))\n        .respond_with(ResponseTemplate::new(200).set_body_json(&issue_json))\n        .mount(&server)\n        .await;\n\n    let client = GitLabClient::new(&server.uri(), \"test-token\", Some(100.0));\n    let issue = client.get_issue_by_iid(5, 42).await.unwrap();\n    assert_eq!(issue.iid, 42);\n    assert_eq!(issue.title, \"Fix login bug\");\n}\n\n#[tokio::test]\nasync fn get_issue_by_iid_not_found() {\n    let server = MockServer::start().await;\n\n    Mock::given(method(\"GET\"))\n        .and(path(\"/api/v4/projects/5/issues/999\"))\n        .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({\"message\": \"404 Not Found\"})))\n        .mount(&server)\n        .await;\n\n    let client = GitLabClient::new(&server.uri(), \"test-token\", Some(100.0));\n    let err = client.get_issue_by_iid(5, 999).await.unwrap_err();\n    assert!(matches!(err, LoreError::GitLabNotFound { .. }));\n}\n\n#[tokio::test]\nasync fn get_mr_by_iid_success() {\n    let server = MockServer::start().await;\n    let mr_json = serde_json::json!({\n        \"id\": 2001,\n        \"iid\": 101,\n        \"project_id\": 5,\n        \"title\": \"Add caching layer\",\n        \"state\": \"merged\",\n        \"created_at\": \"2026-01-20T09:00:00Z\",\n        \"updated_at\": \"2026-02-10T16:00:00Z\",\n        \"author\": { \"id\": 2, \"username\": \"dev2\", \"name\": \"Developer Two\", \"avatar_url\": null, \"web_url\": \"https://gitlab.example.com/dev2\" },\n        \"web_url\": \"https://gitlab.example.com/group/repo/-/merge_requests/101\",\n        \"source_branch\": \"feature/caching\",\n        \"target_branch\": \"main\",\n        \"draft\": false,\n        \"merge_status\": \"can_be_merged\",\n        \"labels\": [],\n        \"milestone\": null,\n        \"assignees\": [],\n        \"reviewers\": [],\n        \"merged_by\": null,\n        \"merged_at\": null,\n        \"closed_at\": null,\n        \"closed_by\": null,\n        \"description\": \"Adds Redis caching\"\n    });\n\n    Mock::given(method(\"GET\"))\n        .and(path(\"/api/v4/projects/5/merge_requests/101\"))\n        .and(header(\"PRIVATE-TOKEN\", \"test-token\"))\n        .respond_with(ResponseTemplate::new(200).set_body_json(&mr_json))\n        .mount(&server)\n        .await;\n\n    let client = GitLabClient::new(&server.uri(), \"test-token\", Some(100.0));\n    let mr = client.get_mr_by_iid(5, 101).await.unwrap();\n    assert_eq!(mr.iid, 101);\n    assert_eq!(mr.title, \"Add caching layer\");\n    assert_eq!(mr.source_branch, \"feature/caching\");\n}\n\n#[tokio::test]\nasync fn get_mr_by_iid_not_found() {\n    let server = MockServer::start().await;\n\n    Mock::given(method(\"GET\"))\n        .and(path(\"/api/v4/projects/5/merge_requests/999\"))\n        .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({\"message\": \"404 Not Found\"})))\n        .mount(&server)\n        .await;\n\n    let client = GitLabClient::new(&server.uri(), \"test-token\", Some(100.0));\n    let err = client.get_mr_by_iid(5, 999).await.unwrap_err();\n    assert!(matches!(err, LoreError::GitLabNotFound { .. }));\n}\n```\n\nGREEN: Add the two methods to `GitLabClient`.\nVERIFY: `cargo test get_issue_by_iid && cargo test get_mr_by_iid`\n\n## Edge Cases\n- The `request()` method already handles 429 retries, so no extra retry logic is needed in the new methods.\n- The GitLabIssue/GitLabMergeRequest fixture JSON must include all required (non-Option) fields. Check the struct definitions in `src/gitlab/types.rs` if deserialization fails — the test fixtures above include the minimum required fields based on the struct definitions.\n- The `project_id` parameter is the GitLab-side numeric project ID (not the local SQLite row ID). The caller must resolve this from the local `projects` table's `gitlab_project_id` column.\n\n## Dependency Context\nThis is a leaf/foundation bead with no upstream dependencies. Downstream bead bd-3sez (surgical.rs) calls these methods during preflight to fetch entities by IID before ingesting.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-17T19:12:14.447996Z","created_by":"tayloreernisse","updated_at":"2026-02-18T21:04:58.567083Z","closed_at":"2026-02-18T21:04:58.567041Z","close_reason":"Completed: all implementation work done, code reviewed, tests passing","compaction_level":0,"original_size":0,"labels":["surgical-sync"],"dependencies":[{"issue_id":"bd-159p","depends_on_id":"bd-1i4i","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-16m8","title":"OBSERV: Record item counts as span fields in sync stages","description":"## Background\nMetricsLayer (bd-34ek) captures span fields, but the stage functions must actually record item counts INTO their spans. This is the bridge between \"work happened\" and \"MetricsLayer knows about it.\"\n\n## Approach\nIn each stage function, after the work loop completes, record counts into the current span:\n\n### src/ingestion/orchestrator.rs - ingest_project_issues_with_progress() (~line 110)\nAfter issues are fetched and discussions synced:\n```rust\ntracing::Span::current().record(\"items_processed\", result.issues_upserted);\ntracing::Span::current().record(\"items_skipped\", result.issues_skipped);\ntracing::Span::current().record(\"errors\", result.errors);\n```\n\n### src/ingestion/orchestrator.rs - drain_resource_events() (~line 566)\nAfter the drain loop:\n```rust\ntracing::Span::current().record(\"items_processed\", result.fetched);\ntracing::Span::current().record(\"errors\", result.failed);\n```\n\n### src/documents/regenerator.rs - regenerate_dirty_documents() (~line 24)\nAfter the regeneration loop:\n```rust\ntracing::Span::current().record(\"items_processed\", result.regenerated);\ntracing::Span::current().record(\"items_skipped\", result.unchanged);\ntracing::Span::current().record(\"errors\", result.errored);\n```\n\n### src/embedding/pipeline.rs - embed_documents() (~line 36)\nAfter embedding completes:\n```rust\ntracing::Span::current().record(\"items_processed\", result.embedded);\ntracing::Span::current().record(\"items_skipped\", result.skipped);\ntracing::Span::current().record(\"errors\", result.failed);\n```\n\nIMPORTANT: These fields must be declared as tracing::field::Empty in the #[instrument] attribute (done in bd-24j1). You can only record() a field that was declared at span creation. Attempting to record an undeclared field silently does nothing.\n\n## Acceptance Criteria\n- [ ] MetricsLayer captures items_processed for each stage\n- [ ] MetricsLayer captures items_skipped and errors when non-zero\n- [ ] Fields match the span declarations from bd-24j1\n- [ ] extract_timings() returns correct counts in StageTiming\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/ingestion/orchestrator.rs (record counts in ingest + drain functions)\n- src/documents/regenerator.rs (record counts in regenerate)\n- src/embedding/pipeline.rs (record counts in embed)\n\n## TDD Loop\nRED: test_stage_fields_recorded (integration: run pipeline, extract timings, verify counts > 0)\nGREEN: Add Span::current().record() calls at end of each stage\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- Span::current() returns a disabled span if no subscriber is registered (e.g., in tests without subscriber setup). record() on disabled span is a no-op. Tests need a subscriber.\n- Field names must exactly match the declaration: \"items_processed\" not \"itemsProcessed\"\n- Recording must happen BEFORE the span closes (before function returns). Place at end of function but before Ok(result).","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T15:54:32.011236Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:27:38.620645Z","closed_at":"2026-02-04T17:27:38.620601Z","close_reason":"Added tracing::field::Empty declarations and Span::current().record() calls in 4 functions: ingest_project_issues, ingest_project_merge_requests, drain_resource_events, regenerate_dirty_documents, embed_documents","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-16m8","depends_on_id":"bd-24j1","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-16m8","depends_on_id":"bd-34ek","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-16m8","depends_on_id":"bd-3er","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-16qf","title":"Phase 4b: Switch timeline_seed_tests to #[asupersync::test]","description":"## What\nSwitch 13 tests in timeline/timeline_seed_tests.rs from #[tokio::test] to #[asupersync::test].\n\n## Rearchitecture Context (2026-03-06)\nThe timeline subsystem was extracted from core/ into its own top-level module:\n- src/core/timeline_seed_tests.rs -> src/timeline/timeline_seed_tests.rs\n- src/core/timeline.rs -> src/timeline/types.rs\n- src/core/timeline_seed.rs -> src/timeline/seed.rs\n- src/core/timeline_expand.rs -> src/timeline/expand.rs\n- src/core/timeline_collect.rs -> src/timeline/collect.rs\n\n## Why\nThese tests are pure CPU/SQLite with no HTTP and no tokio APIs. They are safe and straightforward to migrate. Running them on asupersync validates the test macro works correctly.\n\n## Implementation\n```rust\n// Before\n#[tokio::test]\nasync fn test_foo() { ... }\n\n// After\n#[asupersync::test]\nasync fn test_foo() { ... }\n```\n\n## Files Changed\n- src/timeline/timeline_seed_tests.rs (~13 test attribute changes)\n\n## Testing\n- cargo test -- timeline_seed (all 13 must pass)\n\n## Depends On\n- Phase 3a (asupersync must be in deps for the test macro)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-06T18:41:35.654811Z","created_by":"tayloreernisse","updated_at":"2026-03-06T21:11:12.656849Z","closed_at":"2026-03-06T21:11:12.656699Z","close_reason":"asupersync migration complete — 5 commits pushed to asupersync-migration branch","compaction_level":0,"original_size":0,"labels":["asupersync","phase-4","testing"],"dependencies":[{"issue_id":"bd-16qf","depends_on_id":"bd-1lj5","type":"parent-child","created_at":"2026-03-06T18:41:35.656827Z","created_by":"tayloreernisse"},{"issue_id":"bd-16qf","depends_on_id":"bd-39yp","type":"blocks","created_at":"2026-03-06T18:42:56.052380Z","created_by":"tayloreernisse"}]}
{"id":"bd-17n","title":"OBSERV: Add LoggingConfig to Config struct","description":"## Background\nLoggingConfig centralizes log file settings so users can customize retention and disable file logging. It follows the same #[serde(default)] pattern as SyncConfig (src/core/config.rs:32-78) so existing config.json files continue working with zero changes.\n\n## Approach\nAdd to src/core/config.rs, after the EmbeddingConfig struct (around line 120):\n\n```rust\n#[derive(Debug, Clone, Deserialize)]\n#[serde(default)]\npub struct LoggingConfig {\n    /// Directory for log files. Default: None (= XDG data dir + /logs/)\n    pub log_dir: Option<String>,\n\n    /// Days to retain log files. Default: 30. Set to 0 to disable file logging.\n    pub retention_days: u32,\n\n    /// Enable JSON log files. Default: true.\n    pub file_logging: bool,\n}\n\nimpl Default for LoggingConfig {\n    fn default() -> Self {\n        Self {\n            log_dir: None,\n            retention_days: 30,\n            file_logging: true,\n        }\n    }\n}\n```\n\nAdd to the Config struct (src/core/config.rs:123-137), after the embedding field:\n\n```rust\n#[serde(default)]\npub logging: LoggingConfig,\n```\n\nNote: Using impl Default rather than default helper functions (default_retention_days, default_true) because #[serde(default)] on the struct applies Default::default() to the entire struct when the key is missing. This is the same pattern used by SyncConfig.\n\n## Acceptance Criteria\n- [ ] Deserializing {} as LoggingConfig yields retention_days=30, file_logging=true, log_dir=None\n- [ ] Deserializing {\"retention_days\": 7} preserves file_logging=true default\n- [ ] Existing config.json files (no \"logging\" key) deserialize without error\n- [ ] Config struct has .logging field accessible\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/core/config.rs (add LoggingConfig struct + Default impl, add field to Config)\n\n## TDD Loop\nRED: tests/config_tests.rs (or inline #[cfg(test)] mod):\n  - test_logging_config_defaults\n  - test_logging_config_partial\nGREEN: Add LoggingConfig struct, Default impl, field on Config\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- retention_days=0 means disable file logging entirely (not \"delete all files\") -- document this in the struct doc comment\n- log_dir with a relative path: should be resolved relative to CWD or treated as absolute? Decision: treat as absolute, document it\n- Missing \"logging\" key in JSON: #[serde(default)] handles this -- the entire LoggingConfig gets Default::default()","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-04T15:53:55.471193Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:10:22.751969Z","closed_at":"2026-02-04T17:10:22.751921Z","close_reason":"Added LoggingConfig struct with log_dir, retention_days, file_logging fields and serde defaults","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-17n","depends_on_id":"bd-2nx","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-17v","title":"[CP1] gi sync-status enhancement","description":"## Background\n\nThe `gi sync-status` command shows synchronization state: last successful sync time, cursor positions per project/resource, and overall health. This helps users understand when data was last refreshed and diagnose sync issues.\n\n## Approach\n\n### Module: src/cli/commands/sync_status.rs (enhance existing or create)\n\n### Handler Function\n\n```rust\npub async fn handle_sync_status(conn: &Connection) -> Result<()>\n```\n\n### Data to Display\n\n1. **Last sync run**: From `sync_runs` table\n   - Started at, completed at, status\n   - Issues fetched, discussions fetched\n\n2. **Cursor positions**: From `sync_cursors` table\n   - Per (project, resource_type) pair\n   - Show updated_at_cursor as human-readable date\n   - Show tie_breaker_id (GitLab ID of last processed item)\n\n3. **Overall counts**: Quick summary\n   - Total issues, discussions, notes in DB\n\n### Output Format\n\n```\nLast Sync\n─────────\nStatus:    completed\nStarted:   2024-01-25 10:30:00\nCompleted: 2024-01-25 10:35:00\nDuration:  5m 23s\n\nCursor Positions\n────────────────\ngroup/project-one (issues):\n  Last updated_at: 2024-01-25 10:30:00\n  Last GitLab ID:  12345\n\nData Summary\n────────────\nIssues:      1,234\nDiscussions: 5,678\nNotes:       12,345 (excluding 2,000 system)\n```\n\n### Queries\n\n```sql\n-- Last sync run\nSELECT * FROM sync_runs ORDER BY started_at DESC LIMIT 1\n\n-- Cursor positions\nSELECT p.path, sc.resource_type, sc.updated_at_cursor, sc.tie_breaker_id\nFROM sync_cursors sc\nJOIN projects p ON sc.project_id = p.id\n\n-- Data summary\nSELECT COUNT(*) FROM issues\nSELECT COUNT(*) FROM discussions\nSELECT COUNT(*), SUM(is_system) FROM notes\n```\n\n## Acceptance Criteria\n\n- [ ] Shows last sync run with status and timing\n- [ ] Shows cursor position per project/resource\n- [ ] Shows total counts for issues, discussions, notes\n- [ ] Handles case where no sync has run yet\n- [ ] Formats timestamps as human-readable local time\n\n## Files\n\n- src/cli/commands/sync_status.rs (create or enhance)\n- src/cli/mod.rs (add SyncStatus variant if new)\n\n## TDD Loop\n\nRED:\n```rust\n#[tokio::test] async fn sync_status_shows_last_run()\n#[tokio::test] async fn sync_status_shows_cursor_positions()\n#[tokio::test] async fn sync_status_handles_no_sync_yet()\n```\n\nGREEN: Implement handler with queries and formatting\n\nVERIFY: `cargo test sync_status`\n\n## Edge Cases\n\n- No sync has ever run - show \"No sync runs recorded\"\n- Sync in progress - show \"Status: running\" with started_at\n- Cursor at epoch 0 - means fresh start, show \"Not started\"\n- Multiple projects - show cursor for each","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-25T17:02:38.409353Z","created_by":"tayloreernisse","updated_at":"2026-01-25T23:03:21.851557Z","closed_at":"2026-01-25T23:03:21.851496Z","close_reason":"Implemented gi sync-status showing last run, cursor positions, and data summary","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-17v","depends_on_id":"bd-208","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-18ai","title":"Phase 4a: Adapter integration tests (asupersync-native)","description":"## What\nAdd 3-5 asupersync-native integration tests that exercise the HTTP adapter against a simple HTTP server (not wiremock). These close the gap between wiremock tests (on tokio) and production (on asupersync).\n\n## Why\nThe 42 wiremock tests validate protocol correctness but run on tokio. The adapter actual behavior under the production runtime is untested by mocked-response tests. These tests close the \"works on tokio but does it work on asupersync?\" gap.\n\n## Tests to Write (~50 LOC each)\n\n1. **GET with headers + JSON response** — verify header passing and JSON deserialization through adapter\n2. **POST with JSON body** — verify Content-Type injection and body serialization\n3. **429 + Retry-After** — verify adapter surfaces rate-limit responses correctly\n4. **Timeout** — verify adapter asupersync::time::timeout wrapper fires\n5. **Large response rejection** — verify body size guard triggers at 64 MiB\n\n## Test Infrastructure\nUse a simple HTTP server (hyper or raw TCP listener) instead of wiremock. These tests use #[asupersync::test] since they exercise the production runtime.\n\n## Files Changed\n- src/http.rs or tests/http_integration.rs (NEW, ~250 LOC)\n\n## Testing\n- cargo test (new tests must pass)\n\n## Depends On\n- Phase 1 (adapter must exist)\n- Phase 3a (asupersync must be in deps)","status":"closed","priority":1,"issue_type":"task","created_at":"2026-03-06T18:41:29.108891Z","created_by":"tayloreernisse","updated_at":"2026-03-06T21:11:12.652737Z","closed_at":"2026-03-06T21:11:12.652691Z","close_reason":"asupersync migration complete — 5 commits pushed to asupersync-migration branch","compaction_level":0,"original_size":0,"labels":["asupersync","phase-4","testing"],"dependencies":[{"issue_id":"bd-18ai","depends_on_id":"bd-1lj5","type":"parent-child","created_at":"2026-03-06T18:41:29.116461Z","created_by":"tayloreernisse"},{"issue_id":"bd-18ai","depends_on_id":"bd-39yp","type":"blocks","created_at":"2026-03-06T18:42:55.745113Z","created_by":"tayloreernisse"},{"issue_id":"bd-18ai","depends_on_id":"bd-bqoc","type":"blocks","created_at":"2026-03-06T18:42:55.600869Z","created_by":"tayloreernisse"}]}
{"id":"bd-18bf","title":"NOTE-0B: Immediate deletion propagation for swept notes","description":"## Background\nWhen sweep deletes stale notes, orphaned note documents remain in search results until generate-docs --full runs. This erodes dataset trust. Propagate deletion to documents immediately in the same transaction.\n\n## Approach\nUpdate both sweep functions (issue + MR) to use set-based SQL that deletes documents and dirty_sources entries for stale notes before deleting the note rows:\n\nStep 1: DELETE FROM documents WHERE source_type = 'note' AND source_id IN (SELECT id FROM notes WHERE discussion_id = ? AND last_seen_at < ? AND is_system = 0)\nStep 2: DELETE FROM dirty_sources WHERE source_type = 'note' AND source_id IN (same subquery)\nStep 3: DELETE FROM notes WHERE discussion_id = ? AND last_seen_at < ?\n\nDocument DELETE cascades to document_labels/document_paths via ON DELETE CASCADE (defined in migration 007_documents.sql). FTS trigger documents_ad auto-removes FTS entry (defined in migration 008_fts5.sql). Same pattern for mr_discussions.rs sweep.\n\nNote: MR sweep_stale_notes() at line 551 uses a different WHERE clause (project_id + discussion_id IN subquery + last_seen_at). Apply the same document propagation pattern with the matching subquery.\n\n## Files\n- MODIFY: src/ingestion/discussions.rs (update sweep_stale_issue_notes from NOTE-0A)\n- MODIFY: src/ingestion/mr_discussions.rs (update sweep_stale_notes at line 551)\n\n## TDD Anchor\nRED: test_issue_note_sweep_deletes_note_documents_immediately — setup 3 notes with documents, re-sync 2, sweep, assert stale doc deleted.\nGREEN: Add document/dirty_sources DELETE before note DELETE in sweep functions.\nVERIFY: cargo test sweep_deletes_note_documents -- --nocapture\nTests: test_mr_note_sweep_deletes_note_documents_immediately, test_sweep_deletion_handles_note_without_document, test_set_based_deletion_atomicity\n\n## Acceptance Criteria\n- [ ] Stale note sweep deletes corresponding documents in same transaction\n- [ ] Stale note sweep deletes corresponding dirty_sources entries\n- [ ] Non-system notes only — system notes never have documents (is_system = 0 filter)\n- [ ] Set-based SQL (not per-note loops) for performance\n- [ ] Works for both issue and MR discussion sweeps\n- [ ] No error when sweeping notes that have no documents (DELETE WHERE on absent rows = no-op)\n- [ ] All 4 tests pass\n\n## Dependency Context\n- Depends on NOTE-0A (bd-3bpk): uses sweep_stale_issue_notes/sweep_stale_notes functions created/modified in that bead\n- Depends on NOTE-2A (bd-1oi7): documents table must accept source_type='note' (migration 024 adds CHECK constraint)\n\n## Edge Cases\n- System notes: WHERE clause filters with is_system = 0 (system notes never get documents)\n- Notes without documents: DELETE WHERE on non-existent document is a no-op in SQLite\n- FTS consistency: documents_ad trigger (migration 008) handles FTS cleanup on document DELETE","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T16:59:33.412628Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:13:15.082355Z","closed_at":"2026-02-12T18:13:15.082307Z","close_reason":"Implemented by agent swarm","compaction_level":0,"original_size":0,"labels":["per-note","search"]}
{"id":"bd-18dn","title":"Add normalize_query_path() pure function for path canonicalization","description":"## Background\nPlan section 3a (iteration 6, feedback-6). User input paths like `./src//foo.rs` or whitespace-padded paths fail path resolution even when the file exists in the database. A syntactic normalization function runs before `build_path_query()` to reduce false negatives.\n\n## Approach\nAdd `normalize_query_path()` as a private pure function in who.rs (near `half_life_decay()`):\n\n```rust\nfn normalize_query_path(input: &str) -> String {\n    let trimmed = input.trim();\n    let stripped = trimmed.strip_prefix(\"./\").unwrap_or(trimmed);\n    // Collapse repeated /\n    let mut result = String::with_capacity(stripped.len());\n    let mut prev_slash = false;\n    for ch in stripped.chars() {\n        if ch == '/' {\n            if !prev_slash { result.push('/'); }\n            prev_slash = true;\n        } else {\n            result.push(ch);\n            prev_slash = false;\n        }\n    }\n    result\n}\n```\n\nCalled once at top of `run_who()` before `build_path_query()`. Robot JSON `resolved_input` includes both `path_input_original` (raw) and `path_input_normalized` (after canonicalization).\n\nRules:\n- Strip leading `./`\n- Collapse repeated `/` (e.g., `src//foo.rs` -> `src/foo.rs`)\n- Trim leading/trailing whitespace\n- Preserve trailing `/` (signals explicit prefix intent)\n- Purely syntactic — no filesystem or DB lookups\n\n## TDD Loop\n\n### RED (write first):\n```rust\n#[test]\nfn test_path_normalization_handles_dot_and_double_slash() {\n    assert_eq!(normalize_query_path(\"./src//foo.rs\"), \"src/foo.rs\");\n    assert_eq!(normalize_query_path(\"  src/bar.rs  \"), \"src/bar.rs\");\n    assert_eq!(normalize_query_path(\"src/foo.rs\"), \"src/foo.rs\"); // unchanged\n    assert_eq!(normalize_query_path(\"\"), \"\"); // empty passthrough\n}\n\n#[test]\nfn test_path_normalization_preserves_prefix_semantics() {\n    assert_eq!(normalize_query_path(\"./src/dir/\"), \"src/dir/\"); // trailing slash preserved\n    assert_eq!(normalize_query_path(\"src/dir\"), \"src/dir\"); // no trailing slash = file\n}\n```\n\n### GREEN: Implement normalize_query_path (5-10 lines).\n### VERIFY: `cargo test -p lore -- test_path_normalization`\n\n## Acceptance Criteria\n- [ ] test_path_normalization_handles_dot_and_double_slash passes\n- [ ] test_path_normalization_preserves_prefix_semantics passes\n- [ ] Function is private (`fn` not `pub fn`)\n- [ ] No DB or filesystem dependency — pure string function\n- [ ] Called in run_who() before build_path_query()\n- [ ] Robot JSON resolved_input includes path_input_original and path_input_normalized\n\n## Files\n- src/cli/commands/who.rs (function near half_life_decay, call site in run_who)\n\n## Edge Cases\n- Empty string -> empty string\n- Only whitespace -> empty string\n- Multiple leading ./ (\"././src\") -> strip first \"./\" only per plan spec\n- Trailing slash preserved for prefix intent","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T19:29:27.954857Z","created_by":"tayloreernisse","updated_at":"2026-02-12T20:43:04.406259Z","closed_at":"2026-02-12T20:43:04.406217Z","close_reason":"Implemented by time-decay swarm: 3 agents, 12 tasks, 621 tests passing, all quality gates green","compaction_level":0,"original_size":0,"labels":["scoring"]}
{"id":"bd-18qs","title":"Implement entity table + filter bar widgets","description":"## Background\nThe entity table and filter bar are shared widgets used by Issue List, MR List, and potentially Search results. The entity table supports sortable columns with responsive width allocation. The filter bar provides a typed DSL for filtering with inline diagnostics.\n\n## Approach\nEntity Table (view/common/entity_table.rs):\n- EntityTable<R: TableRow> widget: generic over row type\n- TableRow trait: fn cells(&self) -> Vec<Cell>, fn sort_key(&self, col: usize) -> Ordering\n- Column definitions: name, min_width, flex_weight, alignment, sort_field\n- Responsive column fitting: hide low-priority columns as terminal narrows\n- Keyboard: j/k scroll, J/K page scroll, Tab cycle sort column, Enter select, g+g top, G bottom\n- Visual: alternating row colors, selected row highlight, sort indicator arrow\n\nFilter Bar (view/common/filter_bar.rs):\n- FilterBar widget wrapping ftui TextInput\n- DSL parsing (crate filter_dsl.rs): quoted values (\"in progress\"), negation prefix (-closed), field:value syntax (author:taylor, state:opened, label:bug), free-text search\n- Inline diagnostics: unknown field names highlighted, cursor position for error\n- Applied filter chips shown as tags below the input\n\nFilter DSL (filter_dsl.rs):\n- parse_filter_tokens(input: &str) -> Vec<FilterToken>\n- FilterToken enum: FieldValue{field, value}, Negation{field, value}, FreeText(String), QuotedValue(String)\n- Validation: known fields per entity type (issues: state, author, assignee, label, milestone, status; MRs: state, author, reviewer, target_branch, source_branch, label, draft)\n\n## Acceptance Criteria\n- [ ] EntityTable renders with responsive column widths\n- [ ] Columns hide gracefully when terminal is too narrow\n- [ ] j/k scrolls, Enter selects, Tab cycles sort column\n- [ ] Sort indicator (arrow) shows on active sort column\n- [ ] FilterBar captures text input and parses DSL tokens\n- [ ] Quoted values preserved as single token\n- [ ] Negation prefix (-closed) creates exclusion filter\n- [ ] field:value syntax maps to typed filter fields\n- [ ] Unknown field names highlighted as error\n- [ ] Filter chips rendered below input bar\n\n## Files\n- CREATE: crates/lore-tui/src/view/common/entity_table.rs\n- CREATE: crates/lore-tui/src/view/common/filter_bar.rs\n- CREATE: crates/lore-tui/src/filter_dsl.rs\n\n## TDD Anchor\nRED: Write test_parse_filter_basic in filter_dsl.rs that parses \"state:opened author:taylor\" and asserts two FieldValue tokens.\nGREEN: Implement parse_filter_tokens with field:value splitting.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_parse_filter\n\nAdditional tests:\n- test_parse_quoted_value: \"in progress\" -> single QuotedValue token\n- test_parse_negation: -closed -> Negation token\n- test_parse_mixed: state:opened \"bug fix\" -wontfix -> 3 tokens of correct types\n- test_column_hiding: EntityTable with 5 columns hides lowest priority at 60 cols\n\n## Edge Cases\n- Filter DSL must handle Unicode in values (CJK issue titles)\n- Empty filter string should show all results (no-op)\n- Very long filter strings must not overflow the input area\n- Tab cycling sort must wrap around (last column -> first)\n- Column widths must respect min_width even when terminal is very narrow","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T16:58:07.586225Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:21.742252Z","deleted_at":"2026-03-11T18:34:21.742248Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-18t","title":"Implement discussion truncation logic","description":"## Background\nDiscussion threads can contain dozens of notes spanning thousands of characters. The truncation module ensures discussion documents stay within a 32k character limit (suitable for embedding chunking) by dropping middle notes while preserving first and last notes for context. A separate hard safety cap of 2MB applies to ALL document types for pathological content (pasted logs, base64 blobs). Issue/MR documents are NOT truncated by the discussion logic — only the hard cap applies.\n\n## Approach\nCreate `src/documents/truncation.rs` per PRD Section 2.3:\n\n```rust\npub const MAX_DISCUSSION_CHARS: usize = 32_000;\npub const MAX_DOCUMENT_CHARS_HARD: usize = 2_000_000;\n\npub struct NoteContent {\n    pub author: String,\n    pub date: String,\n    pub body: String,\n}\n\npub struct TruncationResult {\n    pub content: String,\n    pub is_truncated: bool,\n    pub reason: Option<TruncationReason>,\n}\n\npub enum TruncationReason {\n    TokenLimitMiddleDrop,\n    SingleNoteOversized,\n    FirstLastOversized,\n    HardCapOversized,\n}\n```\n\n**Core functions:**\n- `truncate_discussion(notes: &[NoteContent], max_chars: usize) -> TruncationResult`\n- `truncate_utf8(s: &str, max_bytes: usize) -> &str` (shared with fts.rs)\n- `truncate_hard_cap(content: &str) -> TruncationResult` (for any doc type)\n\n**Algorithm for truncate_discussion:**\n1. Format all notes as `@author (date):\\nbody\\n\\n`\n2. If total <= max_chars: return as-is\n3. If single note: truncate at UTF-8 boundary, append `[truncated]`, reason = SingleNoteOversized\n4. Binary search: find max N where first N notes + last 1 note + marker fit within max_chars\n5. If first + last > max_chars: keep only first (truncated), reason = FirstLastOversized\n6. Otherwise: first N + marker + last M, reason = TokenLimitMiddleDrop\n\n**Marker format:** `\\n\\n[... N notes omitted for length ...]\\n\\n`\n\n## Acceptance Criteria\n- [ ] Discussion with total < 32k chars returns untruncated\n- [ ] Discussion > 32k chars: middle notes dropped, first + last preserved\n- [ ] Truncation marker shows correct count of omitted notes\n- [ ] Single note > 32k chars: truncated at UTF-8-safe boundary with `[truncated]` appended\n- [ ] First + last note > 32k: only first note kept (truncated if needed)\n- [ ] Hard cap (2MB) truncates any document type at UTF-8-safe boundary\n- [ ] `truncate_utf8` never panics on multi-byte codepoints (emoji, CJK, accented chars)\n- [ ] `TruncationReason::as_str()` returns DB-compatible strings matching CHECK constraint\n\n## Files\n- `src/documents/truncation.rs` — new file\n- `src/documents/mod.rs` — add `pub use truncation::{truncate_discussion, truncate_hard_cap, TruncationResult, NoteContent};`\n\n## TDD Loop\nRED: Tests in `#[cfg(test)] mod tests`:\n- `test_no_truncation_under_limit` — 3 short notes, all fit\n- `test_middle_notes_dropped` — 10 notes totaling > 32k, first+last preserved\n- `test_single_note_oversized` — one note of 50k chars, truncated safely\n- `test_first_last_oversized` — first=20k, last=20k, only first kept\n- `test_one_note_total` — single note under limit: no truncation\n- `test_utf8_boundary_safety` — content with emoji/CJK at truncation point\n- `test_hard_cap` — 3MB content truncated to 2MB\n- `test_marker_count_correct` — marker says \"[... 5 notes omitted ...]\" when 5 dropped\nGREEN: Implement truncation logic\nVERIFY: `cargo test truncation`\n\n## Edge Cases\n- Empty notes list: return empty content, not truncated\n- All notes are empty strings: total = 0, no truncation\n- Note body contains only multi-byte characters: truncate_utf8 walks backward to find safe boundary\n- Note body with trailing newlines: formatted output should not have excessive blank lines","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-30T15:25:45.597167Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:21:32.256569Z","closed_at":"2026-01-30T17:21:32.256507Z","close_reason":"Completed: truncate_discussion, truncate_hard_cap, truncate_utf8, TruncationReason with as_str(), 12 tests pass","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-18t","depends_on_id":"bd-36p","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-18yh","title":"NOTE-2C: Note document extractor function","description":"## Background\nEach non-system note becomes a searchable document in the FTS/embedding pipeline. Follows the pattern of extract_issue_document() (line 85), extract_mr_document() (line 186), extract_discussion_document() (line 302) in src/documents/extractor.rs.\n\n## Approach\nAdd pub fn extract_note_document(conn: &Connection, note_id: i64) -> Result<Option<DocumentData>> to src/documents/extractor.rs:\n\n1. Fetch note with JOIN to discussions and projects:\n   SELECT n.id, n.gitlab_id, n.author_username, n.body, n.note_type, n.is_system, n.created_at, n.updated_at, n.position_new_path, n.position_new_line, n.position_old_path, n.position_old_line, n.resolvable, n.resolved, n.resolved_by, d.noteable_type, d.issue_id, d.merge_request_id, p.path_with_namespace, p.id as project_id\n   FROM notes n\n   JOIN discussions d ON n.discussion_id = d.id\n   JOIN projects p ON n.project_id = p.id\n   WHERE n.id = ?\n\n2. Return None for: system notes (is_system = 1), not found, orphaned discussions (no parent issue/MR)\n\n3. Fetch parent entity (Issue or MR) — get iid, title, web_url, labels:\n   For issues: SELECT iid, title, web_url FROM issues WHERE id = ?\n   For MRs: SELECT iid, title, web_url FROM merge_requests WHERE id = ?\n   Labels: SELECT label_name FROM issue_labels/mr_labels WHERE issue_id/mr_id = ?\n   (Same pattern as extract_discussion_document lines 332-401)\n\n4. Build paths: BTreeSet from position_old_path + position_new_path (filter None values)\n\n5. Build URL: parent_web_url + \"#note_{gitlab_id}\"\n\n6. Format content with structured key-value header:\n   [[Note]]\n   source_type: note\n   note_gitlab_id: {gitlab_id}\n   project: {path_with_namespace}\n   parent_type: {Issue|MergeRequest}\n   parent_iid: {iid}\n   parent_title: {title}\n   note_type: {DiffNote|DiscussionNote|...}\n   author: @{author}\n   created_at: {iso8601}\n   resolved: {true|false} (only if resolvable)\n   path: {position_new_path}:{line} (only if DiffNote with path)\n   labels: {comma-separated parent labels}\n   url: {url}\n\n   --- Body ---\n\n   {body}\n\n7. Title: \"Note by @{author} on {Issue|MR} #{iid}: {parent_title}\"\n\n8. Compute hashes: content_hash via compute_content_hash() (line 66), labels_hash via compute_list_hash(), paths_hash via compute_list_hash(). Apply truncate_hard_cap() (imported from truncation.rs at line 9).\n\n9. Return DocumentData (struct defined at line 47) with: source_type: SourceType::Note, source_id: note_id, project_id, author_username, labels, paths (as Vec<String>), labels_hash, paths_hash, created_at, updated_at, url, title, content_text (from hard_cap), content_hash, is_truncated, truncated_reason.\n\n## Files\n- MODIFY: src/documents/extractor.rs (add extract_note_document after extract_discussion_document, ~line 500)\n- MODIFY: src/documents/mod.rs (add extract_note_document to pub use exports, line 12 area)\n\n## TDD Anchor\nRED: test_note_document_basic_format — insert project, issue, discussion, note; extract; assert content contains [[Note]], author, parent reference.\nGREEN: Implement extract_note_document with SQL JOIN and content formatting.\nVERIFY: cargo test note_document_basic_format -- --nocapture\nTests: test_note_document_diffnote_with_path, test_note_document_inherits_parent_labels, test_note_document_mr_parent, test_note_document_system_note_returns_none, test_note_document_not_found, test_note_document_orphaned_discussion, test_note_document_hash_deterministic, test_note_document_empty_body, test_note_document_null_body\n\n## Acceptance Criteria\n- [ ] extract_note_document returns Some(DocumentData) for non-system notes\n- [ ] Returns None for system notes, not-found, orphaned discussions\n- [ ] Content includes structured [[Note]] header with all parent context fields\n- [ ] DiffNote includes file path and line info in content header\n- [ ] Labels inherited from parent issue/MR\n- [ ] URL format: parent_url#note_{gitlab_id}\n- [ ] Title format: \"Note by @{author} on {Issue|MR} #{iid}: {parent_title}\"\n- [ ] Hash is deterministic across calls (same input = same hash)\n- [ ] Empty/null body handled gracefully (use empty string)\n- [ ] truncate_hard_cap applied to content\n- [ ] All 10 tests pass\n\n## Dependency Context\n- Depends on NOTE-2B (bd-ef0u): SourceType::Note variant must exist to construct DocumentData\n\n## Edge Cases\n- NULL body: use empty string \"\" — not all notes have body text\n- Orphaned discussion: parent issue/MR deleted but discussion remains — return None\n- Very long note body: truncate_hard_cap handles this (2MB limit)\n- Note with no position data: skip path line in content header\n- Note on MR vs Issue: different label table (mr_labels vs issue_labels)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T17:02:01.802842Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:13:23.928224Z","closed_at":"2026-02-12T18:13:23.928173Z","close_reason":"Implemented by agent swarm","compaction_level":0,"original_size":0,"labels":["per-note","search"],"dependencies":[{"issue_id":"bd-18yh","depends_on_id":"bd-2ezb","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-18yh","depends_on_id":"bd-3cjp","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1au9","title":"Audit and improve test coverage across ingestion module","description":"During code reorganization, discovered that ingestion/issues.rs has only 4 tests covering passes_cursor_filter, while 10 production functions (~400 lines) are untested:\n\nUNTESTED FUNCTIONS in ingestion/issues.rs:\n- ingest_issues() - main async pipeline with cursor-based pagination, shutdown handling\n- process_single_issue() - transforms GitLab issue, wraps in transaction\n- process_issue_in_transaction() - DB upsert with ON CONFLICT, label/assignee/milestone association, dirty tracking\n- upsert_label_tx() - label upsert with INSERT OR IGNORE + created count tracking\n- link_issue_label_tx() - issue-label junction table insert\n- upsert_milestone_tx() - milestone upsert with RETURNING id\n- get_sync_cursor() - reads sync_cursors table for incremental sync\n- update_sync_cursor() - writes sync cursor with tie-breaker ID\n- get_issues_needing_discussion_sync() - identifies issues needing discussion refresh\n- parse_timestamp() - RFC3339 parsing with error wrapping\n\nLIKELY SIMILAR GAPS in sibling files:\n- ingestion/merge_requests.rs (479 lines) - parallel structure to issues.rs\n- ingestion/discussions.rs (469 lines prod code) - discussion upsert pipeline\n- ingestion/mr_discussions.rs (738 lines prod before tests) - MR discussion pipeline\n- ingestion/orchestrator.rs (1703 lines) - full pipeline orchestration\n\nThe ingestion module handles the most critical data path (GitLab API -> SQLite) yet relies primarily on integration-level orchestrator tests rather than unit tests for individual functions.\n\nPRIORITY AREAS:\n1. DB upsert logic with ON CONFLICT handling (data correctness)\n2. Cursor-based pagination (incremental sync correctness)\n3. Label/milestone/assignee association (relational integrity)\n4. Dirty tracker marking after upserts (document pipeline triggering)\n5. Discussion sync queue population (cascading sync correctness)\n6. Error handling paths (invalid timestamps, missing data)\n\nAPPROACH: Use in-memory SQLite (create_connection(Path::new(\":memory:\")) + run_migrations) for unit tests. See existing patterns in core/db_tests.rs and documents/regenerator_tests.rs.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-13T00:53:15.302370Z","created_by":"tayloreernisse","updated_at":"2026-02-13T00:53:15.305167Z","compaction_level":0,"original_size":0,"labels":["testing"]}
{"id":"bd-1b0n","title":"OBSERV: Print human-readable timing summary after interactive sync","description":"## Background\nInteractive users want a quick timing summary after sync completes. This is the human-readable equivalent of meta.stages in robot JSON. Gated behind IngestDisplay::show_text so it doesn't appear in -q, robot, or progress_only modes.\n\n## Approach\nAdd a function to format and print the timing summary, called from run_sync() after the pipeline completes:\n\n```rust\nfn print_timing_summary(stages: &[StageTiming], total_elapsed: Duration) {\n    eprintln!();\n    eprintln!(\"Sync complete in {:.1}s\", total_elapsed.as_secs_f64());\n    for stage in stages {\n        let dots = \".\".repeat(20_usize.saturating_sub(stage.name.len()));\n        eprintln!(\n            \"  {} {} {:.1}s ({} items{})\",\n            stage.name,\n            dots,\n            stage.elapsed_ms as f64 / 1000.0,\n            stage.items_processed,\n            if stage.errors > 0 { format!(\", {} errors\", stage.errors) } else { String::new() },\n        );\n    }\n}\n```\n\nCall in run_sync() (src/cli/commands/sync.rs), after pipeline and before return:\n```rust\nif display.show_text {\n    let stages = metrics_handle.extract_timings();\n    print_timing_summary(&stages, start.elapsed());\n}\n```\n\nOutput format per PRD Section 4.6.4:\n```\nSync complete in 45.2s\n  Ingest issues .... 12.3s (150 items, 42 discussions)\n  Ingest MRs ....... 18.9s (85 items, 1 error)\n  Generate docs .... 8.5s  (235 documents)\n  Embed ............ 5.5s  (1024 chunks)\n```\n\n## Acceptance Criteria\n- [ ] Interactive lore sync prints timing summary to stderr after completion\n- [ ] Summary shows total time and per-stage breakdown\n- [ ] lore -q sync does NOT print timing summary\n- [ ] Robot mode does NOT print timing summary (only JSON)\n- [ ] Error counts shown when non-zero\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/cli/commands/sync.rs (add print_timing_summary function, call after pipeline)\n\n## TDD Loop\nRED: test_timing_summary_format (capture stderr, verify format matches PRD example pattern)\nGREEN: Implement print_timing_summary, gate behind display.show_text\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- Empty stages (e.g., sync with no projects configured): print \"Sync complete in 0.0s\" with no stage lines\n- Very fast stages (<1ms): show \"0.0s\" not scientific notation\n- Stage names with varying lengths: dot padding keeps alignment readable","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T15:54:32.109882Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:32:52.558314Z","closed_at":"2026-02-04T17:32:52.558264Z","close_reason":"Added print_timing_summary with per-stage breakdown (name, elapsed, items, errors, rate limits), nested sub-stage support, gated behind metrics Option","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-1b0n","depends_on_id":"bd-1zj6","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1b0n","depends_on_id":"bd-3er","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1b50","title":"Update existing tests for new ScoringConfig fields","description":"## Background\nThe existing test test_expert_scoring_weights_are_configurable (who.rs:3551-3574) constructs a ScoringConfig with only the original 3 fields. After bd-2w1p adds 8 new fields, this test will not compile without ..Default::default().\n\n## Approach\nFind the test at who.rs:3551-3574. The flipped config construction at line 3567:\n```rust\nlet flipped = ScoringConfig {\n    author_weight: 5,\n    reviewer_weight: 30,\n    note_bonus: 1,\n};\n```\nChange to:\n```rust\nlet flipped = ScoringConfig {\n    author_weight: 5,\n    reviewer_weight: 30,\n    note_bonus: 1,\n    ..Default::default()\n};\n```\n\nAlso check default_scoring() helper at line 2451 — it calls ScoringConfig::default() which already works.\n\n### Important: Scope boundary\nThis bead ONLY handles ScoringConfig struct literal changes. The query_expert() function signature change (7 params -> 10 params) happens in bd-13q8 (Layer 3), which is responsible for updating all test callsites at that time.\n\n### Why existing assertions do not break:\nAll test data is inserted with now_ms(). With as_of_ms also at ~now_ms(), elapsed ~0ms, decay ~1.0. So integer-rounded scores are identical to the flat-weight model.\n\n## Acceptance Criteria\n- [ ] cargo test passes with zero assertion changes to existing test values\n- [ ] test_expert_scoring_weights_are_configurable compiles and passes\n- [ ] All other existing who tests pass unchanged\n- [ ] No new test code needed — only ..Default::default() additions\n- [ ] cargo check --all-targets clean\n\n## Files\n- MODIFY: src/cli/commands/who.rs (ScoringConfig literal at line 3567)\n\n## TDD Loop\nN/A — mechanical change, no new tests.\nVERIFY: cargo check --all-targets && cargo test -p lore -- test_expert_scoring_weights_are_configurable\n\n## Edge Cases\n- Search for ALL ScoringConfig { ... } literals in test module — there may be more than the one at line 3567\n- The default_scoring() helper at line 2451 uses ScoringConfig::default() — no change needed","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-09T17:00:45.084472Z","created_by":"tayloreernisse","updated_at":"2026-02-12T20:43:04.409277Z","closed_at":"2026-02-12T20:43:04.409239Z","close_reason":"Implemented by time-decay swarm: 3 agents, 12 tasks, 621 tests passing, all quality gates green","compaction_level":0,"original_size":0,"labels":["scoring","test"],"dependencies":[{"issue_id":"bd-1b50","depends_on_id":"bd-2w1p","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1b6k","title":"Epic: TUI Phase 5.5 — Reliability Test Pack","description":"## Background\nPhase 5.5 is a comprehensive reliability test suite covering race conditions, stress tests, property-based testing, and deterministic clock verification. These tests ensure the TUI is robust under adverse conditions (rapid input, concurrent writes, resize storms, backpressure).\n\n## Acceptance Criteria\n- [ ] Stale response drop tests pass\n- [ ] Sync cancel/resume tests pass\n- [ ] SQLITE_BUSY retry tests pass\n- [ ] Resize storm + rapid keypress tests pass without panic\n- [ ] Property tests for navigation invariants pass\n- [ ] Performance benchmark fixtures (S/M/L tiers) pass SLOs\n- [ ] Event fuzz tests: 10k traces with zero invariant violations\n- [ ] Deterministic clock/render tests produce identical output\n- [ ] 30-minute soak test: no panic, no deadlock, memory growth < 5%\n- [ ] Concurrent pagination/write race tests: no duplicate/skipped rows\n- [ ] Query cancellation race tests: no cross-task bleed, no stuck loading","status":"tombstone","priority":1,"issue_type":"epic","created_at":"2026-02-12T17:04:04.486702Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:28.292400Z","deleted_at":"2026-03-11T18:34:28.292395Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"epic","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-1b91","title":"CLI: show issue status display (human + robot)","description":"## Background\nOnce status data is in the DB, lore show issue needs to display it. Human view shows colored status text; robot view includes all 5 fields as JSON.\n\n## Approach\nAdd 5 fields to the IssueRow/IssueDetail/IssueDetailJson structs. Extend both find_issue SQL queries. Add status display line after State in human view. New style_with_hex() helper converts hex color to ANSI 256.\n\n## Files\n- src/cli/commands/show.rs\n\n## Implementation\n\nAdd to IssueRow (private struct):\n  status_name: Option<String>, status_category: Option<String>,\n  status_color: Option<String>, status_icon_name: Option<String>,\n  status_synced_at: Option<i64>\n\nUpdate BOTH find_issue SQL queries (with and without project filter) SELECT list — add after existing columns:\n  i.status_name, i.status_category, i.status_color, i.status_icon_name, i.status_synced_at\nColumn indices: status_name=12, status_category=13, status_color=14, status_icon_name=15, status_synced_at=16\n\nRow mapping (after milestone_title: row.get(11)?):\n  status_name: row.get(12)?, ..., status_synced_at: row.get(16)?\n\nAdd to IssueDetail (public struct) — same 5 fields\nAdd to IssueDetailJson — same 5 fields\nAdd to From<&IssueDetail> for IssueDetailJson — clone/copy fields\n\nHuman display in print_show_issue (after State line):\n  if let Some(status) = &issue.status_name {\n      let display = match &issue.status_category {\n          Some(cat) => format!(\"{status} ({})\", cat.to_ascii_lowercase()),\n          None => status.clone(),\n      };\n      println!(\"Status:   {}\", style_with_hex(&display, issue.status_color.as_deref()));\n  }\n\nNew helper:\n  fn style_with_hex<'a>(text: &'a str, hex: Option<&str>) -> console::StyledObject<&'a str>\n  Parses 6-char hex (strips #), converts via ansi256_from_rgb, falls back to unstyled\n\n## Acceptance Criteria\n- [ ] Human: \"Status:   In progress (in_progress)\" shown after State line\n- [ ] Status colored by hex -> ANSI 256\n- [ ] Status line omitted when status_name IS NULL\n- [ ] Robot: all 5 fields present as null when no status\n- [ ] Robot: status_synced_at is integer (ms epoch) or null\n- [ ] Both SQL queries updated (with and without project filter)\n- [ ] cargo check --all-targets passes\n\n## TDD Loop\nRED: No new dedicated test file — verify via cargo test show (existing tests should still pass)\nGREEN: Add fields, SQL columns, display logic\nVERIFY: cargo test show && cargo check --all-targets\n\n## Edge Cases\n- Two separate SQL strings in find_issue — BOTH must be updated identically\n- Column indices are positional — count carefully from 0\n- style_with_hex: hex.len() == 6 check after trimming # prefix\n- Invalid hex -> fall back to unstyled (no panic)\n- NULL hex color -> fall back to unstyled\n- clippy: use let-chain for combined if conditions (if hex.len() == 6 && let (...) = ...)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-11T06:42:16.215984Z","created_by":"tayloreernisse","updated_at":"2026-02-11T07:21:33.420281Z","closed_at":"2026-02-11T07:21:33.420236Z","close_reason":"Implemented by agent swarm — all quality gates pass (595 tests, 0 failures)","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1b91","depends_on_id":"bd-2y79","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1b91","depends_on_id":"bd-3dum","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1cb","title":"[CP0] gi doctor command - health checks","description":"## Background\n\ndoctor is the primary diagnostic command. It checks all system components and reports their status. Supports JSON output for scripting and CI integration. Must degrade gracefully - warn about optional components (Ollama) without failing.\n\nReference: docs/prd/checkpoint-0.md section \"gi doctor\"\n\n## Approach\n\n**src/cli/commands/doctor.ts:**\n\nPerforms 5 checks:\n1. **Config**: Load and validate config file\n2. **Database**: Open DB, verify pragmas, check schema version\n3. **GitLab**: Auth with token, verify connectivity\n4. **Projects**: Count configured vs resolved in DB\n5. **Ollama**: Ping embedding endpoint (optional - warn if unavailable)\n\n**DoctorResult interface:**\n```typescript\ninterface DoctorResult {\n  success: boolean;  // All required checks passed\n  checks: {\n    config: { status: 'ok' | 'error'; path?: string; error?: string };\n    database: { status: 'ok' | 'error'; path?: string; schemaVersion?: number; error?: string };\n    gitlab: { status: 'ok' | 'error'; url?: string; username?: string; error?: string };\n    projects: { status: 'ok' | 'error'; configured?: number; resolved?: number; error?: string };\n    ollama: { status: 'ok' | 'warning' | 'error'; url?: string; model?: string; error?: string };\n  };\n}\n```\n\n**Human-readable output (default):**\n```\ngi doctor\n\n  Config     ✓  Loaded from ~/.config/gi/config.json\n  Database   ✓  ~/.local/share/gi/data.db (schema v1)\n  GitLab     ✓  https://gitlab.example.com (authenticated as @johndoe)\n  Projects   ✓  2 configured, 2 resolved\n  Ollama     ⚠  Not running (semantic search unavailable)\n\nStatus: Ready (lexical search available, semantic search requires Ollama)\n```\n\n**JSON output (--json flag):**\nOutputs DoctorResult as JSON to stdout\n\n## Acceptance Criteria\n\n- [ ] Config check: shows path and validation status\n- [ ] Database check: shows path, schema version, pragma verification\n- [ ] GitLab check: shows URL and authenticated username\n- [ ] Projects check: shows configured count and resolved count\n- [ ] Ollama check: warns if not running, doesn't fail overall\n- [ ] success=true only if config, database, gitlab, projects all ok\n- [ ] --json outputs valid JSON matching DoctorResult interface\n- [ ] Exit 0 if success=true, exit 1 if any required check fails\n- [ ] Colors and symbols in human output (✓, ⚠, ✗)\n\n## Files\n\nCREATE:\n- src/cli/commands/doctor.ts\n- src/types/doctor.ts (DoctorResult interface)\n\n## TDD Loop\n\nN/A - diagnostic command, verify with manual testing:\n\n```bash\n# All good\ngi doctor\n\n# JSON output\ngi doctor --json | jq .\n\n# With missing Ollama\n# (just don't run Ollama - should show warning)\n\n# With bad config\nmv ~/.config/gi/config.json ~/.config/gi/config.json.bak\ngi doctor  # should show config error\n```\n\n## Edge Cases\n\n- Ollama timeout should be short (2s) - don't block on slow network\n- Ollama 404 (wrong model) vs connection refused (not running)\n- Database file exists but wrong schema version\n- Projects in config but not in database (init not run)\n- Token valid for user but project access revoked","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-24T16:09:51.435540Z","created_by":"tayloreernisse","updated_at":"2026-01-25T03:30:24.921206Z","closed_at":"2026-01-25T03:30:24.921041Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1cb","depends_on_id":"bd-13b","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1cb","depends_on_id":"bd-1l1","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1cb","depends_on_id":"bd-3ng","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1cb","depends_on_id":"bd-epj","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1cj0","title":"Epic: TUI Phase 0 — Toolchain Gate","description":"## Background\nPhase 0 is the hard gate for the TUI implementation. It validates that FrankenTUI (nightly Rust) can coexist with the stable lore workspace, that core infrastructure types compile and pass basic tests, and that terminal compatibility meets the bar. If Phase 0 fails, we evaluate alternatives before proceeding.\n\n## Acceptance Criteria\n- [ ] crates/lore-tui/ scaffold exists with Cargo.toml, rust-toolchain.toml, main.rs, lib.rs\n- [ ] cargo +stable check --workspace --all-targets passes for root workspace (lore-tui EXCLUDED)\n- [ ] cargo +nightly check --manifest-path crates/lore-tui/Cargo.toml --all-targets passes\n- [ ] FrankenTUI Model trait skeleton compiles and renders a hello-world frame\n- [ ] DbManager, Clock, safety, and core type modules compile with tests\n- [ ] Terminal compat smoke test passes in iTerm2 and tmux\n\n## Scope\nAll Phase 0 tasks are blockers for Phase 1 (Foundation).","status":"tombstone","priority":1,"issue_type":"epic","created_at":"2026-02-12T16:52:50.687401Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:28.935473Z","deleted_at":"2026-03-11T18:34:28.935468Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"epic","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-1cjx","title":"lore drift: detect discussion divergence from original intent","description":"## Background\nDetect when a discussion thread has evolved away from the original issue description. Surfaces hidden scope creep. No existing tool does this — not GitLab, Jira, Linear, or any CLI.\n\n## Current Infrastructure (Verified 2026-02-12)\n- Embeddings: nomic-embed-text model, 768 dimensions, stored in embedding_metadata + vec0 tables\n- OllamaClient::embed_batch() at src/embedding/ollama.rs:103 — batch embedding\n- notes table: 282K rows with body, author, created_at, is_system, discussion_id\n- issues table: description column contains original intent text\n- CHUNK_MAX_BYTES = 1500 bytes for embedding input\n- No `strip_markdown()` utility exists in the codebase — must be written (see Edge Cases)\n\n## Dependencies\nThis command is standalone. It only requires:\n- OllamaClient (already shipped at src/embedding/ollama.rs) for embedding computation\n- notes + discussions tables (already in DB since migration 001/004)\n- issues table (already in DB since migration 002)\n\nNo dependency on hybrid search (bd-1ksf) or per-note search (bd-2l3s). Drift embeds on-the-fly.\n\n## Algorithm\n\n### Step 1: Embed issue description\n```rust\nlet desc_text = issue.description.unwrap_or_default();\nif desc_text.len() < 20 {\n    // Too short for meaningful drift analysis\n    return Ok(DriftResponse::no_drift(\"Description too short for analysis\"));\n}\nlet desc_embedding = client.embed_batch(&[&desc_text]).await?[0].clone();\n```\n\n### Step 2: Get non-system notes chronologically\n```sql\nSELECT n.id, n.body, n.author_username, n.created_at\nFROM notes n\nJOIN discussions d ON n.discussion_id = d.id\nWHERE d.noteable_type = 'Issue' AND d.noteable_id = ?\n  AND n.is_system = 0\n  AND LENGTH(n.body) >= 20\nORDER BY n.created_at ASC\nLIMIT 200  -- cap for performance\n```\n\n### Step 3: Embed each note\n```rust\nlet note_texts: Vec<&str> = notes.iter().map(|n| n.body.as_str()).collect();\n// Batch in groups of 32 (BATCH_SIZE from embedding pipeline)\nlet note_embeddings = client.embed_batch(&note_texts).await?;\n```\n\n### Step 4: Compute cosine similarity curve\n```rust\n/// Cosine similarity between two embedding vectors.\n/// Returns value in [-1, 1] range; higher = more similar.\n/// Place in src/embedding/similarity.rs for reuse by related (bd-8con) and drift.\npub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {\n    debug_assert_eq!(a.len(), b.len(), \"embedding dimensions must match\");\n    let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();\n    let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();\n    let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();\n    if norm_a == 0.0 || norm_b == 0.0 {\n        return 0.0;  // zero vector = no similarity\n    }\n    dot / (norm_a * norm_b)\n}\n\nlet similarity_curve: Vec<SimilarityPoint> = notes.iter().zip(&note_embeddings)\n    .enumerate()\n    .map(|(i, (note, emb))| SimilarityPoint {\n        note_index: i,\n        note_id: note.id,\n        similarity: cosine_similarity(&desc_embedding, emb),\n        author: note.author.clone(),\n        created_at: note.created_at.clone(),\n    })\n    .collect();\n```\n\n### Step 5: Detect drift via sliding window\n```rust\nconst DEFAULT_THRESHOLD: f32 = 0.4;\nconst WINDOW_SIZE: usize = 3;\n\nfn detect_drift(curve: &[SimilarityPoint], threshold: f32) -> Option<&SimilarityPoint> {\n    if curve.len() < WINDOW_SIZE {\n        return None;  // need minimum 3 notes for window\n    }\n    for window in curve.windows(WINDOW_SIZE) {\n        let avg: f32 = window.iter().map(|p| p.similarity).sum::<f32>() / WINDOW_SIZE as f32;\n        if avg < threshold {\n            return Some(&window[0]);  // first note in drifting window\n        }\n    }\n    None\n}\n```\n\n### Step 6: Extract drift topics (simple term frequency v1)\n```rust\n/// Simple markdown stripping for embedding quality.\n/// Remove code blocks (```...```), inline code (`...`), links [text](url),\n/// block quotes (> ...), and HTML tags (<...>).\n/// This function must be written — no existing utility in the codebase.\nfn strip_markdown(text: &str) -> String {\n    // Phase 1: Remove fenced code blocks (```...```)\n    let re_code_block = regex::Regex::new(r\"(?s)```.*?```\").unwrap();\n    let text = re_code_block.replace_all(text, \"\");\n    // Phase 2: Remove inline code (`...`)\n    let re_inline = regex::Regex::new(r\"`[^`]+`\").unwrap();\n    let text = re_inline.replace_all(&text, \"\");\n    // Phase 3: Remove markdown links, keep text: [text](url) -> text\n    let re_link = regex::Regex::new(r\"\\[([^\\]]+)\\]\\([^)]+\\)\").unwrap();\n    let text = re_link.replace_all(&text, \"$1\");\n    // Phase 4: Remove block quotes\n    let text = text.lines()\n        .filter(|l| !l.trim_start().starts_with('>'))\n        .collect::<Vec<_>>()\n        .join(\"\\n\");\n    // Phase 5: Remove HTML tags\n    let re_html = regex::Regex::new(r\"<[^>]+>\").unwrap();\n    re_html.replace_all(&text, \"\").to_string()\n}\n\nfn extract_drift_topics(\n    notes_after_drift: &[Note],\n    description_words: &HashSet<String>,\n) -> Vec<String> {\n    let stopwords: HashSet<&str> = [\n        \"the\", \"a\", \"an\", \"is\", \"are\", \"was\", \"were\", \"be\", \"been\", \"being\",\n        \"have\", \"has\", \"had\", \"do\", \"does\", \"did\", \"will\", \"would\", \"could\",\n        \"should\", \"may\", \"might\", \"shall\", \"can\", \"need\", \"dare\", \"ought\",\n        \"used\", \"to\", \"of\", \"in\", \"for\", \"on\", \"with\", \"at\", \"by\", \"from\",\n        \"as\", \"into\", \"through\", \"during\", \"before\", \"after\", \"above\", \"below\",\n        \"between\", \"out\", \"off\", \"over\", \"under\", \"again\", \"further\", \"then\",\n        \"once\", \"here\", \"there\", \"when\", \"where\", \"why\", \"how\", \"all\", \"each\",\n        \"every\", \"both\", \"few\", \"more\", \"most\", \"other\", \"some\", \"such\", \"no\",\n        \"nor\", \"not\", \"only\", \"own\", \"same\", \"so\", \"than\", \"too\", \"very\",\n        \"just\", \"because\", \"but\", \"and\", \"or\", \"if\", \"while\", \"that\", \"this\",\n        \"these\", \"those\", \"it\", \"its\", \"they\", \"them\", \"their\", \"we\", \"our\",\n        \"you\", \"your\", \"he\", \"she\", \"his\", \"her\", \"what\", \"which\", \"who\",\n    ].into_iter().collect();\n\n    let mut term_freq: HashMap<String, usize> = HashMap::new();\n    for note in notes_after_drift {\n        let body = strip_markdown(&note.body);\n        for word in body.split_whitespace() {\n            let word = word.to_lowercase()\n                .trim_matches(|c: char| !c.is_alphanumeric())\n                .to_string();\n            if word.len() >= 3\n                && !stopwords.contains(word.as_str())\n                && !description_words.contains(&word)\n            {\n                *term_freq.entry(word).or_default() += 1;\n            }\n        }\n    }\n\n    let mut ranked: Vec<_> = term_freq.into_iter().collect();\n    ranked.sort_by(|a, b| b.1.cmp(&a.1));\n    ranked.into_iter().take(3).map(|(word, _)| word).collect()\n}\n```\n\nNOTE: The `regex` crate is likely already a dependency (check Cargo.toml). If not, add it. Consider compiling regexes once with `lazy_static!` or `std::sync::LazyLock` instead of in-function `Regex::new()`.\n\n## Robot Mode Output Schema\n```json\n{\n  \"ok\": true,\n  \"data\": {\n    \"entity\": { \"type\": \"issue\", \"iid\": 3864, \"title\": \"...\" },\n    \"drift_detected\": true,\n    \"threshold\": 0.4,\n    \"drift_point\": {\n      \"note_index\": 12,\n      \"note_id\": 456,\n      \"author\": \"devname\",\n      \"created_at\": \"2026-01-20T...\",\n      \"similarity\": 0.32\n    },\n    \"drift_topics\": [\"ingestion\", \"maintenance\", \"lubrication\"],\n    \"similarity_curve\": [\n      { \"note_index\": 0, \"similarity\": 0.91, \"author\": \"...\", \"created_at\": \"...\" },\n      { \"note_index\": 1, \"similarity\": 0.85, \"author\": \"...\", \"created_at\": \"...\" }\n    ],\n    \"recommendation\": \"Consider splitting: notes after #12 discuss ingestion, maintenance, lubrication -- topics not in original description\"\n  },\n  \"meta\": { \"elapsed_ms\": 1500, \"notes_analyzed\": 25, \"description_tokens\": 150 }\n}\n```\n\n## Clap Registration\n```rust\n// In src/main.rs Commands enum, add:\nDrift {\n    /// Entity type: \"issues\" (MRs not supported in v1)\n    entity_type: String,\n    /// Entity IID\n    iid: i64,\n    /// Similarity threshold for drift detection (0.0-1.0, default 0.4)\n    #[arg(long, default_value = \"0.4\")]\n    threshold: f32,\n    /// Scope to project (fuzzy match)\n    #[arg(short, long)]\n    project: Option<String>,\n},\n```\n\n## TDD Loop\nRED: Tests in src/cli/commands/drift.rs:\n- test_cosine_similarity_identical: same vector -> 1.0\n- test_cosine_similarity_orthogonal: orthogonal vectors -> 0.0\n- test_cosine_similarity_zero_vector: zero vector -> 0.0 (not NaN)\n- test_drift_detected_when_notes_diverge: mock embeddings where first 5 notes are similar (>0.8) to desc, last 5 are dissimilar (<0.3), assert drift_detected=true\n- test_no_drift_on_consistent_discussion: all notes similar to desc (>0.6), assert drift_detected=false\n- test_drift_point_is_first_divergent: assert drift_point.note_index is the first note in the first sub-threshold window\n- test_drift_topics_exclude_original_terms: terms from description body should NOT appear in drift_topics\n- test_single_note: assert drift_detected=false (need min 3 notes)\n- test_empty_description: assert response with \"Description too short for analysis\" message\n- test_strip_markdown_code_blocks: verify fenced code blocks removed\n- test_strip_markdown_preserves_text: verify plain text preserved\n\nGREEN: Implement drift command with cosine_similarity + sliding window + topic extraction\n\nVERIFY:\n```bash\ncargo test drift:: && cargo clippy --all-targets -- -D warnings\ncargo run --release -- -J drift issues 3864 | jq '.data.drift_detected'\n```\n\n## Acceptance Criteria\n- [ ] lore drift issues N computes similarity curve between description and notes\n- [ ] Drift detected when sliding window of 3 notes averages below threshold\n- [ ] Drift topics extracted from divergent notes (top 3 terms not in description)\n- [ ] --threshold flag to adjust sensitivity (default 0.4)\n- [ ] Robot mode returns structured analysis with similarity_curve array\n- [ ] Human mode shows visual indication (similarity bar or sparkline per note)\n- [ ] Suggests splitting when drift detected\n- [ ] Performance: <2s for issue with 100 notes (mostly embedding time)\n- [ ] Command registered in main.rs and robot-docs\n- [ ] cosine_similarity function has its own unit tests\n- [ ] strip_markdown function has its own unit tests\n\n## Edge Cases\n- Empty description: return early with message \"Description too short for analysis\"\n- Single note: drift_detected = false, similarity_curve has 1 entry\n- Very short notes (<20 chars): filtered out in SQL query\n- All notes by same author: still valid analysis (self-drift is real)\n- Notes that are mostly quotes/code blocks: strip_markdown before embedding (remove ``` blocks, > quotes)\n- Issue with 500+ notes: SQL LIMIT 200 on notes, note in meta that analysis is partial\n- Ollama unavailable: exit code 14 with message (drift requires embedding computation)\n- No stored note embeddings: always embed on-the-fly (drift needs to compare against description, not stored embeddings)\n- Embedding dimension mismatch: assert desc and note embeddings have same length (768 for nomic-embed-text)\n- Regex compilation: use LazyLock or lazy_static to avoid recompiling regexes on every call\n\n## Files to Create/Modify\n- NEW: src/cli/commands/drift.rs (main command implementation)\n- NEW: src/embedding/similarity.rs (cosine_similarity utility, reusable by bd-8con)\n- src/embedding/mod.rs (export similarity module)\n- src/cli/commands/mod.rs (add pub mod drift; re-export)\n- src/main.rs (register Drift subcommand in Commands enum, add handle_drift fn)","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-02-12T15:47:40.232427Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:49:02.922951Z","closed_at":"2026-02-12T16:49:02.922901Z","close_reason":"Drift detection command implemented: cosine similarity curve, sliding window, topic extraction, human+robot output","compaction_level":0,"original_size":0,"labels":["cli-imp","intelligence"],"dependencies":[{"issue_id":"bd-1cjx","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1cl9","title":"Epic: TUI Phase 2 — Core Screens","description":"## Background\nPhase 2 implements the five core screens: Dashboard, Issue List, Issue Detail, MR List, and MR Detail. These screens cover the primary read workflows. Each screen has a state struct, view function, and action query bridge. The entity table and filter bar widgets are shared across list screens.\n\n## Acceptance Criteria\n- [ ] Dashboard renders project overview with stats, recent activity, sync status\n- [ ] Issue List supports keyset pagination, filtering, sorting, and Quick Peek\n- [ ] Issue Detail shows progressive hydration (metadata, discussions, cross-refs)\n- [ ] MR List mirrors Issue List patterns with MR-specific columns\n- [ ] MR Detail shows file changes, diff discussions, and general discussions\n- [ ] All screens use TaskSupervisor for data loading with stale-result guards\n- [ ] Navigation between screens preserves state\n\n## Scope\nBlocked by Phase 1 (Foundation). Blocks Phase 2.5 (Vertical Slice Gate).","status":"tombstone","priority":1,"issue_type":"epic","created_at":"2026-02-12T16:57:23.090933Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:28.783665Z","deleted_at":"2026-03-11T18:34:28.783658Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"epic","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-1d5","title":"[CP1] GitLab client pagination methods","description":"Add async generator methods for paginated GitLab API calls.\n\nMethods to add to src/gitlab/client.ts:\n- paginateIssues(gitlabProjectId, updatedAfter?) → AsyncGenerator<GitLabIssue>\n- paginateIssueDiscussions(gitlabProjectId, issueIid) → AsyncGenerator<GitLabDiscussion>\n- requestWithHeaders<T>(path) → { data: T, headers: Headers }\n\nImplementation:\n- Use scope=all, state=all for issues\n- Order by updated_at ASC\n- Follow X-Next-Page header until empty/absent\n- Apply cursor rewind (subtract cursorRewindSeconds) for tuple semantics\n- Fall back to empty-page detection if headers missing\n\nFiles: src/gitlab/client.ts\nTests: tests/unit/pagination.test.ts\nDone when: Pagination handles multiple pages and respects cursors","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:19:43.069869Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.156881Z","closed_at":"2026-01-25T15:21:35.156881Z","deleted_at":"2026-01-25T15:21:35.156877Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-1d6z","title":"Implement discussion tree + cross-reference widgets","description":"## Background\nThe discussion tree renders threaded conversations from GitLab issues/MRs using FrankenTUI's Tree widget. Cross-references show linked entities (closing MRs, related issues) as navigable links. Both are used in Issue Detail and MR Detail views.\n\n## Approach\nDiscussion Tree (view/common/discussion_tree.rs):\n- Wraps ftui Tree widget with TreePersistState for expand/collapse persistence\n- Tree structure: top-level discussions as roots, notes within discussion as children\n- Each node renders: author, timestamp (relative via Clock), note body (sanitized)\n- System notes rendered with muted style\n- Diff notes show file path + line reference\n- Keyboard: j/k navigate, Enter expand/collapse, Space toggle thread\n- Expand-on-demand: thread bodies loaded only when expanded (progressive hydration phase 3)\n\nCross-Reference (view/common/cross_ref.rs):\n- CrossRefWidget: renders list of entity references with type icon and navigable links\n- CrossRef struct: kind (ClosingMR, RelatedIssue, MentionedIn), entity_key (EntityKey), label (String)\n- Enter on a cross-ref navigates to that entity (pushes nav stack)\n- Renders as: \"Closing MR !42: Fix authentication flow\" with colored kind indicator\n\n## Acceptance Criteria\n- [ ] Discussion tree renders top-level discussions as expandable nodes\n- [ ] Notes within discussion shown as children with indentation\n- [ ] System notes visually distinguished (muted color)\n- [ ] Diff notes show file path context\n- [ ] Timestamps use injected Clock for deterministic rendering\n- [ ] All note text sanitized via sanitize_for_terminal()\n- [ ] Cross-references render with entity type icons\n- [ ] Enter on cross-ref navigates to entity detail\n- [ ] Tree state persists across navigation (expand/collapse remembered)\n\n## Files\n- CREATE: crates/lore-tui/src/view/common/discussion_tree.rs\n- CREATE: crates/lore-tui/src/view/common/cross_ref.rs\n\n## TDD Anchor\nRED: Write test_cross_ref_entity_key that creates a CrossRef with EntityKey::mr(1, 42), asserts kind and key are correct.\nGREEN: Implement CrossRef struct.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_cross_ref\n\n## Edge Cases\n- Deeply nested discussions (rare in GitLab but possible): limit indent depth to 4 levels\n- Very long note bodies: wrap text within tree node area\n- Empty discussions (resolved with no notes): show \"[resolved]\" indicator\n- Cross-references to entities not in local DB: show as non-navigable text\n\n## Dependency Context\nUses sanitize_for_terminal() from \"Implement terminal safety module\" task.\nUses Clock for timestamps from \"Implement Clock trait\" task.\nUses EntityKey, Screen from \"Implement core types\" task.\nUses NavigationStack from \"Implement NavigationStack\" task.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T16:58:49.765694Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:21.831087Z","deleted_at":"2026-03-11T18:34:21.831083Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-1df9","title":"Epic: TUI Phase 4 — Operations","description":"## Background\nPhase 4 adds operational screens: Sync (real-time progress + post-sync summary), Doctor/Stats (health checks), and CLI integration (lore tui command for binary delegation). The Sync screen is the most complex — it needs real-time streaming progress with backpressure handling.\n\n## Acceptance Criteria\n- [ ] Sync screen shows real-time progress during sync with per-lane indicators\n- [ ] Sync summary shows exact changed entities after completion\n- [ ] Doctor screen shows environment health checks\n- [ ] Stats screen shows database statistics\n- [ ] CLI integration: lore tui launches lore-tui binary via runtime delegation","status":"tombstone","priority":1,"issue_type":"epic","created_at":"2026-02-12T17:01:44.603447Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:28.464677Z","deleted_at":"2026-03-11T18:34:28.464670Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"epic","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-1elx","title":"Implement run_embed_for_document_ids scoped embedding","description":"## Background\n\nCurrently `embed_documents()` in `src/embedding/pipeline.rs` uses `find_pending_documents()` to discover ALL documents that need embedding (no existing embedding, changed content_hash, or model mismatch). The surgical sync pipeline needs a scoped variant that only embeds specific document IDs — the ones returned by the scoped doc regeneration step (bd-hs6j).\n\nThe existing `embed_page()` private function handles the actual embedding work for a batch of `PendingDocument` structs. It calls `split_into_chunks`, sends batches to the OllamaClient, and writes embeddings + metadata to the DB. The scoped function can reuse this by constructing `PendingDocument` structs from the provided document IDs.\n\nKey types:\n- `PendingDocument { document_id: i64, content_text: String, content_hash: String }` (from `change_detector.rs`)\n- `EmbedResult { chunks_embedded, docs_embedded, failed, skipped }` (pipeline.rs:21)\n- `OllamaClient` for the actual embedding API calls\n- `ShutdownSignal` for cancellation support\n\n## Approach\n\nAdd `embed_documents_by_ids()` to `src/embedding/pipeline.rs`:\n\n```rust\npub struct EmbedForIdsResult {\n    pub chunks_embedded: usize,\n    pub docs_embedded: usize,\n    pub failed: usize,\n    pub skipped: usize,\n}\n\npub async fn embed_documents_by_ids(\n    conn: &Connection,\n    client: &OllamaClient,\n    model_name: &str,\n    concurrency: usize,\n    document_ids: &[i64],\n    signal: &ShutdownSignal,\n) -> Result<EmbedForIdsResult>\n```\n\nImplementation:\n1. If `document_ids` is empty, return immediately with zero counts.\n2. Load `PendingDocument` structs for the specified IDs. Query: `SELECT id, content_text, content_hash FROM documents WHERE id IN (...)`. Filter out documents that already have current embeddings (same content_hash, model, dims, chunk_max_bytes) — reuse the LEFT JOIN logic from `find_pending_documents` but with `WHERE d.id IN (?)` instead of `WHERE d.id > ?`.\n3. If no documents need embedding after filtering, return with skipped=len.\n4. Chunk into pages of `DB_PAGE_SIZE` (500).\n5. For each page, call `embed_page()` (reuse existing private function) within a SAVEPOINT.\n6. Handle cancellation via `signal.is_cancelled()` between pages.\n\nAlternative simpler approach: load all specified doc IDs into a temp table or use a parameterized IN clause, then let `embed_page` process them. Since the list is typically small (1-5 documents for surgical sync), a single page call suffices.\n\nExport from `src/embedding/mod.rs` if not already pub.\n\n## Acceptance Criteria\n\n- [ ] `embed_documents_by_ids` only embeds the specified document IDs, not all pending documents\n- [ ] Documents already embedded with current content_hash + model are skipped (not re-embedded)\n- [ ] Empty document_ids input returns immediately with zero counts\n- [ ] Cancellation via ShutdownSignal is respected between pages\n- [ ] SAVEPOINT/ROLLBACK semantics match existing `embed_documents` for data integrity\n- [ ] Ollama errors for individual documents are counted as failed, not fatal\n- [ ] Function is pub for use by orchestration (bd-1i4i)\n\n## Files\n\n- `src/embedding/pipeline.rs` (add new function + result struct)\n- `src/embedding/mod.rs` (export if needed)\n\n## TDD Anchor\n\nTests in `src/embedding/pipeline_tests.rs` (or new `src/embedding/scoped_embed_tests.rs`):\n\n```rust\n#[tokio::test]\nasync fn test_embed_by_ids_only_embeds_specified_docs() {\n    let conn = setup_test_db_with_documents();\n    let mock = MockServer::start().await;\n    setup_ollama_mock(&mock).await;\n    let client = OllamaClient::new(&mock.uri());\n\n    // Insert 2 documents: A (id=1) and B (id=2)\n    insert_test_document(&conn, 1, \"Content A\", \"hash_a\");\n    insert_test_document(&conn, 2, \"Content B\", \"hash_b\");\n\n    let signal = ShutdownSignal::new();\n    let result = embed_documents_by_ids(\n        &conn, &client, \"nomic-embed-text\", 1,\n        &[1],  // Only embed doc 1\n        &signal,\n    ).await.unwrap();\n\n    assert_eq!(result.docs_embedded, 1);\n    // Verify doc 1 has embeddings\n    let count: i64 = conn.query_row(\n        \"SELECT COUNT(*) FROM embedding_metadata WHERE document_id = 1\",\n        [], |r| r.get(0),\n    ).unwrap();\n    assert!(count > 0);\n    // Verify doc 2 has NO embeddings\n    let count_b: i64 = conn.query_row(\n        \"SELECT COUNT(*) FROM embedding_metadata WHERE document_id = 2\",\n        [], |r| r.get(0),\n    ).unwrap();\n    assert_eq!(count_b, 0);\n}\n\n#[tokio::test]\nasync fn test_embed_by_ids_skips_already_embedded() {\n    let conn = setup_test_db_with_documents();\n    let mock = MockServer::start().await;\n    setup_ollama_mock(&mock).await;\n    let client = OllamaClient::new(&mock.uri());\n\n    insert_test_document(&conn, 1, \"Content A\", \"hash_a\");\n    let signal = ShutdownSignal::new();\n\n    // Embed once\n    embed_documents_by_ids(&conn, &client, \"nomic-embed-text\", 1, &[1], &signal).await.unwrap();\n    // Embed again with same hash — should skip\n    let result = embed_documents_by_ids(\n        &conn, &client, \"nomic-embed-text\", 1, &[1], &signal,\n    ).await.unwrap();\n    assert_eq!(result.docs_embedded, 0);\n    assert_eq!(result.skipped, 1);\n}\n\n#[tokio::test]\nasync fn test_embed_by_ids_empty_input() {\n    let conn = setup_test_db_with_documents();\n    let mock = MockServer::start().await;\n    let client = OllamaClient::new(&mock.uri());\n    let signal = ShutdownSignal::new();\n\n    let result = embed_documents_by_ids(\n        &conn, &client, \"nomic-embed-text\", 1, &[], &signal,\n    ).await.unwrap();\n    assert_eq!(result.docs_embedded, 0);\n    assert_eq!(result.chunks_embedded, 0);\n}\n\n#[tokio::test]\nasync fn test_embed_by_ids_respects_cancellation() {\n    let conn = setup_test_db_with_documents();\n    let mock = MockServer::start().await;\n    // Use delayed response to allow cancellation\n    setup_slow_ollama_mock(&mock).await;\n    let client = OllamaClient::new(&mock.uri());\n\n    insert_test_document(&conn, 1, \"Content A\", \"hash_a\");\n    let signal = ShutdownSignal::new();\n    signal.cancel();  // Pre-cancel\n\n    let result = embed_documents_by_ids(\n        &conn, &client, \"nomic-embed-text\", 1, &[1], &signal,\n    ).await.unwrap();\n    assert_eq!(result.docs_embedded, 0);\n}\n```\n\n## Edge Cases\n\n- Document ID that does not exist in the documents table: query returns no rows, skipped silently.\n- Document with empty `content_text`: `split_into_chunks` may return 0 chunks, counted as skipped.\n- Ollama server unreachable: returns `OllamaUnavailable` error. Must not leave partial embeddings (SAVEPOINT rollback).\n- Very long document (>1500 bytes): gets chunked into multiple chunks by `split_into_chunks`. All chunks for one document must be embedded atomically.\n- Document already has embeddings but with different model: content_hash check passes but model mismatch detected — should re-embed.\n- Concurrent calls with overlapping document_ids: SAVEPOINT isolation prevents conflicts, last writer wins on embedding_metadata upsert.\n\n## Dependency Context\n\n- **Blocked by bd-hs6j**: Gets `document_ids` from scoped doc regeneration output\n- **Blocks bd-1i4i**: Orchestration function calls this as the final step of surgical sync\n- **Blocks bd-3jqx**: Integration tests verify embed isolation (only surgical docs get embedded)\n- **Uses existing internals**: `embed_page`, `PendingDocument`, `split_into_chunks`, `OllamaClient`, `ShutdownSignal`","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-17T19:16:43.680009Z","created_by":"tayloreernisse","updated_at":"2026-02-18T21:04:58.570513Z","closed_at":"2026-02-18T21:04:58.570467Z","close_reason":"Completed: all implementation work done, code reviewed, tests passing","compaction_level":0,"original_size":0,"labels":["surgical-sync"],"dependencies":[{"issue_id":"bd-1elx","depends_on_id":"bd-1i4i","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1ep","title":"Wire resource event fetching into sync pipeline","description":"## Background\nAfter issue/MR primary ingestion and discussion fetch, changed entities need resource_events jobs enqueued and drained. This is the integration point that connects the queue (bd-tir), API client (bd-sqw), DB upserts (bd-1uc), and config flag (bd-2e8).\n\n## Approach\nModify the sync pipeline to add two new phases after discussion sync:\n\n**Phase 1 — Enqueue during ingestion:**\nIn src/ingestion/orchestrator.rs, after each entity upsert (issue or MR), call:\n```rust\nif config.sync.fetch_resource_events {\n    enqueue_job(conn, project_id, \"issue\", iid, local_id, \"resource_events\", None)?;\n}\n// For MRs, also enqueue mr_closes_issues (always) and mr_diffs (when fetchMrFileChanges)\n```\n\nThe \"changed entity\" detection uses the existing dirty tracker: if an entity was inserted or updated during this sync run, it gets enqueued. On --full sync, all entities are enqueued.\n\n**Phase 2 — Drain dependent queue:**\nAdd a new drain step in src/cli/commands/sync.rs (or new src/core/drain.rs), called after discussion sync:\n```rust\npub async fn drain_dependent_queue(\n    conn: &Connection,\n    client: &GitLabClient,\n    config: &Config,\n    progress: Option<ProgressCallback>,\n) -> Result<DrainResult>\n```\n\nFlow:\n1. reclaim_stale_locks(conn, config.sync.stale_lock_minutes)\n2. Loop: claim_jobs(conn, \"resource_events\", batch_size=10)\n3. For each job:\n   a. Fetch 3 event types via client (fetch_issue_state_events etc.)\n   b. Store via upsert functions (upsert_state_events etc.)\n   c. complete_job(conn, job.id) on success\n   d. fail_job(conn, job.id, error_msg) on failure\n4. Report progress: \"Fetching resource events... [N/M]\"\n5. Repeat until no more claimable jobs\n\n**Progress reporting:**\nAdd new ProgressEvent variants:\n```rust\nResourceEventsFetchStart { total: usize },\nResourceEventsFetchProgress { completed: usize, total: usize },\nResourceEventsFetchComplete { fetched: usize, failed: usize },\n```\n\n## Acceptance Criteria\n- [ ] Full sync enqueues resource_events jobs for all issues and MRs\n- [ ] Incremental sync only enqueues for entities changed since last sync\n- [ ] --no-events prevents enqueueing resource_events jobs\n- [ ] Drain step fetches all 3 event types per entity\n- [ ] Successful fetches stored and job completed\n- [ ] Failed fetches recorded with error, job retried on next sync\n- [ ] Stale locks reclaimed at drain start\n- [ ] Progress displayed: \"Fetching resource events... [N/M]\"\n- [ ] Robot mode progress suppressed (quiet mode)\n\n## Files\n- src/ingestion/orchestrator.rs (add enqueue calls during upsert)\n- src/cli/commands/sync.rs (add drain step after discussions)\n- src/core/drain.rs (new, optional — or inline in sync.rs)\n\n## TDD Loop\nRED: tests/sync_pipeline_tests.rs (or extend existing):\n- `test_sync_enqueues_resource_events_for_changed_entities` - mock sync, verify jobs enqueued\n- `test_sync_no_events_flag_skips_enqueue` - verify no jobs when flag false\n- `test_drain_completes_jobs_on_success` - mock API responses, verify jobs deleted\n- `test_drain_fails_jobs_on_error` - mock API failure, verify job attempts incremented\n\nNote: Full pipeline integration tests may need mock HTTP server. Start with unit tests on enqueue/drain logic using the real DB with mock API responses.\n\nGREEN: Implement enqueue hooks + drain step\n\nVERIFY: `cargo test sync -- --nocapture && cargo build`\n\n## Edge Cases\n- Entity deleted between enqueue and drain: API returns 404, fail_job with \"entity not found\" (retry won't help but backoff caps it)\n- Rate limiting during drain: GitLabRateLimited error should fail_job with retry (transient)\n- Network error during drain: GitLabNetworkError should fail_job with retry\n- Multiple sync runs competing: locked_at prevents double-processing; stale lock reclaim handles crashes\n- Drain should have a max iterations guard to prevent infinite loop if jobs keep failing and being retried within the same run","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:31:57.334527Z","created_by":"tayloreernisse","updated_at":"2026-02-03T17:46:51.336138Z","closed_at":"2026-02-03T17:46:51.336077Z","close_reason":"Implemented: enqueue + drain resource events in orchestrator, wired counts through ingest→sync pipeline, added progress events, 4 new tests, all 209 tests pass","compaction_level":0,"original_size":0,"labels":["gate-1","phase-b","pipeline"],"dependencies":[{"issue_id":"bd-1ep","depends_on_id":"bd-1uc","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1ep","depends_on_id":"bd-2e8","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1ep","depends_on_id":"bd-2zl","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1ep","depends_on_id":"bd-sqw","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1ep","depends_on_id":"bd-tir","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1f1f","title":"Implement username resolution function","description":"## Background\n`lore me` needs deterministic username resolution before any DB query executes. Source precedence is fixed (`--user` > `config.gitlab.username`), and missing username is a usage failure (exit 2), not an internal error.\n\nThis bead also defines the reusable runtime usage-error pathway for me-related commands.\n\n## Approach\n### 1. Add explicit usage error plumbing\nIn `src/core/error.rs`:\n- Add `ErrorCode::UsageError`.\n- Display string: `USAGE_ERROR`.\n- Exit code mapping: `2`.\n- Add `LoreError::UsageError(String)`.\n- Map `LoreError::UsageError(_)` to `ErrorCode::UsageError` in `code()`.\n\n### 2. Implement resolver\nIn `src/cli/commands/me/mod.rs`:\n```rust\npub fn resolve_username(cli_user: Option<&str>, config: &Config) -> Result<String>\n```\nRules:\n- Trim leading/trailing whitespace before validation and return.\n- CLI value wins if trimmed value is non-empty.\n- Otherwise use trimmed `config.gitlab.username` if non-empty.\n- Otherwise return `LoreError::UsageError` with actionable remediation text.\n\n### 3. Preserve case\nDo not lowercase or case-normalize username; only trim surrounding whitespace.\n\n## Acceptance Criteria\n- [ ] `ErrorCode::UsageError` exists and renders as `USAGE_ERROR`\n- [ ] `ErrorCode::UsageError` maps to exit code `2`\n- [ ] `LoreError::UsageError(String)` exists\n- [ ] `LoreError::UsageError(_)` maps to `ErrorCode::UsageError`\n- [ ] `resolve_username(Some(\"cli_user\"), config)` returns `\"cli_user\"` regardless of config\n- [ ] `resolve_username(None, config_with_username)` returns config username\n- [ ] `resolve_username(None, config_without_username)` returns `LoreError::UsageError`\n- [ ] `resolve_username(Some(\"  jdoe  \"), config)` returns `\"jdoe\"`\n- [ ] Empty/whitespace-only CLI value falls through to config\n- [ ] Empty/whitespace-only config username is treated as missing\n- [ ] Error text includes both config and CLI remediation paths\n\n## Files\n- MODIFY: `src/core/error.rs`\n- MODIFY: `src/cli/commands/me/mod.rs`\n\n## TDD Anchor\nRED:\n- `test_username_cli_overrides_config`\n- `test_username_from_config_when_cli_missing`\n- `test_username_missing_returns_usage_error`\n- `test_username_whitespace_trimmed`\n- `test_usage_error_code_is_exit_2`\n\nGREEN:\n- Add usage-error plumbing.\n- Implement resolver with trimmed, case-preserving precedence.\n\nVERIFY:\n- `cargo test resolve_username`\n- `cargo test usage_error_code`\n\n## Edge Cases\n- Input containing only whitespace (`\"   \"`) is treated as missing.\n- Mixed-case usernames remain unchanged after resolution.\n\n## Dependency Context\nConsumes config field from `bd-qpk3` and provides error semantics used by:\n- `bd-a7ba` (scope usage errors)\n- `bd-1vv8` (invalid `--since` usage errors)\n- `bd-32aw` (error-path compliance)\n\nDependencies:\n  -> bd-qpk3 (blocks) - Add gitlab.username config field\n\nDependents:\n  <- bd-utt4 (blocks) - Define MeArgs struct and register me subcommand","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-19T19:35:16.892995Z","created_by":"tayloreernisse","updated_at":"2026-02-20T16:09:13.045072Z","closed_at":"2026-02-20T16:09:13.045024Z","close_reason":"Implemented by lore-me agent swarm","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1f1f","depends_on_id":"bd-qpk3","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1f5b","title":"Extract query functions from CLI to shared pub API","description":"## Background\nThe TUI's action.rs bridges to existing CLI query functions. To avoid code duplication, the existing query_* functions in cli/commands/*.rs need to be made pub so action.rs can call them. This is the minimal refactoring approach — no new domain query layer, just visibility changes.\n\n## Approach\nModify existing CLI command files to extract and expose query functions:\n- src/cli/commands/list.rs: make query_issues(), query_mrs() pub\n- src/cli/commands/show.rs: make query_issue_detail(), query_mr_detail() pub\n- src/cli/commands/who.rs: make query_experts(), query_workload(), query_reviews(), query_active(), query_overlap() pub\n- src/cli/commands/search.rs: make run_search_query() pub\n\nThese functions should take Connection + parameters and return Result<T, LoreError>. Any CLI-specific formatting logic stays in the CLI; only the pure query logic is extracted.\n\nIf a function mixes query + format logic, split it:\n1. query_X() -> Result<Vec<T>, LoreError> (pure query, made pub)\n2. format_X(data: &[T]) -> String (CLI-only formatting, stays private)\n\n## Acceptance Criteria\n- [ ] query_issues() is pub and callable from outside cli module\n- [ ] query_mrs() is pub and callable\n- [ ] query_issue_detail() and query_mr_detail() are pub\n- [ ] query_experts() and other who functions are pub\n- [ ] run_search_query() is pub\n- [ ] Existing CLI behavior unchanged (no functional changes)\n- [ ] All extracted functions take Connection + params, return Result\n- [ ] cargo test passes (no regressions)\n\n## Files\n- MODIFY: src/cli/commands/list.rs (make query functions pub)\n- MODIFY: src/cli/commands/show.rs (make query functions pub)\n- MODIFY: src/cli/commands/who.rs (make query functions pub)\n- MODIFY: src/cli/commands/search.rs (make search query pub)\n\n## TDD Anchor\nRED: Write test in lore-tui action.rs that calls crate::cli::commands::list::query_issues() and asserts it compiles.\nGREEN: Make query_issues pub.\nVERIFY: cargo test --all-targets\n\n## Edge Cases\n- Some query functions may have Config dependencies — extract only the Connection-dependent parts\n- Visibility changes may expose functions that weren't designed for external use — review signatures\n- This is a non-breaking change (additive pub visibility)\n\n## Dependency Context\nThis modifies the main lore crate (stable Rust).\nRequired by all TUI action.rs query bridge functions.\nMust be completed before TUI can fetch real data.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T17:06:25.285403Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:21.912755Z","deleted_at":"2026-03-11T18:34:21.912749Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-1fgr","title":"Implement reviewing MRs query for me command","description":"## Background\nThe third dashboard section shows MRs where the user is a reviewer. Reviewers tracked in `mr_reviewers(merge_request_id, username)`. Labels in `mr_labels` junction table. This section shows MRs by OTHER people that the user needs to review, so it includes the MR author's username.\n\n## Approach\nAdd to `src/cli/commands/me/mrs.rs` (same file as authored MRs):\n```rust\npub fn fetch_my_reviewing_mrs(\n    conn: &Connection,\n    username: &str,\n    scope: &ProjectScope,\n) -> Result<Vec<MeMrReviewing>> {\n    let project_filter = match scope {\n        ProjectScope::Single(id) => format!(\"AND p.id = {id}\"),\n        ProjectScope::All => String::new(),\n    };\n    let sql = format!(r#\"\n        SELECT p.path_with_namespace, mr.iid, mr.title, mr.state,\n               mr.author_username, mr.draft, mr.updated_at,\n               mr.web_url,\n               (SELECT GROUP_CONCAT(l.name, ',')\n                FROM mr_labels ml\n                JOIN labels l ON l.id = ml.label_id\n                WHERE ml.merge_request_id = mr.id) AS label_names\n        FROM merge_requests mr\n        JOIN mr_reviewers mrr ON mrr.merge_request_id = mr.id\n        JOIN projects p ON p.id = mr.project_id\n        WHERE mrr.username = ?1\n          AND mr.state = 'opened'\n          {project_filter}\n        ORDER BY mr.updated_at DESC\n    \"#);\n    // ... map rows to MeMrReviewing (same pattern as authored)\n}\n```\n\nKey difference from authored: `author_username` included (who wrote the MR), joins through `mr_reviewers` not `author_username`.\n\n## Acceptance Criteria\n- [ ] Returns Vec<MeMrReviewing> for MRs where `mr_reviewers.username` matches (AC-3.3)\n- [ ] Only returns `state = 'opened'` MRs (AC-5.3)\n- [ ] Includes `author_username` (who wrote the MR) (AC-7.5)\n- [ ] Includes `draft` as bool\n- [ ] Labels via GROUP_CONCAT on `mr_labels` + `labels`\n- [ ] Respects ProjectScope filtering\n- [ ] User both author AND reviewer of same MR → appears in BOTH sections (valid)\n- [ ] No limit, no truncation\n\n## Files\n- MODIFY: src/cli/commands/me/mrs.rs (add alongside authored query)\n\n## TDD Anchor\nRED: Write `test_fetch_reviewing_mrs` using in-memory DB. Insert project, MR by \"alice\", mr_reviewers row for \"jdoe\". Assert returns 1 MR with author_username=\"alice\".\nGREEN: Implement the SQL query.\nVERIFY: `cargo test fetch_my_reviewing_mrs`\n\nAdditional tests:\n- test_reviewing_mrs_excludes_non_reviewer (no mr_reviewers row → not returned)\n- test_user_both_author_and_reviewer (insert MR where user is author + add reviewer row → appears)\n\n## Edge Cases\n- `mr_reviewers` has no timestamp — can't tell when review was requested from this table\n- Multiple reviewers → our user is one of N → MR appears once (JOIN, PK is unique)\n\n## Dependency Context\nUses `MeMrReviewing` and `ProjectScope` from bd-3bwh and bd-a7ba.\nSame file as bd-1obt (authored MRs). Uses `ms_to_iso` from `src/core/time.rs`.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-19T19:37:09.743899Z","created_by":"tayloreernisse","updated_at":"2026-02-20T16:09:13.052896Z","closed_at":"2026-02-20T16:09:13.052861Z","close_reason":"Implemented by lore-me agent swarm","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1fgr","depends_on_id":"bd-3bwh","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1fgr","depends_on_id":"bd-a7ba","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1fn","title":"[CP1] Integration tests for discussion watermark","description":"Integration tests verifying discussion sync watermark behavior.\n\n## Tests (tests/discussion_watermark_tests.rs)\n\n- skips_discussion_fetch_when_updated_at_unchanged\n- fetches_discussions_when_updated_at_advanced\n- updates_watermark_after_successful_discussion_sync\n- does_not_update_watermark_on_discussion_sync_failure\n\n## Test Scenario\n1. Ingest issue with updated_at = T1\n2. Verify discussions_synced_for_updated_at = T1\n3. Re-run ingest with same issue (updated_at = T1)\n4. Verify NO discussion API calls made (watermark prevents)\n5. Simulate issue update (updated_at = T2)\n6. Re-run ingest\n7. Verify discussion API calls made for T2\n8. Verify watermark updated to T2\n\n## Why This Matters\nDiscussion API is expensive (1 call per issue). Watermark ensures\nwe only refetch when issue actually changed, even with cursor rewind.\n\nFiles: tests/discussion_watermark_tests.rs\nDone when: Watermark correctly prevents redundant discussion refetch","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T16:59:11.362495Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:02.086158Z","closed_at":"2026-01-25T17:02:02.086158Z","deleted_at":"2026-01-25T17:02:02.086154Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-1gu","title":"[CP0] gi auth-test command","description":"## Background\n\nauth-test is a quick diagnostic command to verify GitLab connectivity. Used for troubleshooting and CI pipelines. Simpler than doctor because it only checks auth, not full system health.\n\nReference: docs/prd/checkpoint-0.md section \"gi auth-test\"\n\n## Approach\n\n**src/cli/commands/auth-test.ts:**\n```typescript\nimport { Command } from 'commander';\nimport { loadConfig } from '../../core/config';\nimport { GitLabClient } from '../../gitlab/client';\nimport { TokenNotSetError } from '../../core/errors';\n\nexport const authTestCommand = new Command('auth-test')\n  .description('Verify GitLab authentication')\n  .action(async (options, command) => {\n    const globalOpts = command.optsWithGlobals();\n    \n    // 1. Load config\n    const config = loadConfig(globalOpts.config);\n    \n    // 2. Get token from environment\n    const token = process.env[config.gitlab.tokenEnvVar];\n    if (!token) {\n      throw new TokenNotSetError(config.gitlab.tokenEnvVar);\n    }\n    \n    // 3. Create client and test auth\n    const client = new GitLabClient({\n      baseUrl: config.gitlab.baseUrl,\n      token,\n    });\n    \n    // 4. Get current user\n    const user = await client.getCurrentUser();\n    \n    // 5. Output success\n    console.log(`Authenticated as @${user.username} (${user.name})`);\n    console.log(`GitLab: ${config.gitlab.baseUrl}`);\n  });\n```\n\n**Output format:**\n```\nAuthenticated as @johndoe (John Doe)\nGitLab: https://gitlab.example.com\n```\n\n## Acceptance Criteria\n\n- [ ] Loads config from default or --config path\n- [ ] Gets token from configured env var (default GITLAB_TOKEN)\n- [ ] Throws TokenNotSetError if env var not set\n- [ ] Calls GET /api/v4/user to verify auth\n- [ ] Prints username and display name on success\n- [ ] Exit 0 on success\n- [ ] Exit 1 on auth failure (GitLabAuthError)\n- [ ] Exit 1 if config not found (ConfigNotFoundError)\n\n## Files\n\nCREATE:\n- src/cli/commands/auth-test.ts\n\n## TDD Loop\n\nN/A - simple command, verify manually and with integration test in init.test.ts\n\n```bash\n# Manual verification\nexport GITLAB_TOKEN=\"valid-token\"\ngi auth-test\n\n# With invalid token\nexport GITLAB_TOKEN=\"invalid\"\ngi auth-test  # should exit 1\n```\n\n## Edge Cases\n\n- Config exists but token env var not set - clear error message\n- Token exists but wrong scopes - GitLabAuthError (401)\n- Network unreachable - GitLabNetworkError\n- Token with extra whitespace - should trim","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-24T16:09:51.135580Z","created_by":"tayloreernisse","updated_at":"2026-01-25T03:28:16.369542Z","closed_at":"2026-01-25T03:28:16.369481Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1gu","depends_on_id":"bd-13b","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1gu","depends_on_id":"bd-1l1","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1gvg","title":"Implement status fetcher with adaptive paging and pagination guard","description":"## Background\nWith the GraphQL client in place, we need a status-specific fetcher that paginates through all issues in a project, extracts status widgets via __typename matching, and handles edge cases like complexity errors and cursor stalls.\n\n## Approach\nAll code goes in src/gitlab/graphql.rs alongside GraphqlClient. The fetcher uses the workItems(types:[ISSUE]) resolver (NOT project.issues which returns the old Issue type without status widgets). Widget matching uses __typename == \"WorkItemWidgetStatus\" for deterministic identification.\n\n## Files\n- src/gitlab/graphql.rs (add to existing file created by bd-2dlt)\n\n## Implementation\n\nConstants:\n  ISSUE_STATUS_QUERY: GraphQL query string with $projectPath, $after, $first variables\n  PAGE_SIZES: &[u32] = &[100, 50, 25, 10]\n\nPrivate deserialization types:\n  WorkItemsResponse { project: Option<ProjectNode> }\n  ProjectNode { work_items: Option<WorkItemConnection> } (serde rename workItems)\n  WorkItemConnection { nodes: Vec<WorkItemNode>, page_info: PageInfo } (serde rename pageInfo)\n  WorkItemNode { iid: String, widgets: Vec<serde_json::Value> }\n  PageInfo { end_cursor: Option<String>, has_next_page: bool } (serde renames)\n  StatusWidget { status: Option<WorkItemStatus> }\n\nPublic types:\n  UnsupportedReason enum: GraphqlEndpointMissing, AuthForbidden (Debug, Clone)\n  FetchStatusResult struct:\n    statuses: HashMap<i64, WorkItemStatus>\n    all_fetched_iids: HashSet<i64>\n    unsupported_reason: Option<UnsupportedReason>\n    partial_error_count: usize\n    first_partial_error: Option<String>\n\nis_complexity_or_timeout_error(msg) -> bool: lowercase contains \"complexity\" or \"timeout\"\n\nfetch_issue_statuses(client, project_path) -> Result<FetchStatusResult>:\n  Pagination loop:\n    1. Build variables with current page_size from PAGE_SIZES[page_size_idx]\n    2. Call client.query() — match errors:\n       - GitLabNotFound -> Ok(empty + GraphqlEndpointMissing) + warn\n       - GitLabAuthFailed -> Ok(empty + AuthForbidden) + warn  \n       - Other with complexity/timeout msg -> reduce page_size_idx, continue (retry same cursor)\n       - Other with smallest page size exhausted -> return Err\n       - Other -> return Err\n    3. Track partial errors from GraphqlQueryResult\n    4. Parse response into WorkItemsResponse\n    5. For each node: parse iid to i64, add to all_fetched_iids, check widgets for __typename == \"WorkItemWidgetStatus\", insert status into map\n    6. Reset page_size_idx to 0 after successful page\n    7. Pagination guard: if has_next_page but new cursor == old cursor or is None, warn + break\n    8. Update cursor, continue loop\n\n## Acceptance Criteria\n- [ ] Paginates: 2-page mock returns all statuses + all IIDs\n- [ ] No status widget: IID in all_fetched_iids but not in statuses\n- [ ] Status widget with null status: IID in all_fetched_iids but not in statuses\n- [ ] 404 -> Ok(empty, unsupported_reason: GraphqlEndpointMissing)\n- [ ] 403 -> Ok(empty, unsupported_reason: AuthForbidden)\n- [ ] Success -> unsupported_reason: None\n- [ ] __typename != \"WorkItemWidgetStatus\" -> ignored, no error\n- [ ] Cursor stall (same endCursor twice) -> aborts, returns partial result\n- [ ] Complexity error at first=100 -> retries at 50, succeeds\n- [ ] Timeout error -> reduces page size\n- [ ] All page sizes fail -> returns Err\n- [ ] After successful page, next page starts at first=100 again\n- [ ] Partial-data pages -> partial_error_count incremented, first_partial_error captured\n\n## TDD Loop\nRED: test_fetch_statuses_pagination, test_fetch_statuses_no_status_widget, test_fetch_statuses_404_graceful, test_fetch_statuses_403_graceful, test_typename_matching_ignores_non_status_widgets, test_fetch_statuses_cursor_stall_aborts, test_fetch_statuses_complexity_error_reduces_page_size, test_fetch_statuses_timeout_error_reduces_page_size, test_fetch_statuses_smallest_page_still_fails, test_fetch_statuses_page_size_resets_after_success, test_fetch_statuses_unsupported_reason_none_on_success, test_fetch_statuses_partial_errors_tracked\n  Adaptive tests: mock must inspect $first variable in request body to return different responses per page size\nGREEN: Implement all types + fetch_issue_statuses function\nVERIFY: cargo test fetch_statuses && cargo test typename\n\n## Edge Cases\n- GraphQL returns iid as String — parse to i64\n- widgets is Vec<serde_json::Value> — match __typename field, then deserialize matching widgets\n- let-chain syntax: if is_status_widget && let Ok(sw) = serde_json::from_value::<StatusWidget>(...)\n- Pagination guard: new_cursor.is_none() || new_cursor == cursor\n- Page size resets to 0 (index into PAGE_SIZES) after each successful page\n- FetchStatusResult is NOT Clone — test fields individually","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-11T06:42:00.388137Z","created_by":"tayloreernisse","updated_at":"2026-02-11T07:21:33.418490Z","closed_at":"2026-02-11T07:21:33.418451Z","close_reason":"Implemented by agent swarm — all quality gates pass (595 tests, 0 failures)","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1gvg","depends_on_id":"bd-2dlt","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1gvg","depends_on_id":"bd-2y79","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1h3f","title":"Add rename awareness to path resolution probes","description":"## Background\nThe path resolution layer (build_path_query at who.rs:467 and suffix_probe at who.rs:596) only checks position_new_path and new_path. If a user queries an old filename (e.g., 'login.rs' after rename to 'auth.rs'), the probes return 'not found' and scoring never runs — even though the scoring SQL (bd-1hoq) now matches old_path.\n\n## Approach\n\n### build_path_query() changes (who.rs:467):\n\nProbe 1 (exact_exists):\n- Notes query: add OR position_old_path = ?1\n- File changes query: add OR old_path = ?1\n\nProbe 2 (prefix_exists):\n- Notes query: add OR position_old_path LIKE ?1 ESCAPE '\\\\'\n- File changes query: add OR old_path LIKE ?1 ESCAPE '\\\\'\n\nNote: These probes use simple OR (not UNION ALL) since they only check existence (SELECT 1 ... LIMIT 1) — no risk of planner degradation on single-row probes.\n\n### suffix_probe() changes (who.rs:596):\n\nAdd two UNION branches to the existing query:\n```sql\nUNION\nSELECT position_old_path AS full_path FROM notes\nWHERE note_type = 'DiffNote' AND is_system = 0\n  AND position_old_path IS NOT NULL\n  AND (position_old_path LIKE ?1 ESCAPE '\\\\' OR position_old_path = ?2)\n  AND (?3 IS NULL OR project_id = ?3)\nUNION\nSELECT old_path AS full_path FROM mr_file_changes\nWHERE old_path IS NOT NULL\n  AND (old_path LIKE ?1 ESCAPE '\\\\' OR old_path = ?2)\n  AND (?3 IS NULL OR project_id = ?3)\n```\n\nUse UNION (not UNION ALL) — the existing query uses UNION for dedup.\n\n## TDD Loop\n\n### RED (write first):\n```rust\n#[test]\nfn test_old_path_probe_exact_and_prefix() {\n    let conn = setup_test_db();\n    insert_project(&conn, 1, \"team/backend\");\n    insert_mr(&conn, 1, 1, 100, \"alice\", \"merged\");\n    insert_file_change_with_old_path(&conn, 1, 1, \"src/new/foo.rs\", Some(\"src/old/foo.rs\"), \"renamed\");\n    insert_discussion(&conn, 1, 1, Some(1), None, true, false);\n    insert_diffnote_at(&conn, 1, 1, 1, \"alice\", \"src/new/foo.rs\", Some(\"src/old/foo.rs\"), \"review comment\", now_ms());\n\n    // Exact probe by OLD path should resolve\n    let pq = build_path_query(&conn, \"src/old/foo.rs\", None).unwrap();\n    assert\\!(matches\\!(pq, PathQuery::Exact { .. } | PathQuery::Prefix { .. }));\n\n    // Prefix probe by OLD directory should resolve\n    let pq = build_path_query(&conn, \"src/old/\", None).unwrap();\n    assert\\!(matches\\!(pq, PathQuery::Prefix { .. }));\n\n    // New path still works\n    let pq = build_path_query(&conn, \"src/new/foo.rs\", None).unwrap();\n    assert\\!(matches\\!(pq, PathQuery::Exact { .. }));\n}\n\n#[test]\nfn test_suffix_probe_uses_old_path_sources() {\n    let conn = setup_test_db();\n    insert_project(&conn, 1, \"team/backend\");\n    insert_mr(&conn, 1, 1, 100, \"alice\", \"merged\");\n    insert_file_change_with_old_path(&conn, 1, 1, \"src/utils.rs\", Some(\"legacy/utils.rs\"), \"renamed\");\n\n    let result = suffix_probe(&conn, \"utils.rs\", None).unwrap();\n    match result {\n        SuffixResult::Ambiguous(paths) => {\n            assert\\!(paths.contains(&\"src/utils.rs\".to_string()));\n            assert\\!(paths.contains(&\"legacy/utils.rs\".to_string()));\n        }\n        SuffixResult::Unique(p) => {\n            assert\\!(p == \"src/utils.rs\" || p == \"legacy/utils.rs\");\n        }\n        other => panic\\!(\"Expected Ambiguous or Unique, got {other:?}\"),\n    }\n}\n```\n\n### GREEN: Add OR old_path clauses to probes + UNION branches to suffix_probe.\n### VERIFY: cargo test -p lore -- test_old_path_probe test_suffix_probe_uses_old_path\n\n## Acceptance Criteria\n- [ ] test_old_path_probe_exact_and_prefix passes\n- [ ] test_suffix_probe_uses_old_path_sources passes\n- [ ] Existing path probe tests still pass\n- [ ] No changes to PathQuery or SuffixResult enums\n\n## Files\n- MODIFY: src/cli/commands/who.rs (build_path_query at line 467, suffix_probe at line 596)\n\n## Edge Cases\n- position_old_path can be NULL — OR clause handles naturally (NULL \\!= ?1)\n- Old path might match multiple new paths (copy+rename) — suffix_probe Ambiguous handles this\n- Requires insert_file_change_with_old_path and insert_diffnote_at helpers from bd-2yu5","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-09T16:59:51.706482Z","created_by":"tayloreernisse","updated_at":"2026-02-12T20:43:04.411615Z","closed_at":"2026-02-12T20:43:04.411575Z","close_reason":"Implemented by time-decay swarm: 3 agents, 12 tasks, 621 tests passing, all quality gates green","compaction_level":0,"original_size":0,"labels":["scoring"],"dependencies":[{"issue_id":"bd-1h3f","depends_on_id":"bd-2ao4","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1h3f","depends_on_id":"bd-2yu5","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1hj","title":"[CP1] Ingestion orchestrator","description":"Coordinate issue + dependent discussion sync with bounded concurrency.\n\n## Module\nsrc/ingestion/orchestrator.rs\n\n## Canonical Pattern (CP1)\n\nWhen gi ingest --type=issues runs:\n\n1. **Ingest issues** - cursor-based with incremental cursor updates per page\n2. **Collect touched issues** - record IssueForDiscussionSync for each issue passing cursor filter\n3. **Filter for discussion sync** - enqueue issues where:\n   issue.updated_at > issues.discussions_synced_for_updated_at\n4. **Execute discussion sync** - with bounded concurrency (dependent_concurrency from config)\n5. **Update watermark** - after each issue's discussions successfully ingested\n\n## Concurrency Notes\n\nRuntime decision: Use single-threaded Tokio runtime (flavor = \"current_thread\")\n- rusqlite::Connection is !Send, conflicts with multi-threaded runtimes\n- Single-threaded avoids Send bounds entirely\n- Use tokio::task::spawn_local + LocalSet for concurrent discussion fetches\n- Keeps code simple; can upgrade to channel-based DB writer in CP2 if needed\n\n## Configuration Used\n- config.sync.dependent_concurrency - limits parallel discussion requests\n- config.sync.cursor_rewind_seconds - safety margin for cursor\n\n## Progress Reporting\n- Show total issues fetched\n- Show issues needing discussion sync\n- Show discussion/note counts per project\n\nFiles: src/ingestion/orchestrator.rs\nTests: Integration tests with mocked GitLab\nDone when: Full issue + discussion ingestion orchestrated correctly","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T16:57:57.325679Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.851047Z","closed_at":"2026-01-25T17:02:01.851047Z","deleted_at":"2026-01-25T17:02:01.851043Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-1hoq","title":"Restructure expert SQL with CTE-based dual-path matching","description":"## Background\nThe current query_expert() at who.rs:641 uses a 4-signal UNION ALL that only matches position_new_path and new_path, with flat COUNT-based scoring computed entirely in SQL. The new model needs dual-path matching, 5 signal types, state-aware timestamps, and returns per-signal rows for Rust-side decay computation (bd-13q8).\n\n## Approach\n**Important**: This bead builds the new SQL as a separate function WITHOUT modifying query_expert() yet. bd-13q8 wires it into query_expert(). This keeps this bead independently testable.\n\nAdd a new function:\n```rust\n/// Build the CTE-based expert scoring SQL for a given path query mode.\n/// Returns SQL string. Params: ?1=path, ?2=since_ms, ?3=project_id, ?4=as_of_ms, ?5=closed_mr_multiplier, ?6=reviewer_min_note_chars\nfn build_expert_sql(path_op: &str) -> String {\n    // ... format the SQL with {path_op} inlined, all config values as bound params\n}\n```\n\n### SQL structure (8 CTEs + final SELECT):\n1. **matched_notes_raw**: UNION ALL on position_new_path + position_old_path\n2. **matched_notes**: DISTINCT dedup by id\n3. **matched_file_changes_raw**: UNION ALL on new_path + old_path\n4. **matched_file_changes**: DISTINCT dedup by (merge_request_id, project_id)\n5. **mr_activity**: Centralized state-aware timestamps AND state_mult. Joins merge_requests via matched_file_changes. Computes:\n   - activity_ts: CASE WHEN state='merged' THEN COALESCE(merged_at, created_at) WHEN state='closed' THEN COALESCE(closed_at, created_at) ELSE COALESCE(updated_at, created_at) END\n   - state_mult: CASE WHEN state='closed' THEN ?5 ELSE 1.0 END\n6. **reviewer_participation**: substantive DiffNotes WHERE LENGTH(TRIM(body)) >= ?6\n7. **raw**: 5 signals (diffnote_reviewer, diffnote_author, file_author, file_reviewer_participated, file_reviewer_assigned). Signals 1-2 compute state_mult inline. Signals 3-4a-4b reference mr_activity.\n8. **aggregated**: MR-level GROUP BY + note_group with COUNT\n\n### Returns 6 columns: (username TEXT, signal TEXT, mr_id INTEGER, qty INTEGER, ts INTEGER, state_mult REAL)\n\nSee plans/time-decay-expert-scoring.md section 3 for the full SQL template.\n\n## TDD Loop\n\n### RED (write first):\n```rust\n#[test]\nfn test_expert_sql_returns_expected_signal_rows() {\n    let conn = setup_test_db();\n    insert_project(&conn, 1, \"team/backend\");\n    insert_mr(&conn, 1, 1, 100, \"alice\", \"merged\");\n    insert_file_change(&conn, 1, 1, \"src/app.rs\", \"modified\");\n    insert_reviewer(&conn, 1, \"bob\");\n    insert_reviewer(&conn, 1, \"carol\");\n    insert_discussion(&conn, 1, 1, Some(1), None, true, false);\n    insert_diffnote(&conn, 1, 1, 1, \"carol\", \"src/app.rs\", \"This needs error handling for the edge case\");\n\n    let sql = build_expert_sql(\"= ?1\");\n    let mut stmt = conn.prepare(&sql).unwrap();\n    let rows: Vec<(String, String, i64, i64, i64, f64)> = stmt\n        .query_map(\n            rusqlite::params![\"src/app.rs\", 0_i64, Option::<i64>::None, now_ms() + 1000, 0.5_f64, 20_i64],\n            |row| Ok((\n                row.get(0).unwrap(), row.get(1).unwrap(), row.get(2).unwrap(),\n                row.get(3).unwrap(), row.get(4).unwrap(), row.get(5).unwrap(),\n            ))\n        ).unwrap().filter_map(|r| r.ok()).collect();\n\n    // alice: file_author\n    assert!(rows.iter().any(|(u, s, ..)| u == \"alice\" && s == \"file_author\"));\n    // carol: file_reviewer_participated (left substantive DiffNote)\n    assert!(rows.iter().any(|(u, s, ..)| u == \"carol\" && s == \"file_reviewer_participated\"));\n    // bob: file_reviewer_assigned (no DiffNotes)\n    assert!(rows.iter().any(|(u, s, ..)| u == \"bob\" && s == \"file_reviewer_assigned\"));\n    // carol: note_group\n    assert!(rows.iter().any(|(u, s, ..)| u == \"carol\" && s == \"note_group\"));\n    // alice: diffnote_author\n    assert!(rows.iter().any(|(u, s, ..)| u == \"alice\" && s == \"diffnote_author\"));\n    // All merged rows have state_mult = 1.0\n    assert!(rows.iter().all(|(.., sm)| (sm - 1.0).abs() < f64::EPSILON));\n}\n```\n\n### GREEN: Implement build_expert_sql() with the 8 CTEs.\n### VERIFY: cargo test -p lore -- test_expert_sql_returns_expected_signal_rows\n\n## Acceptance Criteria\n- [ ] test_expert_sql_returns_expected_signal_rows passes (all 5 signal types correct)\n- [ ] SQL compiles against :memory: DB with indexes from bd-2ao4 (migration 026)\n- [ ] 6 columns returned: username, signal, mr_id, qty, ts, state_mult (REAL, not TEXT)\n- [ ] 6 SQL params: ?1=path, ?2=since_ms, ?3=project_id, ?4=as_of_ms, ?5=closed_mr_multiplier, ?6=reviewer_min_note_chars\n- [ ] mr_activity CTE centralizes timestamp + state_mult (not repeated)\n- [ ] reviewer_participation uses ?6 not inlined literal\n- [ ] Existing query_expert() and all existing tests UNTOUCHED\n- [ ] build_expert_sql() is a pure function (no Connection param)\n\n## Files\n- MODIFY: src/cli/commands/who.rs (new build_expert_sql function + test, placed near query_expert at line ~641)\n\n## Edge Cases\n- ?5 (closed_mr_multiplier) bound as f64 — rusqlite handles this\n- ?6 (reviewer_min_note_chars) bound as i64 — SQLite LENGTH returns integer\n- Signals 1-2 compute state_mult inline (join through discussions, not mr_activity)\n- COALESCE fallback to created_at for NULL merged_at/closed_at/updated_at\n- Dedup in matched_notes/matched_file_changes prevents double-counting","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-09T16:59:44.665314Z","created_by":"tayloreernisse","updated_at":"2026-02-12T20:43:04.410514Z","closed_at":"2026-02-12T20:43:04.410470Z","close_reason":"Implemented by time-decay swarm: 3 agents, 12 tasks, 621 tests passing, all quality gates green","compaction_level":0,"original_size":0,"labels":["scoring"],"dependencies":[{"issue_id":"bd-1hoq","depends_on_id":"bd-1soz","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1hoq","depends_on_id":"bd-2ao4","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1hoq","depends_on_id":"bd-2w1p","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1ht","title":"Epic: Gate 5 - Code Trace (lore trace)","description":"## Background\n\nGate 5 implements 'lore trace' — answers 'Why was this code introduced?' by tracing from a file path through the MR that modified it, to the issue that motivated the MR, to the discussions with decision rationale. Capstone of Phase B.\n\nGate 5 ships Tier 1 only (API-only, no local git). Tier 2 (git blame via git2-rs) deferred to Phase C.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Gate 5 (Sections 5.1-5.7).\n\n## Prerequisites\n\n- Gates 1-2 COMPLETE: entity_references populated, resource events fetched\n- Gate 4 (bd-14q): provides mr_file_changes table + resolve_rename_chain algorithm\n- entity_references source_method: 'api' | 'note_parse' | 'description_parse'\n- discussions/notes tables for DiffNote content\n- merge_requests.merged_at exists (migration 006). Use COALESCE(merged_at, updated_at) for ordering.\n\n## Architecture\n\n- **No new tables.** Trace queries combine mr_file_changes, entity_references, discussions/notes\n- **Query flow:** file -> mr_file_changes -> MRs -> entity_references (closes/related) -> issues -> discussions with DiffNote context\n- **Tier 1:** File-level granularity only. Cannot trace a specific line to its introducing commit.\n- **Path parsing:** Supports 'src/foo.rs:45' syntax — line number parsed but deferred with Tier 2 warning.\n- **Rename aware:** Reuses file_history::resolve_rename_chain for multi-path matching.\n\n## Children (Execution Order)\n\n1. **bd-2n4** — Trace query logic: file -> MR -> issue -> discussion chain (src/core/trace.rs)\n2. **bd-9dd** — CLI command with human + robot output (src/cli/commands/trace.rs)\n\n## Gate Completion Criteria\n\n- [ ] `lore trace <file>` shows MRs with linked issues + discussion context\n- [ ] Output includes MR -> issue -> discussion chain\n- [ ] DiffNote snippets show content on the traced file\n- [ ] Cross-references from entity_references used for MR->issue linking\n- [ ] :line suffix parses and emits Tier 2 warning\n- [ ] Robot mode JSON with tier: 'api_only'\n- [ ] Graceful handling when no MR data found (suggest sync with fetchMrFileChanges)\n","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-02-02T21:31:01.141053Z","created_by":"tayloreernisse","updated_at":"2026-02-18T21:44:16.629378Z","closed_at":"2026-02-18T21:44:16.629324Z","close_reason":"Gate 5 complete. trace command shipped, all impl children closed. Only doc update tasks (bd-1v8, bd-2fc) remain as independent tasks.","compaction_level":0,"original_size":0,"labels":["epic","gate-5","phase-b"],"dependencies":[{"issue_id":"bd-1ht","depends_on_id":"bd-14q","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1ht","depends_on_id":"bd-1se","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1i2","title":"Integrate mark_dirty_tx into ingestion modules","description":"## Background\nThis bead integrates dirty source tracking into the existing ingestion pipelines. Every entity upserted during ingestion must be marked dirty so the document regenerator knows to update the corresponding search document. The critical constraint: mark_dirty_tx() must be called INSIDE the same transaction that upserts the entity — not after commit.\n\n**Key PRD clarification:** Mark ALL upserted entities dirty (not just changed ones). The regenerator's hash comparison handles \"unchanged\" detection cheaply — this avoids needing change detection in ingestion.\n\n## Approach\nModify 4 existing ingestion files to add mark_dirty_tx() calls inside existing transaction blocks per PRD Section 6.1.\n\n**1. src/ingestion/issues.rs:**\nInside the issue upsert loop, after each successful INSERT/UPDATE:\n```rust\ndirty_tracker::mark_dirty_tx(&tx, SourceType::Issue, issue_row.id)?;\n```\n\n**2. src/ingestion/merge_requests.rs:**\nInside the MR upsert loop:\n```rust\ndirty_tracker::mark_dirty_tx(&tx, SourceType::MergeRequest, mr_row.id)?;\n```\n\n**3. src/ingestion/discussions.rs:**\nInside discussion insert (issue discussions, full-refresh transaction):\n```rust\ndirty_tracker::mark_dirty_tx(&tx, SourceType::Discussion, discussion_row.id)?;\n```\n\n**4. src/ingestion/mr_discussions.rs:**\nInside discussion upsert (write phase):\n```rust\ndirty_tracker::mark_dirty_tx(&tx, SourceType::Discussion, discussion_row.id)?;\n```\n\n**Discussion Sweep Cleanup (PRD Section 6.1 — CRITICAL):**\nWhen the MR discussion sweep deletes stale discussions (`last_seen_at < run_start_time`), **delete the corresponding document rows directly** — do NOT use the dirty queue for cleanup. The `ON DELETE CASCADE` on `document_labels`/`document_paths` and the `documents_embeddings_ad` trigger handle all downstream cleanup.\n\n**PRD-exact CTE pattern:**\n```sql\n-- In src/ingestion/mr_discussions.rs, during sweep phase.\n-- Uses a CTE to capture stale IDs atomically before cascading deletes.\n-- This is more defensive than two separate statements because the CTE\n-- guarantees the ID set is captured before any row is deleted.\nWITH stale AS (\n  SELECT id FROM discussions\n  WHERE merge_request_id = ? AND last_seen_at < ?\n)\n-- Step 1: delete orphaned documents (must happen while source_id still resolves)\nDELETE FROM documents\n  WHERE source_type = 'discussion' AND source_id IN (SELECT id FROM stale);\n-- Step 2: delete the stale discussions themselves\nDELETE FROM discussions\n  WHERE id IN (SELECT id FROM stale);\n```\n\n**NOTE:** If SQLite version doesn't support CTE-based multi-statement, execute as two sequential statements capturing IDs in Rust first:\n```rust\nlet stale_ids: Vec<i64> = conn.prepare(\n    \"SELECT id FROM discussions WHERE merge_request_id = ? AND last_seen_at < ?\"\n)?.query_map(params![mr_id, run_start], |r| r.get(0))?\n  .collect::<Result<Vec<_>, _>>()?;\n\nif !stale_ids.is_empty() {\n    // Delete documents FIRST (while source_id still resolves)\n    conn.execute(\n        \"DELETE FROM documents WHERE source_type = 'discussion' AND source_id IN (...)\",\n        ...\n    )?;\n    // Then delete the discussions\n    conn.execute(\n        \"DELETE FROM discussions WHERE id IN (...)\",\n        ...\n    )?;\n}\n```\n\n**IMPORTANT difference from dirty queue pattern:** The sweep deletes documents DIRECTLY (not via dirty_sources queue). This is because the source entity is being deleted — there's nothing for the regenerator to regenerate from. The cascade handles FTS, labels, paths, and embeddings cleanup.\n\n## Acceptance Criteria\n- [ ] Every upserted issue is marked dirty inside the same transaction\n- [ ] Every upserted MR is marked dirty inside the same transaction\n- [ ] Every upserted discussion (issue + MR) is marked dirty inside the same transaction\n- [ ] ALL upserted entities marked dirty (not just changed ones) — regenerator handles skip\n- [ ] mark_dirty_tx called with &Transaction (not &Connection)\n- [ ] mark_dirty_tx uses upsert with ON CONFLICT to reset backoff state (not INSERT OR IGNORE)\n- [ ] Discussion sweep deletes documents DIRECTLY (not via dirty queue)\n- [ ] Discussion sweep uses CTE (or Rust-side ID capture) to capture stale IDs before cascading deletes\n- [ ] Documents deleted BEFORE discussions (while source_id still resolves)\n- [ ] ON DELETE CASCADE handles document_labels, document_paths cleanup\n- [ ] documents_embeddings_ad trigger handles embedding cleanup\n- [ ] `cargo build` succeeds\n- [ ] Existing ingestion tests still pass\n\n## Files\n- `src/ingestion/issues.rs` — add mark_dirty_tx calls in upsert loop\n- `src/ingestion/merge_requests.rs` — add mark_dirty_tx calls in upsert loop\n- `src/ingestion/discussions.rs` — add mark_dirty_tx calls in insert loop\n- `src/ingestion/mr_discussions.rs` — add mark_dirty_tx calls + direct document deletion in sweep\n\n## TDD Loop\nRED: Existing tests should still pass (regression); new tests:\n- `test_issue_upsert_marks_dirty` — after issue ingest, dirty_sources has entry\n- `test_mr_upsert_marks_dirty` — after MR ingest, dirty_sources has entry\n- `test_discussion_upsert_marks_dirty` — after discussion ingest, dirty_sources has entry\n- `test_discussion_sweep_deletes_documents` — stale discussion documents deleted directly\n- `test_sweep_cascade_cleans_labels_paths` — ON DELETE CASCADE works\nGREEN: Add mark_dirty_tx calls in all 4 files, implement sweep with CTE\nVERIFY: `cargo test ingestion && cargo build`\n\n## Edge Cases\n- Upsert that doesn't change data: still marks dirty (regenerator hash check handles skip)\n- Transaction rollback: dirty mark also rolled back (atomic, inside same txn)\n- Discussion sweep with zero stale IDs: CTE returns empty, no DELETE executed\n- Large batch of upserts: each mark_dirty_tx is O(1) INSERT with ON CONFLICT\n- Sweep deletes document before discussion: order matters for source_id resolution","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:27:09.540279Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:39:17.241433Z","closed_at":"2026-01-30T17:39:17.241390Z","close_reason":"Added mark_dirty_tx calls in issues.rs, merge_requests.rs, discussions.rs, mr_discussions.rs (2 paths)","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1i2","depends_on_id":"bd-38q","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1i4i","title":"Implement run_sync_surgical orchestration function","description":"## Background\n\nThe surgical sync pipeline needs a top-level orchestration function that coordinates the full pipeline for syncing specific IIDs. Unlike `run_sync` (lines 63-360 of `src/cli/commands/sync.rs`) which syncs all projects and all entities, `run_sync_surgical` targets specific issues/MRs by IID within a single project. The pipeline stages are: resolve project, record sync run, preflight fetch, check cancellation, acquire lock, ingest with TOCTOU guards, inline dependent enrichment (discussions, events, diffs), scoped doc regeneration, scoped embedding, finalize recorder, and build `SyncResult`.\n\n## Approach\n\nCreate `pub async fn run_sync_surgical()` in a new file `src/cli/commands/sync_surgical.rs`. Signature:\n\n```rust\npub async fn run_sync_surgical(\n    config: &Config,\n    options: SyncOptions,\n    run_id: Option<&str>,\n    signal: &ShutdownSignal,\n) -> Result<SyncResult>\n```\n\nThe function reads `options.issue_iids` and `options.mr_iids` (added by bd-1lja) to determine target IIDs. Pipeline:\n\n1. **Resolve project**: Call `resolve_project(conn, project_str)` from `src/core/project.rs` to get `gitlab_project_id`.\n2. **Start recorder**: `SyncRunRecorder::start(&recorder_conn, \"surgical-sync\", run_id)`. Note: `succeed()` and `fail()` consume `self`, so control flow must ensure exactly one terminal call.\n3. **Preflight fetch**: For each IID, call `get_issue_by_iid` / `get_mr_by_iid` (bd-159p) to confirm the entity exists on GitLab and capture `updated_at` for TOCTOU.\n4. **Check cancellation**: `if signal.is_cancelled() { recorder.fail(...); return Ok(result); }`\n5. **Acquire lock**: `AppLock::new(conn, LockOptions { name: \"surgical-sync\".into(), stale_lock_minutes: config.sync.stale_lock_minutes, heartbeat_interval_seconds: config.sync.heartbeat_interval_seconds })`. Lock must `acquire(force)` and `release()` on all exit paths.\n6. **Ingest with TOCTOU**: For each preflight entity, call surgical ingest (bd-3sez). Compare DB `updated_at` with preflight `updated_at`; skip if already current. Record outcome in `EntitySyncResult`.\n7. **Inline dependents**: For ingested entities, fetch discussions, resource events (if `config.sync.fetch_resource_events`), MR diffs (if `config.sync.fetch_mr_file_changes`). Use `config.sync.requests_per_second` for rate limiting.\n8. **Scoped docs**: Call `run_generate_docs_for_sources()` (bd-hs6j) with only the affected entity source IDs.\n9. **Scoped embed**: Call `run_embed_for_document_ids()` (bd-1elx) with only the regenerated document IDs.\n10. **Finalize**: `recorder.succeed(conn, &metrics, total_items, total_errors)`.\n11. **Build SyncResult**: Populate surgical fields (bd-wcja): `surgical_mode: Some(true)`, `surgical_iids`, `entity_results`, `preflight_only`.\n\nIf `options.preflight_only` is set, return after step 3 with the preflight data and skip steps 4-10.\n\nProgress output uses `stage_spinner_v2(icon, label, msg, robot_mode)` from `src/cli/progress.rs` line 18 during execution, and `format_stage_line(icon, label, summary, elapsed)` from `src/cli/progress.rs` line 67 for completion lines. Stage icons via `Icons::sync()` from `src/cli/render.rs` line 208. Error completion uses `color_icon(icon, has_errors)` from `src/cli/commands/sync.rs` line 55.\n\n## Acceptance Criteria\n\n1. `run_sync_surgical` compiles and runs the full pipeline for 1+ issue IIDs\n2. Preflight-only mode returns early with fetched entity data, no DB writes beyond recorder\n3. TOCTOU: entities whose DB `updated_at` matches preflight `updated_at` are skipped with `skipped_toctou` outcome\n4. Cancellation at any stage between preflight and ingest stops processing, calls `recorder.fail()`\n5. Lock is acquired before ingest and released on all exit paths (success, error, cancellation)\n6. `SyncResult` surgical fields are populated: `surgical_mode`, `surgical_iids`, `entity_results`\n7. Robot mode produces valid JSON with per-entity outcomes\n8. Human mode shows stage spinners and completion lines\n\n## Files\n\n- `src/cli/commands/sync_surgical.rs` — new file, main orchestration function\n- `src/cli/commands/mod.rs` — add `pub mod sync_surgical;`\n\n## TDD Anchor\n\nTests in `src/cli/commands/sync_surgical.rs` or a companion `sync_surgical_tests.rs`:\n\n```rust\n#[cfg(test)]\nmod tests {\n    use super::*;\n    use crate::core::db::{create_connection, run_migrations};\n    use std::path::Path;\n    use wiremock::{MockServer, Mock, ResponseTemplate};\n    use wiremock::matchers::{method, path_regex};\n\n    fn test_config(mock_url: &str) -> Config {\n        let mut config = Config::default();\n        config.gitlab.url = mock_url.to_string();\n        config.gitlab.token = \"test-token\".to_string();\n        config\n    }\n\n    fn setup_db() -> rusqlite::Connection {\n        let conn = create_connection(Path::new(\":memory:\")).unwrap();\n        run_migrations(&conn).unwrap();\n        // Insert test project\n        conn.execute(\n            \"INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url)\n             VALUES (1, 'group/project', 'https://gitlab.example.com/group/project')\",\n            [],\n        ).unwrap();\n        conn\n    }\n\n    #[tokio::test]\n    async fn surgical_sync_single_issue_end_to_end() {\n        let server = MockServer::start().await;\n        // Mock: GET /projects/:id/issues?iids[]=7 returns one issue\n        Mock::given(method(\"GET\"))\n            .and(path_regex(r\"/api/v4/projects/1/issues\"))\n            .respond_with(ResponseTemplate::new(200).set_body_json(\n                serde_json::json!([{\n                    \"id\": 100, \"iid\": 7, \"project_id\": 1, \"title\": \"Test\",\n                    \"state\": \"opened\", \"created_at\": \"2026-01-01T00:00:00Z\",\n                    \"updated_at\": \"2026-02-17T00:00:00Z\",\n                    \"author\": {\"id\": 1, \"username\": \"dev\", \"name\": \"Dev\"},\n                    \"web_url\": \"https://gitlab.example.com/group/project/-/issues/7\"\n                }])\n            ))\n            .mount(&server).await;\n        // Mock discussions endpoint\n        Mock::given(method(\"GET\"))\n            .and(path_regex(r\"/api/v4/projects/1/issues/7/discussions\"))\n            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))\n            .mount(&server).await;\n\n        let config = test_config(&server.uri());\n        let options = SyncOptions {\n            robot_mode: true,\n            issue_iids: vec![7],\n            ..SyncOptions::default()\n        };\n        let signal = ShutdownSignal::new();\n        let result = run_sync_surgical(&config, options, Some(\"test01\"), &signal).await.unwrap();\n\n        assert_eq!(result.surgical_mode, Some(true));\n        assert_eq!(result.surgical_iids.as_ref().unwrap().issues, vec![7]);\n        let entities = result.entity_results.as_ref().unwrap();\n        assert_eq!(entities.len(), 1);\n        assert_eq!(entities[0].outcome, \"synced\");\n    }\n\n    #[tokio::test]\n    async fn preflight_only_returns_early() {\n        let server = MockServer::start().await;\n        Mock::given(method(\"GET\"))\n            .and(path_regex(r\"/api/v4/projects/1/issues\"))\n            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([{\n                \"id\": 100, \"iid\": 7, \"project_id\": 1, \"title\": \"Test\",\n                \"state\": \"opened\", \"created_at\": \"2026-01-01T00:00:00Z\",\n                \"updated_at\": \"2026-02-17T00:00:00Z\",\n                \"author\": {\"id\": 1, \"username\": \"dev\", \"name\": \"Dev\"},\n                \"web_url\": \"https://gitlab.example.com/group/project/-/issues/7\"\n            }])))\n            .mount(&server).await;\n\n        let config = test_config(&server.uri());\n        let options = SyncOptions {\n            robot_mode: true,\n            issue_iids: vec![7],\n            preflight_only: true,\n            ..SyncOptions::default()\n        };\n        let signal = ShutdownSignal::new();\n        let result = run_sync_surgical(&config, options, Some(\"test02\"), &signal).await.unwrap();\n\n        assert_eq!(result.preflight_only, Some(true));\n        assert_eq!(result.issues_updated, 0); // No actual ingest happened\n    }\n\n    #[tokio::test]\n    async fn cancellation_before_ingest_fails_recorder() {\n        let server = MockServer::start().await;\n        Mock::given(method(\"GET\"))\n            .and(path_regex(r\"/api/v4/projects/1/issues\"))\n            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([{\n                \"id\": 100, \"iid\": 7, \"project_id\": 1, \"title\": \"Test\",\n                \"state\": \"opened\", \"created_at\": \"2026-01-01T00:00:00Z\",\n                \"updated_at\": \"2026-02-17T00:00:00Z\",\n                \"author\": {\"id\": 1, \"username\": \"dev\", \"name\": \"Dev\"},\n                \"web_url\": \"https://gitlab.example.com/group/project/-/issues/7\"\n            }])))\n            .mount(&server).await;\n\n        let config = test_config(&server.uri());\n        let options = SyncOptions {\n            robot_mode: true,\n            issue_iids: vec![7],\n            ..SyncOptions::default()\n        };\n        let signal = ShutdownSignal::new();\n        signal.cancel(); // Cancel before we start\n        let result = run_sync_surgical(&config, options, Some(\"test03\"), &signal).await.unwrap();\n\n        // Result should indicate cancellation\n        assert_eq!(result.issues_updated, 0);\n    }\n}\n```\n\n## Edge Cases\n\n- **Entity not found on GitLab**: Preflight returns 404 for an IID. Record `EntitySyncResult { outcome: \"not_found\" }` and continue with remaining IIDs.\n- **All entities skipped by TOCTOU**: Every entity's `updated_at` matches DB. Result has `entity_results` with all `skipped_toctou`, zero actual sync work.\n- **Mixed success/failure**: Some IIDs succeed, some fail. All recorded in `entity_results`. Function returns `Ok` with partial results, not `Err`.\n- **SyncRunRecorder consume semantics**: `succeed()` and `fail()` take `self` by value. The orchestrator must ensure exactly one terminal call. Use an `Option<SyncRunRecorder>` pattern: `let mut recorder = Some(recorder); ... recorder.take().unwrap().succeed(...)`.\n- **Lock contention**: If another sync holds the lock and `force` is false, fail with clear error before any ingest.\n- **Empty IID lists**: If both `options.issue_iids` and `options.mr_iids` are empty, return immediately with default `SyncResult` (no surgical fields set).\n\n## Dependency Context\n\n- **Depends on (upstream)**: bd-wcja (SyncResult fields), bd-1lja (SyncOptions extensions), bd-159p (get_by_iid client methods), bd-3sez (surgical ingest/preflight/TOCTOU), bd-kanh (per-entity helpers), bd-arka (SyncRunRecorder surgical methods), bd-1elx (scoped embed), bd-hs6j (scoped docs), bd-tiux (migration 027)\n- **Blocks (downstream)**: bd-3bec (wiring into run_sync), bd-3jqx (integration tests)\n- This is the keystone bead — it consumes all upstream primitives and is consumed by the final wiring and integration test beads.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-17T19:17:24.197299Z","created_by":"tayloreernisse","updated_at":"2026-02-18T20:36:39.596508Z","closed_at":"2026-02-18T20:36:39.596455Z","close_reason":"run_sync_surgical orchestrator: 719-line pipeline with preflight/TOCTOU/ingest/dependents/docs/embed stages, Option<SyncRunRecorder> pattern, graceful embed failures","compaction_level":0,"original_size":0,"labels":["surgical-sync"],"dependencies":[{"issue_id":"bd-1i4i","depends_on_id":"bd-3bec","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1iuj","title":"Phase 0d: Error type migration — NetworkErrorKind enum","description":"## What\nModify LoreError in src/core/error.rs to:\n1. Remove the reqwest::Error From impl (Http variant)\n2. Add a NetworkErrorKind enum for error classification\n3. Change GitLabNetworkError to use kind: NetworkErrorKind + detail: Option<String> instead of source: Option<reqwest::Error>\n\n## Why\nThe adapter layer (Phase 1) uses GitLabNetworkError { detail: Option<String> }, which requires this error type change before the adapter compiles. This MUST precede Phase 1. Placed in Phase 0 so Phases 1-3 compile as a unit.\n\n## Implementation\n\n### New enum in src/core/error.rs:\n\\`\\`\\`rust\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum NetworkErrorKind {\n    Timeout,\n    ConnectionRefused,\n    DnsResolution,\n    Tls,\n    Other,\n}\n\\`\\`\\`\n\n### Change GitLabNetworkError variant:\n\\`\\`\\`rust\n// Remove:\n#[error(\\\"HTTP error: {0}\\\")]\nHttp(#[from] reqwest::Error),\n\n// Change:\n#[error(\\\"Cannot connect to GitLab at {base_url}\\\")]\nGitLabNetworkError {\n    base_url: String,\n    kind: NetworkErrorKind,\n    detail: Option<String>,\n},\n\\`\\`\\`\n\n## Error Granularity Rationale\nFlattening all HTTP errors to detail: Option<String> would lose distinction between timeouts, TLS failures, DNS resolution, and connection resets. NetworkErrorKind preserves actionable error categories without coupling LoreError to any HTTP client. The adapter execute() method classifies errors at the boundary:\n- Timeout from asupersync::time::timeout -> NetworkErrorKind::Timeout\n- Transport errors -> classified by error type into appropriate kind\n- Unknown errors -> NetworkErrorKind::Other\n\nThis keeps LoreError client-agnostic while preserving retry decisions based on error type (e.g., retry on timeout but not on TLS).\n\n## CRITICAL: Finding All Affected Call Sites\nRemoving Http(#[from] reqwest::Error) breaks all code that relies on the implicit From<reqwest::Error> conversion (i.e., ? on reqwest::Error in functions returning LoreError/Result).\n\nTo find all affected sites:\n\\`\\`\\`bash\n# Find all files using reqwest error types or ? on reqwest calls\nrg 'reqwest::Error' src/ --files-with-matches\nrg '\\.send\\(\\)\\.await\\?' src/ --files-with-matches\n# Also check for direct GitLabNetworkError construction\nrg 'GitLabNetworkError' src/ --files-with-matches\n\\`\\`\\`\n\n### Known sites to update:\n- src/gitlab/client.rs — GitLabNetworkError construction in request(), request_with_headers()\n- src/gitlab/graphql.rs — GitLabNetworkError construction in execute_query()\n- src/embedding/ollama.rs — may construct errors from reqwest failures\n\n### Pattern for temporary fix:\n\\`\\`\\`rust\n// Before (implicit From):\nlet response = self.client.get(&url).send().await?;\n// After (explicit conversion — temporary until Phase 2 rewrites these):\nlet response = self.client.get(&url).send().await\n    .map_err(|e| LoreError::GitLabNetworkError {\n        base_url: url.clone(),\n        kind: NetworkErrorKind::Other,\n        detail: Some(format!(\\\"{e:?}\\\")),\n    })?;\n\\`\\`\\`\n\nThese temporary conversions are throwaway work — Phase 2 rewrites all these call sites to use the adapter. But they must compile after Phase 0d.\n\n## Files Changed\n- src/core/error.rs (~15 LOC changed)\n- src/gitlab/client.rs (update GitLabNetworkError construction sites)\n- src/gitlab/graphql.rs (same)\n- src/embedding/ollama.rs (same, if any)\n\n## Testing\n- cargo check --all-targets (MUST compile cleanly)\n- cargo clippy --all-targets -- -D warnings\n- cargo test (existing tests should pass — verify no tests match on Http variant or source field)","status":"closed","priority":1,"issue_type":"task","created_at":"2026-03-06T18:38:46.412914Z","created_by":"tayloreernisse","updated_at":"2026-03-06T21:11:12.560623Z","closed_at":"2026-03-06T21:11:12.560572Z","close_reason":"asupersync migration complete — 5 commits pushed to asupersync-migration branch","compaction_level":0,"original_size":0,"labels":["asupersync","phase-0"],"dependencies":[{"issue_id":"bd-1iuj","depends_on_id":"bd-1lj5","type":"parent-child","created_at":"2026-03-06T18:38:46.415930Z","created_by":"tayloreernisse"}]}
{"id":"bd-1j1","title":"Integration test: full Phase B sync pipeline","description":"## Background\n\nThis integration test proves the full Phase B sync pipeline works end-to-end. Since Gates 1 and 2 are already implemented and closed, this test validates that the complete pipeline — including Gate 4 mr_diffs draining — works together.\n\n## Codebase Context\n\n- **Gates 1-2 FULLY IMPLEMENTED (CLOSED):** resource events fetch, closes_issues API, system note parsing (note_parser.rs), entity_references extraction (references.rs)\n- **Gate 4 in progress:** migration 016 (mr_file_changes), fetch_mr_diffs, drain_mr_diffs — already wired in orchestrator (lines 708-726, 1514+)\n- **26 migrations exist** (001-026). LATEST_SCHEMA_VERSION = 26. In-memory DB must run all 26.\n- Orchestrator has drain_resource_events() (line 932), drain_mr_closes_issues() (line 1254), and drain_mr_diffs() (line 1514).\n- wiremock crate used in existing tests (check dev-dependencies in Cargo.toml)\n- src/core/dependent_queue.rs: enqueue_job(), claim_jobs(), complete_job(), fail_job() with exponential backoff\n- IngestProjectResult and IngestMrProjectResult track counts for all drain phases\n\n## Approach\n\nCreate tests/phase_b_integration.rs:\n\n### Test Setup\n\n1. In-memory SQLite DB with all 26 migrations (001-026)\n2. wiremock mock server with:\n   - /api/v4/projects/:id/issues — 2 test issues\n   - /api/v4/projects/:id/merge_requests — 1 test MR\n   - /api/v4/projects/:id/issues/:iid/resource_state_events — state events\n   - /api/v4/projects/:id/issues/:iid/resource_label_events — label events\n   - /api/v4/projects/:id/merge_requests/:iid/resource_state_events — merge event with source_merge_request_iid\n   - /api/v4/projects/:id/merge_requests/:iid/closes_issues — linked issues\n   - /api/v4/projects/:id/merge_requests/:iid/diffs — file changes\n   - /api/v4/projects/:id/issues/:iid/discussions — discussion with system note \"mentioned in !1\"\n3. Config with fetch_resource_events=true and fetch_mr_file_changes=true\n4. Use dependent_concurrency=1 to avoid timing issues\n\n### Test Flow\n\n```rust\n#[tokio::test]\nasync fn test_full_phase_b_pipeline() {\n    // 1. Set up mock server + DB with all 26 migrations\n    // 2. Run ingest issues + MRs (orchestrator functions)\n    // 3. Verify pending_dependent_fetches enqueued: resource_events, mr_closes_issues, mr_diffs\n    // 4. Drain all dependent fetch queues\n    // 5. Assert: resource_state_events populated (count > 0)\n    // 6. Assert: resource_label_events populated (count > 0)\n    // 7. Assert: entity_references has closes ref with source_method='api'\n    // 8. Assert: entity_references has mentioned ref with source_method='note_parse'\n    // 9. Assert: mr_file_changes populated from diffs API\n    // 10. Assert: pending_dependent_fetches fully drained (no stuck locks)\n}\n```\n\n### Assertions (SQL)\n\n```sql\nSELECT COUNT(*) FROM resource_state_events  -- > 0\nSELECT COUNT(*) FROM resource_label_events  -- > 0\nSELECT COUNT(*) FROM entity_references WHERE reference_type = 'closes' AND source_method = 'api'  -- >= 1\nSELECT COUNT(*) FROM entity_references WHERE source_method = 'note_parse'  -- >= 1\nSELECT COUNT(*) FROM mr_file_changes  -- > 0\nSELECT COUNT(*) FROM pending_dependent_fetches WHERE locked_at IS NOT NULL  -- = 0\n```\n\n## Acceptance Criteria\n\n- [ ] Test creates DB with all 26 migrations, mocks, and runs full pipeline\n- [ ] resource_state_events and resource_label_events populated\n- [ ] entity_references has closes ref (source_method='api') and mentioned ref (source_method='note_parse')\n- [ ] mr_file_changes populated from diffs mock\n- [ ] pending_dependent_fetches fully drained (no stuck locks, no retryable jobs)\n- [ ] Test runs in < 10 seconds\n- [ ] `cargo test --test phase_b_integration` passes\n\n## Files\n\n- CREATE: tests/phase_b_integration.rs\n\n## TDD Anchor\n\nRED: Write test with all assertions — should pass if all Gates are wired correctly.\n\nGREEN: If anything fails, it indicates a missing orchestrator connection — fix the wiring.\n\nVERIFY: cargo test --test phase_b_integration -- --nocapture\n\n## Edge Cases\n\n- Paginated mock responses: include Link header for multi-page responses\n- Empty pages: verify graceful handling\n- Use dependent_concurrency=1 to avoid timing issues in test environment\n- Stale lock reclaim: test that locks older than stale_lock_minutes are reclaimed\n- If Gate 4 drain_mr_diffs is not fully wired yet, the mr_file_changes assertion will fail — this is the intended RED signal\n\n## Dependency Context\n\n- **bd-8t4 (resource_state_events extraction)**: CLOSED. Provides drain_resource_events() which populates resource_state_events and resource_label_events tables.\n- **bd-3ia (closes_issues)**: CLOSED. Provides drain_mr_closes_issues() which populates entity_references with reference_type='closes', source_method='api'.\n- **bd-1ji (note parsing)**: CLOSED. Provides note_parser.rs which extracts \"mentioned in !N\" patterns and stores as entity_references with source_method='note_parse'.\n- **dependent_queue.rs**: Provides the claim/complete/fail lifecycle. All three drain functions use this.\n- **orchestrator.rs**: Contains all drain functions. drain_mr_diffs() at line 1514+ populates mr_file_changes.","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-02T22:42:26.355071Z","created_by":"tayloreernisse","updated_at":"2026-02-17T16:52:30.970742Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1j1","depends_on_id":"bd-1ji","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1j1","depends_on_id":"bd-1se","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1j1","depends_on_id":"bd-3ia","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1j1","depends_on_id":"bd-8t4","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1j5o","title":"Verification: quality gates, query plan check, real-world validation","description":"## Background\n\nPost-implementation verification checkpoint. Runs after all code beads complete to validate the full scoring model works correctly against real data, not just test fixtures.\n\n## Approach\n\nExecute 8 verification steps in order. Each step has a binary pass/fail outcome.\n\n### Step 1: Compiler check\n```bash\ncargo check --all-targets\n```\nPass: exit 0\n\n### Step 2: Clippy\n```bash\ncargo clippy --all-targets -- -D warnings\n```\nPass: exit 0\n\n### Step 3: Formatting\n```bash\ncargo fmt --check\n```\nPass: exit 0\n\n### Step 4: Test suite\n```bash\ncargo test -p lore\n```\nPass: all tests green, including 31 new decay/scoring tests\n\n### Step 5: UBS scan\n```bash\nubs src/cli/commands/who.rs src/core/config.rs src/core/db.rs\n```\nPass: exit 0\n\n### Step 6: Query plan verification (manual)\nRun against real database:\n```bash\ncargo run --release -- who --path MeasurementQualityDialog.tsx -vvv 2>&1 | grep -i \"query plan\"\n```\nOr use sqlite3 CLI with EXPLAIN QUERY PLAN on the expert SQL (both exact and prefix modes).\n\nPass criteria (6 checks):\n- matched_notes_raw branch 1 uses existing new_path index\n- matched_notes_raw branch 2 uses idx_notes_old_path_author\n- matched_file_changes_raw uses idx_mfc_new_path_project_mr and idx_mfc_old_path_project_mr\n- reviewer_participation uses idx_notes_diffnote_discussion_author\n- mr_activity CTE joins merge_requests via primary key from matched_file_changes\n- Path resolution probes (old_path leg) use idx_notes_old_path_project_created\nDocument observed plan as SQL comment near the CTE.\n\n### Step 7: Performance baseline (manual)\n```bash\ntime cargo run --release -- who --path MeasurementQualityDialog.tsx\ntime cargo run --release -- who --path src/\ntime cargo run --release -- who --path Dialog.tsx\n```\nPass criteria (soft SLOs):\n- Exact path: p95 < 200ms\n- Prefix: p95 < 300ms\n- Suffix: p95 < 500ms\nRecord timings as SQL comment for future regression reference.\n\n### Step 8: Real-world validation\n```bash\ncargo run --release -- who --path MeasurementQualityDialog.tsx\ncargo run --release -- who --path MeasurementQualityDialog.tsx --explain-score\ncargo run --release -- who --path MeasurementQualityDialog.tsx --as-of 2025-06-01\ncargo run --release -- who --path MeasurementQualityDialog.tsx --all-history\n```\nPass criteria:\n- [ ] Recency discounting visible (recent authors rank above old reviewers)\n- [ ] --explain-score components sum to total (within f64 tolerance)\n- [ ] --as-of produces identical results on repeated runs\n- [ ] Assigned-only reviewers rank below participated reviewers on same MR\n- [ ] Known renamed file path resolves and credits old expertise\n- [ ] LGTM-only reviewers classified as assigned-only\n- [ ] Closed MRs at ~50% contribution visible via --explain-score\n\n## Acceptance Criteria\n- [ ] Steps 1-5 pass (exit 0)\n- [ ] Step 6: query plan documented with all 6 index usage points confirmed\n- [ ] Step 7: timing baselines recorded\n- [ ] Step 8: all 7 real-world checks pass\n\n## Files\n- All files modified by child beads (read-only verification)\n- Add SQL comments near CTE with observed EXPLAIN QUERY PLAN output\n\n## Edge Cases\n- SQLite planner may choose different plans across versions — document version\n- Timing varies by hardware — record machine specs alongside baselines\n- Real DB may have NULL merged_at on old MRs — state-aware fallback handles this","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-09T17:00:59.287720Z","created_by":"tayloreernisse","updated_at":"2026-02-12T20:43:04.415816Z","closed_at":"2026-02-12T20:43:04.415772Z","close_reason":"Implemented by time-decay swarm: 3 agents, 12 tasks, 621 tests passing, all quality gates green","compaction_level":0,"original_size":0,"labels":["scoring"],"dependencies":[{"issue_id":"bd-1j5o","depends_on_id":"bd-1b50","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1j5o","depends_on_id":"bd-1vti","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1je","title":"Implement pending discussion queue","description":"## Background\nThe pending discussion queue tracks discussions that need to be fetched from GitLab. When an issue or MR is updated, its discussions may need re-fetching. This queue is separate from dirty_sources (which tracks entities needing document regeneration) — it tracks entities needing API calls to GitLab. The queue uses the same backoff pattern as dirty_sources for consistency.\n\n## Approach\nCreate `src/ingestion/discussion_queue.rs`:\n\n```rust\nuse crate::core::backoff::compute_next_attempt_at;\n\n/// Noteable type for discussion queue.\n#[derive(Debug, Clone, Copy)]\npub enum NoteableType {\n    Issue,\n    MergeRequest,\n}\n\nimpl NoteableType {\n    pub fn as_str(&self) -> &'static str {\n        match self {\n            Self::Issue => \"Issue\",\n            Self::MergeRequest => \"MergeRequest\",\n        }\n    }\n}\n\npub struct PendingFetch {\n    pub project_id: i64,\n    pub noteable_type: NoteableType,\n    pub noteable_iid: i64,\n    pub attempt_count: i32,\n}\n\n/// Queue a discussion fetch. ON CONFLICT DO UPDATE resets backoff (consistent with dirty_sources).\npub fn queue_discussion_fetch(\n    conn: &Connection,\n    project_id: i64,\n    noteable_type: NoteableType,\n    noteable_iid: i64,\n) -> Result<()>;\n\n/// Get next batch of pending fetches (WHERE next_attempt_at IS NULL OR <= now).\npub fn get_pending_fetches(conn: &Connection, limit: usize) -> Result<Vec<PendingFetch>>;\n\n/// Mark fetch complete (remove from queue).\npub fn complete_fetch(\n    conn: &Connection,\n    project_id: i64,\n    noteable_type: NoteableType,\n    noteable_iid: i64,\n) -> Result<()>;\n\n/// Record fetch error with backoff.\npub fn record_fetch_error(\n    conn: &Connection,\n    project_id: i64,\n    noteable_type: NoteableType,\n    noteable_iid: i64,\n    error: &str,\n) -> Result<()>;\n```\n\n## Acceptance Criteria\n- [ ] queue_discussion_fetch uses ON CONFLICT DO UPDATE (consistent with dirty_sources pattern)\n- [ ] Re-queuing resets: attempt_count=0, next_attempt_at=NULL, last_error=NULL\n- [ ] get_pending_fetches respects next_attempt_at backoff\n- [ ] get_pending_fetches returns entries ordered by queued_at ASC\n- [ ] complete_fetch removes entry from queue\n- [ ] record_fetch_error increments attempt_count, computes next_attempt_at via shared backoff\n- [ ] NoteableType.as_str() returns \"Issue\" or \"MergeRequest\" (matches DB CHECK constraint)\n- [ ] `cargo test discussion_queue` passes\n\n## Files\n- `src/ingestion/discussion_queue.rs` — new file\n- `src/ingestion/mod.rs` — add `pub mod discussion_queue;`\n\n## TDD Loop\nRED: Tests in `#[cfg(test)] mod tests`:\n- `test_queue_and_get` — queue entry, get returns it\n- `test_requeue_resets_backoff` — queue, error, re-queue -> attempt_count=0\n- `test_backoff_respected` — entry with future next_attempt_at not returned\n- `test_complete_removes` — complete_fetch removes entry\n- `test_error_increments_attempts` — error -> attempt_count=1, next_attempt_at set\nGREEN: Implement all functions\nVERIFY: `cargo test discussion_queue`\n\n## Edge Cases\n- Queue same (project_id, noteable_type, noteable_iid) twice: ON CONFLICT resets state\n- NoteableType must match DB CHECK constraint exactly (\"Issue\", \"MergeRequest\" — capitalized)\n- Empty queue: get_pending_fetches returns empty Vec","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:27:09.505548Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:31:35.496454Z","closed_at":"2026-01-30T17:31:35.496405Z","close_reason":"Implemented discussion_queue with queue/get/complete/record_error + 6 tests","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1je","depends_on_id":"bd-hrs","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1je","depends_on_id":"bd-mem","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1ji","title":"Parse system notes for cross-reference patterns","description":"## Background\nSystem notes contain cross-reference patterns like 'mentioned in !{iid}', 'closed by !{iid}', etc. This is best-effort, English-only extraction that supplements the structured API data from bd-3ia and bd-8t4. Runs as a local post-processing step (no API calls).\n\n## Approach\nCreate src/core/note_parser.rs:\n\n```rust\nuse regex::Regex;\nuse lazy_static::lazy_static;\n\n/// A parsed cross-reference from a system note.\npub struct ParsedCrossRef {\n    pub reference_type: String,    // \"mentioned\" | \"closes\"\n    pub target_entity_type: String, // \"issue\" | \"merge_request\"  \n    pub target_iid: i64,\n    pub target_project_path: Option<String>,  // None = same project\n}\n\nlazy_static! {\n    static ref MENTIONED_RE: Regex = Regex::new(\n        r\"mentioned in (?:(?P<project>[\\w\\-]+/[\\w\\-]+))?(?P<sigil>[#!])(?P<iid>\\d+)\"\n    ).unwrap();\n    static ref CLOSED_BY_RE: Regex = Regex::new(\n        r\"closed by (?:(?P<project>[\\w\\-]+/[\\w\\-]+))?(?P<sigil>[#!])(?P<iid>\\d+)\"\n    ).unwrap();\n}\n\n/// Parse a system note body for cross-references.\npub fn parse_cross_refs(body: &str) -> Vec<ParsedCrossRef>\n\n/// Extract cross-references from all system notes and insert into entity_references.\n/// Queries notes WHERE is_system = 1, parses body text, resolves to entity_references.\npub fn extract_refs_from_system_notes(\n    conn: &Connection,\n    project_id: i64,\n) -> Result<ExtractResult>\n\npub struct ExtractResult {\n    pub inserted: usize,\n    pub skipped_unresolvable: usize,\n    pub parse_failures: usize,   // logged at debug level\n}\n```\n\nSigil mapping: `#` = issue, `!` = merge_request\n\nResolution logic:\n1. If target_project_path is None (same project): look up entity by iid in local DB → set target_entity_id\n2. If target_project_path is Some: check if project is synced locally\n   - If yes: resolve to local entity id\n   - If no: store as unresolved (target_entity_id=NULL, target_project_path=path, target_entity_iid=iid)\n\nInsert with source_method='system_note_parse', INSERT OR IGNORE for dedup.\n\nCall after drain_dependent_queue and extract_refs_from_state_events in the sync pipeline.\n\n## Acceptance Criteria\n- [ ] 'mentioned in !123' → mentioned ref, target=MR iid 123\n- [ ] 'mentioned in #456' → mentioned ref, target=issue iid 456\n- [ ] 'mentioned in group/project!789' → cross-project mentioned ref\n- [ ] 'closed by !123' → closes ref\n- [ ] Cross-project refs stored as unresolved when target project not synced\n- [ ] source_method = 'system_note_parse'\n- [ ] Parse failures logged at debug level (not errors)\n- [ ] Idempotent (INSERT OR IGNORE)\n- [ ] Only processes is_system=1 notes\n\n## Files\n- src/core/note_parser.rs (new)\n- src/core/mod.rs (add `pub mod note_parser;`)\n- src/cli/commands/sync.rs (call after other ref extraction steps)\n\n## TDD Loop\nRED: tests/note_parser_tests.rs:\n- `test_parse_mentioned_in_mr` - \"mentioned in !567\" → ParsedCrossRef { mentioned, merge_request, 567 }\n- `test_parse_mentioned_in_issue` - \"mentioned in #234\" → ParsedCrossRef { mentioned, issue, 234 }\n- `test_parse_mentioned_cross_project` - \"mentioned in group/repo!789\" → with project path\n- `test_parse_closed_by_mr` - \"closed by !567\" → ParsedCrossRef { closes, merge_request, 567 }\n- `test_parse_multiple_refs` - note with two mentions → two refs\n- `test_parse_no_refs` - \"Updated the description\" → empty vec\n- `test_extract_refs_from_system_notes_integration` - seed DB with system notes, verify entity_references created\n\nGREEN: Implement regex patterns and extraction logic\n\nVERIFY: `cargo test note_parser -- --nocapture`\n\n## Edge Cases\n- Non-English GitLab instances: \"ajouté l'étiquette ~bug\" won't match — this is accepted limitation, logged at debug\n- Multi-level group paths: \"mentioned in top/sub/project#123\" — regex needs to handle arbitrary depth ([\\w\\-]+(?:/[\\w\\-]+)+)\n- Note body may contain markdown links that look like refs: \"[#123](url)\" — the regex should handle this correctly since the prefix \"mentioned in\" is required\n- Same ref mentioned multiple times in same note — dedup via INSERT OR IGNORE\n- Note may reference itself (e.g., system note on issue #123 says \"mentioned in #123\") — technically valid, store it","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-02T21:32:33.663304Z","created_by":"tayloreernisse","updated_at":"2026-02-04T20:13:33.398960Z","closed_at":"2026-02-04T20:13:33.398868Z","close_reason":"Completed: parse_cross_refs regex parser, extract_refs_from_system_notes DB function, wired into orchestrator. 17 tests passing.","compaction_level":0,"original_size":0,"labels":["gate-2","parsing","phase-b"],"dependencies":[{"issue_id":"bd-1ji","depends_on_id":"bd-1se","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1ji","depends_on_id":"bd-hu3","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1k1","title":"Implement FTS5 search function and query sanitization","description":"## Background\nFTS5 search is the core lexical retrieval engine. It wraps SQLite's FTS5 with safe query parsing that prevents user input from causing SQL syntax errors, while preserving useful features like prefix search for type-ahead. The search function returns ranked results with BM25 scores and contextual snippets. This module is the Gate A search backbone and also provides fallback search when Ollama is unavailable in Gate B.\n\n## Approach\nCreate `src/search/` module with `mod.rs` and `fts.rs` per PRD Section 3.1-3.2.\n\n**src/search/mod.rs:**\n```rust\nmod fts;\nmod filters;\n// Later beads add: mod vector; mod hybrid; mod rrf;\npub use fts::{search_fts, to_fts_query, FtsResult, FtsQueryMode, generate_fallback_snippet, get_result_snippet};\n```\n\n**src/search/fts.rs — key functions:**\n\n1. `to_fts_query(raw: &str, mode: FtsQueryMode) -> String`\n   - Safe mode: wrap each token in quotes, escape internal quotes, preserve trailing * on alphanumeric tokens\n   - Raw mode: pass through unchanged\n\n2. `search_fts(conn: &Connection, query: &str, limit: usize, mode: FtsQueryMode) -> Result<Vec<FtsResult>>`\n   - Uses `bm25(documents_fts)` for ranking\n   - Uses `snippet(documents_fts, 1, '<mark>', '</mark>', '...', 64)` for context\n   - Column index 1 = content_text (0=title)\n\n3. `generate_fallback_snippet(content_text: &str, max_chars: usize) -> String`\n   - For semantic-only results without FTS snippets\n   - Uses `truncate_utf8()` for safe byte boundaries\n\n4. `truncate_utf8(s: &str, max_bytes: usize) -> &str`\n   - Walks backward from max_bytes to find nearest char boundary\n\n5. `get_result_snippet(fts_snippet: Option<&str>, content_text: &str) -> String`\n   - Prefers FTS snippet, falls back to truncated content\n\nUpdate `src/lib.rs`: add `pub mod search;`\n\n## Acceptance Criteria\n- [ ] Porter stemming works: search \"searching\" matches document containing \"search\"\n- [ ] Prefix search works: `auth*` matches \"authentication\"\n- [ ] Empty query returns empty Vec (no error)\n- [ ] Special characters don't cause FTS5 errors: `-`, `\"`, `:`, `*`\n- [ ] Query `\"-DWITH_SSL\"` returns results (dash not treated as NOT operator)\n- [ ] Query `C++` returns results (special chars preserved in quotes)\n- [ ] Safe mode preserves trailing `*` on alphanumeric tokens: `auth*` -> `\"auth\"*`\n- [ ] Raw mode passes query unchanged\n- [ ] BM25 scores returned (lower = better match)\n- [ ] Snippets contain `<mark>` tags around matches\n- [ ] `generate_fallback_snippet` truncates at word boundary, appends \"...\"\n- [ ] `truncate_utf8` never panics on multi-byte codepoints\n- [ ] `cargo test fts` passes\n\n## Files\n- `src/search/mod.rs` — new file (module root)\n- `src/search/fts.rs` — new file (FTS5 search + query sanitization)\n- `src/lib.rs` — add `pub mod search;`\n\n## TDD Loop\nRED: Tests in `fts.rs` `#[cfg(test)] mod tests`:\n- `test_safe_query_basic` — \"auth error\" -> `\"auth\" \"error\"`\n- `test_safe_query_prefix` — \"auth*\" -> `\"auth\"*`\n- `test_safe_query_special_chars` — \"C++\" -> `\"C++\"`\n- `test_safe_query_dash` — \"-DWITH_SSL\" -> `\"-DWITH_SSL\"`\n- `test_safe_query_quotes` — `he said \"hello\"` -> escaped\n- `test_raw_mode_passthrough` — raw query unchanged\n- `test_empty_query` — returns empty vec\n- `test_truncate_utf8_emoji` — truncate mid-emoji walks back\n- `test_fallback_snippet_word_boundary` — truncates at space\nGREEN: Implement to_fts_query, search_fts, helpers\nVERIFY: `cargo test fts`\n\n## Edge Cases\n- Query with only whitespace: treated as empty, returns empty\n- Query with only special characters: quoted, may return no results (not an error)\n- Very long query (1000+ chars): works but may be slow (no explicit limit)\n- FTS5 snippet returns empty string: fallback to truncated content_text\n- Non-alphanumeric prefix: `C++*` — NOT treated as prefix (special chars present)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:26:13.005179Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:23:35.204290Z","closed_at":"2026-01-30T17:23:35.204106Z","close_reason":"Completed: to_fts_query (safe/raw modes), search_fts with BM25+snippets, generate_fallback_snippet, get_result_snippet, truncate_utf8 reuse, 13 tests pass","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1k1","depends_on_id":"bd-221","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1k4","title":"OBSERV: Add get_log_dir() helper to paths module","description":"## Background\nA centralized helper for the log directory path ensures consistent XDG compliance and directory creation. The existing get_data_dir() (src/core/paths.rs:40-43) returns ~/.local/share/lore/. We add a sibling that appends /logs/.\n\n## Approach\nAdd to src/core/paths.rs, after get_db_path() (around line 53):\n\n```rust\n/// Get the log directory path. Creates the directory if it doesn't exist.\npub fn get_log_dir(config_override: Option<&str>) -> PathBuf {\n    let dir = if let Some(path) = config_override {\n        PathBuf::from(path)\n    } else {\n        get_data_dir().join(\"logs\")\n    };\n    std::fs::create_dir_all(&dir).ok();\n    dir\n}\n```\n\nThe config_override comes from LoggingConfig.log_dir (bd-17n). When None, uses XDG default.\n\nExisting pattern to follow (src/core/paths.rs:40-53):\n- get_data_dir() -> PathBuf (returns ~/.local/share/lore/)\n- get_db_path(config_override: Option<&str>) -> PathBuf\n\n## Acceptance Criteria\n- [ ] get_log_dir(None) returns ~/.local/share/lore/logs/\n- [ ] get_log_dir(Some(\"/tmp/custom\")) returns /tmp/custom\n- [ ] Directory is created if it doesn't exist\n- [ ] Function is pub and accessible from other modules\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/core/paths.rs (add get_log_dir function after line ~53)\n\n## TDD Loop\nRED: test_get_log_dir_default, test_get_log_dir_override (use tempdir)\nGREEN: Add get_log_dir() function\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- create_dir_all failure (e.g., permissions): .ok() swallows error silently. This matches get_db_path() which also doesn't create dirs. Consider: should we propagate the error? The subscriber init will fail anyway if the dir doesn't exist, providing a clear error.\n- Trailing slash: PathBuf handles this correctly","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-04T15:53:55.525165Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:10:22.907812Z","closed_at":"2026-02-04T17:10:22.907763Z","close_reason":"Added get_log_dir() helper mirroring get_db_path/get_backup_dir pattern","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-1k4","depends_on_id":"bd-2nx","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1kh","title":"[CP0] Raw payload handling - compression and deduplication","description":"## Background\n\nRaw payload storage allows replaying API responses for debugging and audit. Compression reduces storage for large payloads. SHA-256 deduplication prevents storing identical payloads multiple times (important for frequently polled resources that haven't changed).\n\nReference: docs/prd/checkpoint-0.md section \"Raw Payload Handling\"\n\n## Approach\n\n**src/core/payloads.ts:**\n```typescript\nimport { createHash } from 'node:crypto';\nimport { gzipSync, gunzipSync } from 'node:zlib';\nimport Database from 'better-sqlite3';\nimport { nowMs } from './time';\n\ninterface StorePayloadOptions {\n  projectId: number | null;\n  resourceType: string;  // 'project' | 'issue' | 'mr' | 'note' | 'discussion'\n  gitlabId: string;      // TEXT because discussion IDs are strings\n  payload: unknown;      // JSON-serializable object\n  compress: boolean;     // from config.storage.compressRawPayloads\n}\n\nexport function storePayload(db: Database.Database, options: StorePayloadOptions): number | null {\n  // 1. JSON.stringify the payload\n  // 2. SHA-256 hash the JSON bytes\n  // 3. Check for duplicate by (project_id, resource_type, gitlab_id, payload_hash)\n  // 4. If duplicate, return existing ID\n  // 5. If compress=true, gzip the JSON bytes\n  // 6. INSERT with content_encoding='gzip' or 'identity'\n  // 7. Return lastInsertRowid\n}\n\nexport function readPayload(db: Database.Database, id: number): unknown {\n  // 1. SELECT content_encoding, payload FROM raw_payloads WHERE id = ?\n  // 2. If gzip, decompress\n  // 3. JSON.parse and return\n}\n```\n\n## Acceptance Criteria\n\n- [ ] storePayload() with compress=true stores gzip-encoded payload\n- [ ] storePayload() with compress=false stores identity-encoded payload\n- [ ] Duplicate payload (same hash) returns existing row ID, not new row\n- [ ] readPayload() correctly decompresses gzip payloads\n- [ ] readPayload() returns null for non-existent ID\n- [ ] SHA-256 hash computed from pre-compression JSON bytes\n- [ ] Large payloads (100KB+) compress to ~10-20% of original size\n\n## Files\n\nCREATE:\n- src/core/payloads.ts\n- tests/unit/payloads.test.ts\n\n## TDD Loop\n\nRED:\n```typescript\n// tests/unit/payloads.test.ts\ndescribe('Payload Storage', () => {\n  describe('storePayload', () => {\n    it('stores uncompressed payload with identity encoding')\n    it('stores compressed payload with gzip encoding')\n    it('deduplicates identical payloads by hash')\n    it('stores different payloads for same gitlab_id')\n  })\n\n  describe('readPayload', () => {\n    it('reads uncompressed payload')\n    it('reads and decompresses gzip payload')\n    it('returns null for non-existent id')\n  })\n})\n```\n\nGREEN: Implement storePayload() and readPayload()\n\nVERIFY: `npm run test -- tests/unit/payloads.test.ts`\n\n## Edge Cases\n\n- gitlabId is TEXT not INTEGER - discussion IDs are UUIDs\n- Compression ratio varies - some JSON compresses better than others\n- null projectId valid for global resources (like user profile)\n- Hash collision extremely unlikely with SHA-256 but unique index enforces","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-24T16:09:50.189494Z","created_by":"tayloreernisse","updated_at":"2026-01-25T03:19:12.854771Z","closed_at":"2026-01-25T03:19:12.854372Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1kh","depends_on_id":"bd-3ng","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1ksf","title":"Wire up hybrid search: FTS5 + vector + RRF ranking","description":"## Problem\nlore search hardcodes lexical-only mode. The full hybrid/vector/RRF backend is ALREADY IMPLEMENTED and tested -- it just needs to be called from the CLI.\n\n## Current State (Verified 2026-02-12)\n\n### Backend: COMPLETE\n- `search_hybrid()` in src/search/hybrid.rs:47 — async fn, handles Lexical/Semantic/Hybrid modes with graceful degradation\n- `search_vector()` in src/search/vector.rs:43 — sqlite-vec KNN with chunk deduplication and adaptive k multiplier\n- `rank_rrf()` in src/search/rrf.rs:13 — reciprocal rank fusion with normalization (7 passing tests)\n- `SearchMode::parse()` — parses hybrid, lexical/fts, semantic/vector\n- `OllamaClient::embed_batch()` in src/embedding/ollama.rs:103 — batch embedding via Ollama /api/embed endpoint\n- All exported from src/search/mod.rs:7-14\n\n### CLI: BROKEN\n- src/cli/commands/search.rs:61 `run_search()` is SYNCHRONOUS (not async)\n- Line 76: `let actual_mode = \"lexical\";` — hardcoded\n- Lines 77-82: warns if user requests vector/hybrid, falls back to lexical\n- Line 161: calls `search_fts()` directly instead of `search_hybrid()`\n- Line 172: calls `rank_rrf(&[], &fts_tuples)` — empty vector list, FTS-only ranking\n- Lines 143-152: manually constructs `SearchFilters` (this code is reusable)\n- Lines 187-223: hydrates + maps to `SearchResultDisplay` (this can be adapted)\n\n### Entry Point\n- src/main.rs:1731 `async fn handle_search()` — IS async, but calls `run_search()` synchronously at line 1758\n- main.rs is 2579 lines total\n\n## Actual Work Required\n\n### Step 1: Make run_search async\nChange `pub fn run_search(...)` to `pub async fn run_search(...)` in search.rs:61.\nUpdate handle_search call site (main.rs:1758) to `.await`.\n\n### Step 2: Create OllamaClient when mode != lexical\nPattern from src/cli/commands/embed.rs — reuse `OllamaConfig` from config:\n```rust\nlet client = if actual_mode != SearchMode::Lexical {\n    let ollama_cfg = &config.embedding;\n    Some(OllamaClient::new(&ollama_cfg.ollama_url, &ollama_cfg.model))\n} else {\n    None\n};\n```\n\n### Step 3: Replace manual FTS+filter+rank with search_hybrid call\nReplace lines 161-172 (search_fts + rank_rrf) with:\n```rust\nlet (hybrid_results, mut hybrid_warnings) = search_hybrid(\n    &conn,\n    client.as_ref(),\n    query,\n    actual_mode,\n    &filters,\n    fts_mode,\n).await?;\nwarnings.append(&mut hybrid_warnings);\n```\n\n### Step 4: Map HybridResult to SearchResultDisplay\nHybridResult (src/search/hybrid.rs:39-45) has these fields:\n```rust\npub struct HybridResult {\n    pub document_id: i64,\n    pub score: f64,           // combined score\n    pub vector_rank: Option<usize>,\n    pub fts_rank: Option<usize>,\n    pub rrf_score: f64,\n}\n```\nNOTE: HybridResult has NO `snippet` field and NO `normalized_score` field. `score` is the combined score. The `snippet` must still be obtained from the FTS results or from `get_result_snippet()`.\n\nSearchResultDisplay needs: document_id, source_type, title, url, author, etc. (from hydration).\nKeep the existing hydrate_results() call (line 187) and rrf_map construction (lines 189-190), but adapt to use HybridResult instead of RrfResult:\n```rust\n// Map hybrid results for lookup\nlet hybrid_map: HashMap<i64, &HybridResult> =\n    hybrid_results.iter().map(|r| (r.document_id, r)).collect();\n\n// For each hydrated row:\nlet hr = hybrid_map.get(&row.document_id);\nlet explain_data = if explain {\n    hr.map(|r| ExplainData {\n        vector_rank: r.vector_rank,\n        fts_rank: r.fts_rank,\n        rrf_score: r.rrf_score,\n    })\n} else { None };\n// score: hr.map(|r| r.score).unwrap_or(0.0)\n```\n\nFor snippets: search_hybrid calls search_fts internally, but does NOT return snippets. You need to either:\n(a) Call search_fts separately just for snippets, or\n(b) Modify search_hybrid to also return a snippet_map — preferred if touching hybrid.rs is in scope.\nSimpler approach: keep the existing `search_fts()` call for snippets, use hybrid for ranking. The FTS call is fast (<50ms) and avoids modifying the already-complete hybrid.rs.\n\n### Step 5: Determine actual_mode from config + CLI flag\n```rust\nlet actual_mode = SearchMode::parse(requested_mode).unwrap_or(SearchMode::Hybrid);\n// search_hybrid handles graceful degradation internally\n```\n\n## Signatures for Reference\n\n```rust\n// src/search/hybrid.rs:47\npub async fn search_hybrid(\n    conn: &Connection,\n    client: Option<&OllamaClient>,\n    query: &str,\n    mode: SearchMode,\n    filters: &SearchFilters,\n    fts_mode: FtsQueryMode,\n) -> Result<(Vec<HybridResult>, Vec<String>)>\n\n// src/search/hybrid.rs:39\npub struct HybridResult {\n    pub document_id: i64,\n    pub score: f64,\n    pub vector_rank: Option<usize>,\n    pub fts_rank: Option<usize>,\n    pub rrf_score: f64,\n}\n\n// src/search/mod.rs exports\npub use hybrid::{HybridResult, SearchMode, search_hybrid};\npub use rrf::{RrfResult, rank_rrf};\npub use vector::{VectorResult, search_vector};\n\n// src/embedding/ollama.rs:103\npub async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>>\n```\n\n## TDD Loop\nRED: Add test in src/search/hybrid.rs:\n- test_hybrid_lexical_fallback_no_ollama: search_hybrid with mode=Hybrid, client=None returns FTS results + warning\n- test_hybrid_mode_detection: verify default mode is Hybrid when embeddings exist\n\nGREEN: Wire search.rs to call search_hybrid() as described above\n\nVERIFY:\n```bash\ncargo test search:: && cargo clippy --all-targets -- -D warnings\ncargo run --release -- -J search 'throw time' --mode hybrid --explain | jq '.data.mode'\n# Should return \"hybrid\" (or \"lexical\" with warning if Ollama is down)\n```\n\n## Edge Cases\n- Ollama running but model not found: clear error with suggestion to run `ollama pull nomic-embed-text`\n- No embeddings in DB (never ran lore embed): search_vector returns empty, RRF uses FTS only — search_hybrid handles this gracefully\n- Query embedding returns all zeros: should still return FTS results\n- Very long query string (>1500 bytes): chunk or truncate before embedding (CHUNK_MAX_BYTES=1500)\n- sqlite-vec table missing (old DB without migration 009): graceful error from search_vector\n- OllamaConfig missing from config: check `config.embedding` exists before constructing client\n- Snippet handling: HybridResult has no snippet field — must obtain snippets from a separate search_fts call or from get_result_snippet() with content_text fallback\n\n## Files to Modify\n- src/cli/commands/search.rs — make run_search async, replace manual FTS+RRF with search_hybrid call (~80 lines replaced with ~20)\n- src/main.rs:1758 — add .await to run_search call (already in async context)\n\n## Files NOT to Modify (already complete)\n- src/search/hybrid.rs\n- src/search/vector.rs\n- src/search/rrf.rs\n- src/embedding/ollama.rs","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-02-12T15:45:56.305343Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:49:25.720332Z","closed_at":"2026-02-12T16:49:25.720209Z","compaction_level":0,"original_size":0,"labels":["cli-imp","search"],"dependencies":[{"issue_id":"bd-1ksf","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1ksf","depends_on_id":"bd-2l3s","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1l1","title":"[CP0] GitLab API client with rate limiting","description":"## Background\n\nThe GitLab client handles all API communication with rate limiting to avoid 429 errors. Uses native fetch (Node 18+). Rate limiter adds jitter to prevent thundering herd. All errors are typed for clean error handling in CLI commands.\n\nReference: docs/prd/checkpoint-0.md section \"GitLab Client\"\n\n## Approach\n\n**src/gitlab/client.ts:**\n```typescript\nexport class GitLabClient {\n  private baseUrl: string;\n  private token: string;\n  private rateLimiter: RateLimiter;\n\n  constructor(options: { baseUrl: string; token: string; requestsPerSecond?: number }) {\n    this.baseUrl = options.baseUrl.replace(/\\/$/, '');\n    this.token = options.token;\n    this.rateLimiter = new RateLimiter(options.requestsPerSecond ?? 10);\n  }\n\n  async getCurrentUser(): Promise<GitLabUser>\n  async getProject(pathWithNamespace: string): Promise<GitLabProject>\n  private async request<T>(path: string, options?: RequestInit): Promise<T>\n}\n\nclass RateLimiter {\n  private lastRequest = 0;\n  private minInterval: number;\n\n  constructor(requestsPerSecond: number) {\n    this.minInterval = 1000 / requestsPerSecond;\n  }\n\n  async acquire(): Promise<void> {\n    // Wait if too soon since last request\n    // Add 0-50ms jitter\n  }\n}\n```\n\n**src/gitlab/types.ts:**\n```typescript\nexport interface GitLabUser {\n  id: number;\n  username: string;\n  name: string;\n}\n\nexport interface GitLabProject {\n  id: number;\n  path_with_namespace: string;\n  default_branch: string;\n  web_url: string;\n  created_at: string;\n  updated_at: string;\n}\n```\n\n**Integration tests with MSW (Mock Service Worker):**\nSet up MSW handlers that mock GitLab API responses for /api/v4/user and /api/v4/projects/:path\n\n## Acceptance Criteria\n\n- [ ] getCurrentUser() returns GitLabUser with id, username, name\n- [ ] getProject(\"group/project\") URL-encodes path correctly\n- [ ] 401 response throws GitLabAuthError\n- [ ] 404 response throws GitLabNotFoundError\n- [ ] 429 response throws GitLabRateLimitError with retryAfter from header\n- [ ] Network failure throws GitLabNetworkError\n- [ ] Rate limiter enforces minimum interval between requests\n- [ ] Rate limiter adds random jitter (0-50ms)\n- [ ] tests/integration/gitlab-client.test.ts passes (6 tests)\n\n## Files\n\nCREATE:\n- src/gitlab/client.ts\n- src/gitlab/types.ts\n- tests/integration/gitlab-client.test.ts\n- tests/fixtures/mock-responses/gitlab-user.json\n- tests/fixtures/mock-responses/gitlab-project.json\n\n## TDD Loop\n\nRED:\n```typescript\n// tests/integration/gitlab-client.test.ts\ndescribe('GitLab Client', () => {\n  it('authenticates with valid PAT')\n  it('returns 401 for invalid PAT')\n  it('fetches project by path')\n  it('handles rate limiting (429) with Retry-After')\n  it('respects rate limit (requests per second)')\n  it('adds jitter to rate limiting')\n})\n```\n\nGREEN: Implement client.ts and types.ts\n\nVERIFY: `npm run test -- tests/integration/gitlab-client.test.ts`\n\n## Edge Cases\n\n- Path with special characters (spaces, slashes) must be URL-encoded\n- Retry-After header may be missing - default to 60s\n- Network timeout should be handled (use AbortController)\n- Rate limiter jitter prevents multiple clients syncing in lockstep\n- baseUrl trailing slash should be stripped","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-24T16:09:49.842981Z","created_by":"tayloreernisse","updated_at":"2026-01-25T03:06:39.520300Z","closed_at":"2026-01-25T03:06:39.520131Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1l1","depends_on_id":"bd-gg1","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1lj5","title":"Epic: Asupersync Migration — Replace Tokio + Reqwest","description":"## Background\n\nGitlore uses tokio as its async runtime and reqwest as its HTTP client. Both work, but:\n- Ctrl+C during join_all silently drops in-flight HTTP requests with no cleanup\n- ShutdownSignal is a hand-rolled AtomicBool with no structured cancellation\n- No deterministic testing for concurrent ingestion patterns\n- tokio provides no structured concurrency guarantees\n\nAsupersync is a cancel-correct async runtime with region-owned tasks, obligation tracking, and deterministic lab testing. Replacing tokio+reqwest gives us structured shutdown, cancel-correct ingestion, and testable concurrency.\n\n## Trade-offs Accepted\n- Nightly Rust required (asupersync dependency)\n- Pre-1.0 runtime dependency (mitigated by adapter layer + version pinning)\n- Deeper function signature changes for Cx threading\n\n## Why Not tokio CancellationToken + JoinSet?\nConsidered and rejected: CancellationToken/JoinSet fix cancel-cleanly but don't give obligation tracking (compile-time proof all spawned work is awaited) or deterministic lab testing. These prevent future concurrency bugs, not just the current Ctrl+C issue. Also, fixing Ctrl+C with tokio first then migrating doubles the effort. If asupersync proves unviable, the fallback IS tokio + CancellationToken + JoinSet.\n\n## Escape Hatch Triggers\n1. Nightly breakage > 7 days with no fix\n2. TLS incompatibility on macOS (system CA store)\n3. Breaking asupersync API change requiring > 2 days rework\n4. Wiremock test incompatibility unresolvable in 1 day\n\n## Execution Order\nPhase 0 (prep) -> Decision Gate -> Phases 1-3 (atomic) -> Phase 4 (tests) -> Phase 5 (hardening)\n\n## Rearchitecture Context (2026-03-06)\nA major code reorganization was completed before implementation began. Key changes affecting this migration:\n\n### main.rs thinned\n- main.rs is now ~419 LOC (was ~3744). Handler code lives in src/app/handlers.rs, src/app/errors.rs, src/app/robot_docs.rs via include!() chain.\n- Signal handlers, command dispatch, and Cx threading targets are in src/app/handlers.rs.\n\n### CLI commands split into folder modules\n- cli/commands/sync.rs -> cli/commands/sync/ (mod.rs, run.rs, render.rs, surgical.rs, sync_tests.rs)\n- cli/commands/ingest.rs -> cli/commands/ingest/ (mod.rs, run.rs, render.rs)\n- cli/commands/sync_surgical.rs -> cli/commands/sync/surgical.rs\n- cli/commands/list.rs -> cli/commands/list/ (mod.rs, issues.rs, mrs.rs, notes.rs, render_helpers.rs)\n- cli/commands/show.rs -> cli/commands/show/ (mod.rs, issue.rs, mr.rs, render.rs)\n\n### Timeline extracted from core\n- core/timeline*.rs -> timeline/ (types.rs, seed.rs, expand.rs, collect.rs + tests)\n\n### Cross-references extracted from core\n- core/note_parser.rs -> xref/note_parser.rs\n- core/references.rs -> xref/references.rs\n\n### Ingestion storage extracted from core\n- core/payloads.rs -> ingestion/storage/payloads.rs\n- core/events_db.rs -> ingestion/storage/events.rs\n- core/dependent_queue.rs -> ingestion/storage/queue.rs\n- core/sync_run.rs -> ingestion/storage/sync_run.rs\n\n### Embedding chunks merged\n- embedding/chunk_ids.rs + embedding/chunking.rs -> embedding/chunks.rs\n\n### Test support centralized\n- src/test_support.rs (NEW) — shared test helpers\n\n### CLI args extracted\n- cli/mod.rs split: args structs moved to cli/args.rs\n\n### Documents extractor split\n- documents/extractor.rs -> documents/extractor/ (mod.rs, common.rs, issues.rs, mrs.rs, discussions.rs, notes.rs)\n\n## File Change Summary (updated)\n~20+ files modified across the migration, 1 new file (src/http.rs), ~400-500 LOC changed total. File paths in child beads have been updated to reflect the rearchitecture.\n\n## Reference\nFull plan: plans/asupersync-migration.md\nRearchitecture plan: PROPOSED_CODE_FILE_REORGANIZATION_PLAN.md","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-03-06T18:37:52.914426Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:33:10.959599Z","closed_at":"2026-03-11T18:33:10.959551Z","close_reason":"All 22 child phases completed. Migration from tokio+reqwest to asupersync fully shipped with 1,190 tests passing.","compaction_level":0,"original_size":0,"labels":["asupersync"]}
{"id":"bd-1lja","title":"Add --issue, --mr, -p, --preflight-only CLI flags and SyncOptions extensions with validation","description":"## Background\nSurgical sync is invoked via `lore sync --issue 123 --mr 456 -p myproject`. This bead adds the CLI flags to `SyncArgs` (clap struct), extends `SyncOptions` with surgical fields, and wires them together in `handle_sync_cmd` with full validation. This is the user-facing entry point for the entire surgical sync feature.\n\nThe existing `SyncArgs` struct at lines 760-805 of `src/cli/mod.rs` defines all CLI flags for `lore sync`. `SyncOptions` at lines 20-29 of `src/cli/commands/sync.rs` is the runtime options struct passed to `run_sync`. `handle_sync_cmd` at lines 2070-2096 of `src/main.rs` bridges CLI args to SyncOptions and calls `run_sync`.\n\n## Approach\n\n### Step 1: Add flags to SyncArgs (src/cli/mod.rs, struct SyncArgs at line ~760)\n\nAdd after the existing `timings` field:\n\n```rust\n/// Surgically sync specific issues by IID (repeatable, must be positive)\n#[arg(long, value_parser = clap::value_parser!(u64).range(1..), action = clap::ArgAction::Append)]\npub issue: Vec<u64>,\n\n/// Surgically sync specific merge requests by IID (repeatable, must be positive)\n#[arg(long, value_parser = clap::value_parser!(u64).range(1..), action = clap::ArgAction::Append)]\npub mr: Vec<u64>,\n\n/// Scope to a single project (required when --issue or --mr is used, falls back to config.defaultProject)\n#[arg(short = 'p', long)]\npub project: Option<String>,\n\n/// Validate remote entities exist without any DB content writes. Runs preflight network fetch only.\n#[arg(long, default_value_t = false)]\npub preflight_only: bool,\n```\n\n**Why u64 with range(1..)**: IIDs are always positive. Parse-time validation gives immediate, clear error messages from clap.\n\n### Step 2: Extend SyncOptions (src/cli/commands/sync.rs, struct SyncOptions at line ~20)\n\nAdd fields:\n\n```rust\npub issue_iids: Vec<u64>,\npub mr_iids: Vec<u64>,\npub project: Option<String>,\npub preflight_only: bool,\n```\n\nAdd helper:\n\n```rust\nimpl SyncOptions {\n    pub const MAX_SURGICAL_TARGETS: usize = 100;\n\n    pub fn is_surgical(&self) -> bool {\n        !self.issue_iids.is_empty() || !self.mr_iids.is_empty()\n    }\n}\n```\n\n### Step 3: Wire in handle_sync_cmd (src/main.rs, function handle_sync_cmd at line ~2070)\n\nAfter existing SyncOptions construction (~line 2088):\n\n1. **Dedup IIDs** before constructing options:\n```rust\nlet mut issue_iids = args.issue;\nlet mut mr_iids = args.mr;\nissue_iids.sort_unstable();\nissue_iids.dedup();\nmr_iids.sort_unstable();\nmr_iids.dedup();\n```\n\n2. **Add new fields** to the SyncOptions construction.\n\n3. **Validation** (after options creation, before calling run_sync):\n- Hard cap: `issue_iids.len() + mr_iids.len() > MAX_SURGICAL_TARGETS` → error with count\n- Project required: if `is_surgical()`, use `config.effective_project(options.project.as_deref())`. If None → error saying `-p` or `defaultProject` is required\n- Incompatible flags: `--full` + surgical → error\n- Embed leakage guard: `--no-docs` without `--no-embed` in surgical mode → error (stale embeddings for regenerated docs)\n- `--preflight-only` requires surgical mode → error if not `is_surgical()`\n\n## Acceptance Criteria\n- [ ] `lore sync --issue 123` parses correctly (issue_iids = [123])\n- [ ] `lore sync --issue 123 --issue 456` produces deduplicated sorted vec\n- [ ] `lore sync --mr 789` parses correctly\n- [ ] `lore sync --issue 0` rejected at parse time by clap (range 1..)\n- [ ] `lore sync --issue -1` rejected at parse time by clap (u64 parse failure)\n- [ ] `lore sync -p myproject --issue 1` sets project = Some(\"myproject\")\n- [ ] `lore sync --preflight-only --issue 1 -p proj` sets preflight_only = true\n- [ ] `SyncOptions::is_surgical()` returns true when issue_iids or mr_iids is non-empty\n- [ ] `SyncOptions::is_surgical()` returns false when both vecs are empty\n- [ ] `SyncOptions::MAX_SURGICAL_TARGETS` is 100\n- [ ] Validation: `--issue 1` without `-p` and no defaultProject → error mentioning `-p`\n- [ ] Validation: `--issue 1` without `-p` but with defaultProject in config → uses defaultProject (no error)\n- [ ] Validation: `--full --issue 1 -p proj` → incompatibility error\n- [ ] Validation: `--no-docs --issue 1 -p proj` (without --no-embed) → embed leakage error\n- [ ] Validation: `--no-docs --no-embed --issue 1 -p proj` → accepted\n- [ ] Validation: `--preflight-only` without --issue/--mr → error\n- [ ] Validation: >100 combined targets → hard cap error\n- [ ] Normal `lore sync` (without --issue/--mr) still works identically\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n- MODIFY: src/cli/mod.rs (add fields to SyncArgs, ~line 805)\n- MODIFY: src/cli/commands/sync.rs (extend SyncOptions + is_surgical + MAX_SURGICAL_TARGETS)\n- MODIFY: src/main.rs (wire fields + validation in handle_sync_cmd)\n\n## TDD Anchor\nRED: Write tests in `src/cli/commands/sync.rs` (in a `#[cfg(test)] mod tests` block):\n\n```rust\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    fn default_options() -> SyncOptions {\n        SyncOptions {\n            full: false,\n            no_status: false,\n            no_docs: false,\n            no_embed: false,\n            timings: false,\n            issue_iids: vec![],\n            mr_iids: vec![],\n            project: None,\n            preflight_only: false,\n        }\n    }\n\n    #[test]\n    fn is_surgical_with_issues() {\n        let opts = SyncOptions { issue_iids: vec![1], ..default_options() };\n        assert!(opts.is_surgical());\n    }\n\n    #[test]\n    fn is_surgical_with_mrs() {\n        let opts = SyncOptions { mr_iids: vec![10], ..default_options() };\n        assert!(opts.is_surgical());\n    }\n\n    #[test]\n    fn is_surgical_empty() {\n        let opts = default_options();\n        assert!(!opts.is_surgical());\n    }\n\n    #[test]\n    fn max_surgical_targets_is_100() {\n        assert_eq!(SyncOptions::MAX_SURGICAL_TARGETS, 100);\n    }\n}\n```\n\nGREEN: Add the fields and `is_surgical()` method.\nVERIFY: `cargo test is_surgical && cargo test max_surgical_targets`\n\nAdditional validation tests (in integration or as unit tests on a `validate_surgical_options` helper if extracted):\n- `preflight_only_requires_surgical` — SyncOptions with preflight_only=true, empty iids → error\n- `surgical_no_docs_requires_no_embed` — SyncOptions with no_docs=true, no_embed=false, is_surgical=true → error\n- `surgical_incompatible_with_full` — SyncOptions with full=true, is_surgical=true → error\n\n## Edge Cases\n- Clap `ArgAction::Append` allows `--issue 1 --issue 2` but NOT `--issue 1,2` (no value_delimiter). This is intentional — comma-separated values are ambiguous and error-prone.\n- Duplicate IIDs like `--issue 123 --issue 123` are handled by dedup in handle_sync_cmd, not rejected.\n- The `effective_project` method on Config (line 309 of config.rs) already handles the `-p` / defaultProject fallback: `cli_project.or(self.default_project.as_deref())`.\n- The `-p` short flag does not conflict with any existing SyncArgs flags.\n\n## Dependency Context\nThis is a leaf dependency with no upstream blockers. Can be done in parallel with bd-1sc6, bd-159p, bd-tiux. Downstream bead bd-1i4i (orchestrator) reads these fields to dispatch surgical vs standard sync.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-17T19:12:43.921399Z","created_by":"tayloreernisse","updated_at":"2026-02-18T21:03:39.041002Z","closed_at":"2026-02-18T21:03:39.040947Z","close_reason":"Completed: --issue, --mr, -p, --preflight-only CLI flags, SyncOptions.is_surgical(), MAX_SURGICAL_TARGETS, validation","compaction_level":0,"original_size":0,"labels":["surgical-sync"],"dependencies":[{"issue_id":"bd-1lja","depends_on_id":"bd-1i4i","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1lli","title":"Decision Gate: Verify nightly + asupersync compile and behavioral smoke tests","description":"## What\nBefore investing in Phases 1-3, verify that asupersync compiles on the pinned nightly with tls-native-roots on macOS AND that HTTP behavior and API shape are correct. This is a GO/NO-GO gate.\n\n## Why\nCompile-only gating is insufficient — the migration failure modes are semantic (HTTP behavior parity), not just syntactic. A broken TLS stack, missing timeout support, or different-than-assumed API shape would make the entire migration unviable.\n\n## Implementation\n\n1. Create rust-toolchain.toml with pinned nightly date:\n\\`\\`\\`toml\n[toolchain]\nchannel = \\\"nightly-2026-03-01\\\"\n\\`\\`\\`\n\n2. Add asupersync to Cargo.toml temporarily (can be in a test binary):\n\\`\\`\\`toml\nasupersync = { version = \\\"0.2\\\", features = [\\\"tls\\\", \\\"tls-native-roots\\\"] }\n\\`\\`\\`\n\n3. Build and verify compilation succeeds.\n\n4. **API shape verification** — confirm the following APIs exist and match plan assumptions:\n   a. HttpClient::with_config(HttpClientConfig) — connection pool configuration\n   b. HttpClient::request(Method, url, headers, body) -> response with status/reason/headers/body\n   c. asupersync::time::timeout(duration, future) — per-request timeout wrapper\n   d. asupersync::time::sleep(duration) — rate limiter backoff replacement\n   e. #[asupersync::main] macro — entrypoint with cx: &Cx parameter\n   f. #[asupersync::test] macro — test attribute\n   g. cx.spawn(name, async closure) — named task spawning\n   h. cx.region(|scope| async { scope.spawn(...) }) — region-scoped fan-out\n   i. cx.shutdown_signal().await — signal handling\n   If ANY of these don't exist or have significantly different signatures, document the delta and assess rework cost.\n\n5. Run behavioral smoke tests in a throwaway binary or integration test:\n   a. TLS validation: HTTPS GET to a public endpoint succeeds with valid cert\n   b. DNS resolution: Request using hostname (not IP) resolves correctly\n   c. Redirect handling: GET to a 301/302 endpoint — verify adapter returns redirect status\n   d. Timeout behavior: Request to slow/non-responsive endpoint times out within configured duration\n   e. Connection pooling: 4 sequential requests to same host reuse connections\n\n## Decision Criteria\n- If compilation fails: STOP. Evaluate tokio CancellationToken fallback.\n- If TLS doesn't work on macOS: STOP. Try tls-webpki-roots. If that fails too, fallback.\n- If timeouts don't fire: STOP. Core requirement.\n- If API shape differs significantly (>2 day rework): STOP. Evaluate fallback.\n- If all pass: PROCEED to Phases 1-3.\n\n## Escape Hatch (documented in epic)\nIf gate fails, fall back to: tokio + CancellationToken + JoinSet. The adapter layer design is still valid — swap asupersync::http for reqwest behind same crate::http::Client API.\n\n## Files Changed\n- rust-toolchain.toml (NEW, 3 LOC)\n- Cargo.toml (temporary test dep, will be finalized in Phase 3a)\n- Throwaway test binary or integration test for smoke tests\n\n## Output\nDocument results: pass/fail for each of the 5 smoke tests + API shape verification. If all pass, update this bead with results and close.","status":"closed","priority":0,"issue_type":"task","created_at":"2026-03-06T18:39:05.093888Z","created_by":"tayloreernisse","updated_at":"2026-03-06T21:11:12.563988Z","closed_at":"2026-03-06T21:11:12.563939Z","close_reason":"asupersync migration complete — 5 commits pushed to asupersync-migration branch","compaction_level":0,"original_size":0,"labels":["asupersync","gate"],"dependencies":[{"issue_id":"bd-1lli","depends_on_id":"bd-1iuj","type":"blocks","created_at":"2026-03-06T18:42:49.101418Z","created_by":"tayloreernisse"},{"issue_id":"bd-1lli","depends_on_id":"bd-1lj5","type":"parent-child","created_at":"2026-03-06T18:39:05.097877Z","created_by":"tayloreernisse"},{"issue_id":"bd-1lli","depends_on_id":"bd-1p58","type":"blocks","created_at":"2026-03-06T18:55:28.333282Z","created_by":"tayloreernisse"},{"issue_id":"bd-1lli","depends_on_id":"bd-21fb","type":"blocks","created_at":"2026-03-06T18:55:28.144786Z","created_by":"tayloreernisse"},{"issue_id":"bd-1lli","depends_on_id":"bd-2xct","type":"blocks","created_at":"2026-03-06T18:55:28.241144Z","created_by":"tayloreernisse"}]}
{"id":"bd-1m8","title":"Extend 'lore stats --check' for event table integrity and queue health","description":"## Background\nThe existing stats --check command validates data integrity. Need to extend it for event tables (referential integrity) and dependent job queue health (stuck locks, retryable jobs). This provides operators and agents a way to detect data quality issues after sync.\n\n## Approach\nExtend src/cli/commands/stats.rs check mode:\n\n**New checks:**\n\n1. Event FK integrity:\n```sql\n-- Orphaned state events (issue_id points to non-existent issue)\nSELECT COUNT(*) FROM resource_state_events rse\nWHERE rse.issue_id IS NOT NULL\n  AND NOT EXISTS (SELECT 1 FROM issues i WHERE i.id = rse.issue_id);\n-- (repeat for merge_request_id, and for label + milestone event tables)\n```\n\n2. Queue health:\n```sql\n-- Pending jobs by type\nSELECT job_type, COUNT(*) FROM pending_dependent_fetches GROUP BY job_type;\n-- Stuck locks (locked_at older than 5 minutes)\nSELECT COUNT(*) FROM pending_dependent_fetches WHERE locked_at IS NOT NULL AND locked_at < ?;\n-- Retryable jobs (attempts > 0, not locked)\nSELECT COUNT(*) FROM pending_dependent_fetches WHERE attempts > 0 AND locked_at IS NULL;\n-- Max attempts (jobs that may be permanently failing)\nSELECT job_type, MAX(attempts) FROM pending_dependent_fetches GROUP BY job_type;\n```\n\n3. Human output per check: PASS / WARN / FAIL with counts\n```\nEvent FK integrity:     PASS (0 orphaned events)\nQueue health:           WARN (3 stuck locks, 12 retryable jobs)\n```\n\n4. Robot JSON: structured health report\n```json\n{\n  \"event_integrity\": {\n    \"status\": \"pass\",\n    \"orphaned_state_events\": 0,\n    \"orphaned_label_events\": 0,\n    \"orphaned_milestone_events\": 0\n  },\n  \"queue_health\": {\n    \"status\": \"warn\",\n    \"pending_by_type\": {\"resource_events\": 5, \"mr_closes_issues\": 2},\n    \"stuck_locks\": 3,\n    \"retryable_jobs\": 12,\n    \"max_attempts_by_type\": {\"resource_events\": 5}\n  }\n}\n```\n\n## Acceptance Criteria\n- [ ] Detects orphaned events (FK target missing)\n- [ ] Detects stuck locks (locked_at older than threshold)\n- [ ] Reports retryable job count and max attempts\n- [ ] Human output shows PASS/WARN/FAIL per check\n- [ ] Robot JSON matches structured schema\n- [ ] Graceful when event/queue tables don't exist\n\n## Files\n- src/cli/commands/stats.rs (extend check mode)\n\n## TDD Loop\nRED: tests/stats_check_tests.rs:\n- `test_stats_check_events_pass` - clean data, verify PASS\n- `test_stats_check_events_orphaned` - delete an issue with events remaining, verify FAIL count\n- `test_stats_check_queue_stuck_locks` - set old locked_at, verify WARN\n- `test_stats_check_queue_retryable` - fail some jobs, verify retryable count\n\nGREEN: Add the check queries and formatting\n\nVERIFY: `cargo test stats_check -- --nocapture`\n\n## Edge Cases\n- FK with CASCADE should prevent orphaned events in normal operation — but manual DB edits or bugs could cause them\n- Tables may not exist if migration 011 not applied — check table existence before querying\n- Empty queue is PASS (not WARN for \"no jobs found\")\n- Distinguish between \"0 stuck locks\" (good) and \"queue table doesn't exist\" (skip check)","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-02T21:31:57.422916Z","created_by":"tayloreernisse","updated_at":"2026-02-03T16:23:13.409909Z","closed_at":"2026-02-03T16:23:13.409717Z","close_reason":"Extended IntegrityResult with orphan_state/label/milestone_events and queue_stuck_locks/queue_max_attempts. Added FK integrity queries for all 3 event tables and queue health checks. Updated human output with PASS/WARN/FAIL indicators and robot JSON.","compaction_level":0,"original_size":0,"labels":["cli","gate-1","phase-b"],"dependencies":[{"issue_id":"bd-1m8","depends_on_id":"bd-2zl","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1m8","depends_on_id":"bd-hu3","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1m8","depends_on_id":"bd-tir","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1mf","title":"[CP1] gi sync-status enhancement","description":"Enhance sync-status from CP0 stub to show issue cursors.\n\nOutput:\n- Last run timestamp and duration\n- Cursor positions per project (issues resource_type)\n- Entity counts (issues, discussions, notes)\n\nFiles: src/cli/commands/sync-status.ts (update existing)\nDone when: Shows cursor positions and counts after ingestion","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T15:20:36.449088Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.157235Z","closed_at":"2026-01-25T15:21:35.157235Z","deleted_at":"2026-01-25T15:21:35.157232Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-1mju","title":"Vertical slice integration test + SLO verification","description":"## Background\nThe vertical slice gate validates that core screens work together end-to-end with real data flows and meet performance SLOs. This is a manual + automated verification pass.\n\n## Approach\nCreate integration tests in crates/lore-tui/tests/:\n- test_full_nav_flow: Dashboard -> press i -> IssueList loads -> press Enter -> IssueDetail loads -> press Esc -> back to IssueList with cursor preserved -> press H -> Dashboard\n- test_filter_requery: IssueList -> type filter -> verify re-query triggers and results update\n- test_stale_result_guard: rapidly navigate between screens, verify no stale data displayed\n- Performance benchmarks: run M-tier fixture, measure p95 nav latency, assert < 75ms\n- Stuck-input check: fuzz InputMode transitions, assert always recoverable via Esc or Ctrl+C\n- Cancel latency: start sync, cancel, measure time to acknowledgment, assert < 2s\n\n## Acceptance Criteria\n- [ ] Full nav flow test passes without panic\n- [ ] Filter re-query test shows updated results\n- [ ] No stale data displayed during rapid navigation\n- [ ] p95 nav latency < 75ms on M-tier fixtures\n- [ ] Zero stuck-input states across 1000 random key sequences\n- [ ] Sync cancel acknowledged p95 < 2s\n- [ ] All state preserved correctly on back-navigation\n\n## Files\n- CREATE: crates/lore-tui/tests/vertical_slice.rs\n\n## TDD Anchor\nRED: Write test_dashboard_to_issue_detail_roundtrip that navigates Dashboard -> IssueList -> IssueDetail -> Esc -> IssueList, asserts cursor position preserved.\nGREEN: Ensure all navigation and state preservation is wired up.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml vertical_slice\n\n## Edge Cases\n- Tests need FakeClock and synthetic DB fixtures (not real GitLab)\n- ftui test harness required for rendering tests without TTY\n- Performance benchmarks may vary by machine — use relative thresholds\n\n## Dependency Context\nRequires all Phase 2 screens: Dashboard, Issue List, Issue Detail, MR List, MR Detail.\nRequires NavigationStack, TaskSupervisor, DbManager from Phase 1.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T17:00:18.310264Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:21.993329Z","deleted_at":"2026-03-11T18:34:21.993322Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-1n5","title":"[CP1] gi ingest --type=issues command","description":"CLI command to orchestrate issue ingestion.\n\nImplementation:\n1. Acquire app lock with heartbeat\n2. Create sync_run record (status='running')\n3. For each configured project:\n   - Call ingestIssues()\n   - For each ingested issue, call ingestIssueDiscussions()\n   - Show progress (spinner or progress bar)\n4. Update sync_run (status='succeeded', metrics_json)\n5. Release lock\n\nFlags:\n- --type=issues (required)\n- --project=PATH (optional, filter to single project)\n- --force (override stale lock)\n\nOutput: Progress bar, then summary with counts\n\nFiles: src/cli/commands/ingest.ts\nTests: tests/integration/sync-runs.test.ts\nDone when: Full issue + discussion ingestion works end-to-end","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:20:05.114751Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.153598Z","closed_at":"2026-01-25T15:21:35.153598Z","deleted_at":"2026-01-25T15:21:35.153595Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-1n5q","title":"lore brief: situational awareness for topic/module/person","description":"## Background\nComposable capstone command. An agent says \"I am about to work on auth\" and gets everything in one call: open issues, active MRs, experts, recent activity, unresolved threads, related context. Replaces 5 separate lore calls with 1.\n\n## Input Modes\n1. Topic: `lore brief 'authentication'` — FTS search to find relevant entities, aggregate\n2. Path: `lore brief --path src/auth/` — who expert internals for path expertise\n3. Person: `lore brief --person teernisse` — who workload internals\n4. Entity: `lore brief issues 3864` — single entity focus with cross-references\n\n## Topic-to-Entity Scoping Strategy (Option A: Search-First, IID Filter)\n\nTopic mode requires scoping issue/MR lists to a free-text query. The existing `ListFilters` has no \"search\" field, so brief uses a two-pass approach:\n\n1. Run `run_search(config, topic, ...)` to find matching entity IIDs via FTS (or hybrid search if bd-1ksf is shipped)\n2. Pass those IIDs into `ListFilters { iids: Some(&iid_vec), state: Some(\"opened\"), ... }`\n3. List module handles sorting, field selection, project scoping\n\n**Required change:** Add `iids: Option<&'a [i64]>` to both `ListFilters` and `MrListFilters` in `src/cli/commands/list/`. In the query builder, when `iids` is `Some`, add `AND iid IN (?)` (use repeated `?` placeholders). This is a small, clean change that benefits any future command needing \"give me issues matching a search.\"\n\nFor path mode and person mode, the existing filters suffice — path mode uses `run_who` for experts and scopes issues by file path mentions; person mode filters by `assignee`/`author`.\n\n## Section Assembly Architecture\n\n### Reuse existing run_* functions\nEach section calls existing CLI command functions and converts their output.\n\nIMPORTANT: All existing run_* functions take `&Config`, NOT `&Connection`. The Config contains the db_path and each function opens its own connection internally.\n\n**Sync/async mix:** `run_brief` is `async`. Sync callees (`run_list_issues`, `run_list_mrs`, `run_who`) are called directly — no `spawn_blocking` needed, they're fast single-SQLite queries. Async callees (`run_related`, `run_timeline`) use `.await`.\n\n### Verified Function Signatures (as of 2026-03-13)\n\n| Module | Function | Signature |\n|--------|----------|-----------|\n| list/issues.rs:133 | `run_list_issues` | `(config: &Config, filters: ListFilters<'a>) -> Result<ListResult>` |\n| list/mrs.rs:120 | `run_list_mrs` | `(config: &Config, filters: MrListFilters<'a>) -> Result<MrListResult>` |\n| who/mod.rs:115 | `run_who` | `(config: &Config, args: &WhoArgs) -> Result<WhoRun>` |\n| search.rs:69 | `run_search` | `(config: &Config, query: &str, cli_filters: SearchCliFilters, fts_mode: FtsQueryMode, requested_mode: &str, explain: bool) -> Result<SearchResponse>` — currently `pub async fn` |\n| related.rs:92 | `run_related` | `async (config: &Config, query_or_type: &str, iid: Option<i64>, limit: usize, project: Option<&str>) -> Result<RelatedResponse>` |\n| timeline.rs:71 | `run_timeline` | `async (config: &Config, params: &TimelineParams) -> Result<TimelineResult>` |\n\n### CRITICAL: ListFilters and MrListFilters have lifetimes and do NOT derive Default\n\nBoth structs use `&'a str` and `&'a [String]` references. All fields must be constructed explicitly.\n\n`ListFilters<'a>` fields: `limit: usize`, `project: Option<&'a str>`, `state: Option<&'a str>`, `author: Option<&'a str>`, `assignee: Option<&'a str>`, `labels: Option<&'a [String]>`, `milestone: Option<&'a str>`, `since: Option<&'a str>`, `due_before: Option<&'a str>`, `has_due_date: bool`, `statuses: &'a [String]`, `sort: &'a str`, `order: &'a str`. After the iids change: `iids: Option<&'a [i64]>`.\n\n`MrListFilters<'a>` fields: `limit: usize`, `project: Option<&'a str>`, `state: Option<&'a str>`, `author: Option<&'a str>`, `assignee: Option<&'a str>`, `reviewer: Option<&'a str>`, `labels: Option<&'a [String]>`, `since: Option<&'a str>`, `draft: bool`, `no_draft: bool`, `target_branch: Option<&'a str>`, `source_branch: Option<&'a str>`, `sort: &'a str`, `order: &'a str`. After the iids change: `iids: Option<&'a [i64]>`.\n\n### CRITICAL: WhoArgs is a clap struct, NOT a domain struct\n\n`WhoArgs` (defined in `src/cli/args.rs:605`) is a clap `#[derive(Parser)]` struct. There is no `mode` field — mode is resolved internally by `resolve_mode()` in `who/mod.rs` based on which fields are set. Key fields: `target: Option<String>`, `path: Option<String>`, `active: bool`, `overlap: Option<String>`, `reviews: bool`, `since: Option<String>`, `project: Option<String>`, `limit: Option<u16>`, `fields: Option<Vec<String>>`, `detail: bool`, `no_detail: bool`, `as_of: Option<String>`, `explain_score: bool`.\n\nTo trigger expert mode: set `path = Some(...)`. To trigger workload mode: set `target = Some(username)`. Do not attempt to set a `mode` field.\n\n### Corrected Example Code\n\n```rust\n// In src/cli/commands/brief.rs\n\nuse crate::cli::commands::list::issues::{run_list_issues, ListFilters};\nuse crate::cli::commands::list::mrs::{run_list_mrs, MrListFilters};\nuse crate::cli::commands::who::run_who;\nuse crate::cli::commands::related::run_related;\nuse crate::cli::commands::timeline::{run_timeline, TimelineParams};\nuse crate::cli::WhoArgs;\nuse crate::core::config::Config;\nuse crate::core::error::Result;\n\npub async fn run_brief(config: &Config, args: &BriefArgs) -> Result<BriefResponse> {\n    let empty_statuses: Vec<String> = vec![];\n    let section_limit = args.section_limit;\n\n    // 1. Topic mode: search first to get relevant entity IIDs\n    let search_iids: Vec<i64> = if let Some(ref query) = args.query {\n        // run_search returns SearchResponse with results containing entity IIDs\n        // Extract and deduplicate IIDs for filtering\n        let search_result = run_search(config, query, /* filters */).await?;\n        search_result.results.iter().map(|r| r.iid).collect()\n    } else {\n        vec![]\n    };\n\n    // 2. open_issues (sync — call directly)\n    let open_issues = run_list_issues(config, ListFilters {\n        limit: section_limit,\n        project: args.project.as_deref(),\n        state: Some(\"opened\"),\n        author: None,\n        assignee: args.person.as_deref(), // person mode: filter by assignee\n        labels: None,\n        milestone: None,\n        since: None,\n        due_before: None,\n        has_due_date: false,\n        statuses: &empty_statuses,\n        sort: \"updated_at\",\n        order: \"desc\",\n        iids: if search_iids.is_empty() { None } else { Some(&search_iids) },\n    })?;\n\n    // 3. active_mrs (sync — call directly)\n    let active_mrs = run_list_mrs(config, MrListFilters {\n        limit: section_limit,\n        project: args.project.as_deref(),\n        state: Some(\"opened\"),\n        author: args.person.as_deref(), // person mode: filter by author\n        assignee: None,\n        reviewer: None,\n        labels: None,\n        since: None,\n        draft: false,\n        no_draft: false,\n        target_branch: None,\n        source_branch: None,\n        sort: \"updated_at\",\n        order: \"desc\",\n        iids: if search_iids.is_empty() { None } else { Some(&search_iids) },\n    })?;\n\n    // 4. experts (sync — call directly; path mode only or derived from topic)\n    let experts = if args.path.is_some() {\n        Some(run_who(config, &WhoArgs {\n            target: None,\n            path: args.path.clone(),\n            active: false,\n            overlap: None,\n            reviews: false,\n            since: None,\n            project: args.project.clone(),\n            limit: Some(3),\n            fields: None,\n            detail: false,\n            no_detail: false,\n            as_of: None,\n            explain_score: false,\n        })?)\n    } else {\n        None\n    };\n\n    // 5. related (async — graceful fallback if no embeddings)\n    let related = run_related(\n        config,\n        args.query.as_deref().unwrap_or(\"\"),\n        None,           // iid (None for query mode)\n        section_limit,\n        args.project.as_deref(),\n    ).await.ok(); // Ok(response) or None on error\n\n    // 6. recent_activity (async — timeline pipeline)\n    let activity = run_timeline(config, &TimelineParams {\n        query: args.query.clone().unwrap_or_default(),\n        project: args.project.clone(),\n        since: Some(\"30d\".to_string()),\n        depth: 0,\n        no_mentions: true,\n        limit: section_limit,\n        max_seeds: 5,\n        max_entities: 10,\n        max_evidence: 5,\n        robot_mode: false,\n    }).await.ok();\n\n    // 7. unresolved_threads — direct SQL (no existing run_* function)\n    // 8. warnings — computed from issues/MRs\n    // ... assemble BriefResponse from all sections\n}\n```\n\n### Section Details\n| Section | Source | Limit | Fallback |\n|---------|--------|-------|----------|\n| open_issues | list/issues.rs with state=opened, iids filter for topic mode | section_limit | empty array |\n| active_mrs | list/mrs.rs with state=opened, iids filter for topic mode | section_limit | empty array |\n| experts | who/mod.rs Expert mode (path mode) or omit (topic/person mode) | 3 | empty array (no path data) or omit section |\n| recent_activity | timeline pipeline | section_limit events | empty array |\n| unresolved_threads | Direct SQL: discussions WHERE resolved=false | section_limit | empty array |\n| related | related.rs run_related() | section_limit | omit section (no embeddings) |\n| warnings | computed from dates/state | all | empty array |\n\n### Warning Generation\n```rust\nfn compute_warnings(issues: &[IssueRow]) -> Vec<String> {\n    let now = chrono::Utc::now();\n    issues.iter().filter_map(|i| {\n        let updated = parse_timestamp(i.updated_at)?;\n        let days_stale = (now - updated).num_days();\n        if days_stale > 30 {\n            Some(format!(\"Issue #{} has no activity for {} days\", i.iid, days_stale))\n        } else { None }\n    }).chain(\n        issues.iter().filter(|i| i.assignees.is_empty())\n            .map(|i| format!(\"Issue #{} is unassigned\", i.iid))\n    ).collect()\n}\n```\n\n## Human Output Design\n\nThe human renderer follows the established pattern from `me/render_human.rs` — the closest architectural precedent (multi-section dashboard using Theme, Table, section_divider, Icons, StyledCell). New file: `src/cli/commands/brief/render_human.rs`.\n\n### Rendering Primitives per Section\n\n| Section | Primitives |\n|---------|------------|\n| Header | `section_divider(\"Brief: {query}\")`, `Theme::bold()`, `Theme::username()` |\n| Open Issues | `Icons::issue_opened()`, `Theme::issue_ref()`, `truncate()` (titles only), `format_relative_time()`, `Theme::dim()` |\n| Active MRs | `Icons::mr_opened()`/`mr_draft()`, `Theme::mr_ref()`, `Theme::username()`, `Theme::state_draft()` |\n| Experts | `Table` (3 cols, indent 4), `Theme::username()`, `Theme::dim()` |\n| Recent Activity | `Table` (5 cols, indent 4) — mirrors `me`'s `print_activity_section` pattern |\n| Unresolved Threads | `Theme::issue_ref()`/`mr_ref()`, full note text (no truncation), note count, `Theme::dim()` |\n| Related | `Table` (4 cols, indent 4), `Theme::issue_ref()`/`mr_ref()`, similarity score, `Theme::dim()` |\n| Warnings | `Icons::warning()`, `Theme::warning()`, indent 2 |\n\n### CRITICAL: No truncation on notes or discussions\nNote/discussion body text is NEVER truncated in human output. Full text wraps naturally within the terminal width. This applies to: unresolved thread first-note body, activity feed body previews, related section titles. Issue/MR titles still truncate since they're single-line tabular rows (matches existing `me` behavior).\n\n### Design Decisions\n\n| Decision | Choice | Rationale |\n|----------|--------|-----------|\n| Header style | Single `section_divider(\"Brief: {query}\")` + counts line | Lighter than `me`'s full header — brief is a quick lookup, not a personal dashboard |\n| Attention states | No — use entity state icons (`Icons::issue_opened`, `Icons::mr_opened`) | Brief shows someone else's domain, not your personal items. Attention states are ego-centric. |\n| Empty sections | Omit entirely (don't show \"Open Issues (0)\") | Brief should be scannable; empty sections are noise. Differs from `me` which always shows sections. |\n| Warnings | Bottom of output, `Icons::warning()` + `Theme::warning()` | Separated from data so they're scannable |\n\n### Mode-Specific Variations\n\n- **`--path src/auth/`**: Header says `Brief: src/auth/`. Experts section is prominent (moved to position after header, before issues). Issues/MRs scoped by file path mentions. Activity scoped similarly.\n- **`--person @teernisse`**: Header says `Brief: @teernisse`. No experts section (you're looking at the person). Issues = their assignments. MRs = their authored. Activity = their actions.\n- **Entity `issues 3864`**: Header says `Brief: Issue #3864`. Single entity focus — cross-references replace the standard sections.\n\n### Example Output: `lore brief 'authentication'`\n\n```\n  -- Brief: authentication -----------------------------------------------\n  3 open issues  2 active MRs  top expert: @teernisse\n\n  -- Open Issues (3) -----------------------------------------------------\n    o #3864  Fix token refresh race condition          [In progress]  3d\n    o #3801  Add OAuth2 PKCE support                                 12d\n    o #3800  Session expiry not respecting timezone                   45d\n\n  -- Active MRs (2) ------------------------------------------------------\n    <-> !456  Implement refresh token rotation   @jdoe     [draft]    1d\n    <-> !443  Add PKCE flow to auth middleware   @asmith               5d\n\n  -- Experts (3) ----------------------------------------------------------\n    @teernisse   42 pts  last active 2d ago\n    @jdoe        28 pts  last active 5d ago\n    @asmith      15 pts  last active 12d ago\n\n  -- Recent Activity (5) -------------------------------------------------\n    note    #3864  \"Added retry logic for token refresh\"   @jdoe       1d\n    status  #3864  reopened                                @teernisse  3d\n    note    !456   \"PKCE challenge method discussion\"      @asmith     5d\n\n  -- Unresolved Threads (2) ----------------------------------------------\n    #3864  \"Should we invalidate all sessions on token rotation, or\n            only the compromised one? The RFC recommends full rotation\n            but our mobile clients would all disconnect.\"      5 notes  3d\n    !456   \"PKCE code verifier length seems short at 43 chars,\n            RFC 7636 recommends 43-128. Should we bump to 128\n            for extra security margin?\"                        2 notes  1d\n\n  -- Related (3) ----------------------------------------------------------\n    #3750  Session management overhaul             0.85  12d\n    !412   Add CORS headers for auth endpoints     0.72  20d\n    #3699  Password reset flow broken              0.68  30d\n\n  ! Issue #3800 has no activity for 45 days\n  ! Issue #3801 is unassigned\n```\n\n(Unicode/Nerd glyphs render in place of ASCII placeholders above)\n\n## Robot Mode Output Schema\n```json\n{\n  \"ok\": true,\n  \"data\": {\n    \"mode\": \"topic\",\n    \"query\": \"authentication\",\n    \"summary\": \"3 open issues, 2 active MRs, top expert: teernisse\",\n    \"open_issues\": [{ \"iid\": 123, \"title\": \"...\", \"state\": \"opened\", \"assignees\": [...], \"updated_at\": \"...\", \"labels\": [...] }],\n    \"active_mrs\": [{ \"iid\": 456, \"title\": \"...\", \"state\": \"opened\", \"author\": \"...\", \"draft\": false, \"updated_at\": \"...\" }],\n    \"experts\": [{ \"username\": \"teernisse\", \"score\": 42, \"last_activity\": \"...\" }],\n    \"recent_activity\": [{ \"timestamp\": \"...\", \"event_type\": \"state_change\", \"entity_ref\": \"issues#123\", \"summary\": \"...\", \"actor\": \"...\" }],\n    \"unresolved_threads\": [{ \"discussion_id\": \"abc\", \"entity_ref\": \"issues#123\", \"started_by\": \"...\", \"note_count\": 5, \"last_note_at\": \"...\", \"body\": \"full note text, never truncated\" }],\n    \"related\": [{ \"iid\": 789, \"title\": \"...\", \"similarity_score\": 0.85 }],\n    \"warnings\": [\"Issue #3800 has no activity for 45 days\"]\n  },\n  \"meta\": { \"elapsed_ms\": 1200, \"sections_computed\": [\"open_issues\", \"active_mrs\", \"experts\", \"recent_activity\"] }\n}\n```\n\n## Clap Registration\n```rust\n// In src/main.rs Commands enum, add:\nBrief {\n    /// Free-text topic, entity type, or omit for project-wide brief\n    query: Option<String>,\n    /// Focus on a file path (who expert mode)\n    #[arg(long)]\n    path: Option<String>,\n    /// Focus on a person (who workload mode)\n    #[arg(long)]\n    person: Option<String>,\n    /// Scope to project (fuzzy match)\n    #[arg(short, long)]\n    project: Option<String>,\n    /// Maximum items per section\n    #[arg(long, default_value = \"5\")]\n    section_limit: usize,\n},\n```\n\n## TDD Loop\nRED: Tests in src/cli/commands/brief.rs (or brief/mod.rs):\n- test_brief_topic_returns_all_sections: insert test data, search 'auth', assert all section keys present in response\n- test_brief_path_uses_who_expert: brief --path src/auth/, assert experts section populated\n- test_brief_person_uses_who_workload: brief --person user, assert open_issues filtered to user's assignments\n- test_brief_warnings_stale_issue: insert issue with updated_at > 30 days ago, assert warning generated\n- test_brief_token_budget: robot mode output for topic query is under 12000 bytes (~3000 tokens)\n- test_brief_no_embeddings_graceful: related section omitted (not errored) when no embeddings exist\n- test_brief_empty_topic: zero matches returns valid JSON with empty arrays + \"No data found\" summary\n- test_list_filters_iids: verify ListFilters with iids field correctly filters to matching IIDs only\n\nGREEN: Implement brief with section assembly, calling existing run_* functions\n\nVERIFY:\n```bash\ncargo test brief:: && cargo clippy --all-targets -- -D warnings\ncargo run --release -- -J brief 'throw time' | jq '.data | keys'\ncargo run --release -- -J brief 'throw time' | wc -c  # target <12000\n```\n\n## Acceptance Criteria\n- [ ] lore brief TOPIC returns all sections for free-text topic\n- [ ] lore brief --path PATH returns path-focused briefing with experts\n- [ ] lore brief --person USERNAME returns person-focused briefing\n- [ ] lore brief issues N returns entity-focused briefing\n- [ ] Robot mode output under 12000 bytes (~3000 tokens)\n- [ ] Each section degrades gracefully if its data source is unavailable\n- [ ] summary field is auto-generated one-liner from section counts\n- [ ] warnings detect: stale issues (>30d), unassigned, no due date\n- [ ] Performance: <2s total (acceptable since composing multiple queries)\n- [ ] Command registered in main.rs and robot-docs\n- [ ] Human output uses lipgloss Theme/Table/section_divider/Icons from render.rs\n- [ ] Human output never truncates note/discussion body text\n- [ ] Empty sections omitted in human output (not shown with count 0)\n- [ ] ListFilters and MrListFilters gain `iids: Option<&'a [i64]>` field\n\n## Edge Cases\n- Topic with zero matches: return empty sections + \"No data found for this topic\" summary\n- Path that nobody has touched: experts empty, related may still have results\n- Person not found in DB: exit code 17 with suggestion\n- All sections empty: still return valid JSON with empty arrays\n- Very broad topic (\"the\"): may return too many results — each section respects its limit cap\n\n## Dependencies\n- Hybrid search (bd-1ksf) for topic relevance ranking (optional — falls back to FTS-only)\n- lore who (shipped) for expertise\n- lore related (bd-8con, SHIPPED/CLOSED) for semantic connections\n- Timeline pipeline (shipped) for recent activity\n\n## Dependency Context\n- **bd-1ksf (hybrid search)**: Provides `search_hybrid()` which brief uses for topic mode to find relevant entities. Without it, topic mode falls back to FTS-only via `run_search()`.\n- **bd-8con (related)**: CLOSED. `run_related()` exists at `src/cli/commands/related.rs:92`. No longer a blocker.\n\n## Files to Create/Modify\n- NEW: src/cli/commands/brief/mod.rs (or brief.rs)\n- NEW: src/cli/commands/brief/render_human.rs (following me/render_human.rs pattern)\n- MODIFY: src/cli/commands/list/issues.rs (add `iids: Option<&'a [i64]>` to ListFilters, add WHERE clause)\n- MODIFY: src/cli/commands/list/mrs.rs (add `iids: Option<&'a [i64]>` to MrListFilters, add WHERE clause)\n- MODIFY: src/cli/commands/mod.rs (add pub mod brief; re-export)\n- MODIFY: src/main.rs (register Brief subcommand in Commands enum, add handle_brief dispatch)\n- Reuse: list.rs, who.rs, timeline.rs, search.rs, related.rs internals","status":"open","priority":2,"issue_type":"epic","created_at":"2026-02-12T15:47:22.893231Z","created_by":"tayloreernisse","updated_at":"2026-03-13T15:12:38.004221Z","compaction_level":0,"original_size":0,"labels":["cli-imp","intelligence"],"dependencies":[{"issue_id":"bd-1n5q","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1nf","title":"Register 'lore timeline' command with all flags","description":"## Background\n\nThis bead wires the `lore timeline` command into the CLI — adding the subcommand to the Commands enum, defining all flags, registering in VALID_COMMANDS, and dispatching to the timeline handler. The actual query logic and rendering are in separate beads.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.1 (Command Design).\n\n## Codebase Context\n\n- Commands enum in `src/cli/mod.rs` (line ~86): uses #[derive(Subcommand)] with nested Args structs\n- VALID_COMMANDS in `src/main.rs` (line ~448): &[&str] array for fuzzy command matching\n- Handler dispatch in `src/main.rs` match on Commands:: variants\n- robot-docs manifest in `src/main.rs`: registers commands for `lore robot-docs` output\n- Existing pattern: `Sync(SyncArgs)`, `Search(SearchArgs)`, etc.\n- No timeline module exists yet — this bead creates the CLI entry point only\n\n## Approach\n\n### 1. TimelineArgs struct (`src/cli/mod.rs`):\n\n```rust\n/// Show a chronological timeline of events matching a query\n#[derive(Parser, Debug)]\npub struct TimelineArgs {\n    /// Search query (keywords to find in issues, MRs, and discussions)\n    pub query: String,\n\n    /// Scope to a specific project (fuzzy match)\n    #[arg(short = 'p', long)]\n    pub project: Option<String>,\n\n    /// Only show events after this date (e.g. \"6m\", \"2w\", \"2024-01-01\")\n    #[arg(long)]\n    pub since: Option<String>,\n\n    /// Cross-reference expansion depth (0 = no expansion)\n    #[arg(long, default_value = \"1\")]\n    pub depth: usize,\n\n    /// Also follow 'mentioned' edges during expansion (high fan-out)\n    #[arg(long = \"expand-mentions\")]\n    pub expand_mentions: bool,\n\n    /// Maximum number of events to display\n    #[arg(short = 'n', long = \"limit\", default_value = \"100\")]\n    pub limit: usize,\n}\n```\n\n### 2. Commands enum variant:\n\n```rust\n/// Show a chronological timeline of events matching a query\n#[command(name = \"timeline\")]\nTimeline(TimelineArgs),\n```\n\n### 3. Handler in `src/main.rs`:\n\n```rust\nCommands::Timeline(args) => {\n    // Placeholder: will be filled by bd-2f2 (human) and bd-dty (robot)\n    // For now: resolve project, call timeline query, dispatch to renderer\n}\n```\n\n### 4. VALID_COMMANDS: add `\"timeline\"` to the array\n\n### 5. robot-docs: add timeline command description to manifest\n\n## Acceptance Criteria\n\n- [ ] `TimelineArgs` struct with all 6 flags: query, project, since, depth, expand-mentions, limit\n- [ ] Commands::Timeline variant registered in Commands enum\n- [ ] Handler stub in src/main.rs dispatches to timeline logic\n- [ ] `\"timeline\"` added to VALID_COMMANDS array\n- [ ] robot-docs manifest includes timeline command description\n- [ ] `lore timeline --help` shows correct help text\n- [ ] `lore timeline` without query shows error (query is required positional)\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/cli/mod.rs` (TimelineArgs struct + Commands::Timeline variant)\n- `src/main.rs` (handler dispatch + VALID_COMMANDS + robot-docs entry)\n\n## TDD Loop\n\nNo unit tests for CLI wiring. Verify with:\n\n```bash\ncargo check --all-targets\ncargo run -- timeline --help\n```\n\n## Edge Cases\n\n- --since parsing: reuse existing date parsing from ListFilters (src/cli/mod.rs handles \"7d\", \"2w\", \"YYYY-MM-DD\")\n- --depth 0: valid, means no cross-reference expansion\n- --expand-mentions: off by default because mentioned edges have high fan-out\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:33:28.422082Z","created_by":"tayloreernisse","updated_at":"2026-02-06T13:49:15.313047Z","closed_at":"2026-02-06T13:49:15.312993Z","close_reason":"Wired lore timeline command: TimelineArgs with 9 flags, Commands::Timeline variant, handle_timeline handler, VALID_COMMANDS entry, robot-docs manifest with temporal_intelligence workflow","compaction_level":0,"original_size":0,"labels":["cli","gate-3","phase-b"],"dependencies":[{"issue_id":"bd-1nf","depends_on_id":"bd-2f2","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1nf","depends_on_id":"bd-dty","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1nf","depends_on_id":"bd-ike","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1np","title":"[CP1] GitLab types for issues, discussions, notes","description":"## Background\n\nGitLab types define the Rust structs for deserializing GitLab API responses. These types are the foundation for all ingestion work - issues, discussions, and notes must be correctly typed for serde to parse them.\n\n## Approach\n\nAdd types to `src/gitlab/types.rs` with serde derives:\n\n### GitLabIssue\n\n```rust\n#[derive(Debug, Clone, Deserialize)]\npub struct GitLabIssue {\n    pub id: i64,                      // GitLab global ID\n    pub iid: i64,                     // Project-scoped issue number\n    pub project_id: i64,\n    pub title: String,\n    pub description: Option<String>,\n    pub state: String,                // \"opened\" | \"closed\"\n    pub created_at: String,           // ISO 8601\n    pub updated_at: String,           // ISO 8601\n    pub closed_at: Option<String>,\n    pub author: GitLabAuthor,\n    pub labels: Vec<String>,          // Array of label names (CP1 canonical)\n    pub web_url: String,\n}\n```\n\nNOTE: `labels_details` intentionally NOT modeled - varies across GitLab versions.\n\n### GitLabAuthor\n\n```rust\n#[derive(Debug, Clone, Deserialize)]\npub struct GitLabAuthor {\n    pub id: i64,\n    pub username: String,\n    pub name: String,\n}\n```\n\n### GitLabDiscussion\n\n```rust\n#[derive(Debug, Clone, Deserialize)]\npub struct GitLabDiscussion {\n    pub id: String,                   // String ID like \"6a9c1750b37d...\"\n    pub individual_note: bool,        // true = standalone comment\n    pub notes: Vec<GitLabNote>,\n}\n```\n\n### GitLabNote\n\n```rust\n#[derive(Debug, Clone, Deserialize)]\npub struct GitLabNote {\n    pub id: i64,\n    #[serde(rename = \"type\")]\n    pub note_type: Option<String>,    // \"DiscussionNote\" | \"DiffNote\" | null\n    pub body: String,\n    pub author: GitLabAuthor,\n    pub created_at: String,           // ISO 8601\n    pub updated_at: String,           // ISO 8601\n    pub system: bool,                 // true for system-generated notes\n    #[serde(default)]\n    pub resolvable: bool,\n    #[serde(default)]\n    pub resolved: bool,\n    pub resolved_by: Option<GitLabAuthor>,\n    pub resolved_at: Option<String>,\n    pub position: Option<GitLabNotePosition>,\n}\n```\n\n### GitLabNotePosition\n\n```rust\n#[derive(Debug, Clone, Deserialize)]\npub struct GitLabNotePosition {\n    pub old_path: Option<String>,\n    pub new_path: Option<String>,\n    pub old_line: Option<i32>,\n    pub new_line: Option<i32>,\n}\n```\n\n## Acceptance Criteria\n\n- [ ] GitLabIssue deserializes from API response JSON\n- [ ] GitLabAuthor embedded correctly in issue and note\n- [ ] GitLabDiscussion with notes array deserializes\n- [ ] GitLabNote handles null note_type (use Option<String>)\n- [ ] GitLabNote uses #[serde(rename = \"type\")] for reserved keyword\n- [ ] resolvable/resolved default to false via #[serde(default)]\n- [ ] All timestamp fields are String (ISO 8601 parsed elsewhere)\n\n## Files\n\n- src/gitlab/types.rs (edit - add types)\n\n## TDD Loop\n\nRED:\n```rust\n// tests/gitlab_types_tests.rs\n#[test] fn deserializes_gitlab_issue_from_json()\n#[test] fn deserializes_gitlab_discussion_from_json()\n#[test] fn handles_null_note_type()\n#[test] fn handles_missing_resolvable_field()\n#[test] fn deserializes_labels_as_string_array()\n```\n\nGREEN: Add type definitions with serde attributes\n\nVERIFY: `cargo test gitlab_types`\n\n## Edge Cases\n\n- note_type can be null, \"DiscussionNote\", or \"DiffNote\"\n- labels array can be empty\n- description can be null\n- resolved_by/resolved_at can be null\n- position is only present for DiffNotes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-25T17:02:38.150472Z","created_by":"tayloreernisse","updated_at":"2026-01-25T22:17:08.842965Z","closed_at":"2026-01-25T22:17:08.842895Z","close_reason":"Implemented GitLabAuthor, GitLabIssue, GitLabDiscussion, GitLabNote, GitLabNotePosition types with 10 passing tests","compaction_level":0,"original_size":0}
{"id":"bd-1nsl","title":"Epic: Surgical Per-IID Sync","description":"## Background\n\nSurgical Per-IID Sync adds `lore sync --issue <IID> --mr <IID> -p <project>` for on-demand sync of specific entities without running the full project-wide pipeline. This is critical for agent workflows: when an agent needs fresh data for a specific issue or MR, waiting for a full sync (minutes) is unacceptable. Surgical sync completes in seconds by fetching only the targeted entities, their discussions, resource events, and dependent data, then scoping doc regeneration and embedding to only the affected documents.\n\n## Architecture\n\nThe pipeline stages mirror full sync but scoped to individual entities:\n\n```\nPREFLIGHT -> TOCTOU CHECK -> INGEST -> DEPENDENTS -> DOCS -> EMBED -> FINALIZE\n```\n\n- **Preflight**: Fetch entity from GitLab API by IID, confirm existence\n- **TOCTOU check**: Compare payload `updated_at` with DB — skip if already current\n- **Ingest**: Upsert entity via existing `process_single_issue`/`process_single_mr`\n- **Dependents**: Inline fetch of discussions, resource events, MR diffs, closes_issues\n- **Docs**: Scoped `regenerate_dirty_documents_for_sources()` — only affected source keys\n- **Embed**: Scoped `embed_documents_by_ids()` — only regenerated document IDs\n- **Finalize**: SyncRunRecorder with surgical mode columns\n\n## Children (Execution Order)\n\n### Foundation (no blockers, can parallelize)\n1. **bd-tiux** — Migration 027: surgical mode columns on sync_runs\n2. **bd-1sc6** — Error variant + pub(crate) visibility changes\n3. **bd-159p** — GitLab client get_by_iid methods\n4. **bd-1lja** — CLI flags + SyncOptions extensions\n\n### Core (blocked by foundation)\n5. **bd-wcja** — SyncResult surgical fields (blocked by bd-3sez)\n6. **bd-arka** — SyncRunRecorder surgical lifecycle (blocked by bd-tiux)\n7. **bd-3sez** — surgical.rs core module + tests (blocked by bd-159p, bd-1sc6)\n8. **bd-hs6j** — Scoped doc regeneration (no blockers)\n9. **bd-1elx** — Scoped embedding (no blockers)\n\n### Orchestration (blocked by core)\n10. **bd-kanh** — Per-entity dependent helpers (blocked by bd-3sez)\n11. **bd-1i4i** — Orchestrator function (blocked by all core beads)\n\n### Wiring + Validation\n12. **bd-3bec** — Wire dispatch in run_sync + robot-docs (blocked by bd-1i4i)\n13. **bd-3jqx** — Integration tests (blocked by bd-1i4i + core beads)\n\n## Completion Criteria\n\n- [ ] `lore sync --issue 7 -p group/project` fetches, ingests, and reports for issue 7 only\n- [ ] `lore sync --mr 101 --mr 102 -p proj` handles multiple MRs\n- [ ] `lore sync --preflight-only --issue 7 -p proj` validates without DB writes\n- [ ] Robot mode JSON includes `surgical_mode`, `surgical_iids`, `entity_results`\n- [ ] TOCTOU: already-current entities are skipped (not re-ingested)\n- [ ] Scoped docs + embed: only affected documents are regenerated and embedded\n- [ ] Cancellation at any stage stops gracefully with partial results\n- [ ] `lore robot-docs` documents all surgical flags and response schemas\n- [ ] All existing full-sync tests pass unchanged\n- [ ] Integration test suite (bd-3jqx) passes","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-02-17T19:11:34.020453Z","created_by":"tayloreernisse","updated_at":"2026-02-18T21:38:02.294242Z","closed_at":"2026-02-18T21:38:02.294190Z","close_reason":"All children shipped. Surgical per-IID sync landed in 9ec1344.","compaction_level":0,"original_size":0,"labels":["surgical-sync"]}
{"id":"bd-1o1","title":"OBSERV: Add -v/--verbose and --log-format CLI flags","description":"## Background\nUsers and agents need CLI-controlled verbosity without knowing RUST_LOG syntax. The -v flag convention (cargo, curl, ssh) is universally understood. --log-format json enables lore sync 2>&1 | jq workflows without reading log files.\n\n## Approach\nAdd two new global flags to the Cli struct in src/cli/mod.rs (insert after the quiet field at line ~37):\n\n```rust\n/// Increase log verbosity (-v, -vv, -vvv)\n#[arg(short = 'v', long = \"verbose\", action = clap::ArgAction::Count, global = true)]\npub verbose: u8,\n\n/// Log format for stderr output: text (default) or json\n#[arg(long = \"log-format\", global = true, value_parser = [\"text\", \"json\"], default_value = \"text\")]\npub log_format: String,\n```\n\nThe existing Cli struct (src/cli/mod.rs:13-42) has these global flags: config, robot, json, color, quiet. The new flags follow the same pattern.\n\nNote: clap::ArgAction::Count allows -v, -vv, -vvv as a single flag with increasing count (0, 1, 2, 3).\n\n## Acceptance Criteria\n- [ ] lore -v sync parses without error (verbose=1)\n- [ ] lore -vv sync parses (verbose=2)\n- [ ] lore -vvv sync parses (verbose=3)\n- [ ] lore --log-format json sync parses (log_format=\"json\")\n- [ ] lore --log-format text sync parses (default)\n- [ ] lore --log-format xml sync errors (invalid value)\n- [ ] Existing commands unaffected (verbose defaults to 0, log_format to \"text\")\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/cli/mod.rs (modify Cli struct, lines 13-42)\n\n## TDD Loop\nRED: Write test that parses Cli with -v flag and asserts verbose=1\nGREEN: Add the two fields to Cli struct\nVERIFY: cargo test -p lore && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- -v and -q together: both parse fine; conflict resolution happens in subscriber setup (bd-2rr), not here\n- -v flag must be global=true so it works before and after subcommands: lore -v sync AND lore sync -v\n- --log-format is a string, not enum, to keep Cli struct simple","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-04T15:53:55.421339Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:10:22.585947Z","closed_at":"2026-02-04T17:10:22.585905Z","close_reason":"Added -v/--verbose (count) and --log-format (text|json) global CLI flags","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-1o1","depends_on_id":"bd-2nx","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1o4h","title":"OBSERV: Define StageTiming struct in src/core/metrics.rs","description":"## Background\nStageTiming is the materialized view of span timing data. It's the data structure that flows through robot JSON output, sync_runs.metrics_json, and the human-readable timing summary. Defined in a new file because it's genuinely new functionality that doesn't fit existing modules.\n\n## Approach\nCreate src/core/metrics.rs:\n\n```rust\nuse serde::Serialize;\n\nfn is_zero(v: &usize) -> bool { *v == 0 }\n\n#[derive(Debug, Clone, Serialize)]\npub struct StageTiming {\n    pub name: String,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub project: Option<String>,\n    pub elapsed_ms: u64,\n    pub items_processed: usize,\n    #[serde(skip_serializing_if = \"is_zero\")]\n    pub items_skipped: usize,\n    #[serde(skip_serializing_if = \"is_zero\")]\n    pub errors: usize,\n    #[serde(skip_serializing_if = \"Vec::is_empty\")]\n    pub sub_stages: Vec<StageTiming>,\n}\n```\n\nRegister module in src/core/mod.rs (line ~11, add):\n```rust\npub mod metrics;\n```\n\nThe is_zero helper is a private function used by serde's skip_serializing_if. It must take &usize (reference) and return bool.\n\n## Acceptance Criteria\n- [ ] StageTiming serializes to JSON matching PRD Section 4.6.2 example\n- [ ] items_skipped omitted when 0\n- [ ] errors omitted when 0\n- [ ] sub_stages omitted when empty vec\n- [ ] project omitted when None\n- [ ] name, elapsed_ms, items_processed always present\n- [ ] Struct is Debug + Clone + Serialize\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/core/metrics.rs (new file)\n- src/core/mod.rs (register module, add line after existing pub mod declarations)\n\n## TDD Loop\nRED:\n  - test_stage_timing_serialization: create StageTiming with sub_stages, serialize, assert JSON structure\n  - test_stage_timing_zero_fields_omitted: errors=0, items_skipped=0, assert no \"errors\" or \"items_skipped\" keys\n  - test_stage_timing_empty_sub_stages: sub_stages=vec![], assert no \"sub_stages\" key\nGREEN: Create metrics.rs with StageTiming struct and is_zero helper\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- is_zero must be a function, not a closure (serde skip_serializing_if requires a function path)\n- Vec::is_empty is a method on Vec, and serde accepts \"Vec::is_empty\" as a path for skip_serializing_if\n- Recursive StageTiming (sub_stages contains StageTiming): serde handles this naturally, no special handling needed","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T15:54:31.907234Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:21:40.915842Z","closed_at":"2026-02-04T17:21:40.915794Z","close_reason":"Created src/core/metrics.rs with StageTiming struct, serde skip_serializing_if for zero/empty fields, 5 tests","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-1o4h","depends_on_id":"bd-3er","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1obt","title":"Implement authored MRs query for me command","description":"## Background\nThe second dashboard section shows merge requests authored by the current user in opened state. MR authorship is stored directly as `merge_requests.author_username`. Labels are in `mr_labels(merge_request_id, label_id)` joined to `labels(id, name, ...)` — NOT a column on merge_requests.\n\n## Approach\nCreate in `src/cli/commands/me/mrs.rs`:\n```rust\nuse rusqlite::Connection;\nuse crate::core::error::Result;\nuse super::types::{MeMrAuthored, ProjectScope, AttentionState};\n\npub fn fetch_my_authored_mrs(\n    conn: &Connection,\n    username: &str,\n    scope: &ProjectScope,\n) -> Result<Vec<MeMrAuthored>> {\n    let project_filter = match scope {\n        ProjectScope::Single(id) => format!(\"AND p.id = {id}\"),\n        ProjectScope::All => String::new(),\n    };\n    let sql = format!(r#\"\n        SELECT p.path_with_namespace, mr.iid, mr.title, mr.state,\n               mr.draft, mr.detailed_merge_status, mr.updated_at,\n               mr.web_url,\n               (SELECT GROUP_CONCAT(l.name, ',')\n                FROM mr_labels ml\n                JOIN labels l ON l.id = ml.label_id\n                WHERE ml.merge_request_id = mr.id) AS label_names\n        FROM merge_requests mr\n        JOIN projects p ON p.id = mr.project_id\n        WHERE mr.author_username = ?1\n          AND mr.state = 'opened'\n          {project_filter}\n        ORDER BY mr.updated_at DESC\n    \"#);\n    let mut stmt = conn.prepare(&sql)?;\n    let rows = stmt.query_map([username], |row| {\n        let label_str: Option<String> = row.get(8)?;\n        Ok(MeMrAuthored {\n            project_path: row.get(0)?,\n            iid: row.get(1)?,\n            title: row.get(2)?,\n            state: row.get(3)?,\n            draft: row.get::<_, i64>(4)? != 0,  // SQLite stores bool as integer\n            detailed_merge_status: row.get(5)?,\n            attention_state: AttentionState::NotStarted, // enriched later\n            labels: label_str\n                .map(|s| s.split(',').map(String::from).collect())\n                .unwrap_or_default(),\n            updated_at_iso: crate::core::time::ms_to_iso(row.get::<_, i64>(6)?),\n            web_url: row.get(7)?,\n        })\n    })?;\n    rows.collect::<std::result::Result<Vec<_>, _>>()\n        .map_err(Into::into)\n}\n```\n\nKey details:\n- `draft` stored as INTEGER in SQLite (0/1) — convert to bool\n- `detailed_merge_status` is nullable TEXT\n- Labels via GROUP_CONCAT on `mr_labels` + `labels`\n- MR assignees NOT used (AC-3.5) — no join on mr_assignees\n\n## Acceptance Criteria\n- [ ] Returns Vec<MeMrAuthored> for MRs where `author_username` matches (AC-3.2)\n- [ ] Only returns `state = 'opened'` MRs (AC-5.2)\n- [ ] Includes `draft` as bool (AC-7.5) — converted from SQLite integer\n- [ ] Includes `detailed_merge_status` as Option<String> (AC-7.5)\n- [ ] Labels fetched via GROUP_CONCAT on `mr_labels` JOIN `labels`\n- [ ] Respects ProjectScope filtering\n- [ ] No limit, no truncation\n- [ ] Does NOT join `mr_assignees` (AC-3.5)\n\n## Files\n- CREATE: src/cli/commands/me/mrs.rs\n- MODIFY: src/cli/commands/me/mod.rs (add `pub mod mrs;`)\n\n## TDD Anchor\nRED: Write `test_fetch_authored_mrs_returns_authored` using in-memory DB. Insert project + MR with author_username matching, state='opened'. Assert returns 1 MR.\nGREEN: Implement the SQL query.\nVERIFY: `cargo test fetch_my_authored_mrs`\n\nAdditional tests:\n- test_fetch_authored_mrs_excludes_merged (state='merged' not returned)\n- test_fetch_authored_mrs_excludes_other_authors\n- test_fetch_authored_mrs_draft_flag (insert with draft=1, verify draft=true)\n- test_fetch_authored_mrs_includes_labels\n\n## Edge Cases\n- `draft` column is INTEGER in SQLite — must cast to bool (`!= 0`)\n- `detailed_merge_status` may be NULL for older MRs or pre-enrichment\n- draft=1 MRs still appear (they're flagged, not filtered)\n\n## Dependency Context\nUses `MeMrAuthored` and `ProjectScope` from bd-3bwh and bd-a7ba.\nUses `ms_to_iso` from `src/core/time.rs`.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-19T19:36:57.295429Z","created_by":"tayloreernisse","updated_at":"2026-02-20T16:09:13.052019Z","closed_at":"2026-02-20T16:09:13.051982Z","close_reason":"Implemented by lore-me agent swarm","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1obt","depends_on_id":"bd-3bwh","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1obt","depends_on_id":"bd-a7ba","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1oi7","title":"NOTE-2A: Schema migration for note documents (migration 024)","description":"## Background\nThe documents and dirty_sources tables have CHECK constraints limiting source_type to ('issue', 'merge_request', 'discussion'). Need to add 'note' as valid source_type. SQLite doesn't support ALTER CONSTRAINT, so use the table-rebuild pattern. Uses migration slot 024 (022 = query indexes, 023 = issue_detail_fields already exists).\n\n## Approach\nCreate migrations/024_note_documents.sql:\n\n1. Rebuild dirty_sources: CREATE dirty_sources_new with CHECK adding 'note', INSERT SELECT, DROP old, RENAME.\n2. Rebuild documents (complex — must preserve FTS consistency):\n   - Save junction table data (_doc_labels_backup, _doc_paths_backup)\n   - Drop FTS triggers (documents_ai, documents_ad, documents_au — defined in migration 008_fts5.sql)\n   - Drop junction tables (document_labels, document_paths — defined in migration 007_documents.sql)\n   - Create documents_new with updated CHECK adding 'note'\n   - INSERT INTO documents_new SELECT * FROM documents (preserves rowids for FTS)\n   - Drop documents, rename new\n   - Recreate all indexes (idx_documents_project_updated, idx_documents_author, idx_documents_source, idx_documents_content_hash — see migration 007_documents.sql for definitions)\n   - Recreate junction tables + restore data from backups\n   - Recreate FTS triggers (see migration 008_fts5.sql for trigger SQL)\n   - INSERT INTO documents_fts(documents_fts) VALUES('rebuild')\n3. Defense-in-depth triggers:\n   - notes_ad_cleanup: AFTER DELETE ON notes WHEN old.is_system = 0 → delete doc + dirty_sources for source_type='note', source_id=old.id\n   - notes_au_system_cleanup: AFTER UPDATE OF is_system ON notes WHEN NEW.is_system = 1 AND OLD.is_system = 0 → delete doc + dirty_sources\n4. Drop temp backup tables\n\nRegister as (\"024\", include_str!(\"../../migrations/024_note_documents.sql\")) in MIGRATIONS array in src/core/db.rs. Position AFTER the \"023\" entry.\n\n## Files\n- CREATE: migrations/024_note_documents.sql\n- MODIFY: src/core/db.rs (add (\"024\", include_str!(...)) to MIGRATIONS array, after line 75)\n\n## TDD Anchor\nRED: test_migration_024_allows_note_source_type — INSERT with source_type='note' should succeed in both documents and dirty_sources.\nGREEN: Implement the table rebuild migration.\nVERIFY: cargo test migration_024 -- --nocapture\nTests: test_migration_024_preserves_existing_data, test_migration_024_fts_triggers_intact, test_migration_024_row_counts_preserved, test_migration_024_integrity_checks_pass, test_migration_024_fts_rebuild_consistent, test_migration_024_note_delete_trigger_cleans_document, test_migration_024_note_system_flip_trigger_cleans_document, test_migration_024_system_note_delete_trigger_does_not_fire\n\n## Acceptance Criteria\n- [ ] INSERT source_type='note' succeeds in documents and dirty_sources\n- [ ] All existing data preserved through table rebuild (row counts match before/after)\n- [ ] FTS triggers fire correctly after rebuild (insert a doc, verify FTS entry exists)\n- [ ] documents_fts row count == documents row count after rebuild\n- [ ] PRAGMA foreign_key_check returns no violations\n- [ ] notes_ad_cleanup trigger fires on note deletion (deletes document + dirty_sources)\n- [ ] notes_au_system_cleanup trigger fires when is_system flips 0→1\n- [ ] System note deletion does NOT trigger notes_ad_cleanup (is_system = 1 guard)\n- [ ] All 9 tests pass\n\n## Edge Cases\n- Rowid preservation: INSERT INTO documents_new SELECT * preserves id column = rowid for FTS consistency\n- CRITICAL: Must save/restore junction table data (ON DELETE CASCADE on document_labels/document_paths would delete them when documents table is dropped)\n- The FTS rebuild at end is a safety net for any rowid drift\n- Empty database: migration is a no-op (all SELECTs return 0 rows, tables rebuilt with new CHECK)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T17:01:35.164340Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:13:24.078558Z","closed_at":"2026-02-12T18:13:24.078512Z","close_reason":"Implemented by agent swarm","compaction_level":0,"original_size":0,"labels":["per-note","search"],"dependencies":[{"issue_id":"bd-1oi7","depends_on_id":"bd-18bf","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1oi7","depends_on_id":"bd-22ai","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1oi7","depends_on_id":"bd-ef0u","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1oo","title":"Register migration 015 in db.rs and create migration 016 for mr_file_changes","description":"## Background\n\nThis bead creates the `mr_file_changes` table that stores which files each MR touched, enabling Gate 4 (file-history) and Gate 5 (trace). It maps MRs to the file paths they modify.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 4.1 (Schema).\n\n## Codebase Context — CRITICAL Migration Numbering\n\n- **LATEST_SCHEMA_VERSION = 14** (MIGRATIONS array in db.rs includes 001-014)\n- **Migration 015 exists on disk** (`migrations/015_commit_shas_and_closes_watermark.sql`) but is **NOT registered** in `src/core/db.rs` MIGRATIONS array\n- `merge_commit_sha` and `squash_commit_sha` are already on merge_requests (added by 015 SQL) and already used in `src/ingestion/merge_requests.rs`\n- `closes_issues_synced_for_updated_at` also added by 015 and used in orchestrator.rs\n- **This bead must FIRST register migration 015 in db.rs**, then create migration 016 for mr_file_changes\n- pending_dependent_fetches already has `job_type='mr_diffs'` in CHECK constraint (migration 011)\n- Schema version auto-computes: `LATEST_SCHEMA_VERSION = MIGRATIONS.len() as i32`\n\n## Approach\n\n### Step 1: Register existing migration 015 in db.rs\n\nAdd to MIGRATIONS array in `src/core/db.rs` (after the \"014\" entry):\n\n```rust\n(\n    \"015\",\n    include_str!(\"../../migrations/015_commit_shas_and_closes_watermark.sql\"),\n),\n```\n\nThis makes LATEST_SCHEMA_VERSION = 15.\n\n### Step 2: Create migration 016 for mr_file_changes\n\nCreate `migrations/016_mr_file_changes.sql`:\n\n```sql\n-- Migration 016: MR file changes table\n-- Powers file-history and trace commands (Gates 4-5)\n\nCREATE TABLE mr_file_changes (\n    id INTEGER PRIMARY KEY,\n    merge_request_id INTEGER NOT NULL REFERENCES merge_requests(id) ON DELETE CASCADE,\n    project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,\n    old_path TEXT,\n    new_path TEXT NOT NULL,\n    change_type TEXT NOT NULL CHECK (change_type IN ('added', 'modified', 'renamed', 'deleted')),\n    UNIQUE(merge_request_id, new_path)\n);\n\nCREATE INDEX idx_mfc_project_path ON mr_file_changes(project_id, new_path);\nCREATE INDEX idx_mfc_project_old_path ON mr_file_changes(project_id, old_path) WHERE old_path IS NOT NULL;\nCREATE INDEX idx_mfc_mr ON mr_file_changes(merge_request_id);\nCREATE INDEX idx_mfc_renamed ON mr_file_changes(project_id, change_type) WHERE change_type = 'renamed';\n\nINSERT INTO schema_version (version, applied_at, description)\nVALUES (16, strftime('%s', 'now') * 1000, 'MR file changes table');\n```\n\n### Step 3: Register migration 016 in db.rs\n\n```rust\n(\n    \"016\",\n    include_str!(\"../../migrations/016_mr_file_changes.sql\"),\n),\n```\n\nLATEST_SCHEMA_VERSION will auto-compute to 16.\n\n## Acceptance Criteria\n\n- [ ] Migration 015 registered in MIGRATIONS array in src/core/db.rs\n- [ ] Migration file exists at `migrations/016_mr_file_changes.sql`\n- [ ] `mr_file_changes` table has columns: id, merge_request_id, project_id, old_path, new_path, change_type\n- [ ] UNIQUE constraint on (merge_request_id, new_path)\n- [ ] CHECK constraint on change_type: added, modified, renamed, deleted\n- [ ] 4 indexes: project+new_path, project+old_path (partial), mr_id, project+renamed (partial)\n- [ ] Migration 016 registered in MIGRATIONS array\n- [ ] LATEST_SCHEMA_VERSION auto-computes to 16\n- [ ] `lore migrate` applies both 015 and 016 successfully on a v14 database\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/core/db.rs` (register migrations 015 AND 016 in MIGRATIONS array)\n- `migrations/016_mr_file_changes.sql` (NEW)\n\n## TDD Loop\n\nRED: `lore migrate` on v14 database says \"already up to date\" (015 not registered)\n\nGREEN: Register 015 in db.rs, create 016 file, register 016 in db.rs. `lore migrate` applies both.\n\nVERIFY:\n```bash\ncargo check --all-targets\nlore --robot migrate\nsqlite3 ~/.local/share/lore/lore.db '.schema mr_file_changes'\nsqlite3 ~/.local/share/lore/lore.db \"SELECT version FROM schema_version ORDER BY version DESC LIMIT 1\"\n```\n\n## Edge Cases\n\n- Databases already at v15 via manual migration: 015 will be skipped, only 016 applied\n- old_path is NULL for added files, populated for renamed/deleted\n- No lines_added/lines_removed columns (spec does not require them; removed to match spec exactly)\n- Partial indexes only index relevant rows for rename chain BFS performance\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:34:08.837816Z","created_by":"tayloreernisse","updated_at":"2026-02-05T21:40:46.766136Z","closed_at":"2026-02-05T21:40:46.766074Z","close_reason":"Completed: registered migration 015 in db.rs MIGRATIONS array, created migration 016 (mr_file_changes table with 4 indexes, CHECK constraint, UNIQUE constraint), registered 016 in db.rs. LATEST_SCHEMA_VERSION auto-computes to 16. cargo check, clippy, and fmt all pass.","compaction_level":0,"original_size":0,"labels":["gate-4","phase-b","schema"],"dependencies":[{"issue_id":"bd-1oo","depends_on_id":"bd-14q","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1oo","depends_on_id":"bd-hu3","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1oyf","title":"NOTE-1D: robot-docs integration for notes command","description":"## Background\nAdd the notes command to the robot-docs manifest so agents can discover it. Also forward-prep SearchArgs --type to accept \"note\"/\"notes\" (duplicates work in NOTE-2F but is safe to do early).\n\n## Approach\n1. Robot-docs manifest is in src/main.rs, function handle_robot_docs() starting at line 2087. The commands JSON is built at line 2090 with serde_json::json!. Add a \"notes\" entry following the pattern of \"issues\" (line 2107 area) and \"mrs\" entries:\n\n   \"notes\": {\n       \"description\": \"List notes from discussions with rich filtering\",\n       \"flags\": [\"--limit/-n <N>\", \"--author/-a <username>\", \"--note-type <type>\", \"--contains <text>\", \"--for-issue <iid>\", \"--for-mr <iid>\", \"-p/--project <path>\", \"--since <period>\", \"--until <period>\", \"--path <filepath>\", \"--resolution <any|unresolved|resolved>\", \"--sort <created|updated>\", \"--asc\", \"--include-system\", \"--note-id <id>\", \"--gitlab-note-id <id>\", \"--discussion-id <id>\", \"--format <table|json|jsonl|csv>\", \"--fields <list|minimal>\", \"--open\"],\n       \"robot_flags\": [\"--format json\", \"--fields minimal\"],\n       \"example\": \"lore --robot notes --author jdefting --since 1y --format json --fields minimal\",\n       \"response_schema\": {\n           \"ok\": \"bool\",\n           \"data\": {\"notes\": \"[NoteListRowJson]\", \"total_count\": \"int\", \"showing\": \"int\"},\n           \"meta\": {\"elapsed_ms\": \"int\"}\n       }\n   }\n\n2. Update SearchArgs.source_type value_parser in src/cli/mod.rs (line 560) to include \"note\":\n   value_parser = [\"issue\", \"mr\", \"discussion\", \"note\"]\n   (This is also done in NOTE-2F but is safe to do in either order — value_parser is additive)\n\n3. Add \"notes\" to the command list in handle_robot_docs (line 662 area where command names are listed).\n\n## Files\n- MODIFY: src/main.rs (add notes to robot-docs commands JSON at line 2090 area, add to command list at line 662)\n- MODIFY: src/cli/mod.rs (add \"note\" to SearchArgs source_type value_parser at line 560)\n\n## TDD Anchor\nSmoke test: cargo run -- --robot robot-docs | jq '.data.commands.notes' should return the notes command entry.\nVERIFY: cargo test -- --nocapture (no dedicated test needed — robot-docs is a static JSON generator)\n\n## Acceptance Criteria\n- [ ] lore robot-docs output includes notes command with all flags\n- [ ] notes command has response_schema, example, and robot_flags\n- [ ] SearchArgs accepts --type note\n- [ ] All existing tests still pass\n\n## Dependency Context\n- Depends on NOTE-1A (bd-20p9), NOTE-1B (bd-3iod), NOTE-1C (bd-25hb): command must be fully wired before documenting (the manifest should describe actual working behavior)\n\n## Edge Cases\n- robot-docs --brief mode: notes command should still appear in brief output\n- Value parser order doesn't matter — \"note\" can be added at any position in the array","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-12T17:01:04.191582Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:13:15.359505Z","closed_at":"2026-02-12T18:13:15.359457Z","close_reason":"Implemented by agent swarm","compaction_level":0,"original_size":0,"labels":["cli","per-note","search"]}
{"id":"bd-1p58","title":"Phase 0c: Replace tokio::join! with futures::join!","description":"## What\nReplace tokio::join! with futures::join! at 2 sites in gitlab/client.rs (lines ~741, 748).\n\n## Why\nfutures::join! is runtime-agnostic and already in deps (futures crate). This removes a tokio dependency without changing any behavior.\n\n## Rearchitecture Context (2026-03-06)\ngitlab/client.rs was modified but not moved during the rearchitecture. Line numbers shifted slightly from the original (~729, 736) to approximately (~741, 748). The file remains at src/gitlab/client.rs.\n\n## Implementation\n```rust\n// Before\nuse tokio::join;\nlet (result_a, result_b) = tokio::join!(future_a, future_b);\n\n// After\nuse futures::join;\nlet (result_a, result_b) = futures::join!(future_a, future_b);\n```\n\n## Files Changed\n- src/gitlab/client.rs (~4 LOC changed: 2 import + 2 macro calls, at lines ~741, 748)\n\n## Testing\n- cargo check --all-targets\n- cargo clippy --all-targets -- -D warnings\n- Existing pagination tests should pass unchanged\n\n## Post-Phase 0 Remaining Tokio\nAfter 0a-0c, remaining tokio in production code is minimal:\n- #[tokio::main] (1 site in src/main.rs)\n- tokio::spawn + tokio::signal::ctrl_c (4 handlers in src/app/handlers.rs, to be consolidated in Phase 0a)\n- tokio::time::sleep (1 import)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-06T18:38:27.847787Z","created_by":"tayloreernisse","updated_at":"2026-03-06T21:11:12.557306Z","closed_at":"2026-03-06T21:11:12.557264Z","close_reason":"asupersync migration complete — 5 commits pushed to asupersync-migration branch","compaction_level":0,"original_size":0,"labels":["asupersync","phase-0"],"dependencies":[{"issue_id":"bd-1p58","depends_on_id":"bd-1lj5","type":"parent-child","created_at":"2026-03-06T18:38:27.850127Z","created_by":"tayloreernisse"}]}
{"id":"bd-1pzj","title":"Implement responsive layout system (LORE_BREAKPOINTS + Responsive)","description":"## Background\n\nEvery TUI view needs to adapt its layout to terminal width. The PRD defines a project-wide breakpoint system using FrankenTUI native `Responsive<T>` with 5 tiers: Xs (<60), Sm (60-89), Md (90-119), Lg (120-159), Xl (160+). This is cross-cutting infrastructure — Dashboard uses it for 1/2/3-column layout, Issue/MR lists use it for column visibility, Search/Who use it for split-pane toggling. Without this, every view would reinvent breakpoint logic.\n\n## Approach\n\nDefine a single `layout.rs` module in the TUI crate with:\n\n1. **`LORE_BREAKPOINTS` constant** — `Breakpoints::new(60, 90, 120)` (Xl defaults to Lg + 40 = 160)\n2. **`classify_width(width: u16) -> Breakpoint`** — wrapper around `LORE_BREAKPOINTS.classify_width(area.width)`\n3. **Helper functions** using `Responsive<T>`:\n   - `dashboard_columns(bp: Breakpoint) -> u16` — 1 (Xs/Sm), 2 (Md), 3 (Lg/Xl)\n   - `show_preview_pane(bp: Breakpoint) -> bool` — false (Xs/Sm), true (Md+)\n   - `table_columns(bp: Breakpoint, screen: Screen) -> Vec<ColumnDef>` — returns visible column set per breakpoint per screen\n\nReference FrankenTUI types:\n```rust\nuse ftui::layout::{Breakpoints, Breakpoint, Responsive, Flex, Constraint};\npub const LORE_BREAKPOINTS: Breakpoints = Breakpoints::new(60, 90, 120);\n```\n\nThe `Responsive<T>` wrapper provides breakpoint-aware values with inheritance — `.new(base)` sets Xs, `.at(bp, val)` sets overrides, `.resolve_cloned(bp)` walks downward.\n\n## Acceptance Criteria\n- [ ] `LORE_BREAKPOINTS` constant defined with sm=60, md=90, lg=120\n- [ ] `classify_width()` returns correct Breakpoint for widths: 40->Xs, 60->Sm, 90->Md, 120->Lg, 160->Xl\n- [ ] `dashboard_columns()` returns 1 for Xs/Sm, 2 for Md, 3 for Lg/Xl\n- [ ] `show_preview_pane()` returns false for Xs/Sm, true for Md+\n- [ ] All helpers use `Responsive<T>` (not bare match), so inheritance is automatic\n- [ ] Module is `pub` and importable by all view modules\n\n## Files\n- CREATE: crates/lore-tui/src/layout.rs\n- MODIFY: crates/lore-tui/src/lib.rs (add `pub mod layout;`)\n\n## TDD Anchor\nRED: Write `test_classify_width_boundaries` that asserts classify_width(59)=Xs, classify_width(60)=Sm, classify_width(89)=Sm, classify_width(90)=Md, classify_width(119)=Md, classify_width(120)=Lg, classify_width(159)=Lg, classify_width(160)=Xl.\nGREEN: Implement LORE_BREAKPOINTS and classify_width().\nVERIFY: cargo test -p lore-tui classify_width\n\nAdditional tests:\n- test_dashboard_columns_per_breakpoint\n- test_show_preview_pane_per_breakpoint\n- test_responsive_inheritance (Sm inherits from Xs when no Sm override set)\n\n## Edge Cases\n- Terminal width of 0 or 1 must not panic — classify to Xs\n- Very wide terminals (>300 cols) should still work — classify to Xl\n- All Responsive<T> values must have an Xs base so resolve never fails\n\n## Dependency Context\n- Depends on bd-3ddw (crate scaffold) which creates the crates/lore-tui/ workspace\n- Consumed by all view beads (bd-35g5 Dashboard, bd-3ei1 Issue List, bd-2kr0 MR List, bd-1zow Search, bd-u7se Who, etc.) for layout decisions","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T19:29:34.077080Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:24.951102Z","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-1q8z","title":"WHO: Epic — People Intelligence Commands","description":"## Background\n\nThe current beads roadmap focuses on Gate 4/5 (file-history, code-trace) — archaeology queries requiring mr_file_changes data that does not exist yet. Meanwhile, the DB has rich people/activity data (280K notes, 210K discussions, 33K DiffNotes with file positions, 53 active participants) that can answer collaboration questions immediately with zero new tables or API calls.\n\n## Scope\n\nThis epic builds `lore who` — a pure SQL query layer answering 5 questions:\n1. **Expert**: \"Who should I talk to about this feature/file?\" (DiffNote path analysis)\n2. **Workload**: \"What is person X working on?\" (open issues, authored/reviewing MRs, unresolved discussions)\n3. **Reviews**: \"What review patterns does person X have?\" (DiffNote **prefix** category extraction)\n4. **Active**: \"What discussions are actively in progress?\" (unresolved resolvable discussions)\n5. **Overlap**: \"Who else has MRs/notes touching my files?\" (path-based activity overlap)\n\n## Plan Reference\n\nFull implementation plan with 8 iterations of review: `docs/who-command-design.md`\n\n## Children (Execution Order)\n\n1. **bd-34rr** — Migration 017: 5 composite indexes for query performance\n2. **bd-2rk9** — CLI skeleton: WhoArgs, Commands::Who, dispatch, stub file\n3. **bd-2ldg** — Mode resolution, path helpers, run_who entry point, all result types\n4. **bd-zqpf** — Expert mode query (CTE + MR-breadth scoring)\n5. **bd-s3rc** — Workload mode query (4 SELECT queries)\n6. **bd-m7k1** — Active mode query (CTE + global/scoped SQL variants)\n7. **bd-b51e** — Overlap mode query (dual role tracking + accumulator)\n8. **bd-2711** — Reviews mode query (prefix extraction + normalization)\n9. **bd-1rdi** — Human terminal output for all 5 modes\n10. **bd-3mj2** — Robot JSON output for all 5 modes\n11. **bd-tfh3** — Comprehensive test suite (20+ tests)\n12. **bd-zibc** — VALID_COMMANDS + robot-docs manifest\n13. **bd-g0d5** — Verification gate (check, clippy, fmt, EXPLAIN QUERY PLAN)\n\n## Design Principles (from plan)\n\n- All SQL fully static — no format!() for query text, LIMIT bound as ?N\n- prepare_cached() everywhere for statement caching\n- (?N IS NULL OR ...) nullable binding except Active mode (two SQL variants for index selection)\n- Self-review exclusion on all DiffNote-based branches\n- Deterministic output: sorted GROUP_CONCAT, sorted HashSet-derived vectors, stable tie-breakers\n- Truncation transparency: LIMIT+1 pattern with truncated bool\n- Bounded payloads: capped arrays with *_total + *_truncated metadata\n- Robot-first reproducibility: input + resolved_input with since_mode tri-state\n\n## Files\n\n| File | Action | Description |\n|---|---|---|\n| `src/cli/commands/who.rs` | CREATE | All 5 query modes + human/robot output |\n| `src/cli/commands/mod.rs` | MODIFY | Add `pub mod who` + re-exports |\n| `src/cli/mod.rs` | MODIFY | Add `WhoArgs` struct + `Commands::Who` variant |\n| `src/main.rs` | MODIFY | Add dispatch arm + `handle_who` fn + VALID_COMMANDS + robot-docs |\n| `src/core/db.rs` | MODIFY | Add migration 017: composite indexes for who query paths |\n\n## TDD Loop\n\nEach child bead has its own RED/GREEN/VERIFY cycle. The epic TDD strategy:\n- RED: Tests in bd-tfh3 (written alongside query beads)\n- GREEN: Query implementations in bd-zqpf, bd-s3rc, bd-m7k1, bd-b51e, bd-2711\n- VERIFY: bd-g0d5 runs `cargo test` + `cargo clippy` + EXPLAIN QUERY PLAN\n\n## Acceptance Criteria\n\n- [ ] `lore who src/path/` shows ranked experts with scores\n- [ ] `lore who @username` shows workload across all projects\n- [ ] `lore who @username --reviews` shows categorized review patterns\n- [ ] `lore who --active` shows unresolved discussions\n- [ ] `lore who --overlap src/path/` shows other contributors\n- [ ] `lore who --path README.md` handles root files\n- [ ] `lore -J who ...` produces valid JSON with input + resolved_input\n- [ ] All indexes verified via EXPLAIN QUERY PLAN\n- [ ] cargo check + clippy + fmt + test all pass\n\n## Edge Cases\n\n- This epic has zero new tables — all queries are pure SQL over existing schema + migration 017 indexes\n- Gate 4/5 beads are NOT dependencies — who command works independently with current data\n- If DB has <1000 notes, queries will work but results will be sparse — this is expected for fresh installations\n- format_relative_time() is duplicated from list.rs intentionally (private fn, small blast radius > refactoring shared module)\n- lookup_project_path() is local to who.rs — single invocation per run, does not warrant shared utility","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-02-08T02:39:39.538892Z","created_by":"tayloreernisse","updated_at":"2026-02-08T04:10:38.665143Z","closed_at":"2026-02-08T04:10:38.665094Z","close_reason":"All 13 child beads implemented: migration 017 (5 composite indexes), CLI skeleton with WhoArgs/dispatch/robot-docs, 5 query modes (expert/workload/active/overlap/reviews), human terminal + robot JSON output, 20 tests. All quality gates pass: cargo check, clippy (pedantic+nursery), fmt, test.","compaction_level":0,"original_size":0}
{"id":"bd-1qf","title":"[CP1] Discussion and note transformers","description":"## Background\n\nDiscussion and note transformers convert GitLab API discussion responses into our normalized schema. They compute derived fields like `first_note_at`, `last_note_at`, resolvable/resolved status, and note positions. These are pure functions with no I/O.\n\n## Approach\n\nCreate transformer module with:\n\n### Structs\n\n```rust\n// src/gitlab/transformers/discussion.rs\n\npub struct NormalizedDiscussion {\n    pub gitlab_discussion_id: String,\n    pub project_id: i64,\n    pub issue_id: i64,\n    pub noteable_type: String,        // \"Issue\"\n    pub individual_note: bool,\n    pub first_note_at: Option<i64>,   // min(note.created_at) in ms epoch\n    pub last_note_at: Option<i64>,    // max(note.created_at) in ms epoch\n    pub last_seen_at: i64,\n    pub resolvable: bool,             // any note is resolvable\n    pub resolved: bool,               // all resolvable notes are resolved\n}\n\npub struct NormalizedNote {\n    pub gitlab_id: i64,\n    pub project_id: i64,\n    pub note_type: Option<String>,    // \"DiscussionNote\" | \"DiffNote\" | null\n    pub is_system: bool,              // from note.system\n    pub author_username: String,\n    pub body: String,\n    pub created_at: i64,              // ms epoch\n    pub updated_at: i64,              // ms epoch\n    pub last_seen_at: i64,\n    pub position: i32,                // 0-indexed array position\n    pub resolvable: bool,\n    pub resolved: bool,\n    pub resolved_by: Option<String>,\n    pub resolved_at: Option<i64>,\n}\n```\n\n### Functions\n\n```rust\npub fn transform_discussion(\n    gitlab_discussion: &GitLabDiscussion,\n    local_project_id: i64,\n    local_issue_id: i64,\n) -> NormalizedDiscussion\n\npub fn transform_notes(\n    gitlab_discussion: &GitLabDiscussion,\n    local_project_id: i64,\n) -> Vec<NormalizedNote>\n```\n\n## Acceptance Criteria\n\n- [ ] `NormalizedDiscussion` struct with all fields\n- [ ] `NormalizedNote` struct with all fields\n- [ ] `transform_discussion` computes first_note_at/last_note_at from notes array\n- [ ] `transform_discussion` computes resolvable (any note is resolvable)\n- [ ] `transform_discussion` computes resolved (all resolvable notes resolved)\n- [ ] `transform_notes` preserves array order via position field (0-indexed)\n- [ ] `transform_notes` maps system flag to is_system\n- [ ] Unit tests cover all computed fields\n\n## Files\n\n- src/gitlab/transformers/mod.rs (add `pub mod discussion;`)\n- src/gitlab/transformers/discussion.rs (create)\n\n## TDD Loop\n\nRED:\n```rust\n// tests/discussion_transformer_tests.rs\n#[test] fn transforms_discussion_payload_to_normalized_schema()\n#[test] fn extracts_notes_array_from_discussion()\n#[test] fn sets_individual_note_flag_correctly()\n#[test] fn flags_system_notes_with_is_system_true()\n#[test] fn preserves_note_order_via_position_field()\n#[test] fn computes_first_note_at_and_last_note_at_correctly()\n#[test] fn computes_resolvable_and_resolved_status()\n```\n\nGREEN: Implement transform_discussion and transform_notes\n\nVERIFY: `cargo test discussion_transformer`\n\n## Edge Cases\n\n- Discussion with single note - first_note_at == last_note_at\n- All notes are system notes - still compute timestamps\n- No notes resolvable - resolvable=false, resolved=false\n- Mix of resolved/unresolved notes - resolved=false until all done","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-25T17:02:38.196079Z","created_by":"tayloreernisse","updated_at":"2026-01-25T22:27:11.485112Z","closed_at":"2026-01-25T22:27:11.485058Z","close_reason":"Implemented NormalizedDiscussion, NormalizedNote, transform_discussion, transform_notes with 9 passing unit tests","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1qf","depends_on_id":"bd-1np","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1qpp","title":"Implement NavigationStack (back/forward/jump list)","description":"## Background\nNavigation uses a stack with global shortcuts, supporting back/forward (browser-like) and jump list (vim-like Ctrl+O/Ctrl+I). State is preserved when navigating away — screens are never cleared on pop. The jump list only records \"significant\" hops (detail views, cross-references).\n\n## Approach\nCreate crates/lore-tui/src/navigation.rs:\n- NavigationStack struct: back_stack (Vec<Screen>), current (Screen), forward_stack (Vec<Screen>), jump_list (Vec<Screen>), jump_index (usize), browse_snapshots (HashMap<ScreenKind, BrowseSnapshot>)\n- new() -> Self: initializes with Dashboard as current\n- current() -> &Screen\n- is_at(&Screen) -> bool\n- push(Screen): pushes current to back_stack, clears forward_stack, sets new current, records detail hops in jump_list\n- pop() -> Option<Screen>: pops from back_stack, pushes current to forward_stack\n- go_forward() -> Option<Screen>: pops from forward_stack, pushes current to back_stack\n- jump_back() -> Option<&Screen>: moves backward in jump list (Ctrl+O)\n- jump_forward() -> Option<&Screen>: moves forward in jump list (Ctrl+I)\n- reset_to(Screen): clears all stacks, sets new current (H=Home)\n- breadcrumbs() -> Vec<&str>: returns labels for breadcrumb display\n- depth() -> usize: back_stack.len() + 1\n- BrowseSnapshot struct: per-screen pagination cursor snapshot for stable ordering during concurrent writes\n\n## Acceptance Criteria\n- [ ] push() adds to back_stack and clears forward_stack\n- [ ] pop() moves current to forward_stack and restores previous\n- [ ] go_forward() restores from forward_stack\n- [ ] jump_back/forward navigates only through detail views\n- [ ] reset_to() clears all history\n- [ ] breadcrumbs() returns ordered screen labels\n- [ ] pop() returns None at root (can't pop past Dashboard)\n- [ ] push() only records is_detail_or_entity() screens in jump_list\n\n## Files\n- CREATE: crates/lore-tui/src/navigation.rs\n\n## TDD Anchor\nRED: Write test_push_pop_preserves_order that pushes Dashboard->IssueList->IssueDetail, pops twice, verifies correct order.\nGREEN: Implement push/pop with back_stack.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_push_pop\n\nAdditional tests:\n- test_forward_stack_cleared_on_new_push\n- test_jump_list_skips_list_screens\n- test_reset_clears_all_history\n- test_pop_at_root_returns_none\n- test_breadcrumbs_reflect_stack\n\n## Edge Cases\n- Stack depth has no explicit limit — deeply nested cross-reference chains are supported\n- Forward stack must be cleared on any new push (browser behavior)\n- Jump list must truncate forward entries when recording a new jump (vim behavior)\n\n## Dependency Context\nUses Screen enum and Screen::is_detail_or_entity() from \"Implement core types\" task.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T16:56:01.365386Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:22.070503Z","deleted_at":"2026-03-11T18:34:22.070497Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-1qz","title":"[CP1] Database migration 002_issues.sql","description":"Create migration file with tables for issues, labels, issue_labels, discussions, and notes.\n\n## Tables\n\n### issues\n- id INTEGER PRIMARY KEY\n- gitlab_id INTEGER UNIQUE NOT NULL\n- project_id INTEGER NOT NULL REFERENCES projects(id)\n- iid INTEGER NOT NULL\n- title TEXT, description TEXT, state TEXT\n- author_username TEXT\n- created_at, updated_at, last_seen_at INTEGER (ms epoch UTC)\n- discussions_synced_for_updated_at INTEGER (watermark for dependent sync)\n- web_url TEXT\n- raw_payload_id INTEGER REFERENCES raw_payloads(id)\n\n### labels (name-only for CP1)\n- id INTEGER PRIMARY KEY\n- gitlab_id INTEGER (optional, for future Labels API)\n- project_id INTEGER NOT NULL REFERENCES projects(id)\n- name TEXT NOT NULL\n- color TEXT, description TEXT (nullable, deferred)\n- UNIQUE(project_id, name)\n\n### issue_labels (junction)\n- issue_id, label_id with CASCADE DELETE\n- Clear existing links before INSERT to handle removed labels\n\n### discussions\n- gitlab_discussion_id TEXT (string ID from API)\n- project_id, issue_id/merge_request_id FKs\n- noteable_type TEXT ('Issue' | 'MergeRequest')\n- individual_note INTEGER, first_note_at, last_note_at, last_seen_at\n- resolvable, resolved flags\n- CHECK constraint for Issue vs MR exclusivity\n\n### notes\n- gitlab_id INTEGER UNIQUE NOT NULL\n- discussion_id, project_id FKs\n- note_type, is_system, author_username, body\n- timestamps, position (array order)\n- resolution fields, DiffNote position fields\n\n## Indexes\n- idx_issues_project_updated, idx_issues_author, idx_issues_discussions_sync\n- uq_issues_project_iid, uq_labels_project_name\n- idx_issue_labels_label\n- uq_discussions_project_discussion_id, idx_discussions_issue/mr/last_note\n- idx_notes_discussion/author/system\n\nFiles: migrations/002_issues.sql\nDone when: Migration applies cleanly on top of 001_initial.sql, schema_version = 2","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:42:31.464544Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.685262Z","closed_at":"2026-01-25T17:02:01.685262Z","deleted_at":"2026-01-25T17:02:01.685258Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-1rdi","title":"WHO: Human terminal output for all 5 modes","description":"## Background\n\nTerminal output for humans. Each mode gets a dedicated print function with consistent styling: bold headers, cyan usernames/refs, dim metadata, table alignment.\n\n## Approach\n\n### Dispatch:\n```rust\npub fn print_who_human(result: &WhoResult, project_path: Option<&str>) {\n    match result {\n        WhoResult::Expert(r) => print_expert_human(r, project_path),\n        WhoResult::Workload(r) => print_workload_human(r),\n        WhoResult::Reviews(r) => print_reviews_human(r),\n        WhoResult::Active(r) => print_active_human(r, project_path),\n        WhoResult::Overlap(r) => print_overlap_human(r, project_path),\n    }\n}\n```\n\n### Shared helpers:\n- **print_scope_hint()**: dim \"(aggregated across all projects; use -p to scope)\" when project_path is None. Called by Expert, Active, Overlap.\n- **format_relative_time(ms_epoch)**: \"just now\" / \"N min ago\" / \"N hours ago\" / \"N days ago\" / \"N weeks ago\" / \"N months ago\" — DUPLICATE from list.rs (private there, keep blast radius small)\n- **truncate_str(s, max)**: Unicode-aware, appends \"...\" if truncated\n\n### Mode formats:\n- **Expert**: table with Username(16) / Score(6) / Reviewed(MRs)(12) / Notes(6) / Authored(MRs)(12) / Last Seen. Path match hint line. \"-\" for zero counts.\n- **Workload**: 4 sections (Assigned Issues, Authored MRs, Reviewing MRs, Unresolved Discussions). Canonical refs in cyan. Draft indicator. Per-section truncation.\n- **Reviews**: DiffNote summary line + category table (Category(16) / Count(6) / %(6)). Uncategorized count note.\n- **Active**: Discussion list with entity ref, note count, participants (comma-joined @usernames), project path. Discussion count in header.\n- **Overlap**: table with Username(16) / Role(6) / MRs(7) / Last Seen(12) / MR Refs (first 5, +N overflow). Path match hint.\n\n### All modes: truncation dim hints, empty-state messages, console::style formatting.\n\n## Files\n\n- `src/cli/commands/who.rs`\n\n## TDD Loop\n\nNo unit tests for print functions (they write to stdout). Verification is manual smoke test.\nVERIFY: `cargo check --all-targets` then manual: `cargo run --release -- who src/features/global-search/`\n\n## Acceptance Criteria\n\n- [ ] cargo check passes (all print functions compile)\n- [ ] Each mode produces readable, aligned terminal output\n- [ ] Scope hint shown when project not specified (Expert, Active, Overlap)\n- [ ] Truncation hints shown when results exceed limit\n- [ ] Empty-state messages for zero results\n\n## Edge Cases\n\n- format_relative_time handles negative diff (\"in the future\")\n- truncate_str is Unicode-aware (.chars().count(), not .len())\n- Workload shows empty message only when ALL 4 sections are empty","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-08T02:41:06.190608Z","created_by":"tayloreernisse","updated_at":"2026-02-08T04:10:29.599783Z","closed_at":"2026-02-08T04:10:29.599749Z","close_reason":"Implemented by agent team: migration 017, CLI skeleton, all 5 query modes, human+robot output, 20 tests. All quality gates pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1rdi","depends_on_id":"bd-2711","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1rdi","depends_on_id":"bd-b51e","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1rdi","depends_on_id":"bd-m7k1","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1rdi","depends_on_id":"bd-s3rc","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1rdi","depends_on_id":"bd-zqpf","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1re","title":"[CP1] gi show issue command","description":"Show issue details with discussions.\n\nFlags:\n- --project=PATH (required if iid is ambiguous across projects)\n\nOutput:\n- Title, project, state, author, dates, labels, URL\n- Description text\n- All discussions with notes (formatted thread view)\n\nHandle ambiguity: If multiple projects have same iid, prompt for --project or show error.\n\nFiles: src/cli/commands/show.ts\nDone when: Issue detail view displays all fields including threaded discussions","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T15:20:29.826786Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.153211Z","closed_at":"2026-01-25T15:21:35.153211Z","deleted_at":"2026-01-25T15:21:35.153208Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-1s1","title":"[CP1] Integration tests for issue ingestion","description":"Full integration tests for issue ingestion module.\n\n## Tests (tests/issue_ingestion_tests.rs)\n\n- inserts_issues_into_database\n- creates_labels_from_issue_payloads\n- links_issues_to_labels_via_junction_table\n- removes_stale_label_links_on_resync\n- stores_raw_payload_for_each_issue\n- stores_raw_payload_for_each_discussion\n- updates_cursor_incrementally_per_page\n- resumes_from_cursor_on_subsequent_runs\n- handles_issues_with_no_labels\n- upserts_existing_issues_on_refetch\n- skips_discussion_refetch_for_unchanged_issues\n\n## Test Setup\n- tempfile::TempDir for isolated database\n- wiremock::MockServer for GitLab API\n- Mock handlers returning fixture data\n\nFiles: tests/issue_ingestion_tests.rs\nDone when: All integration tests pass with mocked GitLab","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T16:59:12.158586Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:02.109109Z","closed_at":"2026-01-25T17:02:02.109109Z","deleted_at":"2026-01-25T17:02:02.109105Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-1sc6","title":"Add SurgicalPreflightFailed error variant and foundation visibility changes","description":"## Background\nSurgical sync needs a dedicated error variant for preflight failures (e.g., IID not found on GitLab, project mismatch). The existing `GitLabNotFound` variant maps to exit code 6 and is too generic — it does not convey that the failure occurred during surgical preflight validation. A new `SurgicalPreflightFailed` variant in `LoreError` with a clear Display message and exit code 6 provides actionable feedback to both human and robot consumers.\n\nAdditionally, the `process_single_issue` function in `src/ingestion/issues.rs` and `process_single_mr` + `ProcessMrResult` in `src/ingestion/merge_requests.rs` are currently private. The surgical sync orchestrator (downstream bead bd-3sez) will need to call these from `src/core/surgical.rs`, so they must be raised to `pub(crate)` visibility. No config field is needed for this bead — the surgical sync feature is triggered purely by CLI flags (bead bd-1lja).\n\n## Approach\n\n### Step 1: Add ErrorCode variant (src/core/error.rs, line ~23)\nAdd `SurgicalPreflightFailed` to the `ErrorCode` enum (after `Ambiguous`). Wire it through three impls:\n- `Display`: maps to `\"SURGICAL_PREFLIGHT_FAILED\"`\n- `exit_code()`: maps to `6` (same category as GitLabNotFound — resource-level failure)\n\n### Step 2: Add LoreError variant (src/core/error.rs, after EmbeddingsNotBuilt ~line 155)\n```rust\n#[error(\"Surgical preflight failed for {entity_type} !{iid} in {project}: {reason}\")]\nSurgicalPreflightFailed {\n    entity_type: String,  // \"issue\" or \"merge_request\"\n    iid: u64,\n    project: String,\n    reason: String,\n},\n```\nWire in `code()` → `ErrorCode::SurgicalPreflightFailed`, `suggestion()` → a helpful message about verifying the IID exists, `actions()` → `[\"lore issues -p <project>\", \"lore mrs -p <project>\"]`.\n\n### Step 3: Raise visibility (src/ingestion/issues.rs, src/ingestion/merge_requests.rs)\n- `process_single_issue` at line 143: `fn` → `pub(crate) fn`\n- `process_single_mr` at line 144: `fn` → `pub(crate) fn`\n- `ProcessMrResult` at line 138: `struct` → `pub(crate) struct` (and its fields)\n\n## Acceptance Criteria\n- [ ] `ErrorCode::SurgicalPreflightFailed` exists with Display `\"SURGICAL_PREFLIGHT_FAILED\"` and exit code 6\n- [ ] `LoreError::SurgicalPreflightFailed { entity_type, iid, project, reason }` exists\n- [ ] `LoreError::SurgicalPreflightFailed { .. }.code()` returns `ErrorCode::SurgicalPreflightFailed`\n- [ ] Display output includes entity_type, iid, project, and reason\n- [ ] `suggestion()` returns a non-None helpful string\n- [ ] `process_single_issue` is `pub(crate)`\n- [ ] `process_single_mr` is `pub(crate)`\n- [ ] `ProcessMrResult` and its fields are `pub(crate)`\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n- [ ] All existing tests pass\n\n## Files\n- MODIFY: src/core/error.rs (add ErrorCode variant, LoreError variant, wire Display/exit_code/code/suggestion/actions)\n- MODIFY: src/ingestion/issues.rs (pub(crate) on process_single_issue)\n- MODIFY: src/ingestion/merge_requests.rs (pub(crate) on process_single_mr, ProcessMrResult + fields)\n\n## TDD Anchor\nRED: Write three tests in a new `#[cfg(test)] mod tests` block at the bottom of `src/core/error.rs`:\n\n```rust\n#[cfg(test)]\nmod tests {\n    use super::*;\n\n    #[test]\n    fn surgical_preflight_failed_display() {\n        let err = LoreError::SurgicalPreflightFailed {\n            entity_type: \"issue\".to_string(),\n            iid: 42,\n            project: \"group/repo\".to_string(),\n            reason: \"not found on GitLab\".to_string(),\n        };\n        let msg = err.to_string();\n        assert!(msg.contains(\"issue\"), \"missing entity_type: {msg}\");\n        assert!(msg.contains(\"42\"), \"missing iid: {msg}\");\n        assert!(msg.contains(\"group/repo\"), \"missing project: {msg}\");\n        assert!(msg.contains(\"not found on GitLab\"), \"missing reason: {msg}\");\n    }\n\n    #[test]\n    fn surgical_preflight_failed_error_code() {\n        let code = ErrorCode::SurgicalPreflightFailed;\n        assert_eq!(code.exit_code(), 6);\n    }\n\n    #[test]\n    fn surgical_preflight_failed_code_mapping() {\n        let err = LoreError::SurgicalPreflightFailed {\n            entity_type: \"merge_request\".to_string(),\n            iid: 99,\n            project: \"ns/proj\".to_string(),\n            reason: \"404\".to_string(),\n        };\n        assert_eq!(err.code(), ErrorCode::SurgicalPreflightFailed);\n    }\n}\n```\n\nGREEN: Add the variant and wire all impls.\nVERIFY: `cargo test surgical_preflight_failed`\n\n## Edge Cases\n- Exit code 6 is shared with `GitLabNotFound` — this is intentional (same semantic category: resource not found). The `ErrorCode` Display string distinguishes them for robot consumers.\n- The `entity_type` field uses strings (\"issue\", \"merge_request\") rather than an enum to avoid over-abstraction for two values.\n- Visibility changes are `pub(crate)`, not `pub` — these are internal implementation details, not public API.\n\n## Dependency Context\nThis is a leaf/foundation bead with no upstream dependencies. Downstream beads bd-1i4i (orchestrator) and bd-3sez (surgical.rs module) depend on this for both the error variant and the pub(crate) visibility of ingestion functions.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-17T19:11:41.476902Z","created_by":"tayloreernisse","updated_at":"2026-02-18T21:04:58.563213Z","closed_at":"2026-02-18T21:04:58.563158Z","close_reason":"Completed: all implementation work done, code reviewed, tests passing","compaction_level":0,"original_size":0,"labels":["surgical-sync"],"dependencies":[{"issue_id":"bd-1sc6","depends_on_id":"bd-1i4i","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1se","title":"Epic: Gate 2 - Cross-Reference Extraction","description":"## Background\nGate 2 builds the entity relationship graph that connects issues, MRs, and discussions. Without cross-references, temporal queries can only show events for individually-matched entities. With them, \"lore timeline auth migration\" can discover that MR !567 closed issue #234, which spawned follow-up issue #299 — even if #299 does not contain the words \"auth migration.\"\n\nThree data sources feed entity_references:\n1. **Structured API (reliable):** GET /projects/:id/merge_requests/:iid/closes_issues\n2. **State events (reliable):** resource_state_events.source_merge_request_id\n3. **System note parsing (best-effort):** \"mentioned in !456\", \"closed by !789\" patterns\n\n## Architecture\n- **entity_references table:** Already created in migration 011 (bd-hu3/bd-czk). Stores source→target relationships with reference_type (closes/mentioned/related) and source_method provenance.\n- **Directionality convention:** source = entity where reference was observed, target = entity being referenced. Consistent across all source_methods.\n- **Unresolved references:** Cross-project refs stored with target_entity_id=NULL, target_project_path populated. Still valuable for timeline narratives.\n- **closes_issues fetch:** Uses generic dependent fetch queue (job_type = mr_closes_issues). One API call per MR.\n- **System note parsing:** Local post-processing after all dependent fetches complete. No API calls. English-only, best-effort.\n\n## Children (Execution Order)\n1. **bd-czk** [CLOSED] — entity_references schema (folded into migration 011)\n2. **bd-8t4** [OPEN] — Extract cross-references from resource_state_events (source_merge_request_id)\n3. **bd-3ia** [OPEN] — Fetch closes_issues API and populate entity_references\n4. **bd-1ji** [OPEN] — Parse system notes for cross-reference patterns\n\n## Gate Completion Criteria\n- [ ] entity_references populated from closes_issues API for all synced MRs\n- [ ] entity_references populated from state events where source_merge_request_id present\n- [ ] System notes parsed for cross-reference patterns (English instances)\n- [ ] Cross-project references stored as unresolved (target_entity_id=NULL)\n- [ ] source_method tracks provenance of each reference\n- [ ] Deduplication: same relationship from multiple sources stored once (UNIQUE constraint)\n- [ ] Timeline JSON includes expansion provenance (via) for expanded entities\n- [ ] Integration test: sync with all three extraction methods, verify entity_references populated\n\n## Dependencies\n- Depends on: Gate 1 (bd-2zl) — event tables and dependent fetch queue\n- Downstream: Gate 3 (bd-ike) depends on entity_references for BFS expansion","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-02-02T21:31:00.981132Z","created_by":"tayloreernisse","updated_at":"2026-02-05T16:08:26.965177Z","closed_at":"2026-02-05T16:08:26.964997Z","close_reason":"All child beads completed: bd-8t4 (state event extraction), bd-3ia (closes_issues API), bd-1ji (system note parsing)","compaction_level":0,"original_size":0,"labels":["epic","gate-2","phase-b"],"dependencies":[{"issue_id":"bd-1se","depends_on_id":"bd-2zl","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1ser","title":"Implement scope context (global project filter)","description":"## Background\nThe scope context provides a global project filter that flows through all query bridge functions. Users can pin to a specific project set or view all projects. The P keybinding opens a scope picker overlay. Scope is persisted in session state.\n\n## Approach\nCreate crates/lore-tui/src/scope.rs:\n- ScopeContext enum: AllProjects, Pinned(Vec<ProjectInfo>)\n- ProjectInfo: id (i64), path (String)\n- scope_filter_sql(scope: &ScopeContext) -> String: generates WHERE clause fragment\n- All action.rs query functions accept &ScopeContext parameter\n- Scope picker overlay: list of projects with checkbox selection\n- P keybinding toggles scope picker from any screen\n\n## Acceptance Criteria\n- [ ] AllProjects scope returns unfiltered results\n- [ ] Pinned scope filters to specific project IDs\n- [ ] All query functions respect global scope\n- [ ] P keybinding opens scope picker\n- [ ] Scope persisted in session state\n- [ ] Scope change triggers re-query of current screen\n\n## Files\n- CREATE: crates/lore-tui/src/scope.rs\n- MODIFY: crates/lore-tui/src/action.rs (add scope parameter to all queries)\n\n## TDD Anchor\nRED: Write test_scope_filter_sql that creates Pinned scope with 2 projects, asserts generated SQL contains IN (1, 2).\nGREEN: Implement scope_filter_sql.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_scope_filter\n\n## Edge Cases\n- Single-project datasets: scope picker not needed, but should still work\n- Very many projects (>50): scope picker should be scrollable\n- Scope change mid-pagination: reset cursor to first page\n\n## Dependency Context\nUses AppState from \"Implement AppState composition\" task.\nUses session persistence from \"Implement session persistence\" task.","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-02-12T17:03:37.555484Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:22.149922Z","deleted_at":"2026-03-11T18:34:22.149916Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-1soz","title":"Add half_life_decay() pure function","description":"## Background\nThe decay function is the mathematical core of the scoring model. It must be correct, tested first (TDD RED), and verified independently of any DB or SQL changes.\n\n## Approach\nAdd to who.rs as a private function near the top of the module (before query_expert):\n\n```rust\n/// Exponential half-life decay: R = 2^(-t/h)\n/// Returns 1.0 at elapsed=0, 0.5 at elapsed=half_life, 0.0 if half_life=0.\nfn half_life_decay(elapsed_ms: i64, half_life_days: u32) -> f64 {\n    let days = (elapsed_ms as f64 / 86_400_000.0).max(0.0);\n    let hl = f64::from(half_life_days);\n    if hl <= 0.0 { return 0.0; }\n    2.0_f64.powf(-days / hl)\n}\n```\n\n## TDD Loop\n\n### RED (write first):\n```rust\n#[test]\nfn test_half_life_decay_math() {\n    let hl_180 = 180;\n    // At t=0, full retention\n    assert!((half_life_decay(0, hl_180) - 1.0).abs() < f64::EPSILON);\n    // At t=half_life, exactly 0.5\n    let one_hl_ms = 180 * 86_400_000_i64;\n    assert!((half_life_decay(one_hl_ms, hl_180) - 0.5).abs() < 1e-10);\n    // At t=2*half_life, exactly 0.25\n    assert!((half_life_decay(2 * one_hl_ms, hl_180) - 0.25).abs() < 1e-10);\n    // Negative elapsed clamped to 0 -> 1.0\n    assert!((half_life_decay(-1000, hl_180) - 1.0).abs() < f64::EPSILON);\n    // Zero half-life -> 0.0 (div-by-zero guard)\n    assert!((half_life_decay(86_400_000, 0)).abs() < f64::EPSILON);\n}\n\n#[test]\nfn test_score_monotonicity_by_age() {\n    // For any half-life, older timestamps must never produce higher decay than newer ones.\n    // Use deterministic LCG PRNG (no rand dependency).\n    let mut seed: u64 = 42;\n    let hl = 90_u32;\n    for _ in 0..50 {\n        seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);\n        let newer_ms = (seed % 100_000_000) as i64;       // 0-100M ms (~1.15 days max)\n        seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);\n        let older_ms = newer_ms + (seed % 500_000_000) as i64; // always >= newer\n        assert!(\n            half_life_decay(older_ms, hl) <= half_life_decay(newer_ms, hl),\n            \"Monotonicity violated: decay({older_ms}) > decay({newer_ms})\"\n        );\n    }\n}\n```\n\n### GREEN: Add the half_life_decay function (3 lines of math).\n### VERIFY: `cargo test -p lore -- test_half_life_decay_math test_score_monotonicity`\n\n## Acceptance Criteria\n- [ ] test_half_life_decay_math passes (4 boundary cases + div-by-zero guard)\n- [ ] test_score_monotonicity_by_age passes (50 random pairs, deterministic seed)\n- [ ] Function is `fn` not `pub fn` (module-private)\n- [ ] No DB dependency — pure function\n\n## Files\n- src/cli/commands/who.rs (function near top, tests in test module)\n\n## Edge Cases\n- Negative elapsed_ms: clamped to 0 via .max(0.0) -> returns 1.0\n- half_life_days = 0: returns 0.0, not NaN/Inf\n- Very large elapsed (10 years): returns very small positive f64, never negative","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-09T16:59:22.913281Z","created_by":"tayloreernisse","updated_at":"2026-02-12T20:43:04.404986Z","closed_at":"2026-02-12T20:43:04.404933Z","close_reason":"Implemented by time-decay swarm: 3 agents, 12 tasks, 621 tests passing, all quality gates green","compaction_level":0,"original_size":0,"labels":["scoring"]}
{"id":"bd-1t4","title":"Epic: CP2 Gate C - Dependent Discussion Sync","description":"## Background\nGate C validates the dependent discussion sync with DiffNote position capture. This is critical for code review context preservation - without DiffNote positions, we lose the file/line context for review comments.\n\n## Acceptance Criteria (Pass/Fail)\n- [ ] Discussions fetched for MRs with updated_at > discussions_synced_for_updated_at\n- [ ] `SELECT COUNT(*) FROM discussions WHERE merge_request_id IS NOT NULL` > 0\n- [ ] DiffNotes have `position_new_path` populated (file path)\n- [ ] DiffNotes have `position_new_line` populated (line number)\n- [ ] DiffNotes have `position_type` populated (text/image/file)\n- [ ] DiffNotes have SHA triplet: `position_base_sha`, `position_start_sha`, `position_head_sha`\n- [ ] Multi-line DiffNotes have `position_line_range_start` and `position_line_range_end`\n- [ ] Unchanged MRs skip discussion refetch (watermark comparison works)\n- [ ] Watermark NOT advanced on HTTP error mid-pagination\n- [ ] Watermark NOT advanced on note timestamp parse failure\n- [ ] `gi show mr <iid>` displays DiffNote with file context `[path:line]`\n\n## Validation Script\n```bash\n#!/bin/bash\nset -e\n\nDB_PATH=\"${XDG_DATA_HOME:-$HOME/.local/share}/gitlab-inbox/db.sqlite3\"\n\necho \"=== Gate C: Dependent Discussion Sync ===\"\n\n# 1. Check discussion count for MRs\necho \"Step 1: Check MR discussion count...\"\nMR_DISC_COUNT=$(sqlite3 \"$DB_PATH\" \"SELECT COUNT(*) FROM discussions WHERE merge_request_id IS NOT NULL;\")\necho \"  MR discussions: $MR_DISC_COUNT\"\n[ \"$MR_DISC_COUNT\" -gt 0 ] || { echo \"FAIL: No MR discussions found\"; exit 1; }\n\n# 2. Check note count\necho \"Step 2: Check note count...\"\nNOTE_COUNT=$(sqlite3 \"$DB_PATH\" \"\n  SELECT COUNT(*) FROM notes n\n  JOIN discussions d ON d.id = n.discussion_id\n  WHERE d.merge_request_id IS NOT NULL;\n\")\necho \"  MR notes: $NOTE_COUNT\"\n\n# 3. Check DiffNote position data\necho \"Step 3: Check DiffNote positions...\"\nDIFFNOTE_COUNT=$(sqlite3 \"$DB_PATH\" \"SELECT COUNT(*) FROM notes WHERE position_new_path IS NOT NULL;\")\necho \"  DiffNotes with position: $DIFFNOTE_COUNT\"\n\n# 4. Sample DiffNote data\necho \"Step 4: Sample DiffNote data...\"\nsqlite3 \"$DB_PATH\" \"\n  SELECT \n    n.gitlab_id,\n    n.position_new_path,\n    n.position_new_line,\n    n.position_type,\n    SUBSTR(n.position_head_sha, 1, 7) as head_sha\n  FROM notes n\n  WHERE n.position_new_path IS NOT NULL\n  LIMIT 5;\n\"\n\n# 5. Check multi-line DiffNotes\necho \"Step 5: Check multi-line DiffNotes...\"\nMULTILINE_COUNT=$(sqlite3 \"$DB_PATH\" \"\n  SELECT COUNT(*) FROM notes \n  WHERE position_line_range_start IS NOT NULL \n    AND position_line_range_end IS NOT NULL\n    AND position_line_range_start != position_line_range_end;\n\")\necho \"  Multi-line DiffNotes: $MULTILINE_COUNT\"\n\n# 6. Check watermarks set\necho \"Step 6: Check watermarks...\"\nWATERMARKED=$(sqlite3 \"$DB_PATH\" \"\n  SELECT COUNT(*) FROM merge_requests \n  WHERE discussions_synced_for_updated_at IS NOT NULL;\n\")\necho \"  MRs with watermark set: $WATERMARKED\"\n\n# 7. Check last_seen_at for sweep pattern\necho \"Step 7: Check last_seen_at (sweep pattern)...\"\nsqlite3 \"$DB_PATH\" \"\n  SELECT \n    MIN(last_seen_at) as oldest,\n    MAX(last_seen_at) as newest\n  FROM discussions \n  WHERE merge_request_id IS NOT NULL;\n\"\n\n# 8. Test show command with DiffNote\necho \"Step 8: Find MR with DiffNotes for show test...\"\nMR_IID=$(sqlite3 \"$DB_PATH\" \"\n  SELECT DISTINCT m.iid\n  FROM merge_requests m\n  JOIN discussions d ON d.merge_request_id = m.id\n  JOIN notes n ON n.discussion_id = d.id\n  WHERE n.position_new_path IS NOT NULL\n  LIMIT 1;\n\")\nif [ -n \"$MR_IID\" ]; then\n  echo \"  Testing: gi show mr $MR_IID\"\n  gi show mr \"$MR_IID\" | head -50\nfi\n\n# 9. Re-run and verify skip count\necho \"Step 9: Re-run ingest (should skip unchanged MRs)...\"\ngi ingest --type=merge_requests\n# Should report \"Skipped discussion sync for N unchanged MRs\"\n\necho \"\"\necho \"=== Gate C: PASSED ===\"\n```\n\n## Atomicity Test (Manual - Kill Test)\n```bash\n# This tests that partial failure preserves data\n\n# 1. Get an MR with discussions\nMR_ID=$(sqlite3 \"$DB_PATH\" \"\n  SELECT m.id FROM merge_requests m\n  JOIN discussions d ON d.merge_request_id = m.id\n  LIMIT 1;\n\")\n\n# 2. Note current note count\nBEFORE=$(sqlite3 \"$DB_PATH\" \"\n  SELECT COUNT(*) FROM notes n\n  JOIN discussions d ON d.id = n.discussion_id\n  WHERE d.merge_request_id = $MR_ID;\n\")\necho \"Notes before: $BEFORE\"\n\n# 3. Note watermark\nWATERMARK_BEFORE=$(sqlite3 \"$DB_PATH\" \"\n  SELECT discussions_synced_for_updated_at FROM merge_requests WHERE id = $MR_ID;\n\")\necho \"Watermark before: $WATERMARK_BEFORE\"\n\n# 4. Force full sync and kill mid-run\ngi ingest --type=merge_requests --full &\nPID=$!\nsleep 3 && kill -9 $PID 2>/dev/null || true\nwait $PID 2>/dev/null || true\n\n# 5. Verify notes preserved (should be same or more, never less)\nAFTER=$(sqlite3 \"$DB_PATH\" \"\n  SELECT COUNT(*) FROM notes n\n  JOIN discussions d ON d.id = n.discussion_id\n  WHERE d.merge_request_id = $MR_ID;\n\")\necho \"Notes after kill: $AFTER\"\n[ \"$AFTER\" -ge \"$BEFORE\" ] || echo \"WARNING: Notes decreased - atomicity may be broken\"\n\n# 6. Note watermark should NOT have advanced if killed mid-pagination\nWATERMARK_AFTER=$(sqlite3 \"$DB_PATH\" \"\n  SELECT discussions_synced_for_updated_at FROM merge_requests WHERE id = $MR_ID;\n\")\necho \"Watermark after: $WATERMARK_AFTER\"\n```\n\n## Test Commands (Quick Verification)\n```bash\n# Check DiffNote data:\nsqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 \"\n  SELECT \n    (SELECT COUNT(*) FROM discussions WHERE merge_request_id IS NOT NULL) as mr_discussions,\n    (SELECT COUNT(*) FROM notes WHERE position_new_path IS NOT NULL) as diffnotes,\n    (SELECT COUNT(*) FROM merge_requests WHERE discussions_synced_for_updated_at IS NOT NULL) as watermarked;\n\"\n\n# Find MR with DiffNotes and show it:\ngi show mr $(sqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 \"\n  SELECT DISTINCT m.iid FROM merge_requests m\n  JOIN discussions d ON d.merge_request_id = m.id\n  JOIN notes n ON n.discussion_id = d.id\n  WHERE n.position_new_path IS NOT NULL LIMIT 1;\n\")\n```\n\n## Dependencies\nThis gate requires:\n- bd-3j6 (Discussion transformer with DiffNote position extraction)\n- bd-20h (MR discussion ingestion with atomicity guarantees)\n- bd-iba (Client pagination for MR discussions)\n- Gates A and B must pass first\n\n## Edge Cases\n- MRs without discussions: should sync successfully, just with 0 discussions\n- Discussions without DiffNotes: regular comments have NULL position fields\n- Deleted discussions in GitLab: sweep pattern should remove them locally\n- Invalid note timestamps: should NOT advance watermark, should log warning","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-26T22:06:01.769694Z","created_by":"tayloreernisse","updated_at":"2026-01-27T00:48:21.060017Z","closed_at":"2026-01-27T00:48:21.059974Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1t4","depends_on_id":"bd-20h","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1ta","title":"[CP1] Integration tests for pagination","description":"Integration tests for GitLab pagination with wiremock.\n\n## Tests (tests/pagination_tests.rs)\n\n### Page Navigation\n- fetches_all_pages_when_multiple_exist\n- respects_per_page_parameter\n- follows_x_next_page_header_until_empty\n- falls_back_to_empty_page_stop_if_headers_missing\n\n### Cursor Behavior\n- applies_cursor_rewind_for_tuple_semantics\n- clamps_negative_rewind_to_zero\n\n## Test Setup\n- Use wiremock::MockServer\n- Set up handlers for /api/v4/projects/:id/issues\n- Return x-next-page headers\n- Verify request params (updated_after, per_page)\n\nFiles: tests/pagination_tests.rs\nDone when: All pagination tests pass with mocked server","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T16:59:07.806593Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:02.038945Z","closed_at":"2026-01-25T17:02:02.038945Z","deleted_at":"2026-01-25T17:02:02.038939Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-1tv8","title":"Epic: lore me — Personal Work Dashboard","description":"## Background\n`lore me` is the self-service command that answers: \"what needs my attention right now?\" for the configured GitLab user. It must work without local per-item state and without manual triage steps by deriving attention directly from synced GitLab data.\n\nThis epic exists to deliver a complete personal dashboard in both human and robot mode with deterministic behavior across section filtering, project scoping, sorting, and error handling.\n\nThe command is intentionally separate from `lore who`:\n- `lore who` = inspect other people or team-level activity.\n- `lore me` = high-signal personal dashboard with attention scoring.\n\n## Approach\nImplement the command in vertical slices, then wire them together in a single handler:\n\n1. Input and identity resolution:\n- Add optional `gitlab.username` config field.\n- Resolve username with strict precedence: `--user` > `config.gitlab.username` > usage error (exit 2).\n- Add `me` command args and section flags (`--issues`, `--mrs`, `--activity`) with combinable behavior.\n\n2. Scope and filtering:\n- Resolve project scope from `--project`, `--all`, and `config.default_project`.\n- Treat `--project` + `--all` as a usage error.\n- Parse `--since` (default `30d`) for activity feed cutoff only.\n\n3. Data model and queries:\n- Introduce typed dashboard structs (`MeDashboard`, per-section item structs, summary struct).\n- Implement open issues / authored MRs / reviewing MRs queries using current-association semantics.\n- Compute attention state from non-system notes with deterministic priority ordering.\n- Build activity feed from 5 sources (notes, state events, label events, milestone events, assignment system-note patterns) and merge via `UNION ALL` sorted by timestamp desc.\n\n4. Rendering:\n- Human mode: summary + legend + section renderers with existing theme/icons behavior and single-project path suppression.\n- Robot mode: `{ok,data,meta}` envelope with stable field names and `--fields minimal` support.\n\n5. Integration and errors:\n- Wire a single `handle_me(...)` flow in CLI dispatch.\n- Enforce required errors and exit codes (no username, no synced data, invalid flag combos, invalid since).\n- Keep empty-result dashboards non-error with explicit zero counts.\n\n## Acceptance Criteria\n- [ ] `lore me` command is available with flags: `--issues`, `--mrs`, `--activity`, `--since`, `--project`, `--all`, `--user`, `--fields`.\n- [ ] Username precedence is exact: CLI flag > config field > exit 2 usage error.\n- [ ] `--project` and `--all` together return usage error (exit 2).\n- [ ] Default scope uses `config.default_project` when present; otherwise all synced projects.\n- [ ] Work item sections include only open items and current association definitions:\n  - Issues assigned to me.\n  - MRs authored by me.\n  - MRs where I am a reviewer.\n- [ ] Activity feed includes the 5 required sources and remains restricted to currently associated items.\n- [ ] Attention state is computed from non-system notes with stable priority order and no local cursor/high-water state.\n- [ ] Work items are sorted by attention priority then updated time descending.\n- [ ] Activity is sorted newest-first and includes own-action marker (`is_own` / `(you)`).\n- [ ] Human output includes summary counts, attention legend, event badges, and single-project path suppression.\n- [ ] Robot output uses `{ok,data,meta}` and supports `--fields minimal` preset for me-specific lists.\n- [ ] Empty data renders valid zero-state dashboard (not an error).\n- [ ] No synced data returns exit 17 with actionable suggestion.\n- [ ] All me-related logic is covered by tests and passes project quality gates.\n\n## Files\nConfirmed existing integration points:\n- MODIFY: `src/main.rs` (CLI args + dispatch)\n- MODIFY: `src/cli/commands/mod.rs` (module exports)\n- MODIFY: `src/core/config.rs` (`gitlab.username`)\n- MODIFY: `src/core/error.rs` (usage-path mapping)\n- MODIFY: `src/cli/robot.rs` (me field preset + field filtering integration)\n\nExpected new command surface:\n- CREATE: `src/cli/commands/me/mod.rs`\n\nLikely supporting files (if split by concern instead of single-file module):\n- CREATE: `src/cli/commands/me/types.rs`\n- CREATE: `src/cli/commands/me/queries.rs`\n- CREATE: `src/cli/commands/me/render_human.rs`\n- CREATE: `src/cli/commands/me/render_robot.rs`\n\n## TDD Anchor\nRED:\n- Add tests for username resolution precedence and missing-username error.\n- Add tests for scope resolution (`--project`, `--all`, default project, conflict).\n- Add query tests for each section and attention-state computation edge ordering.\n- Add activity-feed tests validating all 5 sources and timestamp sort order.\n- Add renderer tests for zero-state, icon tier fallback, and project-path suppression.\n- Add robot tests for envelope shape and `--fields minimal` behavior.\n\nGREEN:\n- Implement minimal behavior to satisfy one failing test class at a time in this order:\n  1) identity/scope,\n  2) data structs,\n  3) section queries,\n  4) attention,\n  5) activity feed,\n  6) human renderer,\n  7) robot renderer,\n  8) handler wiring + errors.\n\nVERIFY:\n- `cargo check --all-targets`\n- `cargo clippy --all-targets -- -D warnings`\n- `cargo fmt --check`\n- `cargo test`\n\n## Edge Cases\n- User has synced projects but zero matching issues/MRs/activity: must render valid dashboard with zeros.\n- `--since` affects only activity feed; it must not filter issue/MR sections.\n- Stale vs not-started distinction: items with zero non-system notes are `not_started`, not `stale`.\n- MR `not_ready` applies only when `draft=1` and reviewer count is zero.\n- Activity should still include closed items when they are currently associated with the user.\n\n## Dependency Context\nThis epic is implemented through the following child beads; each one is required because it provides a concrete subsystem consumed by the handler:\n\n- `bd-qpk3`: adds `gitlab.username` config source consumed by username resolution.\n- `bd-1f1f`: username resolver and usage-error path consumed by command entrypoint.\n- `bd-utt4`: CLI argument struct + subcommand registration consumed by dispatch.\n- `bd-a7ba`: project scope resolver consumed by section query filters.\n- `bd-1vai`: `AttentionState` enum + ordering consumed by sorting and serialization.\n- `bd-3bwh`: dashboard data structs consumed by queries and renderers.\n- `bd-joja`: open-issues query feeding issues section.\n- `bd-1obt`: authored-MR query feeding authored section.\n- `bd-1fgr`: reviewing-MR query feeding reviewing section.\n- `bd-1xuf`: attention SQL enrichment feeding all work-item sections.\n- `bd-b3r3`: core activity-source queries (notes/state/label/milestone).\n- `bd-2nl3`: assignment/review-request extraction from system notes (5th activity source).\n- `bd-2tl5`: `UNION ALL` activity assembly consumed by dashboard activity section.\n- `bd-1vxq`: summary + attention legend renderer used at top of human output.\n- `bd-2fuw`: open-issues human renderer.\n- `bd-9cob`: authored/reviewing MRs human renderer.\n- `bd-e48d`: activity human renderer with event badges.\n- `bd-2ilv`: robot serializer/envelope for me dashboard.\n- `bd-3jiq`: me-specific `--fields minimal` projection behavior.\n- `bd-1vv8`: integration handler that orchestrates all previous slices.\n- `bd-32aw`: final error-path hardening and exit-code compliance.","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-02-19T19:34:50.419598Z","created_by":"tayloreernisse","updated_at":"2026-02-20T16:09:17.575066Z","closed_at":"2026-02-20T16:09:17.575014Z","close_reason":"All 21 child beads implemented by lore-me agent swarm. lore me command fully operational.","compaction_level":0,"original_size":0}
{"id":"bd-1u1","title":"Implement document regenerator","description":"## Background\nThe document regenerator drains the dirty_sources queue, regenerating documents for each entry. It uses per-item transactions for crash safety, a triple-hash fast path to skip unchanged documents entirely (no writes at all), and a bounded batch loop that drains completely. Error recording includes backoff computation.\n\n## Approach\nCreate `src/documents/regenerator.rs` per PRD Section 6.3.\n\n**Core function:**\n```rust\npub fn regenerate_dirty_documents(conn: &Connection) -> Result<RegenerateResult>\n```\n\n**RegenerateResult:** { regenerated, unchanged, errored }\n\n**Algorithm (per PRD):**\n1. Loop: get_dirty_sources(conn) -> Vec<(SourceType, i64)>\n2. If empty, break (queue fully drained)\n3. For each (source_type, source_id):\n   a. Begin transaction\n   b. Call regenerate_one_tx(&tx, source_type, source_id) -> Result<bool>\n   c. If Ok(changed): clear_dirty_tx, commit, count regenerated or unchanged\n   d. If Err: record_dirty_error_tx (with backoff), commit, count errored\n\n**regenerate_one_tx (per PRD):**\n1. Extract document via extract_{type}_document(conn, source_id)\n2. If None (deleted): delete_document, return Ok(true)\n3. If Some(doc): call get_existing_hash() to check current state\n4. **If ALL THREE hashes match: return Ok(false) — skip ALL writes** (fast path)\n5. Otherwise: upsert_document with conditional label/path relinking\n6. Return Ok(content changed)\n\n**Helper functions (PRD-exact):**\n\n`get_existing_hash` — uses `optional()` to distinguish missing rows from DB errors:\n```rust\nfn get_existing_hash(\n    conn: &Connection,\n    source_type: SourceType,\n    source_id: i64,\n) -> Result<Option<String>> {\n    use rusqlite::OptionalExtension;\n    let hash: Option<String> = stmt\n        .query_row(params, |row| row.get(0))\n        .optional()?;  // IMPORTANT: Not .ok() — .ok() would hide real DB errors\n    Ok(hash)\n}\n```\n\n`get_document_id` — resolve document ID after upsert:\n```rust\nfn get_document_id(conn: &Connection, source_type: SourceType, source_id: i64) -> Result<i64>\n```\n\n`upsert_document` — checks existing triple hash before writing:\n```rust\nfn upsert_document(conn: &Connection, doc: &DocumentData) -> Result<()> {\n    // 1. Query existing (id, content_hash, labels_hash, paths_hash) via OptionalExtension\n    // 2. Triple-hash fast path: all match -> return Ok(())\n    // 3. Upsert document row (ON CONFLICT DO UPDATE)\n    // 4. Get doc_id (from existing or query after insert)\n    // 5. Only delete+reinsert labels if labels_hash changed\n    // 6. Only delete+reinsert paths if paths_hash changed\n}\n```\n\n**Key PRD detail — triple-hash fast path:**\n```rust\nif old_content_hash == &doc.content_hash\n    && old_labels_hash == &doc.labels_hash\n    && old_paths_hash == &doc.paths_hash\n{ return Ok(()); }  // Skip ALL writes — prevents WAL churn\n```\n\n**Error recording with backoff:**\nrecord_dirty_error_tx reads current attempt_count from DB, computes next_attempt_at via shared backoff utility:\n```rust\nlet next_attempt_at = crate::core::backoff::compute_next_attempt_at(now, attempt_count + 1);\n```\n\n**All internal functions use _tx suffix** (take &Transaction) for atomicity.\n\n## Acceptance Criteria\n- [ ] Queue fully drained (bounded batch loop until empty)\n- [ ] Per-item transactions (crash loses at most 1 doc)\n- [ ] Triple-hash fast path: ALL THREE hashes match -> skip ALL writes (return Ok(false))\n- [ ] Content change: upsert document, update labels/paths\n- [ ] Labels-only change: relabels but skips path writes (paths_hash unchanged)\n- [ ] Deleted entity: delete document (cascade handles FTS/labels/paths/embeddings)\n- [ ] get_existing_hash uses `.optional()` (not `.ok()`) to preserve DB errors\n- [ ] get_document_id resolves document ID after upsert\n- [ ] Error recording: increment attempt_count, compute next_attempt_at via backoff\n- [ ] FTS triggers fire on insert/update/delete (verified by trigger, not regenerator)\n- [ ] RegenerateResult counts accurate (regenerated, unchanged, errored)\n- [ ] Errors do not abort batch (log, increment, continue)\n- [ ] `cargo test regenerator` passes\n\n## Files\n- `src/documents/regenerator.rs` — new file\n- `src/documents/mod.rs` — add `pub use regenerator::regenerate_dirty_documents;`\n\n## TDD Loop\nRED: Tests requiring DB:\n- `test_creates_new_document` — dirty source -> document created\n- `test_skips_unchanged_triple_hash` — all 3 hashes match -> unchanged count incremented, no DB writes\n- `test_updates_changed_content` — content_hash mismatch -> updated\n- `test_updates_changed_labels_only` — content same but labels_hash different -> updated\n- `test_updates_changed_paths_only` — content same but paths_hash different -> updated\n- `test_deletes_missing_source` — source deleted -> document deleted\n- `test_drains_queue` — queue empty after regeneration\n- `test_error_records_backoff` — error -> attempt_count incremented, next_attempt_at set\n- `test_get_existing_hash_not_found` — returns Ok(None) for missing document\nGREEN: Implement regenerate_dirty_documents + all helpers\nVERIFY: `cargo test regenerator`\n\n## Edge Cases\n- Empty queue: return immediately with all-zero counts\n- Extractor error for one item: record_dirty_error_tx, commit, continue\n- Triple-hash prevents WAL churn on incremental syncs (most entities unchanged)\n- Labels change but content does not: labels_hash mismatch triggers upsert with label relinking\n- get_existing_hash on missing document: returns Ok(None) via .optional() (not DB error)\n- get_existing_hash on corrupt DB: propagates real DB error (not masked by .ok())","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:25:55.178825Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:41:29.942386Z","closed_at":"2026-01-30T17:41:29.942324Z","close_reason":"Implemented document regenerator with triple-hash fast path, queue draining, fail-soft error handling + 5 tests","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1u1","depends_on_id":"bd-1yz","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1u1","depends_on_id":"bd-247","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1u1","depends_on_id":"bd-2fp","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1uc","title":"Implement DB upsert functions for resource events","description":"## Background\nNeed to store fetched resource events into the three event tables created by migration 011. The existing DB pattern uses rusqlite prepared statements with named parameters. Timestamps from GitLab are ISO 8601 strings that need conversion to ms epoch UTC (matching the existing time.rs parse_datetime_to_ms function).\n\n## Approach\nCreate src/core/events_db.rs (new module) with three upsert functions:\n\n```rust\nuse rusqlite::Connection;\nuse super::error::Result;\n\n/// Upsert state events for an entity.\n/// Uses INSERT OR REPLACE keyed on UNIQUE(gitlab_id, project_id).\npub fn upsert_state_events(\n    conn: &Connection,\n    project_id: i64,          // local DB project id\n    entity_type: &str,        // \"issue\" | \"merge_request\"\n    entity_local_id: i64,     // local DB id of the issue/MR\n    events: &[GitLabStateEvent],\n) -> Result<usize>\n\n/// Upsert label events for an entity.\npub fn upsert_label_events(\n    conn: &Connection,\n    project_id: i64,\n    entity_type: &str,\n    entity_local_id: i64,\n    events: &[GitLabLabelEvent],\n) -> Result<usize>\n\n/// Upsert milestone events for an entity.\npub fn upsert_milestone_events(\n    conn: &Connection,\n    project_id: i64,\n    entity_type: &str,\n    entity_local_id: i64,\n    events: &[GitLabMilestoneEvent],\n) -> Result<usize>\n```\n\nEach function:\n1. Prepares INSERT OR REPLACE statement\n2. For each event, maps GitLab types to DB columns:\n   - `actor_gitlab_id` = event.user.map(|u| u.id)\n   - `actor_username` = event.user.map(|u| u.username.clone())\n   - `created_at` = parse_datetime_to_ms(&event.created_at)?\n   - Set issue_id or merge_request_id based on entity_type\n3. Returns count of upserted rows\n4. Wraps in a savepoint for atomicity per entity\n\nRegister module in src/core/mod.rs:\n```rust\npub mod events_db;\n```\n\n## Acceptance Criteria\n- [ ] All three upsert functions compile and handle all event fields\n- [ ] Upserts are idempotent (re-inserting same event doesn't duplicate)\n- [ ] Timestamps converted to ms epoch UTC via parse_datetime_to_ms\n- [ ] actor_gitlab_id and actor_username populated from event.user (handles None)\n- [ ] entity_type correctly maps to issue_id/merge_request_id (other is NULL)\n- [ ] source_merge_request_id populated for state events (iid from source_merge_request)\n- [ ] source_commit populated for state events\n- [ ] label_name populated for label events\n- [ ] milestone_title and milestone_id populated for milestone events\n- [ ] Returns upserted count\n\n## Files\n- src/core/events_db.rs (new)\n- src/core/mod.rs (add `pub mod events_db;`)\n\n## TDD Loop\nRED: tests/events_db_tests.rs (new):\n- `test_upsert_state_events_basic` - insert 3 events, verify count and data\n- `test_upsert_state_events_idempotent` - insert same events twice, verify no duplicates\n- `test_upsert_label_events_with_actor` - verify actor fields populated\n- `test_upsert_milestone_events_null_user` - verify user: null doesn't crash\n- `test_upsert_state_events_entity_exclusivity` - verify only one of issue_id/merge_request_id set\n\nSetup: create_test_db() helper that applies migrations 001-011, inserts a test project + issue + MR.\n\nGREEN: Implement the three functions\n\nVERIFY: `cargo test events_db -- --nocapture`\n\n## Edge Cases\n- parse_datetime_to_ms must handle GitLab's format: \"2024-03-15T10:30:00.000Z\" and \"2024-03-15T10:30:00.000+00:00\"\n- INSERT OR REPLACE will fire CASCADE deletes if there are FK references to these rows — currently no other table references event rows, so this is safe\n- entity_type must be validated (\"issue\" or \"merge_request\") — panic or error on invalid\n- source_merge_request field contains an MR ref object, not an ID — extract .iid for DB column","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:31:57.242549Z","created_by":"tayloreernisse","updated_at":"2026-02-03T16:19:14.169437Z","closed_at":"2026-02-03T16:19:14.169233Z","close_reason":"Implemented upsert_state_events, upsert_label_events, upsert_milestone_events, count_events in src/core/events_db.rs. Uses savepoints for atomicity, LoreError::Database via ? operator for clean error handling.","compaction_level":0,"original_size":0,"labels":["db","gate-1","phase-b"],"dependencies":[{"issue_id":"bd-1uc","depends_on_id":"bd-2zl","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1uc","depends_on_id":"bd-hu3","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1ut","title":"[CP0] Final validation - tests, lint, typecheck","description":"## Background\n\nFinal validation ensures everything works together before marking CP0 complete. This is the integration gate - all unit tests, integration tests, lint, and type checking must pass. Manual smoke tests verify the full user experience.\n\nReference: docs/prd/checkpoint-0.md sections \"Definition of Done\", \"Manual Smoke Tests\"\n\n## Approach\n\n**Automated checks:**\n```bash\n# All tests pass\nnpm run test\n\n# TypeScript strict mode\nnpm run build  # or: npx tsc --noEmit\n\n# ESLint with no errors\nnpm run lint\n```\n\n**Manual smoke tests (from PRD table):**\n\n| Command | Expected | Pass Criteria |\n|---------|----------|---------------|\n| `gi --help` | Command list | Shows all commands |\n| `gi version` | Version number | Shows installed version |\n| `gi init` | Interactive prompts | Creates valid config |\n| `gi init` (config exists) | Confirmation prompt | Warns before overwriting |\n| `gi init --force` | No prompt | Overwrites without asking |\n| `gi auth-test` | `Authenticated as @username` | Shows GitLab username |\n| `GITLAB_TOKEN=invalid gi auth-test` | Error message | Non-zero exit, clear error |\n| `gi doctor` | Status table | All required checks pass |\n| `gi doctor --json` | JSON object | Valid JSON, `success: true` |\n| `gi backup` | Backup path | Creates timestamped backup |\n| `gi sync-status` | No runs message | Stub output works |\n\n**Definition of Done gate items:**\n- [ ] `gi init` writes config to XDG path and validates projects against GitLab\n- [ ] `gi auth-test` succeeds with real PAT\n- [ ] `gi doctor` reports DB ok + GitLab ok\n- [ ] DB migrations apply; WAL + FK enabled; busy_timeout + synchronous set\n- [ ] App lock mechanism works (concurrent runs blocked)\n- [ ] All unit tests pass\n- [ ] All integration tests pass (mocked)\n- [ ] ESLint passes with no errors\n- [ ] TypeScript compiles with strict mode\n\n## Acceptance Criteria\n\n- [ ] `npm run test` exits 0 (all tests pass)\n- [ ] `npm run build` exits 0 (TypeScript compiles)\n- [ ] `npm run lint` exits 0 (no ESLint errors)\n- [ ] All 11 manual smoke tests pass\n- [ ] All 9 Definition of Done gate items verified\n\n## Files\n\nNo new files created. This bead verifies existing work.\n\n## TDD Loop\n\nThis IS the final verification step:\n\n```bash\n# Automated\nnpm run test\nnpm run build\nnpm run lint\n\n# Manual (requires GITLAB_TOKEN set with valid token)\ngi --help\ngi version\ngi init  # go through setup\ngi auth-test\ngi doctor\ngi doctor --json | jq .success  # should output true\ngi backup\ngi sync-status\ngi reset --confirm\ngi init  # re-setup\n```\n\n## Edge Cases\n\n- Test coverage should be reasonable (aim for 80%+ on core modules)\n- Integration tests may flake on CI - check MSW setup\n- Manual tests require real GitLab token - document in README\n- ESLint may warn vs error - only errors block\n- TypeScript noImplicitAny catches missed types","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-24T16:09:52.078907Z","created_by":"tayloreernisse","updated_at":"2026-01-25T03:37:51.858558Z","closed_at":"2026-01-25T03:37:51.858474Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1ut","depends_on_id":"bd-1cb","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1ut","depends_on_id":"bd-1gu","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1ut","depends_on_id":"bd-1kh","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1ut","depends_on_id":"bd-38e","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1ut","depends_on_id":"bd-3kj","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1v8","title":"Update robot-docs manifest with Phase B commands","description":"## Background\n\nThe robot-docs manifest is the agent self-discovery mechanism. It must include all Phase B commands so agents can discover temporal intelligence features.\n\n## Codebase Context\n\n- handle_robot_docs() in src/main.rs (line ~1646) returns JSON with commands, exit_codes, workflows, aliases, clap_error_codes\n- Currently 18 commands documented in the manifest\n- VALID_COMMANDS array in src/main.rs (line ~448): [\"issues\", \"mrs\", \"search\", \"sync\", \"ingest\", \"count\", \"status\", \"auth\", \"doctor\", \"version\", \"init\", \"stats\", \"generate-docs\", \"embed\", \"migrate\", \"health\", \"robot-docs\", \"completions\"]\n- Phase B adds 3 new commands: timeline, file-history, trace\n- count gains new entity: \"references\" (bd-2ez)\n- Existing workflows: first_setup, daily_sync, search, pre_flight\n\n## Approach\n\n### 1. Add commands to handle_robot_docs() JSON:\n\n```json\n\"timeline\": {\n    \"description\": \"Chronological timeline of events matching a keyword query\",\n    \"flags\": [\"<QUERY>\", \"-p <project>\", \"--since <duration>\", \"--depth <n>\", \"--expand-mentions\", \"-n <limit>\"],\n    \"example\": \"lore --robot timeline 'authentication' --since 30d\"\n},\n\"file-history\": {\n    \"description\": \"Which MRs touched a file, with rename chain resolution\",\n    \"flags\": [\"<PATH>\", \"-p <project>\", \"--discussions\", \"--no-follow-renames\", \"--merged\", \"-n <limit>\"],\n    \"example\": \"lore --robot file-history src/auth/oauth.rs\"\n},\n\"trace\": {\n    \"description\": \"Trace file -> MR -> issue -> discussions decision chain\",\n    \"flags\": [\"<PATH>\", \"-p <project>\", \"--discussions\", \"--no-follow-renames\", \"-n <limit>\"],\n    \"example\": \"lore --robot trace src/auth/oauth.rs\"\n}\n```\n\n### 2. Update count command to mention \"references\" entity\n\n### 3. Add temporal_intelligence workflow:\n```json\n\"temporal_intelligence\": {\n    \"description\": \"Query temporal data about project history\",\n    \"steps\": [\n        \"lore sync (ensure events fetched with fetchResourceEvents=true)\",\n        \"lore timeline '<keyword>' for chronological event history\",\n        \"lore file-history <path> for file-level MR history\",\n        \"lore trace <path> for file -> MR -> issue -> discussion chain\"\n    ]\n}\n```\n\n### 4. Add timeline, file-history, trace to VALID_COMMANDS array\n\n## Acceptance Criteria\n\n- [ ] robot-docs includes timeline, file-history, trace commands\n- [ ] count references documented\n- [ ] temporal_intelligence workflow present\n- [ ] VALID_COMMANDS includes all 3 new commands\n- [ ] Examples are valid, runnable commands\n- [ ] cargo check --all-targets passes\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n\n- src/main.rs (update handle_robot_docs + VALID_COMMANDS array)\n\n## TDD Loop\n\nVERIFY: lore robot-docs | jq '.data.commands.timeline'\nVERIFY: lore robot-docs | jq '.data.workflows.temporal_intelligence'","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-02T22:43:07.859092Z","created_by":"tayloreernisse","updated_at":"2026-02-18T21:51:58.389215Z","closed_at":"2026-02-18T21:51:58.389154Z","close_reason":"Robot-docs already includes all 26 commands (timeline, file-history, trace, drift, cron, token, etc.)","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1v8","depends_on_id":"bd-1ht","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1v8","depends_on_id":"bd-2n4","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1v8t","title":"Add WorkItemStatus type and SyncConfig toggle","description":"## Background\nThe GraphQL status response returns name, category, color, and iconName fields. We need a Rust struct that deserializes this directly. Category is stored as raw Option<String> (not an enum) because GitLab 18.5+ supports custom statuses with arbitrary category values. We also need a config toggle so users can disable status enrichment.\n\n## Approach\nAdd WorkItemStatus to the existing types module. Add fetch_work_item_status to the existing SyncConfig with default_true() helper. Also add WorkItemStatus to pub use re-exports in src/gitlab/mod.rs.\n\n## Files\n- src/gitlab/types.rs (add struct after GitLabMergeRequest, before #[cfg(test)])\n- src/core/config.rs (add field to SyncConfig struct + Default impl)\n- src/gitlab/mod.rs (add WorkItemStatus to pub use)\n\n## Implementation\n\nIn src/gitlab/types.rs (needs Serialize, Deserialize derives already in scope):\n  #[derive(Debug, Clone, Serialize, Deserialize)]\n  pub struct WorkItemStatus {\n      pub name: String,\n      pub category: Option<String>,\n      pub color: Option<String>,\n      #[serde(rename = \"iconName\")]\n      pub icon_name: Option<String>,\n  }\n\nIn src/core/config.rs SyncConfig struct (after fetch_mr_file_changes):\n  #[serde(rename = \"fetchWorkItemStatus\", default = \"default_true\")]\n  pub fetch_work_item_status: bool,\n\nIn impl Default for SyncConfig (after fetch_mr_file_changes: true):\n  fetch_work_item_status: true,\n\n## Acceptance Criteria\n- [ ] WorkItemStatus deserializes: {\"name\":\"In progress\",\"category\":\"IN_PROGRESS\",\"color\":\"#1f75cb\",\"iconName\":\"status-in-progress\"}\n- [ ] Optional fields: {\"name\":\"To do\"} -> category/color/icon_name are None\n- [ ] Unknown category: {\"name\":\"Custom\",\"category\":\"SOME_FUTURE_VALUE\"} -> Ok\n- [ ] Null category: {\"name\":\"In progress\",\"category\":null} -> None\n- [ ] SyncConfig::default().fetch_work_item_status == true\n- [ ] JSON without fetchWorkItemStatus key -> defaults true\n- [ ] cargo check --all-targets passes\n\n## TDD Loop\nRED: test_work_item_status_deserialize, test_work_item_status_optional_fields, test_work_item_status_unknown_category, test_work_item_status_null_category, test_config_fetch_work_item_status_default_true, test_config_deserialize_without_key\nGREEN: Add struct + config field\nVERIFY: cargo test test_work_item_status && cargo test test_config\n\n## Edge Cases\n- serde rename \"iconName\" -> icon_name (camelCase in GraphQL)\n- Category is Option<String>, NOT an enum\n- Config key is camelCase \"fetchWorkItemStatus\" matching existing convention","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-11T06:41:42.790001Z","created_by":"tayloreernisse","updated_at":"2026-02-11T07:21:33.416990Z","closed_at":"2026-02-11T07:21:33.416950Z","close_reason":"Implemented by agent swarm — all quality gates pass (595 tests, 0 failures)","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1v8t","depends_on_id":"bd-2y79","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1v9m","title":"Implement AppState composition + LoadState + ScreenIntent","description":"## Background\nAppState is the top-level state composition — each field corresponds to one screen. State is preserved when navigating away (never cleared on pop). LoadState enables stale-while-revalidate: screens show last data during refresh with a spinner. ScreenIntent is the pure return type from state handlers — they never launch async tasks directly.\n\n## Approach\nCreate crates/lore-tui/src/state/mod.rs:\n- AppState struct: dashboard (DashboardState), issue_list (IssueListState), issue_detail (IssueDetailState), mr_list (MrListState), mr_detail (MrDetailState), search (SearchState), timeline (TimelineState), who (WhoState), sync (SyncState), command_palette (CommandPaletteState), global_scope (ScopeContext), load_state (ScreenLoadStateMap), error_toast (Option<String>), show_help (bool), terminal_size ((u16, u16))\n- LoadState enum: Idle, LoadingInitial, Refreshing, Error(String)\n- ScreenLoadStateMap: wraps HashMap<Screen, LoadState>, get()/set()/any_loading()\n- AppState methods: set_loading(), set_error(), clear_error(), has_text_focus(), blur_text_focus(), delegate_text_event(), interpret_screen_key(), handle_screen_msg()\n- ScreenIntent enum: None, Navigate(Screen), RequeryNeeded(Screen)\n- handle_screen_msg() matches Msg variants and returns ScreenIntent (NEVER Cmd::task)\n\nCreate stub per-screen state files (just Default-derivable structs):\n- state/dashboard.rs, issue_list.rs, issue_detail.rs, mr_list.rs, mr_detail.rs, search.rs, timeline.rs, who.rs, sync.rs, command_palette.rs\n\n## Acceptance Criteria\n- [ ] AppState derives Default and compiles with all screen state fields\n- [ ] LoadState has Idle, LoadingInitial, Refreshing, Error variants\n- [ ] ScreenLoadStateMap::get() returns Idle for untracked screens\n- [ ] ScreenLoadStateMap::any_loading() returns true when any screen is loading\n- [ ] has_text_focus() checks all filter/query focused flags\n- [ ] blur_text_focus() resets all focus flags\n- [ ] handle_screen_msg() returns ScreenIntent, never Cmd::task\n- [ ] ScreenIntent::RequeryNeeded signals that LoreApp should dispatch supervised query\n\n## Files\n- CREATE: crates/lore-tui/src/state/mod.rs\n- CREATE: crates/lore-tui/src/state/dashboard.rs (stub)\n- CREATE: crates/lore-tui/src/state/issue_list.rs (stub)\n- CREATE: crates/lore-tui/src/state/issue_detail.rs (stub)\n- CREATE: crates/lore-tui/src/state/mr_list.rs (stub)\n- CREATE: crates/lore-tui/src/state/mr_detail.rs (stub)\n- CREATE: crates/lore-tui/src/state/search.rs (stub)\n- CREATE: crates/lore-tui/src/state/timeline.rs (stub)\n- CREATE: crates/lore-tui/src/state/who.rs (stub)\n- CREATE: crates/lore-tui/src/state/sync.rs (stub)\n- CREATE: crates/lore-tui/src/state/command_palette.rs (stub)\n\n## TDD Anchor\nRED: Write test_load_state_default_idle that creates ScreenLoadStateMap, asserts get(&Screen::Dashboard) returns Idle.\nGREEN: Implement ScreenLoadStateMap with HashMap defaulting to Idle.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_load_state\n\n## Edge Cases\n- LoadState::set() removes Idle entries from the map to prevent unbounded growth\n- Screen::IssueDetail(key) comparison for HashMap: requires Screen to impl Hash+Eq or use ScreenKind discriminant\n- has_text_focus() must be kept in sync as new screens add text inputs","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T16:56:42.023482Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:22.230077Z","deleted_at":"2026-03-11T18:34:22.230070Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-1vai","title":"Define AttentionState enum with sort ordering","description":"## Background\nThe attention state model (AC-4) classifies each work item into one of 5 states based on comment activity. These states have a defined sort priority (AC-9.2). The enum is used across queries, rendering, and JSON serialization. NotReady is MR-only and has no attention icon (just draft indicator).\n\n## Approach\nCreate `src/cli/commands/me/types.rs`:\n```rust\nuse serde::Serialize;\n\n#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]\n#[serde(rename_all = \"snake_case\")]\npub enum AttentionState {\n    NeedsAttention,    // others commented after me (or I haven't engaged)\n    NotStarted,        // zero non-system notes from anyone\n    AwaitingResponse,  // my latest >= others' latest\n    Stale,             // had activity, but latest note > 30 days old\n    NotReady,          // MR-only: draft=1 AND no reviewers\n}\n\nimpl AttentionState {\n    /// Sort priority — lower = higher urgency (shows first in dashboard)\n    #[must_use]\n    pub const fn sort_priority(self) -> u8 {\n        match self {\n            Self::NeedsAttention => 0,\n            Self::NotStarted => 1,\n            Self::AwaitingResponse => 2,\n            Self::Stale => 3,\n            Self::NotReady => 4,\n        }\n    }\n}\n\nimpl Ord for AttentionState {\n    fn cmp(&self, other: &Self) -> std::cmp::Ordering {\n        self.sort_priority().cmp(&other.sort_priority())\n    }\n}\n\nimpl PartialOrd for AttentionState {\n    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {\n        Some(self.cmp(other))\n    }\n}\n\nimpl std::fmt::Display for AttentionState {\n    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n        match self {\n            Self::NeedsAttention => write!(f, \"needs_attention\"),\n            Self::NotStarted => write!(f, \"not_started\"),\n            Self::AwaitingResponse => write!(f, \"awaiting_response\"),\n            Self::Stale => write!(f, \"stale\"),\n            Self::NotReady => write!(f, \"not_ready\"),\n        }\n    }\n}\n\nimpl AttentionState {\n    pub fn from_sql_str(s: &str) -> Self {\n        match s {\n            \"needs_attention\" => Self::NeedsAttention,\n            \"not_started\" => Self::NotStarted,\n            \"awaiting_response\" => Self::AwaitingResponse,\n            \"stale\" => Self::Stale,\n            \"not_ready\" => Self::NotReady,\n            _ => Self::NotStarted,  // fallback\n        }\n    }\n}\n```\n\n## Acceptance Criteria\n- [ ] 5 variants: NeedsAttention, NotStarted, AwaitingResponse, Stale, NotReady\n- [ ] Serializes to snake_case: \"needs_attention\", \"not_started\", etc. (AC-7.3)\n- [ ] Implements Ord: NeedsAttention < NotStarted < AwaitingResponse < Stale < NotReady (AC-9.2)\n- [ ] sort_priority() is const fn returning u8\n- [ ] Display shows snake_case name\n- [ ] from_sql_str() parses SQL CASE output strings back to enum\n- [ ] Derives: Debug, Clone, Copy, PartialEq, Eq, Serialize\n\n## Files\n- CREATE: src/cli/commands/me/types.rs\n- MODIFY: src/cli/commands/me/mod.rs (add `pub mod types;`)\n\n## TDD Anchor\nRED: Write `test_attention_state_sort_order`:\n```rust\nlet mut states = [AttentionState::NotReady, AttentionState::Stale,\n                  AttentionState::NeedsAttention, AttentionState::AwaitingResponse,\n                  AttentionState::NotStarted];\nstates.sort();\nassert_eq!(states[0], AttentionState::NeedsAttention);\nassert_eq!(states[4], AttentionState::NotReady);\n```\nGREEN: Implement the enum with Ord.\nVERIFY: `cargo test attention_state`\n\nAdditional tests:\n- test_attention_state_serializes_snake_case (serde_json::to_string → \"\\\"needs_attention\\\"\")\n- test_attention_state_from_sql_str (round-trip each variant)\n- test_attention_state_display\n\n## Edge Cases\n- Using array `states.sort()` (not `vec![]`) avoids clippy pedantic lint\n- `from_sql_str` has a fallback to NotStarted for unknown strings — safe default\n- NotReady is MR-only but the enum doesn't enforce this — enforcement at query level (bd-1xuf)\n\n## Dependency Context\nNo upstream dependencies beyond the me/ module existing (bd-utt4).\nConsumed by all query beads and rendering beads.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-19T19:36:07.010461Z","created_by":"tayloreernisse","updated_at":"2026-02-20T16:09:13.048789Z","closed_at":"2026-02-20T16:09:13.048750Z","close_reason":"Implemented by lore-me agent swarm","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1vai","depends_on_id":"bd-utt4","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1vti","title":"Write decay and scoring example-based tests (TDD)","description":"## Background\nAll implementation beads (bd-1soz through bd-11mg) include their own inline TDD tests. This bead is the integration verification: run the full test suite and confirm everything works together with no regressions.\n\n## Approach\nRun cargo test and verify:\n1. All NEW tests pass (31 tests across implementation beads)\n2. All EXISTING tests pass unchanged (existing who tests, config tests, etc.)\n3. No test interference (--test-threads=1 mode)\n4. All tests in who.rs test module compile and run cleanly\n\nTest count by bead:\n- bd-1soz: 2 (test_half_life_decay_math, test_score_monotonicity_by_age)\n- bd-2w1p: 3 (test_config_validation_rejects_zero_half_life, _absurd_half_life, _nan_multiplier)\n- bd-18dn: 2 (test_path_normalization_handles_dot_and_double_slash, _preserves_prefix_semantics)\n- bd-1hoq: 1 (test_expert_sql_returns_expected_signal_rows)\n- bd-1h3f: 2 (test_old_path_probe_exact_and_prefix, test_suffix_probe_uses_old_path_sources)\n- bd-13q8: 13 (decay integration + invariant tests)\n- bd-11mg: 8 (CLI flag tests: explain_score, as_of, excluded_usernames, etc.)\nTotal: 2+3+2+1+2+13+8 = 31 new tests\n\nThis is NOT a code-writing bead — it is a verification checkpoint.\n\n## Acceptance Criteria\n- [ ] cargo test -p lore passes (all tests green)\n- [ ] cargo test -p lore -- --test-threads=1 passes (no test interference)\n- [ ] No existing test assertions were changed (only callsite signatures updated in bd-13q8 and ScoringConfig literals in bd-1b50)\n- [ ] Total test count: existing + 31 new = all pass\n\n## TDD Loop\nN/A — this bead verifies, does not write code.\nVERIFY: cargo test -p lore\n\n## Files\nNone modified — read-only verification.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-09T17:00:29.453420Z","created_by":"tayloreernisse","updated_at":"2026-02-12T20:43:04.414775Z","closed_at":"2026-02-12T20:43:04.414735Z","close_reason":"Implemented by time-decay swarm: 3 agents, 12 tasks, 621 tests passing, all quality gates green","compaction_level":0,"original_size":0,"labels":["scoring","test"],"dependencies":[{"issue_id":"bd-1vti","depends_on_id":"bd-11mg","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1vti","depends_on_id":"bd-18dn","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1vti","depends_on_id":"bd-1b50","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1vti","depends_on_id":"bd-1h3f","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1vti","depends_on_id":"bd-1soz","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1vti","depends_on_id":"bd-2w1p","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1vti","depends_on_id":"bd-2yu5","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1vv8","title":"Implement me command handler: wire queries to renderers","description":"## Background\nThis is the integration bead for `lore me`: resolve inputs, fetch data, enrich attention, build dashboard DTO, and dispatch to human or robot rendering. It should mirror the command-handler style used by `handle_who`, but with me-specific section filtering and output shaping.\n\n## Approach\nImplement `handle_me(config_override, args, robot_mode)` in `src/cli/commands/me/mod.rs` with deterministic flow:\n\n1. **Bootstrap**\n- load config\n- resolve username (`bd-1f1f`)\n- open DB connection\n- validate synced data exists (`SELECT COUNT(*) FROM projects` > 0)\n\n2. **Scope + temporal inputs**\n- resolve `ProjectScope` (`bd-a7ba`)\n- parse `--since` via `parse_since(&args.since)` into `since_ms`\n- compute `since_iso` from `since_ms` using existing ms->ISO helper\n\n3. **Section selection**\n- no section flags => all sections\n- `--issues`, `--mrs`, `--activity` combinable\n- `--since` affects activity query only\n\n4. **Fetch + enrich**\n- fetch selected sections (`bd-joja`, `bd-1obt`, `bd-1fgr`, `bd-2tl5`)\n- run attention enrichment on work-item collections (`bd-1xuf`)\n- sort each work-item list by attention then `updated_at_iso` desc\n\n5. **Assemble dashboard DTO**\n- compute summary counts from full selected data\n- include `username`, `since_iso`, `summary`, and all four arrays (empty when not selected)\n\n6. **Render/emit**\n- robot: call me robot renderer with `elapsed_ms` and optional `args.fields`\n- human: render summary first, then selected sections in order: issues, authored MRs, reviewing MRs, activity\n\n## Acceptance Criteria\n- [ ] No section flags => full dashboard\n- [ ] `--issues` => only issues section\n- [ ] `--mrs` => both MR sections (authored + reviewing)\n- [ ] `--activity` => only activity section\n- [ ] Section flags combine without conflict (`--issues --mrs` excludes activity)\n- [ ] `--since` is parsed once and only used by activity query\n- [ ] Invalid `--since` returns usage error (exit 2 path)\n- [ ] Work-item sorting is attention-priority then recency within state\n- [ ] Summary `needs_attention_count` aggregates across issues + authored + reviewing lists\n- [ ] Empty result sets still produce valid dashboard with zero counts\n- [ ] Robot output path receives `args.fields` for `--fields minimal` behavior\n- [ ] Human output suppresses project path lines when scope is single-project\n\n## Files\n- MODIFY: `src/cli/commands/me/mod.rs`\n\n## TDD Anchor\nRED:\n- `test_handler_section_filter_matrix`\n- `test_handler_since_only_filters_activity`\n- `test_handler_passes_fields_to_robot_renderer`\n- `test_handler_empty_results_zero_summary`\n- `test_handler_sort_order_attention_then_updated`\n\nGREEN:\n- Implement handler flow end-to-end with minimal branching.\n\nVERIFY:\n- `cargo test handle_me`\n- `cargo test me_handler`\n\n## Edge Cases\n- If only one section is selected, non-selected arrays should still serialize as `[]` in robot output.\n- `since_iso` must reflect parsed cutoff timestamp, not current time.\n- `single_project` detection should come from resolved scope, not from item count.\n\n## Dependency Context\nThis bead consumes all me slice outputs and is blocked by:\n- `bd-3jiq`, `bd-e48d`, `bd-9cob`, `bd-2fuw`, `bd-2tl5`, `bd-1xuf`.\nIts output/error behavior is finalized by `bd-32aw`.\n\nDependencies:\n  -> bd-3jiq (blocks) - Implement --fields minimal preset for me robot mode\n  -> bd-e48d (blocks) - Render activity feed with event badges in human mode\n  -> bd-9cob (blocks) - Render MR sections (authored + reviewing) in human mode\n  -> bd-2fuw (blocks) - Render open issues section in human mode\n  -> bd-2tl5 (blocks) - Implement activity feed UNION ALL assembly\n  -> bd-1xuf (blocks) - Implement attention state SQL computation\n\nDependents:\n  <- bd-32aw (blocks) - Implement error handling paths for me command","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-19T19:40:42.375637Z","created_by":"tayloreernisse","updated_at":"2026-02-20T16:09:13.065642Z","closed_at":"2026-02-20T16:09:13.065606Z","close_reason":"Implemented by lore-me agent swarm","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1vv8","depends_on_id":"bd-1xuf","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1vv8","depends_on_id":"bd-2fuw","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1vv8","depends_on_id":"bd-2tl5","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1vv8","depends_on_id":"bd-3jiq","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1vv8","depends_on_id":"bd-9cob","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1vv8","depends_on_id":"bd-e48d","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1vxq","title":"Render summary header and attention legend","description":"## Background\nThe dashboard starts with a summary header and attention legend (AC-5.5, AC-6.1, AC-6.2). Attention icons go in the existing `Icons` struct in `src/cli/render.rs` (user decision). The `not_ready` state uses no attention icon — just the draft indicator.\n\n## Approach\n\n### 1. Add attention icon methods to Icons struct\nIn `src/cli/render.rs`, add to the `Icons` impl block (around line 91-231):\n```rust\npub fn attention_needs(&self) -> &'static str {\n    match self.mode {\n        GlyphMode::Nerd => \"\\u{f0f3}\",     // nf-fa-bell\n        GlyphMode::Unicode => \"\\u{25c6}\",   // ◆ (black diamond)\n        GlyphMode::Ascii => \"[!]\",\n    }\n}\npub fn attention_not_started(&self) -> &'static str {\n    match self.mode {\n        GlyphMode::Nerd => \"\\u{f005}\",     // nf-fa-star\n        GlyphMode::Unicode => \"\\u{2605}\",   // ★ (black star)\n        GlyphMode::Ascii => \"[*]\",\n    }\n}\npub fn attention_awaiting(&self) -> &'static str {\n    match self.mode {\n        GlyphMode::Nerd => \"\\u{f017}\",     // nf-fa-clock_o\n        GlyphMode::Unicode => \"\\u{25f7}\",   // ◷ (white circle with upper right quadrant)\n        GlyphMode::Ascii => \"[~]\",\n    }\n}\npub fn attention_stale(&self) -> &'static str {\n    match self.mode {\n        GlyphMode::Nerd => \"\\u{f54c}\",     // nf-mdi-skull\n        GlyphMode::Unicode => \"\\u{2620}\",   // ☠ (skull and crossbones)\n        GlyphMode::Ascii => \"[x]\",\n    }\n}\n```\n\n### 2. Summary header renderer\nCreate `src/cli/commands/me/render_human.rs`:\n```rust\nuse crate::cli::render::{section_divider, format_relative_time, Theme, Icons, LoreRenderer};\nuse super::types::{MeDashboard, AttentionState};\n\npub fn render_summary(dashboard: &MeDashboard, single_project: bool) -> String {\n    let mut out = String::new();\n    // Header\n    let title = if single_project {\n        format!(\"My Dashboard\")\n    } else {\n        format!(\"My Dashboard ({} projects)\", dashboard.summary.project_count)\n    };\n    out.push_str(&section_divider(&title));\n    // Counts line\n    out.push_str(&format!(\"\\n  {} issues · {} authored MRs · {} reviewing MRs\",\n        dashboard.summary.open_issues,\n        dashboard.summary.authored_mrs,\n        dashboard.summary.reviewing_mrs));\n    if dashboard.summary.needs_attention_count > 0 {\n        out.push_str(&format!(\" · {} need attention\",\n            Theme::warning().render(dashboard.summary.needs_attention_count)));\n    }\n    // Legend\n    let icons = LoreRenderer::icons();\n    out.push_str(\"\\n\\n  \");\n    out.push_str(&format!(\"{} Needs attention  \", Theme::warning().render(icons.attention_needs())));\n    out.push_str(&format!(\"{} Not started  \", Theme::info().render(icons.attention_not_started())));\n    out.push_str(&format!(\"{} Awaiting response  \", Theme::dim().render(icons.attention_awaiting())));\n    out.push_str(&format!(\"{} Stale\", Theme::dim().render(icons.attention_stale())));\n    out\n}\n```\n\n### 3. Attention icon helper\nShared function for rendering an attention icon by state:\n```rust\npub fn attention_icon(state: &AttentionState) -> String {\n    let icons = LoreRenderer::icons();\n    match state {\n        AttentionState::NeedsAttention => Theme::warning().render(icons.attention_needs()),\n        AttentionState::NotStarted => Theme::info().render(icons.attention_not_started()),\n        AttentionState::AwaitingResponse => Theme::dim().render(icons.attention_awaiting()),\n        AttentionState::Stale => Theme::dim().render(icons.attention_stale()),\n        AttentionState::NotReady => String::new(),  // no icon — draft indicator suffices\n    }\n}\n```\n\n## Acceptance Criteria\n- [ ] Four new methods on Icons: attention_needs, attention_not_started, attention_awaiting, attention_stale\n- [ ] Nerd Font icons: bell, star, clock, skull (AC-6.2)\n- [ ] Unicode fallback: diamond, star, clock, skull+crossbones (AC-6.2)\n- [ ] ASCII fallback: [!], [*], [~], [x] (AC-6.2)\n- [ ] Summary header shows project count, issue count, authored MRs, reviewing MRs (AC-5.5)\n- [ ] needs_attention count highlighted in amber/warning (AC-5.5)\n- [ ] Attention legend shows all 4 icons with labels and correct colors\n- [ ] NotReady renders no icon (empty string) — draft indicator only\n- [ ] Uses existing `section_divider()` for header\n- [ ] Uses existing `Theme::warning()`, `Theme::info()`, `Theme::dim()` for colors\n\n## Files\n- MODIFY: src/cli/render.rs (add 4 attention icon methods to Icons impl)\n- CREATE: src/cli/commands/me/render_human.rs (render_summary + attention_icon helper)\n- MODIFY: src/cli/commands/me/mod.rs (add `pub mod render_human;`)\n\n## TDD Anchor\nRED: Write `test_attention_icon_needs_attention_not_empty` that calls `attention_icon(&AttentionState::NeedsAttention)` and asserts result is non-empty.\nGREEN: Implement the icon methods and helper.\nVERIFY: `cargo test attention_icon`\n\nAdditional tests:\n- test_attention_icon_not_ready_is_empty\n- test_summary_contains_counts (create dashboard with known counts, verify output)\n\n## Edge Cases\n- Icons struct accessed via `LoreRenderer::icons()` — must be initialized first (in tests, call `LoreRenderer::init()`)\n- The render functions return String (not print directly) — enables testing\n- Pad attention icons to consistent width? Nerd icons are typically 1-2 chars, ASCII is 3 chars — may need padding for alignment\n\n## Dependency Context\nUses `MeDashboard`, `MeSummary`, `AttentionState` from bd-3bwh.\nModifies `Icons` struct in `src/cli/render.rs` (existing file).\nPattern: `section_divider()` at render.rs:600, `Theme` at render.rs:291.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-19T19:38:51.330729Z","created_by":"tayloreernisse","updated_at":"2026-02-20T16:09:13.058934Z","closed_at":"2026-02-20T16:09:13.058888Z","close_reason":"Implemented by lore-me agent swarm","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1vxq","depends_on_id":"bd-3bwh","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1wa2","title":"Design Actionable Insights panel (heuristic queries TBD)","description":"## Background\nThe PRD specifies an Actionable Insights panel on the Dashboard that surfaces heuristic signals: stale P1 issues, blocked MRs awaiting review, velocity spikes/dips, and assignee workload imbalance. This requires heuristic query functions that do NOT currently exist in the lore codebase.\n\nSince the TUI work is purely UI built on existing code, the Actionable Insights panel is deferred to a later phase when the heuristic queries are implemented. This bead tracks the design and eventual implementation.\n\n## Approach\nWhen ready to implement:\n1. Define InsightKind enum: StaleHighPriority, BlockedMR, VelocityAnomaly, WorkloadImbalance\n2. Define Insight struct: kind, severity (Info/Warning/Critical), title, description, entity_refs (Vec<EntityKey>)\n3. Implement heuristic query functions in lore core (NOT in TUI crate)\n4. Wire insights into DashboardState as Optional<Vec<Insight>>\n5. Render as a scrollable panel with severity-colored icons\n\n## Acceptance Criteria\n- [ ] InsightKind and Insight types defined\n- [ ] At least 2 heuristic queries implemented (stale P1, blocked MR)\n- [ ] Dashboard renders insights panel when data available\n- [ ] Insights panel is scrollable with j/k\n- [ ] Enter on insight navigates to related entity\n- [ ] Empty insights shows \"No insights\" or hides panel entirely\n\n## Status\nBLOCKED: Requires heuristic query functions that don't exist yet. This is NOT a TUI-only task — it requires backend query work first.\n\n## Files\n- CREATE: src/core/insights.rs (heuristic query functions — in main crate, not TUI)\n- MODIFY: crates/lore-tui/src/state/dashboard.rs (add insights field)\n- MODIFY: crates/lore-tui/src/view/dashboard.rs (add insights panel)\n\n## Edge Cases\n- Insights depend on data freshness: stale DB = stale insights. Show \"last updated\" timestamp.\n- Heuristic thresholds should be configurable (e.g., \"stale\" = P1 untouched for 7 days)\n- Large number of insights: cap at 20, show \"N more...\" link","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-12T18:08:15.172539Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:23.170545Z","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-1x6","title":"Implement lore sync CLI command","description":"## Background\nThe sync command is the unified orchestrator for the full pipeline: ingest -> generate-docs -> embed. It replaces the need to run three separate commands. It acquires a lock, runs each stage sequentially, and reports combined results. Individual stages can be skipped via flags (--no-embed, --no-docs). The command is designed for cron/scheduled execution. Individual commands (`lore generate-docs`, `lore embed`) still exist for manual recovery and debugging.\n\n## Approach\nCreate `src/cli/commands/sync.rs` per PRD Section 6.4.\n\n**IMPORTANT: run_sync is async** (embed_documents and search_hybrid are async).\n\n**Key types (PRD-exact):**\n```rust\n#[derive(Debug, Serialize)]\npub struct SyncResult {\n    pub issues_updated: usize,\n    pub mrs_updated: usize,\n    pub discussions_fetched: usize,\n    pub documents_regenerated: usize,\n    pub documents_embedded: usize,\n}\n\n#[derive(Debug, Default)]\npub struct SyncOptions {\n    pub full: bool,       // Reset cursors, fetch everything\n    pub force: bool,      // Override stale lock\n    pub no_embed: bool,   // Skip embedding step\n    pub no_docs: bool,    // Skip document regeneration\n}\n```\n\n**Core function (async, PRD-exact):**\n```rust\npub async fn run_sync(config: &Config, options: SyncOptions) -> Result<SyncResult>\n```\n\n**Pipeline (sequential steps per PRD):**\n1. Acquire app lock with heartbeat (via existing `src/core/lock.rs`)\n2. Ingest delta: fetch issues + MRs via cursor-based sync (calls existing ingestion orchestrator)\n   - Each upserted entity marked dirty via `mark_dirty_tx(&tx)` inside ingestion transaction\n3. Process `pending_discussion_fetches` queue (bounded)\n   - Discussion sweep uses CTE to capture stale IDs, then cascading deletes\n4. Regenerate documents from `dirty_sources` queue (unless --no-docs)\n5. Embed documents with changed content_hash (unless --no-embed; skipped gracefully if Ollama unavailable)\n6. Release lock, record sync_run\n\n**NOTE (PRD):** Rolling backfill window removed — the existing cursor + watermark design handles old issues with resumed activity. GitLab updates `updated_at` when new comments are added, so the cursor naturally picks up old issues that receive new activity.\n\n**CLI args (PRD-exact):**\n```rust\n#[derive(Args)]\npub struct SyncArgs {\n    /// Reset cursors, fetch everything\n    #[arg(long)]\n    full: bool,\n    /// Override stale lock\n    #[arg(long)]\n    force: bool,\n    /// Skip embedding step\n    #[arg(long)]\n    no_embed: bool,\n    /// Skip document regeneration\n    #[arg(long)]\n    no_docs: bool,\n}\n```\n\n**Human output:**\n```\nSync complete:\n  Issues updated:             42\n  MRs updated:                18\n  Discussions fetched:        56\n  Documents regenerated:      38\n  Documents embedded:         38\n  Elapsed: 2m 15s\n```\n\n**JSON output:**\n```json\n{\"ok\": true, \"data\": {...}, \"meta\": {\"elapsed_ms\": 135000}}\n```\n\n## Acceptance Criteria\n- [ ] Function is `async fn run_sync`\n- [ ] Takes `SyncOptions` struct (not separate params)\n- [ ] Returns `SyncResult` with flat fields (not nested sub-structs)\n- [ ] Full pipeline orchestrated: ingest -> discussion queue -> docs -> embed\n- [ ] --full resets cursors (passes through to ingest)\n- [ ] --force overrides stale sync lock\n- [ ] --no-embed skips embedding stage (Ollama not needed)\n- [ ] --no-docs skips document regeneration stage\n- [ ] Discussion queue processing bounded per run\n- [ ] Dirty sources marked inside ingestion transactions (via mark_dirty_tx)\n- [ ] Progress reporting: stage names + elapsed time\n- [ ] Lock acquired with heartbeat at start, released at end (even on error)\n- [ ] Embedding skipped gracefully if Ollama unavailable (warning, not error)\n- [ ] JSON summary in robot mode\n- [ ] Human-readable summary with elapsed time\n- [ ] `cargo build` succeeds\n\n## Files\n- `src/cli/commands/sync.rs` — new file\n- `src/cli/commands/mod.rs` — add `pub mod sync;`\n- `src/cli/mod.rs` — add SyncArgs, wire up sync subcommand\n- `src/main.rs` — add sync command handler (async dispatch)\n\n## TDD Loop\nRED: Integration test requiring full pipeline\nGREEN: Implement run_sync orchestration (async)\nVERIFY: `cargo build && cargo test sync`\n\n## Edge Cases\n- Ollama unavailable + --no-embed not set: sync should NOT fail — embed stage logs warning, returns 0 embedded\n- Lock already held: error unless --force (and lock is stale)\n- No dirty sources after ingest: regeneration stage returns 0 (not error)\n- --full with large dataset: keyset pagination prevents OFFSET degradation","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:27:09.577782Z","created_by":"tayloreernisse","updated_at":"2026-01-30T18:05:34.676100Z","closed_at":"2026-01-30T18:05:34.676035Z","close_reason":"Sync CLI: async run_sync orchestrator with 4-stage pipeline (ingest issues, ingest MRs, generate-docs, embed), SyncOptions/SyncResult, --full/--force/--no-embed/--no-docs flags, graceful Ollama degradation, human+JSON output, clean build, all tests pass","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1x6","depends_on_id":"bd-1i2","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1x6","depends_on_id":"bd-1je","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1x6","depends_on_id":"bd-2sx","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1x6","depends_on_id":"bd-38q","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1x6","depends_on_id":"bd-3qs","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1xuf","title":"Implement attention state SQL computation","description":"## Background\nThe attention state model (AC-4) is the core intelligence of the dashboard. It classifies each work item based on comment activity: who commented last, and when. The computation uses CTEs and a CASE expression with 5 branches, evaluated in priority order.\n\n## Approach\nCreate `src/cli/commands/me/attention.rs`:\n```rust\nuse rusqlite::Connection;\nuse crate::core::error::Result;\nuse super::types::{AttentionState, MeIssue, MeMrAuthored, MeMrReviewing};\n\npub fn enrich_attention_states(\n    conn: &Connection,\n    username: &str,\n    issues: &mut [MeIssue],\n    authored_mrs: &mut [MeMrAuthored],\n    reviewing_mrs: &mut [MeMrReviewing],\n) -> Result<()>\n```\n\nThis runs AFTER the raw items are fetched. For each work item type, execute a single SQL query that computes the attention state for all items at once, then update the `attention_state` field on each struct.\n\n### Schema context for the SQL\n\n**notes** (`src/core/db.rs` migration 002): `id, gitlab_id, discussion_id (FK→discussions), project_id, note_type, is_system (INTEGER 0/1), author_username, body, created_at (ms epoch), updated_at, ...`\n\n**discussions** (migration 002): `id, gitlab_discussion_id, project_id, issue_id (FK→issues, nullable), merge_request_id (nullable), noteable_type ('Issue'|'MergeRequest'), ...`\n- CHECK constraint: exactly one of `issue_id`/`merge_request_id` is non-NULL based on `noteable_type`\n\n**mr_reviewers** (migration 006): `merge_request_id (FK), username TEXT, PRIMARY KEY(merge_request_id, username)`\n\n**merge_requests** (migration 006): `draft INTEGER NOT NULL DEFAULT 0` (0/1 boolean)\n\n### Attention SQL pattern\n\nFor issues, the query computes per-issue attention state:\n```sql\nWITH my_latest AS (\n    SELECT d.issue_id,\n           MAX(n.created_at) AS ts\n    FROM notes n\n    JOIN discussions d ON n.discussion_id = d.id\n    WHERE n.is_system = 0\n      AND n.author_username = ?username\n      AND d.issue_id IS NOT NULL\n    GROUP BY d.issue_id\n),\nothers_latest AS (\n    SELECT d.issue_id,\n           MAX(n.created_at) AS ts\n    FROM notes n\n    JOIN discussions d ON n.discussion_id = d.id\n    WHERE n.is_system = 0\n      AND n.author_username != ?username\n      AND d.issue_id IS NOT NULL\n    GROUP BY d.issue_id\n),\nany_latest AS (\n    SELECT d.issue_id,\n           MAX(n.created_at) AS ts\n    FROM notes n\n    JOIN discussions d ON n.discussion_id = d.id\n    WHERE n.is_system = 0\n      AND d.issue_id IS NOT NULL\n    GROUP BY d.issue_id\n)\nSELECT i.id,\n    CASE\n        -- 2. needs_attention: others commented AND (I haven't OR others' latest > mine)\n        WHEN ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts) THEN 'needs_attention'\n        -- 3. stale: any notes exist AND latest > 30 days old\n        WHEN al.ts IS NOT NULL AND al.ts < (?now_ms - 30 * 86400 * 1000) THEN 'stale'\n        -- 4. awaiting_response: my latest >= others' latest\n        WHEN ml.ts IS NOT NULL AND (ol.ts IS NULL OR ml.ts >= ol.ts) THEN 'awaiting_response'\n        -- 5. not_started: zero non-system notes\n        ELSE 'not_started'\n    END AS attention\nFROM issues i\nLEFT JOIN my_latest ml ON ml.issue_id = i.id\nLEFT JOIN others_latest ol ON ol.issue_id = i.id\nLEFT JOIN any_latest al ON al.issue_id = i.id\nWHERE i.id IN (...)\n```\n\nFor MRs, prepend a `not_ready` check before the CASE:\n```sql\nWHEN mr.draft = 1 AND NOT EXISTS (\n    SELECT 1 FROM mr_reviewers mrr WHERE mrr.merge_request_id = mr.id\n) THEN 'not_ready'\n```\n\n### Implementation strategy\n\n1. Collect all internal DB `id` values from the fetched issue/MR vecs\n2. Run the CTE query with those IDs as parameters (use `rusqlite::params_from_iter` or IN-list)\n3. Build a `HashMap<i64, AttentionState>` from results\n4. Iterate over the mutable slices, setting `attention_state` from the map\n5. Default to `NotStarted` for any ID not found (shouldn't happen but safe fallback)\n\nUse `AttentionState::from_sql_str(s)` (from bd-1vai) to convert the SQL CASE string to the enum.\n\nNote: The fetched structs (`MeIssue`, `MeMrAuthored`, `MeMrReviewing`) will need an internal `id: i64` field (the DB primary key) for this lookup. This field should NOT serialize to JSON (use `#[serde(skip)]`). Ensure bd-3bwh includes this.\n\n## Acceptance Criteria\n- [ ] not_ready: MR with draft=1 AND zero mr_reviewers → NotReady (AC-4.4.1)\n- [ ] not_ready: MR with draft=1 BUT has reviewers → falls through to other states\n- [ ] needs_attention: others commented, I haven't → NeedsAttention (AC-4.4.2)\n- [ ] needs_attention: others' latest > my latest → NeedsAttention\n- [ ] stale: notes exist but latest is >30 days old → Stale (AC-4.4.3)\n- [ ] stale: zero notes → NOT stale (falls to not_started)\n- [ ] not_started: zero non-system notes from anyone → NotStarted (AC-4.4.4)\n- [ ] awaiting_response: my latest >= others' latest → AwaitingResponse (AC-4.4.5)\n- [ ] awaiting_response: only my notes, no others → AwaitingResponse\n- [ ] Applied to issues, authored MRs, and reviewing MRs (AC-4.5)\n- [ ] Only considers non-system notes (is_system = 0) (AC-4.2)\n- [ ] Uses AttentionState::from_sql_str() for enum conversion\n\n## Files\n- CREATE: src/cli/commands/me/attention.rs\n- MODIFY: src/cli/commands/me/mod.rs (add `pub mod attention;`)\n\n## TDD Anchor\nRED: Write `test_needs_attention_when_others_commented_after_me` using in-memory DB (`create_connection(Path::new(\":memory:\"))` + `run_migrations(&conn)`). Insert a project, issue, issue_assignee, discussion (noteable_type='Issue', issue_id set), two notes: mine at created_at=100000, theirs at created_at=200000. Call `enrich_attention_states`. Assert issue's attention_state = NeedsAttention.\n\nGREEN: Implement the SQL CTE query.\nVERIFY: `cargo test attention`\n\nAdditional tests:\n- test_needs_attention_others_commented_i_havent\n- test_awaiting_response_i_commented_last\n- test_awaiting_response_only_my_notes\n- test_not_started_zero_notes\n- test_stale_old_notes (set any_latest.ts to > 30 days ago using a fixed \"now\" param)\n- test_stale_not_applied_to_zero_notes\n- test_not_ready_draft_no_reviewers (insert MR with draft=1, no mr_reviewers rows)\n- test_not_ready_draft_with_reviewers_falls_through\n- test_system_notes_excluded (insert is_system=1 note, verify not counted)\n\n## Edge Cases\n- notes join through discussions: `notes.discussion_id → discussions.id`, then `discussions.issue_id / discussions.merge_request_id`\n- \"30 days\" stale threshold: use current time (ms) minus `30 * 86_400 * 1_000` ms. Pass `now_ms` as a parameter for testability (don't call `SystemTime::now()` inside SQL).\n- An item with ONLY system notes → zero non-system notes → `not_started`\n- NULL handling: LEFT JOINs mean `ml.ts`, `ol.ts`, `al.ts` can all be NULL — the CASE handles this with IS NOT NULL / IS NULL checks\n- The `id IN (...)` list may be empty (no issues fetched) — skip the query entirely for empty slices\n\n## Dependency Context\nUses `AttentionState` enum and its `from_sql_str()` from bd-1vai (same `types.rs` file).\nUses `MeIssue`, `MeMrAuthored`, `MeMrReviewing` structs from bd-3bwh — these need an internal `id: i64` field with `#[serde(skip)]`.\nRuns after the fetch queries from bd-joja (issues), bd-1obt (authored MRs), bd-1fgr (reviewing MRs).\nCalled by the handler in bd-1vv8.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-19T19:37:34.521556Z","created_by":"tayloreernisse","updated_at":"2026-02-20T16:09:13.054002Z","closed_at":"2026-02-20T16:09:13.053951Z","close_reason":"Implemented by lore-me agent swarm","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1xuf","depends_on_id":"bd-1fgr","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1xuf","depends_on_id":"bd-1obt","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1xuf","depends_on_id":"bd-joja","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1y7q","title":"Write invariant tests for ranking system","description":"## Background\nInvariant tests catch subtle ranking regressions that example-based tests miss. These test properties that must hold for ANY input, not specific values.\n\n## Approach\n\n### test_score_monotonicity_by_age:\nGenerate 50 random (age_ms, half_life_days) pairs using a simple LCG PRNG (deterministic seed for reproducibility). Assert decay(older) <= decay(newer) for all pairs where older > newer. Tests the pure half_life_decay() function only.\n\n### test_row_order_independence:\nInsert the same 5 signals in two orderings (forward and reverse). Run query_expert on both -> assert identical username ordering and identical scores (f64 bit-equal). Use a deterministic dataset with varied timestamps.\n\n### test_reviewer_split_is_exhaustive:\nSet up 3 reviewers on the same MR:\n1. Reviewer with substantive DiffNotes (>= 20 chars) -> must appear in participated ONLY\n2. Reviewer with no DiffNotes -> must appear in assigned-only ONLY\n3. Reviewer with trivial note (< 20 chars) -> must appear in assigned-only ONLY\nUse --explain-score to verify each reviewer's components: participated reviewer has reviewer_participated > 0 and reviewer_assigned == 0; others have reviewer_assigned > 0 and reviewer_participated == 0.\n\n### test_deterministic_accumulation_order:\nInsert signals for one user with 15 MRs at varied timestamps. Run query_expert 100 times in a loop. Assert all 100 runs produce the exact same f64 score (use == not approx, to verify bit-identical results from sorted accumulation).\n\n## Acceptance Criteria\n- [ ] All 4 tests pass\n- [ ] No flakiness across 10 consecutive cargo test runs\n- [ ] test_score_monotonicity covers at least 50 random pairs\n- [ ] test_deterministic_accumulation runs at least 100 iterations\n\n## Files\n- src/cli/commands/who.rs (test module)\n\n## Edge Cases\n- LCG PRNG for monotonicity test: use fixed seed, not rand crate (avoid dependency)\n- Bit-identical f64: use assert_eq!(a, b) not approx — the deterministic ordering guarantees this\n- Row order test: must insert in genuinely different orders, not just shuffled within same transaction","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-09T17:00:35.774542Z","created_by":"tayloreernisse","updated_at":"2026-02-09T17:17:18.920235Z","closed_at":"2026-02-09T17:17:18.920188Z","close_reason":"Tests distributed to implementation beads: monotonicity->bd-1soz, row_order+split+deterministic->bd-13q8","compaction_level":0,"original_size":0,"labels":["scoring","test"]}
{"id":"bd-1y8","title":"Implement chunk ID encoding module","description":"## Background\nsqlite-vec uses a single integer rowid for embeddings. To store multiple chunks per document, we encode (document_id, chunk_index) into a single rowid using a multiplier. This module is shared between the embedding pipeline (encode on write) and vector search (decode on read). The encoding scheme supports up to 1000 chunks per document.\n\n## Approach\nCreate `src/embedding/chunk_ids.rs`:\n\n```rust\n/// Multiplier for encoding (document_id, chunk_index) into a single rowid.\n/// Supports up to 1000 chunks per document (32M chars at 32k/chunk).\npub const CHUNK_ROWID_MULTIPLIER: i64 = 1000;\n\n/// Encode (document_id, chunk_index) into a sqlite-vec rowid.\n///\n/// rowid = document_id * CHUNK_ROWID_MULTIPLIER + chunk_index\npub fn encode_rowid(document_id: i64, chunk_index: i64) -> i64 {\n    document_id * CHUNK_ROWID_MULTIPLIER + chunk_index\n}\n\n/// Decode a sqlite-vec rowid back into (document_id, chunk_index).\npub fn decode_rowid(rowid: i64) -> (i64, i64) {\n    let document_id = rowid / CHUNK_ROWID_MULTIPLIER;\n    let chunk_index = rowid % CHUNK_ROWID_MULTIPLIER;\n    (document_id, chunk_index)\n}\n```\n\nAlso create the parent module `src/embedding/mod.rs`:\n```rust\npub mod chunk_ids;\n// Later beads add: pub mod ollama; pub mod pipeline;\n```\n\nUpdate `src/lib.rs`: add `pub mod embedding;`\n\n## Acceptance Criteria\n- [ ] `encode_rowid(42, 0)` == 42000\n- [ ] `encode_rowid(42, 5)` == 42005\n- [ ] `decode_rowid(42005)` == (42, 5)\n- [ ] Roundtrip: decode(encode(doc_id, chunk_idx)) == (doc_id, chunk_idx) for all valid inputs\n- [ ] CHUNK_ROWID_MULTIPLIER is 1000\n- [ ] `cargo test chunk_ids` passes\n\n## Files\n- `src/embedding/chunk_ids.rs` — new file\n- `src/embedding/mod.rs` — new file (module root)\n- `src/lib.rs` — add `pub mod embedding;`\n\n## TDD Loop\nRED: Tests in `#[cfg(test)] mod tests`:\n- `test_encode_single_chunk` — encode(1, 0) == 1000\n- `test_encode_multi_chunk` — encode(1, 5) == 1005\n- `test_decode_roundtrip` — property test over range of doc_ids and chunk_indices\n- `test_decode_zero_chunk` — decode(42000) == (42, 0)\n- `test_multiplier_value` — assert CHUNK_ROWID_MULTIPLIER == 1000\nGREEN: Implement encode_rowid, decode_rowid\nVERIFY: `cargo test chunk_ids`\n\n## Edge Cases\n- chunk_index >= 1000: not expected (documents that large would be pathological), but no runtime panic — just incorrect decode. The embedding pipeline caps chunks well below this.\n- document_id = 0: valid (encode returns chunk_index directly)","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-30T15:26:34.060769Z","created_by":"tayloreernisse","updated_at":"2026-01-30T16:51:59.048910Z","closed_at":"2026-01-30T16:51:59.048843Z","close_reason":"Completed: chunk_ids module with encode_rowid/decode_rowid, CHUNK_ROWID_MULTIPLIER=1000, 6 tests pass","compaction_level":0,"original_size":0}
{"id":"bd-1yky","title":"Phase 4c: Cancellation integration tests (asupersync-native)","description":"## What\nAdd asupersync-native integration tests for cancellation and region semantics. These test the PRIMARY MOTIVATION for this migration — structured cancellation.\n\n## Why\nWiremock tests validate protocol/serialization but cannot test cancellation. The deterministic lab runtime is one of the primary motivations for this migration.\n\n## Tests to Write\n\n### 1. Ctrl+C during fan-out\nSimulate cancellation mid-batch in orchestrator. Verify:\n- All in-flight tasks are drained\n- No task leaks\n- No obligation leaks\n\n### 2. Region quiescence\nAfter a region completes (normal or cancelled), verify no background tasks remain running.\n\n### 3. Transaction integrity under cancellation\nCancel during an ingestion batch that has fetched data but not yet written to DB. Verify:\n- No partial data is committed\n- DB is in a consistent state\n- Next sync picks up where it left off\n\n## Test Infrastructure\nUse asupersync deterministic lab runtime for reproducible cancellation timing.\n\n## Files Changed\n- tests/cancellation_tests.rs (NEW, ~150-200 LOC)\n\n## Testing\n- cargo test -- cancellation (all new tests must pass)\n\n## Depends On\n- Phase 3f-Step1 (region wrapping must be in place)","status":"closed","priority":1,"issue_type":"task","created_at":"2026-03-06T18:41:45.716082Z","created_by":"tayloreernisse","updated_at":"2026-03-06T21:11:12.659001Z","closed_at":"2026-03-06T21:11:12.658956Z","close_reason":"asupersync migration complete — 5 commits pushed to asupersync-migration branch","compaction_level":0,"original_size":0,"labels":["asupersync","phase-4","testing"],"dependencies":[{"issue_id":"bd-1yky","depends_on_id":"bd-1lj5","type":"parent-child","created_at":"2026-03-06T18:41:45.720121Z","created_by":"tayloreernisse"},{"issue_id":"bd-1yky","depends_on_id":"bd-zgke","type":"blocks","created_at":"2026-03-06T18:42:57.177821Z","created_by":"tayloreernisse"}]}
{"id":"bd-1yu","title":"[CP1] GitLab types for issues, discussions, notes","description":"Add TypeScript interfaces for GitLab API responses.\n\nTypes to add to src/gitlab/types.ts:\n- GitLabIssue: id, iid, project_id, title, description, state, timestamps, author, labels[], labels_details?, web_url\n- GitLabDiscussion: id (string), individual_note, notes[]\n- GitLabNote: id, type, body, author, timestamps, system, resolvable, resolved, resolved_by, resolved_at, position?\n\nFiles: src/gitlab/types.ts\nDone when: Types compile and match GitLab API documentation","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:19:00.558718Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.153996Z","closed_at":"2026-01-25T15:21:35.153996Z","deleted_at":"2026-01-25T15:21:35.153993Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-1yx","title":"Implement rename chain resolution for file-history","description":"## Background\n\nRename chain resolution is the core algorithm for Gate 4. When querying history of src/auth.rs, it finds MRs that touched the file when it was previously named src/authentication.rs. This is reused by Gate 5 (trace) as well.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 4.6 (Rename Handling).\n\n## Codebase Context\n\n- mr_file_changes table (migration 016, bd-1oo): merge_request_id, project_id, old_path, new_path, change_type\n- change_type='renamed' rows have both old_path and new_path populated\n- Partial index `idx_mfc_renamed` on (project_id, change_type) WHERE change_type='renamed' optimizes BFS queries\n- Also `idx_mfc_project_path` on (project_id, new_path) and `idx_mfc_project_old_path` partial index\n- No timeline/trace/file_history modules exist yet in src/core/\n\n## Approach\n\nCreate `src/core/file_history.rs`:\n\n```rust\nuse std::collections::HashSet;\nuse std::collections::VecDeque;\nuse rusqlite::Connection;\nuse crate::core::error::Result;\n\n/// Resolves a file path through its rename history.\n/// Returns all equivalent paths (original + renames) for use in queries.\n/// BFS in both directions: forward (old_path -> new_path) and backward (new_path -> old_path).\npub fn resolve_rename_chain(\n    conn: &Connection,\n    project_id: i64,\n    path: &str,\n    max_hops: usize,       // default 10 from CLI\n) -> Result<Vec<String>> {\n    let mut visited: HashSet<String> = HashSet::new();\n    let mut queue: VecDeque<String> = VecDeque::new();\n\n    visited.insert(path.to_string());\n    queue.push_back(path.to_string());\n\n    let forward_sql = \"SELECT mfc.new_path FROM mr_file_changes mfc \\\n        WHERE mfc.project_id = ?1 AND mfc.old_path = ?2 AND mfc.change_type = 'renamed'\";\n    let backward_sql = \"SELECT mfc.old_path FROM mr_file_changes mfc \\\n        WHERE mfc.project_id = ?1 AND mfc.new_path = ?2 AND mfc.change_type = 'renamed'\";\n\n    while let Some(current) = queue.pop_front() {\n        if visited.len() > max_hops + 1 { break; }\n\n        // Forward: current was the old name -> discover new names\n        let mut stmt = conn.prepare(forward_sql)?;\n        let forward: Vec<String> = stmt.query_map(\n            rusqlite::params\\![project_id, current],\n            |row| row.get(0),\n        )?.filter_map(|r| r.ok()).collect();\n\n        // Backward: current was the new name -> discover old names\n        let mut stmt = conn.prepare(backward_sql)?;\n        let backward: Vec<String> = stmt.query_map(\n            rusqlite::params\\![project_id, current],\n            |row| row.get(0),\n        )?.filter_map(|r| r.ok()).collect();\n\n        for discovered in forward.into_iter().chain(backward) {\n            if visited.insert(discovered.clone()) {\n                queue.push_back(discovered);\n            }\n        }\n    }\n\n    Ok(visited.into_iter().collect())\n}\n```\n\nRegister in `src/core/mod.rs`: add `pub mod file_history;`\n\n## Acceptance Criteria\n\n- [ ] `resolve_rename_chain()` follows renames in both directions (forward + backward)\n- [ ] Cycles detected via HashSet (same path never visited twice)\n- [ ] Bounded at max_hops (default 10)\n- [ ] No renames found: returns vec with just the original path\n- [ ] max_hops=0: returns just original path without querying DB\n- [ ] Module registered in src/core/mod.rs as `pub mod file_history;`\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/core/file_history.rs` (NEW)\n- `src/core/mod.rs` (add `pub mod file_history;`)\n\n## TDD Loop\n\nRED:\n- `test_rename_chain_no_renames` — returns just original path\n- `test_rename_chain_forward` — a.rs -> b.rs -> c.rs: starting from a.rs finds all three\n- `test_rename_chain_backward` — starting from c.rs finds a.rs and b.rs\n- `test_rename_chain_cycle_detection` — a->b->a terminates without infinite loop\n- `test_rename_chain_max_hops_zero` — returns just original path\n- `test_rename_chain_max_hops_bounded` — chain longer than max is truncated\n\nTests need in-memory DB with migrations applied through 016 + mr_file_changes test data with change_type='renamed'.\n\nGREEN: Implement BFS with visited set.\n\nVERIFY: `cargo test --lib -- file_history`\n\n## Edge Cases\n\n- File never renamed: single-element vec\n- Circular rename (a->b->a): visited set prevents infinite loop\n- max_hops=0: return just original path, no queries executed\n- Case sensitivity: paths are case-sensitive (Linux default, matches GitLab behavior)\n- Multiple renames from same old_path: BFS discovers all branches\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:34:08.985345Z","created_by":"tayloreernisse","updated_at":"2026-02-13T14:00:46.354253Z","closed_at":"2026-02-13T14:00:46.354201Z","close_reason":"Implemented resolve_rename_chain() BFS in src/core/file_history.rs with 8 tests covering: no renames, forward chain, backward chain, cycle detection, max_hops=0, max_hops bounded, branching renames, project isolation. All 765 tests pass, clippy+fmt clean.","compaction_level":0,"original_size":0,"labels":["gate-4","phase-b","query"],"dependencies":[{"issue_id":"bd-1yx","depends_on_id":"bd-14q","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1yx","depends_on_id":"bd-1oo","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1yz","title":"Implement MR document extraction","description":"## Background\nMR documents are similar to issue documents but include source/target branch information in the header. The extractor queries merge_requests and mr_labels tables. Like issue extraction, it produces a DocumentData struct for the regeneration pipeline.\n\n## Approach\nImplement `extract_mr_document()` in `src/documents/extractor.rs`:\n\n```rust\n/// Extract a searchable document from a merge request.\n/// Returns None if the MR has been deleted from the DB.\npub fn extract_mr_document(conn: &Connection, mr_id: i64) -> Result<Option<DocumentData>>\n```\n\n**SQL queries (from PRD Section 2.2):**\n```sql\n-- Main entity\nSELECT m.id, m.iid, m.title, m.description, m.state, m.author_username,\n       m.source_branch, m.target_branch,\n       m.created_at, m.updated_at, m.web_url,\n       p.path_with_namespace, p.id AS project_id\nFROM merge_requests m\nJOIN projects p ON p.id = m.project_id\nWHERE m.id = ?\n\n-- Labels\nSELECT l.name FROM mr_labels ml\nJOIN labels l ON l.id = ml.label_id\nWHERE ml.merge_request_id = ?\nORDER BY l.name\n```\n\n**Document format:**\n```\n[[MergeRequest]] !456: Implement JWT authentication\nProject: group/project-one\nURL: https://gitlab.example.com/group/project-one/-/merge_requests/456\nLabels: [\"feature\", \"auth\"]\nState: opened\nAuthor: @johndoe\nSource: feature/jwt-auth -> main\n\n--- Description ---\n\nThis MR implements JWT-based authentication...\n```\n\n**Key difference from issues:** The `Source:` line with `source_branch -> target_branch`.\n\n## Acceptance Criteria\n- [ ] Deleted MR returns Ok(None)\n- [ ] MR document has `[[MergeRequest]]` prefix with `!` before iid (not `#`)\n- [ ] Source line shows `source_branch -> target_branch`\n- [ ] Labels sorted alphabetically in JSON array\n- [ ] content_hash computed from full content_text\n- [ ] labels_hash computed from sorted labels\n- [ ] paths is empty (MR-level docs don't have DiffNote paths; those are on discussion docs)\n- [ ] `cargo test extract_mr` passes\n\n## Files\n- `src/documents/extractor.rs` — implement `extract_mr_document()`\n\n## TDD Loop\nRED: Tests in `#[cfg(test)] mod tests`:\n- `test_mr_document_format` — verify header matches PRD template with Source line\n- `test_mr_not_found` — returns Ok(None)\n- `test_mr_no_description` — header only\n- `test_mr_branch_info` — Source line correct\nGREEN: Implement extract_mr_document with SQL queries\nVERIFY: `cargo test extract_mr`\n\n## Edge Cases\n- MR with NULL description: skip \"--- Description ---\" section\n- MR with NULL source_branch or target_branch: omit Source line (shouldn't happen in practice)\n- Draft MRs: state field captures this, no special handling needed","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-30T15:25:45.521703Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:30:04.308781Z","closed_at":"2026-01-30T17:30:04.308598Z","close_reason":"Implemented extract_mr_document() with Source line, PRD format, and 5 tests","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1yz","depends_on_id":"bd-36p","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1yz","depends_on_id":"bd-hrs","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1zj6","title":"OBSERV: Enrich robot JSON meta with run_id and stages","description":"## Background\nRobot JSON currently has a flat meta.elapsed_ms. This enriches it with run_id and a stages array, making every lore --robot sync output a complete performance profile.\n\n## Approach\nThe robot JSON output is built in src/cli/commands/sync.rs. The current SyncResult (line 15-25) is serialized into the data field. The meta field is built alongside it.\n\n1. Find or create the SyncMeta struct (likely near SyncResult). Add fields:\n```rust\n#[derive(Debug, Serialize)]\nstruct SyncMeta {\n    run_id: String,\n    elapsed_ms: u64,\n    stages: Vec<StageTiming>,\n}\n```\n\n2. After run_sync() completes, extract timings from MetricsLayer:\n```rust\nlet stages = metrics_handle.extract_timings();\nlet meta = SyncMeta {\n    run_id: run_id.to_string(),\n    elapsed_ms: start.elapsed().as_millis() as u64,\n    stages,\n};\n```\n\n3. Build the JSON envelope:\n```rust\nlet output = serde_json::json!({\n    \"ok\": true,\n    \"data\": result,\n    \"meta\": meta,\n});\n```\n\nThe metrics_handle (Arc<MetricsLayer>) must be passed from main.rs to the command handler. This requires adding a parameter to handle_sync_cmd() and run_sync(), or using a global. Prefer parameter passing.\n\nSame pattern for standalone ingest: add stages to IngestMeta.\n\n## Acceptance Criteria\n- [ ] lore --robot sync output includes meta.run_id (string, 8 hex chars)\n- [ ] lore --robot sync output includes meta.stages (array of StageTiming)\n- [ ] meta.elapsed_ms still present (total wall clock time)\n- [ ] Each stage has name, elapsed_ms, items_processed at minimum\n- [ ] Top-level stages have sub_stages when applicable\n- [ ] lore --robot ingest also includes run_id and stages\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/cli/commands/sync.rs (add SyncMeta struct, wire extract_timings)\n- src/cli/commands/ingest.rs (same for standalone ingest)\n- src/main.rs (pass metrics_handle to command handlers)\n\n## TDD Loop\nRED: test_sync_meta_includes_stages (run robot-mode sync, parse JSON, assert meta.stages is array)\nGREEN: Add SyncMeta, extract timings, include in JSON output\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- Empty stages: if sync runs with --no-docs --no-embed, some stages won't exist. stages array is shorter, not padded.\n- extract_timings() called before root span closes: returns incomplete tree. Must call AFTER run_sync returns (span is dropped on function exit).\n- metrics_handle clone: MetricsLayer uses Arc internally, clone is cheap (reference count increment).","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T15:54:32.062410Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:31:11.073580Z","closed_at":"2026-02-04T17:31:11.073534Z","close_reason":"Wired MetricsLayer into subscriber stack (all 4 branches), added run_id to SyncResult, enriched SyncMeta with run_id + stages Vec<StageTiming>, updated print_sync_json to accept MetricsLayer and extract timings","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-1zj6","depends_on_id":"bd-34ek","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-1zj6","depends_on_id":"bd-3er","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-1zow","title":"Implement Search screen (state + action + view)","description":"## Background\nThe Search screen provides full-text and semantic search across all indexed documents. It supports 3 modes (lexical FTS5, hybrid FTS+vector, semantic vector-only), a split-pane layout with results on the left and preview on the right, and capability-aware mode selection based on available indexes.\n\n## Approach\nState (state/search.rs):\n- SearchState: query (String), query_input (TextInput), query_focused (bool), mode (SearchMode), results (Vec<SearchResult>), selected_index (usize), preview (Option<SearchPreview>), capabilities (SearchCapabilities), generation (u64)\n- SearchMode: Lexical, Hybrid, Semantic\n- SearchCapabilities: has_fts (bool), has_embeddings (bool), embedding_coverage_pct (f32)\n- SearchResult: doc_id, entity_type, entity_iid, project_path, title, snippet, score, mode_used\n- SearchPreview: full document text or entity detail\n\n**Capability detection** (on screen entry):\n- Probe documents_fts table: SELECT COUNT(*) FROM documents_fts_docsize (uses fast B-tree count, not FTS5 virtual table scan — see MEMORY.md perf audit)\n- Probe embeddings: SELECT COUNT(*) FROM embeddings / SELECT COUNT(*) FROM documents to compute coverage pct\n- If has_fts=false: disable Lexical and Hybrid modes, only Semantic available\n- If has_embeddings=false: disable Semantic and Hybrid modes, only Lexical available\n- If both false: show \"No search indexes found. Run lore generate-docs and lore embed first.\"\n\n**Score explanation (e key):**\n- Press e on a selected result to toggle a score breakdown panel\n- For Lexical: show FTS5 bm25 raw score\n- For Hybrid: show FTS score, vector score, and RRF combined score with weights\n- For Semantic: show cosine similarity score\n- Panel appears below the selected result row, Esc or e dismisses\n\n**Debounced input (200ms):**\n- Uses Msg::SearchDebounceArmed and Msg::SearchDebounced timer pattern\n- On keystroke in query input: arm debounce timer via Cmd::timer(200ms, Msg::SearchDebounced)\n- On SearchDebounced: execute search with current query text\n- This prevents flooding the search backend on rapid typing\n\nAction (action.rs):\n- fetch_search_capabilities(conn) -> SearchCapabilities: probe FTS and embedding tables\n- execute_search(conn, query, mode, limit) -> Vec<SearchResult>: dispatches to correct search backend. Uses existing crate::search module functions.\n- fetch_search_preview(conn, result) -> SearchPreview: loads full entity detail for selected result\n\nView (view/search.rs):\n- Split pane: results list (60%) | preview (40%)\n- Query bar at top with mode indicator (L/H/S)\n- Mode switching: Tab cycles modes (only available modes based on capabilities)\n- Score column shows numeric score; e key expands explanation\n- Empty query shows recent entities instead of empty state\n- Narrow terminal (<100 cols): hide preview pane\n\n## Acceptance Criteria\n- [ ] 3 search modes: Lexical, Hybrid, Semantic\n- [ ] Mode switching via Tab, only available modes selectable based on capability detection\n- [ ] Capability detection probes FTS and embedding tables on screen entry\n- [ ] Graceful degradation: unavailable modes shown as greyed out with reason\n- [ ] \"No search indexes\" message when both FTS and embeddings are empty\n- [ ] 200ms debounce on search input (timer-driven via Msg::SearchDebounceArmed/Fired)\n- [ ] Split pane: results | preview\n- [ ] Enter on result navigates to entity detail\n- [ ] Score shown next to each result\n- [ ] e key toggles score explanation panel for selected result\n- [ ] Empty query shows recent entities instead of empty state\n- [ ] Narrow terminal (<100 cols): hide preview pane\n\n## Files\n- MODIFY: crates/lore-tui/src/state/search.rs (expand from stub)\n- MODIFY: crates/lore-tui/src/action.rs (add search functions)\n- CREATE: crates/lore-tui/src/view/search.rs\n\n## TDD Anchor\nRED: Write test_search_capability_detection that creates DB with FTS but no embeddings, asserts has_fts=true, has_embeddings=false, Semantic mode disabled.\nGREEN: Implement fetch_search_capabilities.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_search_capability\n\nAdditional tests:\n- test_debounce_prevents_rapid_search: simulate 5 keystrokes in 100ms, assert only 1 search executed\n- test_score_explanation_lexical: verify bm25 score shown for Lexical mode result\n- test_empty_query_shows_recent: assert recent entities returned when query is empty\n\n## Edge Cases\n- Search query < 2 chars: don't execute search (debounce filter)\n- FTS5 special characters (*, \", -): escape or pass through based on mode\n- Hybrid mode: uses existing RRF implementation from crate::search module\n- Very large result sets: limit to 100 results, show \"more results available\" hint\n- Preview pane on narrow terminal (<100 cols): hide preview, full-width results only\n- FTS count performance: use documents_fts_docsize shadow table for COUNT (19x faster)\n\n## Dependency Context\nUses existing search infrastructure from lore core (crate::search::{FtsQueryMode, to_fts_query} — note private submodules, import via crate::search).\nUses SearchDebounceArmed/SearchDebounced Msg variants from \"Implement core types\" (bd-c9gk).\nUses TaskSupervisor debounce management from \"Implement TaskSupervisor\" (bd-3le2).\nUses AppState composition from \"Implement AppState composition\" (bd-1v9m).","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T17:00:48.862621Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:22.306964Z","deleted_at":"2026-03-11T18:34:22.306960Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-1zwv","title":"Display assignees, due_date, and milestone in lore issues <id> output","description":"## Background\nThe `lore issues <id>` command displays issue details but omits key metadata that exists in the database: assignees, due dates, and milestones. Users need this information to understand issue context without opening GitLab.\n\n**System fit**: This data is already ingested during issue sync (migration 005) but the show command never queries it.\n\n## Approach\n\nAll changes in `src/cli/commands/show.rs`:\n\n### 1. Update IssueRow struct (line ~119)\nAdd fields to internal row struct:\n```rust\nstruct IssueRow {\n    // ... existing 10 fields ...\n    due_date: Option<String>,        // NEW\n    milestone_title: Option<String>, // NEW\n}\n```\n\n### 2. Update find_issue() SQL (line ~137)\nExtend SELECT:\n```sql\nSELECT i.id, i.iid, i.title, i.description, i.state, i.author_username,\n       i.created_at, i.updated_at, i.web_url, p.path_with_namespace,\n       i.due_date, i.milestone_title  -- ADD THESE\nFROM issues i ...\n```\n\nUpdate row mapping to extract columns 10 and 11.\n\n### 3. Add get_issue_assignees() (after get_issue_labels ~line 189)\n```rust\nfn get_issue_assignees(conn: &Connection, issue_id: i64) -> Result<Vec<String>> {\n    let mut stmt = conn.prepare(\n        \"SELECT username FROM issue_assignees WHERE issue_id = ? ORDER BY username\"\n    )?;\n    let assignees = stmt\n        .query_map([issue_id], |row| row.get(0))?\n        .collect::<std::result::Result<Vec<_>, _>>()?;\n    Ok(assignees)\n}\n```\n\n### 4. Update IssueDetail struct (line ~59)\n```rust\npub struct IssueDetail {\n    // ... existing 12 fields ...\n    pub assignees: Vec<String>,      // NEW\n    pub due_date: Option<String>,    // NEW\n    pub milestone: Option<String>,   // NEW\n}\n```\n\n### 5. Update IssueDetailJson struct (line ~770)\nAdd same 3 fields with identical types.\n\n### 6. Update run_show_issue() (line ~89)\n```rust\nlet assignees = get_issue_assignees(&conn, issue.id)?;\n// In return struct:\nassignees,\ndue_date: issue.due_date,\nmilestone: issue.milestone_title,\n```\n\n### 7. Update print_show_issue() (line ~533, after Author line ~548)\n```rust\nif !issue.assignees.is_empty() {\n    println!(\"Assignee{}: {}\",\n        if issue.assignees.len() > 1 { \"s\" } else { \"\" },\n        issue.assignees.iter().map(|a| format!(\"@{}\", a)).collect::<Vec<_>>().join(\", \"));\n}\nif let Some(due) = &issue.due_date {\n    println!(\"Due:       {}\", due);\n}\nif let Some(ms) = &issue.milestone {\n    println!(\"Milestone: {}\", ms);\n}\n```\n\n### 8. Update From<&IssueDetail> for IssueDetailJson (line ~799)\n```rust\nassignees: issue.assignees.clone(),\ndue_date: issue.due_date.clone(),\nmilestone: issue.milestone.clone(),\n```\n\n## Acceptance Criteria\n- [ ] `cargo test test_get_issue_assignees` passes (3 tests)\n- [ ] `lore issues <id>` shows Assignees line when assignees exist\n- [ ] `lore issues <id>` shows Due line when due_date set\n- [ ] `lore issues <id>` shows Milestone line when milestone set\n- [ ] `lore -J issues <id>` includes assignees/due_date/milestone in JSON\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n- `src/cli/commands/show.rs` - ALL changes\n\n## TDD Loop\n\n**RED** - Add tests to `src/cli/commands/show.rs` `#[cfg(test)] mod tests`:\n\n```rust\nuse crate::core::db::{create_connection, run_migrations};\nuse std::path::Path;\n\nfn setup_test_db() -> Connection {\n    let conn = create_connection(Path::new(\":memory:\")).unwrap();\n    run_migrations(&conn).unwrap();\n    conn\n}\n\n#[test]\nfn test_get_issue_assignees_empty() {\n    let conn = setup_test_db();\n    // seed project + issue with no assignees\n    let result = get_issue_assignees(&conn, 1).unwrap();\n    assert!(result.is_empty());\n}\n\n#[test]\nfn test_get_issue_assignees_multiple_sorted() {\n    let conn = setup_test_db();\n    // seed with alice, bob\n    let result = get_issue_assignees(&conn, 1).unwrap();\n    assert_eq!(result, vec![\"alice\", \"bob\"]); // alphabetical\n}\n\n#[test]\nfn test_get_issue_assignees_single() {\n    let conn = setup_test_db();\n    // seed with charlie only\n    let result = get_issue_assignees(&conn, 1).unwrap();\n    assert_eq!(result, vec![\"charlie\"]);\n}\n```\n\n**GREEN** - Implement get_issue_assignees() and struct updates\n\n**VERIFY**: `cargo test test_get_issue_assignees && cargo clippy --all-targets -- -D warnings`\n\n## Edge Cases\n- Empty assignees list -> don't print Assignees line\n- NULL due_date -> don't print Due line  \n- NULL milestone_title -> don't print Milestone line\n- Single vs multiple assignees -> \"Assignee\" vs \"Assignees\" grammar","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-02-05T15:16:00.105830Z","created_by":"tayloreernisse","updated_at":"2026-02-05T15:26:08.147202Z","closed_at":"2026-02-05T15:26:08.147154Z","close_reason":"Implemented: assignees, due_date, milestone now display in lore issues <id>. All 7 new tests pass.","compaction_level":0,"original_size":0,"labels":["ISSUE"]}
{"id":"bd-208","title":"[CP1] Issue ingestion module","description":"## Background\n\nThe issue ingestion module fetches and stores issues with cursor-based incremental sync. It is the primary data ingestion component, establishing the pattern reused for MR ingestion in CP2. The module handles tuple-cursor semantics, raw payload storage, label extraction, and tracking which issues need discussion sync.\n\n## Approach\n\n### Module: src/ingestion/issues.rs\n\n### Key Structs\n\n```rust\n#[derive(Debug, Default)]\npub struct IngestIssuesResult {\n    pub fetched: usize,\n    pub upserted: usize,\n    pub labels_created: usize,\n    pub issues_needing_discussion_sync: Vec<IssueForDiscussionSync>,\n}\n\n#[derive(Debug, Clone)]\npub struct IssueForDiscussionSync {\n    pub local_issue_id: i64,\n    pub iid: i64,\n    pub updated_at: i64,  // ms epoch\n}\n```\n\n### Main Function\n\n```rust\npub async fn ingest_issues(\n    conn: &Connection,\n    client: &GitLabClient,\n    config: &Config,\n    project_id: i64,           // Local DB project ID\n    gitlab_project_id: i64,\n) -> Result<IngestIssuesResult>\n```\n\n### Logic (Step by Step)\n\n1. **Get current cursor** from sync_cursors table:\n```sql\nSELECT updated_at_cursor, tie_breaker_id\nFROM sync_cursors\nWHERE project_id = ? AND resource_type = 'issues'\n```\n\n2. **Call pagination method** with cursor rewind:\n```rust\nlet issues_stream = client.paginate_issues(\n    gitlab_project_id,\n    cursor.updated_at_cursor,\n    config.sync.cursor_rewind_seconds,\n);\n```\n\n3. **Apply local filtering** for tuple cursor semantics:\n```rust\n// Skip if issue.updated_at < cursor_updated_at\n// Skip if issue.updated_at == cursor_updated_at AND issue.gitlab_id <= cursor_gitlab_id\nfn passes_cursor_filter(issue: &GitLabIssue, cursor: &SyncCursor) -> bool {\n    if issue.updated_at < cursor.updated_at_cursor {\n        return false;\n    }\n    if issue.updated_at == cursor.updated_at_cursor \n        && issue.gitlab_id <= cursor.tie_breaker_id {\n        return false;\n    }\n    true\n}\n```\n\n4. **For each issue passing filter**:\n```rust\n// Begin transaction (unchecked_transaction for rusqlite)\nlet tx = conn.unchecked_transaction()?;\n\n// Store raw payload (compressed based on config)\nlet payload_id = store_raw_payload(&tx, &issue_json, config.storage.compress_raw_payloads)?;\n\n// Transform and upsert issue\nlet issue_row = transform_issue(&issue)?;\nupsert_issue(&tx, &issue_row, project_id, payload_id)?;\nlet local_issue_id = get_local_issue_id(&tx, project_id, issue.iid)?;\n\n// Clear existing label links (stale removal!)\ntx.execute(\"DELETE FROM issue_labels WHERE issue_id = ?\", [local_issue_id])?;\n\n// Extract and upsert labels\nfor label_name in &issue_row.label_names {\n    let label_id = upsert_label(&tx, project_id, label_name)?;\n    link_issue_label(&tx, local_issue_id, label_id)?;\n}\n\ntx.commit()?;\n```\n\n5. **Incremental cursor update** every 100 issues:\n```rust\nif batch_count % 100 == 0 {\n    update_sync_cursor(conn, project_id, \"issues\", last_updated_at, last_gitlab_id)?;\n}\n```\n\n6. **Final cursor update** after all issues processed\n\n7. **Determine issues needing discussion sync**:\n```sql\nSELECT id, iid, updated_at\nFROM issues\nWHERE project_id = ?\n  AND updated_at > COALESCE(discussions_synced_for_updated_at, 0)\n```\n\n### Helper Functions\n\n```rust\nfn store_raw_payload(conn, json: &Value, compress: bool) -> Result<i64>\nfn upsert_issue(conn, issue: &IssueRow, project_id: i64, payload_id: i64) -> Result<()>\nfn get_local_issue_id(conn, project_id: i64, iid: i64) -> Result<i64>\nfn upsert_label(conn, project_id: i64, name: &str) -> Result<i64>\nfn link_issue_label(conn, issue_id: i64, label_id: i64) -> Result<()>\nfn update_sync_cursor(conn, project_id: i64, resource: &str, updated_at: i64, gitlab_id: i64) -> Result<()>\n```\n\n### Critical Invariant\n\nStale label links MUST be removed on resync. The \"DELETE then INSERT\" pattern ensures GitLab reality is reflected locally. If an issue had labels [A, B] and now has [A, C], the B link must be removed.\n\n## Acceptance Criteria\n\n- [ ] `ingest_issues` returns IngestIssuesResult with all counts\n- [ ] Cursor fetched from sync_cursors at start\n- [ ] Cursor rewind applied before API call\n- [ ] Local filtering skips already-processed issues\n- [ ] Each issue wrapped in transaction for atomicity\n- [ ] Raw payload stored with correct compression\n- [ ] Issue upserted (INSERT OR REPLACE pattern)\n- [ ] Existing label links deleted before new links inserted\n- [ ] Labels upserted (INSERT OR IGNORE by project+name)\n- [ ] Cursor updated every 100 issues (crash recovery)\n- [ ] Final cursor update after all issues\n- [ ] issues_needing_discussion_sync populated correctly\n\n## Files\n\n- src/ingestion/mod.rs (add `pub mod issues;`)\n- src/ingestion/issues.rs (create)\n\n## TDD Loop\n\nRED:\n```rust\n// tests/issue_ingestion_tests.rs\n#[tokio::test] async fn ingests_issues_from_stream()\n#[tokio::test] async fn applies_cursor_filter_correctly()\n#[tokio::test] async fn updates_cursor_every_100_issues()\n#[tokio::test] async fn stores_raw_payload_for_each_issue()\n#[tokio::test] async fn upserts_issues_correctly()\n\n// tests/label_linkage_tests.rs\n#[tokio::test] async fn extracts_and_stores_labels()\n#[tokio::test] async fn removes_stale_label_links_on_resync()\n#[tokio::test] async fn handles_empty_labels_array()\n\n// tests/discussion_eligibility_tests.rs\n#[tokio::test] async fn identifies_issues_needing_discussion_sync()\n#[tokio::test] async fn skips_issues_with_current_watermark()\n```\n\nGREEN: Implement ingest_issues with all helper functions\n\nVERIFY: `cargo test issue_ingestion && cargo test label_linkage && cargo test discussion_eligibility`\n\n## Edge Cases\n\n- Empty issues stream - return result with all zeros\n- Cursor at epoch 0 - fetch all issues (no filtering)\n- Issue with no labels - empty Vec, no label links created\n- Issue with 50+ labels - all should be linked\n- Crash mid-batch - cursor at last 100-boundary, some issues re-fetched\n- Label already exists - upsert via INSERT OR IGNORE\n- Same issue fetched twice (due to rewind) - upsert handles it","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-25T17:02:38.245404Z","created_by":"tayloreernisse","updated_at":"2026-01-25T22:52:38.003964Z","closed_at":"2026-01-25T22:52:38.003868Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-208","depends_on_id":"bd-2iq","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-208","depends_on_id":"bd-3nd","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-208","depends_on_id":"bd-xhz","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-20e","title":"Define TimelineEvent model and TimelineEventType enum","description":"## Background\n\nThe TimelineEvent model is the foundational data type for Gate 3's timeline feature. All pipeline stages (seed, expand, collect, interleave) produce or consume TimelineEvents. This must be defined first because every downstream bead (bd-32q, bd-ypa, bd-3as, bd-dty, bd-2f2) depends on these types.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.3 (Event Model).\n\n## Codebase Context\n\n- Migration 011 created: resource_state_events, resource_label_events, resource_milestone_events, entity_references, pending_dependent_fetches\n- source_method CHECK constraint: `'api' | 'note_parse' | 'description_parse'` (NOT spec's 'api_closes_issues' etc.)\n- reference_type CHECK constraint: `'closes' | 'mentioned' | 'related'`\n- LATEST_SCHEMA_VERSION = 14\n\n## Approach\n\nCreate `src/core/timeline.rs` with the following types:\n\n```rust\n/// The core timeline event. All pipeline stages produce or consume these.\n/// Spec ref: Section 3.3 \"Event Model\"\n#[derive(Debug, Clone, Serialize)]\npub struct TimelineEvent {\n    pub timestamp: i64,                    // ms epoch UTC\n    pub entity_type: String,               // \"issue\" | \"merge_request\"\n    pub entity_id: i64,                    // local DB id (internal, not in JSON output)\n    pub entity_iid: i64,\n    pub project_path: String,\n    pub event_type: TimelineEventType,\n    pub summary: String,                   // human-readable one-liner\n    pub actor: Option<String>,             // username or None for system\n    pub url: Option<String>,               // web URL for the event source\n    pub is_seed: bool,                     // true if from seed phase, false if expanded\n}\n\n/// Per spec Section 3.3. Serde tagged enum for JSON output.\n/// IMPORTANT: entity_type is String (not &'static str) because serde Serialize\n/// requires owned types for struct fields when deriving.\n#[derive(Debug, Clone, Serialize)]\n#[serde(tag = \"kind\", rename_all = \"snake_case\")]\npub enum TimelineEventType {\n    Created,\n    StateChanged { state: String },        // spec: just the target state\n    LabelAdded { label: String },\n    LabelRemoved { label: String },\n    MilestoneSet { milestone: String },\n    MilestoneRemoved { milestone: String },\n    Merged,                                // spec: unit variant\n    NoteEvidence {\n        note_id: i64,                      // spec: required\n        snippet: String,                   // first ~200 chars of matching note body\n        discussion_id: Option<i64>,        // spec: optional\n    },\n    CrossReferenced { target: String },    // compact target ref like \"\\!567\" or \"#234\"\n}\n\n/// Internal entity reference used across pipeline stages.\n#[derive(Debug, Clone, Serialize)]\npub struct EntityRef {\n    pub entity_type: String,               // String not &'static str — needed for Serialize\n    pub entity_id: i64,\n    pub entity_iid: i64,\n    pub project_path: String,\n}\n\n/// An entity discovered via BFS expansion.\n/// Spec ref: Section 3.5 \"expanded_entities\" JSON structure.\n#[derive(Debug, Clone, Serialize)]\npub struct ExpandedEntityRef {\n    pub entity_ref: EntityRef,\n    pub depth: u32,\n    pub via_from: EntityRef,               // the entity that referenced this one\n    pub via_reference_type: String,        // \"closes\", \"mentioned\", \"related\"\n    pub via_source_method: String,         // \"api\", \"note_parse\", \"description_parse\"\n}\n\n/// Reference to an unsynced external entity.\n/// Spec ref: Section 3.5 \"unresolved_references\" JSON structure.\n#[derive(Debug, Clone, Serialize)]\npub struct UnresolvedRef {\n    pub source: EntityRef,\n    pub target_project: Option<String>,\n    pub target_type: String,\n    pub target_iid: i64,\n    pub reference_type: String,\n}\n\n/// Complete result from the timeline pipeline.\n#[derive(Debug, Clone, Serialize)]\npub struct TimelineResult {\n    pub query: String,\n    pub events: Vec<TimelineEvent>,\n    pub seed_entities: Vec<EntityRef>,\n    pub expanded_entities: Vec<ExpandedEntityRef>,\n    pub unresolved_references: Vec<UnresolvedRef>,\n}\n```\n\nImplement `Ord` on `TimelineEvent` for chronological sort: primary key `timestamp`, tiebreak by `entity_id` then event_type discriminant.\n\nAlso implement `PartialEq`, `Eq`, `PartialOrd` (required by Ord).\n\nRegister in `src/core/mod.rs`: `pub mod timeline;`\n\n## Acceptance Criteria\n\n- [ ] `src/core/timeline.rs` compiles with no warnings\n- [ ] All struct fields use `String` not `&'static str` (required for `#[derive(Serialize)]`)\n- [ ] `TimelineEventType` has exactly 9 variants matching spec Section 3.3\n- [ ] `NoteEvidence` has `note_id: i64`, `snippet: String`, `discussion_id: Option<i64>`\n- [ ] `ExpandedEntityRef.via_source_method` documents codebase values: api, note_parse, description_parse\n- [ ] `Ord` impl sorts by (timestamp, entity_id, event_type discriminant)\n- [ ] `PartialEq`, `Eq`, `PartialOrd` derived or implemented\n- [ ] Module registered in `src/core/mod.rs`\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/core/timeline.rs` (NEW)\n- `src/core/mod.rs` (add `pub mod timeline;`)\n\n## TDD Loop\n\nRED: Create `src/core/timeline.rs` with `#[cfg(test)] mod tests`:\n- `test_timeline_event_sort_by_timestamp` - events sort chronologically\n- `test_timeline_event_sort_tiebreak` - same-timestamp events sort stably\n- `test_timeline_event_type_serializes_tagged` - serde JSON uses `kind` tag\n- `test_note_evidence_has_note_id` - note_id present in serialized output\n\nGREEN: Implement the types and Ord trait.\n\nVERIFY: `cargo test --lib -- timeline`\n\n## Edge Cases\n\n- Ord must be consistent and total for all valid TimelineEvent pairs\n- NoteEvidence snippet truncated to 200 chars at construction, not in the type\n- entity_type uses String to satisfy serde Serialize derive requirements\n- url field: constructed from project_path + entity_type + iid; None for entities without web_url","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:33:08.569126Z","created_by":"tayloreernisse","updated_at":"2026-02-05T21:43:02.449502Z","closed_at":"2026-02-05T21:43:02.449454Z","close_reason":"Completed: Created src/core/timeline.rs with TimelineEvent, TimelineEventType (9 variants), EntityRef, ExpandedEntityRef, UnresolvedRef, TimelineResult. Ord impl sorts by (timestamp, entity_id, event_type discriminant). entity_id skipped in serde output. 6 tests pass. All quality gates pass.","compaction_level":0,"original_size":0,"labels":["gate-3","phase-b","types"],"dependencies":[{"issue_id":"bd-20e","depends_on_id":"bd-ike","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-20h","title":"Implement MR discussion ingestion module","description":"## Background\nMR discussion ingestion with critical atomicity guarantees. Parse notes BEFORE destructive DB operations to prevent data loss. Watermark ONLY advanced on full success.\n\n## Approach\nCreate `src/ingestion/mr_discussions.rs` with:\n1. `IngestMrDiscussionsResult` - Per-MR stats\n2. `ingest_mr_discussions()` - Main function with atomicity guarantees\n3. Upsert + sweep pattern for notes (not delete-all-then-insert)\n4. Sync health telemetry for debugging failures\n\n## Files\n- `src/ingestion/mr_discussions.rs` - New module\n- `tests/mr_discussion_ingestion_tests.rs` - Integration tests\n\n## Acceptance Criteria\n- [ ] `IngestMrDiscussionsResult` has: discussions_fetched, discussions_upserted, notes_upserted, notes_skipped_bad_timestamp, diffnotes_count, pagination_succeeded\n- [ ] `ingest_mr_discussions()` returns `Result<IngestMrDiscussionsResult>`\n- [ ] CRITICAL: Notes parsed BEFORE any DELETE operations\n- [ ] CRITICAL: Watermark NOT advanced if `pagination_succeeded == false`\n- [ ] CRITICAL: Watermark NOT advanced if any note parse fails\n- [ ] Upsert + sweep pattern using `last_seen_at`\n- [ ] Stale discussions/notes removed only on full success\n- [ ] Selective raw payload storage (skip system notes without position)\n- [ ] Sync health telemetry recorded on failure\n- [ ] `does_not_advance_discussion_watermark_on_partial_failure` test passes\n- [ ] `atomic_note_replacement_preserves_data_on_parse_failure` test passes\n\n## TDD Loop\nRED: `cargo test does_not_advance_watermark` -> test fails\nGREEN: Add ingestion with atomicity guarantees\nVERIFY: `cargo test mr_discussion_ingestion`\n\n## Main Function\n```rust\npub async fn ingest_mr_discussions(\n    conn: &Connection,\n    client: &GitLabClient,\n    config: &Config,\n    project_id: i64,\n    gitlab_project_id: i64,\n    mr_iid: i64,\n    local_mr_id: i64,\n    mr_updated_at: i64,\n) -> Result<IngestMrDiscussionsResult>\n```\n\n## CRITICAL: Atomic Note Replacement\n```rust\n// Record sync start time for sweep\nlet run_seen_at = now_ms();\n\nwhile let Some(discussion_result) = stream.next().await {\n    let discussion = match discussion_result {\n        Ok(d) => d,\n        Err(e) => {\n            result.pagination_succeeded = false;\n            break; // Stop but don't advance watermark\n        }\n    };\n    \n    // CRITICAL: Parse BEFORE destructive operations\n    let notes = match transform_notes_with_diff_position(&discussion, project_id) {\n        Ok(notes) => notes,\n        Err(e) => {\n            warn!(\"Note transform failed; preserving existing notes\");\n            result.notes_skipped_bad_timestamp += discussion.notes.len();\n            result.pagination_succeeded = false;\n            continue; // Skip this discussion, don't delete existing\n        }\n    };\n    \n    // Only NOW start transaction (after parse succeeded)\n    let tx = conn.unchecked_transaction()?;\n    \n    // Upsert discussion with run_seen_at\n    // Upsert notes with run_seen_at (not delete-all)\n    \n    tx.commit()?;\n}\n```\n\n## Stale Data Sweep (only on success)\n```rust\nif result.pagination_succeeded {\n    // Sweep stale discussions\n    conn.execute(\n        \"DELETE FROM discussions\n         WHERE project_id = ? AND merge_request_id = ?\n           AND last_seen_at < ?\",\n        params![project_id, local_mr_id, run_seen_at],\n    )?;\n    \n    // Sweep stale notes\n    conn.execute(\n        \"DELETE FROM notes\n         WHERE discussion_id IN (\n           SELECT id FROM discussions\n           WHERE project_id = ? AND merge_request_id = ?\n         )\n         AND last_seen_at < ?\",\n        params![project_id, local_mr_id, run_seen_at],\n    )?;\n}\n```\n\n## Watermark Update (ONLY on success)\n```rust\nif result.pagination_succeeded {\n    mark_discussions_synced(conn, local_mr_id, mr_updated_at)?;\n    clear_sync_health_error(conn, local_mr_id)?;\n} else {\n    record_sync_health_error(conn, local_mr_id, \"Pagination incomplete or parse failure\")?;\n    warn!(\"Watermark NOT advanced; will retry on next sync\");\n}\n```\n\n## Selective Payload Storage\n```rust\n// Only store payload for DiffNotes and non-system notes\nlet should_store_note_payload =\n    !note.is_system() ||\n    note.position_new_path().is_some() ||\n    note.position_old_path().is_some();\n```\n\n## Integration Tests (CRITICAL)\n```rust\n#[tokio::test]\nasync fn does_not_advance_discussion_watermark_on_partial_failure() {\n    // Setup: MR with updated_at > discussions_synced_for_updated_at\n    // Mock: Page 1 returns OK, Page 2 returns 500\n    // Assert: discussions_synced_for_updated_at unchanged\n}\n\n#[tokio::test]\nasync fn does_not_advance_discussion_watermark_on_note_parse_failure() {\n    // Setup: Existing notes in DB\n    // Mock: Discussion with note having invalid created_at\n    // Assert: Original notes preserved, watermark unchanged\n}\n\n#[tokio::test]\nasync fn atomic_note_replacement_preserves_data_on_parse_failure() {\n    // Setup: Discussion with 3 valid notes\n    // Mock: Updated discussion where note 2 has bad timestamp\n    // Assert: All 3 original notes still in DB\n}\n```\n\n## Edge Cases\n- HTTP error mid-pagination: preserve existing data, log error, no watermark advance\n- Invalid note timestamp: skip discussion, preserve existing notes\n- System notes without position: don't store raw payload (saves space)\n- Empty discussion: still upsert discussion record, no notes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-26T22:06:42.335714Z","created_by":"tayloreernisse","updated_at":"2026-01-27T00:22:43.207057Z","closed_at":"2026-01-27T00:22:43.206996Z","close_reason":"Implemented MR discussion ingestion module with full atomicity guarantees:\n- IngestMrDiscussionsResult with all required fields\n- parse-before-destructive pattern (transform notes before DB ops)\n- Upsert + sweep pattern with last_seen_at timestamps\n- Watermark advanced ONLY on full pagination success\n- Selective payload storage (skip system notes without position)\n- Sync health telemetry for failure debugging\n- All 163 tests passing","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-20h","depends_on_id":"bd-3ir","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-20h","depends_on_id":"bd-3j6","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-20h","depends_on_id":"bd-iba","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-20p9","title":"NOTE-1A: Note query layer data types and filters","description":"## Background\nPhase 1 adds a lore notes command for direct SQL query over the notes table. This chunk implements the data structures, filter logic, and query function following existing patterns in src/cli/commands/list.rs. The existing file contains: IssueListRow/Json/Result (for issues), MrListRow/Json/Result (for MRs), ListFilters/MrListFilters, query_issues(), query_mrs().\n\n## Approach\nAdd to src/cli/commands/list.rs (after the existing MR query code):\n\nData structures:\n- NoteListRow: id, gitlab_id, author_username, body, note_type, is_system, created_at, updated_at, position_new_path, position_new_line, position_old_path, position_old_line, resolvable, resolved, resolved_by, noteable_type (from discussions.noteable_type), parent_iid (i64), parent_title, project_path\n- NoteListRowJson: ISO timestamp variants (created_at_iso, updated_at_iso using ms_to_iso from crate::core::time) + #[derive(Serialize)]\n- NoteListResult: notes: Vec<NoteListRow>, total_count: i64\n- NoteListResultJson: notes: Vec<NoteListRowJson>, total_count: i64, showing: usize\n- NoteListFilters: limit (usize), project (Option<String>), author (Option<String>), note_type (Option<String>), include_system (bool), for_issue_iid (Option<i64>), for_mr_iid (Option<i64>), note_id (Option<i64>), gitlab_note_id (Option<i64>), discussion_id (Option<String>), since (Option<String>), until (Option<String>), path (Option<String>), contains (Option<String>), resolution (Option<String>), sort (String), order (String)\n\nQuery function pub fn query_notes(conn: &Connection, filters: &NoteListFilters, config: &Config) -> Result<NoteListResult>:\n- Time window: parse since/until relative to single anchored now_ms via parse_since (from crate::core::time). --until date = end-of-day (23:59:59.999). Validate since_ms <= until_ms.\n- Core SQL: SELECT from notes n JOIN discussions d ON n.discussion_id = d.id JOIN projects p ON n.project_id = p.id LEFT JOIN issues i ON d.issue_id = i.id LEFT JOIN merge_requests m ON d.merge_request_id = m.id WHERE {dynamic_filters} ORDER BY {sort} {order}, n.id {order} LIMIT ?\n- Filter mappings:\n  - author: COLLATE NOCASE, strip leading @ (same pattern as existing list filters)\n  - note_type: exact match\n  - project: resolve_project(conn, project_str) from crate::core::project\n  - since/until: n.created_at >= ?ms / n.created_at <= ?ms\n  - path: trailing / = LIKE prefix match with escape_like (from crate::core::project), else exact match on position_new_path\n  - contains: LIKE %term% COLLATE NOCASE on n.body with escape_like for %, _\n  - resolution: \"unresolved\" → n.resolvable = 1 AND n.resolved = 0, \"resolved\" → n.resolvable = 1 AND n.resolved = 1, \"any\" → no filter\n  - for_issue_iid/for_mr_iid: requires project_id context. Validation at query layer (return error if no project and no defaultProject), NOT as clap requires.\n  - include_system: when false (default), add n.is_system = 0\n  - note_id: exact match on n.id\n  - gitlab_note_id: exact match on n.gitlab_id\n  - discussion_id: exact match on d.gitlab_discussion_id\n- Use dynamic WHERE clause building with params vector (same pattern as query_issues/query_mrs)\n\n## Files\n- MODIFY: src/cli/commands/list.rs (add NoteListRow, NoteListRowJson, NoteListResult, NoteListResultJson, NoteListFilters, query_notes)\n\n## TDD Anchor\nRED: test_query_notes_empty_db — setup DB with no notes, call query_notes, assert total_count == 0.\nGREEN: Implement NoteListFilters + query_notes with basic SELECT.\nVERIFY: cargo test query_notes_empty_db -- --nocapture\n28 tests from PRD: test_query_notes_empty_db, test_query_notes_filter_author, test_query_notes_filter_author_strips_at, test_query_notes_filter_author_case_insensitive, test_query_notes_filter_note_type, test_query_notes_filter_project, test_query_notes_filter_since, test_query_notes_filter_until, test_query_notes_filter_since_and_until_combined, test_query_notes_invalid_time_window_rejected, test_query_notes_until_date_uses_end_of_day, test_query_notes_filter_contains, test_query_notes_filter_contains_case_insensitive, test_query_notes_filter_contains_escapes_like_wildcards, test_query_notes_filter_path, test_query_notes_filter_path_prefix, test_query_notes_filter_for_issue_requires_project, test_query_notes_filter_for_mr_requires_project, test_query_notes_filter_for_issue_uses_default_project, test_query_notes_filter_resolution_unresolved, test_query_notes_filter_resolution_resolved, test_query_notes_sort_created_desc, test_query_notes_sort_created_asc, test_query_notes_deterministic_tiebreak, test_query_notes_limit, test_query_notes_combined_filters, test_query_notes_filter_note_id_exact, test_query_notes_filter_gitlab_note_id_exact, test_query_notes_filter_discussion_id_exact, test_note_list_row_json_conversion\n\n## Acceptance Criteria\n- [ ] NoteListRow/Json/Result/Filters structs defined with all fields\n- [ ] query_notes returns notes matching all filter combinations\n- [ ] Author filter is case-insensitive and strips @ prefix\n- [ ] Time window validates since <= until with clear error message including swap suggestion\n- [ ] --until date uses end-of-day (23:59:59.999)\n- [ ] Path filter: trailing / = prefix match with LIKE escape, otherwise exact\n- [ ] Contains filter: case-insensitive body substring with LIKE escape for %, _\n- [ ] for_issue_iid/for_mr_iid require project context (error if no --project and no defaultProject)\n- [ ] Default: exclude system notes (is_system = 0). --include-system overrides.\n- [ ] ORDER BY includes n.id tiebreaker for deterministic results\n- [ ] All 28+ tests pass\n\n## Edge Cases\n- parse_until_with_anchor: YYYY-MM-DD --until returns end-of-day (not start-of-day)\n- Inverted time window: --since 30d --until 90d → error message suggesting swap\n- LIKE wildcards in --contains: % and _ escaped via escape_like (from crate::core::project)\n- IID without project: error at query layer (not clap) to support defaultProject\n- Discussion with NULL noteable_type: LEFT JOIN handles gracefully (parent_iid/title will be None)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T17:00:26.741853Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:13:24.378983Z","closed_at":"2026-02-12T18:13:24.378936Z","close_reason":"Implemented by agent swarm","compaction_level":0,"original_size":0,"labels":["cli","per-note","search"],"dependencies":[{"issue_id":"bd-20p9","depends_on_id":"bd-1oyf","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-20p9","depends_on_id":"bd-25hb","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-20p9","depends_on_id":"bd-3iod","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-21fb","title":"Phase 0a: Extract signal handler to core/shutdown.rs","description":"## What\nExtract the 4 identical Ctrl+C signal handlers from the command handler code into a single function in core/shutdown.rs.\n\n## Why\nReduces tokio surface area before the runtime swap. 4 spawn sites -> 1 function. The function body changes in Phase 3d (signal handler rewrite for asupersync), so centralizing first makes that change trivial.\n\n## Rearchitecture Context (2026-03-06)\nA major code reorganization moved command handler dispatch logic out of main.rs into physical files under src/app/ using include!() chains:\n- src/main.rs (419 LOC, thin entry) -> include!(\"app/dispatch.rs\") -> include!(\"handlers.rs\"), include!(\"errors.rs\"), include!(\"robot_docs.rs\")\n- All handler code is LOGICALLY still in main.rs scope but PHYSICALLY in src/app/handlers.rs (~1730 LOC)\n\n## Current State\nThe 4 signal handler sites are in src/app/handlers.rs (physically), at approximately:\n- Line ~214 (sync handler)\n- Line ~1535 (ingest handler)\n- Line ~1661 (surgical sync handler)\n- Line ~1718 (embed handler)\n\nEach handler is a tokio::spawn block that:\n1. Awaits tokio::signal::ctrl_c()\n2. Prints interrupt message\n3. Calls signal.cancel()\n4. Awaits second ctrl_c for force quit via process::exit(130)\n\n## Implementation\n\nNew file: src/core/shutdown.rs\n```rust\npub fn install_ctrl_c_handler(signal: ShutdownSignal) {\n    tokio::spawn(async move {\n        let _ = tokio::signal::ctrl_c().await;\n        eprintln!(\"\\nInterrupted, finishing current batch... (Ctrl+C again to force quit)\");\n        signal.cancel();\n        let _ = tokio::signal::ctrl_c().await;\n        std::process::exit(130);\n    });\n}\n```\n\nReplace all 4 sites in src/app/handlers.rs with: install_ctrl_c_handler(signal.clone());\n\n## Files Changed\n- src/core/shutdown.rs (NEW ~20 LOC)\n- src/core/mod.rs (add pub mod shutdown)\n- src/app/handlers.rs (replace 4 handler blocks with 1-line calls)\n\n## Testing\n- cargo check --all-targets\n- cargo clippy --all-targets -- -D warnings\n- Existing tests should pass unchanged\n\n## Notes\n- This is independently valuable cleanup regardless of whether the full migration happens\n- The function signature will change in Phase 3d to accept &Cx instead of using tokio::spawn","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-06T18:38:08.054343Z","created_by":"tayloreernisse","updated_at":"2026-03-06T21:11:12.487068Z","closed_at":"2026-03-06T21:11:12.485805Z","close_reason":"asupersync migration complete — 5 commits pushed to asupersync-migration branch","compaction_level":0,"original_size":0,"labels":["asupersync","phase-0"],"dependencies":[{"issue_id":"bd-21fb","depends_on_id":"bd-1lj5","type":"parent-child","created_at":"2026-03-06T18:38:08.057882Z","created_by":"tayloreernisse"}]}
{"id":"bd-221","title":"Create migration 008_fts5.sql","description":"## Background\nFTS5 (Full-Text Search 5) provides the lexical search backbone for Gate A. The virtual table + triggers keep the FTS index in sync with the documents table automatically. This migration must be applied AFTER migration 007 (documents table exists). The trigger design handles NULL titles via COALESCE and only rebuilds the FTS entry when searchable text actually changes (not metadata-only updates).\n\n## Approach\nCreate `migrations/008_fts5.sql` with the exact SQL from PRD Section 1.2:\n\n1. **Virtual table:** `documents_fts` using FTS5 with porter stemmer, prefix indexes (2,3,4), external content backed by `documents` table\n2. **Insert trigger:** `documents_ai` — inserts into FTS on document insert, uses COALESCE(title, '') for NULL safety\n3. **Delete trigger:** `documents_ad` — removes from FTS on document delete using the FTS5 delete command syntax\n4. **Update trigger:** `documents_au` — only fires when `title` or `content_text` changes (WHEN clause), performs delete-then-insert to update FTS\n\nRegister migration 8 in `src/core/db.rs` MIGRATIONS array.\n\n**Critical detail:** The COALESCE is required because FTS5 external-content tables require exact value matching for delete operations. If NULL was inserted, the delete trigger couldn't match it (NULL != NULL in SQL).\n\n## Acceptance Criteria\n- [ ] `migrations/008_fts5.sql` file exists\n- [ ] `documents_fts` virtual table created with `tokenize='porter unicode61'` and `prefix='2 3 4'`\n- [ ] `content='documents'` and `content_rowid='id'` set (external content mode)\n- [ ] Insert trigger `documents_ai` fires on document insert with COALESCE(title, '')\n- [ ] Delete trigger `documents_ad` fires on document delete using FTS5 delete command\n- [ ] Update trigger `documents_au` only fires when `old.title IS NOT new.title OR old.content_text != new.content_text`\n- [ ] Prefix search works: query `auth*` matches \"authentication\"\n- [ ] After bulk insert of N documents, `SELECT count(*) FROM documents_fts` returns N\n- [ ] Schema version 8 recorded in schema_version table\n- [ ] `cargo test migration_tests` passes\n\n## Files\n- `migrations/008_fts5.sql` — new file (copy exact SQL from PRD Section 1.2)\n- `src/core/db.rs` — add migration 8 to MIGRATIONS array\n\n## TDD Loop\nRED: Register migration in db.rs, `cargo test migration_tests` fails (SQL file missing)\nGREEN: Create `008_fts5.sql` with all triggers\nVERIFY: `cargo test migration_tests && cargo build`\n\n## Edge Cases\n- Metadata-only updates (e.g., changing `updated_at` or `labels_hash`) must NOT trigger FTS rebuild — the WHEN clause prevents this\n- NULL titles must use COALESCE to empty string in both insert and delete triggers\n- The update trigger does delete+insert (not FTS5 'delete' + regular insert atomically) — this is the correct FTS5 pattern for content changes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:25:25.763146Z","created_by":"tayloreernisse","updated_at":"2026-01-30T16:56:13.131830Z","closed_at":"2026-01-30T16:56:13.131771Z","close_reason":"Completed: migration 008_fts5.sql with FTS5 virtual table, 3 sync triggers (insert/delete/update with COALESCE NULL safety), prefix search, registered in db.rs, cargo build + tests pass","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-221","depends_on_id":"bd-hrs","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-226s","title":"Epic: Time-Decay Expert Scoring Model","description":"## Background\n\nReplace flat-weight expertise scoring with exponential half-life decay, split reviewer signals (participated vs assigned-only), dual-path rename awareness, and new CLI flags (--as-of, --explain-score, --include-bots, --all-history).\n\n**Plan document:** plans/time-decay-expert-scoring.md (iteration 6, target 8)\n**Beads revision:** 3 (updated line numbers to match v0.7.0 codebase, fixed migration 022->026, fixed test count 27->31, added explicit callsite update scope to bd-13q8)\n\n## Children (Execution Order)\n\n### Layer 0 — Foundation (no deps)\n- **bd-2w1p** — Add half-life fields and config validation to ScoringConfig\n- **bd-1soz** — Add half_life_decay() pure function\n- **bd-18dn** — Add normalize_query_path() pure function\n\n### Layer 1 — Schema + Helpers (depends on Layer 0)\n- **bd-2ao4** — Add migration 026 for dual-path and reviewer participation indexes (5 indexes)\n- **bd-2yu5** — Add timestamp-aware test helpers (insert_mr_at, insert_diffnote_at, insert_file_change_with_old_path)\n- **bd-1b50** — Update existing tests for new ScoringConfig fields (..Default::default())\n\n### Layer 2 — SQL + Path Probes (depends on Layer 1)\n- **bd-1hoq** — Restructure expert SQL with CTE-based dual-path matching (8 CTEs, mr_activity, parameterized ?5/?6)\n- **bd-1h3f** — Add rename awareness to path resolution probes (build_path_query + suffix_probe)\n\n### Layer 3 — Rust Aggregation (depends on Layer 2)\n- **bd-13q8** — Implement Rust-side decay aggregation with reviewer split + update all 17 existing query_expert() callsites\n\n### Layer 4 — CLI (depends on Layer 3)\n- **bd-11mg** — Add CLI flags: --as-of, --explain-score, --include-bots, --all-history, path normalization\n\n### Layer 5 — Verification (depends on Layer 4)\n- **bd-1vti** — Run full test suite: 31 new tests + all existing tests, no regressions\n- **bd-1j5o** — Quality gates, query plan check (6 index points), real-world validation\n\n## Revision 3 Delta (from revision 2)\n- **Migration number**: 022 -> 026 (latest existing is 025_note_dirty_backfill.sql)\n- **Test count**: 27 -> 31 (correct tally: 2+3+2+1+2+13+8=31)\n- **Line numbers**: All beads updated to match v0.7.0 codebase (ScoringConfig at 155, validate_scoring at 274, query_expert at 641, build_path_query at 467, suffix_probe at 596, run_who at 276, test helpers at 2469-2598, test_expert_scoring_weights at 3551)\n- **bd-13q8 scope**: Now explicitly documents updating all 17 existing query_expert() callsites (1 production + 16 test) when changing signature from 7 to 10 params\n- **bd-2yu5**: insert_file_change_with_old_path now has complete SQL implementation (was placeholder)\n\n## Files Modified\n- src/core/config.rs (ScoringConfig struct at line 155, validation at line 274)\n- src/cli/commands/who.rs (decay function, normalize_query_path, SQL, aggregation, CLI flags, tests)\n- src/core/db.rs (MIGRATIONS array — add (\"026\", ...) entry)\n- CREATE: migrations/026_scoring_indexes.sql (5 new indexes)\n\n## Acceptance Criteria\n- [ ] All 31 new tests pass (across all child beads)\n- [ ] All existing tests pass unchanged (decay ~1.0 at now_ms())\n- [ ] cargo check + clippy + fmt clean\n- [ ] ubs scan clean on modified files\n- [ ] EXPLAIN QUERY PLAN shows 6 index usage points (manual verification)\n- [ ] Real-world validation: who --path on known files shows recency discounting\n- [ ] who --explain-score component breakdown sums to total\n- [ ] who --as-of produces deterministic results across runs\n- [ ] Assigned-only reviewers rank below participated reviewers\n- [ ] Old file paths resolve and credit expertise after renames\n- [ ] Path normalization: ./src//foo.rs resolves identically to src/foo.rs\n\n## Edge Cases\n- f64 NaN guard in half_life_decay (hl=0 -> 0.0)\n- Deterministic f64 ordering via mr_id sort before summation\n- Closed MR multiplier applied via state_mult in SQL (not Rust string match)\n- Trivial notes (< reviewer_min_note_chars) classified as assigned-only\n- Exclusive upper bound on --as-of prevents future event leakage\n- Config upper bounds prevent absurd values (3650-day cap, 4096-char cap, NaN/Inf rejection)","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-02-09T16:58:58.007560Z","created_by":"tayloreernisse","updated_at":"2026-02-12T20:43:09.211271Z","closed_at":"2026-02-12T20:43:09.211222Z","close_reason":"Epic complete: time-decay expert scoring model implemented. 3-agent swarm, 12 tasks, 621 tests, all quality gates green, real-world validation passed.","compaction_level":0,"original_size":0}
{"id":"bd-227","title":"[CP1] gi count issues/discussions/notes commands","description":"Count entities in the database.\n\n## Module\nsrc/cli/commands/count.rs\n\n## Clap Definition\nCount {\n    #[arg(value_parser = [\"issues\", \"mrs\", \"discussions\", \"notes\"])]\n    entity: String,\n    \n    #[arg(long, value_parser = [\"issue\", \"mr\"])]\n    r#type: Option<String>,\n}\n\n## Commands\n- gi count issues → 'Issues: N'\n- gi count discussions → 'Discussions: N'\n- gi count discussions --type=issue → 'Issue Discussions: N'\n- gi count notes → 'Notes: N (excluding M system)'\n- gi count notes --type=issue → 'Issue Notes: N (excluding M system)'\n\n## Implementation\n- Simple COUNT(*) queries\n- For notes, also count WHERE is_system = 1 for system note count\n- Filter by noteable_type when --type specified\n\nFiles: src/cli/commands/count.rs\nDone when: Counts match expected values from GitLab","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T16:58:25.648805Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.920135Z","closed_at":"2026-01-25T17:02:01.920135Z","deleted_at":"2026-01-25T17:02:01.920129Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-22ai","title":"NOTE-2H: Backfill existing notes after upgrade (migration 025)","description":"## Background\nWhen a user upgrades to note document support, existing notes have no documents. Without backfill, only notes that change post-upgrade get documents. This migration seeds all existing non-system notes into dirty queue. Uses migration slot 025 (024 = note documents schema).\n\n## Approach\nCreate migrations/025_note_dirty_backfill.sql:\nINSERT INTO dirty_sources (source_type, source_id, queued_at)\nSELECT 'note', n.id, CAST(strftime('%s', 'now') AS INTEGER) * 1000\nFROM notes n\nLEFT JOIN documents d ON d.source_type = 'note' AND d.source_id = n.id\nWHERE n.is_system = 0 AND d.id IS NULL\nON CONFLICT(source_type, source_id) DO NOTHING;\n\nRegister as (\"025\", include_str!(\"../../migrations/025_note_dirty_backfill.sql\")) in MIGRATIONS array in src/core/db.rs.\nData-only migration — no schema changes. Safe on empty DBs (no notes = no-op).\n\n## Files\n- CREATE: migrations/025_note_dirty_backfill.sql\n- MODIFY: src/core/db.rs (add (\"025\", ...) to MIGRATIONS array)\n\n## TDD Anchor\nRED: test_migration_025_backfills_existing_notes — setup: run migrations through 024, insert 5 non-system + 2 system notes, run migration 025, assert 5 dirty entries with source_type='note'.\nGREEN: Create migration with the INSERT...SELECT...ON CONFLICT statement.\nVERIFY: cargo test migration_025 -- --nocapture\nTests: test_migration_025_idempotent_with_existing_documents (notes already having documents are skipped), test_migration_025_skips_notes_already_in_dirty_queue\n\n## Acceptance Criteria\n- [ ] Migration seeds non-system notes without documents into dirty queue\n- [ ] System notes excluded (is_system = 0 filter)\n- [ ] Notes already having documents excluded (LEFT JOIN + d.id IS NULL)\n- [ ] Idempotent: re-running doesn't create duplicates (ON CONFLICT DO NOTHING)\n- [ ] Notes already in dirty queue not duplicated\n- [ ] All 3 tests pass\n\n## Dependency Context\n- Depends on NOTE-2A (bd-1oi7): dirty_sources must accept source_type='note' (migration 024 adds CHECK constraint). Must run after migration 024.\n\n## Edge Cases\n- Empty database (fresh install): no notes exist, migration is a no-op\n- Database with only system notes: no entries queued\n- Concurrent sync running: ON CONFLICT DO NOTHING handles race safely","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T17:02:48.824398Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:13:15.797283Z","closed_at":"2026-02-12T18:13:15.797239Z","close_reason":"Implemented by agent swarm","compaction_level":0,"original_size":0,"labels":["per-note","search"]}
{"id":"bd-22li","title":"OBSERV: Implement SyncRunRecorder lifecycle helper","description":"## Background\nThe sync_runs table exists (migration 001) but NOTHING writes to it. SyncRunRecorder encapsulates the INSERT-on-start, UPDATE-on-finish lifecycle, fixing this bug and enabling sync history tracking.\n\n## Approach\nCreate src/core/sync_run.rs:\n\n```rust\nuse crate::core::metrics::StageTiming;\nuse crate::core::error::Result;\nuse rusqlite::Connection;\n\npub struct SyncRunRecorder {\n    row_id: i64,\n}\n\nimpl SyncRunRecorder {\n    /// Insert a new sync_runs row with status='running'.\n    pub fn start(conn: &Connection, command: &str, run_id: &str) -> Result<Self> {\n        let now_ms = crate::core::time::now_ms();\n        conn.execute(\n            \"INSERT INTO sync_runs (started_at, heartbeat_at, status, command, run_id)\n             VALUES (?1, ?2, 'running', ?3, ?4)\",\n            rusqlite::params![now_ms, now_ms, command, run_id],\n        )?;\n        let row_id = conn.last_insert_rowid();\n        Ok(Self { row_id })\n    }\n\n    /// Mark run as succeeded with full metrics.\n    pub fn succeed(\n        self,\n        conn: &Connection,\n        metrics: &[StageTiming],\n        total_items: usize,\n        total_errors: usize,\n    ) -> Result<()> {\n        let now_ms = crate::core::time::now_ms();\n        let metrics_json = serde_json::to_string(metrics)\n            .unwrap_or_else(|_| \"[]\".to_string());\n        conn.execute(\n            \"UPDATE sync_runs\n             SET finished_at = ?1, status = 'succeeded',\n                 metrics_json = ?2, total_items_processed = ?3, total_errors = ?4\n             WHERE id = ?5\",\n            rusqlite::params![now_ms, metrics_json, total_items, total_errors, self.row_id],\n        )?;\n        Ok(())\n    }\n\n    /// Mark run as failed with error message and optional partial metrics.\n    pub fn fail(\n        self,\n        conn: &Connection,\n        error: &str,\n        metrics: Option<&[StageTiming]>,\n    ) -> Result<()> {\n        let now_ms = crate::core::time::now_ms();\n        let metrics_json = metrics\n            .map(|m| serde_json::to_string(m).unwrap_or_else(|_| \"[]\".to_string()));\n        conn.execute(\n            \"UPDATE sync_runs\n             SET finished_at = ?1, status = 'failed', error = ?2,\n                 metrics_json = ?3\n             WHERE id = ?4\",\n            rusqlite::params![now_ms, error, metrics_json, self.row_id],\n        )?;\n        Ok(())\n    }\n}\n```\n\nRegister in src/core/mod.rs:\n```rust\npub mod sync_run;\n```\n\nNote: SyncRunRecorder takes self (not &self) in succeed/fail to enforce single-use lifecycle. You start a run, then either succeed or fail it -- never both.\n\nThe existing time::now_ms() helper (src/core/time.rs) returns milliseconds since epoch as i64. Used by the existing sync_runs schema (started_at, finished_at are INTEGER ms).\n\n## Acceptance Criteria\n- [ ] SyncRunRecorder::start() inserts row with status='running', started_at set\n- [ ] SyncRunRecorder::succeed() updates status='succeeded', finished_at set, metrics_json populated\n- [ ] SyncRunRecorder::fail() updates status='failed', error set, finished_at set\n- [ ] fail() with Some(metrics) stores partial metrics in metrics_json\n- [ ] fail() with None leaves metrics_json as NULL\n- [ ] succeed/fail consume self (single-use enforcement)\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/core/sync_run.rs (new file)\n- src/core/mod.rs (register module)\n\n## TDD Loop\nRED:\n  - test_sync_run_recorder_start: in-memory DB, start(), query sync_runs, assert status='running'\n  - test_sync_run_recorder_succeed: start() then succeed(), assert status='succeeded', metrics_json parseable\n  - test_sync_run_recorder_fail: start() then fail(), assert status='failed', error set\n  - test_sync_run_recorder_fail_with_partial_metrics: fail with Some(metrics), assert metrics_json has data\nGREEN: Implement SyncRunRecorder\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- Connection lifetime: SyncRunRecorder stores row_id, not Connection. The caller must ensure the same Connection is used for start/succeed/fail.\n- Panic during sync: if the program panics between start() and succeed()/fail(), the row stays as 'running'. The existing stale lock detection (stale_lock_minutes) handles this.\n- metrics_json encoding: serde_json::to_string on Vec<StageTiming> produces a JSON array string.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T15:54:51.364617Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:38:04.903657Z","closed_at":"2026-02-04T17:38:04.903610Z","close_reason":"Implemented SyncRunRecorder with start/succeed/fail lifecycle, 4 passing tests","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-22li","depends_on_id":"bd-1o4h","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-22li","depends_on_id":"bd-3pz","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-22li","depends_on_id":"bd-apmo","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-22uw","title":"NOTE-2F: Search CLI --type note support","description":"## Background\nAllow lore search --type note to filter search results to note-type documents. The search pipeline uses SearchFilters.source_type which maps to SourceType::parse() for filtering.\n\n## Approach\n1. Update SearchArgs.source_type value_parser in src/cli/mod.rs (line 560):\n   Current: value_parser = [\"issue\", \"mr\", \"discussion\"]\n   New: value_parser = [\"issue\", \"mr\", \"discussion\", \"note\"]\n\n2. Update search results display in src/cli/commands/search.rs (line 333-338):\n   Add match arm in the type_prefix match:\n   \"note\" => \"Note\",\n\n3. Verify SourceType::parse() already handles \"note\" (done by NOTE-2B). The search pipeline in search.rs line 105-108 calls SourceType::parse() on the CLI source_type value. No changes needed to search logic itself — the filter propagates through SearchFilters to the SQL WHERE clause automatically.\n\n## Files\n- MODIFY: src/cli/mod.rs (line 560, add \"note\" to value_parser array)\n- MODIFY: src/cli/commands/search.rs (line 333-338, add \"note\" => \"Note\" match arm)\n\n## TDD Anchor\nCompile test: verify lore search --type note is accepted by clap (no \"invalid value\" error).\nSmoke test: with note documents in DB, verify lore search --type note returns only note results.\nVERIFY: cargo test -- --nocapture (existing search tests still pass)\n\n## Acceptance Criteria\n- [ ] lore search --type note accepted by clap parser\n- [ ] lore search --type note filters to note documents only\n- [ ] Note results display \"Note\" prefix in human table output (line 340-346)\n- [ ] Robot JSON includes source_type: \"note\" field (already handled by SearchResultDisplay serialization)\n- [ ] All existing search tests still pass\n\n## Dependency Context\n- Depends on NOTE-2D (bd-2ezb): note documents must exist in DB to be searched (regenerator must handle SourceType::Note)\n\n## Edge Cases\n- No note documents indexed yet: returns 0 results (same as any empty type filter)\n- Mixed search (no --type flag): note documents appear alongside issue/mr/discussion results","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T17:02:32.836882Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:13:15.638041Z","closed_at":"2026-02-12T18:13:15.637987Z","close_reason":"Implemented by agent swarm","compaction_level":0,"original_size":0,"labels":["cli","per-note","search"]}
{"id":"bd-23a4","title":"OBSERV: Wire SyncRunRecorder into sync and ingest commands","description":"## Background\nWith SyncRunRecorder implemented and MetricsLayer available, we wire them into the actual sync and ingest command handlers. This makes every sync/ingest invocation create a database record with full metrics.\n\n## Approach\n### src/cli/commands/sync.rs - run_sync() (line ~54)\n\nBefore the pipeline:\n```rust\nlet recorder = SyncRunRecorder::start(&conn, \"sync\", &run_id)?;\n```\n\nAfter pipeline succeeds:\n```rust\nlet stages = metrics_handle.extract_timings();\nlet total_items = stages.iter().map(|s| s.items_processed).sum::<usize>();\nlet total_errors = stages.iter().map(|s| s.errors).sum::<usize>();\nrecorder.succeed(&conn, &stages, total_items, total_errors)?;\n```\n\nOn pipeline failure (wrap pipeline in match or use a helper):\n```rust\nmatch pipeline_result {\n    Ok(result) => {\n        let stages = metrics_handle.extract_timings();\n        recorder.succeed(&conn, &stages, total_items, total_errors)?;\n        Ok(result)\n    }\n    Err(e) => {\n        let stages = metrics_handle.extract_timings();\n        recorder.fail(&conn, &e.to_string(), Some(&stages))?;\n        Err(e)\n    }\n}\n```\n\n### src/cli/commands/ingest.rs - run_ingest() (line ~107)\n\nSame pattern: start before pipeline, succeed/fail after.\n\nNote: run_sync() calls run_ingest() internally. Both will create sync_runs records. This is intentional -- standalone ingest should also be tracked. But when run_sync calls run_ingest, the ingest record is a child operation. Consider: should we skip the ingest recorder when called from sync? Decision: keep both records. The run_id differs, and sync-status can distinguish by the \"command\" column.\n\nActually, re-reading the code: run_sync() (line 54-178) calls run_ingest() for issues and MRs. If both create sync_runs rows, we get 3 rows per sync (1 sync + 2 ingest). This is fine -- command='sync' vs command='ingest:issues' distinguishes them.\n\n### Connection sharing\nrun_sync and run_ingest already have access to a Connection. SyncRunRecorder::start takes &Connection.\n\n### MetricsLayer handle\nmetrics_handle must be passed from main.rs through handle_sync_cmd/handle_ingest to run_sync/run_ingest. This requires adding a parameter. Alternative: use a thread-local or global. Prefer parameter passing for testability.\n\n## Acceptance Criteria\n- [ ] Every lore sync creates a sync_runs row with status transitioning running -> succeeded/failed\n- [ ] Every lore ingest creates a sync_runs row\n- [ ] metrics_json contains serialized Vec<StageTiming> on success\n- [ ] Failed syncs record error message and partial metrics\n- [ ] sync_runs.run_id matches run_id in log files and robot JSON\n- [ ] total_items_processed and total_errors are populated\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/cli/commands/sync.rs (wire SyncRunRecorder + extract_timings in run_sync)\n- src/cli/commands/ingest.rs (wire SyncRunRecorder in run_ingest)\n- src/main.rs (pass metrics_handle to command handlers)\n\n## TDD Loop\nRED: test_sync_creates_run_record (integration: run sync, query sync_runs, assert row exists with metrics)\nGREEN: Wire SyncRunRecorder into run_sync and run_ingest\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- Database locked: SyncRunRecorder operations happen on the main connection. If a concurrent process holds the lock, the INSERT/UPDATE will wait (WAL mode) or error. Use existing lock handling.\n- Partial failure: if ingest issues succeeds but ingest MRs fails, the sync recorder should fail() with partial metrics (stages from issues but not MRs).\n- metrics_handle lifetime: must outlive the root span. Since it's an Arc clone, this is guaranteed.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T15:54:51.414504Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:41:04.963794Z","closed_at":"2026-02-04T17:41:04.963749Z","close_reason":"Wired SyncRunRecorder into handle_sync_cmd and handle_ingest in main.rs","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-23a4","depends_on_id":"bd-22li","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-23a4","depends_on_id":"bd-34ek","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-23a4","depends_on_id":"bd-3pz","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-23xb","title":"Phase 5c: Full verification and hardening pass","description":"## What\nRun the complete verification checklist and resolve any remaining issues.\n\n## Verification Checklist\n\n```bash\ncargo check --all-targets\ncargo clippy --all-targets -- -D warnings\ncargo fmt --check\ncargo test\n```\n\n## Specific Things to Verify\n\n1. **async-stream on nightly** — Does async_stream 0.3 compile on current nightly? If not, manual Stream impl is the fallback.\n\n2. **TLS root certs on macOS** — Does tls-native-roots pick up system CA certs? Test with production GitLab endpoint.\n\n3. **Connection pool under concurrency** — Do join_all batches (4-8 concurrent requests to same host) work without pool deadlock? Stress test.\n\n4. **Pagination streams** — Do async_stream::stream! pagination generators work unchanged? Test with multi-page GitLab results.\n\n5. **Wiremock test isolation** — Do 42 wiremock tests pass with tokio only in dev-deps? Verify no accidental production tokio usage.\n\n6. **Build time comparison** — Measure before/after build times. Document delta.\n\n7. **Binary size comparison** — Measure before/after binary sizes.\n\n## Rollback readiness\nVerify that reverting the atomic Phases 1-3 commit cleanly restores the tokio+reqwest setup. Test by checking out the pre-migration commit and running cargo test.\n\n## Files Changed\n- Potentially any file if issues are found\n- Documentation updates if behavior differs\n\n## Depends On\n- Phase 4a, 4b, 4c (all test migration complete)\n- Phase 5a (HTTP parity verified)\n- Phase 5b (DB invariants verified)","status":"closed","priority":1,"issue_type":"task","created_at":"2026-03-06T18:42:35.571383Z","created_by":"tayloreernisse","updated_at":"2026-03-06T21:11:12.691261Z","closed_at":"2026-03-06T21:11:12.691215Z","close_reason":"asupersync migration complete — 5 commits pushed to asupersync-migration branch","compaction_level":0,"original_size":0,"labels":["asupersync","phase-5"],"dependencies":[{"issue_id":"bd-23xb","depends_on_id":"bd-12um","type":"blocks","created_at":"2026-03-06T18:48:01.617572Z","created_by":"tayloreernisse"},{"issue_id":"bd-23xb","depends_on_id":"bd-16qf","type":"blocks","created_at":"2026-03-06T18:42:59.903775Z","created_by":"tayloreernisse"},{"issue_id":"bd-23xb","depends_on_id":"bd-18ai","type":"blocks","created_at":"2026-03-06T18:42:59.817629Z","created_by":"tayloreernisse"},{"issue_id":"bd-23xb","depends_on_id":"bd-1lj5","type":"parent-child","created_at":"2026-03-06T18:42:35.602135Z","created_by":"tayloreernisse"},{"issue_id":"bd-23xb","depends_on_id":"bd-1yky","type":"blocks","created_at":"2026-03-06T18:42:59.995719Z","created_by":"tayloreernisse"},{"issue_id":"bd-23xb","depends_on_id":"bd-26km","type":"blocks","created_at":"2026-03-06T18:43:00.177732Z","created_by":"tayloreernisse"},{"issue_id":"bd-23xb","depends_on_id":"bd-2815","type":"blocks","created_at":"2026-03-06T18:43:00.086917Z","created_by":"tayloreernisse"},{"issue_id":"bd-23xb","depends_on_id":"bd-2pyn","type":"blocks","created_at":"2026-03-06T18:55:28.044984Z","created_by":"tayloreernisse"}]}
{"id":"bd-247","title":"Implement issue document extraction","description":"## Background\nIssue documents are the simplest document type — a structured header + description text. The extractor queries the existing issues and issue_labels tables (populated by ingestion) and assembles a DocumentData struct. This is one of three entity-specific extractors (issue, MR, discussion) that feed the document regeneration pipeline.\n\n## Approach\nImplement `extract_issue_document()` in `src/documents/extractor.rs`:\n\n```rust\n/// Extract a searchable document from an issue.\n/// Returns None if the issue has been deleted from the DB.\npub fn extract_issue_document(conn: &Connection, issue_id: i64) -> Result<Option<DocumentData>>\n```\n\n**SQL queries (from PRD Section 2.2):**\n```sql\n-- Main entity\nSELECT i.id, i.iid, i.title, i.description, i.state, i.author_username,\n       i.created_at, i.updated_at, i.web_url,\n       p.path_with_namespace, p.id AS project_id\nFROM issues i\nJOIN projects p ON p.id = i.project_id\nWHERE i.id = ?\n\n-- Labels\nSELECT l.name FROM issue_labels il\nJOIN labels l ON l.id = il.label_id\nWHERE il.issue_id = ?\nORDER BY l.name\n```\n\n**Document format:**\n```\n[[Issue]] #234: Authentication redesign\nProject: group/project-one\nURL: https://gitlab.example.com/group/project-one/-/issues/234\nLabels: [\"bug\", \"auth\"]\nState: opened\nAuthor: @johndoe\n\n--- Description ---\n\nWe need to modernize our authentication system...\n```\n\n**Implementation steps:**\n1. Query issue row — if not found, return Ok(None)\n2. Query labels via junction table\n3. Format header with [[Issue]] prefix\n4. Compute content_hash via compute_content_hash()\n5. Compute labels_hash via compute_list_hash()\n6. paths is always empty for issues (paths are only for DiffNote discussions)\n7. Return DocumentData with all fields populated\n\n## Acceptance Criteria\n- [ ] Deleted issue (not in DB) returns Ok(None)\n- [ ] Issue with no description: content_text has header only (no \"--- Description ---\" section)\n- [ ] Issue with no labels: Labels line shows \"[]\"\n- [ ] Issue with labels: Labels line shows sorted JSON array\n- [ ] content_hash is SHA-256 of the full content_text\n- [ ] labels_hash is SHA-256 of sorted label names joined by newline\n- [ ] paths_hash is empty string hash (issues have no paths)\n- [ ] project_id comes from the JOIN with projects table\n- [ ] `cargo test extract_issue` passes\n\n## Files\n- `src/documents/extractor.rs` — implement `extract_issue_document()`\n\n## TDD Loop\nRED: Test in `#[cfg(test)] mod tests`:\n- `test_issue_document_format` — verify header format matches PRD template\n- `test_issue_not_found` — returns Ok(None) for nonexistent issue_id\n- `test_issue_no_description` — no description section when description is NULL\n- `test_issue_labels_sorted` — labels appear in alphabetical order\n- `test_issue_hash_deterministic` — same issue produces same content_hash\nGREEN: Implement extract_issue_document with SQL queries\nVERIFY: `cargo test extract_issue`\n\n## Edge Cases\n- Issue with NULL description: skip \"--- Description ---\" section entirely\n- Issue with empty string description: include section but with empty body\n- Issue with very long description: no truncation here (hard cap applied by caller)\n- Labels with special characters (quotes, commas): JSON array handles escaping","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-30T15:25:45.490145Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:28:13.974948Z","closed_at":"2026-01-30T17:28:13.974891Z","close_reason":"Implemented extract_issue_document() with SQL queries, PRD-compliant format, and 7 tests","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-247","depends_on_id":"bd-36p","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-247","depends_on_id":"bd-hrs","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-24j1","title":"OBSERV: Add #[instrument] spans to ingestion stages","description":"## Background\nTracing spans on each sync stage create the hierarchy that (1) makes log lines filterable by stage, (2) Phase 3's MetricsLayer reads to build StageTiming trees, and (3) gives meaningful context in -vv stderr output.\n\n## Approach\nAdd #[instrument] attributes or manual spans to these functions:\n\n### src/ingestion/orchestrator.rs\n1. ingest_project_issues_with_progress() (line ~110):\n```rust\n#[instrument(skip_all, fields(stage = \"ingest_issues\", project = %project_path))]\npub async fn ingest_project_issues_with_progress(...) -> Result<IngestProjectResult> {\n```\n\n2. The MR equivalent (ingest_project_mrs_with_progress or similar):\n```rust\n#[instrument(skip_all, fields(stage = \"ingest_mrs\", project = %project_path))]\n```\n\n3. Inside the issue ingest function, add child spans for sub-stages:\n```rust\nlet _fetch_span = tracing::info_span!(\"fetch_pages\", project = %project_path).entered();\n// ... fetch logic\ndrop(_fetch_span);\n\nlet _disc_span = tracing::info_span!(\"sync_discussions\", project = %project_path).entered();\n// ... discussion sync logic\ndrop(_disc_span);\n```\n\n4. drain_resource_events() (line ~566):\n```rust\nlet _span = tracing::info_span!(\"fetch_resource_events\", project = %project_path).entered();\n```\n\n### src/documents/regenerator.rs\n5. regenerate_dirty_documents() (line ~24):\n```rust\n#[instrument(skip_all, fields(stage = \"generate_docs\"))]\npub fn regenerate_dirty_documents(conn: &Connection) -> Result<RegenerateResult> {\n```\n\n### src/embedding/pipeline.rs\n6. embed_documents() (line ~36):\n```rust\n#[instrument(skip_all, fields(stage = \"embed\"))]\npub async fn embed_documents(...) -> Result<EmbedResult> {\n```\n\n### Important: field declarations for Phase 3\nThe #[instrument] fields should include empty recording fields that Phase 3 (bd-16m8) will populate:\n```rust\n#[instrument(skip_all, fields(\n    stage = \"ingest_issues\",\n    project = %project_path,\n    items_processed = tracing::field::Empty,\n    items_skipped = tracing::field::Empty,\n    errors = tracing::field::Empty,\n))]\n```\n\nThis declares the fields on the span so MetricsLayer can capture them when span.record() is called later.\n\n## Acceptance Criteria\n- [ ] JSON log lines show nested span context: sync > ingest_issues > fetch_pages\n- [ ] Each stage span has a \"stage\" field with the stage name\n- [ ] Per-project spans include \"project\" field\n- [ ] Spans are visible in -vv stderr output as bracketed context\n- [ ] Empty recording fields declared for items_processed, items_skipped, errors\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/ingestion/orchestrator.rs (spans on ingest functions and sub-stages)\n- src/documents/regenerator.rs (span on regenerate_dirty_documents)\n- src/embedding/pipeline.rs (span on embed_documents)\n\n## TDD Loop\nRED:\n  - test_span_context_in_json_logs: mock sync, capture JSON, verify span chain\n  - test_nested_span_chain: verify parent-child: sync > ingest_issues > fetch_pages\n  - test_span_elapsed_on_close: create span, sleep 10ms, verify elapsed >= 10\nGREEN: Add #[instrument] and manual spans to all stage functions\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- #[instrument] on async fn: uses tracing::Instrument trait automatically. Works with tokio.\n- skip_all is essential: without it, #[instrument] tries to Debug-format all parameters, which may not implement Debug or may be expensive.\n- Manual span drop: for sub-stages within a single function, use explicit drop(_span) to end the span before the next sub-stage starts. Otherwise spans overlap.\n- tracing::field::Empty: declares a field that can be recorded later. If never recorded, it appears as empty/missing in output (not zero).","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-04T15:54:07.821068Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:19:34.307672Z","closed_at":"2026-02-04T17:19:34.307624Z","close_reason":"Added #[instrument] spans to ingest_project_issues_with_progress, ingest_project_merge_requests_with_progress, drain_resource_events, regenerate_dirty_documents, embed_documents","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-24j1","depends_on_id":"bd-2ni","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-24j1","depends_on_id":"bd-2rr","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-25hb","title":"NOTE-1C: Human and robot output formatting for notes","description":"## Background\nImplement the 4 output formatters for the notes command: human table, robot JSON, JSONL streaming, and CSV export.\n\n## Approach\nAdd to src/cli/commands/list.rs (after the query_notes function from NOTE-1A):\n\n1. pub fn print_list_notes(result: &NoteListResult) — human table:\n   Use comfy-table (already in Cargo.toml) following the pattern of print_list_issues/print_list_mrs.\n   Columns: ID | Author | Type | Body (truncated to 60 chars + \"...\") | Path:Line | Parent | Created\n   ID: colored_cell with Cyan for gitlab_id\n   Author: @username with Magenta\n   Type: \"Diff\" for DiffNote, \"Disc\" for DiscussionNote, \"-\" for others\n   Path: position_new_path:line (or \"-\" if no path)\n   Parent: \"Issue #N\" or \"MR !N\" from noteable_type + parent_iid\n   Created: format_relative_time (existing helper in list.rs)\n\n2. pub fn print_list_notes_json(result: &NoteListResult, elapsed_ms: u64, fields: Option<&[String]>) — robot JSON:\n   Standard envelope: {\"ok\":true,\"data\":{\"notes\":[...],\"total_count\":N,\"showing\":M},\"meta\":{\"elapsed_ms\":U64}}\n   Supports --fields via filter_fields() from crate::cli::robot\n   Same pattern as print_list_issues_json.\n\n3. pub fn print_list_notes_jsonl(result: &NoteListResult) — one JSON object per line:\n   Each line is one NoteListRowJson serialized. No envelope. Ideal for jq/notebook pipelines.\n   Use serde_json::to_string for each row, println! each line.\n\n4. pub fn print_list_notes_csv(result: &NoteListResult) — CSV output:\n   Check if csv crate is already used in the project. If not, use manual CSV with proper escaping:\n   - Header row with field names matching NoteListRowJson\n   - Quote fields containing commas, quotes, or newlines\n   - Escape internal quotes by doubling them\n   Alternatively, if adding csv crate (add csv = \"1\" to Cargo.toml [dependencies]), use csv::WriterBuilder for RFC 4180 compliance.\n\nHelper: Add a truncate_body(body: &str, max_len: usize) -> String function for the human table truncation.\n\n## Files\n- MODIFY: src/cli/commands/list.rs (4 print functions + truncate_body helper)\n- POSSIBLY MODIFY: Cargo.toml (add csv = \"1\" if using csv crate for CSV output)\n\n## TDD Anchor\nRED: test_truncate_note_body — assert 200-char body truncated to 60 + \"...\"\nGREEN: Implement truncate_body helper.\nVERIFY: cargo test truncate_note_body -- --nocapture\nTests: test_csv_output_basic (CSV output has correct header + escaped fields), test_jsonl_output_one_per_line (each line parses as valid JSON)\n\n## Acceptance Criteria\n- [ ] Human table renders with colored columns, truncated body, relative time\n- [ ] Robot JSON follows standard envelope with timing metadata\n- [ ] --fields filtering works on JSON output (via filter_fields)\n- [ ] JSONL outputs one valid JSON object per line\n- [ ] CSV properly escapes commas, quotes, and newlines in body text\n- [ ] Multi-byte chars handled correctly in CSV and truncation\n- [ ] All 3 tests pass\n\n## Dependency Context\n- Depends on NOTE-1A (bd-20p9): uses NoteListRow, NoteListRowJson, NoteListResult structs\n\n## Edge Cases\n- Empty body in table: show \"-\" or empty cell\n- Very long body with multi-byte chars: truncation must respect char boundaries (use .chars().take(n) not byte slicing)\n- JSONL with body containing newlines: serde_json::to_string escapes \\n correctly\n- CSV with body containing quotes: must double them per RFC 4180","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T17:00:53.482055Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:13:24.304235Z","closed_at":"2026-02-12T18:13:24.304188Z","close_reason":"Implemented by agent swarm","compaction_level":0,"original_size":0,"labels":["cli","per-note","search"],"dependencies":[{"issue_id":"bd-25hb","depends_on_id":"bd-1oyf","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-25rl","title":"Phase 2b: Migrate gitlab/graphql.rs to HTTP adapter","description":"## What\nReplace all reqwest usage in gitlab/graphql.rs with the crate::http adapter.\n\n## Why\nGraphQL module handles POST requests with Bearer auth + JSON body. Smaller scope than client.rs (~20 LOC changed).\n\n## Changes\n\n### Request construction\n```rust\n// Before\nlet response = self.http.post(&url)\n    .header(\"Authorization\", format!(\"Bearer {}\", self.token))\n    .header(\"Content-Type\", \"application/json\")\n    .json(&body).send().await?;\nlet json: Value = response.json().await?;\n\n// After\nlet bearer = format!(\"Bearer {}\", self.token);\nlet response = self.http.post_json(&url, &[\n    (\"Authorization\", &bearer),\n], &body).await?;\nlet json: Value = response.json()?;  // sync — body already buffered\n```\n\n### Status matching\n```rust\n// Before: response.status().as_u16()\n// After: response.status  // already u16\n```\n\n## Files Changed\n- src/gitlab/graphql.rs (~20 LOC changed)\n\n## Testing\n- All 30 graphql_tests.rs tests must pass (wiremock, still on tokio)\n- cargo check --all-targets\n- cargo clippy --all-targets -- -D warnings\n\n## Depends On\n- Phase 1 (adapter layer must exist)","notes":"WIREMOCK COMPATIBILITY: Same risk as Phase 2a (bd-lhj8). After migration, 30 graphql_tests run on #[tokio::test] but call code using asupersync HTTP. See Phase 2a description for resolution options. If asupersync HTTP is runtime-agnostic, no issue. Verify during Decision Gate.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-03-06T18:39:52.295380Z","created_by":"tayloreernisse","updated_at":"2026-03-06T21:11:12.586481Z","closed_at":"2026-03-06T21:11:12.575147Z","close_reason":"asupersync migration complete — 5 commits pushed to asupersync-migration branch","compaction_level":0,"original_size":0,"labels":["asupersync","phase-2"],"dependencies":[{"issue_id":"bd-25rl","depends_on_id":"bd-1lj5","type":"parent-child","created_at":"2026-03-06T18:39:52.297175Z","created_by":"tayloreernisse"},{"issue_id":"bd-25rl","depends_on_id":"bd-bqoc","type":"blocks","created_at":"2026-03-06T18:42:50.737566Z","created_by":"tayloreernisse"}]}
{"id":"bd-25s","title":"robot-docs: Add Ollama dependency discovery to manifest","description":"## Background\n\nAdd Ollama dependency discovery to robot-docs so agents know which commands need Ollama and which work without it. Currently robot-docs lists commands, exit codes, workflows, and aliases — but has no dependency information.\n\n## Codebase Context\n\n- handle_robot_docs() in src/main.rs (line ~1646) returns RobotDocsData JSON\n- RobotDocsData struct has fields: commands, exit_codes, workflows, aliases, clap_error_codes\n- Currently 18 documented commands in the manifest\n- Ollama required for: embed, search --mode=semantic, search --mode=hybrid\n- Not required for: all Phase B temporal commands (timeline, file-history, trace), lexical search, count, ingest, stats, sync, doctor, health, who, show, issues, mrs, etc.\n- No dependencies field exists yet in RobotDocsData\n\n## Approach\n\n### 1. Add dependencies field to RobotDocsData (src/main.rs):\n\n```rust\n#[derive(Serialize)]\nstruct RobotDocsData {\n    // ... existing fields ...\n    dependencies: DependencyInfo,\n}\n\n#[derive(Serialize)]\nstruct DependencyInfo {\n    ollama: OllamaDependency,\n}\n\n#[derive(Serialize)]\nstruct OllamaDependency {\n    required_by: Vec<String>,\n    not_required_by: Vec<String>,\n    install: HashMap<String, String>,  // {\"macos\": \"brew install ollama\", \"linux\": \"curl ...\"}\n    setup: String,                      // \"ollama pull nomic-embed-text\"\n    note: String,\n}\n```\n\n### 2. Populate in handle_robot_docs():\n\n```json\n{\n  \"ollama\": {\n    \"required_by\": [\"embed\", \"search --mode=semantic\", \"search --mode=hybrid\"],\n    \"not_required_by\": [\"issues\", \"mrs\", \"search --mode=lexical\", \"timeline\", \"file-history\", \"count\", \"ingest\", \"stats\", \"sync\", \"doctor\", \"health\", \"who\", \"show\", \"status\"],\n    \"install\": {\"macos\": \"brew install ollama\", \"linux\": \"curl -fsSL https://ollama.ai/install.sh | sh\"},\n    \"setup\": \"ollama pull nomic-embed-text\",\n    \"note\": \"Lexical search and all temporal features work without Ollama.\"\n  }\n}\n```\n\n## Acceptance Criteria\n\n- [ ] `lore robot-docs | jq '.data.dependencies.ollama'` returns structured info\n- [ ] required_by lists embed and semantic/hybrid search modes\n- [ ] not_required_by lists all commands that work without Ollama (including Phase B if they exist)\n- [ ] Install instructions for macos and linux\n- [ ] setup field includes \"ollama pull nomic-embed-text\"\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n- [ ] `cargo fmt --check` passes\n\n## Files\n\n- MODIFY: src/main.rs (add DependencyInfo/OllamaDependency structs, update RobotDocsData, populate in handle_robot_docs)\n\n## TDD Anchor\n\nNo unit test needed — this is static metadata. Verify with:\n\n```bash\ncargo check --all-targets\ncargo run --release -- robot-docs | jq '.data.dependencies.ollama.required_by'\ncargo run --release -- robot-docs | jq '.data.dependencies.ollama.not_required_by'\n```\n\n## Edge Cases\n\n- Keep not_required_by up to date as new commands are added — consider a comment in the code listing which commands to check\n- Phase B commands (timeline, file-history, trace) must be in not_required_by once they exist\n- If a command conditionally needs Ollama (like search with --mode flag), list the specific flag combination in required_by\n\n## Dependency Context\n\n- **RobotDocsData** (src/main.rs ~line 1646): the existing struct that this bead extends. Currently has commands (Vec), exit_codes (Vec), workflows (Vec), aliases (Vec), clap_error_codes (Vec). Adding a dependencies field is additive — no breaking changes.\n- **handle_robot_docs()**: the function that constructs and returns the JSON. All data is hardcoded in the function — no runtime introspection needed.","status":"open","priority":4,"issue_type":"feature","created_at":"2026-01-30T20:26:43.169688Z","created_by":"tayloreernisse","updated_at":"2026-02-17T16:53:20.425853Z","compaction_level":0,"original_size":0,"labels":["enhancement","robot-mode"]}
{"id":"bd-26f2","title":"Implement common widgets (status bar, breadcrumb, loading, error toast, help overlay)","description":"## Background\nCommon widgets appear across all screens: the status bar shows context-sensitive key hints and sync status, the breadcrumb shows navigation depth, the loading spinner indicates background work, the error toast shows transient errors with auto-dismiss, and the help overlay (?) shows available keybindings.\n\n## Approach\nCreate crates/lore-tui/src/view/common/mod.rs and individual widget files:\n\nview/common/mod.rs:\n- render_breadcrumb(frame, area, nav: &NavigationStack, theme: &Theme): renders \"Dashboard > Issues > #42\" trail\n- render_status_bar(frame, area, registry: &CommandRegistry, screen: &Screen, mode: &InputMode, theme: &Theme): renders bottom bar with key hints and sync indicator\n- render_loading(frame, area, load_state: &LoadState, theme: &Theme): renders centered spinner for LoadingInitial, or subtle refresh indicator for Refreshing\n- render_error_toast(frame, area, msg: &str, theme: &Theme): renders floating toast at bottom-right with error message\n- render_help_overlay(frame, area, registry: &CommandRegistry, screen: &Screen, theme: &Theme): renders centered modal with keybinding list from registry\n\nCreate crates/lore-tui/src/view/mod.rs:\n- render_screen(frame, app: &LoreApp): top-level dispatch — renders breadcrumb + screen content + status bar + optional overlays (help, error toast, command palette)\n\n## Acceptance Criteria\n- [ ] Breadcrumb renders all stack entries with \" > \" separator\n- [ ] Status bar shows contextual hints from CommandRegistry\n- [ ] Loading spinner animates via tick subscription\n- [ ] Error toast auto-positions at bottom-right of screen\n- [ ] Help overlay shows all commands for current screen from registry\n- [ ] render_screen routes to correct per-screen view function\n- [ ] Overlays (help, error, palette) render on top of screen content\n\n## Files\n- CREATE: crates/lore-tui/src/view/mod.rs\n- CREATE: crates/lore-tui/src/view/common/mod.rs\n\n## TDD Anchor\nRED: Write test_breadcrumbs_format that creates a NavigationStack with Dashboard > IssueList, calls breadcrumbs(), asserts [\"Dashboard\", \"Issues\"].\nGREEN: Implement breadcrumbs() in NavigationStack (already in nav task) and render_breadcrumb.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_breadcrumbs\n\n## Edge Cases\n- Breadcrumb must truncate from the left if stack is too deep for terminal width\n- Status bar must handle narrow terminals (<60 cols) gracefully — show abbreviated hints\n- Error toast must handle very long messages with truncation\n- Help overlay must scroll if there are more commands than terminal height\n\n## Dependency Context\nUses NavigationStack from \"Implement NavigationStack\" task.\nUses CommandRegistry from \"Implement CommandRegistry\" task.\nUses LoadState from \"Implement AppState composition\" task.\nUses Theme from \"Implement theme configuration\" task.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T16:57:13.520393Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:22.387220Z","deleted_at":"2026-03-11T18:34:22.387217Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-26km","title":"Phase 5b: DB transaction invariant audit","description":"## What\nAudit all ingestion paths for transaction boundary correctness under cancellation. Verify 4 invariants. This is a CODE AUDIT — the corresponding tests are written in Phase 4c (bd-1yky). This bead identifies violations; 4c tests enforce them.\n\n## Rearchitecture Context (2026-03-06)\nKey files for this audit:\n- src/ingestion/orchestrator.rs — main orchestration (unchanged location)\n- src/ingestion/surgical.rs — ingestion-level surgical sync logic (unchanged location)\n- src/cli/commands/sync/surgical.rs — CLI command wrapper (was cli/commands/sync_surgical.rs)\n- src/ingestion/storage/ — persistence helpers moved here from core/ (payloads.rs, events.rs, queue.rs, sync_run.rs)\n\n## Invariants\n\n### INV-1: Atomic batch writes\nEach ingestion batch (issues, MRs, discussions) writes to DB inside a single unchecked_transaction(). If not committed, no partial data visible. Audit all paths — fix any that write outside a transaction.\n\n### INV-2: Region cancellation cannot corrupt committed data\nCancelled region may abandon in-flight HTTP but must not interrupt DB transaction mid-write. This holds because SQLite transactions are synchronous — once tx.execute() starts, it runs to completion regardless of task cancellation. VERIFY this holds for WAL mode.\n\n### HARD RULE: No .await between transaction open and commit/rollback\nCancellation can fire at any .await point. If .await exists between unchecked_transaction() and tx.commit(), cancelled region could drop transaction guard mid-batch, rolling back partial writes silently.\n\nIf any path must do async work mid-transaction, restructure to fetch-then-write: complete all async work first, then open transaction, write synchronously, commit.\n\n### INV-3: No partial batch visibility\nIf cancellation fires after fetching N items but before batch transaction commits, zero items from that batch are persisted. Next sync resumes via cursor-based pagination.\n\n### INV-4: ShutdownSignal + region cancellation are complementary\nExisting ShutdownSignal check-before-write pattern in orchestrator loops (if signal.is_cancelled() { break; }) remains first line of defense. Region cancellation is second — ensures in-flight HTTP tasks are drained even if orchestrator loop moved past signal check. Both must remain active.\n\n## Audit Procedure\n1. Grep for all unchecked_transaction() and begin_transaction() calls in ingestion/ and cli/commands/sync/\n2. For each: verify no .await between open and commit\n3. For each: verify the entire batch write is inside a single transaction\n4. Document any violations and fix them (restructure to fetch-then-write)\n5. Verify WAL mode compatibility for INV-2\n\n## Relationship to Phase 4c\nPhase 4c (bd-1yky) writes integration tests that ENFORCE these invariants at runtime. This bead (5b) is the static code AUDIT that finds violations. If this audit finds issues, fix the code first, then Phase 4c tests confirm the fix holds under cancellation.\n\n## Files to Audit\n- src/ingestion/orchestrator.rs (primary target)\n- src/ingestion/surgical.rs (ingestion-level surgical logic)\n- src/ingestion/issues.rs, merge_requests.rs, discussions.rs, mr_discussions.rs (batch write paths)\n- src/ingestion/storage/payloads.rs, events.rs, queue.rs, sync_run.rs (persistence helpers, moved from core/)\n- No new test files (tests are in Phase 4c)","status":"closed","priority":1,"issue_type":"task","created_at":"2026-03-06T18:42:21.137834Z","created_by":"tayloreernisse","updated_at":"2026-03-06T21:11:12.682092Z","closed_at":"2026-03-06T21:11:12.682048Z","close_reason":"asupersync migration complete — 5 commits pushed to asupersync-migration branch","compaction_level":0,"original_size":0,"labels":["asupersync","phase-5"],"dependencies":[{"issue_id":"bd-26km","depends_on_id":"bd-1lj5","type":"parent-child","created_at":"2026-03-06T18:42:21.141037Z","created_by":"tayloreernisse"},{"issue_id":"bd-26km","depends_on_id":"bd-zgke","type":"blocks","created_at":"2026-03-06T18:42:58.930966Z","created_by":"tayloreernisse"}]}
{"id":"bd-26lp","title":"Implement CLI integration (lore tui command + binary delegation)","description":"## Background\nThe lore CLI binary needs a tui subcommand that launches the lore-tui binary. This is runtime binary delegation — lore finds lore-tui via PATH lookup and execs it, passing through relevant flags. Zero compile-time dependency from lore to lore-tui. The TUI is the human interface; the CLI is the robot/script interface.\n\n## Approach\nAdd a tui subcommand to the lore CLI:\n\n**CLI side** (`src/cli/tui.rs`):\n- Add `Tui` variant to the main CLI enum with flags: --config, --sync, --fresh, --render-mode, --ascii, --no-alt-screen\n- Implementation: resolve lore-tui binary via PATH lookup (std::process::Command with \"lore-tui\")\n- Pass through all flags as CLI arguments\n- If lore-tui not found in PATH, print helpful error: \"lore-tui binary not found. Install with: cargo install --path crates/lore-tui\"\n- Exec (not spawn+wait) using std::os::unix::process::CommandExt::exec() for clean process replacement on Unix\n\n**Binary naming**: The binary is `lore-tui` (hyphenated), matching the crate name.\n\n## Acceptance Criteria\n- [ ] lore tui launches lore-tui binary from PATH\n- [ ] All flags (--config, --sync, --fresh, --render-mode, --ascii, --no-alt-screen) are passed through\n- [ ] Missing binary produces helpful error with install instructions\n- [ ] Uses exec() on Unix for clean process replacement (no zombie parent)\n- [ ] Robot mode: lore --robot tui returns JSON error if binary not found\n- [ ] lore tui --help shows TUI-specific flags\n\n## Files\n- CREATE: src/cli/tui.rs\n- MODIFY: src/cli/mod.rs (add tui subcommand to CLI enum)\n- MODIFY: src/main.rs (add match arm for Tui variant)\n\n## TDD Anchor\nRED: Write `test_tui_binary_not_found_error` that asserts the error message includes install instructions when lore-tui is not in PATH.\nGREEN: Implement the binary lookup and error handling.\nVERIFY: cargo test tui_binary -- --nocapture\n\nAdditional tests:\n- test_tui_flag_passthrough (verify all flags are forwarded)\n- test_tui_robot_mode_json_error (structured error when binary missing)\n\n## Edge Cases\n- lore-tui binary exists but is not executable — should produce clear error\n- PATH contains multiple lore-tui versions — uses first match (standard PATH behavior)\n- Windows: exec() not available — fall back to spawn+wait+exit with same code\n- User runs lore tui in robot mode — should fail with structured JSON error (TUI is human-only)\n\n## Dependency Context\nDepends on bd-2iqk (Doctor + Stats screens) for phase ordering. The CLI integration is one of the last Phase 4 tasks because it requires lore-tui to be substantially complete for the delegation to be useful.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T17:02:39.602970Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:24.711421Z","deleted_at":"2026-03-11T18:34:24.711384Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-2711","title":"WHO: Reviews mode query (query_reviews)","description":"## Background\n\nReviews mode answers \"What review patterns does person X have?\" by analyzing the **prefix** convention in DiffNote bodies (e.g., **suggestion**: ..., **question**: ..., **nit**: ...). Only counts DiffNotes on MRs the user did NOT author (m.author_username != ?1).\n\n## Approach\n\n### Three queries:\n1. **Total DiffNotes**: COUNT(*) of DiffNotes by user on others' MRs\n2. **Distinct MRs reviewed**: COUNT(DISTINCT m.id) \n3. **Category extraction**: SQL-level prefix parsing + Rust normalization\n\n### Category extraction SQL:\n```sql\nSELECT\n    SUBSTR(ltrim(n.body), 3, INSTR(SUBSTR(ltrim(n.body), 3), '**') - 1) AS raw_prefix,\n    COUNT(*) AS cnt\nFROM notes n\nJOIN discussions d ON n.discussion_id = d.id\nJOIN merge_requests m ON d.merge_request_id = m.id\nWHERE n.author_username = ?1\n  AND n.note_type = 'DiffNote' AND n.is_system = 0\n  AND m.author_username != ?1\n  AND ltrim(n.body) LIKE '**%**%'   -- only bodies with **prefix** pattern\n  AND n.created_at >= ?2\n  AND (?3 IS NULL OR n.project_id = ?3)\nGROUP BY raw_prefix ORDER BY cnt DESC\n```\n\nKey: `ltrim(n.body)` tolerates leading whitespace before **prefix** (common in practice).\n\n### normalize_review_prefix() in Rust:\n```rust\nfn normalize_review_prefix(raw: &str) -> String {\n    let s = raw.trim().trim_end_matches(':').trim().to_lowercase();\n    // Strip parentheticals like \"(non-blocking)\"\n    let s = if let Some(idx) = s.find('(') { s[..idx].trim().to_string() } else { s };\n    // Merge nit/nitpick variants\n    match s.as_str() {\n        \"nitpick\" | \"nit\" => \"nit\".to_string(),\n        other => other.to_string(),\n    }\n}\n```\n\n### HashMap merge for normalized categories, then sort by count DESC\n\n### ReviewsResult struct:\n```rust\npub struct ReviewsResult {\n    pub username: String,\n    pub total_diffnotes: u32,\n    pub categorized_count: u32,\n    pub mrs_reviewed: u32,\n    pub categories: Vec<ReviewCategory>,\n}\npub struct ReviewCategory { pub name: String, pub count: u32, pub percentage: f64 }\n```\n\nNo LIMIT needed — categories are naturally bounded (few distinct prefixes).\n\n## Files\n\n- `src/cli/commands/who.rs`\n\n## TDD Loop\n\nRED:\n```\ntest_reviews_query — insert 3 DiffNotes (2 with **prefix**, 1 without); verify total=3, categorized=2, categories.len()=2\ntest_normalize_review_prefix — \"suggestion\" \"Suggestion:\" \"suggestion (non-blocking):\" \"Nitpick:\" \"nit (non-blocking):\" \"question\" \"TODO:\"\n```\n\nGREEN: Implement query_reviews + normalize_review_prefix\nVERIFY: `cargo test -- reviews`\n\n## Acceptance Criteria\n\n- [ ] test_reviews_query passes (total=3, categorized=2)\n- [ ] test_normalize_review_prefix passes (nit/nitpick merge, parenthetical strip)\n- [ ] Only counts DiffNotes on MRs user did NOT author\n- [ ] Default since window: 6m\n\n## Edge Cases\n\n- Self-authored MRs excluded (m.author_username != ?1) — user's notes on own MRs are not \"reviews\"\n- ltrim() handles leading whitespace before **prefix**\n- Empty raw_prefix after normalization filtered out (!normalized.is_empty())\n- Percentage calculated from categorized_count (not total_diffnotes)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-08T02:40:53.350210Z","created_by":"tayloreernisse","updated_at":"2026-02-08T04:10:29.599252Z","closed_at":"2026-02-08T04:10:29.599217Z","close_reason":"Implemented by agent team: migration 017, CLI skeleton, all 5 query modes, human+robot output, 20 tests. All quality gates pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2711","depends_on_id":"bd-2ldg","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-2711","depends_on_id":"bd-34rr","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2815","title":"Phase 5a: HTTP behavior parity verification","description":"## What\nVerify HTTP behavior parity between reqwest (old) and asupersync h1 (new) for all implicit behaviors reqwest provided.\n\n## Acceptance Criteria Table\n\n| reqwest default | Criterion | Test |\n|-----------------|-----------|------|\n| Auto redirect (up to 10) | If GitLab returns 3xx, must not silently lose response. Follow or surface clear error. | wiremock 301 -> verify adapter returns redirect status |\n| Auto gzip/deflate | Not required (JSON small) | N/A |\n| Proxy from HTTP_PROXY | If set, requests route through it. If unsupported, document. | Set HTTP_PROXY=127.0.0.1:9999 -> verify connection targets proxy |\n| Connection keep-alive | Pagination batches (4-8 sequential) must reuse connections | Measure with ss/netstat: 8 requests should use <=2 TCP connections |\n| System DNS | Hostnames must resolve via OS resolver | lore sync against hostname (not IP) |\n| Content-Length on POST | Must include header (some proxies/WAFs require) | Inspect outgoing headers in wiremock test |\n| TLS cert validation | HTTPS must validate certs using system CA store | lore sync against production GitLab + fail against self-signed |\n\n## Implementation\nWrite specific test for each row. Document pass/fail results. Any failure is a blocker that must be resolved before migration is complete.\n\n## Files Changed\n- tests/http_parity_tests.rs (NEW, ~200 LOC)\n- Or extend existing adapter integration tests\n\n## Depends On\n- Phase 2a, 2b, 2c (all HTTP modules migrated)\n- Phase 3a (asupersync in deps)","status":"closed","priority":1,"issue_type":"task","created_at":"2026-03-06T18:41:59.839078Z","created_by":"tayloreernisse","updated_at":"2026-03-06T21:11:12.674081Z","closed_at":"2026-03-06T21:11:12.674033Z","close_reason":"asupersync migration complete — 5 commits pushed to asupersync-migration branch","compaction_level":0,"original_size":0,"labels":["asupersync","phase-5","testing"],"dependencies":[{"issue_id":"bd-2815","depends_on_id":"bd-1lj5","type":"parent-child","created_at":"2026-03-06T18:41:59.843026Z","created_by":"tayloreernisse"},{"issue_id":"bd-2815","depends_on_id":"bd-25rl","type":"blocks","created_at":"2026-03-06T18:42:58.332421Z","created_by":"tayloreernisse"},{"issue_id":"bd-2815","depends_on_id":"bd-2n82","type":"blocks","created_at":"2026-03-06T18:42:58.483829Z","created_by":"tayloreernisse"},{"issue_id":"bd-2815","depends_on_id":"bd-39yp","type":"blocks","created_at":"2026-03-06T18:42:58.687338Z","created_by":"tayloreernisse"},{"issue_id":"bd-2815","depends_on_id":"bd-lhj8","type":"blocks","created_at":"2026-03-06T18:42:58.191948Z","created_by":"tayloreernisse"}]}
{"id":"bd-296a","title":"NOTE-1E: Composite query index and author_id column (migration 022)","description":"## Background\nThe notes table needs composite covering indexes for the new query_notes() function, plus the author_id column for immutable identity (NOTE-0D). Combined in a single migration to avoid an extra migration step. Migration slot 022 is available (021 = work_item_status, 023 = issue_detail_fields already exists).\n\n## Approach\nCreate migrations/022_notes_query_index.sql with:\n\n1. Composite index for author-scoped queries (most common pattern):\n   CREATE INDEX IF NOT EXISTS idx_notes_user_created\n   ON notes(project_id, author_username COLLATE NOCASE, created_at DESC, id DESC)\n   WHERE is_system = 0;\n\n2. Composite index for project-scoped date-range queries:\n   CREATE INDEX IF NOT EXISTS idx_notes_project_created\n   ON notes(project_id, created_at DESC, id DESC)\n   WHERE is_system = 0;\n\n3. Discussion JOIN indexes (check if they already exist first):\n   CREATE INDEX IF NOT EXISTS idx_discussions_issue_id ON discussions(issue_id);\n   CREATE INDEX IF NOT EXISTS idx_discussions_mr_id ON discussions(merge_request_id);\n\n4. Immutable author identity column (for NOTE-0D):\n   ALTER TABLE notes ADD COLUMN author_id INTEGER;\n   CREATE INDEX IF NOT EXISTS idx_notes_author_id ON notes(author_id) WHERE author_id IS NOT NULL;\n\nRegister in src/core/db.rs MIGRATIONS array as (\"022\", include_str!(\"../../migrations/022_notes_query_index.sql\")). Insert BEFORE the existing (\"023\", ...) entry. LATEST_SCHEMA_VERSION auto-increments via MIGRATIONS.len().\n\n## Files\n- CREATE: migrations/022_notes_query_index.sql\n- MODIFY: src/core/db.rs (add (\"022\", include_str!(...)) to MIGRATIONS array, insert at position before \"023\" entry around line 73)\n\n## TDD Anchor\nRED: test_migration_022_indexes_exist — run_migrations on in-memory DB, verify 4 new indexes exist in sqlite_master.\nGREEN: Create migration file with all CREATE INDEX statements.\nVERIFY: cargo test migration_022 -- --nocapture\n\n## Acceptance Criteria\n- [ ] Migration 022 creates idx_notes_user_created partial index\n- [ ] Migration 022 creates idx_notes_project_created partial index\n- [ ] Migration 022 creates idx_discussions_issue_id (or is no-op if exists)\n- [ ] Migration 022 creates idx_discussions_mr_id (or is no-op if exists)\n- [ ] Migration 022 adds author_id INTEGER column to notes\n- [ ] Migration 022 creates idx_notes_author_id partial index\n- [ ] MIGRATIONS array in db.rs includes (\"022\", ...) before (\"023\", ...)\n- [ ] Existing tests still pass with new migration\n- [ ] Test verifying all indexes exist passes\n\n## Edge Cases\n- Partial indexes exclude system notes (is_system = 0) — filters 30-50% of notes\n- COLLATE NOCASE on author_username matches the query's case-insensitive comparison\n- author_id is nullable (existing notes won't have it until re-synced)\n- IF NOT EXISTS on all CREATE INDEX statements makes migration idempotent","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T17:01:18.127989Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:13:15.435624Z","closed_at":"2026-02-12T18:13:15.435576Z","close_reason":"Implemented by agent swarm","compaction_level":0,"original_size":0,"labels":["per-note","search"],"dependencies":[{"issue_id":"bd-296a","depends_on_id":"bd-jbfw","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-29qw","title":"Implement Timeline screen (state + action + view)","description":"## Background\nThe Timeline screen renders a chronological event stream from the 5-stage timeline pipeline (SEED -> HYDRATE -> EXPAND -> COLLECT -> RENDER). Events are color-coded by type and can be scoped to an entity, author, or time range.\n\n## Approach\nState (state/timeline.rs):\n- TimelineState: events (Vec<TimelineEvent>), query (String), query_input (TextInput), query_focused (bool), selected_index (usize), scroll_offset (usize), scope (TimelineScope)\n- TimelineScope: All, Entity(EntityKey), Author(String), DateRange(DateTime, DateTime)\n\nAction (action.rs):\n- fetch_timeline(conn, scope, limit, clock) -> Vec<TimelineEvent>: runs the timeline pipeline against DB\n\nView (view/timeline.rs):\n- Vertical event stream with timestamp gutter on the left\n- Color-coded event types: Created(green), Updated(yellow), Closed(red), Merged(purple), Commented(blue), Labeled(cyan), Milestoned(orange)\n- Each event: timestamp | entity ref | event description\n- Entity refs navigable via Enter\n- Query bar for filtering by text or entity\n- Keyboard: j/k scroll, Enter navigate to entity, / focus query, g+g top\n\n## Acceptance Criteria\n- [ ] Timeline renders chronological event stream\n- [ ] Events color-coded by type\n- [ ] Entity references navigable\n- [ ] Scope filters: all, per-entity, per-author, date range\n- [ ] Query bar filters events\n- [ ] Keyboard navigation works (j/k/Enter/Esc)\n- [ ] Timestamps use injected Clock\n\n## Files\n- MODIFY: crates/lore-tui/src/state/timeline.rs (expand from stub)\n- MODIFY: crates/lore-tui/src/action.rs (add fetch_timeline)\n- CREATE: crates/lore-tui/src/view/timeline.rs\n\n## TDD Anchor\nRED: Write test_fetch_timeline_scoped that creates issues with events, calls fetch_timeline with Entity scope, asserts only that entity's events returned.\nGREEN: Implement fetch_timeline with scope filtering.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_fetch_timeline\n\n## Edge Cases\n- Timeline pipeline may not be fully implemented in core yet — degrade gracefully if SEED/HYDRATE/EXPAND stages are not available, fall back to raw events\n- Very long timelines: VirtualizedList or lazy loading for performance\n- Events with identical timestamps: stable sort by entity type, then iid\n\n## Dependency Context\nUses timeline pipeline types from src/core/timeline.rs if available.\nUses Clock for timestamp rendering from \"Implement Clock trait\" task.\nUses EntityKey navigation from \"Implement core types\" task.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T17:01:05.605968Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:22.463694Z","deleted_at":"2026-03-11T18:34:22.463688Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-2ac","title":"Create migration 009_embeddings.sql","description":"## Background\nMigration 009 creates the embedding storage layer for Gate B. It introduces a sqlite-vec vec0 virtual table for vector search and an embedding_metadata table for tracking provenance per chunk. Unlike migrations 007-008, this migration REQUIRES sqlite-vec to be loaded before it can be applied. The migration runner in db.rs must load the sqlite-vec extension first.\n\n## Approach\nCreate `migrations/009_embeddings.sql` per PRD Section 1.3.\n\n**Tables:**\n1. `embeddings` — vec0 virtual table with `embedding float[768]`\n2. `embedding_metadata` — tracks per-chunk provenance with composite PK (document_id, chunk_index)\n3. Orphan cleanup trigger: `documents_embeddings_ad` — deletes ALL chunk embeddings when a document is deleted using range deletion `[doc_id * 1000, (doc_id + 1) * 1000)`\n\n**Critical: sqlite-vec loading:**\nThe migration runner in `src/core/db.rs` must load sqlite-vec BEFORE applying any migrations. This means adding extension loading to the `create_connection()` or `run_migrations()` function. sqlite-vec is loaded via:\n```rust\nconn.load_extension_enable()?;\nconn.load_extension(\"vec0\", None)?;  // or platform-specific path\nconn.load_extension_disable()?;\n```\n\nRegister migration 9 in `src/core/db.rs` MIGRATIONS array.\n\n## Acceptance Criteria\n- [ ] `migrations/009_embeddings.sql` file exists\n- [ ] `embeddings` vec0 virtual table created with `embedding float[768]`\n- [ ] `embedding_metadata` table has composite PK (document_id, chunk_index)\n- [ ] `embedding_metadata.document_id` has FK to documents(id) ON DELETE CASCADE\n- [ ] Error tracking fields: last_error, attempt_count, last_attempt_at\n- [ ] Orphan cleanup trigger: deletes embeddings WHERE rowid in [doc_id*1000, (doc_id+1)*1000)\n- [ ] Index on embedding_metadata(last_error) WHERE last_error IS NOT NULL\n- [ ] Index on embedding_metadata(document_id)\n- [ ] Schema version 9 recorded\n- [ ] Migration runner loads sqlite-vec before applying migrations\n- [ ] `cargo build` succeeds\n\n## Files\n- `migrations/009_embeddings.sql` — new file (copy exact SQL from PRD Section 1.3)\n- `src/core/db.rs` — add migration 9 to MIGRATIONS array; add sqlite-vec extension loading\n\n## TDD Loop\nRED: Register migration in db.rs, `cargo test migration_tests` fails\nGREEN: Create SQL file + add extension loading\nVERIFY: `cargo test migration_tests && cargo build`\n\n## Edge Cases\n- sqlite-vec not installed: migration fails with clear error (not a silent skip)\n- Migration applied without sqlite-vec loaded: `CREATE VIRTUAL TABLE` fails with \"no such module: vec0\"\n- Documents deleted before embeddings: trigger fires but vec0 DELETE on empty range is safe\n- vec0 doesn't support FK cascades: that's why we need the explicit trigger","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:26:33.958178Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:22:26.478290Z","closed_at":"2026-01-30T17:22:26.478229Z","close_reason":"Completed: migration 009_embeddings.sql with vec0 table, embedding_metadata with composite PK, orphan cleanup trigger, registered in db.rs","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2ac","depends_on_id":"bd-221","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2am8","title":"OBSERV: Enhance sync-status to show recent runs with metrics","description":"## Background\nsync_status currently queries sync_runs but always gets zero rows (nothing writes to the table). After bd-23a4 wires up SyncRunRecorder, rows will exist. This bead enhances the display to show recent runs with metrics.\n\n## Approach\n### src/cli/commands/sync_status.rs\n\n1. Change get_last_sync_run() (line ~66) to get_recent_sync_runs() returning last N:\n```rust\nfn get_recent_sync_runs(conn: &Connection, limit: usize) -> Result<Vec<SyncRunInfo>> {\n    let mut stmt = conn.prepare(\n        \"SELECT id, started_at, finished_at, status, command, error,\n                run_id, total_items_processed, total_errors, metrics_json\n         FROM sync_runs\n         ORDER BY started_at DESC\n         LIMIT ?1\",\n    )?;\n    // ... map rows to SyncRunInfo\n}\n```\n\n2. Extend SyncRunInfo to include new fields:\n```rust\npub struct SyncRunInfo {\n    pub id: i64,\n    pub started_at: i64,\n    pub finished_at: Option<i64>,\n    pub status: String,\n    pub command: String,\n    pub error: Option<String>,\n    pub run_id: Option<String>,          // NEW\n    pub total_items_processed: i64,       // NEW\n    pub total_errors: i64,                // NEW\n    pub stages: Option<Vec<StageTiming>>, // NEW: parsed from metrics_json\n}\n```\n\n3. Parse metrics_json into Vec<StageTiming>:\n```rust\nlet stages: Option<Vec<StageTiming>> = row.get::<_, Option<String>>(9)?\n    .and_then(|json| serde_json::from_str(&json).ok());\n```\n\n4. Interactive output (new format):\n```\nRecent sync runs:\n  Run a1b2c3 | 2026-02-04 14:32 | 45.2s | 235 items | 1 error\n  Run d4e5f6 | 2026-02-03 14:30 | 38.1s | 220 items | 0 errors\n  Run g7h8i9 | 2026-02-02 14:29 | 42.7s | 228 items | 0 errors\n```\n\n5. Robot JSON output: runs array with stages parsed from metrics_json:\n```json\n{\n    \"ok\": true,\n    \"data\": {\n        \"runs\": [{ \"run_id\": \"...\", \"stages\": [...] }],\n        \"cursors\": [...],\n        \"summary\": {...}\n    }\n}\n```\n\n6. Add --run <run_id> flag to sync-status subcommand for single-run detail view (shows full stage breakdown).\n\n## Acceptance Criteria\n- [ ] lore sync-status shows last 10 runs (not just 1) with run_id, duration, items, errors\n- [ ] lore --robot sync-status JSON includes runs array with stages parsed from metrics_json\n- [ ] lore sync-status --run a1b2c3 shows single run detail with full stage breakdown\n- [ ] When no runs exist, shows appropriate \"No sync runs recorded\" message\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/cli/commands/sync_status.rs (rewrite query, extend structs, update display)\n\n## TDD Loop\nRED:\n  - test_sync_status_shows_runs: insert 3 sync_runs rows, call print function, assert all 3 shown\n  - test_sync_status_json_includes_stages: insert row with metrics_json, verify robot JSON has stages\n  - test_sync_status_empty: no rows, verify graceful message\nGREEN: Rewrite get_last_sync_run -> get_recent_sync_runs, extend SyncRunInfo, update output\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- metrics_json is NULL (old rows or failed runs): stages field is null/empty in output\n- metrics_json is malformed: serde_json::from_str fails silently (.ok()), stages is None\n- Duration calculation: finished_at - started_at in ms. If finished_at is NULL (running), show \"in progress\"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T15:54:51.467705Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:43:07.306504Z","closed_at":"2026-02-04T17:43:07.306425Z","close_reason":"Enhanced sync-status: shows last 10 runs with run_id, duration, items, errors, parsed stages; JSON includes full stages array","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-2am8","depends_on_id":"bd-23a4","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-2am8","depends_on_id":"bd-3pz","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2ao4","title":"Add migration for dual-path and reviewer participation indexes","description":"## Background\nThe restructured expert SQL (bd-1hoq) uses UNION ALL + dedup to match both old_path and new_path columns. Without indexes on old_path columns, these branches would force table scans. The reviewer_participation CTE joins notes -> discussions and needs index coverage on discussion_id. Path resolution probes (build_path_query, suffix_probe) need their own old_path index optimized for existence checks.\n\n## Approach\nCreate migration file migrations/026_scoring_indexes.sql (latest is 025_note_dirty_backfill.sql). Add entry to MIGRATIONS array in db.rs. LATEST_SCHEMA_VERSION auto-increments via MIGRATIONS.len().\n\n### Migration SQL (5 indexes):\n```sql\n-- 1. Support old_path leg of matched_notes CTE (scoring queries)\nCREATE INDEX IF NOT EXISTS idx_notes_old_path_author\n  ON notes(position_old_path, author_username, created_at)\n  WHERE note_type = 'DiffNote' AND is_system = 0 AND position_old_path IS NOT NULL;\n\n-- 2. Support old_path leg of matched_file_changes CTE\nCREATE INDEX IF NOT EXISTS idx_mfc_old_path_project_mr\n  ON mr_file_changes(old_path, project_id, merge_request_id)\n  WHERE old_path IS NOT NULL;\n\n-- 3. Ensure new_path index parity for matched_file_changes CTE\nCREATE INDEX IF NOT EXISTS idx_mfc_new_path_project_mr\n  ON mr_file_changes(new_path, project_id, merge_request_id);\n\n-- 4. Support reviewer_participation CTE: notes -> discussions join\nCREATE INDEX IF NOT EXISTS idx_notes_diffnote_discussion_author\n  ON notes(discussion_id, author_username, created_at)\n  WHERE note_type = 'DiffNote' AND is_system = 0;\n\n-- 5. Support path resolution probes on old_path (build_path_query + suffix_probe)\nCREATE INDEX IF NOT EXISTS idx_notes_old_path_project_created\n  ON notes(position_old_path, project_id, created_at)\n  WHERE note_type = 'DiffNote' AND is_system = 0 AND position_old_path IS NOT NULL;\n```\n\n### MIGRATIONS array addition (src/core/db.rs, after the (\"025\", ...) entry):\n```rust\n(\"026\", include_str!(\"../../migrations/026_scoring_indexes.sql\")),\n```\n\n## Acceptance Criteria\n- [ ] Migration file at migrations/026_scoring_indexes.sql with 5 CREATE INDEX statements\n- [ ] MIGRATIONS array has (\"026\", include_str!(\"../../migrations/026_scoring_indexes.sql\"))\n- [ ] LATEST_SCHEMA_VERSION auto-increments to 26 (MIGRATIONS.len())\n- [ ] cargo test (migration tests pass on both fresh and migrated :memory: DBs)\n- [ ] Existing indexes unaffected\n\n## Files\n- CREATE: migrations/026_scoring_indexes.sql\n- MODIFY: src/core/db.rs (MIGRATIONS array — add entry after (\"025\", ...) at end of array)\n\n## TDD Loop\nRED: cargo test should fail if migration not applied (schema version mismatch)\nGREEN: Add migration file + MIGRATIONS entry\nVERIFY: cargo test -p lore\n\n## Edge Cases\n- Use CREATE INDEX IF NOT EXISTS (idempotent)\n- Partial indexes with WHERE clauses keep size minimal\n- position_old_path and old_path can be NULL (handled by WHERE clause)\n- idx_notes_old_path_project_created vs idx_notes_old_path_author: former for probes (no author constraint), latter for scoring (author in covering index)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-09T16:59:30.746899Z","created_by":"tayloreernisse","updated_at":"2026-02-12T20:43:04.407407Z","closed_at":"2026-02-12T20:43:04.407369Z","close_reason":"Implemented by time-decay swarm: 3 agents, 12 tasks, 621 tests passing, all quality gates green","compaction_level":0,"original_size":0,"labels":["db","scoring"]}
{"id":"bd-2as","title":"[CP1] Epic: Issue Ingestion","description":"Ingest all issues, labels, and issue discussions from configured GitLab repositories with resumable cursor-based incremental sync. This establishes the core data ingestion pattern reused for MRs in CP2.\n\nSuccess Criteria:\n- gi ingest --type=issues fetches all issues (count matches GitLab UI)\n- Labels extracted from issue payloads\n- Issue discussions fetched per-issue\n- Cursor-based sync is resumable\n- Sync tracking records all runs\n- Single-flight lock prevents concurrent runs\n\nReference: docs/prd/checkpoint-1.md","status":"tombstone","priority":1,"issue_type":"task","created_at":"2026-01-25T15:18:44.062057Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.155746Z","closed_at":"2026-01-25T15:21:35.155746Z","deleted_at":"2026-01-25T15:21:35.155744Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-2b28","title":"NOTE-0C: Sweep safety guard for partial fetch protection","description":"## Background\nThe sweep pattern (delete notes where last_seen_at < run_seen_at) is correct only when a discussion's notes were fully fetched. If a page fails mid-fetch, the current logic would incorrectly delete valid notes that weren't seen during the incomplete fetch. Especially dangerous for long threads spanning multiple API pages.\n\n## Approach\nAdd a fetch_complete: bool parameter to discussion ingestion functions. Only run sweep when fetch completed successfully:\n\nif fetch_complete {\n    sweep_stale_issue_notes(&tx, local_discussion_id, last_seen_at)?;\n} else {\n    tracing::warn!(discussion_id = local_discussion_id, \"Skipping stale note sweep due to partial/incomplete fetch\");\n}\n\nDetermining fetch_complete: Look at the existing pagination_error pattern in src/ingestion/discussions.rs lines 148-154. When pagination_error is None (all pages fetched successfully), fetch_complete = true. When pagination_error is Some (network error, rate limit, interruption), fetch_complete = false. The MR path has a similar pattern in src/ingestion/mr_discussions.rs — search for where sweep_stale_discussions (line 539) and sweep_stale_notes (line 551) are called to find the equivalent guard.\n\nThe fetch_complete flag should be threaded from the outer discussion-fetch loop into the per-discussion upsert transaction, NOT as a parameter on sweep itself (sweep always sweeps — the caller decides whether to call it).\n\n## Files\n- MODIFY: src/ingestion/discussions.rs (guard sweep call with fetch_complete, lines 132-146)\n- MODIFY: src/ingestion/mr_discussions.rs (guard sweep call, near line 551 call site)\n\n## TDD Anchor\nRED: test_partial_fetch_does_not_sweep_notes — 5 notes in DB, partial fetch returns 2, assert all 5 still exist.\nGREEN: Add fetch_complete guard around sweep call.\nVERIFY: cargo test partial_fetch_does_not_sweep -- --nocapture\nTests: test_complete_fetch_runs_sweep_normally, test_partial_fetch_then_complete_fetch_cleans_up\n\n## Acceptance Criteria\n- [ ] Sweep only runs when fetch_complete = true\n- [ ] Partial fetch logs a warning (tracing::warn!) but preserves all notes\n- [ ] Second complete fetch correctly sweeps notes deleted on GitLab\n- [ ] Both issue and MR discussion paths support fetch_complete\n- [ ] All 3 tests pass\n\n## Dependency Context\n- Depends on NOTE-0A (bd-3bpk): modifies the sweep call site from NOTE-0A. The sweep functions must exist before this guard can wrap them.\n\n## Edge Cases\n- Rate limit mid-page: pagination_error triggers partial fetch — sweep must be skipped\n- Discussion with 1 page of notes: always fully fetched if no error, sweep runs normally\n- Empty discussion (0 notes returned): still counts as complete fetch — sweep is a no-op anyway","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T16:59:44.290790Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:13:15.172004Z","closed_at":"2026-02-12T18:13:15.171952Z","close_reason":"Implemented by agent swarm","compaction_level":0,"original_size":0,"labels":["per-note","search"]}
{"id":"bd-2bu","title":"[CP1] GitLab types for issues, discussions, notes","description":"Add Rust types to src/gitlab/types.rs for GitLab API responses.\n\n## Types to Add\n\n### GitLabIssue\n- id: i64 (GitLab global ID)\n- iid: i64 (project-scoped issue number)\n- project_id: i64\n- title: String\n- description: Option<String>\n- state: String (\"opened\" | \"closed\")\n- created_at, updated_at: String (ISO 8601)\n- closed_at: Option<String>\n- author: GitLabAuthor\n- labels: Vec<String> (array of label names - CP1 canonical)\n- web_url: String\nNOTE: labels_details intentionally NOT modeled - varies across GitLab versions\n\n### GitLabAuthor\n- id: i64\n- username: String\n- name: String\n\n### GitLabDiscussion\n- id: String (like \"6a9c1750b37d...\")\n- individual_note: bool\n- notes: Vec<GitLabNote>\n\n### GitLabNote\n- id: i64\n- note_type: Option<String> (\"DiscussionNote\" | \"DiffNote\" | null)\n- body: String\n- author: GitLabAuthor\n- created_at, updated_at: String (ISO 8601)\n- system: bool\n- resolvable: bool (default false)\n- resolved: bool (default false)\n- resolved_by: Option<GitLabAuthor>\n- resolved_at: Option<String>\n- position: Option<GitLabNotePosition>\n\n### GitLabNotePosition\n- old_path, new_path: Option<String>\n- old_line, new_line: Option<i32>\n\nFiles: src/gitlab/types.rs\nTests: Test deserialization with fixtures\nDone when: Types compile and deserialize sample API responses correctly","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:42:46.922805Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.710057Z","closed_at":"2026-01-25T17:02:01.710057Z","deleted_at":"2026-01-25T17:02:01.710051Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-2cu","title":"[CP1] Discussion ingestion module","description":"Fetch and store discussions/notes for each issue.\n\n## Module\nsrc/ingestion/discussions.rs\n\n## Key Structs\n\n### IngestDiscussionsResult\n- discussions_fetched: usize\n- discussions_upserted: usize\n- notes_upserted: usize\n- system_notes_count: usize\n\n## Main Function\npub async fn ingest_issue_discussions(\n    conn, client, config,\n    project_id, gitlab_project_id,\n    issue_iid, local_issue_id, issue_updated_at\n) -> Result<IngestDiscussionsResult>\n\n## Logic\n1. Paginate through all discussions for given issue\n2. For each discussion:\n   - Begin transaction\n   - Store raw payload (compressed)\n   - Transform and upsert discussion record with correct issue FK\n   - Get local discussion ID\n   - Transform notes from discussion\n   - For each note:\n     - Store raw payload\n     - Upsert note with discussion_id FK\n     - Count system notes\n   - Commit transaction\n3. After all discussions synced: mark_discussions_synced(conn, local_issue_id, issue_updated_at)\n   - UPDATE issues SET discussions_synced_for_updated_at = ? WHERE id = ?\n\n## Invariant\nA rerun MUST NOT refetch discussions for issues whose updated_at has not advanced, even with cursor rewind. The discussions_synced_for_updated_at watermark ensures this.\n\n## Helper Functions\n- upsert_discussion(conn, discussion, payload_id)\n- get_local_discussion_id(conn, project_id, gitlab_id) -> i64\n- upsert_note(conn, discussion_id, note, payload_id)\n- mark_discussions_synced(conn, issue_id, issue_updated_at)\n\nFiles: src/ingestion/discussions.rs\nTests: tests/discussion_watermark_tests.rs\nDone when: Discussions and notes populated with correct FKs, watermark prevents redundant refetch","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T16:57:36.703237Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.827880Z","closed_at":"2026-01-25T17:02:01.827880Z","deleted_at":"2026-01-25T17:02:01.827876Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-2dk","title":"Implement project resolution for --project filter","description":"## Background\nThe --project filter on search (and other commands) accepts a string that must be resolved to a project_id. Users may type the full path, a partial path, or just the project name. The resolution logic provides cascading match with helpful error messages when ambiguous. This improves ergonomics for multi-project installations.\n\n## Approach\nImplement project resolution function (location TBD — likely `src/core/project.rs` or inline in search filters):\n\n```rust\n/// Resolve a project string to a project_id using cascading match:\n/// 1. Exact match on path_with_namespace\n/// 2. Case-insensitive exact match\n/// 3. Suffix match (only if unambiguous)\n/// 4. Error with available projects list\npub fn resolve_project(conn: &Connection, project_str: &str) -> Result<i64>\n```\n\n**SQL queries:**\n```sql\n-- Step 1: exact match\nSELECT id FROM projects WHERE path_with_namespace = ?\n\n-- Step 2: case-insensitive\nSELECT id FROM projects WHERE LOWER(path_with_namespace) = LOWER(?)\n\n-- Step 3: suffix match\nSELECT id, path_with_namespace FROM projects\nWHERE path_with_namespace LIKE '%/' || ?\n   OR path_with_namespace = ?\n\n-- Step 4: list all for error message\nSELECT path_with_namespace FROM projects ORDER BY path_with_namespace\n```\n\n**Error format:**\n```\nError: Project 'auth-service' not found.\n\nAvailable projects:\n  backend/auth-service\n  frontend/auth-service-ui\n  infra/auth-proxy\n\nHint: Use the full path, e.g., --project=backend/auth-service\n```\n\nUses `LoreError::Ambiguous` variant for multiple suffix matches.\n\n## Acceptance Criteria\n- [ ] Exact match: \"group/project\" resolves correctly\n- [ ] Case-insensitive: \"Group/Project\" resolves to \"group/project\"\n- [ ] Suffix match: \"project-name\" resolves when only one \"*/project-name\" exists\n- [ ] Ambiguous suffix: error lists matching projects with hint\n- [ ] No match: error lists all available projects with hint\n- [ ] Empty projects table: clear error message\n- [ ] `cargo test project_resolution` passes\n\n## Files\n- `src/core/project.rs` — new file (or add to existing module)\n- `src/core/mod.rs` — add `pub mod project;`\n\n## TDD Loop\nRED: Tests:\n- `test_exact_match` — full path resolves\n- `test_case_insensitive` — mixed case resolves\n- `test_suffix_unambiguous` — short name resolves when unique\n- `test_suffix_ambiguous` — error with list when multiple match\n- `test_no_match` — error with available projects\nGREEN: Implement resolve_project\nVERIFY: `cargo test project_resolution`\n\n## Edge Cases\n- Project path containing special LIKE characters (%, _): unlikely but escape for safety\n- Single project in DB: suffix always unambiguous\n- Project path with multiple slashes: \"org/group/project\" — suffix match on \"project\" works","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-30T15:26:13.076571Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:39:17.197735Z","closed_at":"2026-01-30T17:39:17.197552Z","close_reason":"Implemented resolve_project() with cascading match (exact, CI, suffix) + 6 tests","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2dk","depends_on_id":"bd-3q2","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2dlt","title":"Implement GraphQL client with partial-error handling","description":"## Background\nGitLab's GraphQL endpoint (/api/graphql) uses different auth than REST (Bearer token, not PRIVATE-TOKEN). We need a minimal GraphQL client that handles the GitLab-specific error codes and partial-data responses per GraphQL spec. The client returns a GraphqlQueryResult struct that propagates partial-error metadata end-to-end.\n\n## Approach\nCreate a new file src/gitlab/graphql.rs with GraphqlClient (uses reqwest). Add httpdate crate for Retry-After HTTP-date parsing. Wire into the module tree. Factory on GitLabClient keeps token encapsulated.\n\n## Files\n- src/gitlab/graphql.rs (NEW) — GraphqlClient struct, GraphqlQueryResult, ansi256_from_rgb\n- src/gitlab/mod.rs (add pub mod graphql;)\n- src/gitlab/client.rs (add graphql_client() factory method)\n- Cargo.toml (add httpdate dependency)\n\n## Implementation\n\nGraphqlClient struct:\n  Fields: http (reqwest::Client with 30s timeout), base_url (String), token (String)\n  Constructor: new(base_url, token) — trims trailing slash from base_url\n  \nquery() method:\n  - POST to {base_url}/api/graphql\n  - Headers: Authorization: Bearer {token}, Content-Type: application/json\n  - Body: {\"query\": \"...\", \"variables\": {...}}\n  - Returns Result<GraphqlQueryResult>\n\nGraphqlQueryResult struct (pub):\n  data: serde_json::Value\n  had_partial_errors: bool\n  first_partial_error: Option<String>\n\nHTTP status mapping:\n  401 | 403 -> LoreError::GitLabAuthFailed\n  404 -> LoreError::GitLabNotFound { resource: \"GraphQL endpoint\" }\n  429 -> LoreError::GitLabRateLimited { retry_after } (parse Retry-After: try u64 first, then httpdate::parse_http_date, fallback 60)\n  Other non-success -> LoreError::Other\n\nGraphQL-level error handling:\n  errors array present + data absent/null -> Err(LoreError::Other(\"GraphQL error: {first_msg}\"))\n  errors array present + data present -> Ok(GraphqlQueryResult { data, had_partial_errors: true, first_partial_error: Some(first_msg) })\n  No errors + data present -> Ok(GraphqlQueryResult { data, had_partial_errors: false, first_partial_error: None })\n  No errors + no data -> Err(LoreError::Other(\"missing 'data' field\"))\n\nansi256_from_rgb(r, g, b) -> u8:\n  Maps RGB to nearest ANSI 256-color index using 6x6x6 cube (indices 16-231).\n  MUST be placed BEFORE #[cfg(test)] module (clippy::items_after_test_module).\n\nFactory in src/gitlab/client.rs:\n  pub fn graphql_client(&self) -> crate::gitlab::graphql::GraphqlClient {\n      crate::gitlab::graphql::GraphqlClient::new(&self.base_url, &self.token)\n  }\n\n## Acceptance Criteria\n- [ ] query() sends POST with Bearer auth header\n- [ ] Success: returns GraphqlQueryResult { data, had_partial_errors: false }\n- [ ] Errors-only (no data): returns Err with first error message\n- [ ] Partial data + errors: returns Ok with had_partial_errors: true\n- [ ] 401 -> GitLabAuthFailed\n- [ ] 403 -> GitLabAuthFailed\n- [ ] 404 -> GitLabNotFound\n- [ ] 429 -> GitLabRateLimited (parses Retry-After delta-seconds and HTTP-date, fallback 60)\n- [ ] ansi256_from_rgb: (0,0,0)->16, (255,255,255)->231\n- [ ] cargo check --all-targets passes\n\n## TDD Loop\nRED: test_graphql_query_success, test_graphql_query_with_errors_no_data, test_graphql_auth_uses_bearer, test_graphql_401_maps_to_auth_failed, test_graphql_403_maps_to_auth_failed, test_graphql_404_maps_to_not_found, test_graphql_partial_data_with_errors_returns_data, test_retry_after_http_date_format, test_retry_after_invalid_falls_back_to_60, test_ansi256_from_rgb\n  Tests use wiremock or similar mock HTTP server\nGREEN: Implement GraphqlClient, add httpdate to Cargo.toml\nVERIFY: cargo test graphql && cargo test ansi256\n\n## Edge Cases\n- Use r##\"...\"## in tests containing \"#1f75cb\" hex colors (# breaks r#\"...\"#)\n- LoreError::GitLabRateLimited uses u64 not Option<u64> — use .unwrap_or(60)\n- httpdate::parse_http_date returns SystemTime — compute duration_since(now) for delta\n- GraphqlQueryResult is NOT Clone — tests must check fields individually","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-11T06:41:52.833151Z","created_by":"tayloreernisse","updated_at":"2026-02-11T07:21:33.417835Z","closed_at":"2026-02-11T07:21:33.417793Z","close_reason":"Implemented by agent swarm — all quality gates pass (595 tests, 0 failures)","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2dlt","depends_on_id":"bd-1v8t","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-2dlt","depends_on_id":"bd-2y79","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2e8","title":"Add fetchResourceEvents config flag to SyncConfig","description":"## Background\nEvent fetching should be opt-in (default true) so users who don't need temporal queries skip 3 extra API calls per entity. This follows the existing SyncConfig pattern with serde defaults and camelCase JSON aliases.\n\n## Approach\nAdd to SyncConfig in src/core/config.rs:\n```rust\n#[serde(rename = \"fetchResourceEvents\", default = \"default_true\")]\npub fetch_resource_events: bool,\n```\n\nAdd default function (if not already present):\n```rust\nfn default_true() -> bool { true }\n```\n\nUpdate Default impl for SyncConfig to include `fetch_resource_events: true`.\n\nAdd --no-events flag to sync command in src/cli/mod.rs (SyncArgs):\n```rust\n/// Skip resource event fetching (overrides config)\n#[arg(long = \"no-events\", help_heading = \"Sync Options\")]\npub no_events: bool,\n```\n\nIn the sync command handler (src/cli/commands/sync.rs), override config when flag is set:\n```rust\nif args.no_events {\n    config.sync.fetch_resource_events = false;\n}\n```\n\n## Acceptance Criteria\n- [ ] SyncConfig deserializes `fetchResourceEvents: false` from JSON config\n- [ ] SyncConfig defaults to `fetch_resource_events: true` when field absent\n- [ ] `--no-events` flag parses correctly in CLI\n- [ ] `--no-events` overrides config to false\n- [ ] `cargo test` passes with no regressions\n\n## Files\n- src/core/config.rs (add field to SyncConfig + default fn + Default impl)\n- src/cli/mod.rs (add --no-events to SyncArgs)\n- src/cli/commands/sync.rs (override config when flag set)\n\n## TDD Loop\nRED: tests/config_tests.rs (or inline in config.rs):\n- `test_sync_config_fetch_resource_events_default_true` - omit field from JSON, verify default\n- `test_sync_config_fetch_resource_events_explicit_false` - set field false, verify parsed\n- `test_sync_config_no_events_flag` - verify CLI arg parsing\n\nGREEN: Add the field, default fn, Default impl update, CLI flag, and override logic\n\nVERIFY: `cargo test config -- --nocapture && cargo build`\n\n## Edge Cases\n- Ensure serde rename matches camelCase convention used by all other SyncConfig fields\n- The default_true fn may already exist for other fields — check before adding duplicate\n- The --no-events flag must NOT be confused with --no-X negation flags already in CLI (check mod.rs for conflicts)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:31:24.006037Z","created_by":"tayloreernisse","updated_at":"2026-02-03T16:10:20.311986Z","closed_at":"2026-02-03T16:10:20.311939Z","close_reason":"Completed: Added fetch_resource_events bool to SyncConfig with serde rename, default_true, --no-events CLI flag, and config override in sync handler","compaction_level":0,"original_size":0,"labels":["config","gate-1","phase-b"],"dependencies":[{"issue_id":"bd-2e8","depends_on_id":"bd-2zl","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2emv","title":"FrankenTUI integration proof + terminal compat smoke test","description":"## Background\nThis is the critical validation that FrankenTUI works with our setup. A minimal Model trait implementation must compile, render a frame, and handle basic input. Terminal compatibility must be verified in iTerm2 and tmux. This proves the toolchain gate before investing in the full implementation.\n\n## Approach\nIn crates/lore-tui/src/app.rs, implement a minimal LoreApp that:\n- implements ftui_runtime::program::Model with type Message = Msg\n- init() returns Cmd::none()\n- update() handles Msg::Quit to return None (exit) and ignores everything else\n- view() renders a simple \"lore TUI\" text centered on screen\n- subscriptions() returns empty vec\n\nAdd a smoke test binary or integration test that:\n- Creates a TerminalSession with ftui test harness\n- Verifies Model::view() produces non-empty output\n- Verifies resize events are handled without panic\n- Tests render in both fullscreen and inline(12) modes\n\nTerminal compat: manually verify ftui demo-showcase renders correctly in iTerm2 and tmux (document results in test notes).\n\n## Acceptance Criteria\n- [ ] LoreApp implements Model trait with Msg as message type\n- [ ] App::fullscreen(lore_app).run() compiles (even if not runnable in CI without a TTY)\n- [ ] App::inline(lore_app, 12).run() compiles\n- [ ] Panic hook installed: terminal restored on crash (crossterm disable_raw_mode + LeaveAlternateScreen)\n- [ ] Crash report written to ~/.local/share/lore/crash-{timestamp}.log with redacted sensitive data\n- [ ] Crash file retention: max 20 files, oldest deleted\n- [ ] ftui demo-showcase renders correctly in iTerm2 (documented)\n- [ ] ftui demo-showcase renders correctly in tmux (documented)\n- [ ] Binary size increase < 5MB over current lore binary\n\n## Files\n- CREATE: crates/lore-tui/src/app.rs (minimal Model impl)\n- MODIFY: crates/lore-tui/src/lib.rs (add install_panic_hook_for_tui, crash report logic)\n- CREATE: crates/lore-tui/src/crash_context.rs (ring buffer stub for crash diagnostics)\n\n## TDD Anchor\nRED: Write test_app_model_compiles that creates LoreApp and calls init(), verifying it returns without error.\nGREEN: Implement minimal LoreApp struct with Model trait.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_app_model\n\n## Edge Cases\n- CI environments have no TTY — tests must use ftui test harness, not actual terminal\n- tmux may not support all ANSI features — FrankenTUI's BOCPD resize coalescing must be verified\n- Panic hook must handle double-panic gracefully (don't panic inside the panic hook)\n- Crash context ring buffer must be lock-free readable from panic hook (signal safety)\n\n## Dependency Context\nUses crate scaffold (Cargo.toml, rust-toolchain.toml) from \"Create lore-tui crate scaffold\" task.\nUses Msg enum and Screen type from \"Implement core types\" task.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T16:54:52.087021Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:24.864252Z","deleted_at":"2026-03-11T18:34:24.864245Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-2ez","title":"Add 'lore count references' command","description":"## Background\n\nThe count command currently supports issues, mrs, discussions, notes, and events. This adds 'references' as a new entity type, showing cross-reference totals and breakdowns by reference_type and source_method.\n\n## Codebase Context\n\n- entity_references table (migration 011) with:\n  - reference_type CHECK: 'closes' | 'mentioned' | 'related'\n  - source_method CHECK: 'api' | 'note_parse' | 'description_parse'\n  - target_entity_id: NULL for unresolved cross-project refs\n- Count command pattern in src/cli/commands/count.rs: run_count() returns CountResult, handle_count formats output\n- events count already implemented as a special case: run_count_events() in main.rs (line ~829)\n- count.rs has value_parser list for entity arg\n- 26 migrations exist (001-026). entity_references was introduced in migration 011.\n\n## Approach\n\n### 1. Add to CountArgs value_parser in `src/cli/mod.rs`:\n```rust\n#[arg(value_parser = [\"issues\", \"mrs\", \"discussions\", \"notes\", \"events\", \"references\"])]\npub entity: String,\n```\n\n### 2. Add types and query in `src/cli/commands/count.rs`:\n\n```rust\npub struct ReferenceCountResult {\n    pub total: i64,\n    pub by_type: HashMap<String, i64>,     // closes, mentioned, related\n    pub by_method: HashMap<String, i64>,   // api, note_parse, description_parse\n    pub unresolved: i64,\n}\n```\n\n### 3. SQL (single conditional aggregate query — no N+1):\n```sql\nSELECT\n    COUNT(*) as total,\n    COALESCE(SUM(CASE WHEN reference_type = 'closes' THEN 1 ELSE 0 END), 0) as closes,\n    COALESCE(SUM(CASE WHEN reference_type = 'mentioned' THEN 1 ELSE 0 END), 0) as mentioned,\n    COALESCE(SUM(CASE WHEN reference_type = 'related' THEN 1 ELSE 0 END), 0) as related,\n    COALESCE(SUM(CASE WHEN source_method = 'api' THEN 1 ELSE 0 END), 0) as api,\n    COALESCE(SUM(CASE WHEN source_method = 'note_parse' THEN 1 ELSE 0 END), 0) as note_parse,\n    COALESCE(SUM(CASE WHEN source_method = 'description_parse' THEN 1 ELSE 0 END), 0) as desc_parse,\n    COALESCE(SUM(CASE WHEN target_entity_id IS NULL THEN 1 ELSE 0 END), 0) as unresolved\nFROM entity_references\n```\n\n### 4. Human output:\n```\nReferences: 1,234\n  By type:\n    closes:    456\n    mentioned: 678\n    related:   100\n  By source:\n    api:              234\n    note_parse:       890\n    description_parse: 110\n  Unresolved: 45 (3.6%)\n```\n\n### 5. Robot JSON:\n```json\n{\n  \"ok\": true,\n  \"data\": {\n    \"entity\": \"references\",\n    \"total\": 1234,\n    \"by_type\": { \"closes\": 456, \"mentioned\": 678, \"related\": 100 },\n    \"by_method\": { \"api\": 234, \"note_parse\": 890, \"description_parse\": 110 },\n    \"unresolved\": 45\n  }\n}\n```\n\n### 6. Wire in main.rs handle_count:\nAdd \"references\" branch, similar to the existing \"events\" special case.\n\n## Acceptance Criteria\n\n- [ ] `lore count references` works with human output\n- [ ] `lore --robot count references` returns JSON with {ok, data, meta} envelope\n- [ ] by_type uses codebase values: closes, mentioned, related\n- [ ] by_method uses codebase values: api, note_parse, description_parse\n- [ ] Unresolved = COUNT WHERE target_entity_id IS NULL\n- [ ] Zero references: all counts 0, not error\n- [ ] entity_references table missing (pre-migration-011 schema): graceful error with migration suggestion\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n- [ ] `cargo fmt --check` passes\n\n## Files\n\n- MODIFY: src/cli/mod.rs (add \"references\" to value_parser list)\n- MODIFY: src/cli/commands/count.rs (add count_references() + ReferenceCountResult)\n- MODIFY: src/main.rs (add \"references\" branch in handle_count)\n\n## TDD Anchor\n\nRED: test_count_references_query — in-memory DB with migration 011+, insert 3 entity_references rows (one closes/api, one mentioned/note_parse, one related/api with target_entity_id=NULL), verify all counts.\n\nGREEN: Implement query, result type, output formatters.\n\nVERIFY: cargo test --lib -- count && cargo check --all-targets\n\n## Edge Cases\n\n- entity_references table doesn't exist (pre-migration-011): catch SQL error, return user-friendly message suggesting `lore sync`\n- All references unresolved: unresolved = total, percentage = 100%\n- Division by zero in percentage: guard with `if total > 0`\n- New reference_type/source_method values added in future: they won't appear in breakdown but will be in total — consider logging unknown values\n\n## Dependency Context\n\n- **bd-hu3 / migration 011**: provides the entity_references table with reference_type and source_method CHECK constraints. This bead reads from that table — no writes.\n- **count.rs pattern**: run_count() dispatches to entity-specific queries. events already has a special-case function run_count_events() — follow the same pattern for references.","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-02T22:42:43.780303Z","created_by":"tayloreernisse","updated_at":"2026-02-17T16:52:59.706810Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2ez","depends_on_id":"bd-1se","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-2ez","depends_on_id":"bd-hu3","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2ezb","title":"NOTE-2D: Regenerator and dirty tracking for note documents","description":"## Background\nWire note document extraction into the regenerator and add change-aware dirty marking in the ingestion pipeline. When a note's semantic content changes during upsert, it gets queued for document regeneration.\n\n## Approach\n1. Update regenerate_one() in src/documents/regenerator.rs (line 86-91):\n   Add match arm: SourceType::Note => extract_note_document(conn, source_id)?\n   Add import: use crate::documents::extract_note_document;\n   This replaces the temporary unreachable!() from NOTE-2B.\n\n2. Add change-aware dirty marking in src/ingestion/discussions.rs (in upsert loop modified by NOTE-0A):\n   After each upsert_note_for_issue call:\n   if !note.is_system && outcome.changed_semantics {\n       dirty_tracker::mark_dirty_tx(&tx, SourceType::Note, outcome.local_note_id)?;\n   }\n   Import: use crate::documents::SourceType;\n\n3. Same in src/ingestion/mr_discussions.rs for MR note upserts (after upsert_note call near line 470 area, once NOTE-0A modifies it to return NoteUpsertOutcome).\n\n4. Update test setup helpers:\n   - src/documents/regenerator.rs tests: the setup_db() function creates test tables. Add notes + discussions tables so regenerate_one can be tested with SourceType::Note. Also update the dirty_sources CHECK constraint in test setup to include 'note'.\n   - src/ingestion/dirty_tracker.rs tests: similar test setup_db() update for CHECK constraint.\n\n## Files\n- MODIFY: src/documents/regenerator.rs (add Note match arm at line 90, add import, update test setup_db)\n- MODIFY: src/ingestion/discussions.rs (add dirty marking after upsert loop)\n- MODIFY: src/ingestion/mr_discussions.rs (add dirty marking after upsert)\n- MODIFY: src/ingestion/dirty_tracker.rs (update test setup_db CHECK constraint if present)\n\n## TDD Anchor\nRED: test_regenerate_note_document — create project, issue, discussion, note, mark dirty, call regenerate_dirty_documents, assert document created with source_type='note'.\nGREEN: Add SourceType::Note arm to regenerate_one.\nVERIFY: cargo test regenerate_note_document -- --nocapture\nTests: test_regenerate_note_system_note_deletes (system note in dirty queue → document gets deleted), test_regenerate_note_unchanged (same content hash → no update), test_note_ingestion_idempotent_across_two_syncs (identical re-sync produces no new dirty entries), test_mark_dirty_note_type\n\n## Acceptance Criteria\n- [ ] regenerate_one() handles SourceType::Note via extract_note_document\n- [ ] Changed notes queued as dirty during issue discussion ingestion\n- [ ] Changed notes queued as dirty during MR discussion ingestion\n- [ ] System notes never queued as dirty (is_system guard)\n- [ ] Unchanged notes not re-queued (changed_semantics = false from NOTE-0A)\n- [ ] Second sync of identical data produces no new dirty entries\n- [ ] All 5 tests pass\n\n## Dependency Context\n- Depends on NOTE-0A (bd-3bpk): uses NoteUpsertOutcome.changed_semantics from upsert functions\n- Depends on NOTE-2B (bd-ef0u): SourceType::Note enum variant for dirty marking and match arm\n- Depends on NOTE-2C (bd-18yh): extract_note_document function for the regenerator dispatch\n\n## Edge Cases\n- Note deleted during regeneration: extract_note_document returns None → delete_document called (line 93-95 of regenerator.rs)\n- System note in dirty queue (from manual INSERT): extract returns None → document deleted\n- Concurrent sync + regeneration: dirty_tracker uses ON CONFLICT handling","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T17:02:14.161688Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:13:23.852811Z","closed_at":"2026-02-12T18:13:23.852765Z","close_reason":"Implemented by agent swarm","compaction_level":0,"original_size":0,"labels":["per-note","search"],"dependencies":[{"issue_id":"bd-2ezb","depends_on_id":"bd-22uw","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-2ezb","depends_on_id":"bd-3o0i","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-2ezb","depends_on_id":"bd-9wl5","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2f0","title":"[CP1] gi count issues/discussions/notes commands","description":"## Background\n\nThe `gi count` command provides quick counts of entities in the local database. It supports counting issues, MRs, discussions, and notes, with optional filtering by noteable type. This enables quick validation that sync is working correctly.\n\n## Approach\n\n### Module: src/cli/commands/count.rs\n\n### Clap Definition\n\n```rust\n#[derive(Args)]\npub struct CountArgs {\n    /// Entity type to count\n    #[arg(value_parser = [\"issues\", \"mrs\", \"discussions\", \"notes\"])]\n    pub entity: String,\n\n    /// Filter by noteable type (for discussions/notes)\n    #[arg(long, value_parser = [\"issue\", \"mr\"])]\n    pub r#type: Option<String>,\n}\n```\n\n### Handler Function\n\n```rust\npub async fn handle_count(args: CountArgs, conn: &Connection) -> Result<()>\n```\n\n### Queries by Entity\n\n**issues:**\n```sql\nSELECT COUNT(*) FROM issues\n```\nOutput: `Issues: 3,801`\n\n**discussions:**\n```sql\n-- Without type filter\nSELECT COUNT(*) FROM discussions\n\n-- With --type=issue\nSELECT COUNT(*) FROM discussions WHERE noteable_type = 'Issue'\n```\nOutput: `Issue Discussions: 1,234`\n\n**notes:**\n```sql\n-- Total and system count\nSELECT COUNT(*), SUM(is_system) FROM notes\n\n-- With --type=issue (join through discussions)\nSELECT COUNT(*), SUM(n.is_system)\nFROM notes n\nJOIN discussions d ON n.discussion_id = d.id\nWHERE d.noteable_type = 'Issue'\n```\nOutput: `Issue Notes: 5,678 (excluding 1,234 system)`\n\n### Output Format\n\n```\nIssues: 3,801\n```\n\n```\nIssue Discussions: 1,234\n```\n\n```\nIssue Notes: 5,678 (excluding 1,234 system)\n```\n\n## Acceptance Criteria\n\n- [ ] `gi count issues` shows total issue count\n- [ ] `gi count discussions` shows total discussion count\n- [ ] `gi count discussions --type=issue` filters to issue discussions\n- [ ] `gi count notes` shows total note count with system note exclusion\n- [ ] `gi count notes --type=issue` filters to issue notes\n- [ ] Numbers formatted with thousands separators (1,234)\n\n## Files\n\n- src/cli/commands/mod.rs (add `pub mod count;`)\n- src/cli/commands/count.rs (create)\n- src/cli/mod.rs (add Count variant to Commands enum)\n\n## TDD Loop\n\nRED:\n```rust\n#[tokio::test] async fn count_issues_returns_total()\n#[tokio::test] async fn count_discussions_with_type_filter()\n#[tokio::test] async fn count_notes_excludes_system_notes()\n```\n\nGREEN: Implement handler with queries\n\nVERIFY: `cargo test count`\n\n## Edge Cases\n\n- Zero entities - show \"Issues: 0\"\n- --type flag invalid for issues/mrs - ignore or error\n- All notes are system notes - show \"Notes: 0 (excluding 1,234 system)\"","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-25T17:02:38.360495Z","created_by":"tayloreernisse","updated_at":"2026-01-25T23:01:37.084627Z","closed_at":"2026-01-25T23:01:37.084568Z","close_reason":"Implemented gi count command with issues/discussions/notes support, format_number helper, and system note exclusion","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2f0","depends_on_id":"bd-208","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2f2","title":"Implement timeline human output renderer","description":"## Background\n\nThis bead implements the human-readable (non-robot) output renderer for `lore timeline`. It takes a collection of TimelineEvents and renders them as a colored, chronological timeline in the terminal.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.4 (Human Output Format).\n\n## Codebase Context\n\n- Colored output pattern: src/cli/commands/show.rs uses `colored` crate for terminal styling\n- Existing formatters: `print_show_issue()`, `print_show_mr()`, `print_list_issues()`\n- TimelineEvent model (bd-20e): timestamp, entity_type, entity_iid, project_path, event_type, summary, actor, url, is_seed\n- TimelineEventType enum (bd-20e): Created, StateChanged, LabelAdded, LabelRemoved, MilestoneSet, MilestoneRemoved, Merged, NoteEvidence, CrossReferenced\n- Expansion provenance: expanded entities have `via` info (from which seed, what edge type)\n- Convention: all output functions take `&[TimelineEvent]` and metadata, not raw DB results\n\n## Approach\n\nCreate `src/cli/commands/timeline.rs`:\n\n```rust\nuse colored::Colorize;\nuse crate::core::timeline::{TimelineEvent, TimelineEventType, TimelineQueryResult};\n\npub fn print_timeline(result: &TimelineQueryResult) {\n    // Header\n    println\\!();\n    println\\!(\"{}\", format\\!(\"Timeline: \\\"{}\\\" ({} events across {} entities)\",\n        result.query, result.events.len(), result.total_entities).bold());\n    println\\!(\"{}\", \"─\".repeat(60));\n    println\\!();\n\n    // Events\n    for event in &result.events {\n        print_timeline_event(event);\n    }\n\n    // Footer\n    println\\!();\n    println\\!(\"{}\", \"─\".repeat(60));\n    print_timeline_footer(result);\n}\n\nfn print_timeline_event(event: &TimelineEvent) {\n    let date = format_date(event.timestamp);\n    let tag = format_event_tag(&event.event_type);\n    let entity = format_entity_ref(event.entity_type.as_str(), event.entity_iid);\n    let actor = event.actor.as_deref().map(|a| format\\!(\"@{a}\")).unwrap_or_default();\n    let expanded_marker = if event.is_seed { \"\" } else { \"  [expanded]\" };\n\n    println\\!(\"{date}  {tag:10}  {entity:6}  {summary:40}  {actor}{expanded_marker}\",\n        summary = &event.summary);\n\n    // Extra lines for specific event types\n    match &event.event_type {\n        TimelineEventType::NoteEvidence { snippet, .. } => {\n            // Show snippet indented, wrapped to ~70 chars\n            for line in wrap_text(snippet, 70) {\n                println\\!(\"                             \\\"{line}\\\"\");\n            }\n        }\n        TimelineEventType::Created => {\n            // Could show labels if available in details\n        }\n        _ => {}\n    }\n}\n```\n\n### Event Tag Colors:\n| Tag | Color |\n|-----|-------|\n| CREATED | green |\n| CLOSED | red |\n| REOPENED | yellow |\n| MERGED | cyan |\n| LABEL | blue |\n| MILESTONE | magenta |\n| NOTE | white/dim |\n| REF | dim |\n\n### Date Format:\n```\n2024-03-15  CREATED   #234  Migrate to OAuth2                    @alice\n```\nUse `YYYY-MM-DD` for dates. Group consecutive same-day events visually.\n\nAdd `pub mod timeline;` to `src/cli/commands/mod.rs` and re-export `print_timeline`.\n\n## Acceptance Criteria\n\n- [ ] `print_timeline()` renders header with query, event count, entity count\n- [ ] Events displayed chronologically with: date, tag, entity ref, summary, actor\n- [ ] Expanded entities marked with [expanded] suffix\n- [ ] NoteEvidence events show snippet text indented and quoted\n- [ ] Tags colored by event type\n- [ ] Footer shows seed entities and expansion info\n- [ ] Module registered in src/cli/commands/mod.rs\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/cli/commands/timeline.rs` (NEW)\n- `src/cli/commands/mod.rs` (add `pub mod timeline;` and re-export `print_timeline`)\n\n## TDD Loop\n\nNo unit tests for terminal rendering. Verify visually:\n\n```bash\ncargo check --all-targets\n# After full pipeline: lore timeline \"some query\"\n```\n\n## Edge Cases\n\n- Empty result: print \"No events found for query.\" and exit 0\n- Very long summaries: truncate to 60 chars with \"...\"\n- NoteEvidence snippets: wrap at 70 chars, cap at 4 lines\n- Null actors (system events): show no @username\n- Entity types: # for issues, \\! for MRs (GitLab convention)\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:33:28.326026Z","created_by":"tayloreernisse","updated_at":"2026-02-06T13:49:10.580508Z","closed_at":"2026-02-06T13:49:10.580438Z","close_reason":"Implemented print_timeline() human renderer in src/cli/commands/timeline.rs with colored chronological output, event tags, entity refs, evidence note snippets, and footer summary","compaction_level":0,"original_size":0,"labels":["cli","gate-3","phase-b"],"dependencies":[{"issue_id":"bd-2f2","depends_on_id":"bd-3as","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-2f2","depends_on_id":"bd-ike","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2fc","title":"Update AGENTS.md and CLAUDE.md with Phase B commands","description":"## Background\n\nAfter Phase B implementation, update AGENTS.md and CLAUDE.md with temporal intelligence command documentation so agents can discover and use the new commands.\n\n## Codebase Context\n\n- AGENTS.md section \"Gitlore Robot Mode\" (line ~592) has Robot Mode Commands table\n- ~/.claude/CLAUDE.md has matching \"Gitlore (lore)\" section with command reference\n- New Phase B commands: timeline, file-history, trace\n- New count entity: references\n- sync gains --no-file-changes flag (bd-jec)\n- Config gains fetchMrFileChanges (bd-jec) and fetchResourceEvents (already exists)\n\n## Approach\n\nAdd \"Temporal Intelligence Commands\" section after existing Robot Mode Commands in both files:\n\n```bash\n# Timeline - chronological event history\nlore --robot timeline \"authentication\" --since 30d\nlore --robot timeline \"deployment\" --depth 2 --expand-mentions\n\n# File History - which MRs touched a file\nlore --robot file-history src/auth/oauth.rs --discussions\n\n# Trace - file -> MR -> issue -> discussion chain\nlore --robot trace src/auth/oauth.rs --discussions\n\n# Count references - cross-reference statistics\nlore --robot count references\n\n# Sync with file changes\nlore --robot sync --no-file-changes  # skip MR diff fetching\n```\n\nAlso document config flags:\n```json\n{\n  \"sync\": {\n    \"fetchResourceEvents\": true,\n    \"fetchMrFileChanges\": true\n  }\n}\n```\n\n## Acceptance Criteria\n\n- [ ] AGENTS.md has Temporal Intelligence Commands section\n- [ ] ~/.claude/CLAUDE.md has matching section\n- [ ] All examples are valid, runnable commands\n- [ ] Config flags documented (fetchResourceEvents, fetchMrFileChanges)\n- [ ] --no-events and --no-file-changes CLI flags documented\n- [ ] sync-related changes documented\n- [ ] Mentions resource events requirement for timeline queries\n\n## Files\n\n- AGENTS.md (add temporal intelligence section)\n- ~/.claude/CLAUDE.md (add matching section)\n\n## Edge Cases\n\n- Both files must stay in sync\n- Examples must use --robot flag consistently\n- Config flag names use camelCase in JSON, snake_case in Rust","status":"closed","priority":4,"issue_type":"task","created_at":"2026-02-02T22:43:22.090741Z","created_by":"tayloreernisse","updated_at":"2026-02-18T21:51:58.469566Z","closed_at":"2026-02-18T21:51:58.469519Z","close_reason":"CLAUDE.md and AGENTS.md already document all Phase B commands (timeline, file-history, trace, cron, token)","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2fc","depends_on_id":"bd-1ht","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2fm","title":"Add GitLab Resource Event serde types","description":"## Background\nNeed Rust types for deserializing GitLab Resource Events API responses. These map directly to the API JSON shape from three endpoints: resource_state_events, resource_label_events, resource_milestone_events.\n\nExisting pattern: types.rs uses #[derive(Debug, Clone, Deserialize)] with Option<T> for nullable fields. GitLabAuthor is already defined (id, username, name). Tests in tests/gitlab_types_tests.rs use serde_json::from_str with sample payloads.\n\n## Approach\nAdd to src/gitlab/types.rs (after existing types):\n\n```rust\n/// Reference to an MR in state event's source_merge_request field\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct GitLabMergeRequestRef {\n    pub iid: i64,\n    pub title: Option<String>,\n    pub web_url: Option<String>,\n}\n\n/// Reference to a label in label event's label field\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct GitLabLabelRef {\n    pub id: i64,\n    pub name: String,\n    pub color: Option<String>,\n    pub description: Option<String>,\n}\n\n/// Reference to a milestone in milestone event's milestone field\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct GitLabMilestoneRef {\n    pub id: i64,\n    pub iid: i64,\n    pub title: String,\n}\n\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct GitLabStateEvent {\n    pub id: i64,\n    pub user: Option<GitLabAuthor>,\n    pub created_at: String,\n    pub resource_type: String,       // \"Issue\" | \"MergeRequest\"\n    pub resource_id: i64,\n    pub state: String,               // \"opened\" | \"closed\" | \"reopened\" | \"merged\" | \"locked\"\n    pub source_commit: Option<String>,\n    pub source_merge_request: Option<GitLabMergeRequestRef>,\n}\n\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct GitLabLabelEvent {\n    pub id: i64,\n    pub user: Option<GitLabAuthor>,\n    pub created_at: String,\n    pub resource_type: String,\n    pub resource_id: i64,\n    pub label: GitLabLabelRef,\n    pub action: String,              // \"add\" | \"remove\"\n}\n\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct GitLabMilestoneEvent {\n    pub id: i64,\n    pub user: Option<GitLabAuthor>,\n    pub created_at: String,\n    pub resource_type: String,\n    pub resource_id: i64,\n    pub milestone: GitLabMilestoneRef,\n    pub action: String,              // \"add\" | \"remove\"\n}\n```\n\nAlso export from src/gitlab/mod.rs if needed.\n\n## Acceptance Criteria\n- [ ] All 6 types (3 events + 3 refs) compile\n- [ ] GitLabStateEvent deserializes from real GitLab API JSON (with and without source_merge_request)\n- [ ] GitLabLabelEvent deserializes with nested label object\n- [ ] GitLabMilestoneEvent deserializes with nested milestone object\n- [ ] All Optional fields handle null/missing correctly\n- [ ] Types exported from lore::gitlab::types\n\n## Files\n- src/gitlab/types.rs (add 6 new types)\n- tests/gitlab_types_tests.rs (add deserialization tests)\n\n## TDD Loop\nRED: Add to tests/gitlab_types_tests.rs:\n- `test_deserialize_state_event_closed_by_mr` - JSON with source_merge_request present\n- `test_deserialize_state_event_simple` - JSON with source_merge_request null, user null\n- `test_deserialize_label_event_add` - label add with full label object\n- `test_deserialize_label_event_remove` - label remove\n- `test_deserialize_milestone_event` - milestone add with nested milestone\nImport new types: `use lore::gitlab::types::{GitLabStateEvent, GitLabLabelEvent, GitLabMilestoneEvent, GitLabMergeRequestRef, GitLabLabelRef, GitLabMilestoneRef};`\n\nGREEN: Add the type definitions to types.rs\n\nVERIFY: `cargo test gitlab_types_tests -- --nocapture`\n\n## Edge Cases\n- GitLab sometimes returns user: null for system-generated events (e.g., auto-close on merge) — user must be Option\n- source_merge_request can be null even when state is \"closed\" (manually closed, not by MR)\n- label.color may be null for labels created via API without color\n- The resource_type field uses PascalCase (\"MergeRequest\" not \"merge_request\") — don't confuse with DB entity_type","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:31:24.081234Z","created_by":"tayloreernisse","updated_at":"2026-02-03T16:10:20.253407Z","closed_at":"2026-02-03T16:10:20.253344Z","close_reason":"Completed: Added 6 new types (GitLabMergeRequestRef, GitLabLabelRef, GitLabMilestoneRef, GitLabStateEvent, GitLabLabelEvent, GitLabMilestoneEvent) to types.rs with exports and 8 passing tests","compaction_level":0,"original_size":0,"labels":["gate-1","phase-b","types"],"dependencies":[{"issue_id":"bd-2fm","depends_on_id":"bd-2zl","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2fp","title":"Implement discussion document extraction","description":"## Background\nDiscussion documents are the most complex extraction — they involve querying discussions + notes + parent entity (issue or MR) + parent labels + DiffNote file paths. The output includes a threaded conversation format with author/date prefixes per note. System notes (bot-generated) are excluded. DiffNote paths are extracted for the --path search filter.\n\n## Approach\nImplement `extract_discussion_document()` in `src/documents/extractor.rs`:\n\n```rust\n/// Extract a searchable document from a discussion thread.\n/// Returns None if the discussion or its parent has been deleted.\npub fn extract_discussion_document(conn: &Connection, discussion_id: i64) -> Result<Option<DocumentData>>\n```\n\n**SQL queries (from PRD Section 2.2):**\n```sql\n-- Discussion metadata\nSELECT d.id, d.noteable_type, d.issue_id, d.merge_request_id,\n       p.path_with_namespace, p.id AS project_id\nFROM discussions d\nJOIN projects p ON p.id = d.project_id\nWHERE d.id = ?\n\n-- Parent entity (conditional on noteable_type)\n-- If Issue: SELECT i.iid, i.title, i.web_url FROM issues i WHERE i.id = ?\n-- If MR: SELECT m.iid, m.title, m.web_url FROM merge_requests m WHERE m.id = ?\n\n-- Parent labels (via issue_labels or mr_labels junction)\n\n-- Non-system notes in thread order\nSELECT n.author_username, n.body, n.created_at, n.gitlab_id,\n       n.note_type, n.position_old_path, n.position_new_path\nFROM notes n\nWHERE n.discussion_id = ? AND n.is_system = 0\nORDER BY n.created_at ASC, n.id ASC\n```\n\n**Document format:**\n```\n[[Discussion]] Issue #234: Authentication redesign\nProject: group/project-one\nURL: https://gitlab.example.com/group/project-one/-/issues/234#note_12345\nLabels: [\"bug\", \"auth\"]\nFiles: [\"src/auth/login.ts\"]\n\n--- Thread ---\n\n@johndoe (2024-03-15):\nI think we should move to JWT-based auth...\n\n@janedoe (2024-03-15):\nAgreed. What about refresh token strategy?\n```\n\n**Implementation steps:**\n1. Query discussion row — if not found, return Ok(None)\n2. Determine parent type (Issue or MR) from noteable_type\n3. Query parent entity for iid, title, web_url — if not found, return Ok(None)\n4. Query parent labels via appropriate junction table\n5. Query non-system notes ordered by created_at ASC, id ASC\n6. Extract DiffNote paths: collect position_old_path and position_new_path, dedup\n7. Construct URL: `{parent_web_url}#note_{first_note_gitlab_id}`\n8. Format header with [[Discussion]] prefix\n9. Format thread body: `@author (YYYY-MM-DD):\\nbody\\n\\n` per note\n10. Apply discussion truncation via `truncate_discussion()` if needed\n11. Author = first non-system note's author_username\n12. Compute hashes, return DocumentData\n\n## Acceptance Criteria\n- [ ] System notes (is_system=1) excluded from content\n- [ ] DiffNote paths extracted from position_old_path and position_new_path\n- [ ] Paths deduplicated and sorted\n- [ ] URL constructed as `parent_web_url#note_GITLAB_ID`\n- [ ] Header uses parent entity type: \"Issue #N\" or \"MR !N\"\n- [ ] Parent title included in header\n- [ ] Labels come from PARENT entity (not the discussion itself)\n- [ ] First non-system note author used as document author\n- [ ] Thread formatted with `@author (date):` per note\n- [ ] Truncation applied for long threads via truncate_discussion()\n- [ ] `cargo test extract_discussion` passes\n\n## Files\n- `src/documents/extractor.rs` — implement `extract_discussion_document()`\n\n## TDD Loop\nRED: Tests in `#[cfg(test)] mod tests`:\n- `test_discussion_document_format` — verify header + thread format\n- `test_discussion_not_found` — returns Ok(None)\n- `test_discussion_parent_deleted` — returns Ok(None) when parent issue/MR missing\n- `test_discussion_system_notes_excluded` — system notes not in content\n- `test_discussion_diffnote_paths` — old_path + new_path extracted and deduped\n- `test_discussion_url_construction` — URL has #note_GITLAB_ID anchor\n- `test_discussion_uses_parent_labels` — labels from parent entity, not discussion\nGREEN: Implement extract_discussion_document\nVERIFY: `cargo test extract_discussion`\n\n## Edge Cases\n- Discussion with all system notes: no non-system notes -> return empty thread (or skip document entirely?)\n- Discussion with NULL parent (orphaned): return Ok(None)\n- DiffNote with same old_path and new_path: dedup produces single entry\n- Notes with NULL body: skip or use empty string\n- Discussion on MR: header shows \"MR !N\" (not \"MergeRequest !N\")","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-30T15:25:45.549099Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:34:43.597398Z","closed_at":"2026-01-30T17:34:43.597339Z","close_reason":"Implemented extract_discussion_document() with parent entity lookup, DiffNote paths, system note exclusion, URL construction + 9 tests","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2fp","depends_on_id":"bd-18t","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-2fp","depends_on_id":"bd-36p","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-2fp","depends_on_id":"bd-hrs","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2fr7","title":"Implement crash_context ring buffer + panic hook","description":"## Background\ncrash_context.rs provides a ring buffer of the last 2000 app events (key presses, messages, state transitions) plus a panic hook that dumps this buffer to a crash file for post-mortem diagnostics. This is critical for debugging TUI issues that only reproduce under specific interaction sequences.\n\n## Approach\nCreate `crates/lore-tui/src/crash_context.rs`:\n\n**CrashContext struct:**\n- events: VecDeque<CrashEvent> (capacity 2000)\n- push(event: CrashEvent) — append, auto-evict oldest when full\n- dump_to_file(path: &Path) -> io::Result<()> — write all events as newline-delimited JSON\n- install_panic_hook() — set_hook that calls dump_to_file to ~/.local/share/lore/crash-<timestamp>.json\n\n**CrashEvent enum:**\n- KeyPress { key: String, mode: InputMode, screen: Screen }\n- MsgDispatched { msg_name: String, screen: Screen }\n- StateTransition { from: Screen, to: Screen }\n- Error { message: String }\n- Custom { tag: String, detail: String }\n\n**Retention policy:** Keep last 5 crash files, delete older ones on startup.\n\n**Integration:** LoreApp.update() calls crash_context.push() for every Msg before dispatch. The crash_context is a field on LoreApp.\n\n## Acceptance Criteria\n- [ ] CrashContext stores up to 2000 events in ring buffer\n- [ ] push() evicts oldest event when buffer is full\n- [ ] dump_to_file() writes all events as newline-delimited JSON\n- [ ] Panic hook installed via std::panic::set_hook\n- [ ] Crash file written to ~/.local/share/lore/ with timestamp in filename\n- [ ] Retention: only last 5 crash files kept, older deleted on startup\n- [ ] Unit test: push 2500 events, assert only last 2000 retained\n- [ ] Unit test: dump_to_file writes valid NDJSON\n\n## Files\n- CREATE: crates/lore-tui/src/crash_context.rs\n- MODIFY: crates/lore-tui/src/lib.rs (add pub mod crash_context)\n- MODIFY: crates/lore-tui/src/app.rs (add crash_context field, push in update(), install hook in init())\n\n## TDD Anchor\nRED: Write test_ring_buffer_evicts_oldest that creates CrashContext, pushes 2500 events, asserts len()==2000 and first event is event #501.\nGREEN: Implement CrashContext with VecDeque and capacity check.\nVERIFY: cargo test -p lore-tui crash_context\n\n## Edge Cases\n- Crash file directory doesn't exist: create it with fs::create_dir_all\n- Disk full during dump: best-effort, don't panic in the panic hook\n- Concurrent access: CrashContext is only accessed from the main update() thread, no sync needed\n- Event serialization: use serde_json::to_string, fallback to debug format if serialization fails\n\n## Dependency Context\nUsed by LoreApp (bd-6pmy) — added as a field and called in update(). Uses Screen and InputMode from core types (bd-c9gk).","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T18:08:10.416241Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:22.550223Z","deleted_at":"2026-03-11T18:34:22.550219Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-2fuw","title":"Render open issues section in human mode","description":"## Background\nRender the Open Issues section for `lore me` human output. It must show assigned issues with attention signal, status, and recency in a compact two-line layout.\n\n## Approach\nImplement in `src/cli/commands/me/render_human.rs`:\n```rust\npub fn render_issues_section(issues: &[MeIssue], single_project: bool) -> String\n```\n\nLayout per item:\n- Line 1: attention icon + `#iid` + title + optional `[status_name]` + relative time.\n- Line 2: dimmed `project_path` unless `single_project == true`.\n\nUse existing render helpers:\n- `section_divider` for header\n- `Theme::issue_ref`, `Theme::dim`, `Theme::warning`, `Theme::info`, `Theme::muted`\n- icon helpers from `bd-1vxq`\n- `format_relative_time` for recency\n\nTimestamp conversion rule:\n- Convert `updated_at_iso` to ms with `crate::core::time::iso_to_ms` before `format_relative_time`.\n- If parse fails, render `updated_at_iso` date text as fallback (no panic).\n\nAttention mapping:\n- `NeedsAttention` -> warning icon\n- `NotStarted` -> info/muted icon\n- `AwaitingResponse` -> info/dim icon\n- `Stale` -> dim icon\n- `NotReady` -> empty icon for issues\n\n## Acceptance Criteria\n- [ ] Header is `Open Issues (N)` via `section_divider`\n- [ ] Each row shows icon, `#iid`, title, optional status, and relative time\n- [ ] Project path renders on second line and is hidden when `single_project=true`\n- [ ] Icon tier follows renderer icon mode (Nerd/Unicode/ASCII)\n- [ ] `NotReady` renders no icon for issues\n- [ ] `status_name=None` renders no empty brackets\n- [ ] Empty list renders `No open issues`\n- [ ] Titles are not truncated\n- [ ] Invalid `updated_at_iso` does not panic and still renders timestamp fallback text\n\n## Files\n- MODIFY: `src/cli/commands/me/render_human.rs`\n\n## TDD Anchor\nRED:\n- `test_issues_section_renders_iid`\n- `test_issues_section_empty_shows_message`\n- `test_issues_section_hides_project_when_single`\n- `test_issues_section_shows_status_name`\n- `test_issues_section_omits_status_when_none`\n- `test_issues_section_invalid_iso_fallback`\n\nGREEN:\n- Implement renderer with safe ISO->ms conversion and fallback.\n\nVERIFY:\n- `cargo test issues_section`\n\n## Edge Cases\n- Very long titles remain full-width (no truncation).\n- Icon glyph widths differ across font tiers; keep one separating space after icon to maintain readable alignment.\n- Sorting is upstream in handler; renderer preserves input order.\n\n## Dependency Context\nConsumes `MeIssue` from `bd-3bwh` and icon helpers from `bd-1vxq`.\nCalled by `bd-1vv8` handler.\n\nDependencies:\n  -> bd-1vxq (blocks) - Render summary header and attention legend\n\nDependents:\n  <- bd-1vv8 (blocks) - Implement me command handler: wire queries to renderers","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-19T19:39:08.891551Z","created_by":"tayloreernisse","updated_at":"2026-02-20T16:09:13.060071Z","closed_at":"2026-02-20T16:09:13.060033Z","close_reason":"Implemented by lore-me agent swarm","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2fuw","depends_on_id":"bd-1vxq","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2g50","title":"Audit and fill data gaps: lore detail view vs glab","description":"## Background\nFor lore to be the definitive read path, its single-entity detail view must return everything glab returns PLUS lore-exclusive enrichments.\n\n## Current Issue Detail Output (lore -J issues N)\nFields returned: assignees, author_username, closing_merge_requests, created_at, description, discussions, due_date, id, iid, labels, milestone, project_path, state, status_color, status_icon_name, status_name, status_synced_at, title, updated_at, web_url\n\n## Gap Analysis (Verified 2026-02-12)\n\n### Raw Payload Audit\nIssue raw_payloads store exactly 15 fields: assignees, author, closed_at, created_at, description, due_date, id, iid, labels, milestone, project_id, state, title, updated_at, web_url.\n\nFields NOT in raw payloads (require ingestion pipeline update to capture from GitLab API):\n- closed_by, confidential, upvotes, downvotes, weight, issue_type, time_stats, health_status, references\n\n### Phase 1 — Computed fields (NO schema change, NO ingestion change)\nThese can be derived from existing data:\n1. `references_full`: format!(\"{path_with_namespace}#{iid}\") — project_path already in show.rs:IssueDetail\n2. `user_notes_count`: SELECT COUNT(*) FROM notes n JOIN discussions d ON n.discussion_id = d.id WHERE d.noteable_type = 'Issue' AND d.noteable_id = ? AND n.is_system = 0\n3. `merge_requests_count`: COUNT from closing_merge_requests vec already loaded in show.rs (just .len())\n\n### Phase 2 — Extract from existing raw payloads (schema change, NO ingestion change)\n`closed_at` IS in raw_payloads for closed issues. Can be backfilled:\n1. Add `closed_at TEXT` column to issues table (migration 023)\n2. Backfill: UPDATE issues SET closed_at = json_extract((SELECT payload FROM raw_payloads WHERE id = issues.raw_payload_id), '$.closed_at') WHERE state = 'closed'\n3. Capture during ingestion going forward\n\n### Phase 3 — Requires ingestion pipeline update (schema change + API capture)\nThese fields are in the GitLab Issues API response but NOT captured by lore's ingestion:\n1. `closed_by` (object with username) — add closed_by_username TEXT to issues\n2. `confidential` (boolean) — add confidential INTEGER DEFAULT 0 to issues\n3. Both require updating src/ingestion/ to extract these fields during sync\n\n### Phase 4 — Same audit for MR detail view\nMR detail (src/cli/commands/show.rs MrDetail struct lines 14-33) already includes: closed_at, merged_at, draft, source/target branch, reviewers. Missing: approvers_count, pipeline_status.\n\n## Implementation: show.rs Modifications\n\n### IssueDetail struct (src/cli/commands/show.rs:69-91)\nAdd fields:\n```rust\npub references_full: String,        // Phase 1: computed\npub user_notes_count: i64,          // Phase 1: computed\npub merge_requests_count: usize,    // Phase 1: computed (closing_merge_requests.len())\npub closed_at: Option<String>,      // Phase 2: from DB after migration\npub confidential: bool,             // Phase 3: from DB after ingestion update\n```\n\n### SQL for computed fields\n```sql\n-- user_notes_count\nSELECT COUNT(*) FROM notes n\nJOIN discussions d ON n.discussion_id = d.id\nWHERE d.noteable_type = 'Issue' AND d.noteable_id = ?1 AND n.is_system = 0\n\n-- references_full (in Rust)\nformat!(\"{}#{}\", project_path, iid)\n\n-- merge_requests_count (in Rust)\nclosing_merge_requests.len()\n```\n\n## Migration 023 (after bd-2l3s takes 022)\n```sql\n-- migrations/023_issue_detail_fields.sql\nALTER TABLE issues ADD COLUMN closed_at TEXT;\nALTER TABLE issues ADD COLUMN confidential INTEGER NOT NULL DEFAULT 0;\n\n-- Backfill closed_at from raw_payloads\nUPDATE issues SET closed_at = (\n    SELECT json_extract(rp.payload, '$.closed_at')\n    FROM raw_payloads rp\n    WHERE rp.id = issues.raw_payload_id\n) WHERE state = 'closed' AND raw_payload_id IS NOT NULL;\n\nINSERT INTO schema_version (version, applied_at, description)\nVALUES (23, strftime('%s', 'now') * 1000, 'Issue detail fields: closed_at, confidential');\n```\n\nNOTE: raw_payload_id column on issues — verify this exists. If issues don't have a direct FK to raw_payloads, the backfill SQL needs adjustment (may need to join through another path).\n\n## TDD Loop\nRED: Tests in src/cli/commands/show.rs:\n- test_show_issue_has_references_full: insert issue with known project_path, assert JSON output contains \"project/path#123\"\n- test_show_issue_has_notes_count: insert issue + 3 user notes + 1 system note, assert user_notes_count = 3\n- test_show_issue_closed_has_closed_at: insert closed issue with closed_at in raw_payload, run migration, verify closed_at appears\n\nGREEN: Add computed fields to IssueDetail, add migration 023 for closed_at + confidential columns\n\nVERIFY:\n```bash\ncargo test show:: && cargo clippy --all-targets -- -D warnings\ncargo run --release -- -J issues 3864 | jq '{references_full, user_notes_count, merge_requests_count}'\n```\n\n## Acceptance Criteria\n- [ ] lore -J issues N includes references_full (string, e.g., \"vs/typescript-code#3864\")\n- [ ] lore -J issues N includes user_notes_count (integer, excludes system notes)\n- [ ] lore -J issues N includes merge_requests_count (integer)\n- [ ] lore -J issues N includes closed_at (ISO string for closed issues, null for open)\n- [ ] lore -J issues N includes confidential (boolean, after Phase 3)\n- [ ] --fields minimal preset updated to include references_full\n- [ ] Migration 023 adds closed_at and confidential columns to issues table\n- [ ] Backfill SQL populates closed_at from existing raw_payloads\n- [ ] cargo test passes with new show:: tests\n\n## Edge Cases\n- Issue with zero notes: user_notes_count = 0 (not null)\n- Issue with no closing MRs: merge_requests_count = 0\n- Open issue: closed_at = null (serialized as JSON null, not omitted)\n- confidential before Phase 3: default false (safe default)\n- MR detail: different computed fields (approvers_count, pipeline_status if available)\n- Raw payload missing for very old issues (raw_payload_id = NULL): closed_at stays NULL\n- raw_payload_id column: verify it exists on the issues table before writing backfill SQL\n\n## Files to Modify\n- src/cli/commands/show.rs (IssueDetail struct + query logic)\n- src/core/db.rs (migration 023: wire into MIGRATIONS array)\n- NEW: migrations/023_issue_detail_fields.sql\n- src/ingestion/ (Phase 3: capture closed_by, confidential during sync — specify exact file after reviewing ingestion pipeline)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T15:45:16.512418Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:49:01.580183Z","closed_at":"2026-02-12T16:49:01.580133Z","close_reason":"Data gaps filled: references_full, user_notes_count, merge_requests_count, closed_at, confidential via migration 023","compaction_level":0,"original_size":0,"labels":["cli","cli-imp","robot-mode"],"dependencies":[{"issue_id":"bd-2g50","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2h0","title":"[CP1] gi list issues command","description":"List issues from the database.\n\n## Module\nsrc/cli/commands/list.rs\n\n## Clap Definition\nList {\n    #[arg(value_parser = [\"issues\", \"mrs\"])]\n    entity: String,\n    \n    #[arg(long, default_value = \"20\")]\n    limit: usize,\n    \n    #[arg(long)]\n    project: Option<String>,\n    \n    #[arg(long, value_parser = [\"opened\", \"closed\", \"all\"])]\n    state: Option<String>,\n}\n\n## Output Format\nIssues (showing 20 of 3,801)\n\n  #1234  Authentication redesign                       opened   @johndoe   3 days ago\n  #1233  Fix memory leak in cache                      closed   @janedoe   5 days ago\n  #1232  Add dark mode support                         opened   @bobsmith  1 week ago\n  ...\n\n## Implementation\n- Query issues table with filters\n- Join with projects table for display\n- Format updated_at as relative time (\"3 days ago\")\n- Truncate title if too long\n\nFiles: src/cli/commands/list.rs\nDone when: List displays issues with proper filtering and formatting","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T16:58:23.809829Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.898106Z","closed_at":"2026-01-25T17:02:01.898106Z","deleted_at":"2026-01-25T17:02:01.898102Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-2i10","title":"OBSERV: Add log file diagnostics to lore doctor","description":"## Background\nlore doctor is the diagnostic entry point. Adding log file info lets users verify logging is working and check disk usage. The existing DoctorChecks struct (src/cli/commands/doctor.rs:43-51) has checks for config, database, gitlab, projects, ollama.\n\n## Approach\nAdd a new LoggingCheck struct and field to DoctorChecks:\n\n```rust\n#[derive(Debug, Serialize)]\npub struct LoggingCheck {\n    pub result: CheckResult,\n    pub log_dir: String,\n    pub file_count: usize,\n    pub total_bytes: u64,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub oldest_file: Option<String>,\n}\n```\n\nAdd to DoctorChecks (src/cli/commands/doctor.rs:43-51):\n```rust\npub logging: LoggingCheck,\n```\n\nImplement check_logging() function:\n```rust\nfn check_logging() -> LoggingCheck {\n    let log_dir = get_log_dir(None); // TODO: accept config override\n    let mut file_count = 0;\n    let mut total_bytes = 0u64;\n    let mut oldest: Option<String> = None;\n\n    if let Ok(entries) = std::fs::read_dir(&log_dir) {\n        for entry in entries.flatten() {\n            let name = entry.file_name().to_string_lossy().to_string();\n            if name.starts_with(\"lore.\") && name.ends_with(\".log\") {\n                file_count += 1;\n                if let Ok(meta) = entry.metadata() {\n                    total_bytes += meta.len();\n                }\n                if oldest.as_ref().map_or(true, |o| name < *o) {\n                    oldest = Some(name);\n                }\n            }\n        }\n    }\n\n    LoggingCheck {\n        result: CheckResult { status: CheckStatus::Ok, message: None },\n        log_dir: log_dir.display().to_string(),\n        file_count,\n        total_bytes,\n        oldest_file: oldest,\n    }\n}\n```\n\nCall from run_doctor() (src/cli/commands/doctor.rs:91-126) and add to DoctorChecks construction.\n\nFor interactive output in print_doctor_results(), add a section:\n```\nLogging\n  Log directory: ~/.local/share/lore/logs/\n  Log files: 7 (2.3 MB)\n  Oldest: lore.2026-01-28.log\n```\n\n## Acceptance Criteria\n- [ ] lore doctor shows log directory path, file count, total size\n- [ ] lore --robot doctor JSON includes logging field with log_dir, file_count, total_bytes, oldest_file\n- [ ] When no log files exist: file_count=0, total_bytes=0, oldest_file=null\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/cli/commands/doctor.rs (add LoggingCheck struct, check_logging fn, wire into DoctorChecks)\n\n## TDD Loop\nRED: test_check_logging_with_files, test_check_logging_empty_dir\nGREEN: Implement LoggingCheck struct and check_logging function\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- Log directory doesn't exist yet (first run before any sync): report file_count=0, status Ok\n- Permission errors on read_dir: report status Warning with message","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-04T15:53:55.682986Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:15:04.520915Z","closed_at":"2026-02-04T17:15:04.520868Z","close_reason":"Added LoggingCheck to DoctorChecks with log_dir, file_count, total_bytes; shows in both interactive and robot output","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-2i10","depends_on_id":"bd-1k4","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-2i10","depends_on_id":"bd-2nx","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2i3z","title":"Task 1: Register explain command and wire dispatch","description":"## Background\n\nAdd `lore explain issues N` / `lore explain mrs N` command to auto-generate structured narratives of what happened on an issue or MR. This task sets up the CLI registration, handler dispatch, parameter parsing, and skeleton run_explain() function.\n\n**Parent bead:** bd-9lbr (lore explain feature)\n**Spec:** specs/SPEC_explain.md\n**Phase:** 1 — Setup & Registration\n\n## Why This Matters\n\nThe explain command is the entry point for the entire feature. Without CLI registration and parameter parsing, none of the core logic (key decisions, open threads, timeline) can be invoked. This task creates the skeleton that all other explain tasks build on top of.\n\n## Architecture Decisions\n\n### Command Registration Pattern\nUses inline args on the enum variant (like Drift, Related pattern). No separate ExplainArgs struct needed.\n\nEntity type validated by value_parser = [\"issues\", \"mrs\", \"issue\", \"mr\"].\nHandler normalizes singular forms: \"issue\" -> \"issues\", \"mr\" -> \"mrs\".\n\n### ExplainParams Struct\nControls explain behavior:\n\\`\\`\\`rust\npub struct ExplainParams {\n    pub entity_type: String,       // \"issues\" or \"mrs\" (already normalized)\n    pub iid: i64,\n    pub project: Option<String>,\n    pub sections: Option<Vec<String>>,  // None = all sections\n    pub no_timeline: bool,\n    pub max_decisions: usize,      // default 10\n    pub since: Option<i64>,        // ms epoch threshold from --since parsing\n}\n\\`\\`\\`\n\n### ExplainResult and Sub-Types (full definitions)\n\\`\\`\\`rust\n#[derive(Debug, Serialize)]\npub struct ExplainResult {\n    pub entity: EntitySummary,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub description_excerpt: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub key_decisions: Option<Vec<KeyDecision>>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub activity: Option<ActivitySummary>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub open_threads: Option<Vec<OpenThread>>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub related: Option<RelatedEntities>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub timeline_excerpt: Option<Vec<TimelineEventSummary>>,\n}\n\n#[derive(Debug, Serialize)]\npub struct EntitySummary {\n    #[serde(rename = \"type\")]\n    pub entity_type: String,    // \"issue\" or \"merge_request\"\n    pub iid: i64,\n    pub title: String,\n    pub state: String,\n    pub author: String,\n    pub assignees: Vec<String>,\n    pub labels: Vec<String>,\n    pub created_at: String,     // ISO 8601\n    pub updated_at: String,     // ISO 8601\n    pub url: Option<String>,\n    pub status_name: Option<String>,\n}\n\n#[derive(Debug, Serialize)]\npub struct KeyDecision {\n    pub timestamp: String,      // ISO 8601\n    pub actor: String,\n    pub action: String,         // \"state: closed\" or \"label: +bug\"\n    pub context_note: String,   // truncated to 500 chars\n}\n\n#[derive(Debug, Serialize)]\npub struct ActivitySummary {\n    pub state_changes: usize,\n    pub label_changes: usize,\n    pub notes: usize,           // non-system only\n    pub first_event: Option<String>,   // ISO 8601\n    pub last_event: Option<String>,    // ISO 8601\n}\n\n#[derive(Debug, Serialize)]\npub struct OpenThread {\n    pub discussion_id: String,\n    pub started_by: String,\n    pub started_at: String,     // ISO 8601\n    pub note_count: usize,\n    pub last_note_at: String,   // ISO 8601\n}\n\n#[derive(Debug, Serialize)]\npub struct RelatedEntities {\n    pub closing_mrs: Vec<ClosingMrInfo>,\n    pub related_issues: Vec<RelatedEntityInfo>,\n}\n\n#[derive(Debug, Serialize)]\npub struct ClosingMrInfo {\n    pub iid: i64,\n    pub title: String,\n    pub state: String,\n    pub web_url: Option<String>,\n}\n\n#[derive(Debug, Serialize)]\npub struct RelatedEntityInfo {\n    pub entity_type: String,\n    pub iid: i64,\n    pub title: Option<String>,\n    pub reference_type: String,\n}\n\n#[derive(Debug, Serialize)]\npub struct TimelineEventSummary {\n    pub timestamp: String,      // ISO 8601\n    pub event_type: String,\n    pub actor: Option<String>,\n    pub summary: String,\n}\n\\`\\`\\`\n\n### Clap Registration\n\\`\\`\\`rust\n/// Auto-generate a structured narrative of an issue or MR\n#[command(after_help = \"\\x1b[1mExamples:\\x1b[0m\n  lore explain issues 42                  # Narrative for issue #42\n  lore explain mrs 99 -p group/repo       # Narrative for MR !99 in specific project\n  lore -J explain issues 42               # JSON output for automation\n  lore explain issues 42 --sections key_decisions,open_threads  # Specific sections only\n  lore explain issues 42 --since 30d      # Narrative scoped to last 30 days\n  lore explain issues 42 --no-timeline    # Skip timeline (faster)\")]\nExplain {\n    /// Entity type: \"issues\" or \"mrs\" (singular forms also accepted)\n    #[arg(value_parser = [\"issues\", \"mrs\", \"issue\", \"mr\"])]\n    entity_type: String,\n    /// Entity IID\n    iid: i64,\n    /// Scope to project (fuzzy match)\n    #[arg(short, long)]\n    project: Option<String>,\n    /// Select specific sections (comma-separated)\n    /// Valid: entity, description, key_decisions, activity, open_threads, related, timeline\n    #[arg(long, value_delimiter = ',', help_heading = \"Output\")]\n    sections: Option<Vec<String>>,\n    /// Skip timeline excerpt (faster execution)\n    #[arg(long, help_heading = \"Output\")]\n    no_timeline: bool,\n    /// Maximum key decisions to include\n    #[arg(long, default_value = \"10\", help_heading = \"Output\")]\n    max_decisions: usize,\n    /// Time scope for events/notes (e.g. 7d, 2w, 1m, or YYYY-MM-DD)\n    #[arg(long, help_heading = \"Filters\")]\n    since: Option<String>,\n},\n\\`\\`\\`\n\n## Files to Create/Modify\n\n- NEW: src/cli/commands/explain.rs — command module with types, run_explain(), handle_explain(), and tests\n- EDIT: src/cli/mod.rs — add Explain variant to Commands enum with inline args (code above)\n- EDIT: src/cli/commands/mod.rs — add pub mod explain;\n- EDIT: src/main.rs — add Explain match arm calling handle_explain (like Drift/Related pattern)\n\n**Handler location:** Put handle_explain() in src/cli/commands/explain.rs. main.rs imports and calls it. This keeps the handler next to its logic.\n\n## run_explain() Signature and Skeleton\n\n\\`\\`\\`rust\npub fn run_explain(conn: &Connection, params: &ExplainParams) -> Result<ExplainResult> {\n    // 1. Resolve project (if provided)\n    let project_id = params.project.as_deref().map(|p| resolve_project(conn, p)).transpose()?;\n\n    // 2. Find entity\n    let (entity_summary, entity_local_id, project_path) = if params.entity_type == \"issues\" {\n        find_explain_issue(conn, params.iid, project_id)?\n    } else {\n        find_explain_mr(conn, params.iid, project_id)?\n    };\n\n    // 3. Description excerpt\n    let description_excerpt = if should_include(&params.sections, \"description\") {\n        Some(/* first 500 chars or \"(no description)\" */)\n    } else { None };\n\n    // 4-7. Stub remaining sections as None (Tasks 2-4 fill these in)\n    Ok(ExplainResult {\n        entity: entity_summary,\n        description_excerpt,\n        key_decisions: if should_include(&params.sections, \"key_decisions\") { Some(vec![]) } else { None },\n        activity: if should_include(&params.sections, \"activity\") { Some(ActivitySummary { state_changes: 0, label_changes: 0, notes: 0, first_event: None, last_event: None }) } else { None },\n        open_threads: if should_include(&params.sections, \"open_threads\") { Some(vec![]) } else { None },\n        related: if should_include(&params.sections, \"related\") { Some(RelatedEntities { closing_mrs: vec![], related_issues: vec![] }) } else { None },\n        timeline_excerpt: None, // Task 4\n    })\n}\n\nfn should_include(sections: &Option<Vec<String>>, name: &str) -> bool {\n    sections.as_ref().map_or(true, |s| s.iter().any(|sec| sec == name))\n}\n\\`\\`\\`\n\n## Key Implementation Notes\n\n- Copy find_issue/find_mr query patterns from show/issue.rs and show/mr.rs — they are private functions (inside include!() files) so cannot be imported\n- Use resolve_project() from crate::core::project for project resolution\n- Use ms_to_iso() from crate::core::time for timestamp conversion\n- Parse --since using crate::core::time::parse_since() — returns Option<i64> ms epoch threshold\n- Validate --sections values against allowed set in handle_explain before building ExplainParams\n- Entity section is always included (needed for identification) even if not in --sections list\n\n### Entity Resolution\nThe find_issue pattern in show/issue.rs:\n- fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result<IssueRow>\n- Two SQL paths: with project_id (resolve_project first), or multi-project query\n- Returns NotFound (exit 17) or Ambiguous (exit 18) errors\n- Same pattern for find_mr in show/mr.rs\n\n### IssueRow / MrRow fields (from show/)\nIssue: id, iid, title, description, state, author_username, created_at, updated_at, closed_at, confidential, web_url, project_path, labels (comma-join), assignees (comma-join), status_name, status_category\nMR: id, iid, title, description, state, draft, author_username, source_branch, target_branch, created_at, updated_at, merged_at, closed_at, web_url, project_path, labels, assignees, reviewers\n\n## Test Helper (shared across all explain tests)\n\nCreate a setup_explain_db() helper that creates in-memory DB with migrations, inserts a test project, and returns (conn, project_id). Pattern from src/test_support.rs and existing test modules:\n\n\\`\\`\\`rust\nfn setup_explain_db() -> (Connection, i64) {\n    let conn = crate::core::db::create_connection(std::path::Path::new(\":memory:\")).unwrap();\n    crate::core::db::run_migrations(&conn).unwrap();\n    conn.execute(\n        \"INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url) VALUES (100, 'test/project', 'https://gitlab.example.com/test/project')\",\n        [],\n    ).unwrap();\n    let project_id = conn.last_insert_rowid();\n    (conn, project_id)\n}\n\\`\\`\\`\n\nInsert test issues/MRs with required columns:\n\\`\\`\\`rust\nfn insert_test_issue(conn: &Connection, project_id: i64, iid: i64, desc: Option<&str>) -> i64 {\n    conn.execute(\n        \"INSERT INTO issues (gitlab_id, iid, project_id, title, state, author_username, created_at, updated_at, last_seen_at, description) VALUES (?1, ?2, ?3, 'Test Issue', 'opened', 'testuser', 1704067200000, 1704153600000, 1704153600000, ?4)\",\n        rusqlite::params![iid * 10, iid, project_id, desc],\n    ).unwrap();\n    conn.last_insert_rowid()\n}\n\\`\\`\\`\n\n## Boundaries\n\nAutonomous: Register command in CLI, wire dispatch, create all types, implement entity resolution and description_excerpt, write test helpers.\n\nAsk first: Adding new dependencies to Cargo.toml, modifying existing show/ or timeline/ modules.\n\nNever: No LLM calls, no API/network calls, no new DB migrations, do not modify show/ or timeline/ (copy patterns instead).\n\n## TDD Workflow (Red-Green)\n\n### RED — Write these tests first, confirm they FAIL:\n\n1. test_explain_issue_basic: Insert minimal issue + project + 1 discussion + 1 note + 1 state event into in-memory DB, call run_explain() with default ExplainParams, assert all 7 sections present in result (entity is always populated, rest are Some(...))\n2. test_explain_mr: Insert MR with merged_at, call run_explain(), assert entity.entity_type == \"merge_request\"\n3. test_explain_singular_entity_type: Call with entity_type: \"issue\", assert it normalizes to \"issues\" and resolves correctly\n\n### GREEN — Implement:\n\n- Explain variant in Commands enum (with all flags — use exact Clap code above)\n- Match arm in main.rs calling handle_explain\n- handle_explain() that normalizes entity_type, parses --since, validates --sections, builds ExplainParams, calls run_explain()\n- Skeleton run_explain() as shown above\n- find_explain_issue() and find_explain_mr() copied from show/ patterns\n- should_include() helper\n- Test helpers: setup_explain_db(), insert_test_issue(), insert_test_mr()\n\n### Verify:\ncargo test explain::tests:: && cargo clippy --all-targets -- -D warnings && cargo fmt --check\n\n## Acceptance Criteria\n\n- [ ] test_explain_issue_basic passes\n- [ ] test_explain_mr passes\n- [ ] test_explain_singular_entity_type passes\n- [ ] Command appears in lore --help with after_help examples\n- [ ] All ExplainResult types defined with serde Serialize + skip_serializing_if\n- [ ] run_explain() skeleton returns valid ExplainResult with entity populated and stubs for other sections\n- [ ] cargo clippy and cargo fmt clean","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-10T17:36:51.671656Z","created_by":"tayloreernisse","updated_at":"2026-03-10T18:37:44.327202Z","closed_at":"2026-03-10T18:37:44.327144Z","close_reason":"Explain command registered, CLI wired, entity resolution + description working, 6 tests passing","compaction_level":0,"original_size":0,"labels":["cli-imp","explain"],"dependencies":[{"issue_id":"bd-2i3z","depends_on_id":"bd-9lbr","type":"parent-child","created_at":"2026-03-10T17:36:51.673556Z","created_by":"tayloreernisse"}]}
{"id":"bd-2ilv","title":"Implement robot JSON output for me command","description":"## Background\n`lore me` robot output must follow the existing lore envelope pattern and expose a stable machine contract. This bead is the canonical serializer for that contract.\n\nCritical schema decision: use `project_path` consistently across work items and activity payloads (matching `Me*` structs), not mixed `project`/`project_path` naming.\n\n## Approach\nImplement `render_robot` in `src/cli/commands/me/render_robot.rs` with a typed envelope:\n\n```rust\n#[derive(Serialize)]\nstruct MeJsonEnvelope {\n    ok: bool,\n    data: MeDashboard,\n    meta: RobotMeta,\n}\n\npub fn render_robot(dashboard: &MeDashboard, elapsed_ms: u64) -> Result<String, serde_json::Error>\n```\n\nRules:\n- Serialize `MeDashboard` directly under `data`.\n- Keep all section arrays present, including empty arrays.\n- Keep nullable fields as JSON `null`.\n- Emit compact JSON (single-line) for parity with existing robot commands.\n\nDo not apply `--fields` filtering in this bead; filtering behavior is layered by `bd-3jiq`.\n\n## Acceptance Criteria\n- [ ] Output is `{ok,data,meta}` envelope with `ok=true`\n- [ ] `meta.elapsed_ms` is present and numeric\n- [ ] `data` contains: `username`, `since_iso`, `summary`, `open_issues`, `open_mrs_authored`, `reviewing_mrs`, `activity`\n- [ ] Work item payloads use `project_path` field name (no `project` alias)\n- [ ] Activity payload uses `project_path` field name\n- [ ] `attention_state` serializes in snake_case\n- [ ] Issues include nullable `status_name`\n- [ ] Authored MRs include nullable `detailed_merge_status` and `draft`\n- [ ] Reviewing MRs include `author_username` and `draft`\n- [ ] Empty arrays serialize as `[]` (not omitted)\n- [ ] Serializer never panics in normal flow (returns Result and caller handles)\n\n## Files\n- CREATE: `src/cli/commands/me/render_robot.rs`\n- MODIFY: `src/cli/commands/me/mod.rs` (wire renderer)\n\n## TDD Anchor\nRED:\n- `test_robot_envelope_structure`\n- `test_robot_uses_project_path_field`\n- `test_robot_attention_state_snake_case`\n- `test_robot_empty_arrays_present`\n- `test_robot_nullable_fields_serialize_null`\n\nGREEN:\n- Implement typed envelope serializer and wire into handler.\n\nVERIFY:\n- `cargo test robot_envelope`\n- `cargo test me_robot`\n\n## Edge Cases\n- If JSON serialization fails unexpectedly, propagate error into command error path rather than falling back to ad-hoc JSON strings.\n- Keep key order deterministic only where serde guarantees struct order; tests should assert presence/shape, not strict key ordering.\n\n## Dependency Context\nConsumes data structs from `bd-3bwh` and `RobotMeta` from `src/cli/robot.rs`.\nFeeds `bd-3jiq` which applies section-array field projection on top of this full payload.\n\nDependencies:\n  -> bd-3bwh (blocks) - Define dashboard data structs for me command\n\nDependents:\n  <- bd-3jiq (blocks) - Implement --fields minimal preset for me robot mode","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-19T19:40:07.681875Z","created_by":"tayloreernisse","updated_at":"2026-02-20T16:09:13.063421Z","closed_at":"2026-02-20T16:09:13.063381Z","close_reason":"Implemented by lore-me agent swarm","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2ilv","depends_on_id":"bd-3bwh","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2iq","title":"[CP1] Database migration 002_issues.sql","description":"## Background\n\nThe 002_issues.sql migration creates tables for issues, labels, issue_labels, discussions, and notes. This is the data foundation for Checkpoint 1, enabling issue ingestion with cursor-based sync, label tracking, and discussion storage.\n\n## Approach\n\nCreate `migrations/002_issues.sql` with complete SQL statements.\n\n### Full Migration SQL\n\n```sql\n-- Migration 002: Issue Ingestion Tables\n-- Applies on top of 001_initial.sql\n\n-- Issues table\nCREATE TABLE issues (\n    id INTEGER PRIMARY KEY,\n    gitlab_id INTEGER UNIQUE NOT NULL,\n    project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,\n    iid INTEGER NOT NULL,\n    title TEXT,\n    description TEXT,\n    state TEXT NOT NULL CHECK (state IN ('opened', 'closed')),\n    author_username TEXT,\n    created_at INTEGER NOT NULL,           -- ms epoch UTC\n    updated_at INTEGER NOT NULL,           -- ms epoch UTC\n    last_seen_at INTEGER NOT NULL,         -- updated on every upsert\n    discussions_synced_for_updated_at INTEGER, -- watermark for dependent sync\n    web_url TEXT,\n    raw_payload_id INTEGER REFERENCES raw_payloads(id)\n);\n\nCREATE INDEX idx_issues_project_updated ON issues(project_id, updated_at);\nCREATE INDEX idx_issues_author ON issues(author_username);\nCREATE UNIQUE INDEX uq_issues_project_iid ON issues(project_id, iid);\n\n-- Labels table (name-only for CP1)\nCREATE TABLE labels (\n    id INTEGER PRIMARY KEY,\n    gitlab_id INTEGER,                      -- optional, for future Labels API\n    project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,\n    name TEXT NOT NULL,\n    color TEXT,\n    description TEXT\n);\n\nCREATE UNIQUE INDEX uq_labels_project_name ON labels(project_id, name);\nCREATE INDEX idx_labels_name ON labels(name);\n\n-- Issue-label junction (DELETE before INSERT for stale removal)\nCREATE TABLE issue_labels (\n    issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,\n    label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,\n    PRIMARY KEY(issue_id, label_id)\n);\n\nCREATE INDEX idx_issue_labels_label ON issue_labels(label_id);\n\n-- Discussion threads for issues (MR discussions added in CP2)\nCREATE TABLE discussions (\n    id INTEGER PRIMARY KEY,\n    gitlab_discussion_id TEXT NOT NULL,     -- GitLab string ID (e.g., \"6a9c1750b37d...\")\n    project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,\n    issue_id INTEGER REFERENCES issues(id) ON DELETE CASCADE,\n    merge_request_id INTEGER,               -- FK added in CP2 via ALTER TABLE\n    noteable_type TEXT NOT NULL CHECK (noteable_type IN ('Issue', 'MergeRequest')),\n    individual_note INTEGER NOT NULL DEFAULT 0,  -- 0=threaded, 1=standalone\n    first_note_at INTEGER,                  -- min(note.created_at) for ordering\n    last_note_at INTEGER,                   -- max(note.created_at) for \"recently active\"\n    last_seen_at INTEGER NOT NULL,          -- updated on every upsert\n    resolvable INTEGER NOT NULL DEFAULT 0,  -- MR discussions can be resolved\n    resolved INTEGER NOT NULL DEFAULT 0,\n    CHECK (\n        (noteable_type = 'Issue' AND issue_id IS NOT NULL AND merge_request_id IS NULL) OR\n        (noteable_type = 'MergeRequest' AND merge_request_id IS NOT NULL AND issue_id IS NULL)\n    )\n);\n\nCREATE UNIQUE INDEX uq_discussions_project_discussion_id ON discussions(project_id, gitlab_discussion_id);\nCREATE INDEX idx_discussions_issue ON discussions(issue_id);\nCREATE INDEX idx_discussions_mr ON discussions(merge_request_id);\nCREATE INDEX idx_discussions_last_note ON discussions(last_note_at);\n\n-- Notes belong to discussions\nCREATE TABLE notes (\n    id INTEGER PRIMARY KEY,\n    gitlab_id INTEGER UNIQUE NOT NULL,\n    discussion_id INTEGER NOT NULL REFERENCES discussions(id) ON DELETE CASCADE,\n    project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,\n    note_type TEXT,                         -- 'DiscussionNote' | 'DiffNote' | null\n    is_system INTEGER NOT NULL DEFAULT 0,   -- 1 for system-generated notes\n    author_username TEXT,\n    body TEXT,\n    created_at INTEGER NOT NULL,            -- ms epoch\n    updated_at INTEGER NOT NULL,            -- ms epoch\n    last_seen_at INTEGER NOT NULL,          -- updated on every upsert\n    position INTEGER,                       -- 0-indexed array order from API\n    resolvable INTEGER NOT NULL DEFAULT 0,\n    resolved INTEGER NOT NULL DEFAULT 0,\n    resolved_by TEXT,\n    resolved_at INTEGER,\n    -- DiffNote position metadata (populated for MR DiffNotes in CP2)\n    position_old_path TEXT,\n    position_new_path TEXT,\n    position_old_line INTEGER,\n    position_new_line INTEGER,\n    raw_payload_id INTEGER REFERENCES raw_payloads(id)\n);\n\nCREATE INDEX idx_notes_discussion ON notes(discussion_id);\nCREATE INDEX idx_notes_author ON notes(author_username);\nCREATE INDEX idx_notes_system ON notes(is_system);\n\n-- Update schema version\nINSERT INTO schema_version (version, applied_at, description)\nVALUES (2, strftime('%s', 'now') * 1000, 'Issue ingestion tables');\n```\n\n## Acceptance Criteria\n\n- [ ] Migration file exists at `migrations/002_issues.sql`\n- [ ] All tables created: issues, labels, issue_labels, discussions, notes\n- [ ] All indexes created as specified\n- [ ] CHECK constraints on state and noteable_type work correctly\n- [ ] CASCADE deletes work (project deletion cascades)\n- [ ] Migration applies cleanly on fresh DB after 001_initial.sql\n- [ ] schema_version updated to 2 after migration\n- [ ] `gi doctor` shows schema_version = 2\n\n## Files\n\n- migrations/002_issues.sql (create)\n\n## TDD Loop\n\nRED:\n```rust\n// tests/migration_tests.rs\n#[test] fn migration_002_creates_issues_table()\n#[test] fn migration_002_creates_labels_table()\n#[test] fn migration_002_creates_discussions_table()\n#[test] fn migration_002_creates_notes_table()\n#[test] fn migration_002_enforces_state_check()\n#[test] fn migration_002_enforces_noteable_type_check()\n#[test] fn migration_002_cascades_on_project_delete()\n```\n\nGREEN: Create migration file with all SQL\n\nVERIFY:\n```bash\n# Apply migration to test DB\nsqlite3 :memory: < migrations/001_initial.sql\nsqlite3 :memory: < migrations/002_issues.sql\n\n# Verify schema_version\nsqlite3 test.db \"SELECT version FROM schema_version ORDER BY version DESC LIMIT 1\"\n# Expected: 2\n\ncargo test migration_002\n```\n\n## Edge Cases\n\n- Applying twice - should fail on UNIQUE constraint (idempotency via version check)\n- Missing 001 - foreign key to projects fails\n- Long label names - TEXT handles any length\n- NULL description - allowed by schema\n- Empty discussions_synced_for_updated_at - NULL means never synced","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-25T17:02:38.128594Z","created_by":"tayloreernisse","updated_at":"2026-01-25T22:25:10.309900Z","closed_at":"2026-01-25T22:25:10.309852Z","close_reason":"Created 002_issues.sql with issues/labels/issue_labels/discussions/notes tables, 8 passing tests verify schema, constraints, and cascades","compaction_level":0,"original_size":0}
{"id":"bd-2iqk","title":"Implement Doctor + Stats screens","description":"## Background\nDoctor shows environment health checks (config, auth, DB, Ollama). Stats shows database statistics (entity counts, index sizes, FTS coverage). Both are informational screens using ftui JsonView or simple table layouts.\n\n## Approach\nState:\n- DoctorState: checks (Vec<HealthCheck>), overall_status (Healthy|Warning|Error)\n- StatsState: entity_stats (EntityStats), index_stats (IndexStats), fts_stats (FtsStats)\n\nAction:\n- run_doctor(config, conn) -> Vec<HealthCheck>: reuses existing lore doctor logic\n- fetch_stats(conn) -> StatsData: reuses existing lore stats logic\n\nView:\n- Doctor: vertical list of health checks with pass/fail/warn indicators\n- Stats: table of entity counts, index sizes, FTS document count, embedding coverage\n\n## Acceptance Criteria\n- [ ] Doctor shows config, auth, DB, and Ollama health status\n- [ ] Stats shows entity counts matching lore --robot stats output\n- [ ] Both screens accessible via navigation (gd for Doctor)\n- [ ] Health check results color-coded: green pass, yellow warn, red fail\n\n## Files\n- CREATE: crates/lore-tui/src/state/doctor.rs\n- CREATE: crates/lore-tui/src/state/stats.rs\n- CREATE: crates/lore-tui/src/view/doctor.rs\n- CREATE: crates/lore-tui/src/view/stats.rs\n- MODIFY: crates/lore-tui/src/action.rs (add run_doctor, fetch_stats)\n\n## TDD Anchor\nRED: Write test_fetch_stats_counts that creates DB with known data, asserts fetch_stats returns correct counts.\nGREEN: Implement fetch_stats with COUNT queries.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_fetch_stats\n\n## Edge Cases\n- Ollama not running: Doctor shows warning, not error (optional dependency)\n- Very large databases: stats queries should be fast (use shadow tables for FTS count)\n\n## Dependency Context\nUses existing doctor and stats logic from lore CLI commands.\nUses DbManager from \"Implement DbManager\" task.","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-02-12T17:02:21.744226Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:22.628151Z","deleted_at":"2026-03-11T18:34:22.628145Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-2jzn","title":"Migration 021: Add status columns to issues table","description":"## Background\nGitLab issues have work item status (To do, In progress, Done, Won't do, Duplicate) only available via GraphQL. We need 5 nullable columns on the issues table to store this data after enrichment. The status_synced_at column tracks when enrichment last wrote/cleared each row (ms epoch UTC).\n\n## Approach\nCreate a new SQL migration file and register it in the MIGRATIONS array. SQLite ALTER TABLE ADD COLUMN is non-destructive — existing rows get NULL defaults. Add a compound index for --status filter performance.\n\n## Files\n- migrations/021_work_item_status.sql (NEW)\n- src/core/db.rs (add entry to MIGRATIONS array)\n\n## Implementation\n\nmigrations/021_work_item_status.sql:\n  ALTER TABLE issues ADD COLUMN status_name TEXT;\n  ALTER TABLE issues ADD COLUMN status_category TEXT;\n  ALTER TABLE issues ADD COLUMN status_color TEXT;\n  ALTER TABLE issues ADD COLUMN status_icon_name TEXT;\n  ALTER TABLE issues ADD COLUMN status_synced_at INTEGER;\n  CREATE INDEX IF NOT EXISTS idx_issues_project_status_name ON issues(project_id, status_name);\n\nIn src/core/db.rs, add as last entry in MIGRATIONS array:\n  (\"021\", include_str!(\"../../migrations/021_work_item_status.sql\")),\nLATEST_SCHEMA_VERSION is computed as MIGRATIONS.len() as i32 — auto-becomes 21.\n\n## Acceptance Criteria\n- [ ] Migration file exists at migrations/021_work_item_status.sql\n- [ ] MIGRATIONS array has 21 entries ending with (\"021\", ...)\n- [ ] In-memory DB: PRAGMA table_info(issues) includes all 5 new columns\n- [ ] In-memory DB: PRAGMA index_list(issues) includes idx_issues_project_status_name\n- [ ] Existing rows have NULL for all 5 new columns\n- [ ] cargo check --all-targets passes\n\n## TDD Loop\nRED: test_migration_021_adds_columns, test_migration_021_adds_index\n  Pattern: create_connection(Path::new(\":memory:\")) + run_migrations(&conn), then PRAGMA queries\nGREEN: Create SQL file + register in MIGRATIONS\nVERIFY: cargo test test_migration_021\n\n## Edge Cases\n- Migration has 5 columns (including status_synced_at INTEGER), not 4\n- Test project insert uses gitlab_project_id, path_with_namespace, web_url (no name/last_seen_at)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-11T06:41:40.806320Z","created_by":"tayloreernisse","updated_at":"2026-02-11T07:21:33.414434Z","closed_at":"2026-02-11T07:21:33.414387Z","close_reason":"Implemented by agent swarm — all quality gates pass (595 tests, 0 failures)","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2jzn","depends_on_id":"bd-2y79","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2kop","title":"Implement DbManager (read pool + dedicated writer)","description":"## Background\nThe TUI needs concurrent database access: multiple read queries can run in parallel (e.g., loading dashboard stats while prefetching issue list), but writes must be serialized. The DbManager provides a read pool (3 connections, round-robin) plus a dedicated writer connection, accessed via closures.\n\nThe database uses WAL mode with 5000ms busy_timeout (already configured in lore's create_connection). WAL allows concurrent readers + single writer. The TUI is self-contained — it does NOT detect or react to external CLI sync operations. If someone runs lore sync externally while the TUI is open, WAL prevents conflicts and the TUI's natural re-query on navigation handles stale data implicitly.\n\n## Approach\nCreate `crates/lore-tui/src/db.rs`:\n\n```rust\npub struct DbManager {\n    readers: Vec<Connection>,  // 3 connections, WAL mode\n    writer: Connection,         // dedicated writer\n    next_reader: AtomicUsize,   // round-robin index\n}\n```\n\n- `DbManager::open(path: &Path) -> Result<Self>` — opens 4 connections (3 read + 1 write), all with WAL + busy_timeout via lore::core::db::create_connection\n- `with_reader<F, T>(&self, f: F) -> Result<T> where F: FnOnce(&Connection) -> Result<T>` — closure-based read access, round-robin selection\n- `with_writer<F, T>(&self, f: F) -> Result<T> where F: FnOnce(&Connection) -> Result<T>` — closure-based write access (serialized)\n- Reader connections set `PRAGMA query_only = ON` as a safety guard\n- All connections reuse lore's `create_connection()` which sets WAL + busy_timeout + foreign_keys\n\nThe DbManager is created once at app startup and shared (via Arc) across all screen states and action tasks.\n\n## Acceptance Criteria\n- [ ] DbManager opens 3 reader + 1 writer connection\n- [ ] Readers use round-robin selection via AtomicUsize\n- [ ] Reader connections have query_only = ON\n- [ ] Writer connection allows INSERT/UPDATE/DELETE\n- [ ] with_reader and with_writer use closure-based access (no connection leaking)\n- [ ] All connections use WAL mode and 5000ms busy_timeout\n- [ ] DbManager is Send + Sync (can be shared via Arc across async tasks)\n- [ ] Unit test: concurrent reads don't block each other\n- [ ] Unit test: write through reader connection fails (query_only guard)\n\n## Files\n- CREATE: crates/lore-tui/src/db.rs\n- MODIFY: crates/lore-tui/src/lib.rs (add pub mod db)\n\n## TDD Anchor\nRED: Write `test_reader_is_query_only` that opens a DbManager on an in-memory DB, attempts an INSERT via with_reader, and asserts it fails.\nGREEN: Implement DbManager with query_only pragma on readers.\nVERIFY: cargo test -p lore-tui db -- --nocapture\n\nAdditional tests:\n- test_writer_allows_mutations\n- test_round_robin_rotates_readers\n- test_dbmanager_is_send_sync (compile-time assert)\n- test_concurrent_reads (spawn threads, all complete without blocking)\n\n## Edge Cases\n- Database file doesn't exist — create_connection handles this (creates new DB)\n- Database locked by external process — busy_timeout handles retry\n- Connection pool exhaustion — not possible with closure-based access (connection is borrowed, not taken)\n- AtomicUsize overflow — wraps around, which is fine for round-robin (modulo 3)\n\n## Dependency Context\nDepends on bd-3ddw (scaffold) for the crate to exist. Uses lore::core::db::create_connection for connection setup. All screen action modules depend on DbManager for data access.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T16:53:59.708214Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:22.706868Z","deleted_at":"2026-03-11T18:34:22.706863Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-2kr0","title":"Implement MR List (state + action + view)","description":"## Background\nThe MR List mirrors the Issue List pattern with MR-specific columns (target branch, source branch, draft status, reviewer). Same keyset pagination, snapshot fence, and filter bar DSL.\n\n## Approach\nState (state/mr_list.rs):\n- MrListState: same structure as IssueListState but with MrFilter and MrListRow, plus snapshot_upper_updated_at, filter_hash, peek_visible, peek_content\n- MrFilter: state, author, reviewer, target_branch, source_branch, label, draft (bool), free_text, project_id\n- MrListRow: project_path, iid, title, state, author, reviewer, target_branch, labels, updated_at, draft\n- MrCursor: updated_at, iid for keyset pagination\n- handle_key(): j/k scroll, J/K page, Enter select, / focus filter, Tab sort, g+g top, G bottom, r refresh, Space toggle Quick Peek\n\n**Snapshot fence:** Same pattern as Issue List — store snapshot_upper_updated_at on first load and refresh, filter subsequent pages. Explicit refresh (r) resets.\n\n**filter_hash:** Same pattern as Issue List — filter change resets cursor to page 1.\n\n**Quick Peek (Space key):**\n- Space toggles right-side preview pane (40% width) showing selected MR detail\n- Preview loads asynchronously via TaskSupervisor\n- j/k updates preview for newly selected row\n- Narrow terminals (<100 cols): peek replaces list\n\nAction (action.rs):\n- fetch_mrs(conn, filter, cursor, page_size, clock, snapshot_fence) -> Result<MrListPage, LoreError>: keyset query against merge_requests table. Uses idx_mrs_list_default index.\n- fetch_mr_peek(conn, entity_key) -> Result<MrDetail, LoreError>: loads MR detail for Quick Peek preview\n\nView (view/mr_list.rs):\n- render_mr_list(frame, state, area, theme): FilterBar + EntityTable with MR columns\n- When peek_visible: split area horizontally — list (60%) | peek preview (40%)\n- Columns: IID, Title (flex), State, Author, Target, Labels, Updated, Draft indicator\n- Draft MRs shown with muted style and [WIP] tag\n\n## Acceptance Criteria\n- [ ] Keyset pagination works for MR list (same pattern as issues)\n- [ ] Browse snapshot fence prevents rows shifting during concurrent sync\n- [ ] Explicit refresh (r) resets snapshot fence\n- [ ] filter_hash resets cursor on filter change\n- [ ] MR-specific filter fields: draft, reviewer, target_branch, source_branch\n- [ ] Draft MRs visually distinguished with [WIP] indicator\n- [ ] State filter supports: opened, merged, closed, locked, all\n- [ ] Columns: IID, Title, State, Author, Target Branch, Labels, Updated\n- [ ] Enter navigates to MrDetail, Esc returns with state preserved\n- [ ] Space toggles Quick Peek right-side preview pane\n- [ ] Quick Peek loads MR detail asynchronously\n- [ ] j/k in peek mode updates preview for newly selected row\n- [ ] Narrow terminal (<100 cols): peek replaces list\n\n## Files\n- MODIFY: crates/lore-tui/src/state/mr_list.rs (expand from stub)\n- MODIFY: crates/lore-tui/src/action.rs (add fetch_mrs, fetch_mr_peek)\n- CREATE: crates/lore-tui/src/view/mr_list.rs\n\n## TDD Anchor\nRED: Write test_fetch_mrs_draft_filter in action.rs that inserts 5 MRs (3 draft, 2 not), calls fetch_mrs with draft=true filter, asserts 3 results.\nGREEN: Implement fetch_mrs with draft filter.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_fetch_mrs\n\nAdditional tests:\n- test_mr_snapshot_fence: verify fence excludes newer rows\n- test_mr_filter_hash_reset: verify filter change resets cursor\n\n## Edge Cases\n- MR state \"locked\" is rare but must be handled in filter and display\n- Very long branch names: truncate with ellipsis\n- MRs with no reviewer: show \"-\" in reviewer column\n- Quick Peek on empty list: no-op\n- Rapid j/k with peek open: debounce peek loads\n\n## Dependency Context\nUses EntityTable and FilterBar from \"Implement entity table + filter bar widgets\" (bd-18qs).\nUses same keyset pagination pattern from \"Implement Issue List\" (bd-3ei1).\nUses MrListState from \"Implement AppState composition\" (bd-1v9m).\nUses TaskSupervisor for load management from \"Implement TaskSupervisor\" (bd-3le2).\nRequires idx_mrs_list_default index from \"Add required TUI indexes\" (bd-3pm2).","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T16:59:24.070743Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:22.786389Z","deleted_at":"2026-03-11T18:34:22.786382Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-2l3s","title":"Per-note search: search individual comments at note granularity","description":"## Background\nMost knowledge in a GitLab project is buried in discussion threads. Current lore search operates at document level (one doc per issue/MR/discussion). An agent searching for \"we decided to use Redis\" only finds the parent issue, not the specific comment where that decision was stated.\n\n## Current State (Verified 2026-02-12)\n- documents table (migration 007): source_type, source_id, project_id, author_username, label_names, content_text, content_hash, etc. NO source_note_id column.\n- source_type values: 'issue', 'merge_request', 'discussion' — discussion docs concatenate all notes into one text blob\n- notes table: 282K rows with individual note body, author, timestamps, is_system flag\n- discussions table: links notes to their parent entity (noteable_type, noteable_id)\n- FTS5 index (documents_fts): operates on coarse document-level text\n- Document generation: src/documents/extractor.rs extracts issue/MR/discussion documents\n- Document regeneration: src/documents/regenerator.rs handles dirty document refresh\n- PRD exists: docs/prd-per-note-search.md with 5 feedback iterations\n\n## Approach\n\n### Schema (Migration 022)\nThis bead owns migration 022. bd-2g50 (data gaps) ships after this and uses migration 023.\n\n```sql\n-- migrations/022_note_documents.sql\nALTER TABLE documents ADD COLUMN source_note_id INTEGER REFERENCES notes(id);\nCREATE INDEX idx_documents_source_note ON documents(source_note_id) WHERE source_note_id IS NOT NULL;\n```\n- source_note_id = NULL for existing entity-level documents (backwards compatible)\n- source_note_id = notes.id for new note-level documents\n\nWire into src/core/db.rs MIGRATIONS array as entry (\"022\", include_str!(\"../../migrations/022_note_documents.sql\")). LATEST_SCHEMA_VERSION auto-updates since it's `MIGRATIONS.len() as i32`.\n\n### Document Generation (src/documents/extractor.rs)\nAdd a new extraction function alongside existing `extract_issue_document()` (line 85), `extract_mr_document()` (line 186), `extract_discussion_document()` (line 302):\n\n```rust\npub fn extract_note_documents(\n    conn: &Connection,\n    project_id: i64,\n) -> Result<Vec<DocumentData>> {\n    // SELECT n.id, n.body, n.author_username, n.created_at, n.updated_at,\n    //        d.noteable_type, d.noteable_id\n    // FROM notes n\n    // JOIN discussions d ON n.discussion_id = d.id\n    // WHERE n.is_system = 0\n    //   AND LENGTH(n.body) >= 50\n    //   AND d.project_id = ?1\n    //   AND n.id NOT IN (SELECT source_note_id FROM documents WHERE source_note_id IS NOT NULL)\n\n    // For each qualifying note:\n    // - source_type = 'note'\n    // - source_id = note.id (the note's local DB id)\n    // - source_note_id = note.id\n    // - title = format!(\"Re: {}\", parent_entity_title)\n    // - author_username = note.author_username\n    // - content_text = note.body\n    // - content_hash = sha256(note.body) for deduplication\n}\n```\n\nMinimum note length (50 chars) filters out \"+1\", \"LGTM\", emoji-only notes. is_system=0 filters automated state change notes.\n\nNOTE: The documents table CHECK constraint for source_type needs updating — currently enforces `CHECK (source_type IN ('issue','merge_request','discussion'))`. Migration 022 must also:\n```sql\n-- Drop and recreate the CHECK constraint is not supported in SQLite ALTER TABLE.\n-- Instead, the check is only on INSERT, so we need to handle this:\n-- Option A: Don't add 'note' to CHECK — just insert with source_type='note' and let\n--           SQLite ignore the CHECK on ALTER (it won't — CHECK is enforced).\n-- Option B: Use source_type='discussion' for note docs (semantically wrong).\n-- Option C: Recreate the table (heavy migration).\n-- RECOMMENDED: Use a new migration that drops the CHECK constraint entirely.\n--   SQLite doesn't support ALTER TABLE ... DROP CONSTRAINT, so:\n--   CREATE TABLE documents_new (... without CHECK ...);\n--   INSERT INTO documents_new SELECT * FROM documents;\n--   DROP TABLE documents;\n--   ALTER TABLE documents_new RENAME TO documents;\n--   Recreate indexes and triggers.\n-- This is the only correct approach. The CHECK constraint is in migration 007.\n```\n\n### Search Integration\nAdd --granularity flag to search command:\n\n```rust\n// In SearchCliFilters or SearchFilters (src/search/filters.rs:15)\npub granularity: Option<Granularity>,  // note | entity (default)\n\n// In FTS query construction (src/search/fts.rs)\n// When granularity = note:\n//   AND d.source_note_id IS NOT NULL\n// When granularity = entity (or default):\n//   AND d.source_note_id IS NULL (existing behavior)\n```\n\n### Robot Mode Output (note granularity)\n```json\n{\n  \"source_type\": \"note\",\n  \"title\": \"Re: Switch Health Card\",\n  \"parent_type\": \"issue\",\n  \"parent_iid\": 3864,\n  \"parent_title\": \"Switch Health Card (Throw Times)\",\n  \"note_author\": \"teernisse\",\n  \"note_created_at\": \"2026-02-01T...\",\n  \"discussion_id\": \"abc123\",\n  \"snippet\": \"...decided to use once-per-day ingestion from BNSF...\",\n  \"score\": 0.87\n}\n```\n\nJoin path for note metadata:\n```sql\nSELECT d.source_note_id, n.author_username, n.created_at,\n       disc.gitlab_discussion_id,\n       CASE disc.noteable_type\n         WHEN 'Issue' THEN 'issue'\n         WHEN 'MergeRequest' THEN 'merge_request'\n       END as parent_type,\n       disc.noteable_id\nFROM documents d\nJOIN notes n ON d.source_note_id = n.id\nJOIN discussions disc ON n.discussion_id = disc.id\nWHERE d.source_note_id IS NOT NULL AND d.id IN (...)\n```\n\n## TDD Loop\nRED: Tests in src/documents/extractor.rs (or new test file):\n- test_note_document_generation: insert issue + discussion + 3 notes (one 10 chars, one 60 chars, one 200 chars), run extract_note_documents, assert 2 note-level documents created (>= 50 chars only)\n- test_note_document_skips_system_notes: insert system note (is_system=1) with 100-char body, assert no document generated\n- test_note_document_content_hash_dedup: insert note, generate doc, re-run, assert no duplicate created\n- test_note_document_parent_title: assert generated doc title starts with \"Re: \"\n\nTests in src/cli/commands/search.rs:\n- test_search_granularity_note_filter: with note docs in DB, --granularity note returns only note results\n- test_search_granularity_entity_default: default behavior unchanged, does NOT return note docs\n\nGREEN: Add migration, update extractor, add --granularity flag to search\n\nVERIFY:\n```bash\ncargo test note_document && cargo test search_granularity\ncargo clippy --all-targets -- -D warnings\ncargo run --release -- -J search 'ingestion' --granularity note | jq '.data.results[0].parent_iid'\n```\n\n## Acceptance Criteria\n- [ ] Migration 022 adds source_note_id to documents table (nullable, indexed, FK to notes)\n- [ ] Migration 022 handles the source_type CHECK constraint (allows 'note' as valid value)\n- [ ] extract_note_documents creates note-level docs for notes >= 50 chars, non-system\n- [ ] Content hash deduplication prevents duplicate note documents\n- [ ] lore search --granularity note returns note-level results with parent context\n- [ ] lore search (no flag) returns entity-level results only (backwards compatible)\n- [ ] Robot mode includes parent_type, parent_iid, parent_title, note_author, note_created_at\n- [ ] Performance: note-level FTS search across expanded index completes in <200ms\n- [ ] Embedding pipeline handles note-level documents (embed individually, same as entity docs)\n- [ ] lore stats shows note document count separately from entity document count\n\n## Edge Cases\n- Note with only markdown formatting (no text after stripping): skip (LENGTH(body) >= 50 handles most)\n- Note body is a quote of another note (duplicated text): deduplicate via content_hash\n- Very long note (>32KB): apply same truncation as entity documents (src/documents/truncation.rs)\n- Discussion with 100+ notes: each becomes its own document (correct behavior)\n- Deleted notes (if tracked): should not generate documents\n- Notes on confidential issues: inherit visibility (future concern, not blocking)\n- source_type CHECK constraint: migration MUST handle this — SQLite enforces CHECK on INSERT, so inserting source_type='note' will fail without updating the constraint\n\n## Files to Modify\n- NEW: migrations/022_note_documents.sql (schema change + CHECK constraint update)\n- src/core/db.rs (wire migration 022 into MIGRATIONS array)\n- src/documents/extractor.rs (add extract_note_documents function)\n- src/documents/mod.rs (export new function)\n- src/search/fts.rs (add granularity filter to FTS queries)\n- src/search/filters.rs (add granularity to SearchFilters at line 15)\n- src/cli/commands/search.rs (--granularity flag, note metadata in SearchResultDisplay)\n- src/cli/commands/stats.rs (show note document count)","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-02-12T15:45:35.465446Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:55:56.774523Z","closed_at":"2026-02-12T16:55:56.774470Z","close_reason":"Replaced by granular beads broken out from docs/prd-per-note-search.md","compaction_level":0,"original_size":0,"labels":["cli-imp","search"],"dependencies":[{"issue_id":"bd-2l3s","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-2l3s","depends_on_id":"bd-2g50","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2ldg","title":"WHO: Mode resolution, path helpers, run_who entry point","description":"## Background\n\nCore scaffolding that all 5 query modes depend on. Defines the mode discrimination logic, path normalization, path-to-SQL translation (with project-scoped DB probes), time resolution, and the run_who() entry point that dispatches to query functions.\n\n## Approach\n\n### WhoMode enum\n```rust\nenum WhoMode<'a> {\n    Expert { path: String },       // owns String (normalization produces new strings)\n    Workload { username: &'a str },  // borrows from args\n    Reviews { username: &'a str },\n    Active,\n    Overlap { path: String },\n}\n```\n\n### resolve_mode() discrimination rules:\n1. --path flag always wins -> Expert\n2. --active -> Active\n3. --overlap -> Overlap\n4. positional target with --reviews -> Reviews\n5. positional target containing '/' -> Expert (username never contains /)\n6. positional target without '/' -> Workload (strip @ prefix)\n7. No args -> error with usage examples\n\n### normalize_repo_path(): strips ./, leading /, collapses //, converts \\ to / (Windows paste, only when no / present), trims whitespace\n\n### PathQuery + build_path_query(conn, path, project_id):\n- Struct: `{ value: String, is_prefix: bool }`\n- Trailing / forces directory prefix\n- Root path (no /) without trailing / -> exact match (handles Makefile, LICENSE via --path)\n- Last segment contains . -> heuristic: file (exact)\n- **Two-way DB probe** (project-scoped): when heuristics are ambiguous, probe DB:\n  - Probe 1: exact path exists? `SELECT 1 FROM notes WHERE note_type='DiffNote' AND is_system=0 AND position_new_path = ?1 AND (?2 IS NULL OR project_id = ?2) LIMIT 1`\n  - Probe 2 (only if exact miss, not forced-dir): prefix exists?\n  - Decision: forced_dir -> prefix; exact_exists -> exact; prefix_exists -> prefix; else heuristic\n- **CRITICAL**: escape_like() is ONLY called for prefix (LIKE) matches. For exact matches (=), use raw path — LIKE metacharacters (_, %) are not special in = comparisons.\n\n### Result types: WhoRun, WhoResolvedInput (since_mode tri-state: \"default\"/\"explicit\"/\"none\"), WhoResult enum, all 5 mode-specific result structs (see plan Step 2 \"Result Types\")\n\n### run_who() entry: resolve project -> resolve mode -> resolve since -> dispatch to query_* -> return WhoRun\n\n### since_mode semantics:\n- Expert/Reviews/Active/Overlap: default window applies if --since absent -> \"default\"\n- Workload: no default window; --since absent -> \"none\"\n- Any mode with explicit --since -> \"explicit\"\n\n## Files\n\n- `src/cli/commands/who.rs` — all code in this file\n\n## TDD Loop\n\nRED:\n```\ntest_is_file_path_discrimination — resolve_mode for paths/usernames/@/--reviews/--path\ntest_build_path_query — directory/file/root/dotted/underscore/dotless\ntest_build_path_query_exact_does_not_escape — _ in exact path stays raw\ntest_path_flag_dotless_root_file_is_exact — Makefile/Dockerfile via --path\ntest_build_path_query_dotless_subdir_file_uses_db_probe — src/Dockerfile with/without DB data\ntest_build_path_query_probe_is_project_scoped — data in proj 1, query proj 2\ntest_escape_like — normal/underscore/percent/backslash\ntest_normalize_repo_path — ./ / \\\\ // whitespace identity\ntest_lookup_project_path — basic round-trip\n```\n\nGREEN: Implement all functions. Query functions can be stubs (todo!()) for now.\nVERIFY: `cargo test -- who`\n\n## Acceptance Criteria\n\n- [ ] resolve_mode correctly discriminates all 7 cases (see tests)\n- [ ] build_path_query returns exact for files, prefix for dirs\n- [ ] build_path_query DB probe is project-scoped (cross-project isolation)\n- [ ] escape_like escapes %, _, \\ correctly\n- [ ] normalize_repo_path handles ./, /, \\\\, //, whitespace\n- [ ] WhoResolvedInput.since_mode is \"none\" for Workload without --since\n\n## Edge Cases\n\n- Dotless files in subdirectories (src/Dockerfile, infra/Makefile) — DB probe catches these, heuristic alone would misclassify as directory\n- Windows path paste (src\\foo\\bar.rs) — convert \\ to / only when no / present\n- LIKE metacharacters in filenames (README_with_underscore.md) — must NOT be escaped for exact match\n- Root files without / (README.md, LICENSE, Makefile) — must use --path flag, positional would treat as username","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-08T02:40:11.209288Z","created_by":"tayloreernisse","updated_at":"2026-02-08T04:10:29.595703Z","closed_at":"2026-02-08T04:10:29.595666Z","close_reason":"Implemented by agent team: migration 017, CLI skeleton, all 5 query modes, human+robot output, 20 tests. All quality gates pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2ldg","depends_on_id":"bd-2rk9","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2lg6","title":"Implement Clock trait (SystemClock + FakeClock)","description":"## Background\nAll relative-time rendering (e.g., \"3h ago\" labels) must use an injected Clock, not wall-clock time. This ensures deterministic snapshot tests and consistent timestamps within a single frame. FakeClock lets tests control time precisely.\n\n## Approach\nCreate crates/lore-tui/src/clock.rs with:\n- Clock trait: fn now(&self) -> chrono::DateTime<chrono::Utc>\n- SystemClock: impl Clock using chrono::Utc::now()\n- FakeClock: wraps Arc<Mutex<DateTime<Utc>>>, impl Clock returning the frozen value. Methods: new(fixed_time), advance(duration), set(time)\n- Both cloneable (SystemClock is Copy, FakeClock shares Arc)\n\n## Acceptance Criteria\n- [ ] Clock trait with now() method\n- [ ] SystemClock returns real wall-clock time\n- [ ] FakeClock returns frozen time, advance() moves it forward\n- [ ] FakeClock is Clone (shared Arc)\n- [ ] Tests pass: frozen clock returns same time on repeated calls\n- [ ] Tests pass: advance() moves time forward by exact duration\n\n## Files\n- CREATE: crates/lore-tui/src/clock.rs\n\n## TDD Anchor\nRED: Write test_fake_clock_frozen that creates FakeClock at a fixed time, calls now() twice, asserts both return the same value.\nGREEN: Implement FakeClock with Arc<Mutex<DateTime>>.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_fake_clock\n\n## Edge Cases\n- FakeClock must be Send+Sync for use across Cmd::task threads\n- advance() must handle chrono overflow gracefully (use checked_add)","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T16:54:11.756415Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:22.865270Z","deleted_at":"2026-03-11T18:34:22.865266Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-2ms","title":"[CP1] Unit tests for transformers","description":"Comprehensive unit tests for issue and discussion transformers.\n\n## Issue Transformer Tests (tests/issue_transformer_tests.rs)\n\n- transforms_gitlab_issue_to_normalized_schema\n- extracts_labels_from_issue_payload\n- handles_missing_optional_fields_gracefully\n- converts_iso_timestamps_to_ms_epoch\n- sets_last_seen_at_to_current_time\n\n## Discussion Transformer Tests (tests/discussion_transformer_tests.rs)\n\n- transforms_discussion_payload_to_normalized_schema\n- extracts_notes_array_from_discussion\n- sets_individual_note_flag_correctly\n- flags_system_notes_with_is_system_true\n- preserves_note_order_via_position_field\n- computes_first_note_at_and_last_note_at_correctly\n- computes_resolvable_and_resolved_status\n\n## Test Setup\n- Load from test fixtures\n- Use serde_json for deserialization\n- Compare against expected NormalizedX structs\n\nFiles: tests/issue_transformer_tests.rs, tests/discussion_transformer_tests.rs\nDone when: All transformer unit tests pass","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T16:59:04.165187Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:02.015847Z","closed_at":"2026-01-25T17:02:02.015847Z","deleted_at":"2026-01-25T17:02:02.015841Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-2mz","title":"Epic: Gate A - Lexical MVP","description":"## Background\nGate A delivers the lexical search MVP — the foundation that works without sqlite-vec or Ollama. It introduces the document layer (documents, document_labels, document_paths), FTS5 indexing, search filters, and the search + stats + generate-docs CLI commands. Gate A is independently shippable — users get working search with FTS5 only.\n\n## Gate A Deliverables\n1. Document generation from issues/MRs/discussions with FTS5 indexing\n2. Lexical search + filters + snippets + lore stats\n\n## Bead Dependencies (execution order)\n1. **bd-3lc** — Rename GiError to LoreError (no deps, enables all subsequent work)\n2. **bd-hrs** — Migration 007 (blocked by bd-3lc)\n3. **bd-221** — Migration 008 FTS5 (blocked by bd-hrs)\n4. **bd-36p** — Document types + extractor module (blocked by bd-3lc)\n5. **bd-18t** — Truncation logic (blocked by bd-36p)\n6. **bd-247** — Issue extraction (blocked by bd-36p, bd-hrs)\n7. **bd-1yz** — MR extraction (blocked by bd-36p, bd-hrs)\n8. **bd-2fp** — Discussion extraction (blocked by bd-36p, bd-hrs, bd-18t)\n9. **bd-1u1** — Document regenerator (blocked by bd-36p, bd-38q, bd-hrs)\n10. **bd-1k1** — FTS5 search (blocked by bd-221)\n11. **bd-3q2** — Search filters (blocked by bd-36p)\n12. **bd-3lu** — Search CLI (blocked by bd-1k1, bd-3q2, bd-36p)\n13. **bd-3qs** — Generate-docs CLI (blocked by bd-1u1, bd-3lu)\n14. **bd-pr1** — Stats CLI (blocked by bd-hrs)\n15. **bd-2dk** — Project resolution (blocked by bd-3lc)\n\n## Acceptance Criteria\n- [ ] `lore search \"query\"` returns FTS5 results with snippets\n- [ ] `lore search --type issue --label bug \"query\"` filters correctly\n- [ ] `lore generate-docs` creates documents from all entities\n- [ ] `lore generate-docs --full` regenerates everything\n- [ ] `lore stats` shows document/FTS/queue counts\n- [ ] `lore stats --check` verifies FTS consistency\n- [ ] No sqlite-vec dependency in Gate A","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-30T15:25:09.721108Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:54:44.243610Z","closed_at":"2026-01-30T17:54:44.243562Z","close_reason":"All Gate A sub-beads complete. Lexical MVP delivered: document extraction (issue/MR/discussion), FTS5 indexing, search with filters/snippets/RRF, generate-docs CLI, stats CLI with integrity check/repair.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2mz","depends_on_id":"bd-3lu","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-2mz","depends_on_id":"bd-3qs","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-2mz","depends_on_id":"bd-pr1","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2n4","title":"Implement trace query: file -> MR -> issue -> discussion chain","description":"## Background\n\nThe trace query builds a chain from file path -> MRs -> issues -> discussions, combining data from mr_file_changes (Gate 4), entity_references (Gate 2), and the existing discussions/notes tables. This is the backend for the trace CLI command.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 5.4 (Query Flow Tier 1).\n\n## Codebase Context\n\n- entity_references table (migration 011): source_entity_type, source_entity_id, target_entity_type, target_entity_id, reference_type, source_method\n- mr_file_changes table (migration 016, bd-1oo): merge_request_id, project_id, old_path, new_path, change_type\n- discussions table: issue_id, merge_request_id\n- notes table: discussion_id, author_username, body, created_at, is_system, position_new_path (for DiffNotes)\n- merge_requests table: iid, title, state, author_username, web_url, merged_at, updated_at\n- issues table: iid, title, state, web_url\n- resolve_rename_chain() from bd-1yx (src/core/file_history.rs) provides multi-path matching\n- reference_type values: 'closes', 'mentioned', 'related'\n\n## Approach\n\nCreate `src/core/trace.rs`:\n\n```rust\nuse rusqlite::Connection;\nuse crate::core::file_history::resolve_rename_chain;\nuse crate::core::error::Result;\n\n#[derive(Debug, Clone, Serialize)]\npub struct TraceChain {\n    pub merge_request: TraceMr,\n    pub issues: Vec<TraceIssue>,\n    pub discussions: Vec<TraceDiscussion>,\n}\n\n#[derive(Debug, Clone, Serialize)]\npub struct TraceMr {\n    pub iid: i64,\n    pub title: String,\n    pub state: String,\n    pub author_username: String,\n    pub web_url: Option<String>,\n    pub merged_at: Option<i64>,\n    pub merge_commit_sha: Option<String>,\n    pub file_change_type: String,\n}\n\n#[derive(Debug, Clone, Serialize)]\npub struct TraceIssue {\n    pub iid: i64,\n    pub title: String,\n    pub state: String,\n    pub web_url: Option<String>,\n    pub reference_type: String,   // \"closes\", \"mentioned\", \"related\"\n}\n\n#[derive(Debug, Clone, Serialize)]\npub struct TraceDiscussion {\n    pub author_username: String,\n    pub body_snippet: String,     // truncated to 500 chars\n    pub created_at: i64,\n    pub is_diff_note: bool,       // true if position_new_path matched\n}\n\n#[derive(Debug, Clone, Serialize)]\npub struct TraceResult {\n    pub path: String,\n    pub resolved_paths: Vec<String>,\n    pub chains: Vec<TraceChain>,\n}\n\npub fn run_trace(\n    conn: &Connection,\n    project_id: i64,\n    path: &str,\n    follow_renames: bool,\n    include_discussions: bool,\n    limit: usize,\n) -> Result<TraceResult> {\n    // 1. Resolve rename chain (unless !follow_renames)\n    let paths = if follow_renames {\n        resolve_rename_chain(conn, project_id, path, 10)?\n    } else {\n        vec![path.to_string()]\n    };\n\n    // 2. Find MRs via mr_file_changes for all resolved paths\n    //    Dynamic IN-clause for path set\n    // 3. For each MR, find linked issues via entity_references\n    // 4. If include_discussions, fetch DiffNote discussions on traced file\n    // 5. Order chains by COALESCE(merged_at, updated_at) DESC, apply limit\n}\n```\n\n### SQL for step 2 (find MRs):\n\nBuild dynamic IN-clause placeholders for the resolved path set:\n```sql\nSELECT DISTINCT mr.id, mr.iid, mr.title, mr.state, mr.author_username,\n       mr.web_url, mr.merged_at, mr.updated_at, mr.merge_commit_sha,\n       mfc.change_type\nFROM mr_file_changes mfc\nJOIN merge_requests mr ON mr.id = mfc.merge_request_id\nWHERE mfc.project_id = ?1\n  AND (mfc.new_path IN (...placeholders...) OR mfc.old_path IN (...placeholders...))\nORDER BY COALESCE(mr.merged_at, mr.updated_at) DESC\nLIMIT ?N\n```\n\n### SQL for step 3 (linked issues):\n```sql\nSELECT i.iid, i.title, i.state, i.web_url, er.reference_type\nFROM entity_references er\nJOIN issues i ON i.id = er.target_entity_id\nWHERE er.source_entity_type = 'merge_request'\n  AND er.source_entity_id = ?1\n  AND er.target_entity_type = 'issue'\n```\n\n### SQL for step 4 (DiffNote discussions):\n```sql\nSELECT n.author_username, n.body, n.created_at, n.position_new_path\nFROM notes n\nJOIN discussions d ON d.id = n.discussion_id\nWHERE d.merge_request_id = ?1\n  AND n.position_new_path IN (...placeholders...)\n  AND n.is_system = 0\nORDER BY n.created_at ASC\n```\n\nRegister in `src/core/mod.rs`: `pub mod trace;`\n\n## Acceptance Criteria\n\n- [ ] run_trace() returns chains ordered by COALESCE(merged_at, updated_at) DESC\n- [ ] Rename-aware: uses all paths from resolve_rename_chain\n- [ ] Issues linked via entity_references (closes, mentioned, related)\n- [ ] DiffNote discussions correctly filtered to traced file paths via position_new_path\n- [ ] Discussion body_snippet truncated to 500 chars\n- [ ] Empty result (file not in any MR) returns TraceResult with empty chains\n- [ ] Limit applies to number of chains (MRs), not total discussions\n- [ ] Module registered in src/core/mod.rs as `pub mod trace;`\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/core/trace.rs` (NEW)\n- `src/core/mod.rs` (add `pub mod trace;`)\n\n## TDD Loop\n\nRED:\n- `test_trace_empty_file` — unknown file returns empty chains\n- `test_trace_finds_mr` — file in mr_file_changes returns chain with correct MR\n- `test_trace_follows_renames` — renamed file finds historical MRs\n- `test_trace_links_issues` — MR with entity_references shows linked issues\n- `test_trace_limits_chains` — limit=1 returns at most 1 chain\n- `test_trace_no_follow_renames` — follow_renames=false only matches literal path\n\nTests need in-memory DB with migrations applied through 016 + test fixtures for mr_file_changes, entity_references, discussions, notes.\n\nGREEN: Implement SQL queries and chain assembly.\n\nVERIFY: `cargo test --lib -- trace`\n\n## Edge Cases\n\n- MR with no linked issues: chain has empty issues vec\n- Same issue linked from multiple MRs: appears in each chain independently\n- DiffNote on old_path (before rename): captured via resolved path set\n- include_discussions=false: skip DiffNote query for performance\n- Null merged_at: falls back to updated_at for ordering\n- Dynamic IN-clause: use rusqlite::params_from_iter for parameterized queries\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:34:32.738743Z","created_by":"tayloreernisse","updated_at":"2026-02-18T21:10:55.618405Z","closed_at":"2026-02-18T21:10:55.618337Z","close_reason":"Trace query backend implemented","compaction_level":0,"original_size":0,"labels":["gate-5","phase-b","query"],"dependencies":[{"issue_id":"bd-2n4","depends_on_id":"bd-1ht","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-2n4","depends_on_id":"bd-3ia","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-2n4","depends_on_id":"bd-z94","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2n82","title":"Phase 2c: Migrate embedding/ollama.rs to HTTP adapter","description":"## What\nReplace all reqwest usage in embedding/ollama.rs with the crate::http adapter.\n\n## Why\nOllama module handles health checks (GET) and embedding requests (POST JSON). ~20 LOC changed.\n\n## Changes\n\n### Health check\n```rust\n// Before: reqwest::get(url).await?.json::<TagsResponse>()?\n// After:\nlet response = self.client.get(&url, &[]).await?;\nlet tags: TagsResponse = response.json()?;\n```\n\n### Embed batch\n```rust\n// Before: self.client.post(&url).json(&request).send().await?\n// After:\nlet response = self.client.post_json(&url, &[], &request).await?;\nif !response.is_success() {\n    let status = response.status;\n    let body = response.text()?;\n    return Err(LoreError::EmbeddingFailed { document_id: 0, reason: format!(\"HTTP {status}: {body}\") });\n}\nlet embed_response: EmbedResponse = response.json()?;\n```\n\n### Standalone health check (check_ollama_health)\nCurrently creates a temporary reqwest::Client. Replace:\n```rust\npub async fn check_ollama_health(base_url: &str) -> bool {\n    let client = Client::with_timeout(Duration::from_secs(5));\n    let url = format!(\"{base_url}/api/tags\");\n    client.get(&url, &[]).await.map_or(false, |r| r.is_success())\n}\n```\n\n## Files Changed\n- src/embedding/ollama.rs (~20 LOC changed)\n\n## Testing\n- All 4 pipeline_tests.rs tests must pass (wiremock, still on tokio)\n- cargo check --all-targets\n- cargo clippy --all-targets -- -D warnings\n\n## Depends On\n- Phase 1 (adapter layer must exist)","notes":"WIREMOCK COMPATIBILITY: Same risk as Phase 2a (bd-lhj8). After migration, 4 pipeline_tests run on #[tokio::test] but call code using asupersync HTTP. See Phase 2a description for resolution options.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-03-06T18:40:06.280100Z","created_by":"tayloreernisse","updated_at":"2026-03-06T21:11:12.591090Z","closed_at":"2026-03-06T21:11:12.589370Z","close_reason":"asupersync migration complete — 5 commits pushed to asupersync-migration branch","compaction_level":0,"original_size":0,"labels":["asupersync","phase-2"],"dependencies":[{"issue_id":"bd-2n82","depends_on_id":"bd-1lj5","type":"parent-child","created_at":"2026-03-06T18:40:06.303504Z","created_by":"tayloreernisse"},{"issue_id":"bd-2n82","depends_on_id":"bd-bqoc","type":"blocks","created_at":"2026-03-06T18:42:50.928621Z","created_by":"tayloreernisse"}]}
{"id":"bd-2nb","title":"[CP1] Issue ingestion module","description":"Fetch and store issues with cursor-based incremental sync.\n\nImplement ingestIssues(options) → { fetched, upserted, labelsCreated }\n\nLogic:\n1. Get current cursor from sync_cursors\n2. Paginate through issues updated after cursor\n3. Apply local filtering for tuple cursor semantics\n4. For each issue:\n   - Store raw payload (compressed)\n   - Upsert issue record\n   - Extract and upsert labels\n   - Link issue to labels via junction\n5. Update cursor after each page commit\n\nFiles: src/ingestion/issues.ts\nTests: tests/integration/issue-ingestion.test.ts\nDone when: Issues, labels, issue_labels populated correctly with resumable cursor","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:19:50.701180Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.154318Z","closed_at":"2026-01-25T15:21:35.154318Z","deleted_at":"2026-01-25T15:21:35.154316Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-2nfs","title":"Implement snapshot test infrastructure + terminal compat matrix","description":"## Background\nSnapshot tests ensure deterministic rendering using FakeClock and ftui's test backend. They capture rendered TUI output as styled text and compare against golden files, catching visual regressions without a real terminal. The terminal compatibility matrix is a separate documentation artifact, not an automated test.\n\n## Approach\n\n### Snapshot Infrastructure\n\n**Test Backend**: Use `ftui_harness::TestBackend` (or equivalent from ftui-harness crate) which captures rendered output as a Buffer without needing a real terminal. If ftui-harness is not available, create a minimal TestBackend that implements ftui's backend trait and stores cells in a `Vec<Vec<Cell>>`.\n\n**Deterministic Rendering**:\n- Inject FakeClock (from bd-2lg6) to freeze all relative time computations (\"2 hours ago\" always renders the same)\n- Fix terminal size to 120x40 for all snapshot tests\n- Use synthetic DB fixture with known data (same fixture pattern as parity tests)\n\n**Snapshot Capture Flow**:\n```rust\nfn capture_snapshot(app: &LoreApp, size: (u16, u16)) -> String {\n    let backend = TestBackend::new(size.0, size.1);\n    // Render app.view() to backend\n    // Convert buffer cells to plain text with ANSI annotations\n    // Return as String\n}\n```\n\n**Golden File Management**:\n- Golden files stored in `crates/lore-tui/tests/snapshots/` as `.snap` files\n- Naming: `{test_name}.snap` (e.g., `dashboard_default.snap`)\n- Update mode: set env var `UPDATE_SNAPSHOTS=1` to overwrite golden files instead of comparing\n- Use `insta` crate (or manual file comparison) for snapshot assertion\n\n**Fixture Data** (synthetic, deterministic):\n- 50 issues (mix of opened/closed/locked states, various labels)\n- 25 MRs (mix of opened/merged/closed/draft)\n- 100 discussions with notes\n- Known timestamps relative to FakeClock's frozen time\n\n### Snapshot Tests\n\nEach test:\n1. Creates in-memory DB with fixture data\n2. Creates LoreApp with FakeClock frozen at 2026-01-15T12:00:00Z\n3. Sets initial screen state\n4. Renders via TestBackend at 120x40\n5. Compares output against golden file\n\nTests to implement:\n- `test_dashboard_snapshot`: Dashboard screen with fixture counts and recent activity\n- `test_issue_list_snapshot`: Issue list with default sort, showing state badges and relative times\n- `test_issue_detail_snapshot`: Single issue detail with description and discussion thread\n- `test_mr_list_snapshot`: MR list showing draft indicators and review status\n- `test_search_results_snapshot`: Search results with highlighted matches\n- `test_empty_state_snapshot`: Dashboard with empty DB (zero issues/MRs)\n\n### Terminal Compatibility Matrix (Documentation)\n\nThis is a manual verification checklist, NOT an automated test. Document results in `crates/lore-tui/TERMINAL_COMPAT.md`:\n\n| Feature | iTerm2 | tmux | Alacritty | kitty |\n|---------|--------|------|-----------|-------|\n| True color (RGB) | | | | |\n| Unicode width (CJK) | | | | |\n| Box-drawing chars | | | | |\n| Bold/italic/underline | | | | |\n| Mouse events | | | | |\n| Resize handling | | | | |\n| Alt screen | | | | |\n\nFill in during manual QA, not during automated test implementation.\n\n## Acceptance Criteria\n- [ ] At least 6 snapshot tests pass with golden files committed to repo\n- [ ] All snapshots use FakeClock frozen at 2026-01-15T12:00:00Z\n- [ ] All snapshots render at fixed 120x40 terminal size\n- [ ] Dashboard snapshot matches golden file (deterministic)\n- [ ] Issue list snapshot matches golden file (deterministic)\n- [ ] Empty state snapshot matches golden file\n- [ ] UPDATE_SNAPSHOTS=1 env var overwrites golden files for updates\n- [ ] Golden files are plain text (diffable in version control)\n- [ ] TERMINAL_COMPAT.md template created (to be filled during manual QA)\n\n## Files\n- CREATE: crates/lore-tui/tests/snapshot_tests.rs\n- CREATE: crates/lore-tui/tests/snapshots/ (directory for golden files)\n- CREATE: crates/lore-tui/tests/snapshots/dashboard_default.snap\n- CREATE: crates/lore-tui/tests/snapshots/issue_list_default.snap\n- CREATE: crates/lore-tui/tests/snapshots/issue_detail.snap\n- CREATE: crates/lore-tui/tests/snapshots/mr_list_default.snap\n- CREATE: crates/lore-tui/tests/snapshots/search_results.snap\n- CREATE: crates/lore-tui/tests/snapshots/empty_state.snap\n- CREATE: crates/lore-tui/TERMINAL_COMPAT.md (template)\n\n## TDD Anchor\nRED: Write `test_dashboard_snapshot` that creates LoreApp with FakeClock and fixture DB, renders Dashboard at 120x40, asserts output matches `snapshots/dashboard_default.snap`. Fails because golden file does not exist yet.\nGREEN: Render the Dashboard, run with UPDATE_SNAPSHOTS=1 to generate golden file, then run normally to verify match.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml snapshot\n\n## Edge Cases\n- Golden file encoding: always UTF-8, normalize line endings to LF\n- FakeClock must be injected into all components that compute relative time (e.g., \"2 hours ago\")\n- Snapshot diffs on CI: print a clear diff showing expected vs actual when mismatch occurs\n- Fixture data must NOT include non-deterministic values (random IDs, current timestamps)\n- If ftui-harness API changes, TestBackend shim may need updating\n\n## Dependency Context\n- Uses FakeClock from bd-2lg6 (Implement Clock trait)\n- Uses all screen views from Phase 2 (Dashboard, Issue List, MR List, Detail views)\n- Uses TestBackend from ftui-harness crate (or custom implementation)\n- Depends on bd-3h00 (session persistence) per phase ordering — screens must be complete before snapshotting\n- Downstream: bd-nu0d (fuzz tests) and bd-3fjk (race tests) depend on this infrastructure","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T17:03:54.220114Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:22.940990Z","deleted_at":"2026-03-11T18:34:22.940986Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-2ni","title":"OBSERV Epic: Phase 2 - Spans + Correlation IDs","description":"Add tracing spans to all sync stages and generate UUID-based run_id for correlation. Every log line within a sync run includes run_id in JSON span context. Nested spans produce correct parent-child chains.\n\nDepends on: Phase 1 (subscriber must support span recording)\nUnblocks: Phase 3 (metrics), Phase 5 (rate limit logging)\n\nFiles: src/cli/commands/sync.rs, src/cli/commands/ingest.rs, src/ingestion/orchestrator.rs, src/documents/regenerator.rs, src/embedding/pipeline.rs, src/main.rs\n\nAcceptance criteria (PRD Section 6.2):\n- Every log line includes run_id in JSON span context\n- Nested spans produce chain: fetch_pages includes parent ingest_issues span\n- run_id is 8-char hex (truncated UUIDv4)\n- Spans visible in -vv stderr output","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-02-04T15:53:08.935218Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:19:38.721297Z","closed_at":"2026-02-04T17:19:38.721241Z","close_reason":"Phase 2 complete: run_id correlation IDs generated at sync/ingest entry, root spans with .instrument() for async, #[instrument] on 5 key pipeline functions","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-2ni","depends_on_id":"bd-2nx","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2nl3","title":"Implement activity feed: assignment detection from system notes","description":"## Background\n`lore me` activity must surface assignment/reviewer events from system notes (`is_system=1`) as the fifth activity source. The main risk is false-positive username matching (`@ann` matching `@anna`) if naive `LIKE '%@ann%'` is used.\n\n## Approach\nImplement assignment-system-note extraction in `src/cli/commands/me/activity.rs` with explicit token-boundary matching.\n\n### 1. Normalize note body for stable matching\nCreate a normalized expression used in predicates:\n- lowercase body\n- replace common punctuation/newlines with spaces\n- pad leading/trailing spaces\n\nExample SQL expression:\n```sql\n' ' || LOWER(\n  REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(n.body, '\\n', ' '), ',', ' '), '.', ' '), ';', ' '), ':', ' ')\n) || ' '\n```\n\nCall this `body_norm` (inline or CTE).\n\n### 2. Event detection predicates (word-boundary safe)\nUse patterns with trailing-space boundary:\n- assign: `body_norm LIKE '% assigned to @' || LOWER(?username) || ' %'`\n- unassign: `body_norm LIKE '% unassigned @' || LOWER(?username) || ' %'`\n- review request: `body_norm LIKE '% requested review from @' || LOWER(?username) || ' %'`\n\nThis avoids matching `@jdoe` inside `@jdoe2`.\n\n### 3. Column contract\nReturn identical columns as other activity sources:\n`timestamp, event_type, entity_type, entity_iid, project_path, actor, is_own, summary, body_preview`\n\nRules:\n- `event_type`: `assign` | `unassign` | `review_request`\n- `actor`: `COALESCE(n.author_username, 'system')`\n- `is_own`: `(LOWER(COALESCE(n.author_username, '')) = LOWER(?username))`\n- `body_preview`: `NULL`\n- include `since` and `ProjectScope` filters\n- include current-association `my_items` filter\n\n## Acceptance Criteria\n- [ ] Detects assign events from `assigned to @username`\n- [ ] Detects unassign events from `unassigned @username`\n- [ ] Detects review-request events from `requested review from @username`\n- [ ] Username matching is boundary-safe (`@user` does not match `@user2`)\n- [ ] Matching is case-insensitive\n- [ ] Only system notes (`is_system = 1`) are considered\n- [ ] Result shape matches other activity-source queries exactly\n- [ ] `actor` is populated from note author with `'system'` fallback\n- [ ] `is_own` compares actor vs dashboard username\n- [ ] Respects `--since` and `ProjectScope`\n\n## Files\n- MODIFY: `src/cli/commands/me/activity.rs`\n\n## TDD Anchor\nRED:\n- `test_assignment_detection_assign`\n- `test_assignment_detection_unassign`\n- `test_assignment_detection_review_request`\n- `test_assignment_detection_case_insensitive`\n- `test_assignment_detection_username_boundary_no_false_match`\n- `test_assignment_detection_ignores_non_system_notes`\n\nGREEN:\n- Add normalized-body boundary-safe predicates and mapping.\n\nVERIFY:\n- `cargo test assignment_detection`\n- `cargo test me_activity`\n\n## Edge Cases\n- Multi-target notes (`assigned to @alice and @jdoe`) should still match the target username.\n- Punctuation adjacency (`@jdoe,`) is handled by punctuation-to-space normalization.\n- Unexpected note text variants should fail closed (not misclassified).\n\n## Dependency Context\nExtends activity query layer from `bd-b3r3` and is consumed by `bd-2tl5` UNION assembly.\n\nDependencies:\n  -> bd-b3r3 (blocks) - Implement activity feed: note and resource event queries\n\nDependents:\n  <- bd-2tl5 (blocks) - Implement activity feed UNION ALL assembly","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-19T19:38:15.157407Z","created_by":"tayloreernisse","updated_at":"2026-02-20T16:09:13.056445Z","closed_at":"2026-02-20T16:09:13.056408Z","close_reason":"Implemented by lore-me agent swarm","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2nl3","depends_on_id":"bd-b3r3","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2no","title":"Write integration tests","description":"## Background\nIntegration tests verify that modules work together with a real SQLite database. They test FTS search (stemming, empty results), embedding storage (sqlite-vec ops), hybrid search (combined retrieval), and sync orchestration (full pipeline). Each test creates a fresh in-memory DB with migrations applied.\n\n## Approach\nCreate integration test files in `tests/`:\n\n**1. tests/fts_search.rs:**\n- Create DB, apply migrations 001-008\n- Insert test documents via SQL\n- Verify FTS5 triggers fired (documents_fts has matching count)\n- Search with various queries: stemming, prefix, empty, special chars\n- Verify result ranking (BM25 ordering)\n- Verify snippet generation\n\n**2. tests/embedding.rs:**\n- Create DB, apply migrations 001-009 (requires sqlite-vec)\n- Insert test documents + embeddings with known vectors\n- Verify KNN search returns nearest neighbors\n- Verify chunk deduplication\n- Verify orphan cleanup trigger (delete document -> embeddings gone)\n\n**3. tests/hybrid_search.rs:**\n- Create DB, apply all migrations\n- Insert documents + embeddings\n- Test all three modes: lexical, semantic, hybrid\n- Verify RRF ranking produces expected order\n- Test graceful degradation (no embeddings -> FTS fallback)\n- Test adaptive recall with filters\n\n**4. tests/sync.rs:**\n- Test sync orchestration with mock/stub GitLab responses\n- Verify pipeline stages execute in order\n- Verify lock acquisition/release\n- Verify --no-embed and --no-docs flags\n\n**Test fixtures:**\n- Deterministic embedding vectors (no Ollama required): e.g., [1.0, 0.0, 0.0, ...] for doc1, [0.0, 1.0, 0.0, ...] for doc2\n- Known documents with predictable search results\n- Fixed timestamps for reproducibility\n\n## Acceptance Criteria\n- [ ] FTS search tests pass (stemming, prefix, empty, special chars)\n- [ ] Embedding tests pass (KNN, dedup, orphan cleanup)\n- [ ] Hybrid search tests pass (all 3 modes, graceful degradation)\n- [ ] Sync tests pass (pipeline orchestration)\n- [ ] All tests use in-memory DB (no file I/O)\n- [ ] No external dependencies (no Ollama, no GitLab) — use fixtures/stubs\n- [ ] `cargo test --test fts_search --test embedding --test hybrid_search --test sync` passes\n\n## Files\n- `tests/fts_search.rs` — new file\n- `tests/embedding.rs` — new file\n- `tests/hybrid_search.rs` — new file\n- `tests/sync.rs` — new file\n- `tests/fixtures/` — optional: test helper functions (shared DB setup)\n\n## TDD Loop\nThese ARE integration tests — they verify the combined behavior of multiple beads.\nVERIFY: `cargo test --test fts_search && cargo test --test embedding && cargo test --test hybrid_search && cargo test --test sync`\n\n## Edge Cases\n- sqlite-vec not available: embedding tests should skip gracefully (or require feature flag)\n- In-memory DB with WAL mode: may behave differently than file DB — test both if critical\n- Concurrent test execution: each test creates its own DB (no shared state)","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-30T15:27:21.751019Z","created_by":"tayloreernisse","updated_at":"2026-01-30T18:11:12.432092Z","closed_at":"2026-01-30T18:11:12.432036Z","close_reason":"Integration tests: 10 FTS search tests (stemming, empty, special chars, ordering, triggers, null title), 5 embedding tests (KNN, limit, dedup, orphan trigger, empty DB), 6 hybrid search tests (lexical mode, FTS-only, graceful degradation, RRF ranking, filters, mode variants). 310 total tests pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2no","depends_on_id":"bd-1x6","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-2no","depends_on_id":"bd-3eu","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-2no","depends_on_id":"bd-3lu","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-2no","depends_on_id":"bd-am7","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2nx","title":"OBSERV Epic: Phase 1 - Verbosity Flags + Structured File Logging","description":"Foundation layer for observability. Add -v/-vv/-vvv CLI flags, dual-layer tracing subscriber (stderr + file), daily log rotation via tracing-appender, log retention cleanup, --log-format json flag, and LoggingConfig.\n\nDepends on: nothing (first phase)\nUnblocks: Phase 2, and transitively all other phases\n\nFiles: Cargo.toml, src/cli/mod.rs, src/main.rs, src/core/config.rs, src/core/paths.rs, src/cli/commands/doctor.rs\n\nAcceptance criteria (PRD Section 6.1):\n- JSON log files written to ~/.local/share/lore/logs/ with zero config\n- -v/-vv/-vvv control stderr verbosity per table in PRD 4.3\n- RUST_LOG overrides -v for both layers\n- --log-format json emits JSON on stderr\n- Daily rotation, retention cleanup on startup\n- --quiet suppresses stderr, does NOT affect file layer\n- lore doctor reports log directory info","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-02-04T15:53:00.987774Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:15:09.465732Z","closed_at":"2026-02-04T17:15:09.465684Z","close_reason":"Phase 1 complete: dual-layer subscriber, -v/--verbose flags, --log-format json, LoggingConfig, get_log_dir(), log retention, doctor diagnostics","compaction_level":0,"original_size":0,"labels":["observability"]}
{"id":"bd-2o49","title":"Epic: TUI Phase 5.6 — CLI/TUI Parity Pack","description":"## Background\nPhase 5.6 ensures the TUI displays the same data as the CLI robot mode, preventing drift between interfaces. Tests compare TUI query results against CLI --robot output for counts, list data, detail data, and search results.\n\n## Acceptance Criteria\n- [ ] Dashboard counts match lore --robot count output\n- [ ] Issue/MR list data matches lore --robot issues/mrs output\n- [ ] Issue/MR detail data matches lore --robot issues/mrs <iid> output\n- [ ] Search results identity (same IDs, same order) matches lore --robot search output\n- [ ] Terminal safety sanitization applied consistently in TUI and CLI","status":"tombstone","priority":1,"issue_type":"epic","created_at":"2026-02-12T17:05:36.087371Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:28.211077Z","deleted_at":"2026-03-11T18:34:28.211072Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"epic","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-2og9","title":"Implement entity cache + render cache","description":"## Background\nEntity cache provides near-instant detail view reopens during Enter/Esc drill workflows by caching IssueDetail/MrDetail payloads. Render cache prevents per-frame recomputation of expensive render artifacts (markdown to styled text, discussion tree shaping). Both use bounded LRU eviction with selective invalidation.\n\n## Approach\n\n### Entity Cache (entity_cache.rs)\n\n```rust\nuse std::collections::HashMap;\n\npub struct EntityCache<V> {\n    entries: HashMap<EntityKey, (V, u64)>,  // value + last-access tick\n    capacity: usize,\n    tick: u64,\n}\n\nimpl<V> EntityCache<V> {\n    pub fn new(capacity: usize) -> Self;\n    pub fn get(&mut self, key: &EntityKey) -> Option<&V>;  // updates tick\n    pub fn put(&mut self, key: EntityKey, value: V);       // evicts oldest if at capacity\n    pub fn invalidate(&mut self, keys: &[EntityKey]);      // selective by key set\n}\n```\n\n- `EntityKey` is `(EntityType, i64)` from core types (bd-c9gk) — e.g., `(EntityType::Issue, 42)`\n- Default capacity: 64 entries (sufficient for typical drill-in/out workflows)\n- LRU eviction: on `put()` when at capacity, find entry with lowest tick and remove it\n- `get()` bumps the access tick to keep recently-accessed entries alive\n- `invalidate()` takes a slice of changed keys (from sync results) and removes only those entries — NOT a blanket clear\n\n### Render Cache (render_cache.rs)\n\n```rust\npub struct RenderCacheKey {\n    content_hash: u64,      // FxHash of source content\n    terminal_width: u16,    // width affects line wrapping\n}\n\npub struct RenderCache<V> {\n    entries: HashMap<RenderCacheKey, V>,\n    capacity: usize,\n}\n\nimpl<V> RenderCache<V> {\n    pub fn new(capacity: usize) -> Self;\n    pub fn get(&self, key: &RenderCacheKey) -> Option<&V>;\n    pub fn put(&mut self, key: RenderCacheKey, value: V);\n    pub fn invalidate_width(&mut self, keep_width: u16);  // remove entries NOT matching this width\n    pub fn invalidate_all(&mut self);                      // theme change = full clear\n}\n```\n\n- Default capacity: 256 entries\n- Used for: markdown->styled text, discussion tree layout, issue body rendering\n- `content_hash` uses `std::hash::Hasher` with FxHash (or std DefaultHasher) on source text\n- `invalidate_width(keep_width)`: on terminal resize, remove entries cached at old width\n- `invalidate_all()`: on theme change, clear everything (colors changed)\n- Both caches are NOT thread-safe (single-threaded TUI event loop). No Arc/Mutex needed.\n\n### Integration Point\nBoth caches live as fields on the main LoreApp struct. Cache miss falls through to normal DB query transparently — the action functions check cache first, query DB on miss, populate cache on return.\n\n## Acceptance Criteria\n- [ ] EntityCache::get returns Some for recently put items\n- [ ] EntityCache::put evicts the least-recently-accessed entry when at capacity\n- [ ] EntityCache::invalidate removes only the specified keys, leaves others intact\n- [ ] EntityCache capacity defaults to 64\n- [ ] RenderCache::get returns Some for matching (hash, width) pair\n- [ ] RenderCache::invalidate_width removes entries with non-matching width\n- [ ] RenderCache::invalidate_all clears everything\n- [ ] RenderCache capacity defaults to 256\n- [ ] Both caches are Send (no Rc, no raw pointers) but NOT required to be Sync\n- [ ] No unsafe code\n\n## Files\n- CREATE: crates/lore-tui/src/entity_cache.rs\n- CREATE: crates/lore-tui/src/render_cache.rs\n- MODIFY: crates/lore-tui/src/lib.rs (add `pub mod entity_cache; pub mod render_cache;`)\n\n## TDD Anchor\nRED: Write `test_entity_cache_lru_eviction` that creates EntityCache with capacity 3, puts 4 items, asserts first item (lowest tick) is evicted and the other 3 remain.\nGREEN: Implement LRU eviction using tick-based tracking.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml entity_cache\n\nAdditional tests:\n- test_entity_cache_get_bumps_tick (accessed item survives eviction over older untouched items)\n- test_entity_cache_invalidate_selective (removes only specified keys)\n- test_entity_cache_invalidate_nonexistent_key (no panic)\n- test_render_cache_width_invalidation (entries at old width removed, current width kept)\n- test_render_cache_invalidate_all (empty after call)\n- test_render_cache_capacity_eviction\n\n## Edge Cases\n- Invalidating an EntityKey not in the cache is a no-op (no panic)\n- Zero-capacity cache: all gets return None, all puts are no-ops (degenerate but safe)\n- RenderCacheKey equality: two different strings can have the same hash (collision) — accept this; worst case is a wrong cached render that gets corrected on next invalidation\n- Entity cache should NOT be prewarmed synchronously during sync — sync results just invalidate stale entries, and the next view() call repopulates on demand\n\n## Dependency Context\nDepends on bd-c9gk (core types) for EntityKey type definition.\nBoth caches are integrated into LoreApp (bd-6pmy) as struct fields.\nAction functions (from Phase 2/3 screen beads) check cache before querying DB.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T17:03:25.520201Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:23.018699Z","deleted_at":"2026-03-11T18:34:23.018692Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-2px","title":"[CP1] Epic: Issue Ingestion","description":"Ingest all issues, labels, and issue discussions from configured GitLab repositories with resumable cursor-based incremental sync. This establishes the core data ingestion pattern reused for MRs in CP2.\n\n## Success Criteria\n- gi ingest --type=issues fetches all issues (count matches GitLab UI)\n- Labels extracted from issue payloads (name-only)\n- Label linkage reflects current GitLab state (removed labels unlinked on re-sync)\n- Issue discussions fetched per-issue (dependent sync)\n- Cursor-based sync is resumable (re-running fetches 0 new items)\n- Discussion sync skips unchanged issues (per-issue watermark)\n- Sync tracking records all runs\n- Single-flight lock prevents concurrent runs\n\n## Internal Gates\n- Gate A: Issues only (cursor + upsert + raw payloads + list/count/show)\n- Gate B: Labels correct (stale-link removal verified)\n- Gate C: Dependent discussion sync (watermark prevents redundant refetch)\n- Gate D: Resumability proof (kill mid-run, rerun; bounded redo)\n\nReference: docs/prd/checkpoint-1.md","status":"tombstone","priority":1,"issue_type":"epic","created_at":"2026-01-25T15:42:13.167698Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.638609Z","closed_at":"2026-01-25T17:02:01.638609Z","deleted_at":"2026-01-25T17:02:01.638606Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"epic","compaction_level":0,"original_size":0}
{"id":"bd-2pyn","title":"Phase 4d: E2E runtime acceptance + structured logging","description":"## What\nAdd a dedicated end-to-end acceptance bead that runs the production CLI flow under asupersync with structured, high-signal logging. This is the missing bridge between unit/integration validation and real user workflow confidence.\n\n## Why\nCurrent beads cover adapter tests, cancellation tests, parity checks, and hardening, but we still need a single e2e acceptance path that proves the full CLI behavior with runtime semantics, cancellation, and resume behavior in one place.\n\n## Scope\nCreate an e2e test harness (script or integration test) that:\n1. Seeds a realistic project fixture (issues + MRs + discussions) via mocked GitLab endpoints.\n2. Runs `lore sync` with robot output and verbose logging enabled.\n3. Triggers Ctrl+C during active fan-out to validate graceful cancellation behavior.\n4. Re-runs sync to confirm resume behavior and no corruption/stuck dependent jobs.\n5. Emits machine-readable run artifacts (JSON summary + log file path) for CI debugging.\n\n## Logging Requirements (detailed)\nAt minimum, logs/artifacts must include:\n- run/session ID\n- phase transitions (preflight, fetch, ingest, dependent drains, finalize)\n- per-entity outcome (`synced|failed|skipped`) with IID and entity type\n- cancellation signal timing and source (first Ctrl+C vs force exit path)\n- timeout classifications (`NetworkErrorKind`) when applicable\n- retry attempts and terminal failure reasons\n- final counters matching SyncResult fields\n\n## Acceptance Criteria\n- [ ] One command executes the full e2e flow deterministically in CI/local\n- [ ] Cancellation mid-flight is exercised and validated (not just happy path)\n- [ ] Second run resumes cleanly; no stuck locks/jobs remain\n- [ ] Structured logs are persisted and easy to inspect after failure\n- [ ] E2E summary includes pass/fail per scenario and key counters\n- [ ] `cargo test` or CI task wiring documented in bead comments\n\n## Suggested Location\n- `tests/asupersync_e2e.rs` OR `tests/e2e/asupersync_smoke.sh` (+ Rust helper)\n- Keep implementation in-repo (no external infra requirement)\n\n## Dependency Context\nThis bead executes after major migration and phase-4 runtime test work is in place. It is an acceptance gate input for Phase 5 hardening.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-03-06T18:55:18.278302Z","created_by":"tayloreernisse","updated_at":"2026-03-06T21:11:12.667730Z","closed_at":"2026-03-06T21:11:12.667170Z","close_reason":"asupersync migration complete — 5 commits pushed to asupersync-migration branch","compaction_level":0,"original_size":0,"labels":["asupersync","e2e","logging","phase-4","testing"],"dependencies":[{"issue_id":"bd-2pyn","depends_on_id":"bd-16qf","type":"blocks","created_at":"2026-03-06T18:55:27.823086Z","created_by":"tayloreernisse"},{"issue_id":"bd-2pyn","depends_on_id":"bd-18ai","type":"blocks","created_at":"2026-03-06T18:55:27.709595Z","created_by":"tayloreernisse"},{"issue_id":"bd-2pyn","depends_on_id":"bd-1lj5","type":"parent-child","created_at":"2026-03-06T18:55:18.282072Z","created_by":"tayloreernisse"},{"issue_id":"bd-2pyn","depends_on_id":"bd-1yky","type":"blocks","created_at":"2026-03-06T18:55:27.938579Z","created_by":"tayloreernisse"}]}
{"id":"bd-2rk9","title":"WHO: CLI skeleton — WhoArgs, Commands::Who, dispatch arm","description":"## Background\n\nWire up the CLI plumbing so `lore who --help` works and dispatch reaches the who module. This is pure boilerplate — no query logic yet.\n\n## Approach\n\n### 1. src/cli/mod.rs — WhoArgs struct (after TimelineArgs, ~line 195)\n\n```rust\n#[derive(Parser)]\n#[command(after_help = \"\\x1b[1mExamples:\\x1b[0m\n  lore who src/features/auth/           # Who knows about this area?\n  lore who @asmith                      # What is asmith working on?\n  lore who @asmith --reviews            # What review patterns does asmith have?\n  lore who --active                     # What discussions need attention?\n  lore who --overlap src/features/auth/ # Who else is touching these files?\n  lore who --path README.md             # Expert lookup for a root file\")]\npub struct WhoArgs {\n    /// Username or file path (path if contains /)\n    pub target: Option<String>,\n\n    /// Force expert mode for a file/directory path (handles root files like README.md, Makefile)\n    #[arg(long, help_heading = \"Mode\", conflicts_with_all = [\"active\", \"overlap\", \"reviews\"])]\n    pub path: Option<String>,\n\n    /// Show active unresolved discussions\n    #[arg(long, help_heading = \"Mode\", conflicts_with_all = [\"target\", \"overlap\", \"reviews\", \"path\"])]\n    pub active: bool,\n\n    /// Find users with MRs/notes touching this file path\n    #[arg(long, help_heading = \"Mode\", conflicts_with_all = [\"target\", \"active\", \"reviews\", \"path\"])]\n    pub overlap: Option<String>,\n\n    /// Show review pattern analysis (requires username target)\n    #[arg(long, help_heading = \"Mode\", requires = \"target\", conflicts_with_all = [\"active\", \"overlap\", \"path\"])]\n    pub reviews: bool,\n\n    /// Time window (7d, 2w, 6m, YYYY-MM-DD). Default varies by mode.\n    #[arg(long, help_heading = \"Filters\")]\n    pub since: Option<String>,\n\n    /// Scope to a project (supports fuzzy matching)\n    #[arg(short = 'p', long, help_heading = \"Filters\")]\n    pub project: Option<String>,\n\n    /// Maximum results per section (1..=500)\n    #[arg(short = 'n', long = \"limit\", default_value = \"20\",\n          value_parser = clap::value_parser!(u16).range(1..=500),\n          help_heading = \"Output\")]\n    pub limit: u16,\n}\n```\n\n### 2. Commands enum — add Who(WhoArgs) after Timeline, before hidden List\n\n### 3. src/cli/commands/mod.rs — add `pub mod who;` and re-exports:\n```rust\npub use who::{run_who, print_who_human, print_who_json, WhoRun};\n```\n\n### 4. src/main.rs — dispatch arm + handler:\n```rust\nSome(Commands::Who(args)) => handle_who(cli.config.as_deref(), args, robot_mode),\n```\n\n### 5. src/cli/commands/who.rs — stub file with signatures that compile\n\n## Files\n\n- `src/cli/mod.rs` — WhoArgs struct + Commands::Who variant\n- `src/cli/commands/mod.rs` — pub mod who + re-exports\n- `src/main.rs` — dispatch arm + handle_who function + imports\n- `src/cli/commands/who.rs` — CREATE stub file\n\n## TDD Loop\n\nRED: `cargo check --all-targets` fails (missing who module)\nGREEN: Create stub who.rs with empty/todo!() implementations, wire up all 4 files\nVERIFY: `cargo check --all-targets && cargo run -- who --help`\n\n## Acceptance Criteria\n\n- [ ] `cargo check --all-targets` passes\n- [ ] `lore who --help` displays all flags with correct grouping (Mode, Filters, Output)\n- [ ] `lore who --active --overlap foo` rejected by clap (conflicts_with)\n- [ ] `lore who --reviews` rejected by clap (requires target)\n- [ ] WhoArgs is pub and importable from lore::cli\n\n## Edge Cases\n\n- conflicts_with_all on --path must NOT include \"target\" (--path is used alongside positional target in some cases... actually no, --path replaces target — check the plan: it conflicts with active/overlap/reviews but NOT target. Wait, looking at the plan: --path does NOT conflict with target. But if both target and --path are provided, --path takes priority in resolve_mode. The clap struct allows both.)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-08T02:39:58.436660Z","created_by":"tayloreernisse","updated_at":"2026-02-08T04:10:29.594923Z","closed_at":"2026-02-08T04:10:29.594882Z","close_reason":"Implemented by agent team: migration 017, CLI skeleton, all 5 query modes, human+robot output, 20 tests. All quality gates pass.","compaction_level":0,"original_size":0}
{"id":"bd-2rqs","title":"Dynamic shell completions for file paths (lore complete-path)","description":"## Background\n\nTab-completion for lore commands currently only covers static subcommand/flag names via clap_complete v4 (src/main.rs handle_completions(), line ~1667). Users frequently type file paths (for who --path, file-history) and entity IIDs (for issues, mrs, show) manually. Dynamic completions would allow tab-completing these from the local SQLite database.\n\n**Pattern:** kubectl, gh, docker all use hidden subcommands for dynamic completions. clap_complete v4 has a custom completer API that can shell out to these hidden subcommands.\n\n## Codebase Context\n\n- **Static completions**: Commands::Completions variant in src/cli/mod.rs, handled by handle_completions() in src/main.rs (line ~1667) using clap_complete::generate()\n- **clap_complete v4**: Already in Cargo.toml. Supports custom completer API for dynamic values.\n- **Commands taking IIDs**: IssuesArgs (iid: Option<i64>), MrsArgs (iid: Option<i64>), Drift (for: EntityRef), Show (hidden, takes entity ref)\n- **path_resolver**: src/core/path_resolver.rs (245 lines). build_path_query() (lines 71-187) and suffix_probe() (lines 192-240) resolve partial paths against mr_file_changes. SuffixResult::Ambiguous(Vec<String>) returns multiple matches — perfect for completions.\n- **who --path**: WhoArgs has `path: Option<String>` field, already uses path_resolver\n- **DB access**: create_connection() from src/core/db.rs, config loading from src/core/config.rs\n- **Performance**: Must complete in <100ms. SQLite queries against indexed columns are sub-ms.\n\n## Approach\n\n### 1. Hidden Subcommands (src/cli/mod.rs)\n\nAdd hidden subcommands that query the DB and print completion candidates:\n\n```rust\n/// Hidden: emit file path completions for shell integration\n#[command(name = \"complete-path\", hide = true)]\nCompletePath {\n    /// Partial path prefix to complete\n    prefix: String,\n    /// Project scope\n    #[arg(short = 'p', long)]\n    project: Option<String>,\n},\n\n/// Hidden: emit issue IID completions\n#[command(name = \"complete-issue\", hide = true)]\nCompleteIssue {\n    /// Partial IID prefix\n    prefix: String,\n    #[arg(short = 'p', long)]\n    project: Option<String>,\n},\n\n/// Hidden: emit MR IID completions\n#[command(name = \"complete-mr\", hide = true)]\nCompleteMr {\n    /// Partial IID prefix\n    prefix: String,\n    #[arg(short = 'p', long)]\n    project: Option<String>,\n},\n```\n\n### 2. Completion Handlers (src/cli/commands/completions.rs NEW)\n\n```rust\npub fn complete_path(conn: &Connection, prefix: &str, project_id: Option<i64>) -> Result<Vec<String>> {\n    // Use suffix_probe() from path_resolver if prefix looks like a suffix (no leading /)\n    // Otherwise: SELECT DISTINCT new_path FROM mr_file_changes WHERE new_path LIKE ?||'%' LIMIT 50\n    // Also check old_path for rename awareness\n}\n\npub fn complete_issue(conn: &Connection, prefix: &str, project_id: Option<i64>) -> Result<Vec<String>> {\n    // SELECT iid, title FROM issues WHERE CAST(iid AS TEXT) LIKE ?||'%' ORDER BY updated_at DESC LIMIT 30\n    // Output: \"123\\tFix login bug\" (tab-separated for shell description)\n}\n\npub fn complete_mr(conn: &Connection, prefix: &str, project_id: Option<i64>) -> Result<Vec<String>> {\n    // SELECT iid, title FROM merge_requests WHERE CAST(iid AS TEXT) LIKE ?||'%' ORDER BY updated_at DESC LIMIT 30\n    // Output: \"456\\tAdd OAuth support\"\n}\n```\n\n### 3. Wire in main.rs\n\nAdd match arms for CompletePath, CompleteIssue, CompleteMr. Each:\n1. Opens DB connection (read-only)\n2. Resolves project if -p given\n3. Calls completion handler\n4. Prints one candidate per line to stdout\n5. Exits 0\n\n### 4. Shell Integration\n\nUpdate handle_completions() to generate shell scripts that call the hidden subcommands. For fish:\n```fish\ncomplete -c lore -n '__fish_seen_subcommand_from issues' -a '(lore complete-issue \"\")'\ncomplete -c lore -n '__fish_seen_subcommand_from who' -l path -a '(lore complete-path (commandline -ct))'\n```\n\nSimilar for bash (using `_lore_complete()` function) and zsh.\n\n## Acceptance Criteria\n\n- [ ] `lore complete-path \"src/co\"` prints matching file paths from mr_file_changes\n- [ ] `lore complete-issue \"12\"` prints matching issue IIDs with titles\n- [ ] `lore complete-mr \"45\"` prints matching MR IIDs with titles\n- [ ] All three hidden subcommands respect -p for project scoping\n- [ ] All three complete in <100ms (SQLite indexed queries)\n- [ ] Empty prefix returns recent/popular results (not all rows)\n- [ ] Hidden subcommands don't appear in --help or completions themselves\n- [ ] Shell completion scripts (fish, bash, zsh) call hidden subcommands for dynamic values\n- [ ] Static completions (subcommands, flags) still work as before\n- [ ] No DB connection attempted if DB doesn't exist (graceful degradation — return no completions)\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n- [ ] `cargo fmt --check` passes\n\n## Files\n\n- MODIFY: src/cli/mod.rs (add CompletePath, CompleteIssue, CompleteMr hidden variants)\n- CREATE: src/cli/commands/completions.rs (complete_path, complete_issue, complete_mr handlers)\n- MODIFY: src/cli/commands/mod.rs (add pub mod completions)\n- MODIFY: src/main.rs (match arms for hidden subcommands + update handle_completions shell scripts)\n\n## TDD Anchor\n\nRED:\n- test_complete_path_suffix_match (in-memory DB with mr_file_changes rows, verify suffix matching returns correct paths)\n- test_complete_issue_prefix (in-memory DB with issues, verify IID prefix filtering)\n- test_complete_mr_prefix (same for MRs)\n- test_complete_empty_prefix_returns_recent (verify limited results ordered by updated_at DESC)\n\nGREEN: Implement completion handlers with SQL queries.\n\nVERIFY: cargo test --lib -- completions && cargo check --all-targets\n\n## Edge Cases\n\n- DB doesn't exist yet (first run before sync): return empty completions, exit 0 (not error)\n- mr_file_changes empty (sync hasn't run with --fetch-mr-diffs): complete-path returns nothing, no error\n- Very long prefix with no matches: empty output, exit 0\n- Special characters in paths (spaces, brackets): shell quoting handled by completion framework\n- Project ambiguous with -p: exit 18, same as other commands (resolve_project pattern)\n- IID prefix \"0\": return nothing (no issues/MRs have iid=0)\n\n## Dependency Context\n\n- **path_resolver** (src/core/path_resolver.rs): provides suffix_probe() which returns SuffixResult::Exact/Ambiguous/NotFound — reuse for complete-path instead of raw SQL when prefix looks like a suffix\n- **mr_file_changes** (migration 016): provides new_path/old_path columns for file path completions\n- **clap_complete v4** (Cargo.toml): provides generate() for static completions and custom completer API for dynamic shell integration","status":"open","priority":3,"issue_type":"feature","created_at":"2026-02-13T16:31:48.589428Z","created_by":"tayloreernisse","updated_at":"2026-02-17T16:51:21.891406Z","compaction_level":0,"original_size":0,"labels":["cli-ux","gate-4"]}
{"id":"bd-2rr","title":"OBSERV: Replace subscriber init with dual-layer setup","description":"## Background\nThis is the core infrastructure bead for Phase 1. It replaces the single-layer subscriber (src/main.rs:44-58) with a dual-layer registry that separates stderr and file concerns. The file layer provides always-on post-mortem data; the stderr layer respects -v flags.\n\n## Approach\nReplace src/main.rs lines 44-58 with a function (e.g., init_tracing()) that:\n\n1. Build stderr filter from -v count (or RUST_LOG override):\n```rust\nfn build_stderr_filter(verbose: u8, quiet: bool) -> EnvFilter {\n    if let Ok(rust_log) = std::env::var(\"RUST_LOG\") {\n        return EnvFilter::new(rust_log);\n    }\n    if quiet {\n        return EnvFilter::new(\"lore=warn,error\");\n    }\n    match verbose {\n        0 => EnvFilter::new(\"lore=info,warn\"),\n        1 => EnvFilter::new(\"lore=debug,warn\"),\n        2 => EnvFilter::new(\"lore=debug,info\"),\n        _ => EnvFilter::new(\"trace,debug\"),\n    }\n}\n```\n\n2. Build file filter (always lore=debug,warn unless RUST_LOG set):\n```rust\nfn build_file_filter() -> EnvFilter {\n    if let Ok(rust_log) = std::env::var(\"RUST_LOG\") {\n        return EnvFilter::new(rust_log);\n    }\n    EnvFilter::new(\"lore=debug,warn\")\n}\n```\n\n3. Assemble the registry:\n```rust\nlet stderr_layer = fmt::layer()\n    .with_target(false)\n    .with_writer(SuspendingWriter);\n// Conditionally add .json() based on log_format\n\nlet file_appender = tracing_appender::rolling::daily(log_dir, \"lore\");\nlet (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);\nlet file_layer = fmt::layer()\n    .json()\n    .with_writer(non_blocking);\n\ntracing_subscriber::registry()\n    .with(stderr_layer.with_filter(build_stderr_filter(cli.verbose, cli.quiet)))\n    .with(file_layer.with_filter(build_file_filter()))\n    .init();\n```\n\nCRITICAL: The non_blocking _guard must be held for the program's lifetime. Store it in main() scope, NOT in the init function. If the guard drops, the file writer thread stops and buffered logs are lost.\n\nCRITICAL: Per-layer filtering requires each .with_filter() to produce a Filtered<L, F, S> type. The two layers will have different concrete types (one with json, one without). This is fine -- the registry accepts heterogeneous layers via .with().\n\nWhen --log-format json: wrap stderr_layer with .json() too. This requires conditional construction. Two approaches:\n  A) Use Box<dyn Layer<S>> for dynamic dispatch (simpler, tiny perf hit)\n  B) Use an enum wrapper (zero cost but more code)\nRecommend approach A for simplicity. The overhead is one vtable indirection per log event, dwarfed by I/O.\n\nWhen file_logging is false (LoggingConfig.file_logging == false): skip adding the file layer entirely.\n\n## Acceptance Criteria\n- [ ] lore sync writes JSON log lines to ~/.local/share/lore/logs/lore.YYYY-MM-DD.log\n- [ ] lore -v sync shows DEBUG lore::* on stderr, deps at WARN\n- [ ] lore -vv sync shows DEBUG lore::* + INFO deps on stderr\n- [ ] lore -vvv sync shows TRACE everything on stderr\n- [ ] RUST_LOG=lore::gitlab=trace overrides -v for both layers\n- [ ] lore --log-format json sync emits JSON on stderr\n- [ ] -q + -v: -q wins (stderr at WARN+)\n- [ ] -q does NOT affect file layer (still DEBUG+)\n- [ ] File layer does NOT use SuspendingWriter\n- [ ] Non-blocking guard kept alive for program duration\n- [ ] Existing behavior unchanged when no new flags passed\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/main.rs (replace lines 44-58, add init_tracing function or inline)\n\n## TDD Loop\nRED:\n  - test_verbosity_filter_construction: assert filter directives for verbose=0,1,2,3\n  - test_rust_log_overrides_verbose: set env, assert TRACE not DEBUG\n  - test_quiet_overrides_verbose: -q + -v => WARN+\n  - test_json_log_output_format: capture file output, parse as JSON\n  - test_suspending_writer_dual_layer: no garbled stderr with progress bars\nGREEN: Implement build_stderr_filter, build_file_filter, assemble registry\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- _guard lifetime: if guard is dropped early, buffered log lines are lost. MUST hold in main() scope.\n- Type erasure: stderr layer with/without .json() produces different types. Use Box<dyn Layer<_>> or separate init paths.\n- Empty RUST_LOG string: env::var returns Ok(\"\"), which EnvFilter::new(\"\") defaults to TRACE. May want to check is_empty().\n- File I/O error on log dir: tracing-appender handles this gracefully (no panic), but logs will be silently lost. The doctor command (bd-2i10) can diagnose this.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-04T15:53:55.577025Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:15:04.384114Z","closed_at":"2026-02-04T17:15:04.384062Z","close_reason":"Replaced single-layer subscriber with dual-layer setup: stderr (human/json, -v controlled) + file (always-on JSON, daily rotation via tracing-appender)","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-2rr","depends_on_id":"bd-17n","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-2rr","depends_on_id":"bd-1k4","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-2rr","depends_on_id":"bd-1o1","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-2rr","depends_on_id":"bd-2nx","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-2rr","depends_on_id":"bd-gba","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2sr2","title":"Robot sync envelope: status enrichment metadata","description":"## Background\nAgents need machine-readable status enrichment metadata in the robot sync output to detect issues like unsupported GraphQL, partial errors, or enrichment failures. Without this, enrichment problems are invisible to automation.\n\n## Approach\nWire IngestProjectResult status fields into the per-project robot sync JSON. Add aggregate error count to top-level summary.\n\n## Files\n- Wherever robot sync output JSON is constructed (likely src/cli/commands/ingest.rs or the sync output serialization path — search for IngestProjectResult -> JSON conversion)\n\n## Implementation\n\nPer-project status_enrichment object in robot sync JSON:\n{\n  \"mode\": \"fetched\" | \"unsupported\" | \"skipped\",\n  \"reason\": null | \"graphql_endpoint_missing\" | \"auth_forbidden\",\n  \"seen\": N,\n  \"enriched\": N,\n  \"cleared\": N,\n  \"without_widget\": N,\n  \"partial_errors\": N,\n  \"first_partial_error\": null | \"message\",\n  \"error\": null | \"message\"\n}\n\nSource fields from IngestProjectResult:\n  mode <- status_enrichment_mode\n  reason <- status_unsupported_reason\n  seen <- statuses_seen\n  enriched <- statuses_enriched\n  cleared <- statuses_cleared\n  without_widget <- statuses_without_widget\n  partial_errors <- partial_error_count\n  first_partial_error <- first_partial_error\n  error <- status_enrichment_error\n\nTop-level sync summary: add status_enrichment_errors: N (count of projects where error is Some)\n\nField semantics:\n  mode \"fetched\": enrichment ran (even if 0 statuses or error occurred)\n  mode \"unsupported\": 404/403 from GraphQL\n  mode \"skipped\": config toggle off\n  seen > 0 + enriched == 0: project has issues but none with status\n  partial_errors > 0: some pages returned incomplete data\n\n## Acceptance Criteria\n- [ ] Robot sync JSON includes per-project status_enrichment object\n- [ ] All 9 fields present with correct types\n- [ ] mode reflects actual enrichment outcome (fetched/unsupported/skipped)\n- [ ] Top-level status_enrichment_errors count present\n- [ ] Test: full robot sync output validates structure\n\n## TDD Loop\nRED: test_robot_sync_includes_status_enrichment\nGREEN: Wire fields into JSON serialization\nVERIFY: cargo test robot_sync\n\n## Edge Cases\n- Find the exact location where IngestProjectResult is serialized to JSON — may be in a Serialize impl or manual json! macro\n- All numeric fields default to 0, all Option fields default to null in JSON\n- mode is always present (never null)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-11T06:42:29.127412Z","created_by":"tayloreernisse","updated_at":"2026-02-11T07:21:33.422233Z","closed_at":"2026-02-11T07:21:33.422193Z","close_reason":"Implemented by agent swarm — all quality gates pass (595 tests, 0 failures)","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2sr2","depends_on_id":"bd-2y79","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-2sr2","depends_on_id":"bd-3dum","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2sx","title":"Implement lore embed CLI command","description":"## Background\nThe embed CLI command is the user-facing wrapper for the embedding pipeline. It runs Ollama health checks, selects documents to embed (pending or failed), shows progress, and reports results. This is the standalone command for building embeddings outside of the sync orchestrator.\n\n## Approach\nCreate `src/cli/commands/embed.rs` per PRD Section 4.4.\n\n**IMPORTANT: The embed command is async.** The underlying `embed_documents()` function is `async fn` (uses `FuturesUnordered` for concurrent HTTP to Ollama). The CLI runner must use tokio runtime.\n\n**Core function (async):**\n```rust\npub async fn run_embed(\n    config: &Config,\n    retry_failed: bool,\n) -> Result<EmbedResult>\n```\n\n**Pipeline:**\n1. Create OllamaClient from config.embedding (base_url, model, timeout_secs)\n2. Run `client.health_check().await` — fail early with clear error if Ollama unavailable or model missing\n3. Determine selection: `EmbedSelection::RetryFailed` if --retry-failed, else `EmbedSelection::Pending`\n4. Call `embed_documents(conn, &client, selection, concurrency, progress_callback).await`\n   - `concurrency` param controls max in-flight HTTP requests to Ollama\n   - `progress_callback` drives indicatif progress bar\n5. Show progress bar (indicatif) during embedding\n6. Return EmbedResult with counts\n\n**CLI args:**\n```rust\n#[derive(Args)]\npub struct EmbedArgs {\n    #[arg(long)]\n    retry_failed: bool,\n}\n```\n\n**Output:**\n- Human: \"Embedded 42 documents (15 chunks), 2 errors, 5 skipped (unchanged)\"\n- JSON: `{\"ok\": true, \"data\": {\"embedded\": 42, \"chunks\": 15, \"errors\": 2, \"skipped\": 5}}`\n\n**Tokio integration note:**\nThe embed command runs async code. Either:\n- Use `#[tokio::main]` on main and propagate async through CLI dispatch\n- Or use `tokio::runtime::Runtime::new()` in the embed command handler\n\n## Acceptance Criteria\n- [ ] Command is async (embed_documents is async, health_check is async)\n- [ ] OllamaClient created from config.embedding settings\n- [ ] Health check runs first — clear error if Ollama down (exit code 14)\n- [ ] Clear error if model not found: \"Pull the model: ollama pull nomic-embed-text\" (exit code 15)\n- [ ] Embeds pending documents (no existing embeddings or stale content_hash)\n- [ ] --retry-failed re-attempts documents with last_error\n- [ ] Progress bar shows during embedding (indicatif)\n- [ ] embed_documents called with concurrency parameter\n- [ ] embed_documents called with progress_callback for progress bar\n- [ ] Human + JSON output\n- [ ] `cargo build` succeeds\n\n## Files\n- `src/cli/commands/embed.rs` — new file\n- `src/cli/commands/mod.rs` — add `pub mod embed;`\n- `src/cli/mod.rs` — add EmbedArgs, wire up embed subcommand\n- `src/main.rs` — add embed command handler (async dispatch)\n\n## TDD Loop\nRED: Integration test needing Ollama\nGREEN: Implement run_embed (async)\nVERIFY: `cargo build && cargo test embed`\n\n## Edge Cases\n- No documents in DB: \"No documents to embed\" (not error)\n- All documents already embedded and unchanged: \"0 documents to embed (all up to date)\"\n- Ollama goes down mid-embedding: pipeline records errors for remaining docs, returns partial result\n- --retry-failed with no failed docs: \"No failed documents to retry\"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:26:34.126482Z","created_by":"tayloreernisse","updated_at":"2026-01-30T18:02:38.633115Z","closed_at":"2026-01-30T18:02:38.633055Z","close_reason":"Embed CLI command fully wired: EmbedArgs, Commands::Embed variant, handle_embed handler, clean build, all tests pass","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2sx","depends_on_id":"bd-am7","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2tl5","title":"Implement activity feed UNION ALL assembly","description":"## Background\nThe activity feed combines 5 event sources (human comments, state events, label events, milestone events, assignment system notes) into a single chronologically sorted feed. This bead assembles the UNION ALL query, executes it, and returns the final `Vec<MeActivityItem>`.\n\n## Approach\nAdd the top-level activity fetch function in `src/cli/commands/me/activity.rs` (same file as bd-b3r3 and bd-2nl3):\n\n```rust\nuse rusqlite::{Connection, params};\nuse crate::core::error::Result;\nuse super::types::MeActivityItem;\n// ProjectScope from bd-a7ba\n\npub fn fetch_my_activity(\n    conn: &Connection,\n    username: &str,\n    scope: &ProjectScope,\n    since_ms: i64,\n) -> Result<Vec<MeActivityItem>>\n```\n\n### Assembly\n\nCombine the 5 SQL source fragments from bd-b3r3 and bd-2nl3:\n```sql\nSELECT * FROM (\n    {notes_sql}\n    UNION ALL\n    {state_events_sql}\n    UNION ALL\n    {label_events_sql}\n    UNION ALL\n    {milestone_events_sql}\n    UNION ALL\n    {assignment_notes_sql}\n) ORDER BY timestamp DESC\n```\n\n### Row mapping\n\nMap each row to `MeActivityItem`:\n```rust\nMeActivityItem {\n    timestamp_iso: ms_to_iso(row.get::<_, i64>(\"timestamp\")?),\n    event_type: row.get(\"event_type\")?,\n    entity_type: row.get(\"entity_type\")?,\n    entity_iid: row.get(\"entity_iid\")?,\n    project_path: row.get(\"project_path\")?,\n    actor: row.get::<_, Option<String>>(\"actor\")?.unwrap_or_default(),\n    is_own: row.get::<_, i64>(\"is_own\")? != 0,\n    summary: row.get(\"summary\")?,\n    body_preview: row.get(\"body_preview\")?,\n}\n```\n\nUse `crate::core::time::ms_to_iso(ms)` to convert ms epoch → ISO string.\n\n### Parameter binding\n\nAll 5 subqueries share the same parameters: `?username`, `?since_ms`, and optionally `?project_id`. Use named parameters (`?1`, `?2`, etc.) or build the SQL with the same positional params repeated. Since rusqlite doesn't support named params in UNION ALL easily, the cleanest approach is to use `format!()` to build the SQL string, then bind shared params. Be careful with SQL injection — the `username` param must ALWAYS be bound, never interpolated.\n\n## Acceptance Criteria\n- [ ] Combines all 5 activity sources into one result set via UNION ALL\n- [ ] Sorted by timestamp DESC — newest first (AC-9.3)\n- [ ] is_own flag correctly set: `row[\"is_own\"] != 0` (SQLite returns 0/1 for boolean expressions)\n- [ ] Only includes activity on items the user is CURRENTLY associated with (AC-3.6)\n- [ ] Respects --since window (only activity within the window)\n- [ ] Respects ProjectScope filtering\n- [ ] Includes activity on closed items (AC-5.4)\n- [ ] timestamp converted to ISO string via `ms_to_iso()`\n- [ ] Returns `Vec<MeActivityItem>` with all fields populated\n- [ ] actor NULL handled gracefully (COALESCE or unwrap_or_default)\n\n## Files\n- MODIFY: src/cli/commands/me/activity.rs (add top-level assembly function)\n\n## TDD Anchor\nRED: Write `test_activity_feed_combines_all_sources` using in-memory DB. Insert:\n- A project, issue, issue_assignee(username=\"jdoe\")\n- A discussion + human note (event_type=\"note\")\n- A resource_state_event (event_type=\"status\")\n- A system note with \"assigned to @jdoe\" (event_type=\"assign\")\nCall `fetch_my_activity`. Assert result contains all 3 event types, sorted newest first.\n\nGREEN: Implement the UNION ALL assembly.\nVERIFY: `cargo test activity_feed_combines`\n\nAdditional tests:\n- test_activity_feed_sorted_newest_first (3 events at different timestamps, verify order)\n- test_activity_feed_respects_since (event before since_ms excluded)\n- test_activity_feed_only_current_associations (event on unassigned issue excluded)\n- test_activity_feed_includes_closed_items (closed issue with assignee still shows activity)\n- test_activity_feed_is_own_flag (actor=me → is_own=true, actor=other → is_own=false)\n\n## Edge Cases\n- Large activity feeds: the UNION ALL has no LIMIT — returns everything within the since window. SQLite handles this fine for typical use.\n- No duplication between sources: notes source uses is_system=0, assignment source uses is_system=1, resource events are separate tables entirely — no overlap.\n- The \"current association\" filter is critical: if user was unassigned from an issue, activity on it should NOT appear even if it was recent.\n- Empty result (no activity) → return empty Vec, not an error (AC-10.3).\n- `actor_username` can be NULL on resource events — use `unwrap_or_default()` to get empty string.\n\n## Dependency Context\nCombines SQL fragments from bd-b3r3 (note + resource event queries) and bd-2nl3 (assignment detection).\nUses `MeActivityItem` from bd-3bwh, `ProjectScope` from bd-a7ba, `ms_to_iso` from `crate::core::time`.\nCalled by the handler in bd-1vv8.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-19T19:38:33.907533Z","created_by":"tayloreernisse","updated_at":"2026-02-20T16:09:13.057699Z","closed_at":"2026-02-20T16:09:13.057656Z","close_reason":"Implemented by lore-me agent swarm","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2tl5","depends_on_id":"bd-2nl3","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-2tl5","depends_on_id":"bd-b3r3","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2tr4","title":"Epic: TUI Phase 1 — Foundation","description":"## Background\nPhase 1 builds the foundational infrastructure that all screens depend on: the full LoreApp Model implementation with key dispatch, navigation stack, task supervisor for async work management, theme configuration, common widgets, and the state/action architecture. Phase 1 deliverables are the skeleton that Phase 2 screens plug into.\n\n## Acceptance Criteria\n- [ ] LoreApp update() dispatches all Msg variants through 5-stage key pipeline\n- [ ] NavigationStack supports push/pop/forward/jump with state preservation\n- [ ] TaskSupervisor manages background tasks with dedup, cancellation, and generation IDs\n- [ ] Theme renders correctly with adaptive light/dark colors\n- [ ] Status bar, breadcrumb, loading, error toast, and help overlay widgets render\n- [ ] CommandRegistry is the single source of truth for keybindings/help/palette\n- [ ] AppState composition with per-screen states and LoadState map\n\n## Scope\nBlocked by Phase 0 (Toolchain Gate). Blocks Phase 2 (Core Screens).","status":"tombstone","priority":1,"issue_type":"epic","created_at":"2026-02-12T16:55:02.650495Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:28.859559Z","deleted_at":"2026-03-11T18:34:28.859556Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"epic","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-2ug","title":"[CP1] gi ingest --type=issues command","description":"CLI command to orchestrate issue ingestion.\n\n## Module\nsrc/cli/commands/ingest.rs\n\n## Clap Definition\n#[derive(Subcommand)]\npub enum Commands {\n    Ingest {\n        #[arg(long, value_parser = [\"issues\", \"merge_requests\"])]\n        r#type: String,\n        \n        #[arg(long)]\n        project: Option<String>,\n        \n        #[arg(long)]\n        force: bool,\n    },\n}\n\n## Implementation\n1. Acquire app lock with heartbeat (respect --force for stale lock)\n2. Create sync_run record (status='running')\n3. For each configured project (or filtered --project):\n   - Call orchestrator to ingest issues and discussions\n   - Show progress (spinner or progress bar)\n4. Update sync_run (status='succeeded', metrics_json with counts)\n5. Release lock\n\n## Output Format\nIngesting issues...\n\n  group/project-one: 1,234 issues fetched, 45 new labels\n\nFetching discussions (312 issues with updates)...\n\n  group/project-one: 312 issues → 1,234 discussions, 5,678 notes\n\nTotal: 1,234 issues, 1,234 discussions, 5,678 notes (excluding 1,234 system notes)\nSkipped discussion sync for 922 unchanged issues.\n\n## Error Handling\n- Lock acquisition failure: exit with DatabaseLockError message\n- Network errors: show GitLabNetworkError, exit non-zero\n- Rate limiting: respect backoff, show progress\n\nFiles: src/cli/commands/ingest.rs, src/cli/commands/mod.rs\nTests: tests/integration/sync_runs_tests.rs\nDone when: Full issue + discussion ingestion works end-to-end","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T16:57:58.552504Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.875613Z","closed_at":"2026-01-25T17:02:01.875613Z","deleted_at":"2026-01-25T17:02:01.875607Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-2um","title":"[CP1] Epic: Issue Ingestion","description":"Ingest all issues, labels, and issue discussions from configured GitLab repositories with resumable cursor-based incremental sync. This checkpoint establishes the core data ingestion pattern that will be reused for MRs in Checkpoint 2.\n\n## Success Criteria\n- gi ingest --type=issues fetches all issues (count matches GitLab UI)\n- Labels extracted from issue payloads (name-only)\n- Label linkage reflects current GitLab state (removed labels unlinked on re-sync)\n- Issue discussions fetched per-issue (dependent sync)\n- Cursor-based sync is resumable (re-running fetches 0 new items)\n- Discussion sync skips unchanged issues (per-issue watermark)\n- Sync tracking records all runs (sync_runs table)\n- Single-flight lock prevents concurrent runs\n\n## Internal Gates\n- **Gate A**: Issues only - cursor + upsert + raw payloads + list/count/show working\n- **Gate B**: Labels correct - stale-link removal verified; label count matches GitLab\n- **Gate C**: Dependent discussion sync - watermark prevents redundant refetch; concurrency bounded\n- **Gate D**: Resumability proof - kill mid-run, rerun; bounded redo and no redundant discussion refetch\n\n## Reference\ndocs/prd/checkpoint-1.md","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-01-25T17:02:38.075224Z","created_by":"tayloreernisse","updated_at":"2026-01-25T23:27:15.347364Z","closed_at":"2026-01-25T23:27:15.347317Z","close_reason":"CP1 Issue Ingestion complete: all sub-tasks done, 71 tests pass, CLI commands working","compaction_level":0,"original_size":0}
{"id":"bd-2w1p","title":"Add half-life fields and config validation to ScoringConfig","description":"## Background\nThe flat-weight ScoringConfig (config.rs:155-167) has only 3 fields: author_weight (25), reviewer_weight (10), note_bonus (1). Time-decay scoring needs half-life parameters, a reviewer split (participated vs assigned-only), closed MR discount, substantive-note threshold, and bot filtering.\n\n## Approach\nExtend the existing ScoringConfig struct at config.rs:155. Add new fields with #[serde(default)] and camelCase rename to match existing convention (authorWeight, reviewerWeight, noteBonus). Extend the Default impl at config.rs:169 with new defaults. Extend validate_scoring() at config.rs:274-291 (currently validates 3 weights >= 0).\n\n### New fields to add:\n```rust\n#[serde(rename = \"reviewerAssignmentWeight\")]\npub reviewer_assignment_weight: i64,     // default: 3\n#[serde(rename = \"authorHalfLifeDays\")]\npub author_half_life_days: u32,          // default: 180\n#[serde(rename = \"reviewerHalfLifeDays\")]\npub reviewer_half_life_days: u32,        // default: 90\n#[serde(rename = \"reviewerAssignmentHalfLifeDays\")]\npub reviewer_assignment_half_life_days: u32, // default: 45\n#[serde(rename = \"noteHalfLifeDays\")]\npub note_half_life_days: u32,            // default: 45\n#[serde(rename = \"closedMrMultiplier\")]\npub closed_mr_multiplier: f64,           // default: 0.5\n#[serde(rename = \"reviewerMinNoteChars\")]\npub reviewer_min_note_chars: u32,        // default: 20\n#[serde(rename = \"excludedUsernames\")]\npub excluded_usernames: Vec<String>,     // default: vec![]\n```\n\n### Validation additions to validate_scoring() (config.rs:274):\n- All *_half_life_days must be > 0 AND <= 3650\n- All *_weight / *_bonus must be >= 0\n- reviewer_assignment_weight must be >= 0\n- closed_mr_multiplier must be finite (not NaN/Inf) AND in (0.0, 1.0]\n- reviewer_min_note_chars must be >= 0 AND <= 4096\n- excluded_usernames entries must be non-empty strings\n- Return LoreError::ConfigInvalid with clear message on failure\n\n## TDD Loop\n\n### RED (write first):\n```rust\n#[test]\nfn test_config_validation_rejects_zero_half_life() {\n    let mut cfg = ScoringConfig::default();\n    assert!(validate_scoring(&cfg).is_ok());\n    cfg.author_half_life_days = 0;\n    assert!(validate_scoring(&cfg).is_err());\n    cfg.author_half_life_days = 180;\n    cfg.reviewer_half_life_days = 0;\n    assert!(validate_scoring(&cfg).is_err());\n    cfg.reviewer_half_life_days = 90;\n    cfg.closed_mr_multiplier = 0.0;\n    assert!(validate_scoring(&cfg).is_err());\n    cfg.closed_mr_multiplier = 1.5;\n    assert!(validate_scoring(&cfg).is_err());\n    cfg.closed_mr_multiplier = 1.0;\n    assert!(validate_scoring(&cfg).is_ok());\n}\n\n#[test]\nfn test_config_validation_rejects_absurd_half_life() {\n    let mut cfg = ScoringConfig::default();\n    cfg.author_half_life_days = 5000; // > 3650 cap\n    assert!(validate_scoring(&cfg).is_err());\n    cfg.author_half_life_days = 3650; // boundary: valid\n    assert!(validate_scoring(&cfg).is_ok());\n    cfg.reviewer_min_note_chars = 5000; // > 4096 cap\n    assert!(validate_scoring(&cfg).is_err());\n    cfg.reviewer_min_note_chars = 4096; // boundary: valid\n    assert!(validate_scoring(&cfg).is_ok());\n}\n\n#[test]\nfn test_config_validation_rejects_nan_multiplier() {\n    let mut cfg = ScoringConfig::default();\n    cfg.closed_mr_multiplier = f64::NAN;\n    assert!(validate_scoring(&cfg).is_err());\n    cfg.closed_mr_multiplier = f64::INFINITY;\n    assert!(validate_scoring(&cfg).is_err());\n    cfg.closed_mr_multiplier = f64::NEG_INFINITY;\n    assert!(validate_scoring(&cfg).is_err());\n}\n```\n\n### GREEN: Add fields to struct + Default impl + validation rules.\n### VERIFY: cargo test -p lore -- test_config_validation\n\n## Acceptance Criteria\n- [ ] test_config_validation_rejects_zero_half_life passes\n- [ ] test_config_validation_rejects_absurd_half_life passes\n- [ ] test_config_validation_rejects_nan_multiplier passes\n- [ ] ScoringConfig::default() returns correct values for all 11 fields\n- [ ] cargo check --all-targets passes\n- [ ] Existing config deserialization works (#[serde(default)] fills new fields)\n- [ ] validate_scoring() is pub(crate) or accessible from config.rs test module\n\n## Files\n- MODIFY: src/core/config.rs (struct at line 155, Default impl at line 169, validate_scoring at line 274)\n\n## Edge Cases\n- f64 comparison: use .is_finite() for NaN/Inf check, > 0.0 and <= 1.0 for range\n- Vec<String> default: use Vec::new()\n- Upper bounds prevent silent misconfig (5000-day half-life effectively disables decay)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-09T16:59:14.654469Z","created_by":"tayloreernisse","updated_at":"2026-02-12T20:43:04.400186Z","closed_at":"2026-02-12T20:43:04.399988Z","close_reason":"Implemented by time-decay swarm: 3 agents, 12 tasks, 621 tests passing, all quality gates green","compaction_level":0,"original_size":0,"labels":["scoring"]}
{"id":"bd-2wok","title":"Phase 3c: Entrypoint migration (#[asupersync::main])","description":"## What\nReplace #[tokio::main] with #[asupersync::main] in main.rs and thread Cx through command dispatch.\n\n## Rearchitecture Context (2026-03-06)\nmain.rs has been thinned to ~419 LOC. It now uses include!() to pull in handler code:\n- src/main.rs -> include!(\"app/dispatch.rs\") -> include!(\"errors.rs\"), include!(\"handlers.rs\"), include!(\"robot_docs.rs\")\n- The #[tokio::main] attribute is on the main() fn in src/main.rs\n- Command dispatch handler functions are physically in src/app/handlers.rs but logically in main.rs scope\n\n## Implementation\n\n```rust\n// Before\n#[tokio::main]\nasync fn main() -> Result<()> { ... }\n\n// After\n#[asupersync::main]\nasync fn main(cx: &Cx) -> Outcome<()> { ... }\n```\n\nThe Cx parameter is the runtime context that enables region-scoped task management. It flows from main() through all async command dispatch arms.\n\n## Key Considerations\n- Return type changes from Result<()> to Outcome<()> — verify what Outcome provides and how error handling maps\n- Cx is borrowed (&Cx), not owned — it lives for the duration of the program\n- Command dispatch handler functions in src/app/handlers.rs must receive cx through the dispatch chain\n- The include!() chain means cx flows from main.rs through the included handler functions seamlessly (same scope)\n\n## Files Changed\n- src/main.rs (~10 LOC for entrypoint attribute change)\n- src/app/handlers.rs (handler function signatures gain cx: &Cx — these are include!'d into main.rs scope)\n\n## Testing\n- cargo check --all-targets (part of atomic commit)\n- Binary must launch and show help text\n\n## Depends On\n- Phase 3a (asupersync must be in Cargo.toml)","status":"closed","priority":1,"issue_type":"task","created_at":"2026-03-06T18:40:29.594563Z","created_by":"tayloreernisse","updated_at":"2026-03-06T21:11:12.619665Z","closed_at":"2026-03-06T21:11:12.619615Z","close_reason":"asupersync migration complete — 5 commits pushed to asupersync-migration branch","compaction_level":0,"original_size":0,"labels":["asupersync","phase-3"],"dependencies":[{"issue_id":"bd-2wok","depends_on_id":"bd-1lj5","type":"parent-child","created_at":"2026-03-06T18:40:29.612761Z","created_by":"tayloreernisse"},{"issue_id":"bd-2wok","depends_on_id":"bd-39yp","type":"blocks","created_at":"2026-03-06T18:42:51.738254Z","created_by":"tayloreernisse"}]}
{"id":"bd-2wpf","title":"Ship timeline CLI with human and robot renderers","description":"## Problem\nThe timeline pipeline (5-stage SEED->HYDRATE->EXPAND->COLLECT->RENDER) is implemented but not wired to the CLI. This is one of lore's most unique features — chronological narrative reconstruction from resource events, cross-references, and notes — and it is invisible to users and agents.\n\n## Current State\n- Types defined: src/core/timeline.rs (TimelineEvent, TimelineSeed, etc.)\n- Seed stage: src/core/timeline_seed.rs (FTS search -> seed entities)\n- Expand stage: src/core/timeline_expand.rs (cross-reference expansion)\n- Collect stage: src/core/timeline_collect.rs (event gathering from resource events + notes)\n- CLI command structure: src/cli/commands/timeline.rs (exists but incomplete)\n- Remaining beads: bd-1nf (CLI wiring), bd-2f2 (human renderer), bd-dty (robot renderer)\n\n## Acceptance Criteria\n1. lore timeline 'authentication refactor' works end-to-end:\n   - Searches for matching entities (SEED)\n   - Fetches raw data (HYDRATE)\n   - Expands via cross-references (EXPAND with --depth flag, default 1)\n   - Collects events chronologically (COLLECT)\n   - Renders human-readable narrative (RENDER)\n2. Human renderer output:\n   - Chronological event stream with timestamps\n   - Color-coded by event type (state change, label change, note, reference)\n   - Actor names with role context\n   - Grouped by day/week for readability\n   - Evidence snippets from notes (first 200 chars)\n3. Robot renderer output (--robot / -J):\n   - JSON array of events with: timestamp, event_type, actor, entity_ref, body/snippet, metadata\n   - Seed entities listed separately (what matched the query)\n   - Expansion depth metadata (how far from seed)\n   - Total event count and time range\n4. CLI flags:\n   - --project (scope to project)\n   - --since (time range)\n   - --depth N (expansion depth, default 1, max 3)\n   - --expand-mentions (follow mention references, not just closes/related)\n   - -n LIMIT (max events)\n5. Performance: timeline for a single issue with 50 events renders in <200ms\n\n## Relationship to Existing Beads\nThis supersedes/unifies: bd-1nf (CLI wiring), bd-2f2 (human renderer), bd-dty (robot renderer). Those can be closed when this ships.\n\n## Files to Modify\n- src/cli/commands/timeline.rs (CLI wiring, flag parsing, output dispatch)\n- src/core/timeline.rs (may need RENDER stage types)\n- New: src/cli/render/timeline_human.rs or inline in timeline.rs\n- New: src/cli/render/timeline_robot.rs or inline in timeline.rs","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-02-12T15:46:16.246889Z","created_by":"tayloreernisse","updated_at":"2026-02-12T15:50:43.885226Z","closed_at":"2026-02-12T15:50:43.885180Z","close_reason":"Already implemented: run_timeline(), print_timeline(), print_timeline_json_with_meta(), handle_timeline() all exist and are fully wired. Code audit 2026-02-12.","compaction_level":0,"original_size":0,"labels":["cli","cli-imp"],"dependencies":[{"issue_id":"bd-2wpf","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2x2h","title":"Implement Sync screen (running + summary modes + progress coalescer)","description":"## Background\nThe Sync screen provides real-time progress visualization during data synchronization. The TUI drives sync directly via lore library calls (not subprocess) — this gives direct access to progress callbacks, proper error propagation, and cooperative cancellation via CancelToken. The TUI is the primary human interface; the CLI serves robots/scripts.\n\nAfter sync completes, the screen transitions to a summary view showing exact changed entity counts. A progress coalescer prevents render thrashing by batching rapid progress updates.\n\nDesign principle: the TUI is self-contained. It does NOT detect or react to external CLI sync operations. If someone runs lore sync externally, the TUI's natural re-query on navigation handles stale data implicitly.\n\n## Approach\nCreate state, action, and view modules for the Sync screen:\n\n**State** (crates/lore-tui/src/screen/sync/state.rs):\n- SyncScreenMode enum: FullScreen, Inline (for use from Bootstrap screen)\n- SyncState enum: Idle, Running(SyncProgress), Complete(SyncSummary), Error(String)\n- SyncProgress: per-lane progress (issues, MRs, discussions, notes, events, statuses) with counts and ETA\n- SyncSummary: changed entity counts (new, updated, deleted per type), duration, errors\n- ProgressCoalescer: buffers progress updates, emits at most every 100ms to prevent render thrash\n\n**sync_delta_ledger** (crates/lore-tui/src/screen/sync/delta_ledger.rs):\n- SyncDeltaLedger: in-memory per-run record of changed entity IDs\n- Fields: new_issue_iids (Vec<i64>), updated_issue_iids (Vec<i64>), new_mr_iids (Vec<i64>), updated_mr_iids (Vec<i64>)\n- record_change(entity_type, iid, change_kind) — called by sync progress callback\n- summary() -> SyncSummary — produces the final counts for the summary view\n- Purpose: after sync completes, the dashboard and list screens can use the ledger to highlight \"new since last sync\" items\n\n**Action** (crates/lore-tui/src/screen/sync/action.rs):\n- start_sync(db: &DbManager, config: &Config, cancel: CancelToken) -> Cmd<Msg>\n- Calls lore library ingestion functions directly: ingest_issues, ingest_mrs, ingest_discussions, etc.\n- Progress callback sends Msg::SyncProgress(lane, count, total) via channel\n- On completion sends Msg::SyncComplete(SyncSummary)\n- On cancel sends Msg::SyncCancelled(partial_summary)\n\n**Per-project fault isolation:** If sync for one project fails, continue syncing other projects. Collect per-project errors and display in summary view. Don't abort entire sync on single project failure.\n\n**View** (crates/lore-tui/src/screen/sync/view.rs):\n- Running view: per-lane progress bars with counts/totals, overall ETA, cancel hint (Esc)\n- Stream stats footer: show items/sec throughput for active lanes\n- Summary view: table of entity types with new/updated/deleted columns, total duration, per-project error list\n- Error view: error message with retry option\n- Inline mode: compact single-line progress for embedding in Bootstrap screen\n\nThe Sync screen uses TaskSupervisor for the background sync task with cooperative cancellation.\n\n## Acceptance Criteria\n- [ ] Sync screen launches sync via lore library calls (NOT subprocess)\n- [ ] Per-lane progress bars update in real-time during sync\n- [ ] ProgressCoalescer batches updates to at most 10/second (100ms floor)\n- [ ] Esc cancels sync cooperatively via CancelToken, shows partial summary\n- [ ] Sync completion transitions to summary view with accurate change counts\n- [ ] Summary view shows new/updated/deleted counts per entity type\n- [ ] Error during sync shows error message with retry option\n- [ ] Sync task registered with TaskSupervisor (dedup by TaskKey::Sync)\n- [ ] Per-project fault isolation: single project failure doesn't abort entire sync\n- [ ] SyncDeltaLedger records changed entity IDs for post-sync highlighting\n- [ ] Stream stats footer shows items/sec throughput\n- [ ] ScreenMode::Inline renders compact single-line progress for Bootstrap embedding\n- [ ] Unit tests for ProgressCoalescer batching behavior\n- [ ] Unit tests for SyncDeltaLedger record/summary\n- [ ] Integration test: mock sync with FakeClock verifies progress -> summary transition\n\n## Files\n- CREATE: crates/lore-tui/src/screen/sync/state.rs\n- CREATE: crates/lore-tui/src/screen/sync/action.rs\n- CREATE: crates/lore-tui/src/screen/sync/view.rs\n- CREATE: crates/lore-tui/src/screen/sync/delta_ledger.rs\n- CREATE: crates/lore-tui/src/screen/sync/mod.rs\n- MODIFY: crates/lore-tui/src/screen/mod.rs (add pub mod sync)\n\n## TDD Anchor\nRED: Write test_progress_coalescer_batches_rapid_updates that sends 50 progress updates in 10ms and asserts coalescer emits at most 1.\nGREEN: Implement ProgressCoalescer with configurable floor interval.\nVERIFY: cargo test -p lore-tui sync -- --nocapture\n\nAdditional tests:\n- test_sync_cancel_produces_partial_summary\n- test_sync_complete_produces_full_summary\n- test_sync_error_shows_retry\n- test_sync_dedup_prevents_double_launch\n- test_delta_ledger_records_changes: record 5 new issues and 3 updated MRs, assert summary counts\n- test_per_project_fault_isolation: simulate one project failure, verify others complete\n\n## Edge Cases\n- Sync cancelled immediately after start — partial summary with zero counts is valid\n- Network timeout during sync — error state with last-known progress preserved\n- Very large sync (100k+ entities) — progress coalescer prevents render thrash\n- Sync started while another sync TaskKey::Sync exists — TaskSupervisor dedup rejects it\n- Inline mode from Bootstrap: compact rendering, no full progress bars\n\n## Dependency Context\nUses TaskSupervisor from bd-3le2 for dedup and cancellation. Uses DbManager from bd-2kop for database access. Uses lore library ingestion module directly for sync operations. Used by Bootstrap screen (bd-3ty8) in inline mode.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T17:02:09.481354Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:23.096100Z","deleted_at":"2026-03-11T18:34:23.096092Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-2xct","title":"Phase 0b: Replace tokio::sync::Mutex with std::sync::Mutex in rate limiter","description":"## What\nIn gitlab/client.rs, replace tokio::sync::Mutex with std::sync::Mutex for the rate limiter lock.\n\n## Why\nThe rate limiter lock guards a tiny synchronous critical section (check Instant::now(), compute delay). No async work inside the lock. std::sync::Mutex is correct here and removes a tokio dependency.\n\n## Current State (gitlab/client.rs ~line 9-10)\n```rust\nuse tokio::sync::Mutex;\n// ...\nlet delay = self.rate_limiter.lock().await.check_delay();\n```\n\n## Implementation\n```rust\nuse std::sync::Mutex;\n// ...\nlet delay = self.rate_limiter.lock().expect(\"rate limiter poisoned\").check_delay();\n```\n\nUse .expect() over .unwrap() for clarity. Poisoning is near-impossible here (trivial Instant::now() check), but the explicit message aids debugging.\n\n## CRITICAL CONSTRAINT (document at lock site)\nstd::sync::Mutex blocks the executor thread while held. This is safe ONLY because the critical section is a single Instant::now() comparison with no I/O. If the rate limiter ever grows to include async work (HTTP calls, DB queries), it must move back to an async-aware lock.\n\nAdd a comment at the lock site documenting this constraint.\n\n## Files Changed\n- src/gitlab/client.rs (~5 LOC changed)\n\n## Testing\n- cargo check --all-targets\n- cargo clippy --all-targets -- -D warnings\n- Existing rate limiter tests should pass unchanged","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-06T18:38:19.345252Z","created_by":"tayloreernisse","updated_at":"2026-03-06T21:11:12.555501Z","closed_at":"2026-03-06T21:11:12.555307Z","close_reason":"asupersync migration complete — 5 commits pushed to asupersync-migration branch","compaction_level":0,"original_size":0,"labels":["asupersync","phase-0"],"dependencies":[{"issue_id":"bd-2xct","depends_on_id":"bd-1lj5","type":"parent-child","created_at":"2026-03-06T18:38:19.349242Z","created_by":"tayloreernisse"}]}
{"id":"bd-2y79","title":"Add work item status via GraphQL enrichment","description":"## Summary\n\nGitLab 18.2+ has native work item status (To do, In progress, Done, Won't do, Duplicate) available ONLY via GraphQL, not REST. This enriches synced issues with status information by making supplementary GraphQL calls after REST ingestion.\n\n**Plan document:** plans/work-item-status-graphql.md\n\n## Critical Findings (from API research)\n\n- **EE-only (Premium/Ultimate)** — Free tier won't have the widget at all\n- **GraphQL auth differs from REST** — must use `Authorization: Bearer <token>`, NOT `PRIVATE-TOKEN`\n- **Must use `workItems` resolver, NOT `project.issues`** — legacy issues path doesn't expose status widgets\n- **5 categories:** TRIAGE, TO_DO, IN_PROGRESS, DONE, CANCELED (not 3 as originally assumed)\n- **Max 100 items per GraphQL page** (standard GitLab limit)\n- **Custom statuses possible on 18.5+** — can't assume only system-defined statuses\n\n## Migration\n\nUses migration **021** (001-020 already exist on disk).\nAdds `status_name TEXT` and `status_category TEXT` to `issues` table (both nullable).\n\n## Files\n\n- src/gitlab/graphql.rs (NEW — minimal GraphQL client + status fetcher)\n- src/gitlab/mod.rs (add pub mod graphql)\n- src/gitlab/types.rs (WorkItemStatus, WorkItemStatusCategory enum)\n- src/core/db.rs (migration 021 in MIGRATIONS array)\n- src/core/config.rs (fetch_work_item_status toggle in SyncConfig)\n- src/ingestion/orchestrator.rs (enrichment step after issue sync)\n- src/cli/commands/show.rs (display status)\n- src/cli/commands/list.rs (status in list output + --status filter)\n\n## Acceptance Criteria\n\n- [ ] GraphQL client POSTs queries with Bearer auth and handles errors\n- [ ] Status fetched via workItems resolver with pagination\n- [ ] Migration 021 adds status_name and status_category to issues\n- [ ] lore show issue displays status (when available)\n- [ ] lore --robot show issue includes status in JSON\n- [ ] lore list issues --status filter works\n- [ ] Graceful degradation: Free tier, old GitLab, disabled GraphQL all handled\n- [ ] Config toggle: fetch_work_item_status (default true)\n- [ ] cargo check + clippy + tests pass","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-02-05T18:32:39.287957Z","created_by":"tayloreernisse","updated_at":"2026-02-17T15:08:29.499020Z","closed_at":"2026-02-17T15:08:29.498969Z","close_reason":"Already implemented: GraphQL status enrichment shipped in v0.8.x — migration 021, graphql.rs, --status filter, --no-status flag all complete","compaction_level":0,"original_size":0,"labels":["api","phase-b"]}
{"id":"bd-2ygk","title":"Implement user flow integration tests (9 PRD flows)","description":"## Background\n\nThe PRD Section 6 defines 9 end-to-end user flows that exercise cross-screen navigation, state preservation, and data flow. The existing vertical slice test (bd-1mju) covers one flow (Dashboard -> Issue List -> Issue Detail -> Sync). These integration tests cover the remaining 8 flows plus re-test the vertical slice from a user-journey perspective. Each test simulates a realistic keystroke sequence using FrankenTUI's test harness and verifies that the correct screens are reached with the correct data visible.\n\n## Approach\n\nCreate a test module `tests/tui_user_flows.rs` with 9 test functions, each simulating a keystroke sequence against a FrankenTUI `TestHarness` with a pre-populated test database. Tests use `FakeClock` for deterministic timestamps.\n\n**Test database fixture**: A shared setup function creates an in-memory SQLite DB with ~20 issues, ~10 MRs, ~30 discussions, a few experts, and timeline events. This fixture is reused across all flow tests.\n\n**Flow tests**:\n\n1. **`test_flow_find_expert`** — Dashboard -> `w` -> type \"src/auth/\" -> verify Expert mode results appear -> `↓` select first person -> `Enter` -> verify navigation to Issue List filtered by that person\n2. **`test_flow_timeline_query`** — Dashboard -> `t` -> type \"auth timeout\" -> `Enter` -> verify Timeline shows seed events -> `Enter` on first event -> verify entity detail opens -> `Esc` -> back on Timeline\n3. **`test_flow_quick_search`** — Any screen -> `/` -> type query -> verify results appear -> `Tab` (switch mode) -> verify mode label changes -> `Enter` -> verify entity detail opens\n4. **`test_flow_sync_and_browse`** — Dashboard -> `s` -> `Enter` (start sync) -> wait for completion -> verify Summary shows deltas -> `i` -> verify Issue List filtered to new items\n5. **`test_flow_review_workload`** — Dashboard -> `w` -> `Tab` (Workload mode) -> type \"@bjones\" -> verify workload sections appear (assigned, authored, reviewing)\n6. **`test_flow_command_palette`** — Any screen -> `Ctrl+P` -> type \"mrs draft\" -> verify fuzzy match -> `Enter` -> verify MR List opened with draft filter\n7. **`test_flow_morning_triage`** — Dashboard -> `i` -> verify Issue List (opened, sorted by updated) -> `Enter` on first -> verify Issue Detail -> `Esc` -> verify cursor preserved on same row -> `j` -> verify cursor moved\n8. **`test_flow_direct_screen_jumps`** — Issue Detail -> `gt` -> verify Timeline -> `gw` -> verify Who -> `gi` -> verify Issue List -> `H` -> verify Dashboard (clean reset)\n9. **`test_flow_risk_sweep`** — Dashboard -> scroll to Insights -> `Enter` on first insight -> verify pre-filtered Issue List\n\nEach test follows the pattern:\n```rust\n#[test]\nfn test_flow_X() {\n    let (harness, app) = setup_test_harness_with_fixture();\n    // Send keystrokes\n    harness.send_key(Key::Char('w'));\n    // Assert screen state\n    assert_eq!(app.current_screen(), Screen::Who);\n    // Assert visible content\n    let frame = harness.render();\n    assert!(frame.contains(\"Expert\"));\n}\n```\n\n## Acceptance Criteria\n- [ ] All 9 flow tests exist and compile\n- [ ] Each test uses the shared DB fixture (no per-test DB setup)\n- [ ] Each test verifies screen transitions via `current_screen()` assertions\n- [ ] Each test verifies at least one content assertion (rendered text contains expected data)\n- [ ] test_flow_morning_triage verifies cursor preservation after Enter/Esc round-trip\n- [ ] test_flow_direct_screen_jumps verifies the g-prefix navigation chain\n- [ ] test_flow_sync_and_browse verifies delta-filtered navigation after sync\n- [ ] All tests use FakeClock for deterministic timestamps\n- [ ] Tests complete in <5 seconds each (no real I/O)\n\n## Files\n- CREATE: crates/lore-tui/tests/tui_user_flows.rs\n- MODIFY: (none — this is a new test file only)\n\n## TDD Anchor\nRED: Write `test_flow_morning_triage` first — it exercises the most common daily workflow (Dashboard -> Issue List -> Issue Detail -> back with cursor preservation). Start with just the Dashboard -> Issue List transition.\nGREEN: Requires all Phase 2 core screens to be working; the test itself is the GREEN verification.\nVERIFY: cargo test -p lore-tui test_flow_morning_triage\n\nAdditional tests: All 9 flows listed above.\n\n## Edge Cases\n- Flow tests must handle async data loading — use harness.tick() or harness.wait_for_idle() to let async tasks complete before asserting\n- g-prefix timeout (500ms) — tests must send the second key within the timeout; use harness clock control\n- Sync flow test needs a mock sync that completes quickly — use a pre-populated SyncDeltaLedger rather than running actual sync\n\n## Dependency Context\n- Depends on bd-1mju (vertical slice integration test) which establishes the test harness patterns and fixture setup.\n- Depends on bd-2nfs (snapshot test infrastructure) which provides the FakeClock and TestHarness setup.\n- Depends on all Phase 2 core screen beads (bd-35g5 Dashboard, bd-3ei1 Issue List, bd-8ab7 Issue Detail, bd-2kr0 MR List, bd-3t1b MR Detail) being implemented.\n- Depends on Phase 3 power feature beads (bd-1zow Search, bd-29qw Timeline, bd-u7se Who, bd-wzqi Command Palette) being implemented.\n- Depends on bd-2x2h (Sync screen) for the sync+browse flow test.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T19:29:41.060826Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:24.401811Z","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-2yo","title":"Fetch MR diffs API and populate mr_file_changes","description":"## Background\n\nThis bead fetches MR diff metadata from the GitLab API and populates the mr_file_changes table created by migration 016. It extracts only file-level metadata (paths, change type) and discards actual diff content.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 4.3 (Ingestion).\n\n## Codebase Context\n\n- pending_dependent_fetches already has `job_type='mr_diffs'` in CHECK constraint (migration 011)\n- dependent_queue.rs has: enqueue_job(), claim_jobs(), complete_job(), fail_job() with exponential backoff\n- Orchestrator pattern: enqueue after entity ingestion, drain after primary ingestion completes\n- GitLab client uses fetch_all_pages() for pagination\n- Existing drain patterns in orchestrator.rs: drain_resource_events() and drain_mr_closes_issues() — follow same pattern\n- config.sync.fetch_mr_file_changes flag guards enqueue (see bd-jec)\n- mr_file_changes table created by migration 016 (bd-1oo) — NOT 015 (015 is commit SHAs)\n- merge_commit_sha and squash_commit_sha already captured during MR ingestion (src/ingestion/merge_requests.rs lines 184, 205-206, 230-231) — no work needed for those fields\n\n## Approach\n\n### 1. API Client — add to `src/gitlab/client.rs`:\n\n```rust\npub async fn fetch_mr_diffs(\n    &self,\n    project_id: i64,\n    mr_iid: i64,\n) -> Result<Vec<GitLabMrDiff>> {\n    let path = format\\!(\"/projects/{project_id}/merge_requests/{mr_iid}/diffs\");\n    self.fetch_all_pages(&path, &[(\"per_page\", \"100\")]).await\n        .or_else(|e| coalesce_not_found(e, Vec::new()))\n}\n```\n\n### 2. Types — add to `src/gitlab/types.rs`:\n\n```rust\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct GitLabMrDiff {\n    pub old_path: String,\n    pub new_path: String,\n    pub new_file: bool,\n    pub renamed_file: bool,\n    pub deleted_file: bool,\n    // Ignore: diff, a_mode, b_mode, generated_file (not stored)\n}\n```\n\nAdd `GitLabMrDiff` to `src/gitlab/mod.rs` re-exports.\n\n### 3. Change Type Derivation (in new file):\n\n```rust\nfn derive_change_type(diff: &GitLabMrDiff) -> &'static str {\n    if diff.new_file { \"added\" }\n    else if diff.renamed_file { \"renamed\" }\n    else if diff.deleted_file { \"deleted\" }\n    else { \"modified\" }\n}\n```\n\n### 4. DB Storage — new `src/ingestion/mr_diffs.rs`:\n\n```rust\npub fn upsert_mr_file_changes(\n    conn: &Connection,\n    mr_local_id: i64,\n    project_id: i64,\n    diffs: &[GitLabMrDiff],\n) -> Result<usize> {\n    // DELETE FROM mr_file_changes WHERE merge_request_id = ?\n    // INSERT each diff row with derived change_type\n    // DELETE+INSERT is simpler than UPSERT for array replacement\n}\n```\n\nAdd `pub mod mr_diffs;` to `src/ingestion/mod.rs`.\n\n### 5. Queue Integration — in orchestrator.rs:\n\n```rust\n// After MR upsert, if config.sync.fetch_mr_file_changes:\nenqueue_job(conn, project_id, \"merge_request\", mr_iid, mr_local_id, \"mr_diffs\")?;\n```\n\nAdd `drain_mr_diffs()` following the drain_mr_closes_issues() pattern. Call it after drain_mr_closes_issues() in the sync pipeline.\n\n## Acceptance Criteria\n\n- [ ] `fetch_mr_diffs()` calls GET /projects/:id/merge_requests/:iid/diffs with pagination\n- [ ] GitLabMrDiff type added to src/gitlab/types.rs and re-exported from src/gitlab/mod.rs\n- [ ] Change type derived: new_file->added, renamed_file->renamed, deleted_file->deleted, else->modified\n- [ ] mr_file_changes rows have correct old_path, new_path, change_type\n- [ ] Old rows deleted before insert (clean replacement per MR)\n- [ ] Jobs only enqueued when config.sync.fetch_mr_file_changes is true\n- [ ] 404/403 API errors handled gracefully (empty result, not failure)\n- [ ] drain_mr_diffs() added to orchestrator.rs sync pipeline\n- [ ] `pub mod mr_diffs;` added to src/ingestion/mod.rs\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/gitlab/client.rs` (add fetch_mr_diffs method)\n- `src/gitlab/types.rs` (add GitLabMrDiff struct)\n- `src/gitlab/mod.rs` (re-export GitLabMrDiff)\n- `src/ingestion/mr_diffs.rs` (NEW — upsert_mr_file_changes + derive_change_type)\n- `src/ingestion/mod.rs` (add pub mod mr_diffs)\n- `src/ingestion/orchestrator.rs` (enqueue mr_diffs jobs + drain_mr_diffs)\n\n## TDD Loop\n\nRED:\n- `test_derive_change_type_added` - new_file=true -> \"added\"\n- `test_derive_change_type_renamed` - renamed_file=true -> \"renamed\"\n- `test_derive_change_type_deleted` - deleted_file=true -> \"deleted\"\n- `test_derive_change_type_modified` - all false -> \"modified\"\n- `test_upsert_replaces_existing` - second upsert replaces first\n\nGREEN: Implement API client, type derivation, DB ops, orchestrator wiring.\n\nVERIFY: `cargo test --lib -- mr_diffs`\n\n## Edge Cases\n\n- MR with 500+ files: paginate properly via fetch_all_pages\n- Binary files: handled as modified (renamed_file/new_file/deleted_file all false)\n- File renamed AND modified: renamed_file=true takes precedence\n- Draft MRs: still fetch diffs\n- Deleted MR: 404 -> empty vec via coalesce_not_found()\n- merge_commit_sha/squash_commit_sha: already handled in merge_requests.rs ingestion — NOT part of this bead\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:34:08.939514Z","created_by":"tayloreernisse","updated_at":"2026-02-08T18:27:05.993580Z","closed_at":"2026-02-08T18:27:05.993482Z","close_reason":"Implemented: GitLabMrDiff type, fetch_mr_diffs client method, upsert_mr_file_changes in new mr_diffs.rs module, enqueue_mr_diffs_jobs + drain_mr_diffs in orchestrator, migration 020 for diffs_synced_for_updated_at watermark, progress events, autocorrect registry. All 390 tests pass, clippy clean.","compaction_level":0,"original_size":0,"labels":["api","gate-4","phase-b"],"dependencies":[{"issue_id":"bd-2yo","depends_on_id":"bd-14q","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-2yo","depends_on_id":"bd-1oo","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-2yo","depends_on_id":"bd-jec","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-2yo","depends_on_id":"bd-tir","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2yq","title":"[CP1] Issue transformer with label extraction","description":"Transform GitLab issue payloads to normalized database schema.\n\nFunctions to implement:\n- transformIssue(gitlabIssue, localProjectId) → NormalizedIssue\n- extractLabels(gitlabIssue, localProjectId) → Label[]\n\nTransformation rules:\n- Convert ISO timestamps to ms epoch using isoToMs()\n- Set last_seen_at to nowMs()\n- Handle labels vs labels_details (prefer details when available)\n- Handle missing optional fields gracefully\n\nFiles: src/gitlab/transformers/issue.ts\nTests: tests/unit/issue-transformer.test.ts\nDone when: Unit tests pass for payload transformation and label extraction","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:19:09.660448Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.152259Z","closed_at":"2026-01-25T15:21:35.152259Z","deleted_at":"2026-01-25T15:21:35.152254Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-2ys","title":"[CP1] Cargo.toml updates - async-stream and futures","description":"## Background\n\nThe GitLab client pagination methods require async streaming capabilities. The `async-stream` crate provides the `stream!` macro for creating async iterators, and `futures` provides `StreamExt` for consuming them with `.next()` and other combinators.\n\n## Approach\n\nAdd these dependencies to Cargo.toml:\n\n```toml\n[dependencies]\nasync-stream = \"0.3\"\nfutures = { version = \"0.3\", default-features = false, features = [\"alloc\"] }\n```\n\nUse minimal features on `futures` to avoid pulling unnecessary code.\n\n## Acceptance Criteria\n\n- [ ] `async-stream = \"0.3\"` is in Cargo.toml [dependencies]\n- [ ] `futures` with `alloc` feature is in Cargo.toml [dependencies]\n- [ ] `cargo check` succeeds after adding dependencies\n\n## Files\n\n- Cargo.toml (edit)\n\n## TDD Loop\n\nRED: Not applicable (dependency addition)\nGREEN: Add lines to Cargo.toml\nVERIFY: `cargo check`\n\n## Edge Cases\n\n- If `futures` is already present, merge features rather than duplicate\n- Use exact version pins for reproducibility","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-25T17:02:38.104664Z","created_by":"tayloreernisse","updated_at":"2026-01-25T22:25:10.274787Z","closed_at":"2026-01-25T22:25:10.274727Z","close_reason":"Added async-stream 0.3 and futures 0.3 (alloc feature) to Cargo.toml, cargo check passes","compaction_level":0,"original_size":0}
{"id":"bd-2yu5","title":"Add timestamp-aware test helpers","description":"## Background\nExisting test helpers (who.rs:2469-2598) use now_ms() for all timestamps. Time-decay tests need precise timestamp control to verify decay math, state-aware timestamps, and as-of filtering.\n\n## Approach\nAdd three new helpers in the test module (who.rs after insert_file_change at line 2598), patterned after existing helpers:\n\n### insert_mr_at() — variant of insert_mr (who.rs:2469-2493):\n```rust\n#[allow(clippy::too_many_arguments)]\nfn insert_mr_at(\n    conn: &Connection,\n    id: i64,\n    project_id: i64,\n    iid: i64,\n    author: &str,\n    state: &str,\n    updated_at_ms: i64,\n    merged_at_ms: Option<i64>,\n    closed_at_ms: Option<i64>,\n) {\n    conn.execute(\n        \"INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, updated_at, merged_at, closed_at)\n         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)\",\n        rusqlite::params![id, id * 10, project_id, iid, format!(\"MR {iid}\"), author, state, now_ms(), updated_at_ms, merged_at_ms, closed_at_ms],\n    ).unwrap();\n}\n```\n\n### insert_diffnote_at() — variant of insert_diffnote (who.rs:2541-2580):\n```rust\n#[allow(clippy::too_many_arguments)]\nfn insert_diffnote_at(\n    conn: &Connection,\n    id: i64,\n    discussion_id: i64,\n    project_id: i64,\n    author: &str,\n    new_path: &str,\n    old_path: Option<&str>,\n    body: &str,\n    created_at_ms: i64,\n) {\n    conn.execute(\n        \"INSERT INTO notes (id, gitlab_id, project_id, discussion_id, author_username, note_type, is_system, position_new_path, position_old_path, body, created_at, updated_at)\n         VALUES (?1, ?2, ?3, ?4, ?5, 'DiffNote', 0, ?6, ?7, ?8, ?9, ?9)\",\n        rusqlite::params![id, id * 10, project_id, discussion_id, author, new_path, old_path, body, created_at_ms],\n    ).unwrap();\n}\n```\n\n### insert_file_change_with_old_path() — variant of insert_file_change (who.rs:2585-2598):\n```rust\nfn insert_file_change_with_old_path(\n    conn: &Connection,\n    mr_id: i64,\n    project_id: i64,\n    new_path: &str,\n    old_path: Option<&str>,\n    change_type: &str,\n) {\n    conn.execute(\n        \"INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, old_path, change_type)\n         VALUES (?1, ?2, ?3, ?4, ?5)\",\n        rusqlite::params![mr_id, project_id, new_path, old_path, change_type],\n    ).unwrap();\n}\n```\n\nNote: mr_file_changes already has an old_path column (from migration 016). The existing insert_file_change helper simply omits it (defaults to NULL).\n\n## Acceptance Criteria\n- [ ] insert_mr_at compiles and inserts with explicit updated_at, merged_at, closed_at\n- [ ] insert_diffnote_at compiles and inserts with explicit old_path and created_at\n- [ ] insert_file_change_with_old_path compiles and inserts with explicit old_path\n- [ ] Existing helpers (insert_mr, insert_diffnote, insert_file_change) unchanged\n- [ ] cargo check --all-targets passes\n- [ ] All three helpers are used by downstream beads (bd-13q8, bd-1h3f)\n\n## Files\n- MODIFY: src/cli/commands/who.rs (test module, after insert_file_change at line ~2598)\n\n## TDD Loop\nN/A — these are test utilities. Verified indirectly by tests in bd-13q8 and bd-1h3f.\nVERIFY: cargo check --all-targets (compiles cleanly)\n\n## Edge Cases\n- merged_at_ms/closed_at_ms as Option<i64> — NULL when not applicable\n- old_path as Option<&str> — NULL when no rename occurred\n- created_at_ms used for both created_at and updated_at on notes (matching existing insert_diffnote pattern)\n- #[allow(clippy::too_many_arguments)] needed on helpers with 8+ params (project uses pedantic clippy)\n\nDependencies:\n  -> bd-2w1p (blocks) - Add half-life fields and config validation to ScoringConfig","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-09T17:00:19.594086Z","created_by":"tayloreernisse","updated_at":"2026-02-12T20:43:04.408355Z","closed_at":"2026-02-12T20:43:04.408316Z","close_reason":"Implemented by time-decay swarm: 3 agents, 12 tasks, 621 tests passing, all quality gates green","compaction_level":0,"original_size":0,"labels":["scoring","test"],"dependencies":[{"issue_id":"bd-2yu5","depends_on_id":"bd-2w1p","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-2zl","title":"Epic: Gate 1 - Resource Events Ingestion","description":"## Background\nGate 1 transforms gitlore from a snapshot engine into a temporal data store by ingesting structured event data from GitLab Resource Events APIs (state, label, milestone changes). This is the foundation — Gates 2-5 all depend on the event tables and dependent fetch queue that Gate 1 establishes.\n\nCurrently, when an issue is closed or a label changes, gitlore overwrites the current state. The transition is lost. Gate 1 captures these transitions as discrete events with timestamps, actors, and provenance, enabling temporal queries like \"when did this issue become critical?\" and \"who closed this MR?\"\n\n## Architecture\n- **Three new tables:** resource_state_events, resource_label_events, resource_milestone_events (migration 011, already shipped as bd-hu3)\n- **Generic dependent fetch queue:** pending_dependent_fetches table replaces per-type queue tables. Supports job_types: resource_events, mr_closes_issues, mr_diffs. Used by Gates 1, 2, and 4.\n- **Opt-in via config:** sync.fetchResourceEvents (default true). --no-events CLI flag to skip.\n- **Incremental:** Only changed entities enqueued. --full re-enqueues all.\n- **Crash recovery:** locked_at column with 5-minute stale lock reclaim.\n\n## Children (Execution Order)\n1. **bd-hu3** [CLOSED] — Migration 011: event tables + entity_references + dependent fetch queue\n2. **bd-2e8** [CLOSED] — fetchResourceEvents config flag\n3. **bd-2fm** [CLOSED] — GitLab Resource Event serde types\n4. **bd-sqw** [CLOSED] — Resource Events API endpoints in GitLab client\n5. **bd-1uc** [CLOSED] — DB upsert functions for resource events\n6. **bd-tir** [CLOSED] — Generic dependent fetch queue (enqueue + drain)\n7. **bd-1ep** [CLOSED] — Wire resource event fetching into sync pipeline\n8. **bd-3sh** [CLOSED] — lore count events command\n9. **bd-1m8** [CLOSED] — lore stats --check for event integrity + queue health\n\n## Gate Completion Criteria\n- [ ] All 9 children closed\n- [ ] `lore sync` fetches resource events for changed entities\n- [ ] `lore sync --no-events` skips event fetching\n- [ ] Event fetch failures queued for retry with exponential backoff\n- [ ] Stale locks auto-reclaimed on next sync run\n- [ ] `lore count events` shows counts by type (state/label/milestone)\n- [ ] `lore stats --check` validates referential integrity + queue health\n- [ ] Robot mode JSON for all new commands\n- [ ] Integration test: full sync cycle with events enabled\n\n## Dependencies\n- None (Gate 1 is the foundation)\n- Downstream: Gate 2 (bd-1se) depends on event tables and dependent fetch queue","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-02-02T21:30:49.136036Z","created_by":"tayloreernisse","updated_at":"2026-02-05T16:06:52.080788Z","closed_at":"2026-02-05T16:06:52.080725Z","close_reason":"Already implemented: migration 011 exists, events_db.rs has upsert functions, client.rs has fetch_*_state_events, orchestrator.rs has drain_resource_events. Full Gate 1 functionality is live.","compaction_level":0,"original_size":0,"labels":["epic","gate-1","phase-b"]}
{"id":"bd-2zr","title":"[CP1] GitLab client pagination methods","description":"Add async stream methods for paginated GitLab API calls.\n\n## Methods to Add to GitLabClient\n\n### paginate_issues(gitlab_project_id, updated_after, cursor_rewind_seconds) -> Stream<GitLabIssue>\n- Use async_stream::try_stream! macro\n- Query params: scope=all, state=all, order_by=updated_at, sort=asc, per_page=100\n- If updated_after provided, apply cursor_rewind_seconds (subtract from timestamp)\n- Clamp to 0 to avoid underflow: (ts - rewind_ms).max(0)\n- Follow x-next-page header until empty/absent\n- Fall back to empty-page detection if headers missing\n\n### paginate_issue_discussions(gitlab_project_id, issue_iid) -> Stream<GitLabDiscussion>\n- Paginate through discussions for single issue\n- per_page=100\n- Follow x-next-page header\n\n### request_with_headers<T>(path, params) -> Result<(T, HeaderMap)>\n- Acquire rate limiter\n- Make request with PRIVATE-TOKEN header\n- Return both deserialized data and response headers\n\n## Dependencies\n- async-stream = \"0.3\" (for try_stream! macro)\n- futures = \"0.3\" (for Stream trait and StreamExt)\n\nFiles: src/gitlab/client.rs\nTests: tests/pagination_tests.rs\nDone when: Pagination handles multiple pages and respects cursors, tests pass","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T16:57:13.045971Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.784887Z","closed_at":"2026-01-25T17:02:01.784887Z","deleted_at":"2026-01-25T17:02:01.784883Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-31b","title":"[CP1] Discussion ingestion module","description":"Fetch and store discussions/notes for each issue.\n\nImplement ingestIssueDiscussions(options) → { discussionsFetched, discussionsUpserted, notesUpserted, systemNotesCount }\n\nLogic:\n1. Paginate through all discussions for given issue\n2. For each discussion:\n   - Store raw payload (compressed)\n   - Upsert discussion record with correct issue FK\n   - Transform and upsert all notes\n   - Store raw payload per note\n   - Track system notes count\n\nFiles: src/ingestion/discussions.ts\nTests: tests/integration/issue-discussion-ingestion.test.ts\nDone when: Discussions and notes populated with correct FKs and is_system flags","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:19:57.131442Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.156574Z","closed_at":"2026-01-25T15:21:35.156574Z","deleted_at":"2026-01-25T15:21:35.156571Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-31i","title":"Epic: CP2 Gate B - Labels + Assignees + Reviewers","description":"## Background\nGate B validates junction tables for labels, assignees, and reviewers. Ensures relationships are tracked correctly and stale links are removed on resync. This is critical for filtering (`--reviewer=alice`) and display.\n\n## Acceptance Criteria (Pass/Fail)\n- [ ] `mr_labels` table has rows for MRs with labels\n- [ ] Label count per MR matches GitLab UI (spot check 3 MRs)\n- [ ] `mr_assignees` table has rows for MRs with assignees\n- [ ] Assignee usernames match GitLab UI (spot check 3 MRs)\n- [ ] `mr_reviewers` table has rows for MRs with reviewers\n- [ ] Reviewer usernames match GitLab UI (spot check 3 MRs)\n- [ ] Remove label in GitLab -> resync -> link removed from mr_labels\n- [ ] Add reviewer in GitLab -> resync -> link added to mr_reviewers\n- [ ] `gi list mrs --label=bugfix` filters correctly\n- [ ] `gi list mrs --reviewer=alice` filters correctly\n\n## Validation Script\n```bash\n#!/bin/bash\nset -e\n\nDB_PATH=\"${XDG_DATA_HOME:-$HOME/.local/share}/gitlab-inbox/db.sqlite3\"\n\necho \"=== Gate B: Labels + Assignees + Reviewers ===\"\n\n# 1. Check label linkage exists\necho \"Step 1: Check label linkage...\"\nLABEL_LINKS=$(sqlite3 \"$DB_PATH\" \"SELECT COUNT(*) FROM mr_labels;\")\necho \"  Total label links: $LABEL_LINKS\"\n\n# 2. Show sample label linkage\necho \"Step 2: Sample label linkage...\"\nsqlite3 \"$DB_PATH\" \"\n  SELECT m.iid, GROUP_CONCAT(l.name, ', ') as labels\n  FROM merge_requests m\n  JOIN mr_labels ml ON ml.merge_request_id = m.id\n  JOIN labels l ON l.id = ml.label_id\n  GROUP BY m.id\n  LIMIT 5;\n\"\n\n# 3. Check assignee linkage\necho \"Step 3: Check assignee linkage...\"\nASSIGNEE_LINKS=$(sqlite3 \"$DB_PATH\" \"SELECT COUNT(*) FROM mr_assignees;\")\necho \"  Total assignee links: $ASSIGNEE_LINKS\"\n\n# 4. Show sample assignee linkage\necho \"Step 4: Sample assignee linkage...\"\nsqlite3 \"$DB_PATH\" \"\n  SELECT m.iid, GROUP_CONCAT(a.username, ', ') as assignees\n  FROM merge_requests m\n  JOIN mr_assignees a ON a.merge_request_id = m.id\n  GROUP BY m.id\n  LIMIT 5;\n\"\n\n# 5. Check reviewer linkage\necho \"Step 5: Check reviewer linkage...\"\nREVIEWER_LINKS=$(sqlite3 \"$DB_PATH\" \"SELECT COUNT(*) FROM mr_reviewers;\")\necho \"  Total reviewer links: $REVIEWER_LINKS\"\n\n# 6. Show sample reviewer linkage\necho \"Step 6: Sample reviewer linkage...\"\nsqlite3 \"$DB_PATH\" \"\n  SELECT m.iid, GROUP_CONCAT(r.username, ', ') as reviewers\n  FROM merge_requests m\n  JOIN mr_reviewers r ON r.merge_request_id = m.id\n  GROUP BY m.id\n  LIMIT 5;\n\"\n\n# 7. Test filter commands\necho \"Step 7: Test filter commands...\"\n# Get a label that exists\nLABEL=$(sqlite3 \"$DB_PATH\" \"SELECT name FROM labels LIMIT 1;\")\nif [ -n \"$LABEL\" ]; then\n  echo \"  Testing --label=$LABEL\"\n  gi list mrs --label=\"$LABEL\" --limit=3\nfi\n\n# Get a reviewer that exists\nREVIEWER=$(sqlite3 \"$DB_PATH\" \"SELECT username FROM mr_reviewers LIMIT 1;\")\nif [ -n \"$REVIEWER\" ]; then\n  echo \"  Testing --reviewer=$REVIEWER\"\n  gi list mrs --reviewer=\"$REVIEWER\" --limit=3\nfi\n\necho \"\"\necho \"=== Gate B: PASSED ===\"\n```\n\n## Stale Link Removal Test (Manual)\n```bash\n# 1. Pick an MR with labels in GitLab UI\nMR_IID=123\n\n# 2. Note current label count\nsqlite3 \"$DB_PATH\" \"\n  SELECT COUNT(*) FROM mr_labels ml\n  JOIN merge_requests m ON m.id = ml.merge_request_id\n  WHERE m.iid = $MR_IID;\n\"\n# Example: 3 labels\n\n# 3. Remove a label in GitLab UI (manually)\n\n# 4. Resync\ngi ingest --type=merge_requests\n\n# 5. Verify label removed\nsqlite3 \"$DB_PATH\" \"\n  SELECT COUNT(*) FROM mr_labels ml\n  JOIN merge_requests m ON m.id = ml.merge_request_id\n  WHERE m.iid = $MR_IID;\n\"\n# Should be: 2 labels (one less)\n```\n\n## Test Commands (Quick Verification)\n```bash\n# Check counts:\nsqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 \"\n  SELECT \n    (SELECT COUNT(*) FROM mr_labels) as label_links,\n    (SELECT COUNT(*) FROM mr_assignees) as assignee_links,\n    (SELECT COUNT(*) FROM mr_reviewers) as reviewer_links;\n\"\n\n# Test filtering:\ngi list mrs --label=enhancement --limit=5\ngi list mrs --reviewer=alice --limit=5\ngi list mrs --assignee=bob --limit=5\n```\n\n## Dependencies\nThis gate requires:\n- bd-ser (MR ingestion with label/assignee/reviewer linking via clear-and-relink pattern)\n- Gate A must pass first\n\n## Edge Cases\n- MRs with no labels/assignees/reviewers: junction tables should have no rows for that MR\n- Labels shared across issues and MRs: labels table is shared, only junction differs\n- Usernames are case-sensitive: `Alice` != `alice`","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-26T22:06:01.292318Z","created_by":"tayloreernisse","updated_at":"2026-01-27T00:48:21.059422Z","closed_at":"2026-01-27T00:48:21.059378Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-31i","depends_on_id":"bd-ser","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-31m","title":"[CP1] Test fixtures for mocked GitLab responses","description":"Create mock response files for integration tests.\n\nFixtures to create:\n- gitlab-issue.json (single issue with labels)\n- gitlab-issues-page.json (paginated list)\n- gitlab-discussion.json (single discussion with notes)\n- gitlab-discussions-page.json (paginated list)\n\nInclude edge cases:\n- Issue with labels_details\n- Issue with no labels\n- Discussion with individual_note=true\n- System notes with system=true\n\nFiles: tests/fixtures/mock-responses/gitlab-issue*.json, gitlab-discussion*.json\nDone when: MSW handlers can use fixtures for deterministic tests","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T15:20:43.781288Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.155480Z","closed_at":"2026-01-25T15:21:35.155480Z","deleted_at":"2026-01-25T15:21:35.155478Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-327","title":"[CP0] Project scaffold","description":"## Background\n\nThis is the foundational scaffold for the GitLab Inbox CLI tool. Every subsequent bead depends on having the correct project structure, TypeScript configuration, and tooling in place. The configuration choices here (ESM modules, strict TypeScript, Vitest for testing) set constraints for all future code.\n\n## Approach\n\nCreate a Node.js 20+ ESM project with TypeScript strict mode. Use flat ESLint config (v9+) with TypeScript plugin. Configure Vitest with coverage. Create the directory structure matching the PRD exactly.\n\n**package.json essentials:**\n- `\"type\": \"module\"` for ESM\n- `\"bin\": { \"gi\": \"./dist/cli/index.js\" }` for CLI entry point\n- Runtime deps: better-sqlite3, sqlite-vec, commander, zod, pino, pino-pretty, ora, chalk, cli-table3, inquirer\n- Dev deps: typescript, @types/better-sqlite3, @types/node, vitest, msw, eslint, @typescript-eslint/*\n\n**tsconfig.json:**\n- `target: ES2022`, `module: Node16`, `moduleResolution: Node16`\n- `strict: true`, `noImplicitAny: true`, `strictNullChecks: true`\n- `outDir: ./dist`, `rootDir: ./src`\n\n**vitest.config.ts:**\n- Exclude `tests/live/**` unless `GITLAB_LIVE_TESTS=1`\n- Coverage with v8 provider\n\n## Acceptance Criteria\n\n- [ ] `npm install` completes without errors\n- [ ] `npm run build` compiles TypeScript to dist/\n- [ ] `npm run test` runs vitest (0 tests is fine at this stage)\n- [ ] `npm run lint` runs ESLint with no config errors\n- [ ] All directories exist: src/cli/commands/, src/core/, src/gitlab/, src/types/, tests/unit/, tests/integration/, tests/live/, tests/fixtures/mock-responses/, migrations/\n\n## Files\n\nCREATE:\n- package.json\n- tsconfig.json\n- vitest.config.ts\n- eslint.config.js\n- .gitignore\n- src/cli/index.ts (empty placeholder with shebang)\n- src/cli/commands/.gitkeep\n- src/core/.gitkeep\n- src/gitlab/.gitkeep\n- src/types/index.ts (empty)\n- tests/unit/.gitkeep\n- tests/integration/.gitkeep\n- tests/live/.gitkeep\n- tests/fixtures/mock-responses/.gitkeep\n- migrations/.gitkeep\n\n## TDD Loop\n\nN/A - scaffold only. Verify with:\n\n```bash\nnpm install\nnpm run build\nnpm run lint\nnpm run test\n```\n\n## Edge Cases\n\n- Node.js version < 20 will fail on ESM features - add `engines` field\n- better-sqlite3 requires native compilation - may need python/build-essential\n- sqlite-vec installation can fail on some platforms - document fallback","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-24T16:09:47.955044Z","created_by":"tayloreernisse","updated_at":"2026-01-25T02:51:25.347932Z","closed_at":"2026-01-25T02:51:25.347799Z","compaction_level":0,"original_size":0}
{"id":"bd-32aw","title":"Implement error handling paths for me command","description":"## Background\n`lore me` has several expected user-facing failure modes that are usage/data conditions, not internal faults. This bead ensures those paths return correct exit codes and structured error output in both human and robot modes.\n\nThis spec assumes runtime usage errors are represented by `LoreError::UsageError` mapped to `ErrorCode::UsageError` (exit code 2, robot code `USAGE_ERROR`) from `bd-1f1f`.\n\n## Approach\nValidate and harden these paths in the handler and shared error formatting:\n\n1. **No username configured**\n- source: username resolver\n- error: `LoreError::UsageError`\n- exit: `2`\n- message includes: configure `gitlab.username` or pass `--user`\n\n2. **No synced data**\n- source: `projects` table count is zero\n- error: `LoreError::NotFound(\"No synced data. Run 'lore sync' first.\")`\n- exit: `17`\n\n3. **Invalid scope flags (`--project` + `--all`)**\n- source: scope resolver\n- error: `LoreError::UsageError`\n- exit: `2`\n\n4. **Invalid `--since` value**\n- source: parse failure in handler\n- error: `LoreError::UsageError`\n- exit: `2`\n\n5. **Empty results**\n- not an error\n- render zero-state dashboard normally\n\nRobot mode must emit error envelope on stderr with deterministic code/message/suggestion, including `USAGE_ERROR` for usage failures.\n\n## Acceptance Criteria\n- [ ] No username path returns exit 2 with actionable usage message\n- [ ] `--project` + `--all` returns exit 2\n- [ ] Invalid `--since` returns exit 2\n- [ ] No synced data returns exit 17 and sync suggestion\n- [ ] Empty results render dashboard (not error)\n- [ ] Robot stderr envelope includes correct `error.code` (`USAGE_ERROR` or `NOT_FOUND`)\n- [ ] Human-mode stderr is readable and actionable\n- [ ] No usage-path error falls through to internal error exit 1\n\n## Files\n- MODIFY: `src/cli/commands/me/mod.rs`\n- MODIFY: `src/core/error.rs`\n- MODIFY: `src/main.rs` (if command-level error routing needs explicit usage handling)\n- MODIFY: `src/cli/robot.rs` (only if code mapping/suggestion formatting requires extension)\n\n## TDD Anchor\nRED:\n- `test_no_username_exits_2`\n- `test_project_all_conflict_exits_2`\n- `test_invalid_since_exits_2`\n- `test_no_synced_data_exits_17`\n- `test_empty_results_not_error`\n- `test_robot_usage_error_envelope`\n\nGREEN:\n- Wire usage/data errors to explicit `LoreError` variants.\n- Ensure formatter emits consistent code and suggestion.\n\nVERIFY:\n- `cargo test me_error_paths`\n\n## Edge Cases\n- A DB with projects but no matching items is not \"no synced data\"; it is a valid empty dashboard.\n- Error messages should not leak SQL internals to end users.\n\n## Dependency Context\nConsumes handler wiring from `bd-1vv8` and error type from `bd-1f1f`.\nFinalizes AC-10 compliance for `lore me`.\n\nDependencies:\n  -> bd-1vv8 (blocks) - Implement me command handler: wire queries to renderers","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-19T19:40:59.654948Z","created_by":"tayloreernisse","updated_at":"2026-02-20T16:09:13.066507Z","closed_at":"2026-02-20T16:09:13.066472Z","close_reason":"Implemented by lore-me agent swarm","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-32aw","depends_on_id":"bd-1vv8","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-32mc","title":"OBSERV: Implement log retention cleanup at startup","description":"## Background\nLog files accumulate at ~1-10 MB/day. Without cleanup, they grow unbounded. Retention runs BEFORE subscriber init so deleted file handles aren't held open by the appender.\n\n## Approach\nAdd a cleanup function, called from main.rs before the subscriber is initialized (before current line 44):\n\n```rust\n/// Delete log files older than retention_days.\n/// Matches files named lore.YYYY-MM-DD.log in the log directory.\npub fn cleanup_old_logs(log_dir: &Path, retention_days: u32) -> std::io::Result<usize> {\n    if retention_days == 0 {\n        return Ok(0); // 0 means file logging disabled, don't delete\n    }\n    let cutoff = SystemTime::now() - Duration::from_secs(u64::from(retention_days) * 86400);\n    let mut deleted = 0;\n\n    for entry in std::fs::read_dir(log_dir)? {\n        let entry = entry?;\n        let name = entry.file_name();\n        let name_str = name.to_string_lossy();\n\n        // Only match lore.YYYY-MM-DD.log pattern\n        if !name_str.starts_with(\"lore.\") || !name_str.ends_with(\".log\") {\n            continue;\n        }\n\n        if let Ok(metadata) = entry.metadata() {\n            if let Ok(modified) = metadata.modified() {\n                if modified < cutoff {\n                    std::fs::remove_file(entry.path())?;\n                    deleted += 1;\n                }\n            }\n        }\n    }\n    Ok(deleted)\n}\n```\n\nPlace this function in src/core/paths.rs (next to get_log_dir) or a new src/core/log_retention.rs. Prefer paths.rs since it's small and related.\n\nCall from main.rs:\n```rust\nlet log_dir = get_log_dir(config.logging.log_dir.as_deref());\nlet _ = cleanup_old_logs(&log_dir, config.logging.retention_days);\n// THEN init subscriber\n```\n\nNote: Config must be loaded before cleanup runs. Current main.rs parses Cli at line 60, but config loading happens inside command handlers. This means we need to either:\n  A) Load config early in main() before subscriber init (preferred)\n  B) Defer cleanup to after config load\n\nSince the subscriber must also know log_dir, approach A is natural: load config -> cleanup -> init subscriber -> dispatch command.\n\n## Acceptance Criteria\n- [ ] Files matching lore.*.log older than retention_days are deleted\n- [ ] Files matching lore.*.log within retention_days are preserved\n- [ ] Non-matching files (e.g., other.txt) are never deleted\n- [ ] retention_days=0 skips cleanup entirely (no files deleted)\n- [ ] Errors on individual files don't prevent cleanup of remaining files\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/core/paths.rs (add cleanup_old_logs function)\n- src/main.rs (call cleanup before subscriber init)\n\n## TDD Loop\nRED:\n  - test_log_retention_cleanup: create tempdir with lore.2026-01-01.log through lore.2026-02-04.log, run with retention_days=7, assert old deleted, recent preserved\n  - test_log_retention_ignores_non_log_files: create other.txt alongside old log files, assert other.txt untouched\n  - test_log_retention_zero_days: retention_days=0, assert nothing deleted\nGREEN: Implement cleanup_old_logs\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- SystemTime::now() precision varies by OS; use file modified time, not name parsing (simpler and more reliable)\n- read_dir on non-existent directory: get_log_dir creates it first, so this shouldn't happen. But handle gracefully.\n- Permissions error on individual file: log a warning, continue with remaining files (don't propagate)\n- Race condition: another process creates a file during cleanup. Not a concern -- we only delete old files.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-04T15:53:55.627901Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:15:04.452086Z","closed_at":"2026-02-04T17:15:04.452039Z","close_reason":"Implemented cleanup_old_logs() with date-pattern matching and retention_days config, runs at startup before subscriber init","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-32mc","depends_on_id":"bd-17n","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-32mc","depends_on_id":"bd-1k4","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-32mc","depends_on_id":"bd-2nx","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-32q","title":"Implement timeline seed phase: FTS5 keyword search to entity IDs","description":"## Background\n\nThe seed phase is steps 1-2 of the timeline pipeline (spec Section 3.2): SEED + HYDRATE. It converts a keyword query into entity IDs via FTS5 search and collects evidence note candidates.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.2 steps 1-2.\n\n## Codebase Context\n\n- FTS5 index exists: documents_fts table (migration 008)\n- documents table: id, source_type ('issue'|'merge_request'|'discussion'), source_id, project_id, created_at, content\n- discussions table: id, issue_id, merge_request_id\n- notes table: discussion_id, author_username, body, created_at, is_system, id (note_id)\n- Safe FTS query builder: src/search/fts.rs has to_fts_query(raw, FtsQueryMode::Safe) for sanitizing user input\n- projects table: path_with_namespace\n- issues/merge_requests: iid, project_id\n\n## Approach\n\nCreate `src/core/timeline_seed.rs`:\n\n```rust\nuse crate::core::timeline::{EntityRef, TimelineEvent, TimelineEventType};\nuse rusqlite::Connection;\n\npub struct SeedResult {\n    pub seed_entities: Vec<EntityRef>,\n    pub evidence_notes: Vec<TimelineEvent>,  // NoteEvidence events\n}\n\npub fn seed_timeline(\n    conn: &Connection,\n    query: &str,\n    project_id: Option<i64>,\n    since_ms: Option<i64>,\n    max_seeds: usize,           // default 50\n) -> Result<SeedResult> { ... }\n```\n\n### SQL for SEED + HYDRATE (entity discovery):\n```sql\nSELECT DISTINCT d.source_type, d.source_id, d.project_id,\n       CASE d.source_type\n         WHEN 'issue' THEN (SELECT iid FROM issues WHERE id = d.source_id)\n         WHEN 'merge_request' THEN (SELECT iid FROM merge_requests WHERE id = d.source_id)\n         WHEN 'discussion' THEN NULL  -- discussions map to parent entity below\n       END AS iid,\n       CASE d.source_type\n         WHEN 'issue' THEN (SELECT p.path_with_namespace FROM projects p JOIN issues i ON i.project_id = p.id WHERE i.id = d.source_id)\n         WHEN 'merge_request' THEN (SELECT p.path_with_namespace FROM projects p JOIN merge_requests m ON m.project_id = p.id WHERE m.id = d.source_id)\n         WHEN 'discussion' THEN NULL\n       END AS project_path\nFROM documents_fts fts\nJOIN documents d ON d.id = fts.rowid\nWHERE documents_fts MATCH ?1\n  AND (?2 IS NULL OR d.project_id = ?2)\nORDER BY rank\nLIMIT ?3\n```\n\nFor 'discussion' source_type: resolve to parent entity via discussions.issue_id or discussions.merge_request_id.\n\n### SQL for evidence notes (top 10 FTS5-matched notes):\n```sql\nSELECT n.id as note_id, n.body, n.created_at, n.author_username,\n       disc.id as discussion_id,\n       CASE WHEN disc.issue_id IS NOT NULL THEN 'issue' ELSE 'merge_request' END as parent_type,\n       COALESCE(disc.issue_id, disc.merge_request_id) AS parent_entity_id\nFROM documents_fts fts\nJOIN documents d ON d.id = fts.rowid\nJOIN discussions disc ON disc.id = d.source_id AND d.source_type = 'discussion'\nJOIN notes n ON n.discussion_id = disc.id AND n.is_system = 0\nWHERE documents_fts MATCH ?1\nORDER BY rank\nLIMIT 10\n```\n\nEvidence notes become TimelineEvent with:\n- event_type: NoteEvidence { note_id, snippet (first 200 chars), discussion_id }\n- Use to_fts_query(query, FtsQueryMode::Safe) to sanitize user input before MATCH\n\nRegister in `src/core/mod.rs`: `pub mod timeline_seed;`\n\n## Acceptance Criteria\n\n- [ ] seed_timeline() returns entities from FTS5 search\n- [ ] Entities deduplicated (same entity from multiple docs appears once)\n- [ ] Discussion documents resolved to parent entity (issue or MR)\n- [ ] Evidence notes capped at 10\n- [ ] Evidence note snippets truncated to 200 chars (safe UTF-8 boundary)\n- [ ] Uses to_fts_query(query, FtsQueryMode::Safe) for input sanitization\n- [ ] --since filter works\n- [ ] -p filter works\n- [ ] Empty result for zero-match queries (not error)\n- [ ] Module registered in src/core/mod.rs\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/core/timeline_seed.rs` (NEW)\n- `src/core/mod.rs` (add `pub mod timeline_seed;`)\n\n## TDD Loop\n\nRED:\n- `test_seed_deduplicates_entities`\n- `test_seed_resolves_discussion_to_parent`\n- `test_seed_empty_query_returns_empty`\n- `test_seed_evidence_capped_at_10`\n- `test_seed_evidence_snippet_truncated`\n- `test_seed_respects_since_filter`\n\nTests need in-memory DB with migrations 001-014 + documents/FTS test data.\n\nGREEN: Implement FTS5 queries and deduplication.\n\nVERIFY: `cargo test --lib -- timeline_seed`\n\n## Edge Cases\n\n- FTS5 MATCH invalid syntax: to_fts_query(query, FtsQueryMode::Safe) sanitizes\n- Discussion orphans: LEFT JOIN handles deleted notes\n- UTF-8 truncation: use char_indices() to find safe 200-char boundary\n- Discussion source resolving to both issue_id and merge_request_id: prefer issue_id (shouldn't happen but be defensive)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:33:08.615908Z","created_by":"tayloreernisse","updated_at":"2026-02-05T21:47:07.966488Z","closed_at":"2026-02-05T21:47:07.966437Z","close_reason":"Completed: Created src/core/timeline_seed.rs with seed_timeline() function. FTS5 search to entity IDs with discussion-to-parent resolution, entity deduplication, evidence note extraction (capped, snippet-truncated). 12 tests pass. All quality gates pass.","compaction_level":0,"original_size":0,"labels":["gate-3","phase-b","query"],"dependencies":[{"issue_id":"bd-32q","depends_on_id":"bd-20e","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-32q","depends_on_id":"bd-ike","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-335","title":"Implement Ollama API client","description":"## Background\nThe Ollama API client provides the HTTP interface to the local Ollama embedding server. It handles health checks (is Ollama running? does the model exist?), batch embedding requests (up to 32 texts per call), and error translation to LoreError variants. This is the lowest-level embedding component — the pipeline (bd-am7) builds on top of it.\n\n## Approach\nCreate \\`src/embedding/ollama.rs\\` per PRD Section 4.2. **Uses async reqwest (not blocking).**\n\n```rust\nuse reqwest::Client;  // NOTE: async Client, not reqwest::blocking\nuse serde::{Deserialize, Serialize};\nuse crate::core::error::{LoreError, Result};\n\npub struct OllamaConfig {\n    pub base_url: String,      // default \\\"http://localhost:11434\\\"\n    pub model: String,         // default \\\"nomic-embed-text\\\"\n    pub timeout_secs: u64,     // default 60\n}\n\nimpl Default for OllamaConfig { /* PRD defaults */ }\n\npub struct OllamaClient {\n    client: Client,      // async reqwest::Client\n    config: OllamaConfig,\n}\n\n#[derive(Serialize)]\nstruct EmbedRequest { model: String, input: Vec<String> }\n\n#[derive(Deserialize)]\nstruct EmbedResponse { model: String, embeddings: Vec<Vec<f32>> }\n\n#[derive(Deserialize)]\nstruct TagsResponse { models: Vec<ModelInfo> }\n\n#[derive(Deserialize)]\nstruct ModelInfo { name: String }\n\nimpl OllamaClient {\n    pub fn new(config: OllamaConfig) -> Self;\n\n    /// Async health check: GET /api/tags\n    /// Model matched via starts_with (\\\"nomic-embed-text\\\" matches \\\"nomic-embed-text:latest\\\")\n    pub async fn health_check(&self) -> Result<()>;\n\n    /// Async batch embedding: POST /api/embed\n    /// Input: Vec<String> of texts, Response: Vec<Vec<f32>> of 768-dim embeddings\n    pub async fn embed_batch(&self, texts: Vec<String>) -> Result<Vec<Vec<f32>>>;\n}\n\n/// Quick health check without full client (async).\npub async fn check_ollama_health(base_url: &str) -> bool;\n```\n\n**Error mapping (per PRD):**\n- Connection refused/timeout -> LoreError::OllamaUnavailable { base_url, source: Some(e) }\n- Model not in /api/tags -> LoreError::OllamaModelNotFound { model }\n- Non-200 from /api/embed -> LoreError::EmbeddingFailed { document_id: 0, reason: format!(\\\"HTTP {}: {}\\\", status, body) }\n\n**Key PRD detail:** Model matching uses \\`starts_with\\` (not exact match) so \\\"nomic-embed-text\\\" matches \\\"nomic-embed-text:latest\\\".\n\n## Acceptance Criteria\n- [ ] Uses async reqwest::Client (not blocking)\n- [ ] health_check() is async, detects server availability and model presence\n- [ ] Model matched via starts_with (handles \\\":latest\\\" suffix)\n- [ ] embed_batch() is async, sends POST /api/embed\n- [ ] Batch size up to 32 texts\n- [ ] Returns Vec<Vec<f32>> with 768 dimensions each\n- [ ] OllamaUnavailable error includes base_url and source error\n- [ ] OllamaModelNotFound error includes model name\n- [ ] Non-200 response mapped to EmbeddingFailed with status + body\n- [ ] Timeout: 60 seconds default (configurable via OllamaConfig)\n- [ ] \\`cargo build\\` succeeds\n\n## Files\n- \\`src/embedding/ollama.rs\\` — new file\n- \\`src/embedding/mod.rs\\` — add \\`pub mod ollama;\\` and re-exports\n\n## TDD Loop\nRED: Tests (unit tests with mock, integration needs Ollama):\n- \\`test_config_defaults\\` — verify default base_url, model, timeout\n- \\`test_health_check_model_starts_with\\` — \\\"nomic-embed-text\\\" matches \\\"nomic-embed-text:latest\\\"\n- \\`test_embed_batch_parse\\` — mock response parsed correctly\n- \\`test_connection_error_maps_to_ollama_unavailable\\`\nGREEN: Implement OllamaClient\nVERIFY: \\`cargo test ollama\\`\n\n## Edge Cases\n- Ollama returns model name with version tag (\\\"nomic-embed-text:latest\\\"): starts_with handles this\n- Empty texts array: send empty batch, Ollama returns empty embeddings\n- Ollama returns wrong number of embeddings (2 texts, 1 embedding): caller (pipeline) validates\n- Non-JSON response: reqwest deserialization error -> wrap appropriately","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:26:34.025099Z","created_by":"tayloreernisse","updated_at":"2026-01-30T16:58:17.546852Z","closed_at":"2026-01-30T16:58:17.546794Z","close_reason":"Completed: OllamaClient with async health_check (starts_with model matching), embed_batch, error mapping to LoreError variants, check_ollama_health helper, 4 tests pass","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-335","depends_on_id":"bd-ljf","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-343o","title":"Fetch and store GitLab linked issues (Related to)","description":"## Background\n\nGitLab's \"Linked items\" provides bidirectional issue linking distinct from \"closes\" and \"mentioned\" references. This data is only available via the issue links API (GET /projects/:id/issues/:iid/links). The goal is to fetch these links during sync and store them as entity_references so they appear in `lore show issue` and are queryable.\n\n**Why:** Currently `lore show issue` displays closing MRs (via `get_closing_mrs()` in show.rs:~line 1544) but has NO related issues section. This bead adds that capability.\n\n## Codebase Context\n\n- **entity_references table** (migration 011): reference_type CHECK: 'closes' | 'mentioned' | 'related'; source_method CHECK: 'api' | 'note_parse' | 'description_parse'\n- **pending_dependent_fetches** (migration 011): job_type CHECK: 'resource_events' | 'mr_closes_issues' | 'mr_diffs'. No later migrations modified this table.\n- **CRITICAL:** Adding 'issue_links' to job_type CHECK requires recreating pending_dependent_fetches table (SQLite can't ALTER CHECK constraints). Migration **027** must copy data, drop, recreate with expanded CHECK, and reinsert.\n- **Orchestrator** (src/ingestion/orchestrator.rs, 1745 lines): Three drain functions exist — drain_resource_events() (line 932), drain_mr_closes_issues() (line 1254), drain_mr_diffs() (line 1514). Follow the same claim/complete/fail pattern from dependent_queue.rs.\n- **dependent_queue.rs**: enqueue_job(), claim_jobs(), complete_job(), fail_job() with exponential backoff\n- **show.rs** (1544 lines): Has get_closing_mrs() for closing MR display. NO related_issues section exists yet.\n- **GitLab API**: GET /projects/:id/issues/:iid/links returns link_type: \"relates_to\", \"blocks\", \"is_blocked_by\"\n- **Migration count**: 26 migrations exist (001-026). Next migration = **027**.\n\n## Approach\n\n### Phase 1: API Client (src/gitlab/client.rs)\n```rust\npub async fn fetch_issue_links(\n    &self,\n    project_id: i64,\n    issue_iid: i64,\n) -> Result<Vec<GitLabIssueLink>> {\n    // GET /projects/:id/issues/:iid/links\n    // Use fetch_all_pages() + coalesce_not_found()\n}\n```\n\n### Phase 2: Types (src/gitlab/types.rs)\n```rust\n#[derive(Debug, Deserialize)]\npub struct GitLabIssueLink {\n    pub id: i64,\n    pub iid: i64,\n    pub title: String,\n    pub state: String,\n    pub web_url: String,\n    pub link_type: String,      // \"relates_to\", \"blocks\", \"is_blocked_by\"\n    pub link_created_at: Option<String>,\n}\n```\n\n### Phase 3: Migration 027 (migrations/027_issue_links_job_type.sql)\nRecreate pending_dependent_fetches with expanded CHECK:\n```sql\nCREATE TABLE pending_dependent_fetches_new (\n    id INTEGER PRIMARY KEY,\n    project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,\n    entity_type TEXT NOT NULL CHECK (entity_type IN ('issue', 'merge_request')),\n    entity_iid INTEGER NOT NULL,\n    entity_local_id INTEGER NOT NULL,\n    job_type TEXT NOT NULL CHECK (job_type IN (\n        'resource_events', 'mr_closes_issues', 'mr_diffs', 'issue_links'\n    )),\n    payload_json TEXT,\n    enqueued_at INTEGER NOT NULL,\n    attempts INTEGER NOT NULL DEFAULT 0,\n    last_error TEXT,\n    next_retry_at INTEGER,\n    locked_at INTEGER,\n    UNIQUE(project_id, entity_type, entity_iid, job_type)\n);\nINSERT INTO pending_dependent_fetches_new SELECT * FROM pending_dependent_fetches;\nDROP TABLE pending_dependent_fetches;\nALTER TABLE pending_dependent_fetches_new RENAME TO pending_dependent_fetches;\n-- Recreate indexes from migration 011 (idx_pdf_job_type, idx_pdf_next_retry)\n```\n\nRegister in MIGRATIONS array in src/core/db.rs (entry 27).\n\n### Phase 4: Ingestion (src/ingestion/issue_links.rs NEW)\n```rust\npub async fn fetch_and_store_issue_links(\n    conn: &Connection,\n    client: &GitLabClient,\n    project_id: i64,\n    issue_local_id: i64,\n    issue_iid: i64,\n) -> Result<usize> {\n    // 1. Fetch links from API\n    // 2. Resolve target issue to local DB id (SELECT id FROM issues WHERE project_id=? AND iid=?)\n    // 3. Insert into entity_references: reference_type='related', source_method='api'\n    // 4. Create bidirectional refs: A->B and B->A\n    // 5. Skip self-links\n    // 6. Cross-project: store with target_entity_id=NULL (unresolved)\n}\n```\n\n### Phase 5: Queue Integration (src/ingestion/orchestrator.rs)\n- Enqueue 'issue_links' job after issue ingestion (near the existing resource_events enqueue)\n- Add drain_issue_links() following drain_mr_closes_issues() pattern (lines 1254-1512)\n- Config gate: add `sync.fetchIssueLinks` (default true) to config, like existing `sync.fetchResourceEvents`\n\n### Phase 6: Display (src/cli/commands/show.rs)\nIn `lore show issue 123`, add \"Related Issues\" section after closing MRs.\nPattern: query entity_references WHERE source_entity_type='issue' AND source_entity_id=<local_id> AND reference_type='related'.\n\n## Acceptance Criteria\n\n- [ ] API client fetches issue links with pagination (fetch_all_pages + coalesce_not_found)\n- [ ] Stored as entity_reference: reference_type='related', source_method='api'\n- [ ] Bidirectional: A links B creates both A->B and B->A references\n- [ ] link_type captured (relates_to, blocks, is_blocked_by) — stored as 'related' for now\n- [ ] Cross-project links stored as unresolved (target_entity_id NULL)\n- [ ] Self-links skipped\n- [ ] Migration **027** recreates pending_dependent_fetches with 'issue_links' in CHECK\n- [ ] Migration registered in MIGRATIONS array in src/core/db.rs\n- [ ] `lore show issue 123` shows related issues section\n- [ ] `lore --robot show issue 123` includes related_issues in JSON\n- [ ] Config gate: sync.fetchIssueLinks (default true, camelCase serde rename)\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n- [ ] `cargo fmt --check` passes\n\n## Files\n\n- MODIFY: src/gitlab/client.rs (add fetch_issue_links)\n- MODIFY: src/gitlab/types.rs (add GitLabIssueLink)\n- CREATE: src/ingestion/issue_links.rs\n- MODIFY: src/ingestion/mod.rs (add pub mod issue_links)\n- MODIFY: src/ingestion/orchestrator.rs (enqueue + drain_issue_links)\n- CREATE: migrations/027_issue_links_job_type.sql\n- MODIFY: src/core/db.rs (add migration 027 to MIGRATIONS array)\n- MODIFY: src/core/config.rs (add sync.fetchIssueLinks)\n- MODIFY: src/cli/commands/show.rs (display related issues)\n\n## TDD Anchor\n\nRED:\n- test_issue_link_deserialization (types.rs: deserialize GitLabIssueLink from JSON)\n- test_store_issue_links_creates_bidirectional_references (in-memory DB, insert 2 issues, store link, verify 2 rows in entity_references)\n- test_self_link_skipped (same issue_iid both sides, verify 0 rows)\n- test_cross_project_link_unresolved (target not in DB, verify target_entity_id IS NULL)\n\nGREEN: Implement API client, ingestion, migration, display.\n\nVERIFY: cargo test --lib -- issue_links\n\n## Edge Cases\n\n- Cross-project links: target not in local DB -> unresolved reference (target_entity_id NULL)\n- Self-links: skip entirely\n- UNIQUE constraint on entity_references prevents duplicate refs on re-sync\n- \"blocks\"/\"is_blocked_by\" semantics not modeled in entity_references yet — store as 'related'\n- Table recreation migration: safe because pending_dependent_fetches is transient queue data that gets re-enqueued on next sync\n- Recreated table must restore indexes: idx_pdf_job_type, idx_pdf_next_retry (check migration 011 for exact definitions)\n\n## Dependency Context\n\n- **entity_references** (migration 011): provides the target table. reference_type='related' already in CHECK.\n- **dependent_queue.rs**: provides enqueue_job/claim_jobs/complete_job/fail_job lifecycle used by drain_issue_links()\n- **orchestrator drain pattern**: drain_mr_closes_issues() (line 1254) is the closest template — fetch API data, insert entity_references, complete job","status":"open","priority":2,"issue_type":"feature","created_at":"2026-02-05T15:14:25.202900Z","created_by":"tayloreernisse","updated_at":"2026-02-17T16:50:44.934373Z","compaction_level":0,"original_size":0,"labels":["ISSUE"]}
{"id":"bd-34ek","title":"OBSERV: Implement MetricsLayer custom tracing subscriber layer","description":"## Background\nMetricsLayer is a custom tracing subscriber layer that records span timing and structured fields, then materializes them into Vec<StageTiming>. This avoids threading a mutable collector through every function signature -- spans are the single source of truth.\n\n## Approach\nAdd to src/core/metrics.rs (same file as StageTiming):\n\n```rust\nuse std::collections::HashMap;\nuse std::sync::{Arc, Mutex};\nuse std::time::Instant;\nuse tracing::span::{Attributes, Id, Record};\nuse tracing::Subscriber;\nuse tracing_subscriber::layer::{Context, Layer};\nuse tracing_subscriber::registry::LookupSpan;\n\n#[derive(Debug)]\nstruct SpanData {\n    name: String,\n    parent_id: Option<Id>,\n    start: Instant,\n    fields: HashMap<String, serde_json::Value>,\n}\n\n#[derive(Debug, Clone)]\npub struct MetricsLayer {\n    spans: Arc<Mutex<HashMap<u64, SpanData>>>,\n    completed: Arc<Mutex<Vec<(u64, StageTiming)>>>,\n}\n\nimpl MetricsLayer {\n    pub fn new() -> Self {\n        Self {\n            spans: Arc::new(Mutex::new(HashMap::new())),\n            completed: Arc::new(Mutex::new(Vec::new())),\n        }\n    }\n\n    /// Extract timing tree for a completed run.\n    /// Call this after the root span closes.\n    pub fn extract_timings(&self) -> Vec<StageTiming> {\n        let completed = self.completed.lock().unwrap();\n        // Build tree: find root entries (no parent), attach children\n        // ... tree construction logic\n    }\n}\n\nimpl<S> Layer<S> for MetricsLayer\nwhere\n    S: Subscriber + for<'a> LookupSpan<'a>,\n{\n    fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) {\n        let parent_id = ctx.span(id).and_then(|s| s.parent().map(|p| p.id()));\n        let mut fields = HashMap::new();\n        // Visit attrs to capture initial field values\n        let mut visitor = FieldVisitor(&mut fields);\n        attrs.record(&mut visitor);\n\n        self.spans.lock().unwrap().insert(id.into_u64(), SpanData {\n            name: attrs.metadata().name().to_string(),\n            parent_id,\n            start: Instant::now(),\n            fields,\n        });\n    }\n\n    fn on_record(&self, id: &Id, values: &Record<'_>, _ctx: Context<'_, S>) {\n        // Capture recorded fields (items_processed, items_skipped, errors)\n        if let Some(data) = self.spans.lock().unwrap().get_mut(&id.into_u64()) {\n            let mut visitor = FieldVisitor(&mut data.fields);\n            values.record(&mut visitor);\n        }\n    }\n\n    fn on_close(&self, id: Id, _ctx: Context<'_, S>) {\n        if let Some(data) = self.spans.lock().unwrap().remove(&id.into_u64()) {\n            let elapsed = data.start.elapsed();\n            let timing = StageTiming {\n                name: data.name,\n                project: data.fields.get(\"project\").and_then(|v| v.as_str()).map(String::from),\n                elapsed_ms: elapsed.as_millis() as u64,\n                items_processed: data.fields.get(\"items_processed\").and_then(|v| v.as_u64()).unwrap_or(0) as usize,\n                items_skipped: data.fields.get(\"items_skipped\").and_then(|v| v.as_u64()).unwrap_or(0) as usize,\n                errors: data.fields.get(\"errors\").and_then(|v| v.as_u64()).unwrap_or(0) as usize,\n                sub_stages: vec![], // Will be populated during extract_timings tree construction\n            };\n            self.completed.lock().unwrap().push((id.into_u64(), timing));\n        }\n    }\n}\n```\n\nNeed a FieldVisitor struct implementing tracing::field::Visit to capture field values.\n\nRegister in subscriber stack (src/main.rs), alongside stderr and file layers:\n```rust\nlet metrics_layer = MetricsLayer::new();\nlet metrics_handle = metrics_layer.clone(); // Clone Arc for later extraction\n\nregistry()\n    .with(stderr_layer.with_filter(stderr_filter))\n    .with(file_layer.with_filter(file_filter))\n    .with(metrics_layer) // No filter -- captures all spans\n    .init();\n```\n\nPass metrics_handle to command handlers so they can call extract_timings() after the pipeline completes.\n\n## Acceptance Criteria\n- [ ] MetricsLayer captures span enter/close timing\n- [ ] on_record captures items_processed, items_skipped, errors fields\n- [ ] extract_timings() returns correctly nested Vec<StageTiming> tree\n- [ ] Parallel spans (multiple projects) both appear as sub_stages of parent\n- [ ] Thread-safe: Arc<Mutex<>> allows concurrent span operations\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/core/metrics.rs (add MetricsLayer, FieldVisitor, tree construction)\n- src/main.rs (register MetricsLayer in subscriber stack)\n\n## TDD Loop\nRED:\n  - test_metrics_layer_single_span: enter/exit one span, extract, assert one StageTiming\n  - test_metrics_layer_nested_spans: parent + child, assert child in parent.sub_stages\n  - test_metrics_layer_parallel_spans: two sibling spans, assert both in parent.sub_stages\n  - test_metrics_layer_field_recording: record items_processed=42, assert captured\nGREEN: Implement MetricsLayer with on_new_span, on_record, on_close, extract_timings\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- Span ID reuse: tracing may reuse span IDs after close. Using remove on close prevents stale data.\n- Lock contention: Mutex per operation. For high-span-count scenarios, consider parking_lot::Mutex. But lore's span count is low (<100 per run), so std::sync::Mutex is fine.\n- extract_timings tree construction: iterate completed Vec, build parent->children map, then recursively construct StageTiming tree. Root entries have parent_id matching the root span or None.\n- MetricsLayer has no filter: it sees ALL spans. To avoid noise from dependency spans, check if span name starts with known stage names, or rely on the \"stage\" field being present.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T15:54:31.960669Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:25:25.523811Z","closed_at":"2026-02-04T17:25:25.523730Z","close_reason":"Implemented MetricsLayer custom tracing subscriber layer with span timing capture, rate-limit/retry event detection, tree extraction, and 12 unit tests","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-34ek","depends_on_id":"bd-1o4h","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-34ek","depends_on_id":"bd-24j1","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-34ek","depends_on_id":"bd-3er","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-34o","title":"Implement MR transformer","description":"## Background\nTransforms GitLab MR API responses into normalized schema for database storage. Handles deprecated field fallbacks and extracts metadata (labels, assignees, reviewers).\n\n## Approach\nCreate new transformer module following existing issue transformer pattern:\n- `NormalizedMergeRequest` - Database-ready struct\n- `MergeRequestWithMetadata` - MR + extracted labels/assignees/reviewers\n- `transform_merge_request()` - Main transformation function\n- `extract_labels()` - Label extraction helper\n\n## Files\n- `src/gitlab/transformers/merge_request.rs` - New transformer module\n- `src/gitlab/transformers/mod.rs` - Export new module\n- `tests/mr_transformer_tests.rs` - Unit tests\n\n## Acceptance Criteria\n- [ ] `NormalizedMergeRequest` struct exists with all DB columns\n- [ ] `MergeRequestWithMetadata` contains MR + label_names + assignee_usernames + reviewer_usernames\n- [ ] `transform_merge_request()` returns `Result<MergeRequestWithMetadata, String>`\n- [ ] `draft` computed as `gitlab_mr.draft || gitlab_mr.work_in_progress`\n- [ ] `detailed_merge_status` prefers `detailed_merge_status` over `merge_status_legacy`\n- [ ] `merge_user_username` prefers `merge_user` over `merged_by`\n- [ ] `head_sha` extracted from `sha` field\n- [ ] `references_short` and `references_full` extracted from `references` Option\n- [ ] Timestamps parsed with `iso_to_ms()`, errors returned (not zeroed)\n- [ ] `last_seen_at` set to `now_ms()`\n- [ ] `cargo test mr_transformer` passes\n\n## TDD Loop\nRED: `cargo test mr_transformer` -> module not found\nGREEN: Add transformer with all fields\nVERIFY: `cargo test mr_transformer`\n\n## Struct Definitions\n```rust\n#[derive(Debug, Clone)]\npub struct NormalizedMergeRequest {\n    pub gitlab_id: i64,\n    pub project_id: i64,\n    pub iid: i64,\n    pub title: String,\n    pub description: Option<String>,\n    pub state: String,\n    pub draft: bool,\n    pub author_username: String,\n    pub source_branch: String,\n    pub target_branch: String,\n    pub head_sha: Option<String>,\n    pub references_short: Option<String>,\n    pub references_full: Option<String>,\n    pub detailed_merge_status: Option<String>,\n    pub merge_user_username: Option<String>,\n    pub created_at: i64,\n    pub updated_at: i64,\n    pub merged_at: Option<i64>,\n    pub closed_at: Option<i64>,\n    pub last_seen_at: i64,\n    pub web_url: String,\n}\n\n#[derive(Debug, Clone)]\npub struct MergeRequestWithMetadata {\n    pub merge_request: NormalizedMergeRequest,\n    pub label_names: Vec<String>,\n    pub assignee_usernames: Vec<String>,\n    pub reviewer_usernames: Vec<String>,\n}\n```\n\n## Function Signature\n```rust\npub fn transform_merge_request(\n    gitlab_mr: &GitLabMergeRequest,\n    local_project_id: i64,\n) -> Result<MergeRequestWithMetadata, String>\n```\n\n## Key Logic\n```rust\n// Draft: prefer draft, fallback to work_in_progress\nlet is_draft = gitlab_mr.draft || gitlab_mr.work_in_progress;\n\n// Merge status: prefer detailed_merge_status\nlet detailed_merge_status = gitlab_mr.detailed_merge_status\n    .clone()\n    .or_else(|| gitlab_mr.merge_status_legacy.clone());\n\n// Merge user: prefer merge_user\nlet merge_user_username = gitlab_mr.merge_user\n    .as_ref()\n    .map(|u| u.username.clone())\n    .or_else(|| gitlab_mr.merged_by.as_ref().map(|u| u.username.clone()));\n\n// References extraction\nlet (references_short, references_full) = gitlab_mr.references\n    .as_ref()\n    .map(|r| (Some(r.short.clone()), Some(r.full.clone())))\n    .unwrap_or((None, None));\n\n// Head SHA\nlet head_sha = gitlab_mr.sha.clone();\n```\n\n## Edge Cases\n- Invalid timestamps should return `Err`, not zero values\n- Empty labels/assignees/reviewers should return empty Vecs, not None\n- `state` must pass through as-is (including \"locked\")","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-26T22:06:40.849049Z","created_by":"tayloreernisse","updated_at":"2026-01-27T00:11:48.501301Z","closed_at":"2026-01-27T00:11:48.501241Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-34o","depends_on_id":"bd-3ir","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-34o","depends_on_id":"bd-5ta","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-34rr","title":"WHO: Migration 017 — composite indexes for query paths","description":"## Background\n\nWith 280K notes, the path/timestamp queries for lore who will degrade without composite indexes. Existing indexes cover note_type and position_new_path separately (migration 006) but not as composites aligned to the who query patterns. This is a non-breaking, additive-only migration.\n\n## Approach\n\nAdd as entry 17 (index 16) in the MIGRATIONS array in src/core/db.rs. LATEST_SCHEMA_VERSION auto-updates via MIGRATIONS.len() as i32.\n\n### Exact SQL for the migration entry:\n\n```sql\n-- Migration 017: Composite indexes for who query paths\n\n-- Expert/Overlap: DiffNote path prefix + timestamp filter.\n-- Leading with position_new_path (not note_type) because the partial index\n-- predicate already handles the constant filter.\nCREATE INDEX IF NOT EXISTS idx_notes_diffnote_path_created\n    ON notes(position_new_path, created_at, project_id)\n    WHERE note_type = 'DiffNote' AND is_system = 0;\n\n-- Active/Workload: discussion participation lookups.\nCREATE INDEX IF NOT EXISTS idx_notes_discussion_author\n    ON notes(discussion_id, author_username)\n    WHERE is_system = 0;\n\n-- Active (project-scoped): unresolved discussions by recency.\nCREATE INDEX IF NOT EXISTS idx_discussions_unresolved_recent\n    ON discussions(project_id, last_note_at)\n    WHERE resolvable = 1 AND resolved = 0;\n\n-- Active (global): unresolved discussions by recency (no project scope).\n-- Without this, (project_id, last_note_at) can't satisfy ORDER BY last_note_at DESC\n-- efficiently when project_id is unconstrained.\nCREATE INDEX IF NOT EXISTS idx_discussions_unresolved_recent_global\n    ON discussions(last_note_at)\n    WHERE resolvable = 1 AND resolved = 0;\n\n-- Workload: issue assignees by username.\nCREATE INDEX IF NOT EXISTS idx_issue_assignees_username\n    ON issue_assignees(username, issue_id);\n```\n\n### Not added (already adequate):\n- merge_requests(author_username) — idx_mrs_author (migration 006)\n- mr_reviewers(username) — idx_mr_reviewers_username (migration 006)\n- notes(discussion_id) — idx_notes_discussion (migration 002)\n\n## Files\n\n- `src/core/db.rs` — append to MIGRATIONS array as entry index 16\n\n## TDD Loop\n\nRED: `cargo test -- test_migration` (existing migration tests should still pass)\nGREEN: Add the migration SQL string to the array\nVERIFY: `cargo test && cargo check --all-targets`\n\n## Acceptance Criteria\n\n- [ ] MIGRATIONS array has 17 entries (index 0-16)\n- [ ] LATEST_SCHEMA_VERSION is 17\n- [ ] cargo test passes (in-memory DB runs all migrations including 017)\n- [ ] No existing index names conflict\n\n## Edge Cases\n\n- The SQL uses CREATE INDEX IF NOT EXISTS — safe for idempotent reruns\n- Partial indexes (WHERE clause) keep index size small: ~33K of 280K notes for DiffNote index","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-08T02:39:49.397860Z","created_by":"tayloreernisse","updated_at":"2026-02-08T04:10:29.593561Z","closed_at":"2026-02-08T04:10:29.593519Z","close_reason":"Implemented by agent team: migration 017, CLI skeleton, all 5 query modes, human+robot output, 20 tests. All quality gates pass.","compaction_level":0,"original_size":0}
{"id":"bd-35g5","title":"Implement Dashboard state + action + view","description":"## Background\nThe Dashboard is the home screen — first thing users see. It shows entity counts, per-project sync status, recent activity, and a last-sync summary. Data comes from aggregation queries against the local SQLite database.\n\n## Approach\nState (state/dashboard.rs):\n- DashboardState: counts (EntityCounts), projects (Vec<ProjectSyncInfo>), recent (Vec<RecentActivityItem>), last_sync (LastSyncInfo)\n- EntityCounts: issues_open, issues_total, mrs_open, mrs_total, discussions, notes_total, notes_system_pct, documents, embeddings\n- ProjectSyncInfo: path (String), minutes_since_sync (u64)\n- RecentActivityItem: entity_type, iid, title, state, minutes_ago\n- update(data: DashboardData) method\n\nAction (action.rs):\n- fetch_dashboard(conn: &Connection, clock: &dyn Clock) -> Result<DashboardData, LoreError>: runs aggregation queries for counts, recent activity, project sync status. Uses clock.now() for relative time calculations.\n\nView (view/dashboard.rs):\n- render_dashboard(frame, state: &DashboardState, area: Rect, theme: &Theme): responsive layout with breakpoints\n  - Wide (>=120 cols): 3-column: [Stats | Projects | Recent]\n  - Medium (80-119): 2-column: [Stats+Projects | Recent]\n  - Narrow (<80): single column stacked\n- render_stat_panel(): entity counts with colored numbers\n- render_project_list(): project names with sync staleness indicators\n- render_recent_activity(): scrollable list of recent changes\n- render_sync_summary(): last sync stats (if available)\n\n## Acceptance Criteria\n- [ ] DashboardState stores counts, projects, recent activity, last sync info\n- [ ] fetch_dashboard returns correct counts from DB\n- [ ] Dashboard renders with responsive breakpoints (3/2/1 column layouts)\n- [ ] Entity counts show open/total for issues and MRs\n- [ ] Project list shows sync staleness with color coding (green <1h, yellow <6h, red >6h)\n- [ ] Recent activity list is scrollable with j/k\n- [ ] Relative timestamps use injected Clock (not wall-clock)\n\n## Files\n- MODIFY: crates/lore-tui/src/state/dashboard.rs (expand from stub)\n- MODIFY: crates/lore-tui/src/action.rs (add fetch_dashboard)\n- CREATE: crates/lore-tui/src/view/dashboard.rs\n\n## TDD Anchor\nRED: Write test_fetch_dashboard_counts in action.rs that creates in-memory DB with 5 issues (3 open, 2 closed), calls fetch_dashboard, asserts issues_open=3, issues_total=5.\nGREEN: Implement fetch_dashboard with COUNT queries.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_fetch_dashboard\n\n## Edge Cases\n- Empty database (first launch before sync): all counts should be 0, no crash\n- Very long project paths: truncate to fit column width\n- notes_system_pct: compute as (system_notes * 100 / total_notes), handle division by zero\n- Clock injection ensures snapshot tests are deterministic (no \"3 minutes ago\" changing between runs)\n\n## Dependency Context\nUses AppState, DashboardState, LoadState from \"Implement AppState composition\" task.\nUses DbManager from \"Implement DbManager\" task.\nUses Clock from \"Implement Clock trait\" task.\nUses theme from \"Implement theme configuration\" task.\nUses render_screen routing from \"Implement common widgets\" task.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T16:57:44.419736Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:23.172011Z","deleted_at":"2026-03-11T18:34:23.172005Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-35o","title":"Create golden query test suite","description":"## Background\nGolden query tests verify end-to-end search quality with known-good expected results. They use a seeded SQLite DB with deterministic fixture data and fixed embedding vectors (no Ollama dependency). Each test query must return at least one expected URL in the top 10 results. These tests catch search regressions (ranking changes, filter bugs, missing results).\n\n## Approach\nCreate test infrastructure:\n\n**1. tests/fixtures/golden_queries.json:**\n```json\n[\n  {\n    \"query\": \"authentication login\",\n    \"mode\": \"lexical\",\n    \"filters\": {},\n    \"expected_urls\": [\"https://gitlab.example.com/group/project/-/issues/234\"],\n    \"min_results\": 1,\n    \"max_rank\": 10\n  },\n  {\n    \"query\": \"jwt token refresh\",\n    \"mode\": \"hybrid\",\n    \"filters\": {\"type\": \"merge_request\"},\n    \"expected_urls\": [\"https://gitlab.example.com/group/project/-/merge_requests/456\"],\n    \"min_results\": 1,\n    \"max_rank\": 10\n  }\n]\n```\n\n**2. Test harness (tests/golden_query_tests.rs):**\n- Load golden_queries.json\n- Create in-memory DB, apply all migrations\n- Seed with deterministic fixture documents (issues, MRs, discussions)\n- For hybrid/semantic queries: seed with fixed embedding vectors (768-dim, manually constructed for known similarity)\n- For each query: run search, verify expected URL in top N results\n\n**Fixture data design:**\n- 10-20 documents covering different source types\n- Known content that matches expected queries\n- Fixed embeddings: construct vectors where similar documents have small cosine distance\n- No randomness — fully deterministic\n\n## Acceptance Criteria\n- [ ] Golden queries file exists with at least 5 test queries\n- [ ] Test harness loads queries and validates each\n- [ ] All golden queries pass: expected URL in top 10\n- [ ] No external dependencies (no Ollama, no GitLab)\n- [ ] Deterministic fixture data (fixed embeddings, fixed content)\n- [ ] `cargo test --test golden_query_tests` passes in CI\n\n## Files\n- `tests/fixtures/golden_queries.json` — new file\n- `tests/golden_query_tests.rs` — new file (or tests/golden_queries.rs)\n\n## TDD Loop\nRED: Create golden_queries.json with expected results, harness fails (no fixture data)\nGREEN: Seed fixture data that satisfies expected results\nVERIFY: `cargo test --test golden_query_tests`\n\n## Edge Cases\n- Query matches multiple expected URLs: all must be present\n- Lexical queries: FTS ranking determines position, not vector\n- Hybrid queries: RRF combines both signals — fixed vectors must be designed to produce expected ranking\n- Empty result for a golden query: test failure with clear message showing actual results","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-30T15:27:21.788493Z","created_by":"tayloreernisse","updated_at":"2026-01-30T18:12:47.085563Z","closed_at":"2026-01-30T18:12:47.085363Z","close_reason":"Golden query test suite: 7 golden queries in fixture, 8 seeded documents, 2 test functions (all_pass + fixture_valid), deterministic in-memory DB, no external deps. 312 total tests pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-35o","depends_on_id":"bd-2no","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-35r","title":"[CP1] Discussion and note transformers","description":"Transform GitLab discussion/note payloads to normalized database schema.\n\nFunctions to implement:\n- transformDiscussion(gitlabDiscussion, localProjectId, localIssueId) → NormalizedDiscussion\n- transformNotes(gitlabDiscussion, localProjectId) → NormalizedNote[]\n\nTransformation rules:\n- Compute first_note_at/last_note_at from notes array\n- Compute resolvable/resolved status from notes\n- Set is_system from note.system\n- Preserve note order via position (array index)\n- Convert ISO timestamps to ms epoch\n\nFiles: src/gitlab/transformers/discussion.ts\nTests: tests/unit/discussion-transformer.test.ts\nDone when: Unit tests pass for discussion/note transformation with system note flagging","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:19:16.861421Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.154646Z","closed_at":"2026-01-25T15:21:35.154646Z","deleted_at":"2026-01-25T15:21:35.154643Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-36m","title":"Final validation and test coverage","description":"## Background\nFinal validation gate ensuring all CP2 features work correctly. Verifies tests, lint, and manual smoke tests pass.\n\n## Approach\nRun comprehensive validation:\n1. Automated tests (unit + integration)\n2. Clippy and formatting\n3. Critical test case verification\n4. Gate A/B/C/D/E checklist\n5. Manual smoke tests\n\n## Files\nNone - validation only\n\n## Acceptance Criteria\n- [ ] `cargo test` passes (all tests green)\n- [ ] `cargo test --release` passes\n- [ ] `cargo clippy -- -D warnings` passes (zero warnings)\n- [ ] `cargo fmt --check` passes\n- [ ] Critical tests pass (see list below)\n- [ ] Gate A/B/C/D/E verification complete\n- [ ] Manual smoke tests pass\n\n## Validation Commands\n```bash\n# 1. Build and test\ncargo build --release\ncargo test --release\n\n# 2. Lint\ncargo clippy -- -D warnings\ncargo fmt --check\n\n# 3. Run specific critical tests\ncargo test does_not_advance_discussion_watermark_on_partial_failure\ncargo test prefers_detailed_merge_status_when_both_fields_present\ncargo test prefers_merge_user_when_both_fields_present\ncargo test prefers_draft_when_both_draft_and_work_in_progress_present\ncargo test atomic_note_replacement_preserves_data_on_parse_failure\ncargo test full_sync_resets_discussion_watermarks\n```\n\n## Critical Test Cases\n| Test | What It Verifies |\n|------|------------------|\n| `does_not_advance_discussion_watermark_on_partial_failure` | Pagination failure doesn't lose data |\n| `prefers_detailed_merge_status_when_both_fields_present` | Non-deprecated field wins |\n| `prefers_merge_user_when_both_fields_present` | Non-deprecated field wins |\n| `prefers_draft_when_both_draft_and_work_in_progress_present` | OR semantics for draft |\n| `atomic_note_replacement_preserves_data_on_parse_failure` | Parse before delete |\n| `full_sync_resets_discussion_watermarks` | --full truly refreshes |\n\n## Gate Checklist\n\n### Gate A: MRs Only\n- [ ] `gi ingest --type=merge_requests` fetches all MRs\n- [ ] MR state supports: opened, merged, closed, locked\n- [ ] draft field captured with work_in_progress fallback\n- [ ] detailed_merge_status used with merge_status fallback\n- [ ] head_sha and references captured\n- [ ] Cursor-based sync is resumable\n\n### Gate B: Labels + Assignees + Reviewers\n- [ ] Labels linked via mr_labels junction\n- [ ] Stale labels removed on resync\n- [ ] Assignees linked via mr_assignees\n- [ ] Reviewers linked via mr_reviewers\n\n### Gate C: Dependent Discussion Sync\n- [ ] Discussions fetched for MRs with updated_at advancement\n- [ ] DiffNote position metadata captured\n- [ ] DiffNote SHA triplet captured\n- [ ] Upsert + sweep pattern for notes\n- [ ] Watermark NOT advanced on partial failure\n- [ ] Unchanged MRs skip discussion refetch\n\n### Gate D: Resumability Proof\n- [ ] Kill mid-run, rerun -> bounded redo\n- [ ] `--full` resets cursor AND discussion watermarks\n- [ ] Single-flight lock prevents concurrent runs\n\n### Gate E: CLI Complete\n- [ ] `gi list mrs` with all filters including --draft/--no-draft\n- [ ] `gi show mr <iid>` with discussions and DiffNote context\n- [ ] `gi count mrs` with state breakdown\n- [ ] `gi sync-status` shows MR cursors\n\n## Manual Smoke Tests\n| Command | Expected |\n|---------|----------|\n| `gi ingest --type=merge_requests` | Completes, shows counts |\n| `gi list mrs --limit=10` | Shows 10 MRs with correct columns |\n| `gi list mrs --state=merged` | Only merged MRs |\n| `gi list mrs --draft` | Only draft MRs with [DRAFT] prefix |\n| `gi show mr <iid>` | Full detail with discussions |\n| `gi count mrs` | Count with state breakdown |\n| Re-run ingest | \"0 new MRs\", skipped discussion count |\n| `gi ingest --type=merge_requests --full` | Full resync |\n\n## Data Integrity Checks\n```sql\n-- MR count matches GitLab\nSELECT COUNT(*) FROM merge_requests;\n\n-- Every MR has raw payload\nSELECT COUNT(*) FROM merge_requests WHERE raw_payload_id IS NULL;\n-- Should be 0\n\n-- Labels linked correctly\nSELECT m.iid, COUNT(ml.label_id) \nFROM merge_requests m\nLEFT JOIN mr_labels ml ON ml.merge_request_id = m.id\nGROUP BY m.id;\n\n-- DiffNotes have position metadata\nSELECT COUNT(*) FROM notes WHERE position_new_path IS NOT NULL;\n```","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-26T22:06:43.697983Z","created_by":"tayloreernisse","updated_at":"2026-01-27T00:45:17.794393Z","closed_at":"2026-01-27T00:45:17.794325Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-36m","depends_on_id":"bd-3js","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-36m","depends_on_id":"bd-mk3","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-36p","title":"Implement document types and extractor module","description":"## Background\nThe document types module is the foundational data layer for all document operations. It defines SourceType (the enum used everywhere to identify entity types), DocumentData (the struct passed between extractors, regenerator, and storage), and hash functions (for change detection that skips unchanged documents). This bead has 7 downstream dependents — it's the most-depended-on implementation bead.\n\n## Approach\nCreate `src/documents/` module with `mod.rs` and `extractor.rs`.\n\n**src/documents/mod.rs:**\n```rust\nmod extractor;\nmod regenerator;   // placeholder: pub mod added by later bead\nmod truncation;    // placeholder: pub mod added by later bead\n\npub use extractor::{\n    extract_discussion_document, extract_issue_document, extract_mr_document,\n    DocumentData, SourceType, compute_content_hash, compute_list_hash,\n};\npub use regenerator::regenerate_dirty_documents;\npub use truncation::{truncate_content, TruncationResult};\n```\n\n**src/documents/extractor.rs — types only (this bead):**\n- `SourceType` enum with `Issue`, `MergeRequest`, `Discussion` variants\n- `SourceType::as_str()`, `SourceType::parse()` (accepts aliases), `Display` impl\n- `DocumentData` struct with all fields per PRD Section 2.2\n- `compute_content_hash(content: &str) -> String` using SHA-256\n- `compute_list_hash(items: &[String]) -> String` using sorted join + SHA-256\n\n**Dependencies:** Add `sha2 = \"0.10\"` to Cargo.toml if not already present.\n\nNote: The `extract_*_document()` functions are stub signatures in this bead. Their implementations are in separate beads (bd-247, bd-1yz, bd-2fp).\n\n**src/lib.rs:** Add `pub mod documents;`\n\n## Acceptance Criteria\n- [ ] `SourceType::parse(\"issue\")` returns `Some(Issue)`\n- [ ] `SourceType::parse(\"mr\")` returns `Some(MergeRequest)` (alias)\n- [ ] `SourceType::parse(\"mrs\")` returns `Some(MergeRequest)` (alias)\n- [ ] `SourceType::parse(\"merge_request\")` returns `Some(MergeRequest)`\n- [ ] `SourceType::parse(\"discussion\")` returns `Some(Discussion)`\n- [ ] `SourceType::parse(\"invalid\")` returns `None`\n- [ ] `SourceType::as_str()` returns \"issue\", \"merge_request\", \"discussion\"\n- [ ] `compute_content_hash(\"hello\")` returns deterministic SHA-256 hex string\n- [ ] `compute_list_hash([\"b\", \"a\"])` == `compute_list_hash([\"a\", \"b\"])` (sorted)\n- [ ] `DocumentData` struct compiles with all fields per PRD\n- [ ] `cargo build` succeeds\n- [ ] `cargo test documents` passes\n\n## Files\n- `src/documents/mod.rs` — new file (module root with pub use)\n- `src/documents/extractor.rs` — new file (types + hash functions)\n- `src/documents/regenerator.rs` — new file (placeholder, empty module or todo!())\n- `src/documents/truncation.rs` — new file (placeholder, empty module or todo!())\n- `src/lib.rs` — add `pub mod documents;`\n- `Cargo.toml` — add `sha2 = \"0.10\"` if not present\n\n## TDD Loop\nRED: Tests in `extractor.rs` `#[cfg(test)] mod tests`:\n- `test_source_type_parse_aliases` — all parse variants\n- `test_source_type_as_str` — roundtrip as_str\n- `test_content_hash_deterministic` — same input = same hash\n- `test_list_hash_order_independent` — sorted before hashing\n- `test_list_hash_empty` — empty vec produces consistent hash\nGREEN: Implement types and hash functions\nVERIFY: `cargo test documents`\n\n## Edge Cases\n- Empty string content hash: must produce valid SHA-256 (not panic)\n- Empty labels list: compute_list_hash returns hash of empty string\n- SourceType Display shows snake_case (same as as_str)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:25:45.456566Z","created_by":"tayloreernisse","updated_at":"2026-01-30T16:55:18.636263Z","closed_at":"2026-01-30T16:55:18.636205Z","close_reason":"Completed: SourceType enum with parse/as_str/Display, DocumentData struct, compute_content_hash, compute_list_hash, 8 tests pass","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-36p","depends_on_id":"bd-3lc","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-37qw","title":"OBSERV: Generate run_id at command entry in main.rs","description":"## Background\nEvery sync/ingest run needs a unique correlation ID so log lines, database records, and robot JSON can be linked. The uuid crate (v1, v4 feature) is already in Cargo.toml (line ~48). Using first 8 chars of UUIDv4 gives ~4 billion unique values.\n\n## Approach\nIn src/main.rs, after CLI parsing (line ~60) and before command dispatch (line ~73), generate run_id:\n\n```rust\nlet run_id = uuid::Uuid::new_v4().to_string();\nlet run_id = &run_id[..8]; // First 8 hex chars: e.g., \"a1b2c3d4\"\n```\n\nPass run_id to command handlers that need it (sync, ingest). This requires adding a run_id parameter to handle_sync_cmd() and handle_ingest(). Other commands (doctor, list, show, etc.) don't need correlation IDs.\n\nAlternative: generate run_id inside each command handler instead of main(). This avoids changing signatures of commands that don't need it. Prefer this approach -- generate inside run_sync() and run_ingest() directly.\n\nPreferred approach (generate in command handlers):\n```rust\n// In src/cli/commands/sync.rs run_sync():\nlet run_id = &uuid::Uuid::new_v4().to_string()[..8];\n\n// In src/cli/commands/ingest.rs run_ingest():\nlet run_id = &uuid::Uuid::new_v4().to_string()[..8];\n```\n\nThis keeps main.rs clean and only adds run_id where it's used.\n\n## Acceptance Criteria\n- [ ] run_id is generated for every sync and ingest invocation\n- [ ] run_id is exactly 8 characters, all hex (0-9, a-f)\n- [ ] run_id is unique across invocations (probabilistic, ~4 billion space)\n- [ ] No new dependencies needed (uuid already present)\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/cli/commands/sync.rs (generate run_id in run_sync)\n- src/cli/commands/ingest.rs (generate run_id in run_ingest)\n\n## TDD Loop\nRED:\n  - test_run_id_format: generate 100 run_ids, assert each is 8 chars, all hex\n  - test_run_id_uniqueness: generate 1000 run_ids, assert no duplicates\nGREEN: Add run_id generation to run_sync and run_ingest\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- UUID string format: Uuid::new_v4().to_string() produces \"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx\". First 8 chars are always hex (no hyphens). Safe to slice.\n- &run_id[..8] on a String: this is a byte slice on ASCII chars, always valid UTF-8. No panic risk.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-04T15:54:07.673765Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:19:31.361619Z","closed_at":"2026-02-04T17:19:31.361575Z","close_reason":"Generated run_id via uuid::Uuid::new_v4().simple() at entry of run_sync and run_ingest, truncated to 8 hex chars","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-37qw","depends_on_id":"bd-2ni","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-38e","title":"[CP0] gi init command - interactive setup wizard","description":"## Background\n\nThe init command is the user's first interaction with gi. It must guide them through setup, validate everything works before writing config, and leave the system in a ready-to-use state. Poor UX here will frustrate new users.\n\nReference: docs/prd/checkpoint-0.md section \"gi init\"\n\n## Approach\n\n**src/cli/commands/init.ts:**\n\nInteractive flow (using inquirer):\n1. Check if config exists at target path\n   - If exists and no --force: prompt \"Config exists. Overwrite? [y/N]\"\n   - If --non-interactive and config exists: exit 2\n2. Prompt for GitLab base URL (validate URL format)\n3. Prompt for token env var name (default: GITLAB_TOKEN)\n4. Check token is set in environment\n   - If not set: exit 1 with \"Export GITLAB_TOKEN first\"\n5. Test auth with GET /api/v4/user\n   - If 401: exit 1 with \"Authentication failed\"\n   - Show \"Authenticated as @username (Display Name)\"\n6. Prompt for project paths (comma-separated or add one at a time)\n7. Validate each project with GET /api/v4/projects/:encoded_path\n   - If 404: exit 1 with \"Project not found: group/project\"\n   - Show \"✓ group/project (Project Name)\"\n8. Write config.json to target path\n9. Initialize database with migrations\n10. Insert validated projects into projects table\n11. Show \"Setup complete! Run 'gi doctor' to verify.\"\n\n**Flags:**\n- `--config <path>`: Write config to specific path\n- `--force`: Skip overwrite confirmation\n- `--non-interactive`: Fail if prompts would be shown (for CI/scripting)\n\n## Acceptance Criteria\n\n- [ ] Creates config.json with valid structure\n- [ ] Validates GitLab URL is reachable before writing config\n- [ ] Validates token with GET /api/v4/user before writing config\n- [ ] Validates each project path exists in GitLab before writing config\n- [ ] Fails with exit 1 if token not set in environment\n- [ ] Fails with exit 1 if GitLab auth fails\n- [ ] Fails with exit 1 if any project not found\n- [ ] Prompts before overwriting existing config (unless --force)\n- [ ] --force skips overwrite confirmation\n- [ ] --non-interactive fails if prompts would be shown\n- [ ] Creates data directory and applies DB migrations\n- [ ] Inserts validated projects into projects table\n- [ ] tests/integration/init.test.ts passes (11 tests)\n\n## Files\n\nCREATE:\n- src/cli/commands/init.ts\n- tests/integration/init.test.ts\n\n## TDD Loop\n\nRED:\n```typescript\n// tests/integration/init.test.ts\ndescribe('gi init', () => {\n  it('creates config file with valid structure')\n  it('validates GitLab URL format')\n  it('validates GitLab connection before writing config')\n  it('validates each project path exists in GitLab')\n  it('fails if token not set')\n  it('fails if GitLab auth fails')\n  it('fails if any project path not found')\n  it('prompts before overwriting existing config')\n  it('respects --force to skip confirmation')\n  it('generates config with sensible defaults')\n  it('creates data directory if missing')\n})\n```\n\nGREEN: Implement init.ts\n\nVERIFY: `npm run test -- tests/integration/init.test.ts`\n\n## Edge Cases\n\n- User cancels at any prompt: exit 2 (user cancelled)\n- Network error during validation: show specific error, exit 1\n- Token has wrong scopes (no read_api): auth succeeds but project fetch fails\n- Project path with special characters must be URL-encoded\n- Config directory might not exist - create with mkdirSync recursive\n- --non-interactive with missing env var should fail immediately","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-24T16:09:50.810720Z","created_by":"tayloreernisse","updated_at":"2026-01-25T03:27:07.775170Z","closed_at":"2026-01-25T03:27:07.774984Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-38e","depends_on_id":"bd-13b","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-38e","depends_on_id":"bd-1l1","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-38e","depends_on_id":"bd-3ng","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-38e","depends_on_id":"bd-epj","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-38lb","title":"Implement CommandRegistry (keybindings, help, palette)","description":"## Background\nCommandRegistry is the single source of truth for all actions, keybindings, CLI equivalents, palette entries, help text, and status hints. All keybinding/help/status/palette definitions are generated from this registry — no hardcoded duplicate maps in view/state modules.\n\n## Approach\nCreate crates/lore-tui/src/commands.rs:\n- CommandDef struct: id (String), label (String), keybinding (Option<KeyCombo>), cli_equivalent (Option<String>), help_text (String), status_hint (String), available_in (Vec<Screen> or ScreenFilter)\n- CommandRegistry struct: commands (Vec<CommandDef>), by_key (HashMap<KeyCombo, Vec<CommandId>>), by_screen (HashMap<Screen, Vec<CommandId>>)\n- build_registry() -> CommandRegistry: registers all commands with their keybindings\n- lookup_key(key: &KeyEvent, screen: &Screen, mode: &InputMode) -> Option<&CommandDef>\n- palette_entries(screen: &Screen) -> Vec<&CommandDef>: returns commands available for palette\n- help_entries(screen: &Screen) -> Vec<&CommandDef>: returns commands for help overlay\n- status_hints(screen: &Screen) -> Vec<&str>: returns hints for status bar\n\n## Acceptance Criteria\n- [ ] CommandRegistry is the sole source of keybinding definitions\n- [ ] lookup_key respects InputMode (no keybinding leaks through Text mode)\n- [ ] palette_entries returns commands sorted by label\n- [ ] help_entries returns all commands available on a given screen\n- [ ] status_hints returns context-appropriate hints\n- [ ] cli_equivalent populated for commands that have a lore CLI counterpart\n\n## Files\n- CREATE: crates/lore-tui/src/commands.rs\n\n## TDD Anchor\nRED: Write test_registry_lookup_quit that builds registry, looks up 'q' in Normal mode on Dashboard, asserts it maps to Quit command.\nGREEN: Implement build_registry with quit command registered.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_registry\n\n## Edge Cases\n- g-prefix keybindings (gi, gm, g/, gt, gw, gs) require two-key sequences — registry must support this\n- Command availability varies by screen — lookup must check available_in filter\n- InputMode::Text should block all normal keybindings except Esc and Ctrl+P","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T16:56:57.098613Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:23.250214Z","deleted_at":"2026-03-11T18:34:23.250208Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-38q","title":"Implement dirty source tracking module","description":"## Background\nDirty source tracking drives incremental document regeneration. When entities are upserted during ingestion, they're marked dirty. The regenerator drains this queue. The key constraint: mark_dirty_tx() takes &Transaction to enforce atomic marking inside the entity upsert transaction. Uses ON CONFLICT DO UPDATE (not INSERT OR IGNORE) to reset backoff on re-queue.\n\n## Approach\nCreate \\`src/ingestion/dirty_tracker.rs\\` per PRD Section 6.1.\n\n```rust\nconst DIRTY_SOURCES_BATCH_SIZE: usize = 500;\n\n/// Mark dirty INSIDE existing transaction. Takes &Transaction, NOT &Connection.\n/// ON CONFLICT resets ALL backoff/error state (not INSERT OR IGNORE).\n/// This ensures fresh updates are immediately eligible, not stuck behind stale backoff.\npub fn mark_dirty_tx(\n    tx: &rusqlite::Transaction<'_>,\n    source_type: SourceType,\n    source_id: i64,\n) -> Result<()>;\n\n/// Convenience wrapper for non-transactional contexts.\npub fn mark_dirty(conn: &Connection, source_type: SourceType, source_id: i64) -> Result<()>;\n\n/// Get dirty sources ready for processing.\n/// WHERE next_attempt_at IS NULL OR next_attempt_at <= now\n/// ORDER BY attempt_count ASC, queued_at ASC  (failed items deprioritized)\n/// LIMIT 500\npub fn get_dirty_sources(conn: &Connection) -> Result<Vec<(SourceType, i64)>>;\n\n/// Clear dirty entry after successful processing.\npub fn clear_dirty(conn: &Connection, source_type: SourceType, source_id: i64) -> Result<()>;\n```\n\n**PRD-specific details:**\n- get_dirty_sources ORDER BY: \\`attempt_count ASC, queued_at ASC\\` (failed items processed AFTER fresh items)\n- mark_dirty_tx ON CONFLICT resets: queued_at, attempt_count=0, last_attempt_at=NULL, last_error=NULL, next_attempt_at=NULL\n- SourceType parsed from string in query results via match on \\\"issue\\\"/\\\"merge_request\\\"/\\\"discussion\\\"\n- Invalid source_type in DB -> rusqlite::Error::FromSqlConversionFailure\n\n**Error recording is in regenerator.rs (bd-1u1)**, not dirty_tracker. The dirty_tracker only marks, gets, and clears.\n\n## Acceptance Criteria\n- [ ] mark_dirty_tx takes &Transaction<'_>, NOT &Connection\n- [ ] ON CONFLICT DO UPDATE resets: attempt_count=0, next_attempt_at=NULL, last_error=NULL, last_attempt_at=NULL\n- [ ] Uses ON CONFLICT DO UPDATE, NOT INSERT OR IGNORE (PRD explains why)\n- [ ] get_dirty_sources WHERE next_attempt_at IS NULL OR <= now\n- [ ] get_dirty_sources ORDER BY attempt_count ASC, queued_at ASC\n- [ ] get_dirty_sources LIMIT 500\n- [ ] get_dirty_sources returns Vec<(SourceType, i64)>\n- [ ] clear_dirty DELETEs entry\n- [ ] Queue drains completely when called in loop\n- [ ] \\`cargo test dirty_tracker\\` passes\n\n## Files\n- \\`src/ingestion/dirty_tracker.rs\\` — new file\n- \\`src/ingestion/mod.rs\\` — add \\`pub mod dirty_tracker;\\`\n\n## TDD Loop\nRED: Tests:\n- \\`test_mark_dirty_tx_inserts\\` — entry appears in dirty_sources\n- \\`test_requeue_resets_backoff\\` — mark, simulate error state, re-mark -> attempt_count=0, next_attempt_at=NULL\n- \\`test_get_respects_backoff\\` — entry with future next_attempt_at not returned\n- \\`test_get_orders_by_attempt_count\\` — fresh items before failed items\n- \\`test_batch_size_500\\` — insert 600, get returns 500\n- \\`test_clear_removes\\` — entry gone after clear\n- \\`test_drain_loop\\` — insert 1200, loop 3 times = empty\nGREEN: Implement all functions\nVERIFY: \\`cargo test dirty_tracker\\`\n\n## Edge Cases\n- Empty queue: get returns empty Vec\n- Invalid source_type string in DB: FromSqlConversionFailure error\n- Concurrent mark + get: ON CONFLICT handles race condition","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:27:09.434845Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:31:35.455315Z","closed_at":"2026-01-30T17:31:35.455127Z","close_reason":"Implemented dirty_tracker with mark_dirty_tx, get_dirty_sources, clear_dirty, record_dirty_error + 8 tests","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-38q","depends_on_id":"bd-36p","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-38q","depends_on_id":"bd-hrs","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-38q","depends_on_id":"bd-mem","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-39w","title":"[CP1] Test fixtures for mocked GitLab responses","description":"## Background\n\nTest fixtures provide mocked GitLab API responses for unit and integration tests. They enable testing without a live GitLab instance and ensure consistent test data across runs.\n\n## Approach\n\n### Fixture Files\n\nCreate JSON fixtures that match GitLab API response shapes:\n\n```\ntests/fixtures/\n├── gitlab_issue.json           # Single issue\n├── gitlab_issues_page.json     # Array of issues (pagination test)\n├── gitlab_discussion.json      # Single discussion with notes\n└── gitlab_discussions_page.json # Array of discussions\n```\n\n### gitlab_issue.json\n\n```json\n{\n  \"id\": 12345,\n  \"iid\": 42,\n  \"project_id\": 100,\n  \"title\": \"Test issue title\",\n  \"description\": \"Test issue description\",\n  \"state\": \"opened\",\n  \"created_at\": \"2024-01-15T10:00:00.000Z\",\n  \"updated_at\": \"2024-01-20T15:30:00.000Z\",\n  \"closed_at\": null,\n  \"author\": {\n    \"id\": 1,\n    \"username\": \"testuser\",\n    \"name\": \"Test User\"\n  },\n  \"labels\": [\"bug\", \"priority::high\"],\n  \"web_url\": \"https://gitlab.example.com/group/project/-/issues/42\"\n}\n```\n\n### gitlab_discussion.json\n\n```json\n{\n  \"id\": \"6a9c1750b37d513a43987b574953fceb50b03ce7\",\n  \"individual_note\": false,\n  \"notes\": [\n    {\n      \"id\": 1001,\n      \"type\": \"DiscussionNote\",\n      \"body\": \"First comment in thread\",\n      \"author\": { \"id\": 1, \"username\": \"testuser\", \"name\": \"Test User\" },\n      \"created_at\": \"2024-01-16T09:00:00.000Z\",\n      \"updated_at\": \"2024-01-16T09:00:00.000Z\",\n      \"system\": false,\n      \"resolvable\": true,\n      \"resolved\": false,\n      \"resolved_by\": null,\n      \"resolved_at\": null,\n      \"position\": null\n    },\n    {\n      \"id\": 1002,\n      \"type\": \"DiscussionNote\",\n      \"body\": \"Reply to first comment\",\n      \"author\": { \"id\": 2, \"username\": \"reviewer\", \"name\": \"Reviewer\" },\n      \"created_at\": \"2024-01-16T10:00:00.000Z\",\n      \"updated_at\": \"2024-01-16T10:00:00.000Z\",\n      \"system\": false,\n      \"resolvable\": true,\n      \"resolved\": false,\n      \"resolved_by\": null,\n      \"resolved_at\": null,\n      \"position\": null\n    }\n  ]\n}\n```\n\n### Helper Module\n\n```rust\n// tests/fixtures/mod.rs\n\npub fn load_fixture<T: DeserializeOwned>(name: &str) -> T {\n    let path = PathBuf::from(env!(\"CARGO_MANIFEST_DIR\"))\n        .join(\"tests/fixtures\")\n        .join(name);\n    let content = std::fs::read_to_string(&path)\n        .expect(&format!(\"Failed to read fixture: {}\", name));\n    serde_json::from_str(&content)\n        .expect(&format!(\"Failed to parse fixture: {}\", name))\n}\n\npub fn gitlab_issue() -> GitLabIssue {\n    load_fixture(\"gitlab_issue.json\")\n}\n\npub fn gitlab_issues_page() -> Vec<GitLabIssue> {\n    load_fixture(\"gitlab_issues_page.json\")\n}\n\npub fn gitlab_discussion() -> GitLabDiscussion {\n    load_fixture(\"gitlab_discussion.json\")\n}\n```\n\n## Acceptance Criteria\n\n- [ ] gitlab_issue.json deserializes to GitLabIssue correctly\n- [ ] gitlab_issues_page.json contains 3+ issues for pagination tests\n- [ ] gitlab_discussion.json contains multi-note thread\n- [ ] gitlab_discussions_page.json contains mix of individual_note true/false\n- [ ] At least one fixture includes system: true note\n- [ ] Helper functions load fixtures without panic\n\n## Files\n\n- tests/fixtures/gitlab_issue.json (create)\n- tests/fixtures/gitlab_issues_page.json (create)\n- tests/fixtures/gitlab_discussion.json (create)\n- tests/fixtures/gitlab_discussions_page.json (create)\n- tests/fixtures/mod.rs (create)\n\n## TDD Loop\n\nRED:\n```rust\n#[test] fn fixture_gitlab_issue_deserializes()\n#[test] fn fixture_gitlab_discussion_deserializes()\n#[test] fn fixture_has_system_note()\n```\n\nGREEN: Create JSON fixtures and helper module\n\nVERIFY: `cargo test fixture`\n\n## Edge Cases\n\n- Include issue with empty labels array\n- Include issue with null description\n- Include system note (system: true)\n- Include individual_note: true discussion (standalone comment)\n- Timestamps must be valid ISO 8601","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-25T17:02:38.433752Z","created_by":"tayloreernisse","updated_at":"2026-01-25T22:48:08.415195Z","closed_at":"2026-01-25T22:48:08.415132Z","close_reason":"Created 4 JSON fixture files (issue, issues_page, discussion, discussions_page) with helper tests - 6 tests passing","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-39w","depends_on_id":"bd-1np","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-39yp","title":"Phase 3a: Cargo.toml + rust-toolchain.toml dependency swap","description":"## What\nSwap runtime and HTTP client dependencies in Cargo.toml. Finalize rust-toolchain.toml.\n\n## Cargo.toml Changes\n\n### Remove from [dependencies]:\n```toml\nreqwest = { version = \"0.12\", features = [\"json\"] }\ntokio = { version = \"1\", features = [\"rt-multi-thread\", \"macros\", \"time\", \"signal\"] }\n```\n\n### Add to [dependencies]:\n```toml\nasupersync = { version = \"0.2\", features = [\"tls\", \"tls-native-roots\"] }\n```\n\n### Keep unchanged:\n```toml\nasync-stream = \"0.3\"\nfutures = { version = \"0.3\", default-features = false, features = [\"alloc\"] }\nserde = { version = \"1\", features = [\"derive\"] }\nserde_json = \"1\"\nurlencoding = \"2\"\n```\n\n### Add/update [dev-dependencies]:\n```toml\ntokio = { version = \"1\", features = [\"rt\", \"macros\"] }  # Only for wiremock tests\n# Keep: tempfile, wiremock\n```\n\n## rust-toolchain.toml\n```toml\n[toolchain]\nchannel = \"nightly-2026-03-01\"  # Pin specific date\n```\nNever use bare \"nightly\" in production. Update date as needed when newer nightlies are verified.\n\n## Build time consideration\nMeasure build time before/after. asupersync may be heavier than tokio. Document in bead comments.\n\n## Files Changed\n- Cargo.toml (~10 LOC)\n- rust-toolchain.toml (finalize from decision gate)\n- Cargo.lock (auto-updated)\n\n## Testing\n- cargo check --all-targets (will fail until Phases 3b-3f complete — this is expected)\n- This bead is part of the atomic Phases 1-3 commit\n\n## Depends On\n- Decision Gate (nightly verified)\n- Phase 1 (adapter uses asupersync::http)","status":"closed","priority":1,"issue_type":"task","created_at":"2026-03-06T18:40:18.579026Z","created_by":"tayloreernisse","updated_at":"2026-03-06T21:11:12.604192Z","closed_at":"2026-03-06T21:11:12.601697Z","close_reason":"asupersync migration complete — 5 commits pushed to asupersync-migration branch","compaction_level":0,"original_size":0,"labels":["asupersync","phase-3"],"dependencies":[{"issue_id":"bd-39yp","depends_on_id":"bd-1lj5","type":"parent-child","created_at":"2026-03-06T18:40:18.582221Z","created_by":"tayloreernisse"},{"issue_id":"bd-39yp","depends_on_id":"bd-1lli","type":"blocks","created_at":"2026-03-06T18:42:51.236638Z","created_by":"tayloreernisse"},{"issue_id":"bd-39yp","depends_on_id":"bd-bqoc","type":"blocks","created_at":"2026-03-06T18:42:51.439438Z","created_by":"tayloreernisse"}]}
{"id":"bd-3a4k","title":"CLI: list issues status column, filter, and robot fields","description":"## Background\nList issues needs a Status column in the table, status fields in robot JSON, and a --status filter for querying by work item status name. The filter supports multiple values (OR semantics) and case-insensitive matching.\n\n## Approach\nExtend list.rs row types, SQL, table rendering. Add --status Vec<String> to clap args. Build dynamic WHERE clause with COLLATE NOCASE. Wire into both ListFilters constructions in main.rs. Register in autocorrect.\n\n## Files\n- src/cli/commands/list.rs (row types, SQL, table, filter, color helper)\n- src/cli/mod.rs (--status flag on IssuesArgs)\n- src/main.rs (wire statuses into both ListFilters)\n- src/cli/autocorrect.rs (add --status to COMMAND_FLAGS)\n\n## Implementation\n\nIssueListRow + IssueListRowJson: add 5 status fields (all Option)\nFrom<&IssueListRow> for IssueListRowJson: clone all 5 fields\n\nquery_issues SELECT: add i.status_name, i.status_category, i.status_color, i.status_icon_name, i.status_synced_at after existing columns\n  Existing SELECT has 12 columns (indices 0-11). New columns: indices 12-16.\n  Row mapping: status_name: row.get(12)?, ..., status_synced_at: row.get(16)?\n\nListFilters: add pub statuses: &'a [String]\n\nWHERE clause builder (after has_due_date block):\n  if statuses.len() == 1: \"i.status_name = ? COLLATE NOCASE\" + push param\n  if statuses.len() > 1: \"i.status_name IN (?, ?, ...) COLLATE NOCASE\" + push all params\n\nTable: add \"Status\" column header (bold) between State and Assignee\n  Row: match &issue.status_name -> Some: colored_cell_hex(status, color), None: Cell::new(\"\")\n\nNew helper:\n  fn colored_cell_hex(content, hex: Option<&str>) -> Cell\n  If no hex or colors disabled: Cell::new(content)\n  Parse 6-char hex, use Cell::new(content).fg(Color::Rgb { r, g, b })\n\nIn src/cli/mod.rs IssuesArgs:\n  #[arg(long, help_heading = \"Filters\")]\n  pub status: Vec<String>,\n\nIn src/main.rs handle_issues (~line 695):\n  ListFilters { ..., statuses: &args.status }\nIn legacy List handler (~line 2421):\n  ListFilters { ..., statuses: &[] }\n\nIn src/cli/autocorrect.rs COMMAND_FLAGS \"issues\" entry:\n  Add \"--status\" between existing flags\n\n## Acceptance Criteria\n- [ ] Status column appears in table between State and Assignee\n- [ ] NULL status -> empty cell\n- [ ] Status colored by hex in human mode\n- [ ] --status \"In progress\" filters correctly\n- [ ] --status \"in progress\" matches \"In progress\" (COLLATE NOCASE)\n- [ ] --status \"To do\" --status \"In progress\" -> OR semantics (both returned)\n- [ ] Robot: status_name, status_category in each issue JSON\n- [ ] --fields supports status_name, status_category, status_color, status_icon_name, status_synced_at\n- [ ] --fields minimal does NOT include status fields\n- [ ] Autocorrect registry test passes (--status registered)\n- [ ] cargo check --all-targets passes\n\n## TDD Loop\nRED: test_list_filter_by_status, test_list_filter_by_status_case_insensitive, test_list_filter_by_multiple_statuses\nGREEN: Implement all changes across 4 files\nVERIFY: cargo test list_filter && cargo test registry_covers\n\n## Edge Cases\n- COLLATE NOCASE is ASCII-only but sufficient (all system statuses are ASCII)\n- Single-value uses = for simplicity; multi-value uses IN with dynamic placeholders\n- --status combined with other filters (--state, --label) -> AND logic\n- autocorrect registry_covers_command_flags test will FAIL if --status not registered\n- Legacy List command path also constructs ListFilters — needs statuses: &[]\n- Column index offset: new columns start at 12 (0-indexed)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-11T06:42:26.438Z","created_by":"tayloreernisse","updated_at":"2026-02-11T07:21:33.421297Z","closed_at":"2026-02-11T07:21:33.421247Z","close_reason":"Implemented by agent swarm — all quality gates pass (595 tests, 0 failures)","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3a4k","depends_on_id":"bd-2y79","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-3a4k","depends_on_id":"bd-3dum","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-3ae","title":"Epic: CP2 Gate A - MRs Only","description":"## Background\nGate A validates core MR ingestion works before adding complexity. Proves the cursor-based sync, pagination, and basic CLI work. This is the foundation - if Gate A fails, nothing else matters.\n\n## Acceptance Criteria (Pass/Fail)\n- [ ] `gi ingest --type=merge_requests` completes without error\n- [ ] `SELECT COUNT(*) FROM merge_requests` > 0\n- [ ] `gi list mrs --limit=5` shows 5 MRs with iid, title, state, author\n- [ ] `gi count mrs` shows total count matching DB query\n- [ ] MR with `state=locked` can be stored (if exists in test data)\n- [ ] Draft MR shows `draft=1` in DB and `[DRAFT]` in list output\n- [ ] `work_in_progress=true` MR shows `draft=1` (fallback works)\n- [ ] `head_sha` populated for MRs with commits\n- [ ] `references_short` and `references_full` populated\n- [ ] Re-run ingest shows \"0 new MRs\" or minimal refetch (cursor working)\n- [ ] Cursor saved at page boundary, not item boundary\n\n## Validation Script\n```bash\n#!/bin/bash\nset -e\n\nDB_PATH=\"${XDG_DATA_HOME:-$HOME/.local/share}/gitlab-inbox/db.sqlite3\"\n\necho \"=== Gate A: MRs Only ===\"\n\n# 1. Clear any existing MR data for clean test\necho \"Step 1: Reset MR cursor for clean test...\"\nsqlite3 \"$DB_PATH\" \"DELETE FROM sync_cursors WHERE resource_type = 'merge_requests';\"\n\n# 2. Run MR ingestion\necho \"Step 2: Ingest MRs...\"\ngi ingest --type=merge_requests\n\n# 3. Verify MRs exist\necho \"Step 3: Verify MR count...\"\nMR_COUNT=$(sqlite3 \"$DB_PATH\" \"SELECT COUNT(*) FROM merge_requests;\")\necho \"  MR count: $MR_COUNT\"\n[ \"$MR_COUNT\" -gt 0 ] || { echo \"FAIL: No MRs ingested\"; exit 1; }\n\n# 4. Verify list command\necho \"Step 4: Test list command...\"\ngi list mrs --limit=5\n\n# 5. Verify count command\necho \"Step 5: Test count command...\"\ngi count mrs\n\n# 6. Verify draft handling\necho \"Step 6: Check draft MRs...\"\nDRAFT_COUNT=$(sqlite3 \"$DB_PATH\" \"SELECT COUNT(*) FROM merge_requests WHERE draft = 1;\")\necho \"  Draft MR count: $DRAFT_COUNT\"\n\n# 7. Verify head_sha population\necho \"Step 7: Check head_sha...\"\nSHA_COUNT=$(sqlite3 \"$DB_PATH\" \"SELECT COUNT(*) FROM merge_requests WHERE head_sha IS NOT NULL;\")\necho \"  MRs with head_sha: $SHA_COUNT\"\n\n# 8. Verify references\necho \"Step 8: Check references...\"\nREF_COUNT=$(sqlite3 \"$DB_PATH\" \"SELECT COUNT(*) FROM merge_requests WHERE references_short IS NOT NULL;\")\necho \"  MRs with references: $REF_COUNT\"\n\n# 9. Verify cursor saved\necho \"Step 9: Check cursor...\"\nCURSOR=$(sqlite3 \"$DB_PATH\" \"SELECT updated_at, gitlab_id FROM sync_cursors WHERE resource_type = 'merge_requests';\")\necho \"  Cursor: $CURSOR\"\n[ -n \"$CURSOR\" ] || { echo \"FAIL: Cursor not saved\"; exit 1; }\n\n# 10. Re-run and verify minimal refetch\necho \"Step 10: Re-run ingest (should be minimal)...\"\ngi ingest --type=merge_requests\n# Output should show minimal or zero new MRs\n\necho \"\"\necho \"=== Gate A: PASSED ===\"\n```\n\n## Test Commands (Quick Verification)\n```bash\n# Run these in order:\ngi ingest --type=merge_requests\ngi list mrs --limit=10\ngi count mrs\n\n# Verify in DB:\nsqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 \"\n  SELECT \n    COUNT(*) as total,\n    SUM(CASE WHEN draft = 1 THEN 1 ELSE 0 END) as drafts,\n    SUM(CASE WHEN head_sha IS NOT NULL THEN 1 ELSE 0 END) as with_sha,\n    SUM(CASE WHEN references_short IS NOT NULL THEN 1 ELSE 0 END) as with_refs\n  FROM merge_requests;\n\"\n\n# Re-run (should be no-op):\ngi ingest --type=merge_requests\n```\n\n## Dependencies\nThis gate requires these beads to be complete:\n- bd-3ir (Database migration)\n- bd-5ta (GitLab MR types)\n- bd-34o (MR transformer)\n- bd-iba (GitLab client pagination)\n- bd-ser (MR ingestion module)\n\n## Edge Cases\n- `locked` state is transitional (merge in progress); may not exist in test data\n- Some older GitLab instances may not return `head_sha` for all MRs\n- `work_in_progress` is deprecated but should still work as fallback\n- Very large projects (10k+ MRs) may take significant time on first sync","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-26T22:06:00.966522Z","created_by":"tayloreernisse","updated_at":"2026-01-27T00:48:21.057298Z","closed_at":"2026-01-27T00:48:21.057225Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3ae","depends_on_id":"bd-iba","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-3ae","depends_on_id":"bd-ser","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-3as","title":"Implement timeline event collection and chronological interleaving","description":"## Background\n\nThe event collection phase is steps 4-5 of the timeline pipeline (spec Section 3.2). It takes seed + expanded entity sets and collects all their events from resource event tables, then interleaves chronologically.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.2 steps 4-5, Section 3.3 (Event Model).\n\n## Codebase Context\n\n- resource_state_events: columns include state, actor_username (not actor_gitlab_id for display), created_at, issue_id, merge_request_id, source_merge_request_iid, source_commit\n- resource_label_events: columns include action ('add'|'remove'), label_name (NULLABLE since migration 012), actor_username, created_at\n- resource_milestone_events: columns include action ('add'|'remove'), milestone_title (NULLABLE since migration 012), actor_username, created_at\n- issues table: created_at, author_username, title, web_url\n- merge_requests table: created_at, author_username, title, web_url, merged_at, updated_at\n- All timestamps are ms epoch UTC (stored as INTEGER)\n\n## Approach\n\nCreate `src/core/timeline_collect.rs`:\n\n```rust\nuse rusqlite::Connection;\nuse crate::core::timeline::{TimelineEvent, TimelineEventType, EntityRef, ExpandedEntityRef};\n\npub fn collect_events(\n    conn: &Connection,\n    seed_entities: &[EntityRef],\n    expanded_entities: &[ExpandedEntityRef],\n    evidence_notes: &[TimelineEvent],      // from seed phase\n    since_ms: Option<i64>,                 // --since filter\n    limit: usize,                          // -n flag (default 100)\n) -> Result<Vec<TimelineEvent>> { ... }\n```\n\n### Event Collection Per Entity\n\nFor each entity (seed + expanded), collect:\n\n1. **Creation event** (`Created`):\n   ```sql\n   -- Issues:\n   SELECT created_at, author_username, title, web_url FROM issues WHERE id = ?1\n   -- MRs:\n   SELECT created_at, author_username, title, web_url FROM merge_requests WHERE id = ?1\n   ```\n\n2. **State changes** (`StateChanged { state }`):\n   ```sql\n   SELECT state, actor_username, created_at FROM resource_state_events\n   WHERE (issue_id = ?1 OR merge_request_id = ?1)\n     AND (?2 IS NULL OR created_at >= ?2)  -- since filter\n   ORDER BY created_at ASC\n   ```\n   NOTE: For MRs, a state='merged' event also produces a separate Merged variant.\n\n3. **Label changes** (`LabelAdded`/`LabelRemoved`):\n   ```sql\n   SELECT action, label_name, actor_username, created_at FROM resource_label_events\n   WHERE (issue_id = ?1 OR merge_request_id = ?1)\n     AND (?2 IS NULL OR created_at >= ?2)\n   ORDER BY created_at ASC\n   ```\n   Handle NULL label_name (deleted label): use \"[deleted label]\" as fallback.\n\n4. **Milestone changes** (`MilestoneSet`/`MilestoneRemoved`):\n   ```sql\n   SELECT action, milestone_title, actor_username, created_at FROM resource_milestone_events\n   WHERE (issue_id = ?1 OR merge_request_id = ?1)\n     AND (?2 IS NULL OR created_at >= ?2)\n   ORDER BY created_at ASC\n   ```\n   Handle NULL milestone_title: use \"[deleted milestone]\" as fallback.\n\n5. **Merge event** (Merged, MR only):\n   Derive from merge_requests.merged_at (preferred) OR resource_state_events WHERE state='merged'. Skip StateChanged when state='merged' — emit only the Merged variant.\n\n### Chronological Interleave\n\n```rust\nevents.sort(); // Uses Ord impl from bd-20e\nif let Some(since) = since_ms {\n    events.retain(|e| e.timestamp >= since);\n}\nevents.truncate(limit);\n```\n\nRegister in `src/core/mod.rs`: `pub mod timeline_collect;`\n\n## Acceptance Criteria\n\n- [ ] Collects Created, StateChanged, LabelAdded/Removed, MilestoneSet/Removed, Merged, NoteEvidence events\n- [ ] Merged events deduplicated from StateChanged{merged} — emit only Merged variant\n- [ ] NULL label_name/milestone_title handled with fallback text\n- [ ] --since filter applied to all event types\n- [ ] Events sorted chronologically with stable tiebreak\n- [ ] Limit applied AFTER sorting\n- [ ] Evidence notes from seed phase included\n- [ ] is_seed correctly set based on entity source\n- [ ] Module registered in src/core/mod.rs\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/core/timeline_collect.rs` (NEW)\n- `src/core/mod.rs` (add `pub mod timeline_collect;`)\n\n## TDD Loop\n\nRED:\n- `test_collect_creation_event` - entity produces Created event\n- `test_collect_state_events` - state changes produce StateChanged events\n- `test_collect_merged_dedup` - state='merged' produces Merged not StateChanged\n- `test_collect_null_label_fallback` - NULL label_name uses fallback text\n- `test_collect_since_filter` - old events excluded\n- `test_collect_chronological_sort` - mixed entity events interleave correctly\n- `test_collect_respects_limit`\n\nTests need in-memory DB with migrations 001-014 applied.\n\nGREEN: Implement SQL queries and event assembly.\n\nVERIFY: `cargo test --lib -- timeline_collect`\n\n## Edge Cases\n\n- MR with merged_at=NULL and no state='merged' event: no Merged event emitted\n- Entity with 0 events in resource tables: only Created event returned\n- NULL actor_username: actor field is None\n- Timestamps at exact --since boundary: use >= (inclusive)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:33:08.703942Z","created_by":"tayloreernisse","updated_at":"2026-02-05T21:53:01.160429Z","closed_at":"2026-02-05T21:53:01.160380Z","close_reason":"Completed: Created src/core/timeline_collect.rs with event collection for Created, StateChanged, LabelAdded/Removed, MilestoneSet/Removed, Merged, NoteEvidence. Merged dedup (state=merged skipped in favor of Merged variant). NULL label/milestone fallbacks. Since filter, chronological sort, limit. 10 tests pass.","compaction_level":0,"original_size":0,"labels":["gate-3","phase-b","query"],"dependencies":[{"issue_id":"bd-3as","depends_on_id":"bd-1ep","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-3as","depends_on_id":"bd-ike","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-3as","depends_on_id":"bd-ypa","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-3bec","title":"Wire surgical dispatch in run_sync and update robot-docs","description":"## Background\n\nThe existing `run_sync` function (lines 63-360 of `src/cli/commands/sync.rs`) handles the normal full-sync pipeline. Once `run_sync_surgical` (bd-1i4i) is implemented, this bead wires the dispatch: when `SyncOptions` contains issue or MR IIDs, route to the surgical path instead of the normal path. This also requires updating `handle_sync_cmd` (line 2120 of `src/main.rs`) to pass through the new CLI fields (bd-1lja), and updating the robot-docs schema to document the new surgical response fields.\n\n## Approach\n\nThree changes:\n\n**1. Dispatch in `run_sync` (src/cli/commands/sync.rs)**\n\nAdd an early check at the top of `run_sync` (after line 68):\n\n```rust\npub async fn run_sync(\n    config: &Config,\n    options: SyncOptions,\n    run_id: Option<&str>,\n    signal: &ShutdownSignal,\n) -> Result<SyncResult> {\n    // Surgical dispatch: if any IIDs specified, route to surgical pipeline\n    if options.is_surgical() {\n        return run_sync_surgical(config, options, run_id, signal).await;\n    }\n\n    // ... existing normal sync pipeline unchanged ...\n}\n```\n\n**2. Update `handle_sync_cmd` (src/main.rs line 2120)**\n\nPass new fields from `SyncArgs` into `SyncOptions`:\n\n```rust\nlet options = SyncOptions {\n    full: args.full && !args.no_full,\n    force: args.force && !args.no_force,\n    no_embed: args.no_embed,\n    no_docs: args.no_docs,\n    no_events: args.no_events,\n    robot_mode,\n    dry_run,\n    // New surgical fields (from bd-1lja)\n    issue_iids: args.issue.clone(),\n    mr_iids: args.mr.clone(),\n    project: args.project.clone(),\n    preflight_only: args.preflight_only,\n};\n```\n\nAlso: when surgical mode is detected (issues/MRs non-empty), skip the normal SyncRunRecorder setup in `handle_sync_cmd` since `run_sync_surgical` manages its own recorder.\n\n**3. Update robot-docs (src/main.rs handle_robot_docs)**\n\nAdd documentation for the surgical sync response format. The robot-docs output should include:\n- New CLI flags: `--issue`, `--mr`, `-p`/`--project`, `--preflight-only`\n- Surgical response fields: `surgical_mode`, `surgical_iids`, `entity_results`, `preflight_only`\n- `EntitySyncResult` schema: `entity_type`, `iid`, `outcome`, `error`, `toctou_reason`\n- Exit codes for surgical-specific errors\n\n## Acceptance Criteria\n\n1. `lore sync --issue 7 -p group/project` dispatches to `run_sync_surgical`, not normal sync\n2. `lore sync` (no IIDs) follows the existing normal pipeline unchanged\n3. `handle_sync_cmd` passes `issues`, `merge_requests`, `project`, `preflight_only` from args to options\n4. `lore robot-docs` output includes surgical sync documentation\n5. All existing sync tests pass without modification\n6. Robot mode JSON output for surgical sync matches documented schema\n\n## Files\n\n- `src/cli/commands/sync.rs` — add dispatch check at top of `run_sync`, add `use super::sync_surgical::run_sync_surgical`\n- `src/main.rs` — update `handle_sync_cmd` to pass new fields, update robot-docs text\n- `src/cli/commands/mod.rs` — ensure `sync_surgical` module is public (may already be done by bd-1i4i)\n\n## TDD Anchor\n\nTests in `src/cli/commands/sync.rs` or a companion test file:\n\n```rust\n#[cfg(test)]\nmod dispatch_tests {\n    use super::*;\n\n    #[test]\n    fn sync_options_with_issues_is_surgical() {\n        let options = SyncOptions {\n            issue_iids: vec![7],\n            ..SyncOptions::default()\n        };\n        assert!(options.is_surgical());\n    }\n\n    #[test]\n    fn sync_options_without_iids_is_normal() {\n        let options = SyncOptions::default();\n        assert!(!options.is_surgical());\n    }\n\n    #[test]\n    fn sync_options_with_mrs_is_surgical() {\n        let options = SyncOptions {\n            mr_iids: vec![10, 20],\n            ..SyncOptions::default()\n        };\n        assert!(options.is_surgical());\n    }\n\n    #[tokio::test]\n    async fn dispatch_routes_to_surgical_when_issues_present() {\n        // Integration-level test: verify run_sync with IIDs calls surgical path.\n        // This test uses wiremock to mock the surgical path's GitLab calls.\n        // The key assertion: when options.issue_iids is non-empty, the function\n        // does NOT attempt the normal ingest flow (no project cursor queries).\n        let server = wiremock::MockServer::start().await;\n        wiremock::Mock::given(wiremock::matchers::method(\"GET\"))\n            .and(wiremock::matchers::path_regex(r\"/api/v4/projects/1/issues\"))\n            .respond_with(wiremock::ResponseTemplate::new(200)\n                .set_body_json(serde_json::json!([{\n                    \"id\": 100, \"iid\": 7, \"project_id\": 1, \"title\": \"Test\",\n                    \"state\": \"opened\", \"created_at\": \"2026-01-01T00:00:00Z\",\n                    \"updated_at\": \"2026-02-17T00:00:00Z\",\n                    \"author\": {\"id\": 1, \"username\": \"dev\", \"name\": \"Dev\"},\n                    \"web_url\": \"https://gitlab.example.com/group/project/-/issues/7\"\n                }])))\n            .mount(&server).await;\n\n        let mut config = Config::default();\n        config.gitlab.url = server.uri();\n        config.gitlab.token = \"test-token\".to_string();\n        let options = SyncOptions {\n            issue_iids: vec![7],\n            project: Some(\"group/project\".to_string()),\n            robot_mode: true,\n            ..SyncOptions::default()\n        };\n        let signal = ShutdownSignal::new();\n        let result = run_sync(&config, options, Some(\"dispatch-test\"), &signal).await;\n\n        // Should succeed via surgical path (or at least not panic from normal path)\n        assert!(result.is_ok());\n        let r = result.unwrap();\n        assert_eq!(r.surgical_mode, Some(true));\n    }\n\n    #[test]\n    fn robot_docs_includes_surgical_sync() {\n        // Verify the robot-docs string contains surgical sync documentation\n        // This tests the static text, not runtime behavior\n        let docs = include_str!(\"../../../src/main.rs\");\n        // The robot-docs handler should mention surgical sync\n        // (Actual assertion depends on how robot-docs are generated)\n    }\n}\n```\n\n## Edge Cases\n\n- **Dry-run + surgical**: `handle_sync_cmd` currently short-circuits dry-run before SyncRunRecorder setup (line 2149). Surgical dry-run should also short-circuit, but preflight-only is the surgical equivalent. Clarify: `--dry-run --issue 7` should be treated as `--preflight-only --issue 7`.\n- **Normal sync recorder vs surgical recorder**: `handle_sync_cmd` creates a `SyncRunRecorder` for normal sync (line 2159). When dispatching to surgical, skip this since `run_sync_surgical` creates its own. Use `!options.is_surgical()` to decide.\n- **Robot-docs backward compatibility**: New fields are additive. Existing robot-docs consumers that ignore unknown fields are unaffected.\n- **No project specified with IIDs**: If `--issue 7` is passed without `-p project`, the dispatch should fail with a clear usage error (validation in bd-1lja).\n\n## Dependency Context\n\n- **Depends on (upstream)**: bd-1i4i (the `run_sync_surgical` function to call), bd-1lja (SyncOptions extensions with `issues`, `merge_requests`, `project`, `preflight_only` fields), bd-wcja (SyncResult surgical fields for assertion)\n- **No downstream dependents** — this is the final wiring bead for the main code path.\n- Must NOT modify the normal sync pipeline behavior. The dispatch is a pure conditional branch at function entry.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-17T19:18:10.648172Z","created_by":"tayloreernisse","updated_at":"2026-02-18T20:36:35.149830Z","closed_at":"2026-02-18T20:36:35.149779Z","close_reason":"Surgical dispatch wired: run_sync routes to run_sync_surgical when is_surgical(), handle_sync_cmd skips recorder for surgical mode, dry-run+surgical→preflight-only, removed wrong embed validation, robot-docs updated with surgical schema","compaction_level":0,"original_size":0,"labels":["surgical-sync"]}
{"id":"bd-3bo","title":"[CP1] gi count issues/discussions/notes commands","description":"Count entities in the database.\n\nCommands:\n- gi count issues → 'Issues: N'\n- gi count discussions --type=issue → 'Issue Discussions: N'\n- gi count notes --type=issue → 'Issue Notes: N (excluding M system)'\n\nFiles: src/cli/commands/count.ts\nDone when: Counts match expected values from GitLab","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T15:20:16.190875Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.156293Z","closed_at":"2026-01-25T15:21:35.156293Z","deleted_at":"2026-01-25T15:21:35.156290Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-3bpk","title":"NOTE-0A: Upsert/sweep for issue discussion notes","description":"## Background\nIssue discussion note ingestion uses a delete/reinsert pattern (DELETE FROM notes WHERE discussion_id = ? at line 132-135 of src/ingestion/discussions.rs then re-insert). This makes notes.id unstable across syncs. MR discussion notes already use upsert (ON CONFLICT(gitlab_id) DO UPDATE at line 470-536 of src/ingestion/mr_discussions.rs) producing stable IDs. Phase 2 depends on stable notes.id as source_id for note documents.\n\n## Approach\nRefactor src/ingestion/discussions.rs to match the MR pattern in src/ingestion/mr_discussions.rs:\n\n1. Create shared NoteUpsertOutcome struct (in src/ingestion/discussions.rs, also used by mr_discussions.rs):\n   pub struct NoteUpsertOutcome { pub local_note_id: i64, pub changed_semantics: bool }\n\n2. Replace insert_note() (line 201-233) with upsert_note_for_issue(). Current signature is:\n   fn insert_note(conn: &Connection, discussion_id: i64, note: &NormalizedNote, payload_id: Option<i64>) -> Result<()>\n   New signature:\n   fn upsert_note_for_issue(conn: &Connection, discussion_id: i64, note: &NormalizedNote, last_seen_at: i64, payload_id: Option<i64>) -> Result<NoteUpsertOutcome>\n\n   Use ON CONFLICT(gitlab_id) DO UPDATE SET body, note_type, updated_at, last_seen_at, resolvable, resolved, resolved_by, resolved_at, position_old_path, position_new_path, position_old_line, position_new_line, position_type, position_line_range_start, position_line_range_end, position_base_sha, position_start_sha, position_head_sha\n\n   IMPORTANT: The current issue insert_note() only populates: gitlab_id, discussion_id, project_id, note_type, is_system, author_username, body, created_at, updated_at, last_seen_at, position (integer array order), resolvable, resolved, resolved_by, resolved_at, raw_payload_id. It does NOT populate the decomposed position columns (position_new_path, etc.). The MR upsert_note() at line 470 DOES populate all decomposed position columns. Your upsert must include ALL columns from the MR pattern. The NormalizedNote struct (from src/gitlab/transformers.rs) has all position fields.\n\n3. Change detection via pre-read: SELECT existing note before upsert, compare semantic fields (body, note_type, resolved, resolved_by, positions). Exclude updated_at/last_seen_at from semantic comparison. Use IS NOT for NULL-safe comparison.\n\n4. Add sweep_stale_issue_notes(conn, discussion_id, last_seen_at) — DELETE FROM notes WHERE discussion_id = ? AND last_seen_at < ?\n\n5. Replace the delete-reinsert loop (lines 132-139) with:\n   for note in notes { let outcome = upsert_note_for_issue(&tx, local_discussion_id, &note, last_seen_at, None)?; }\n   sweep_stale_issue_notes(&tx, local_discussion_id, last_seen_at)?;\n\n6. Update upsert_note() in mr_discussions.rs (line 470) to return NoteUpsertOutcome with same semantic change detection. Current signature returns Result<()>.\n\nReference files:\n- src/ingestion/mr_discussions.rs: upsert_note() line 470, sweep_stale_notes() line 551\n- src/ingestion/discussions.rs: insert_note() line 201, delete pattern line 132-135\n- src/gitlab/transformers.rs: NormalizedNote struct definition\n\n## Files\n- MODIFY: src/ingestion/discussions.rs (refactor insert_note -> upsert + sweep, lines 132-233)\n- MODIFY: src/ingestion/mr_discussions.rs (return NoteUpsertOutcome from upsert_note at line 470)\n\n## TDD Anchor\nRED: test_issue_note_upsert_stable_id — insert 2 notes, record IDs, re-sync same gitlab_ids, assert IDs unchanged.\nGREEN: Implement upsert_note_for_issue with ON CONFLICT.\nVERIFY: cargo test upsert_stable_id -- --nocapture\nTests: test_issue_note_upsert_detects_body_change, test_issue_note_upsert_unchanged_returns_false, test_issue_note_upsert_updated_at_only_does_not_mark_semantic_change, test_issue_note_sweep_removes_stale, test_issue_note_upsert_returns_local_id\n\n## Acceptance Criteria\n- [ ] upsert_note_for_issue() uses ON CONFLICT(gitlab_id) DO UPDATE\n- [ ] Local note IDs stable across re-syncs of identical data\n- [ ] changed_semantics = true only for body/note_type/resolved/position changes\n- [ ] changed_semantics = false for updated_at-only changes\n- [ ] sweep removes notes with stale last_seen_at\n- [ ] MR upsert_note() returns NoteUpsertOutcome\n- [ ] Issue upsert populates ALL position columns (matching MR pattern)\n- [ ] All 6 tests pass, clippy clean\n\n## Edge Cases\n- NULL body: IS NOT comparison handles NULLs correctly\n- UNIQUE(gitlab_id) already exists on notes table (migration 002)\n- last_seen_at prevents stale-sweep of notes currently being ingested\n- Issue notes currently don't populate position_new_path etc. — the new upsert must extract these from NormalizedNote (check that the transformer populates them for issue DiffNotes)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T16:59:14.783336Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:13:24.151831Z","closed_at":"2026-02-12T18:13:24.151781Z","close_reason":"Implemented by agent swarm","compaction_level":0,"original_size":0,"labels":["per-note","search"],"dependencies":[{"issue_id":"bd-3bpk","depends_on_id":"bd-18bf","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-3bpk","depends_on_id":"bd-2b28","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-3bpk","depends_on_id":"bd-2ezb","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-3bpk","depends_on_id":"bd-jbfw","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-3bwh","title":"Define dashboard data structs for me command","description":"## Background\nThe me command needs data structs for all dashboard sections. These are used by both renderers and must serialize cleanly to JSON. Labels are stored as Vec<String> (populated from junction tables via GROUP_CONCAT in queries). All work item structs share a common shape with AttentionState.\n\n## Approach\nAdd to `src/cli/commands/me/types.rs` (alongside AttentionState):\n\n```rust\nuse serde::Serialize;\n\n#[derive(Debug, Serialize)]\npub struct MeIssue {\n    pub project_path: String,\n    pub iid: i64,\n    pub title: String,\n    pub state: String,\n    pub status_name: Option<String>,       // work item status (AC-7.4)\n    pub attention_state: AttentionState,\n    pub labels: Vec<String>,               // from issue_labels junction table\n    pub updated_at_iso: String,\n    pub web_url: String,\n}\n\n#[derive(Debug, Serialize)]\npub struct MeMrAuthored {\n    pub project_path: String,\n    pub iid: i64,\n    pub title: String,\n    pub state: String,\n    pub draft: bool,                       // AC-7.5\n    pub detailed_merge_status: Option<String>,  // AC-7.5\n    pub attention_state: AttentionState,\n    pub labels: Vec<String>,               // from mr_labels junction table\n    pub updated_at_iso: String,\n    pub web_url: String,\n}\n\n#[derive(Debug, Serialize)]\npub struct MeMrReviewing {\n    pub project_path: String,\n    pub iid: i64,\n    pub title: String,\n    pub state: String,\n    pub author_username: String,           // who wrote the MR (AC-7.5)\n    pub draft: bool,\n    pub attention_state: AttentionState,\n    pub labels: Vec<String>,\n    pub updated_at_iso: String,\n    pub web_url: String,\n}\n\n#[derive(Debug, Serialize)]\npub struct MeActivityItem {\n    pub timestamp_iso: String,\n    pub event_type: String,                // note, status, label, assign, unassign, review_request, milestone\n    pub entity_type: String,               // issue, merge_request\n    pub entity_iid: i64,\n    pub project_path: String,\n    pub actor: String,\n    pub is_own: bool,\n    pub summary: String,\n    pub body_preview: Option<String>,      // notes only, up to 200 chars (AC-7.6)\n}\n\n#[derive(Debug, Serialize)]\npub struct MeSummary {\n    pub project_count: usize,\n    pub open_issues: usize,\n    pub authored_mrs: usize,\n    pub reviewing_mrs: usize,\n    pub needs_attention_count: usize,      // AC-5.5\n}\n\n#[derive(Debug, Serialize)]\npub struct MeDashboard {\n    pub username: String,\n    pub since_iso: String,\n    pub summary: MeSummary,\n    pub open_issues: Vec<MeIssue>,\n    pub open_mrs_authored: Vec<MeMrAuthored>,\n    pub reviewing_mrs: Vec<MeMrReviewing>,\n    pub activity: Vec<MeActivityItem>,\n}\n```\n\nNote: MeMrReviewing does NOT include `detailed_merge_status` (less relevant for reviewers — the spec says \"draft indicator, attention state\" for reviewing, and `detailed_merge_status` only for authored per AC-5.2 vs AC-5.3).\n\n## Acceptance Criteria\n- [ ] MeIssue with: project_path, iid, title, state, status_name (Option), attention_state, labels, updated_at_iso, web_url\n- [ ] MeMrAuthored with: + draft (bool), detailed_merge_status (Option)\n- [ ] MeMrReviewing with: + author_username, draft (bool) — NO detailed_merge_status\n- [ ] MeActivityItem with: timestamp_iso, event_type, entity_type, entity_iid, project_path, actor, is_own, summary, body_preview (Option)\n- [ ] MeSummary with: project_count, open_issues, authored_mrs, reviewing_mrs, needs_attention_count\n- [ ] MeDashboard with: username, since_iso, summary, and 4 section Vecs\n- [ ] All structs derive Debug + Serialize\n- [ ] Labels stored as Vec<String> (populated by queries from junction tables)\n- [ ] Empty Vecs serialize as `[]` in JSON\n\n## Files\n- MODIFY: src/cli/commands/me/types.rs (add all structs)\n\n## TDD Anchor\nRED: Write `test_me_dashboard_serializes`:\n```rust\nlet dashboard = MeDashboard { username: \"test\".into(), since_iso: \"...\".into(),\n    summary: MeSummary { project_count: 1, open_issues: 0, authored_mrs: 0, reviewing_mrs: 0, needs_attention_count: 0 },\n    open_issues: vec![], open_mrs_authored: vec![], reviewing_mrs: vec![], activity: vec![] };\nlet json = serde_json::to_value(&dashboard).unwrap();\nassert_eq!(json[\"username\"], \"test\");\nassert!(json[\"open_issues\"].is_array());\n```\nGREEN: Define structs with Serialize.\nVERIFY: `cargo test me_dashboard_serializes`\n\n## Edge Cases\n- body_preview is Option: None for non-note events, Some for notes (truncated to 200 chars in query)\n- status_name is Option: None when issue hasn't had work item status enriched\n- detailed_merge_status is Option: None for older MRs\n- labels Vec is empty when no labels assigned (not Option — always a Vec)\n\n## Dependency Context\nUses AttentionState from bd-1vai (same file). Consumed by all query beads and rendering beads.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-19T19:36:27.297611Z","created_by":"tayloreernisse","updated_at":"2026-02-20T16:09:13.049912Z","closed_at":"2026-02-20T16:09:13.049871Z","close_reason":"Implemented by lore-me agent swarm","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3bwh","depends_on_id":"bd-1vai","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-3cjp","title":"NOTE-2I: Batch parent metadata cache for note regeneration","description":"## Background\nextract_note_document() (from NOTE-2C) fetches parent entity metadata per note via SQL queries. During initial backfill of ~8K notes, this creates N+1 amplification — 50 notes on same MR = 50 identical parent lookups. This is a performance optimization for batch regeneration only.\n\n## Approach\n1. Add ParentMetadataCache struct in src/documents/extractor.rs:\n   pub struct ParentMetadataCache {\n       cache: HashMap<(String, i64), ParentMetadata>,\n   }\n   Key: (noteable_type: String, parent_local_id: i64)\n   ParentMetadata struct: { iid: i64, title: String, web_url: String, labels: Vec<String>, project_path: String }\n\n   Methods:\n   - pub fn new() -> Self\n   - pub fn get_or_fetch(&mut self, conn: &Connection, noteable_type: &str, parent_id: i64) -> Result<Option<&ParentMetadata>>\n   get_or_fetch uses HashMap entry API: on miss, fetches from DB (same queries as extract_note_document), caches, returns ref.\n\n2. Add pub fn extract_note_document_cached(conn: &Connection, note_id: i64, cache: &mut ParentMetadataCache) -> Result<Option<DocumentData>>:\n   Same logic as extract_note_document but calls cache.get_or_fetch() instead of inline parent queries. The uncached version remains for single-note use.\n\n3. Update batch regeneration loop in src/documents/regenerator.rs. The main regeneration loop is in regenerate_dirty_documents() (top of file, ~line 20). It processes dirty entries one at a time via regenerate_one() (line 86). For batch cache to work:\n   - Create ParentMetadataCache before the loop\n   - In the SourceType::Note arm of regenerate_one, pass the cache through\n   - This requires either making regenerate_one() take an optional cache parameter, or restructuring to handle Note specially in the loop body.\n\n   Cleanest approach: Add cache: &mut Option<ParentMetadataCache> parameter to regenerate_one(). Initialize as Some(ParentMetadataCache::new()) before the loop. Only SourceType::Note uses it. Other types ignore it.\n\n   Cache is created fresh per regenerate_dirty_documents() call — no cross-invocation persistence.\n\n## Files\n- MODIFY: src/documents/extractor.rs (add ParentMetadataCache struct + extract_note_document_cached)\n- MODIFY: src/documents/regenerator.rs (add cache parameter to regenerate_one, use in batch loop)\n- MODIFY: src/documents/mod.rs (export ParentMetadataCache if needed externally)\n\n## TDD Anchor\nRED: test_note_regeneration_batch_uses_cache — insert project, issue, 10 notes on same issue, mark all dirty, regenerate all, assert all 10 documents created correctly.\nGREEN: Implement ParentMetadataCache and extract_note_document_cached.\nVERIFY: cargo test note_regeneration_batch -- --nocapture\nTests: test_note_regeneration_cache_consistent_with_direct_extraction (cached output == uncached output), test_note_regeneration_cache_invalidates_across_parents (notes from different parents get correct metadata)\n\n## Acceptance Criteria\n- [ ] ParentMetadataCache reduces DB queries during batch regeneration (10 notes on 1 parent = 1 parent fetch, not 10)\n- [ ] Cached extraction produces identical DocumentData output to uncached\n- [ ] Cache keyed per (noteable_type, parent_id) — no cross-parent leakage\n- [ ] Cache scoped to single regenerate_dirty_documents call — no persistence or invalidation complexity\n- [ ] All 3 tests pass\n\n## Dependency Context\n- Depends on NOTE-2C (bd-18yh): extract_note_document function must exist to create the cached variant\n\n## Edge Cases\n- Parent deleted between cache creation and lookup: get_or_fetch returns None, extract_note_document_cached returns None (same as uncached)\n- Very large batch (10K+ notes): cache grows but is bounded by number of unique parents (typically <100 issues/MRs)\n- Cache miss for orphaned discussion: cached None result prevents repeated failed lookups","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-12T17:03:00.515490Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:13:15.870738Z","closed_at":"2026-02-12T18:13:15.870693Z","close_reason":"Implemented by agent swarm","compaction_level":0,"original_size":0,"labels":["per-note","search"]}
{"id":"bd-3ddw","title":"Create lore-tui crate scaffold","description":"## Background\nThe TUI is implemented as a separate binary crate (crates/lore-tui/) that uses nightly Rust for FrankenTUI. It is EXCLUDED from the root workspace to keep nightly-only deps isolated. The lore CLI spawns lore-tui at runtime via binary delegation (PATH lookup) — zero compile-time dependency from lore to lore-tui. lore-tui depends on lore as a library (src/lib.rs exists and exports all modules).\n\nFrankenTUI is published on crates.io as ftui (0.1.1), ftui-core, ftui-runtime, ftui-render, ftui-style. Use crates.io versions. Local clone exists at ~/projects/FrankenTUI/ for reference.\n\n## Approach\nCreate the crate directory structure:\n- crates/lore-tui/Cargo.toml with dependencies:\n  - ftui = \"0.1.1\" (crates.io) and related ftui-* crates\n  - lore = { path = \"../..\" } (library dependency for Config, db, ingestion, etc.)\n  - clap, anyhow, chrono, dirs, rusqlite (bundled), crossterm\n- crates/lore-tui/rust-toolchain.toml pinning nightly-2026-02-08\n- crates/lore-tui/src/main.rs — binary entry point with TuiCli struct (clap Parser) supporting --config, --sync, --fresh, --render-mode, --ascii, --no-alt-screen\n- crates/lore-tui/src/lib.rs — public API: launch_tui(), launch_sync_tui(), LaunchOptions struct, module declarations\n- Root Cargo.toml: verify lore-tui is NOT in [workspace] members\n\n## Acceptance Criteria\n- [ ] crates/lore-tui/Cargo.toml exists with ftui (crates.io) and lore (path dep) dependencies\n- [ ] crates/lore-tui/rust-toolchain.toml pins nightly-2026-02-08\n- [ ] crates/lore-tui/src/main.rs compiles with clap CLI args\n- [ ] crates/lore-tui/src/lib.rs declares all module stubs and exports LaunchOptions, launch_tui, launch_sync_tui\n- [ ] cargo +stable check --workspace --all-targets passes (lore-tui excluded)\n- [ ] cargo +nightly check --manifest-path crates/lore-tui/Cargo.toml --all-targets passes\n- [ ] Root Cargo.toml does NOT include lore-tui in workspace members\n\n## Files\n- CREATE: crates/lore-tui/Cargo.toml\n- CREATE: crates/lore-tui/rust-toolchain.toml\n- CREATE: crates/lore-tui/src/main.rs\n- CREATE: crates/lore-tui/src/lib.rs\n- VERIFY: Cargo.toml (root — confirm lore-tui NOT in members)\n\n## TDD Anchor\nRED: Write a shell test that runs cargo +nightly check --manifest-path crates/lore-tui/Cargo.toml and asserts exit 0.\nGREEN: Create the full crate scaffold with all deps.\nVERIFY: cargo +stable check --workspace --all-targets && cargo +nightly check --manifest-path crates/lore-tui/Cargo.toml\n\n## Edge Cases\n- ftui crates may require specific nightly features — pin exact nightly date\n- Path dependency to lore means lore-tui sees lore's edition 2024 — verify compat\n- rusqlite bundled feature pulls in cc build — may need nightly-compatible cc version\n- If ftui 0.1.1 has breaking changes vs PRD assumptions, check ~/projects/FrankenTUI/ for latest API\n\n## Dependency Context\nRoot task — no dependencies. All other Phase 0 tasks depend on this scaffold existing.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T16:53:10.859837Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:24.952483Z","deleted_at":"2026-03-11T18:34:24.952476Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-3dum","title":"Orchestrator: status enrichment phase with transactional writes","description":"## Background\nThe orchestrator controls the sync pipeline. Status enrichment is a new Phase 1.5 that runs after issue ingestion but before discussion sync. It must be non-fatal — errors skip enrichment but don't crash the sync.\n\n## Approach\nAdd enrichment phase to ingest_project_issues_with_progress. Use client.graphql_client() factory. Look up project path from DB via .optional()? for non-fatal failure. Transactional writes via enrich_issue_statuses_txn() with two phases: clear stale, then apply new.\n\n## Files\n- src/ingestion/orchestrator.rs (enrichment phase + txn helper + IngestProjectResult fields + ProgressEvent variants)\n- src/cli/commands/ingest.rs (add match arms for new ProgressEvent variants)\n\n## Implementation\n\nIngestProjectResult new fields:\n  statuses_enriched: usize, statuses_cleared: usize, statuses_seen: usize,\n  statuses_without_widget: usize, partial_error_count: usize,\n  first_partial_error: Option<String>, status_enrichment_error: Option<String>,\n  status_enrichment_mode: String, status_unsupported_reason: Option<String>\n  Default: all 0/None/\"\" as appropriate\n\nProgressEvent new variants:\n  StatusEnrichmentComplete { enriched: usize, cleared: usize }\n  StatusEnrichmentSkipped\n\nPhase 1.5 logic (after ingest_issues, before discussion sync):\n  1. Check config.sync.fetch_work_item_status && !signal.is_cancelled()\n  2. If false: set mode=\"skipped\", emit StatusEnrichmentSkipped\n  3. Look up project path: conn.query_row(\"SELECT path_with_namespace FROM projects WHERE id = ?1\", [project_id], |r| r.get(0)).optional()?\n  4. If None: warn, set status_enrichment_error=\"project_path_missing\", emit StatusEnrichmentComplete{0,0}\n  5. Create graphql_client via client.graphql_client()\n  6. Call fetch_issue_statuses(&graphql_client, &project_path).await\n  7. On Ok: map unsupported_reason to mode/reason, call enrich_issue_statuses_txn(), set counters\n  8. On Err: warn, set status_enrichment_error, mode=\"fetched\"\n  9. Emit StatusEnrichmentComplete\n\nenrich_issue_statuses_txn(conn, project_id, statuses, all_fetched_iids, now_ms) -> Result<(usize, usize)>:\n  Uses conn.unchecked_transaction() (conn is &Connection not &mut)\n  Phase 1 (clear): UPDATE issues SET status_*=NULL, status_synced_at=now_ms WHERE project_id=? AND iid=? AND status_name IS NOT NULL — for IIDs in all_fetched_iids but NOT in statuses\n  Phase 2 (apply): UPDATE issues SET status_name=?, status_category=?, status_color=?, status_icon_name=?, status_synced_at=now_ms WHERE project_id=? AND iid=?\n  tx.commit(), return (enriched, cleared)\n\nIn src/cli/commands/ingest.rs progress callback, add arms:\n  ProgressEvent::StatusEnrichmentComplete { enriched, cleared } => { ... }\n  ProgressEvent::StatusEnrichmentSkipped => { ... }\n\n## Acceptance Criteria\n- [ ] Enrichment runs after ingest_issues, before discussion sync\n- [ ] Gated by config.sync.fetch_work_item_status\n- [ ] Project path missing -> skipped with error=\"project_path_missing\", sync continues\n- [ ] enrich_issue_statuses_txn correctly UPDATEs status columns + status_synced_at\n- [ ] Stale status cleared: issue in all_fetched_iids but not statuses -> NULL + synced_at set\n- [ ] Transaction rollback on failure: no partial updates\n- [ ] Idempotent: running twice with same data produces same result\n- [ ] GraphQL error: logged, enrichment_error captured, sync continues\n- [ ] ingest.rs compiles with new ProgressEvent arms\n- [ ] cargo check --all-targets passes\n\n## TDD Loop\nRED: test_enrich_issue_statuses_txn, test_enrich_skips_unknown_iids, test_enrich_clears_removed_status, test_enrich_transaction_rolls_back_on_failure, test_enrich_idempotent_across_two_runs, test_enrich_sets_synced_at_on_clear, test_enrichment_error_captured_in_result, test_project_path_missing_skips_enrichment\n  Tests use in-memory DB with migration 021 applied\nGREEN: Implement enrichment phase + txn helper + result fields + progress arms\nVERIFY: cargo test enrich && cargo test orchestrator\n\n## Edge Cases\n- unchecked_transaction() needed because conn is &Connection not &mut Connection\n- .optional()? requires use rusqlite::OptionalExtension\n- status_synced_at is set on BOTH clear and apply operations (not NULL on clear)\n- Clear SQL has WHERE status_name IS NOT NULL to avoid counting already-cleared rows\n- Progress callback match must be updated in SAME batch as enum change (compile error otherwise)\n- status_enrichment_mode must be set in ALL code paths (fetched/unsupported/skipped)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-11T06:42:11.254917Z","created_by":"tayloreernisse","updated_at":"2026-02-11T07:21:33.419310Z","closed_at":"2026-02-11T07:21:33.419268Z","close_reason":"Implemented by agent swarm — all quality gates pass (595 tests, 0 failures)","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3dum","depends_on_id":"bd-1gvg","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-3dum","depends_on_id":"bd-2jzn","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-3dum","depends_on_id":"bd-2y79","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-3ei1","title":"Implement Issue List (state + action + view)","description":"## Background\nThe Issue List is the primary browse interface for issues. It uses keyset pagination (not OFFSET) for deterministic cross-page traversal under concurrent sync writes. A browse snapshot fence preserves stable ordering until explicit refresh.\n\n## Approach\nState (state/issue_list.rs):\n- IssueListState: window (Vec<IssueListRow>), total_count, selected_index, scroll_offset, next_cursor (Option<IssueCursor>), prev_cursor (Option<IssueCursor>), prefetch_in_flight (bool), filter (IssueFilter), filter_input (TextInput), filter_focused (bool), sort_field (SortField), sort_order (SortOrder), snapshot_upper_updated_at (Option<i64>), filter_hash (u64), peek_visible (bool), peek_content (Option<IssueDetail>)\n- IssueCursor: updated_at (i64), iid (i64) — boundary values for keyset pagination\n- IssueFilter: state (Option<String>), author (Option<String>), assignee (Option<String>), label (Option<String>), milestone (Option<String>), status (Option<String>), free_text (Option<String>), project_id (Option<i64>)\n- IssueListRow: project_path, iid, title, state, author, assignee, labels, updated_at, status_name, status_icon\n- handle_key(): j/k scroll, J/K page, Enter select, / focus filter, Tab sort, g+g top, G bottom, r refresh, Space toggle Quick Peek\n- scroll_to_top(), apply_filter(), set_sort(), toggle_peek()\n\n**Snapshot fence:** On first load and on explicit refresh (r), store snapshot_upper_updated_at = MAX(updated_at) from result set. Subsequent page fetches add WHERE updated_at <= snapshot_upper_updated_at to prevent rows from shifting as sync inserts new data. Explicit refresh (r) resets the fence.\n\n**filter_hash:** Compute a hash of the current filter state. When filter changes (new hash != old hash), reset cursor to page 1 and clear snapshot fence. This prevents stale pagination after filter changes.\n\n**Prefetch:** When scroll position reaches 80% of current window, trigger background prefetch of next page via TaskSupervisor. Prefetched data appended to window when user scrolls past current page boundary.\n\n**Quick Peek (Space key):**\n- Space toggles a right-side preview pane (40% width) showing the currently selected issue's detail\n- Preview content loads asynchronously via TaskSupervisor\n- Cursor movement (j/k) updates the preview for the newly selected row\n- Esc or Space again closes the peek pane\n- On narrow terminals (<100 cols), peek replaces the list instead of side-by-side\n\nAction (action.rs):\n- fetch_issues(conn, filter, cursor, page_size, clock, snapshot_fence) -> Result<IssueListPage, LoreError>: keyset pagination query with WHERE (updated_at, iid) < (cursor.updated_at, cursor.iid) AND updated_at <= snapshot_fence ORDER BY updated_at DESC, iid DESC LIMIT page_size+1 (extra row detects has_next). Uses idx_issues_list_default index.\n- fetch_issue_peek(conn, entity_key) -> Result<IssueDetail, LoreError>: loads issue detail for Quick Peek preview\n- IssueListPage: rows, next_cursor, prev_cursor, total_count\n\nView (view/issue_list.rs):\n- render_issue_list(frame, state, area, theme): FilterBar at top, EntityTable below, status bar at bottom\n- When peek_visible: split area horizontally — list (60%) | peek preview (40%)\n- Columns: IID, Title (flex), State, Author, Labels, Updated, Status\n\n## Acceptance Criteria\n- [ ] Keyset pagination fetches pages without OFFSET\n- [ ] Next/prev page navigation preserves deterministic ordering\n- [ ] Browse snapshot fence (snapshot_upper_updated_at) prevents rows from shifting during concurrent sync\n- [ ] Explicit refresh (r) resets snapshot fence and re-queries from first page\n- [ ] filter_hash tracks filter state; filter change resets cursor to page 1\n- [ ] Prefetch triggers at 80% scroll position via TaskSupervisor\n- [ ] Filter bar accepts DSL tokens and triggers re-query via ScreenIntent::RequeryNeeded\n- [ ] j/k scrolls within current page, J/K loads next/prev page\n- [ ] Enter navigates to IssueDetail(EntityKey), Esc returns to list with cursor preserved\n- [ ] Tab cycles sort column, sort indicator shown\n- [ ] Total count displayed in status area\n- [ ] Space toggles Quick Peek right-side preview pane\n- [ ] Quick Peek loads issue detail asynchronously\n- [ ] j/k in peek mode updates preview for newly selected row\n- [ ] Narrow terminal (<100 cols): peek replaces list instead of split view\n\n## Files\n- MODIFY: crates/lore-tui/src/state/issue_list.rs (expand from stub)\n- MODIFY: crates/lore-tui/src/action.rs (add fetch_issues, fetch_issue_peek)\n- CREATE: crates/lore-tui/src/view/issue_list.rs\n\n## TDD Anchor\nRED: Write test_keyset_pagination in action.rs that inserts 30 issues, fetches page 1 (size 10), then fetches page 2 using returned cursor, asserts no overlap between pages.\nGREEN: Implement keyset pagination query.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_keyset_pagination\n\nAdditional tests:\n- test_snapshot_fence_excludes_newer_rows: insert row with updated_at > fence, assert not in results\n- test_filter_change_resets_cursor: change filter, verify cursor reset to None\n- test_prefetch_triggered_at_80pct: scroll to 80% of window, verify prefetch_in_flight set\n\n## Edge Cases\n- Multi-project datasets: cursor must include project_id scope from global ScopeContext\n- Issues with identical updated_at: keyset tiebreaker on iid ensures deterministic ordering\n- Empty result set: show \"No issues match your filter\" message, not empty table\n- Filter changes must reset cursor to first page (not continue from mid-pagination)\n- Quick Peek on empty list: no-op (don't show empty pane)\n- Rapid j/k with peek open: debounce peek loads to avoid flooding TaskSupervisor\n\n## Dependency Context\nUses EntityTable and FilterBar from \"Implement entity table + filter bar widgets\" (bd-18qs).\nUses AppState, IssueListState, ScreenIntent from \"Implement AppState composition\" (bd-1v9m).\nUses TaskSupervisor for load management and prefetch from \"Implement TaskSupervisor\" (bd-3le2).\nUses DbManager from \"Implement DbManager\" (bd-2kop).\nRequires idx_issues_list_default index from \"Add required TUI indexes\" (bd-3pm2).","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T16:58:31.401233Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:23.329921Z","deleted_at":"2026-03-11T18:34:23.329915Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-3eis","title":"Implement property tests for navigation invariants","description":"## Background\nProperty-based tests verify navigation invariants hold for all possible sequences of push/pop/forward/jump/reset operations. Uses proptest or quickcheck for automated input generation.\n\n## Approach\n- Property: stack depth always >= 1 (Dashboard is always reachable)\n- Property: after push(X), current() == X\n- Property: after push(X) then pop(), current() returns to previous\n- Property: forward_stack cleared after any push (browser semantics)\n- Property: jump_list only contains detail/entity screens\n- Property: reset_to(X) clears all history, current() == X\n- Property: breadcrumbs length == back_stack.len() + 1\n- Arbitrary sequence of operations should never panic\n\n## Acceptance Criteria\n- [ ] All 7 navigation properties hold for 10000 generated test cases\n- [ ] No panic for any sequence of operations\n- [ ] Proptest shrinking finds minimal counterexamples on failure\n\n## Files\n- CREATE: crates/lore-tui/tests/nav_property_tests.rs\n\n## TDD Anchor\nRED: Write proptest that generates random sequences of push/pop/forward/reset, asserts stack depth >= 1 after every operation.\nGREEN: Ensure NavigationStack maintains invariant (pop returns None at root).\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml nav_property\n\n## Dependency Context\nUses NavigationStack from \"Implement NavigationStack\" task.\nUses Screen enum from \"Implement core types\" task.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T17:04:53.366767Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:23.404696Z","deleted_at":"2026-03-11T18:34:23.404690Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-3er","title":"OBSERV Epic: Phase 3 - Performance Metrics Collection","description":"StageTiming struct, custom MetricsLayer tracing subscriber layer, span-to-metrics extraction, robot JSON enrichment with meta.stages, human-readable timing summary.\n\nDepends on: Phase 2 (spans must exist to extract timing from)\nUnblocks: Phase 4 (sync history needs Vec<StageTiming> to store)\n\nFiles: src/core/metrics.rs (new), src/cli/commands/sync.rs, src/cli/commands/ingest.rs, src/main.rs\n\nAcceptance criteria (PRD Section 6.3):\n- lore --robot sync includes meta.run_id and meta.stages array\n- Each stage has name, elapsed_ms, items_processed\n- Top-level stages have sub_stages arrays\n- Interactive sync prints timing summary table\n- Zero-value fields omitted from JSON","status":"closed","priority":2,"issue_type":"epic","created_at":"2026-02-04T15:53:27.415566Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:32:56.743477Z","closed_at":"2026-02-04T17:32:56.743430Z","close_reason":"All Phase 3 tasks complete: StageTiming struct, MetricsLayer, span field recording, robot JSON enrichment with stages, and human-readable timing summary","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-3er","depends_on_id":"bd-2ni","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-3eu","title":"Implement hybrid search with adaptive recall","description":"## Background\nHybrid search is the top-level search orchestrator that combines FTS5 lexical results with sqlite-vec semantic results via RRF ranking. It supports three modes (Lexical, Semantic, Hybrid) and implements adaptive recall (wider initial fetch when filters are applied) and graceful degradation (falls back to FTS when Ollama is unavailable). All modes use RRF for consistent --explain output.\n\n## Approach\nCreate `src/search/hybrid.rs` per PRD Section 5.3.\n\n**Key types:**\n```rust\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum SearchMode {\n    Hybrid,    // Vector + FTS with RRF\n    Lexical,   // FTS only\n    Semantic,  // Vector only\n}\n\nimpl SearchMode {\n    pub fn from_str(s: &str) -> Option<Self> {\n        match s.to_lowercase().as_str() {\n            \"hybrid\" => Some(Self::Hybrid),\n            \"lexical\" | \"fts\" => Some(Self::Lexical),\n            \"semantic\" | \"vector\" => Some(Self::Semantic),\n            _ => None,\n        }\n    }\n\n    pub fn as_str(&self) -> &'static str {\n        match self {\n            Self::Hybrid => \"hybrid\",\n            Self::Lexical => \"lexical\",\n            Self::Semantic => \"semantic\",\n        }\n    }\n}\n\npub struct HybridResult {\n    pub document_id: i64,\n    pub score: f64,          // Normalized RRF score (0-1)\n    pub vector_rank: Option<usize>,\n    pub fts_rank: Option<usize>,\n    pub rrf_score: f64,      // Raw RRF score\n}\n```\n\n**Core function (ASYNC, PRD-exact signature):**\n```rust\npub async fn search_hybrid(\n    conn: &Connection,\n    client: Option<&OllamaClient>,      // None if Ollama unavailable\n    ollama_base_url: Option<&str>,       // For actionable error messages\n    query: &str,\n    mode: SearchMode,\n    filters: &SearchFilters,\n    fts_mode: FtsQueryMode,\n) -> Result<(Vec<HybridResult>, Vec<String>)>\n```\n\n**IMPORTANT — client is `Option<&OllamaClient>`:** This enables graceful degradation. When Ollama is unavailable, the caller passes `None` and hybrid mode falls back to FTS-only with a warning. The `ollama_base_url` is separate so error messages can include it even when client is None.\n\n**Adaptive recall constants (PRD Section 5.3):**\n```rust\nconst BASE_RECALL_MIN: usize = 50;\nconst FILTERED_RECALL_MIN: usize = 200;\nconst RECALL_CAP: usize = 1500;\n```\n\n**Recall formula:**\n```rust\nlet requested = filters.clamp_limit();\nlet top_k = if filters.has_any_filter() {\n    (requested * 50).max(FILTERED_RECALL_MIN).min(RECALL_CAP)\n} else {\n    (requested * 10).max(BASE_RECALL_MIN).min(RECALL_CAP)\n};\n```\n\n**Mode behavior:**\n- **Lexical:** FTS only -> rank_rrf with empty vector list (single-list RRF)\n- **Semantic:** Vector only -> requires client (error if None) -> rank_rrf with empty FTS list\n- **Hybrid:** Both FTS + vector -> rank_rrf with both lists\n- **Hybrid with client=None:** Graceful degradation to Lexical with warning, NOT error\n\n**Graceful degradation logic:**\n```rust\nSearchMode::Hybrid => {\n    let fts_results = search_fts(conn, query, top_k, fts_mode)?;\n    let fts_tuples: Vec<_> = fts_results.iter().map(|r| (r.document_id, r.rank)).collect();\n\n    match client {\n        Some(client) => {\n            let query_embedding = client.embed_batch(vec\\![query.to_string()]).await?;\n            let embedding = query_embedding.into_iter().next().unwrap();\n            let vec_results = search_vector(conn, &embedding, top_k)?;\n            let vec_tuples: Vec<_> = vec_results.iter().map(|r| (r.document_id, r.distance)).collect();\n            let ranked = rank_rrf(&vec_tuples, &fts_tuples);\n            // ... map to HybridResult\n            Ok((results, warnings))\n        }\n        None => {\n            warnings.push(\"Ollama unavailable, falling back to lexical search\".into());\n            let ranked = rank_rrf(&[], &fts_tuples);\n            // ... map to HybridResult\n            Ok((results, warnings))\n        }\n    }\n}\n```\n\n## Acceptance Criteria\n- [ ] Function is `async` (per PRD — Ollama client methods are async)\n- [ ] Signature takes `client: Option<&OllamaClient>` (not required)\n- [ ] Signature takes `ollama_base_url: Option<&str>` for actionable error messages\n- [ ] Returns `(Vec<HybridResult>, Vec<String>)` — results + warnings\n- [ ] Lexical mode: FTS-only results ranked via RRF (single list)\n- [ ] Semantic mode: vector-only results ranked via RRF; error if client is None\n- [ ] Hybrid mode: both FTS + vector results merged via RRF\n- [ ] Graceful degradation: client=None in Hybrid falls back to FTS with warning (not error)\n- [ ] Adaptive recall: unfiltered max(50, limit*10), filtered max(200, limit*50), capped 1500\n- [ ] All modes produce consistent --explain output (vector_rank, fts_rank, rrf_score)\n- [ ] SearchMode::from_str accepts aliases: \"fts\" for Lexical, \"vector\" for Semantic\n- [ ] `cargo build` succeeds\n\n## Files\n- `src/search/hybrid.rs` — new file\n- `src/search/mod.rs` — add `pub use hybrid::{search_hybrid, HybridResult, SearchMode};`\n\n## TDD Loop\nRED: Tests (some integration, some unit):\n- `test_lexical_mode` — FTS results only\n- `test_semantic_mode` — vector results only\n- `test_hybrid_mode` — both lists merged\n- `test_graceful_degradation` — None client falls back to FTS with warning in warnings vec\n- `test_adaptive_recall_unfiltered` — recall = max(50, limit*10)\n- `test_adaptive_recall_filtered` — recall = max(200, limit*50)\n- `test_recall_cap` — never exceeds 1500\n- `test_search_mode_from_str` — \"hybrid\", \"lexical\", \"fts\", \"semantic\", \"vector\", invalid\nGREEN: Implement search_hybrid\nVERIFY: `cargo test hybrid`\n\n## Edge Cases\n- Both FTS and vector return zero results: empty output (not error)\n- FTS returns results but vector returns empty: RRF still works (single-list)\n- Very high limit (100) with filters: recall = min(5000, 1500) = 1500\n- Semantic mode with client=None: error (OllamaUnavailable), not degradation\n- Semantic mode with 0% coverage: return LoreError::EmbeddingsNotBuilt","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:26:50.343002Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:56:16.631748Z","closed_at":"2026-01-30T17:56:16.631682Z","close_reason":"Implemented hybrid search with 3 modes (lexical/semantic/hybrid), graceful degradation when Ollama unavailable, adaptive recall (50-1500), RRF fusion. 6 tests pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3eu","depends_on_id":"bd-1k1","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-3eu","depends_on_id":"bd-335","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-3eu","depends_on_id":"bd-3ez","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-3eu","depends_on_id":"bd-bjo","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-3ez","title":"Implement RRF ranking","description":"## Background\nReciprocal Rank Fusion (RRF) combines results from multiple retrieval systems (FTS5 lexical + sqlite-vec semantic) into a single ranked list without requiring score normalization. Documents appearing in both lists rank higher than single-list documents. This is the core ranking algorithm for hybrid search in Gate B.\n\n## Approach\nCreate \\`src/search/rrf.rs\\` per PRD Section 5.2.\n\n```rust\nuse std::collections::HashMap;\n\nconst RRF_K: f64 = 60.0;\n\npub struct RrfResult {\n    pub document_id: i64,\n    pub rrf_score: f64,           // Raw RRF score\n    pub normalized_score: f64,    // Normalized to 0-1 (rrf_score / max)\n    pub vector_rank: Option<usize>,  // 1-indexed rank in vector list\n    pub fts_rank: Option<usize>,     // 1-indexed rank in FTS list\n}\n\n/// Input: tuples of (document_id, score/distance) — already sorted by retriever.\n/// Ranks are 1-indexed (first result = rank 1).\n/// Score = sum of 1/(k + rank) for each list containing the document.\npub fn rank_rrf(\n    vector_results: &[(i64, f64)],  // (doc_id, distance)\n    fts_results: &[(i64, f64)],     // (doc_id, bm25_score)\n) -> Vec<RrfResult>\n```\n\n**Algorithm (per PRD):**\n1. Build HashMap<doc_id, (rrf_score, vector_rank, fts_rank)>\n2. For each vector result at position i: score += 1/(K + (i+1)), record vector_rank = i+1 (**1-indexed**)\n3. For each FTS result at position i: score += 1/(K + (i+1)), record fts_rank = i+1 (**1-indexed**)\n4. Sort descending by rrf_score\n5. Normalize: each result.normalized_score = result.rrf_score / max_score (best = 1.0)\n\n**Key PRD details:**\n- Ranks are **1-indexed** (rank 1 = best, not rank 0)\n- Input is \\`&[(i64, f64)]\\` tuples, NOT custom structs\n- Output has both \\`rrf_score\\` (raw) and \\`normalized_score\\` (0-1)\n\n## Acceptance Criteria\n- [ ] Documents in both lists score higher than single-list documents\n- [ ] Single-list documents are included (not dropped)\n- [ ] Ranks are 1-indexed (first element = rank 1)\n- [ ] Raw RRF score available in rrf_score field\n- [ ] Normalized score: best = 1.0, all in [0, 1]\n- [ ] Results sorted descending by rrf_score\n- [ ] vector_rank and fts_rank tracked per result for --explain\n- [ ] Empty input lists handled (return empty)\n- [ ] One empty list + one non-empty returns results from non-empty list\n\n## Files\n- \\`src/search/rrf.rs\\` — new file\n- \\`src/search/mod.rs\\` — add \\`mod rrf; pub use rrf::{rank_rrf, RrfResult};\\`\n\n## TDD Loop\nRED: Tests in \\`#[cfg(test)] mod tests\\`:\n- \\`test_dual_list_ranks_higher\\` — doc in both lists scores > doc in one list\n- \\`test_single_list_included\\` — FTS-only and vector-only docs appear\n- \\`test_normalization\\` — best score is 1.0, all in [0, 1]\n- \\`test_empty_inputs\\` — empty returns empty\n- \\`test_ranks_are_1_indexed\\` — verify vector_rank/fts_rank start at 1\n- \\`test_raw_and_normalized_scores\\` — both fields populated correctly\nGREEN: Implement rank_rrf()\nVERIFY: \\`cargo test rrf\\`\n\n## Edge Cases\n- Duplicate document_id within same list: shouldn't happen, use first occurrence\n- Single result in one list, zero in other: normalized_score = 1.0\n- Very large input lists: HashMap handles efficiently","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:26:50.309012Z","created_by":"tayloreernisse","updated_at":"2026-01-30T16:53:04.128560Z","closed_at":"2026-01-30T16:53:04.128498Z","close_reason":"Completed: RRF ranking with 1-indexed ranks, raw+normalized scores, vector_rank/fts_rank provenance, 7 tests pass","compaction_level":0,"original_size":0}
{"id":"bd-3fjk","title":"Implement stale response + SQLITE_BUSY + cancel race tests","description":"## Background\nThese tests verify the TUI handles async race conditions correctly: stale responses from superseded tasks are dropped, SQLITE_BUSY errors trigger retry with backoff, and query cancellation doesn't bleed across tasks or leave stuck loading states.\n\n## Approach\nStale response tests:\n- Submit task A (generation 1), then submit task B (generation 2) with same key\n- Deliver task A's result: assert it's dropped (generation mismatch)\n- Deliver task B's result: assert it's applied\n\nSQLITE_BUSY retry tests:\n- Lock DB with a writer, attempt read query, assert retry with exponential backoff\n- Verify TUI shows \"Database busy\" toast, not a crash\n\nCancel race tests:\n- Submit task, cancel via CancelToken, immediately submit new task with same key\n- Assert old task's CancelToken is set, new task proceeds normally\n- Rapid cancel-then-resubmit: no stuck LoadingInitial state after sequence\n- Cross-task bleed: interrupt handle only cancels the owning task's connection\n\n## Acceptance Criteria\n- [ ] Stale response with old generation silently dropped\n- [ ] SQLITE_BUSY shows user-friendly error, retries automatically\n- [ ] Cancel-then-resubmit: no stuck loading state\n- [ ] InterruptHandle only cancels its owning task's query\n- [ ] Rapid sequence (5 cancel+submit in 100ms): final state is correct\n\n## Files\n- CREATE: crates/lore-tui/tests/race_condition_tests.rs\n\n## TDD Anchor\nRED: Write test_stale_response_dropped that submits two tasks, delivers first result, asserts state unchanged.\nGREEN: Ensure is_current() check in update() guards all *Loaded handlers.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_stale_response\n\n## Edge Cases\n- SQLITE_BUSY timeout must be configurable (default 5000ms)\n- Rapid navigation can create >10 pending tasks — all but latest should be cancelled\n- CancelToken check must be in hot loops, not just at task entry\n\n## Dependency Context\nUses TaskSupervisor from \"Implement TaskSupervisor\" task.\nUses DbManager from \"Implement DbManager\" task.\nUses LoreApp update() stale-result guards from \"Implement LoreApp Model\" task.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T17:04:20.574583Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:23.481820Z","deleted_at":"2026-03-11T18:34:23.481814Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-3h00","title":"Implement session persistence + instance lock + text width","description":"## Background\nSession state persistence allows the TUI to resume where the user left off (current screen, filter state, scroll position). Instance locking prevents data corruption from accidental double-launch. Text width handling ensures correct rendering of CJK, emoji, and combining marks.\n\n## Approach\nSession (session.rs):\n- SessionState: versioned struct with current_screen, nav_history, per-screen filter/scroll state, global_scope\n- save(path): atomic write (tmp->fsync->rename) + CRC32 checksum + max-size guard (1MB)\n- load(path) -> Result<SessionState>: validate CRC32, reject corrupted (quarantine bad file), handle version migration\n- corruption quarantine: move bad files to .quarantine/ subdir\n\nInstance Lock (instance_lock.rs):\n- InstanceLock: advisory lock file (~/.local/share/lore/tui.lock) with PID written\n- acquire() -> Result<InstanceLock>: try lock, check for stale lock (PID no longer running), clear stale, create new\n- Drop impl: remove lock file\n- On collision: clear error message with running PID\n\nText Width (text_width.rs):\n- measure_display_width(s: &str) -> usize: terminal display width using unicode-width + unicode-segmentation\n- truncate_display_width(s: &str, max_width: usize) -> String: truncate at grapheme boundary, append ellipsis\n- pad_display_width(s: &str, width: usize) -> String: pad with spaces to target display width\n- Handles: CJK (2-cell), emoji ZWJ sequences, skin tone modifiers, flag sequences, combining marks\n\n## Acceptance Criteria\n- [ ] Session state saved on quit, restored on launch\n- [ ] Atomic write prevents partial session files\n- [ ] CRC32 checksum detects corruption\n- [ ] Corrupted sessions quarantined (not deleted)\n- [ ] Max 1MB session file size enforced\n- [ ] Instance lock prevents double-launch with clear error\n- [ ] Stale lock (dead PID) automatically recovered\n- [ ] Lock released on normal exit and on panic (Drop impl)\n- [ ] CJK characters measured as 2 cells wide\n- [ ] Emoji ZWJ sequences treated as single grapheme cluster\n- [ ] Truncation never splits a grapheme cluster\n\n## Files\n- CREATE: crates/lore-tui/src/session.rs\n- CREATE: crates/lore-tui/src/instance_lock.rs\n- CREATE: crates/lore-tui/src/text_width.rs\n\n## TDD Anchor\nRED: Write test_measure_cjk_width that asserts measure_display_width(\"Hello\") == 5 and measure_display_width(\"日本語\") == 6.\nGREEN: Implement measure_display_width using unicode-width.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_measure\n\nAdditional tests:\n- test_session_roundtrip: save and load, assert equal\n- test_session_corruption_detected: modify saved file, assert load returns error\n- test_instance_lock_stale_recovery: create lock with dead PID, assert acquire succeeds\n- test_truncate_emoji: truncate string with emoji, assert no split grapheme\n\n## Edge Cases\n- Lock file dir doesn't exist: create it\n- PID reuse: rare but possible — stale lock detection uses PID existence check only\n- Session version migration: old version file should be handled gracefully (reset to defaults)\n- text_width: some terminals render emoji incorrectly — we use standard wcwidth semantics","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T17:03:09.241016Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:23.561244Z","deleted_at":"2026-03-11T18:34:23.561239Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-3hjh","title":"Quality gates: cargo check, clippy, fmt, test","description":"## Background\nFinal verification that all implementation beads integrate cleanly. Must pass all quality gates before the feature is considered complete.\n\n## Approach\nRun all 4 quality gate commands. Fix any issues discovered.\n\n## Commands (in order)\n1. cargo check --all-targets (zero errors)\n2. cargo clippy --all-targets -- -D warnings (pedantic + nursery clean)\n3. cargo fmt --check (formatted)\n4. cargo test (all green, including all 42 new tests)\n\n## Acceptance Criteria\n- [ ] cargo check --all-targets: exit 0\n- [ ] cargo clippy --all-targets -- -D warnings: exit 0\n- [ ] cargo fmt --check: exit 0\n- [ ] cargo test: all pass (0 failures)\n- [ ] All 42 new tests from the plan are present and green\n\n## Known Gotchas from Plan's Trial Run\n- clippy::items_after_test_module: ansi256_from_rgb must be BEFORE #[cfg(test)]\n- clippy::collapsible_if: use let-chain syntax (if x && let ...)\n- clippy::manual_range_contains: use (16..=231).contains(&blue)\n- r##\"...\"## needed for test JSON with hex colors","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-11T06:42:34.364266Z","created_by":"tayloreernisse","updated_at":"2026-02-11T07:21:33.423111Z","closed_at":"2026-02-11T07:21:33.423074Z","close_reason":"Implemented by agent swarm — all quality gates pass (595 tests, 0 failures)","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3hjh","depends_on_id":"bd-1b91","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-3hjh","depends_on_id":"bd-2sr2","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-3hjh","depends_on_id":"bd-2y79","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-3hjh","depends_on_id":"bd-3a4k","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-3hy","title":"[CP1] Test fixtures for mocked GitLab responses","description":"Create mock response files for integration tests using wiremock.\n\n## Fixtures to Create\n\n### tests/fixtures/gitlab_issue.json\nSingle issue with labels:\n- id, iid, project_id, title, description, state\n- author object\n- labels array (string names)\n- timestamps\n- web_url\n\n### tests/fixtures/gitlab_issues_page.json\nArray of issues simulating paginated response:\n- 3-5 issues with varying states\n- Mix of labels\n\n### tests/fixtures/gitlab_discussion.json\nSingle discussion:\n- id (string)\n- individual_note: false\n- notes array with 2+ notes\n- Include one system note\n\n### tests/fixtures/gitlab_discussions_page.json\nArray of discussions:\n- Mix of individual_note true/false\n- Include resolvable/resolved examples\n\n## Edge Cases to Cover\n- Issue with no labels (empty array)\n- Issue with labels_details (ignored in CP1)\n- Discussion with individual_note=true (single note)\n- System notes with system=true\n- Resolvable notes\n\nFiles: tests/fixtures/gitlab_issue.json, gitlab_issues_page.json, gitlab_discussion.json, gitlab_discussions_page.json\nDone when: wiremock handlers can use fixtures for deterministic tests","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T16:59:01.206436Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.991367Z","closed_at":"2026-01-25T17:02:01.991367Z","deleted_at":"2026-01-25T17:02:01.991362Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-3ia","title":"Fetch closes_issues API and populate entity_references","description":"## Background\nGET /projects/:id/merge_requests/:iid/closes_issues returns issues that will close when MR merges. This is the most reliable source for MR→issue relationships. Uses the generic dependent fetch queue (job_type = 'mr_closes_issues').\n\n## Approach\n\n**1. Add API endpoint to GitLab client (src/gitlab/client.rs):**\n```rust\n/// Fetch issues that will be closed when this MR merges.\npub async fn fetch_mr_closes_issues(\n    &self, \n    project_id: i64, \n    iid: i64\n) -> Result<Vec<GitLabIssueRef>>\n```\n\nNew type in src/gitlab/types.rs:\n```rust\n#[derive(Debug, Clone, Deserialize)]\npub struct GitLabIssueRef {\n    pub id: i64,\n    pub iid: i64,\n    pub project_id: i64,\n    pub title: String,\n    pub state: String,\n    pub web_url: String,\n}\n```\n\nURL: `GET /api/v4/projects/{project_id}/merge_requests/{iid}/closes_issues?per_page=100`\n\n**2. Enqueue jobs during MR ingestion:**\nIn orchestrator.rs, after MR upsert:\n```rust\nenqueue_job(conn, project_id, \"merge_request\", iid, local_id, \"mr_closes_issues\", None)?;\n```\n\nThis is always enqueued (not gated by a config flag) because cross-reference data is fundamental to all temporal queries.\n\n**3. Process jobs in drain step:**\nIn the drain dispatcher (from bd-1ep), handle \"mr_closes_issues\" job_type:\n```rust\nlet closes_issues = client.fetch_mr_closes_issues(gitlab_project_id, job.entity_iid).await?;\nfor issue_ref in &closes_issues {\n    let target_id = resolve_issue_local_id(conn, project_id, issue_ref.iid);\n    insert_entity_reference(conn, EntityReference {\n        source_entity_type: \"merge_request\",\n        source_entity_id: job.entity_local_id,\n        target_entity_type: \"issue\",\n        target_entity_id: target_id,            // Some(id) or None for cross-project\n        target_project_path: if target_id.is_none() { Some(resolve_project_path(issue_ref.project_id)) } else { None },\n        target_entity_iid: if target_id.is_none() { Some(issue_ref.iid) } else { None },\n        reference_type: \"closes\",\n        source_method: \"api_closes_issues\",\n        created_at: None,\n    })?;\n}\n```\n\n**4. Insert helper for entity_references:**\nAdd to src/core/references.rs:\n```rust\npub fn insert_entity_reference(conn: &Connection, ref_: &EntityReference) -> Result<bool>\n// INSERT OR IGNORE, returns true if inserted\n```\n\n## Acceptance Criteria\n- [ ] closes_issues API called for all MRs during sync\n- [ ] Entity references created with reference_type='closes', source_method='api_closes_issues'\n- [ ] Source = MR, target = issue (correct directionality)\n- [ ] Cross-project issues stored as unresolved (target_entity_id=NULL, target_project_path set)\n- [ ] Idempotent: re-sync doesn't create duplicate references\n- [ ] 404 on deleted MR handled gracefully (fail_job)\n\n## Files\n- src/gitlab/client.rs (add fetch_mr_closes_issues)\n- src/gitlab/types.rs (add GitLabIssueRef)\n- src/core/references.rs (add insert_entity_reference helper)\n- src/ingestion/orchestrator.rs (enqueue mr_closes_issues jobs)\n- src/core/drain.rs or sync.rs (handle mr_closes_issues in drain dispatcher)\n\n## TDD Loop\nRED: tests/references_tests.rs:\n- `test_closes_issues_creates_references` - mock closes_issues response, verify entity_references rows\n- `test_closes_issues_cross_project_unresolved` - issue from different project stored as unresolved\n- `test_closes_issues_idempotent` - process same job twice, verify no duplicates\n\ntests/gitlab_types_tests.rs:\n- `test_deserialize_issue_ref` - verify GitLabIssueRef deserialization\n\nGREEN: Implement API endpoint, enqueue hook, drain handler, insert helper\n\nVERIFY: `cargo test references -- --nocapture && cargo test gitlab_types -- --nocapture`\n\n## Edge Cases\n- closes_issues API returns issues from OTHER projects (cross-project closing) — must check if issue is in local DB\n- Empty response (MR doesn't close any issues) — no refs created, job still completed\n- MR may close the same issue via description (\"Closes #123\") and via commits — API deduplicates, but our INSERT OR IGNORE handles it too\n- The closes_issues API may return stale data for draft MRs (issues that *would* close but haven't yet)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:32:33.561956Z","created_by":"tayloreernisse","updated_at":"2026-02-04T20:15:54.763773Z","closed_at":"2026-02-04T20:15:54.763643Z","compaction_level":0,"original_size":0,"labels":["api","gate-2","phase-b"],"dependencies":[{"issue_id":"bd-3ia","depends_on_id":"bd-1se","type":"parent-child","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-3ia","depends_on_id":"bd-hu3","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-3ia","depends_on_id":"bd-tir","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-3iod","title":"NOTE-1B: CLI arguments and command wiring for lore notes","description":"## Background\nWire the notes query layer into the CLI. Add NotesArgs clap struct, Notes variant to Commands enum, and handler in main.rs.\n\n## Approach\n1. Add NotesArgs struct to src/cli/mod.rs (after MrsArgs at line 397):\n   #[derive(Debug, clap::Args)]\n   pub struct NotesArgs {\n       --limit/-n (usize, default 50)\n       --fields (Option<String>)\n       --format (table/json/jsonl/csv, default table)\n       --author/-a (Option<String>)\n       --note-type (Option<String>)\n       --contains (Option<String>)\n       --note-id (Option<i64>)\n       --gitlab-note-id (Option<i64>)\n       --discussion-id (Option<String>)\n       --include-system (bool flag)\n       --for-issue (Option<i64>, conflicts_with = \"for_mr\")\n       --for-mr (Option<i64>, conflicts_with = \"for_issue\")\n       --project/-p (Option<String>)\n       --since (Option<String>)\n       --until (Option<String>)\n       --path (Option<String>)\n       --resolution (Option<String>, value_parser = [\"any\", \"unresolved\", \"resolved\"])\n       --sort (String, default \"created\", value_parser = [\"created\", \"updated\"])\n       --asc (bool flag)\n       --open (bool flag)\n   }\n   NOTE: --for-issue/--for-mr do NOT use clap requires=\"project\" — defaultProject handles project resolution at query time.\n\n2. Add Notes(NotesArgs) to Commands enum (line 108-170, after Mrs variant at line 113):\n   /// List notes from discussions\n   Notes(NotesArgs),\n\n3. Add \"notes\" minimal preset to expand_fields_preset() in src/cli/robot.rs (line 28):\n   \"notes\" => [\"id\", \"author_username\", \"body\", \"created_at_iso\"]\n   Add after the existing \"search\" preset (line 39).\n\n4. Add handle_notes() in src/main.rs following the handle_issues/handle_mrs pattern. The dispatch logic should:\n   - Build NoteListFilters from NotesArgs\n   - Call query_notes() from NOTE-1A\n   - Dispatch on format: robot/json → print_list_notes_json, jsonl → print_list_notes_jsonl, csv → print_list_notes_csv, table → print_list_notes\n   (Print functions come from NOTE-1C)\n\n5. Wire command dispatch in main.rs command match:\n   Some(Commands::Notes(args)) => handle_notes(cli.config.as_deref(), args, robot_mode).await\n\n6. Re-export in src/cli/commands/mod.rs (line 32-35 area):\n   Add to pub use list:: { ..., run_list_notes, NoteListFilters, ... }\n\n## Files\n- MODIFY: src/cli/mod.rs (NotesArgs struct + Commands::Notes variant)\n- MODIFY: src/cli/robot.rs (notes minimal preset at expand_fields_preset, line 28)\n- MODIFY: src/main.rs (handle_notes function + command dispatch)\n- MODIFY: src/cli/commands/mod.rs (add note exports to pub use list block, line 32)\n\n## TDD Anchor\nRED: test_expand_fields_preset_notes — assert expand_fields_preset(&[\"minimal\".to_string()], \"notes\") returns [\"id\", \"author_username\", \"body\", \"created_at_iso\"]\nGREEN: Add \"notes\" case to match in expand_fields_preset.\nVERIFY: cargo test expand_fields_preset_notes -- --nocapture\n\n## Acceptance Criteria\n- [ ] lore notes command exists and dispatches to handle_notes\n- [ ] All 19+ CLI flags parsed correctly by clap\n- [ ] --for-issue and --for-mr are mutually exclusive (conflicts_with)\n- [ ] --format supports table, json, jsonl, csv\n- [ ] Robot mode auto-selects JSON output\n- [ ] --fields minimal preset works for notes\n- [ ] \"notes\" added to CLI autocorrect suggestions in src/cli/autocorrect.rs\n- [ ] Test passes\n\n## Dependency Context\n- Depends on NOTE-1A (bd-20p9): uses NoteListFilters, query_notes, NoteListResult\n\n## Edge Cases\n- Robot mode without --format: auto-selects JSON (same pattern as issues/mrs)\n- Unknown --format value: clap rejects before handler runs\n- --for-issue without --project: handled at query layer (NOTE-1A), not clap","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T17:00:39.671524Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:13:24.227114Z","closed_at":"2026-02-12T18:13:24.227067Z","close_reason":"Implemented by agent swarm","compaction_level":0,"original_size":0,"labels":["cli","per-note","search"],"dependencies":[{"issue_id":"bd-3iod","depends_on_id":"bd-1oyf","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-3ir","title":"Add database migration 006_merge_requests.sql","description":"## Background\nFoundation for all CP2 MR features. This migration defines the schema that all other MR components depend on. Must complete BEFORE any other CP2 work can proceed.\n\n## Approach\nCreate migration file that adds:\n1. `merge_requests` table with all CP2 fields\n2. `mr_labels`, `mr_assignees`, `mr_reviewers` junction tables\n3. Indexes on discussions for MR queries\n4. DiffNote position columns on notes table\n\n## Files\n- `migrations/006_merge_requests.sql` - New migration file\n- `src/core/db.rs` - Update MIGRATIONS const to include version 6\n\n## Acceptance Criteria\n- [ ] Migration file exists at `migrations/006_merge_requests.sql`\n- [ ] `merge_requests` table has columns: id, gitlab_id, project_id, iid, title, description, state, draft, author_username, source_branch, target_branch, head_sha, references_short, references_full, detailed_merge_status, merge_user_username, created_at, updated_at, merged_at, closed_at, last_seen_at, discussions_synced_for_updated_at, discussions_sync_last_attempt_at, discussions_sync_attempts, discussions_sync_last_error, web_url, raw_payload_id\n- [ ] `mr_labels` junction table exists with (merge_request_id, label_id) PK\n- [ ] `mr_assignees` junction table exists with (merge_request_id, username) PK\n- [ ] `mr_reviewers` junction table exists with (merge_request_id, username) PK\n- [ ] `idx_discussions_mr_id` and `idx_discussions_mr_resolved` indexes exist\n- [ ] `notes` table has new columns: position_type, position_line_range_start, position_line_range_end, position_base_sha, position_start_sha, position_head_sha\n- [ ] `gi doctor` runs without migration errors\n- [ ] `cargo test` passes\n\n## TDD Loop\nRED: Cannot open DB with version 6 schema\nGREEN: Add migration file with full SQL\nVERIFY: `cargo run -- doctor` shows healthy DB\n\n## SQL Reference (from PRD)\n```sql\n-- Merge requests table\nCREATE TABLE merge_requests (\n  id INTEGER PRIMARY KEY,\n  gitlab_id INTEGER UNIQUE NOT NULL,\n  project_id INTEGER NOT NULL REFERENCES projects(id),\n  iid INTEGER NOT NULL,\n  title TEXT,\n  description TEXT,\n  state TEXT,                         -- opened | merged | closed | locked\n  draft INTEGER NOT NULL DEFAULT 0,   -- SQLite boolean\n  author_username TEXT,\n  source_branch TEXT,\n  target_branch TEXT,\n  head_sha TEXT,\n  references_short TEXT,\n  references_full TEXT,\n  detailed_merge_status TEXT,\n  merge_user_username TEXT,\n  created_at INTEGER,                 -- ms epoch UTC\n  updated_at INTEGER,\n  merged_at INTEGER,\n  closed_at INTEGER,\n  last_seen_at INTEGER NOT NULL,\n  discussions_synced_for_updated_at INTEGER,\n  discussions_sync_last_attempt_at INTEGER,\n  discussions_sync_attempts INTEGER DEFAULT 0,\n  discussions_sync_last_error TEXT,\n  web_url TEXT,\n  raw_payload_id INTEGER REFERENCES raw_payloads(id)\n);\nCREATE INDEX idx_mrs_project_updated ON merge_requests(project_id, updated_at);\nCREATE UNIQUE INDEX uq_mrs_project_iid ON merge_requests(project_id, iid);\n-- ... (see PRD for full index list)\n\n-- Junction tables\nCREATE TABLE mr_labels (\n  merge_request_id INTEGER REFERENCES merge_requests(id) ON DELETE CASCADE,\n  label_id INTEGER REFERENCES labels(id) ON DELETE CASCADE,\n  PRIMARY KEY(merge_request_id, label_id)\n);\n\nCREATE TABLE mr_assignees (\n  merge_request_id INTEGER REFERENCES merge_requests(id) ON DELETE CASCADE,\n  username TEXT NOT NULL,\n  PRIMARY KEY(merge_request_id, username)\n);\n\nCREATE TABLE mr_reviewers (\n  merge_request_id INTEGER REFERENCES merge_requests(id) ON DELETE CASCADE,\n  username TEXT NOT NULL,\n  PRIMARY KEY(merge_request_id, username)\n);\n\n-- DiffNote position columns (ALTER TABLE)\nALTER TABLE notes ADD COLUMN position_type TEXT;\nALTER TABLE notes ADD COLUMN position_line_range_start INTEGER;\nALTER TABLE notes ADD COLUMN position_line_range_end INTEGER;\nALTER TABLE notes ADD COLUMN position_base_sha TEXT;\nALTER TABLE notes ADD COLUMN position_start_sha TEXT;\nALTER TABLE notes ADD COLUMN position_head_sha TEXT;\n\nINSERT INTO schema_version (version, applied_at, description)\nVALUES (6, strftime('%s', 'now') * 1000, 'Merge requests, MR labels, assignees, reviewers');\n```\n\n## Edge Cases\n- SQLite does not support ADD CONSTRAINT - FK defined as nullable in CP1\n- `locked` state is transitional (merge-in-progress) - store as first-class\n- discussions_synced_for_updated_at prevents redundant discussion refetch","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-26T22:06:40.101470Z","created_by":"tayloreernisse","updated_at":"2026-01-27T00:06:43.899079Z","closed_at":"2026-01-27T00:06:43.898875Z","close_reason":"Migration 006_merge_requests.sql created and verified. Schema v6 applied successfully with all tables, indexes, and position columns.","compaction_level":0,"original_size":0}
{"id":"bd-3ir1","title":"Implement terminal safety module (sanitize + URL policy + redact)","description":"## Background\nGitLab content (issue descriptions, comments, MR descriptions) can contain arbitrary text including ANSI escape sequences, bidirectional text overrides, OSC hyperlinks, and C1 control codes. Displaying unsanitized content in a terminal can hijack cursor position, inject fake UI elements, or cause rendering corruption. This module provides a sanitization layer that strips dangerous sequences while preserving a safe ANSI subset for readability.\n\n## Approach\nCreate `crates/lore-tui/src/safety.rs` with:\n- `sanitize_for_terminal(input: &str) -> String` — the main entry point\n- Strip C1 control codes (0x80-0x9F)\n- Strip OSC sequences (ESC ] ... ST)\n- Strip cursor movement (CSI <n> A/B/C/D/E/F/G/H/J/K)\n- Strip bidi overrides (U+202A-U+202E, U+2066-U+2069)\n- **PRESERVE safe ANSI subset**: SGR sequences for bold (1), italic (3), underline (4), reset (0), and standard foreground/background colors (30-37, 40-47, 90-97, 100-107). These improve readability of formatted GitLab content.\n- `UrlPolicy` enum: `Strip`, `Footnote`, `Passthrough` — controls how OSC 8 hyperlinks are handled\n- `RedactPattern` for optional PII/secret redaction (email, token patterns)\n- All functions are pure (no I/O), fully testable\n\nReference existing terminal safety patterns in ftui-core if available.\n\n## Acceptance Criteria\n- [ ] sanitize_for_terminal strips C1, OSC, cursor movement, bidi overrides\n- [ ] sanitize_for_terminal preserves bold, italic, underline, reset, and standard color SGR sequences\n- [ ] UrlPolicy::Strip removes OSC 8 hyperlinks entirely\n- [ ] UrlPolicy::Footnote converts OSC 8 hyperlinks to numbered footnotes [1] with URL list at end\n- [ ] RedactPattern matches common secret patterns (tokens, emails) and replaces with [REDACTED]\n- [ ] No unsafe code\n- [ ] Unit tests cover each dangerous sequence type AND verify safe sequences are preserved\n- [ ] Fuzz test with 1000 random byte sequences: no panic\n\n## Files\n- CREATE: crates/lore-tui/src/safety.rs\n- MODIFY: crates/lore-tui/src/lib.rs (add pub mod safety)\n\n## TDD Anchor\nRED: Write `test_strips_cursor_movement` that asserts CSI sequences for cursor up/down/left/right are removed from input while bold SGR is preserved.\nGREEN: Implement the sanitizer state machine that categorizes and filters escape sequences.\nVERIFY: cargo test -p lore-tui safety -- --nocapture\n\nAdditional tests:\n- test_strips_c1_control_codes\n- test_strips_bidi_overrides\n- test_strips_osc_sequences\n- test_preserves_bold_italic_underline_reset\n- test_preserves_standard_colors\n- test_url_policy_strip\n- test_url_policy_footnote\n- test_redact_patterns\n- test_fuzz_no_panic\n\n## Edge Cases\n- Malformed/truncated escape sequences (ESC without closing) — must not consume following text\n- Nested SGR sequences (e.g., bold+color combined in single CSI) — preserve entire sequence if all parameters are safe\n- UTF-8 multibyte chars adjacent to escape sequences — must not corrupt char boundaries\n- Empty input returns empty string\n- Input with only safe content passes through unchanged\n\n## Dependency Context\nDepends on bd-3ddw (scaffold) for the crate structure to exist. No other dependencies — this is a pure utility module.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T16:54:30.165761Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:23.635926Z","deleted_at":"2026-03-11T18:34:23.635920Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-3j6","title":"Add transform_mr_discussion and transform_notes_with_diff_position","description":"## Background\nExtends discussion transformer for MR context. MR discussions can contain DiffNotes with file position metadata. This is critical for code review context in CP3 document generation.\n\n## Approach\nAdd two new functions to existing `src/gitlab/transformers/discussion.rs`:\n1. `transform_mr_discussion()` - Transform discussion with MR reference\n2. `transform_notes_with_diff_position()` - Extract DiffNote position metadata\n\nCP1 already has the polymorphic `NormalizedDiscussion` with `NoteableRef` enum - reuse that pattern.\n\n## Files\n- `src/gitlab/transformers/discussion.rs` - Add new functions\n- `tests/diffnote_tests.rs` - DiffNote position extraction tests\n- `tests/mr_discussion_tests.rs` - MR discussion transform tests\n\n## Acceptance Criteria\n- [ ] `transform_mr_discussion()` returns `NormalizedDiscussion` with `merge_request_id: Some(local_mr_id)`\n- [ ] `transform_notes_with_diff_position()` returns `Result<Vec<NormalizedNote>, String>`\n- [ ] DiffNote position fields extracted: `position_old_path`, `position_new_path`, `position_old_line`, `position_new_line`\n- [ ] Extended position fields extracted: `position_type`, `position_line_range_start`, `position_line_range_end`\n- [ ] SHA triplet extracted: `position_base_sha`, `position_start_sha`, `position_head_sha`\n- [ ] Strict timestamp parsing - returns `Err` on invalid timestamps (no `unwrap_or(0)`)\n- [ ] `cargo test diffnote` passes\n- [ ] `cargo test mr_discussion` passes\n\n## TDD Loop\nRED: `cargo test diffnote_position` -> test fails\nGREEN: Add position extraction logic\nVERIFY: `cargo test diffnote`\n\n## Function Signatures\n```rust\n/// Transform GitLab discussion for MR context.\n/// Reuses existing transform_discussion logic, just with MR reference.\npub fn transform_mr_discussion(\n    gitlab_discussion: &GitLabDiscussion,\n    local_project_id: i64,\n    local_mr_id: i64,\n) -> NormalizedDiscussion {\n    // Use existing transform_discussion with NoteableRef::MergeRequest(local_mr_id)\n    transform_discussion(\n        gitlab_discussion,\n        local_project_id,\n        NoteableRef::MergeRequest(local_mr_id),\n    )\n}\n\n/// Transform notes with DiffNote position extraction.\n/// Returns Result to enforce strict timestamp parsing.\npub fn transform_notes_with_diff_position(\n    gitlab_discussion: &GitLabDiscussion,\n    local_project_id: i64,\n) -> Result<Vec<NormalizedNote>, String>\n```\n\n## DiffNote Position Extraction\n```rust\n// Extract position metadata if present\nlet (old_path, new_path, old_line, new_line, position_type, lr_start, lr_end, base_sha, start_sha, head_sha) = note\n    .position\n    .as_ref()\n    .map(|pos| (\n        pos.old_path.clone(),\n        pos.new_path.clone(),\n        pos.old_line,\n        pos.new_line,\n        pos.position_type.clone(),                    // \"text\" | \"image\" | \"file\"\n        pos.line_range.as_ref().map(|r| r.start_line),\n        pos.line_range.as_ref().map(|r| r.end_line),\n        pos.base_sha.clone(),\n        pos.start_sha.clone(),\n        pos.head_sha.clone(),\n    ))\n    .unwrap_or((None, None, None, None, None, None, None, None, None, None));\n```\n\n## Strict Timestamp Parsing\n```rust\n// CRITICAL: Return error on invalid timestamps, never zero\nlet created_at = iso_to_ms(&note.created_at)\n    .ok_or_else(|| format\\!(\n        \"Invalid note.created_at for note {}: {}\",\n        note.id, note.created_at\n    ))?;\n```\n\n## NormalizedNote Fields for DiffNotes\n```rust\nNormalizedNote {\n    // ... existing fields ...\n    // DiffNote position metadata\n    position_old_path: old_path,\n    position_new_path: new_path,\n    position_old_line: old_line,\n    position_new_line: new_line,\n    // Extended position\n    position_type,\n    position_line_range_start: lr_start,\n    position_line_range_end: lr_end,\n    // SHA triplet\n    position_base_sha: base_sha,\n    position_start_sha: start_sha,\n    position_head_sha: head_sha,\n}\n```\n\n## Edge Cases\n- Notes without position should have all position fields as None\n- Invalid timestamp should fail the entire discussion (no partial results)\n- File renames: `old_path \\!= new_path` indicates a renamed file\n- Multi-line comments: `line_range` present means comment spans lines 45-48","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-26T22:06:41.208380Z","created_by":"tayloreernisse","updated_at":"2026-01-27T00:20:13.473091Z","closed_at":"2026-01-27T00:20:13.473031Z","close_reason":"Implemented transform_mr_discussion() and transform_notes_with_diff_position() with full DiffNote position extraction:\n- Extended NormalizedNote with 10 DiffNote position fields (path, line, type, line_range, SHA triplet)\n- Added strict timestamp parsing that returns Err on invalid timestamps\n- Created 13 diffnote_position_tests covering all extraction paths and error cases\n- Created 6 mr_discussion_tests verifying MR reference handling\n- All 161 tests passing","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3j6","depends_on_id":"bd-3ir","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-3j6","depends_on_id":"bd-5ta","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-3jiq","title":"Implement --fields minimal preset for me robot mode","description":"## Background\n`--fields` already exists in lore robot mode and `minimal` presets are entity-specific in `src/cli/robot.rs`. `lore me` needs equivalent behavior without breaking existing commands.\n\nThis bead defines how one requested field set is projected across four me arrays (`open_issues`, `open_mrs_authored`, `reviewing_mrs`, `activity`) while preserving non-array data (`username`, `since_iso`, `summary`, `meta`).\n\n## Approach\n### 1. Extend preset expansion in shared robot utility\nIn `src/cli/robot.rs`, add entity keys to `expand_fields_preset`:\n- `me_work_items` => `[\"iid\", \"title\", \"attention_state\", \"updated_at_iso\"]`\n- `me_activity` => `[\"timestamp_iso\", \"event_type\", \"entity_iid\", \"actor\"]`\n\n### 2. Apply filtering in me robot renderer path\nAfter full envelope is built (from `bd-2ilv`), apply filtering only to arrays:\n- `open_issues` uses expanded fields from `me_work_items`.\n- `open_mrs_authored` uses expanded fields from `me_work_items`.\n- `reviewing_mrs` uses expanded fields from `me_work_items`.\n- `activity` uses expanded fields from `me_activity`.\n\nFor non-`minimal` custom lists, pass through requested fields unchanged for all four arrays.\n\n### 3. Keep top-level and summary stable\nNever filter:\n- `data.username`\n- `data.since_iso`\n- `data.summary`\n- `meta`\n\n## Acceptance Criteria\n- [ ] `--fields minimal` projects work-item arrays to exactly: `iid`, `title`, `attention_state`, `updated_at_iso`\n- [ ] `--fields minimal` projects activity array to exactly: `timestamp_iso`, `event_type`, `entity_iid`, `actor`\n- [ ] `summary`, `username`, and `since_iso` remain present under `--fields minimal`\n- [ ] No `--fields` flag returns full unfiltered payload\n- [ ] Custom lists (e.g. `--fields iid,title,labels`) are applied to all 4 arrays\n- [ ] Unknown fields do not error; they yield empty objects where no keys match (existing behavior)\n- [ ] Existing non-me presets in `expand_fields_preset` remain unchanged\n\n## Files\n- MODIFY: `src/cli/robot.rs`\n- MODIFY: `src/cli/commands/me/render_robot.rs`\n- MODIFY: `src/cli/commands/me/mod.rs` (pass through `args.fields`)\n\n## TDD Anchor\nRED:\n- `test_me_fields_minimal_projects_work_item_arrays`\n- `test_me_fields_minimal_projects_activity_array`\n- `test_me_fields_minimal_preserves_summary_and_identity`\n- `test_me_fields_custom_list_passthrough`\n- `test_me_fields_unknown_names_do_not_error`\n\nGREEN:\n- Add me presets and wire array-level filtering in me renderer.\n\nVERIFY:\n- `cargo test fields_minimal`\n- `cargo test me_robot_fields`\n\n## Edge Cases\n- Applying custom fields that only exist on some arrays (e.g. `draft`) should keep matching keys where present and produce empty objects elsewhere.\n- Empty arrays should remain `[]` after filtering.\n\n## Dependency Context\nBuilds on `bd-2ilv` full me envelope and shared filtering helpers in `src/cli/robot.rs`.\nProvides final robot token-shaping behavior consumed by `bd-1vv8` command handler.\n\nDependencies:\n  -> bd-2ilv (blocks) - Implement robot JSON output for me command\n\nDependents:\n  <- bd-1vv8 (blocks) - Implement me command handler: wire queries to renderers","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-19T19:40:21.642386Z","created_by":"tayloreernisse","updated_at":"2026-02-20T16:09:13.064705Z","closed_at":"2026-02-20T16:09:13.064666Z","close_reason":"Implemented by lore-me agent swarm","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3jiq","depends_on_id":"bd-2ilv","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-3jqx","title":"Implement async integration tests: cancellation, timeout, embed isolation, payload integrity","description":"## Background\n\nThe surgical sync pipeline involves async operations, cancellation signals, timeouts, scoped embedding, and multi-entity coordination. Unit tests in individual beads cover their own logic, but integration tests are needed to verify the full pipeline under realistic conditions: cancellation at different stages, timeout behavior with continuation, embedding scope isolation (only affected documents get embedded), and payload integrity (project_id mismatches rejected). These tests use wiremock for HTTP mocking and tokio for async runtime.\n\n## Approach\n\nCreate `tests/surgical_integration.rs` as an integration test file (Rust convention: `tests/` directory for integration tests). Six test functions covering the critical behavioral properties of the surgical pipeline:\n\n1. **Cancellation before preflight**: Signal cancelled before any HTTP call. Verify: recorder marked failed, no GitLab requests made, result has zero updates.\n2. **Cancellation during dependent stage**: Signal cancelled after preflight succeeds but during discussion fetch. Verify: partial results recorded, recorder marked failed, entities processed before cancellation have outcomes.\n3. **Per-entity timeout with continuation**: One entity's GitLab endpoint is slow (wiremock delay). Verify: that entity gets `failed` outcome with timeout error, remaining entities continue and succeed.\n4. **Embed scope isolation**: Sync two issues. Verify: only documents generated from those two issues are embedded, not the entire corpus. Assert by checking document IDs passed to embed function.\n5. **Payload project_id mismatch rejection**: Preflight returns an issue with `project_id` different from the resolved project. Verify: that entity gets `failed` outcome with clear error, other entities unaffected.\n6. **Successful full pipeline**: Sync one issue end-to-end through all stages. Verify: SyncResult has correct counts, entity_results has `synced` outcome, documents regenerated, embeddings created.\n\nAll tests use in-memory SQLite (`create_connection(Path::new(\":memory:\"))` + `run_migrations`) and wiremock `MockServer`.\n\n## Acceptance Criteria\n\n1. All 6 tests compile and pass\n2. Tests are isolated (each creates its own DB and mock server)\n3. Cancellation tests verify recorder state (failed status in sync_runs table)\n4. Timeout test uses wiremock delay, not `tokio::time::sleep` on the test side\n5. Embed isolation test verifies document-level scoping, not just function call\n6. Tests run in CI without flakiness (no real network, no real Ollama)\n\n## Files\n\n- `tests/surgical_integration.rs` — all 6 integration tests\n\n## TDD Anchor\n\n```rust\n// tests/surgical_integration.rs\n\nuse lore::cli::commands::sync::{SyncOptions, SyncResult};\nuse lore::core::db::{create_connection, run_migrations};\nuse lore::core::shutdown::ShutdownSignal;\nuse lore::Config;\nuse std::path::Path;\nuse std::time::Duration;\nuse wiremock::{Mock, MockServer, ResponseTemplate};\nuse wiremock::matchers::{method, path_regex};\n\nfn test_config(mock_url: &str) -> Config {\n    let mut config = Config::default();\n    config.gitlab.url = mock_url.to_string();\n    config.gitlab.token = \"test-token\".to_string();\n    config\n}\n\nfn setup_db() -> rusqlite::Connection {\n    let conn = create_connection(Path::new(\":memory:\")).unwrap();\n    run_migrations(&conn).unwrap();\n    conn.execute(\n        \"INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url)\n         VALUES (1, 'group/project', 'https://gitlab.example.com/group/project')\",\n        [],\n    ).unwrap();\n    conn\n}\n\nfn mock_issue_json(iid: u64) -> serde_json::Value {\n    serde_json::json!({\n        \"id\": 100 + iid, \"iid\": iid, \"project_id\": 1, \"title\": format!(\"Issue {}\", iid),\n        \"state\": \"opened\", \"created_at\": \"2026-01-01T00:00:00Z\",\n        \"updated_at\": \"2026-02-17T00:00:00Z\",\n        \"author\": {\"id\": 1, \"username\": \"dev\", \"name\": \"Dev\"},\n        \"web_url\": format!(\"https://gitlab.example.com/group/project/-/issues/{}\", iid)\n    })\n}\n\n#[tokio::test]\nasync fn cancellation_before_preflight() {\n    let server = MockServer::start().await;\n    // No mocks mounted — if any request is made, wiremock will return 404\n    let config = test_config(&server.uri());\n    let options = SyncOptions {\n        issue_iids: vec![7],\n        project: Some(\"group/project\".to_string()),\n        robot_mode: true,\n        ..SyncOptions::default()\n    };\n    let signal = ShutdownSignal::new();\n    signal.cancel(); // Cancel before anything starts\n\n    let result = lore::cli::commands::sync_surgical::run_sync_surgical(\n        &config, options, Some(\"cancel-pre\"), &signal,\n    ).await.unwrap();\n\n    assert_eq!(result.issues_updated, 0);\n    assert_eq!(result.mrs_updated, 0);\n    // Verify no HTTP requests were made\n    assert_eq!(server.received_requests().await.unwrap().len(), 0);\n}\n\n#[tokio::test]\nasync fn cancellation_during_dependent_stage() {\n    let server = MockServer::start().await;\n    // Mock issue fetch (preflight succeeds)\n    Mock::given(method(\"GET\"))\n        .and(path_regex(r\"/api/v4/projects/1/issues\"))\n        .respond_with(ResponseTemplate::new(200)\n            .set_body_json(serde_json::json!([mock_issue_json(7)])))\n        .mount(&server).await;\n    // Mock discussion fetch with delay (gives time to cancel)\n    Mock::given(method(\"GET\"))\n        .and(path_regex(r\"/api/v4/projects/1/issues/7/discussions\"))\n        .respond_with(ResponseTemplate::new(200)\n            .set_body_json(serde_json::json!([]))\n            .set_body_delay(Duration::from_secs(2)))\n        .mount(&server).await;\n\n    let config = test_config(&server.uri());\n    let options = SyncOptions {\n        issue_iids: vec![7],\n        project: Some(\"group/project\".to_string()),\n        robot_mode: true,\n        ..SyncOptions::default()\n    };\n    let signal = ShutdownSignal::new();\n\n    // Cancel after a short delay (after preflight, during dependents)\n    let signal_clone = signal.clone();\n    tokio::spawn(async move {\n        tokio::time::sleep(Duration::from_millis(200)).await;\n        signal_clone.cancel();\n    });\n\n    let result = lore::cli::commands::sync_surgical::run_sync_surgical(\n        &config, options, Some(\"cancel-dep\"), &signal,\n    ).await.unwrap();\n\n    // Preflight should have run, but ingest may be partial\n    assert!(result.surgical_mode == Some(true));\n}\n\n#[tokio::test]\nasync fn per_entity_timeout_with_continuation() {\n    let server = MockServer::start().await;\n    // Issue 7: slow response (simulates timeout)\n    Mock::given(method(\"GET\"))\n        .and(path_regex(r\"/api/v4/projects/1/issues\\?.*iids\\[\\]=7\"))\n        .respond_with(ResponseTemplate::new(200)\n            .set_body_json(serde_json::json!([mock_issue_json(7)]))\n            .set_body_delay(Duration::from_secs(30)))\n        .mount(&server).await;\n    // Issue 42: fast response\n    Mock::given(method(\"GET\"))\n        .and(path_regex(r\"/api/v4/projects/1/issues\\?.*iids\\[\\]=42\"))\n        .respond_with(ResponseTemplate::new(200)\n            .set_body_json(serde_json::json!([mock_issue_json(42)])))\n        .mount(&server).await;\n\n    let config = test_config(&server.uri());\n    let options = SyncOptions {\n        issue_iids: vec![7, 42],\n        project: Some(\"group/project\".to_string()),\n        robot_mode: true,\n        ..SyncOptions::default()\n    };\n    let signal = ShutdownSignal::new();\n\n    // With a per-entity timeout, issue 7 should fail, issue 42 should succeed\n    let result = lore::cli::commands::sync_surgical::run_sync_surgical(\n        &config, options, Some(\"timeout-test\"), &signal,\n    ).await.unwrap();\n\n    let entities = result.entity_results.as_ref().unwrap();\n    // One should be failed (timeout), one should be synced\n    let failed = entities.iter().filter(|e| e.outcome == \"failed\").count();\n    let synced = entities.iter().filter(|e| e.outcome == \"synced\").count();\n    assert!(failed >= 1 || synced >= 1, \"Expected mixed outcomes\");\n}\n\n#[tokio::test]\nasync fn embed_scope_isolation() {\n    let server = MockServer::start().await;\n    // Mock two issues\n    Mock::given(method(\"GET\"))\n        .and(path_regex(r\"/api/v4/projects/1/issues\"))\n        .respond_with(ResponseTemplate::new(200)\n            .set_body_json(serde_json::json!([\n                mock_issue_json(7), mock_issue_json(42)\n            ])))\n        .mount(&server).await;\n    // Mock empty discussions for both\n    Mock::given(method(\"GET\"))\n        .and(path_regex(r\"/api/v4/projects/1/issues/\\d+/discussions\"))\n        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))\n        .mount(&server).await;\n\n    let config = test_config(&server.uri());\n    let options = SyncOptions {\n        issue_iids: vec![7, 42],\n        project: Some(\"group/project\".to_string()),\n        robot_mode: true,\n        no_embed: false,\n        ..SyncOptions::default()\n    };\n    let signal = ShutdownSignal::new();\n\n    let result = lore::cli::commands::sync_surgical::run_sync_surgical(\n        &config, options, Some(\"embed-iso\"), &signal,\n    ).await.unwrap();\n\n    // Embedding should only have processed documents from issues 7 and 42\n    // Not the full corpus. Verify via document counts.\n    assert!(result.documents_embedded <= 2,\n        \"Expected at most 2 documents embedded (one per issue), got {}\",\n        result.documents_embedded);\n}\n\n#[tokio::test]\nasync fn payload_project_id_mismatch_rejection() {\n    let server = MockServer::start().await;\n    // Return issue with project_id=999 (doesn't match resolved project_id=1)\n    Mock::given(method(\"GET\"))\n        .and(path_regex(r\"/api/v4/projects/1/issues\"))\n        .respond_with(ResponseTemplate::new(200)\n            .set_body_json(serde_json::json!([{\n                \"id\": 200, \"iid\": 7, \"project_id\": 999, \"title\": \"Wrong Project\",\n                \"state\": \"opened\", \"created_at\": \"2026-01-01T00:00:00Z\",\n                \"updated_at\": \"2026-02-17T00:00:00Z\",\n                \"author\": {\"id\": 1, \"username\": \"dev\", \"name\": \"Dev\"},\n                \"web_url\": \"https://gitlab.example.com/other/project/-/issues/7\"\n            }])))\n        .mount(&server).await;\n\n    let config = test_config(&server.uri());\n    let options = SyncOptions {\n        issue_iids: vec![7],\n        project: Some(\"group/project\".to_string()),\n        robot_mode: true,\n        ..SyncOptions::default()\n    };\n    let signal = ShutdownSignal::new();\n\n    let result = lore::cli::commands::sync_surgical::run_sync_surgical(\n        &config, options, Some(\"mismatch\"), &signal,\n    ).await.unwrap();\n\n    let entities = result.entity_results.as_ref().unwrap();\n    assert_eq!(entities.len(), 1);\n    assert_eq!(entities[0].outcome, \"failed\");\n    assert!(entities[0].error.as_ref().unwrap().contains(\"project_id\"));\n}\n\n#[tokio::test]\nasync fn successful_full_pipeline() {\n    let server = MockServer::start().await;\n    Mock::given(method(\"GET\"))\n        .and(path_regex(r\"/api/v4/projects/1/issues\"))\n        .respond_with(ResponseTemplate::new(200)\n            .set_body_json(serde_json::json!([mock_issue_json(7)])))\n        .mount(&server).await;\n    Mock::given(method(\"GET\"))\n        .and(path_regex(r\"/api/v4/projects/1/issues/7/discussions\"))\n        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))\n        .mount(&server).await;\n    // Mock any resource event endpoints\n    Mock::given(method(\"GET\"))\n        .and(path_regex(r\"/api/v4/projects/1/issues/7/resource_\"))\n        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))\n        .mount(&server).await;\n\n    let config = test_config(&server.uri());\n    let options = SyncOptions {\n        issue_iids: vec![7],\n        project: Some(\"group/project\".to_string()),\n        robot_mode: true,\n        no_embed: true, // Skip embed to avoid Ollama dependency\n        ..SyncOptions::default()\n    };\n    let signal = ShutdownSignal::new();\n\n    let result = lore::cli::commands::sync_surgical::run_sync_surgical(\n        &config, options, Some(\"full-pipe\"), &signal,\n    ).await.unwrap();\n\n    assert_eq!(result.surgical_mode, Some(true));\n    assert_eq!(result.surgical_iids.as_ref().unwrap().issues, vec![7]);\n    assert_eq!(result.preflight_only, Some(false));\n\n    let entities = result.entity_results.as_ref().unwrap();\n    assert_eq!(entities.len(), 1);\n    assert_eq!(entities[0].entity_type, \"issue\");\n    assert_eq!(entities[0].iid, 7);\n    assert_eq!(entities[0].outcome, \"synced\");\n    assert!(entities[0].error.is_none());\n\n    assert!(result.issues_updated >= 1);\n    assert!(result.documents_regenerated >= 1);\n}\n```\n\n## Edge Cases\n\n- **Wiremock delay vs tokio timeout**: Use `set_body_delay` on wiremock, not `tokio::time::sleep` in tests. The per-entity timeout in the orchestrator (bd-1i4i) should use `tokio::time::timeout` around the HTTP call.\n- **Embed isolation without Ollama**: Tests that verify embed scoping should either mock Ollama or use `no_embed: true` and verify the document ID list passed to the embed function. The `successful_full_pipeline` test uses `no_embed: true` to avoid requiring a running Ollama server in CI.\n- **Test isolation**: Each test creates its own `MockServer`, in-memory DB, and `ShutdownSignal`. No shared state between tests.\n- **Flakiness prevention**: Cancellation timing tests (test 2) use deterministic delays (cancel after 200ms, response delayed 2s). If flaky, increase the gap between cancel time and response delay.\n- **CI compatibility**: No real GitLab, no real Ollama, no real filesystem locks (in-memory DB means AppLock may need adaptation for tests — consider a test-only lock bypass or use a temp file DB for lock tests).\n\n## Dependency Context\n\n- **Depends on (upstream)**: bd-1i4i (the `run_sync_surgical` function under test), bd-wcja (SyncResult surgical fields to assert), bd-1lja (SyncOptions extensions), bd-3sez (surgical ingest for TOCTOU test), bd-arka (SyncRunRecorder for recorder state assertions), bd-1elx (scoped embed for isolation test), bd-kanh (per-entity helpers)\n- **No downstream dependents** — this is a terminal test-only bead.\n- These tests validate the behavioral contracts that all upstream beads promise. They are the acceptance gate for the surgical sync feature.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-17T19:18:46.182356Z","created_by":"tayloreernisse","updated_at":"2026-02-18T19:24:23.804017Z","compaction_level":0,"original_size":0,"labels":["surgical-sync"]}
{"id":"bd-3js","title":"Implement MR CLI commands (list, show, count)","description":"## Background\nCLI commands for viewing and filtering merge requests. Includes list, show, and count commands with MR-specific filters.\n\n## Approach\nUpdate existing CLI command files:\n1. `list.rs` - Add MR listing with filters\n2. `show.rs` - Add MR detail view with discussions\n3. `count.rs` - Add MR counting with state breakdown\n\n## Files\n- `src/cli/commands/list.rs` - Add MR subcommand\n- `src/cli/commands/show.rs` - Add MR detail view\n- `src/cli/commands/count.rs` - Add MR counting\n\n## Acceptance Criteria\n- [ ] `gi list mrs` shows MR table with iid, title, state, author, branches\n- [ ] `gi list mrs --state=merged` filters by state\n- [ ] `gi list mrs --state=locked` filters locally (not server-side)\n- [ ] `gi list mrs --draft` shows only draft MRs\n- [ ] `gi list mrs --no-draft` excludes draft MRs\n- [ ] `gi list mrs --reviewer=username` filters by reviewer\n- [ ] `gi list mrs --target-branch=main` filters by target branch\n- [ ] `gi list mrs --source-branch=feature/x` filters by source branch\n- [ ] Draft MRs show `[DRAFT]` prefix in title\n- [ ] `gi show mr <iid>` displays full detail including discussions\n- [ ] DiffNote shows file context: `[src/file.ts:45]`\n- [ ] Multi-line DiffNote shows: `[src/file.ts:45-48]`\n- [ ] `gi show mr` shows `detailed_merge_status`\n- [ ] `gi count mrs` shows total with state breakdown\n- [ ] `gi sync-status` shows MR cursor positions\n- [ ] `cargo test cli_commands` passes\n\n## TDD Loop\nRED: `cargo test list_mrs` -> command not found\nGREEN: Add MR subcommand\nVERIFY: `gi list mrs --help`\n\n## gi list mrs Output\n```\nMerge Requests (showing 20 of 1,234)\n\n  !847  Refactor auth to use JWT tokens           merged   @johndoe   main <- feature/jwt   3 days ago\n  !846  Fix memory leak in websocket handler      opened   @janedoe   main <- fix/websocket 5 days ago\n  !845  [DRAFT] Add dark mode CSS variables       opened   @bobsmith  main <- ui/dark-mode  1 week ago\n```\n\n## SQL for MR Listing\n```sql\nSELECT \n    m.iid, m.title, m.state, m.draft, m.author_username,\n    m.target_branch, m.source_branch, m.updated_at\nFROM merge_requests m\nWHERE m.project_id = ?\n  AND (? IS NULL OR m.state = ?)           -- state filter\n  AND (? IS NULL OR m.draft = ?)           -- draft filter\n  AND (? IS NULL OR m.author_username = ?) -- author filter\n  AND (? IS NULL OR m.target_branch = ?)   -- target-branch filter\n  AND (? IS NULL OR m.source_branch = ?)   -- source-branch filter\n  AND (? IS NULL OR EXISTS (               -- reviewer filter\n    SELECT 1 FROM mr_reviewers r \n    WHERE r.merge_request_id = m.id AND r.username = ?\n  ))\nORDER BY m.updated_at DESC\nLIMIT ?\n```\n\n## gi show mr Output\n```\nMerge Request !847: Refactor auth to use JWT tokens\n================================================================================\n\nProject:        group/project-one\nState:          merged\nDraft:          No\nAuthor:         @johndoe\nAssignees:      @janedoe, @bobsmith\nReviewers:      @alice, @charlie\nSource:         feature/jwt\nTarget:         main\nMerge Status:   mergeable\nMerged By:      @alice\nMerged At:      2024-03-20 14:30:00\nLabels:         enhancement, auth, reviewed\n\nDescription:\n  Moving away from session cookies to JWT-based authentication...\n\nDiscussions (8):\n\n  @janedoe (2024-03-16) [src/auth/jwt.ts:45]:\n    Should we use a separate signing key for refresh tokens?\n\n    @johndoe (2024-03-16):\n      Good point. I'll add a separate key with rotation support.\n\n  @alice (2024-03-18) [RESOLVED]:\n    Looks good! Just one nit about the token expiry constant.\n```\n\n## DiffNote File Context Display\n```rust\n// Build file context string\nlet file_context = match (note.position_new_path, note.position_new_line, note.position_line_range_end) {\n    (Some(path), Some(line), Some(end_line)) if line != end_line => {\n        format!(\"[{}:{}-{}]\", path, line, end_line)\n    }\n    (Some(path), Some(line), _) => {\n        format!(\"[{}:{}]\", path, line)\n    }\n    _ => String::new(),\n};\n```\n\n## gi count mrs Output\n```\nMerge Requests: 1,234\n  opened: 89\n  merged: 1,045\n  closed: 100\n```\n\n## Filter Arguments (clap)\n```rust\n#[derive(Parser)]\nstruct ListMrsArgs {\n    #[arg(long)]\n    state: Option<String>,  // opened|merged|closed|locked|all\n    #[arg(long)]\n    draft: bool,\n    #[arg(long)]\n    no_draft: bool,\n    #[arg(long)]\n    author: Option<String>,\n    #[arg(long)]\n    assignee: Option<String>,\n    #[arg(long)]\n    reviewer: Option<String>,\n    #[arg(long)]\n    target_branch: Option<String>,\n    #[arg(long)]\n    source_branch: Option<String>,\n    #[arg(long)]\n    label: Vec<String>,\n    #[arg(long)]\n    project: Option<String>,\n    #[arg(long, default_value = \"20\")]\n    limit: u32,\n}\n```\n\n## Edge Cases\n- `--state=locked` must filter locally (GitLab API doesn't support it)\n- Ambiguous MR iid across projects: prompt for `--project`\n- Empty discussions: show \"No discussions\" message\n- Multi-line DiffNotes: show line range in context","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-26T22:06:43.354939Z","created_by":"tayloreernisse","updated_at":"2026-01-27T00:37:31.792569Z","closed_at":"2026-01-27T00:37:31.792504Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3js","depends_on_id":"bd-20h","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-3js","depends_on_id":"bd-ser","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-3kj","title":"[CP0] gi version, backup, reset, sync-status commands","description":"## Background\n\nThese are the remaining utility commands for CP0. version is trivial. backup creates safety copies before destructive operations. reset provides clean-slate capability. sync-status is a stub for CP0 that will be implemented in CP1.\n\nReference: docs/prd/checkpoint-0.md sections \"gi version\", \"gi backup\", \"gi reset\", \"gi sync-status\"\n\n## Approach\n\n**src/cli/commands/version.ts:**\n```typescript\nimport { Command } from 'commander';\nimport { version } from '../../../package.json' with { type: 'json' };\n\nexport const versionCommand = new Command('version')\n  .description('Show version information')\n  .action(() => {\n    console.log(\\`gi version \\${version}\\`);\n  });\n```\n\n**src/cli/commands/backup.ts:**\n```typescript\nimport { Command } from 'commander';\nimport { copyFileSync, mkdirSync } from 'node:fs';\nimport { loadConfig } from '../../core/config';\nimport { getDbPath, getBackupDir } from '../../core/paths';\n\nexport const backupCommand = new Command('backup')\n  .description('Create timestamped database backup')\n  .action(async (options, command) => {\n    const globalOpts = command.optsWithGlobals();\n    const config = loadConfig(globalOpts.config);\n    \n    const dbPath = getDbPath(config.storage?.dbPath);\n    const backupDir = getBackupDir(config.storage?.backupDir);\n    \n    mkdirSync(backupDir, { recursive: true });\n    \n    // Format: data-2026-01-24T10-30-00.db (colons replaced for Windows compat)\n    const timestamp = new Date().toISOString().replace(/:/g, '-').replace(/\\\\..*/, '');\n    const backupPath = \\`\\${backupDir}/data-\\${timestamp}.db\\`;\n    \n    copyFileSync(dbPath, backupPath);\n    console.log(\\`Created backup: \\${backupPath}\\`);\n  });\n```\n\n**src/cli/commands/reset.ts:**\n```typescript\nimport { Command } from 'commander';\nimport { unlinkSync, existsSync } from 'node:fs';\nimport { createInterface } from 'node:readline';\nimport { loadConfig } from '../../core/config';\nimport { getDbPath } from '../../core/paths';\n\nexport const resetCommand = new Command('reset')\n  .description('Delete database and reset all state')\n  .option('--confirm', 'Skip confirmation prompt')\n  .action(async (options, command) => {\n    const globalOpts = command.optsWithGlobals();\n    const config = loadConfig(globalOpts.config);\n    const dbPath = getDbPath(config.storage?.dbPath);\n    \n    if (!existsSync(dbPath)) {\n      console.log('No database to reset.');\n      return;\n    }\n    \n    if (!options.confirm) {\n      console.log(\\`This will delete:\\n  - Database: \\${dbPath}\\n  - All sync cursors\\n  - All cached data\\n\\`);\n      // Prompt for 'yes' confirmation\n      // If not 'yes', exit 2\n    }\n    \n    unlinkSync(dbPath);\n    // Also delete WAL and SHM files if they exist\n    if (existsSync(\\`\\${dbPath}-wal\\`)) unlinkSync(\\`\\${dbPath}-wal\\`);\n    if (existsSync(\\`\\${dbPath}-shm\\`)) unlinkSync(\\`\\${dbPath}-shm\\`);\n    \n    console.log(\"Database reset. Run 'gi sync' to repopulate.\");\n  });\n```\n\n**src/cli/commands/sync-status.ts:**\n```typescript\n// CP0 stub - full implementation in CP1\nexport const syncStatusCommand = new Command('sync-status')\n  .description('Show sync state')\n  .action(() => {\n    console.log(\"No sync runs yet. Run 'gi sync' to start.\");\n  });\n```\n\n## Acceptance Criteria\n\n- [ ] `gi version` outputs \"gi version X.Y.Z\"\n- [ ] `gi backup` creates timestamped copy of database\n- [ ] Backup filename is Windows-compatible (no colons)\n- [ ] Backup directory created if missing\n- [ ] `gi reset` prompts for 'yes' confirmation\n- [ ] `gi reset --confirm` skips prompt\n- [ ] Reset deletes .db, .db-wal, and .db-shm files\n- [ ] Reset exits 2 if user doesn't type 'yes'\n- [ ] `gi sync-status` outputs stub message\n\n## Files\n\nCREATE:\n- src/cli/commands/version.ts\n- src/cli/commands/backup.ts\n- src/cli/commands/reset.ts\n- src/cli/commands/sync-status.ts\n\n## TDD Loop\n\nN/A - simple commands, verify manually:\n\n```bash\ngi version\ngi backup\nls ~/.local/share/gi/backups/\ngi reset  # type 'no'\ngi reset --confirm\nls ~/.local/share/gi/data.db  # should not exist\ngi sync-status\n```\n\n## Edge Cases\n\n- Backup when database doesn't exist - show clear error\n- Reset when database doesn't exist - show \"No database to reset\"\n- WAL/SHM files may not exist - check before unlinking\n- Timestamp with milliseconds could cause very long filename\n- readline prompt in non-interactive terminal - handle SIGINT","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-24T16:09:51.774210Z","created_by":"tayloreernisse","updated_at":"2026-01-25T03:31:46.227285Z","closed_at":"2026-01-25T03:31:46.227220Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3kj","depends_on_id":"bd-13b","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"},{"issue_id":"bd-3kj","depends_on_id":"bd-3ng","type":"blocks","created_at":"2026-03-04T20:00:21Z","created_by":"import"}]}
{"id":"bd-3l56","title":"Add lore sync --tui convenience flag","description":"## Background\n\nThe PRD defines two CLI entry paths to the TUI: `lore tui` (full TUI) and `lore sync --tui` (convenience shortcut that launches the TUI directly on the Sync screen in inline mode). The `lore tui` command is covered by bd-26lp. This bead adds the `--tui` flag to the existing `SyncArgs` struct, which delegates to the `lore-tui` binary with `--sync` flag.\n\n## Approach\n\nTwo changes to the existing lore CLI crate (NOT the lore-tui crate):\n\n1. **Add `--tui` flag to `SyncArgs`** in `src/cli/mod.rs`:\n   ```rust\n   /// Show sync progress in interactive TUI (inline mode)\n   #[arg(long)]\n   pub tui: bool,\n   ```\n\n2. **Handle the flag in sync command dispatch** in `src/main.rs` (or wherever Commands::Sync is matched):\n   - If `args.tui` is true, call `resolve_tui_binary()` (from bd-26lp) and spawn it with `--sync` flag\n   - Forward the config path if specified\n   - Exit with the lore-tui process exit code\n   - If lore-tui is not found, print a helpful error message\n\nThe `resolve_tui_binary()` function is implemented by bd-26lp (CLI integration). This bead simply adds the flag and the early-return delegation path in the sync command handler.\n\n## Acceptance Criteria\n- [ ] `lore sync --tui` is accepted by the CLI parser (no unknown flag error)\n- [ ] When `--tui` is set, the sync command delegates to `lore-tui --sync` binary\n- [ ] Config path is forwarded if `--config` was specified\n- [ ] If lore-tui binary is not found, prints error with install instructions and exits non-zero\n- [ ] `lore sync --tui --full` does NOT pass `--full` to lore-tui (TUI has its own sync controls)\n- [ ] `--tui` flag appears in `lore sync --help` output\n\n## Files\n- MODIFY: src/cli/mod.rs (add `tui: bool` field to `SyncArgs` struct at line ~776)\n- MODIFY: src/main.rs or src/cli/commands/sync.rs (add early-return delegation when `args.tui`)\n\n## TDD Anchor\nRED: Write `test_sync_tui_flag_accepted` that verifies `SyncArgs` can be parsed with `--tui` flag.\nGREEN: Add the `tui: bool` field to SyncArgs.\nVERIFY: cargo test sync_tui_flag\n\nAdditional tests:\n- test_sync_tui_flag_default_false (not set by default)\n\n## Edge Cases\n- `--tui` combined with `--dry-run` — the TUI handles dry-run internally, so `--dry-run` should be ignored when `--tui` is set (or warn)\n- `--tui` when lore-tui binary does not exist — clear error, not a panic\n- `--tui` in robot mode (`--robot`) — nonsensical combination, should error with \"cannot use --tui with --robot\"\n\n## Dependency Context\n- Depends on bd-26lp (CLI integration) which implements `resolve_tui_binary()` and `validate_tui_compat()` functions that this bead calls.\n- The SyncArgs struct is at src/cli/mod.rs:739. The existing fields are: full, no_full, force, no_force, no_embed, no_docs, no_events, no_file_changes, dry_run, no_dry_run.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T19:29:40.785182Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:24.478277Z","deleted_at":"2026-03-11T18:34:24.478273Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-3lc","title":"Rename GiError to LoreError across codebase","description":"## Background\nThe codebase currently uses `GiError` as the primary error enum name (legacy from when the project was called \"gi\"). Checkpoint 3 introduces new modules (documents, search, embedding) that import error types. Renaming before Gate A work begins prevents every subsequent bead from needing to reference the old name and avoids merge conflicts across parallel work streams.\n\n## Approach\nMechanical find-and-replace using `ast-grep` or `sed`:\n1. Rename the enum declaration in `src/core/error.rs`: `pub enum GiError` -> `pub enum LoreError`\n2. Update the type alias: `pub type Result<T> = std::result::Result<T, LoreError>;`\n3. Update re-exports in `src/core/mod.rs` and `src/lib.rs`\n4. Update all `use` statements across ~16 files that import `GiError`\n5. Update any `GiError::` variant construction sites\n6. Run `cargo build` to verify no references remain\n\n**Do NOT change:**\n- Error variant names (ConfigNotFound, etc.) — only the enum name\n- ErrorCode enum — it's already named correctly\n- RobotError — already named correctly\n\n## Acceptance Criteria\n- [ ] `cargo build` succeeds with zero warnings about GiError\n- [ ] `rg GiError src/` returns zero results\n- [ ] `rg LoreError src/core/error.rs` shows the enum declaration\n- [ ] `src/core/mod.rs` re-exports `LoreError` (not `GiError`)\n- [ ] `src/lib.rs` re-exports `LoreError`\n- [ ] All `use crate::core::error::LoreError` imports compile\n\n## Files\n- `src/core/error.rs` — enum rename + type alias\n- `src/core/mod.rs` — re-export update\n- `src/lib.rs` — re-export update\n- All files matching `rg 'GiError' src/` (~16 files: ingestion/*.rs, cli/commands/*.rs, gitlab/*.rs, main.rs)\n\n## TDD Loop\nRED: `cargo build` fails after renaming enum but before fixing imports\nGREEN: Fix all imports; `cargo build` succeeds\nVERIFY: `cargo build && rg GiError src/ && echo \"FAIL: GiError references remain\" || echo \"PASS: clean\"`\n\n## Edge Cases\n- Some files may use `GiError` in string literals (error messages) — do NOT rename those, only type references\n- `impl From<X> for GiError` blocks must become `impl From<X> for LoreError`\n- The `thiserror` derive macro on the enum does not reference the name, so no macro changes needed","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:25:25.694773Z","created_by":"tayloreernisse","updated_at":"2026-01-30T16:50:10.612340Z","closed_at":"2026-01-30T16:50:10.612278Z","close_reason":"Completed: renamed GiError to LoreError across all 16 files, cargo build + 164 tests pass","compaction_level":0,"original_size":0}
{"id":"bd-3le2","title":"Implement TaskSupervisor (dedup + cancellation + generation IDs)","description":"## Background\nBackground tasks (DB queries, sync, search) are managed by a centralized TaskSupervisor that prevents redundant work, enables cooperative cancellation, and uses generation IDs for stale-result detection. This is the ONLY allowed path for background work — state handlers return ScreenIntent, not Cmd::task directly.\n\n## Approach\nCreate crates/lore-tui/src/task_supervisor.rs:\n- TaskKey enum: LoadScreen(Screen), Search, SyncStream, FilterRequery(Screen) — dedup keys, NOT generation-bearing\n- TaskPriority enum: Input(0), Navigation(1), Background(2)\n- CancelToken: AtomicBool wrapper with cancel(), is_cancelled()\n- TaskHandle struct: key (TaskKey), generation (u64), cancel (Arc<CancelToken>), interrupt (Option<InterruptHandle>)\n- TaskSupervisor struct: active (HashMap<TaskKey, TaskHandle>), generation (AtomicU64)\n- submit(key: TaskKey) -> TaskHandle: cancels existing task with same key (via CancelToken), increments generation, stores new handle, returns TaskHandle\n- is_current(key: &TaskKey, generation: u64) -> bool: checks if generation matches active handle\n- complete(key: &TaskKey, generation: u64): removes handle if generation matches\n- cancel_all(): cancels all active tasks (used on quit)\n\n## Acceptance Criteria\n- [ ] submit() with existing key cancels previous task's CancelToken\n- [ ] submit() returns handle with monotonically increasing generation\n- [ ] is_current() returns true only for the latest generation\n- [ ] complete() removes handle only if generation matches (prevents removing newer task)\n- [ ] CancelToken is Arc-wrapped and thread-safe (Send+Sync)\n- [ ] TaskHandle includes optional InterruptHandle for SQLite cancellation\n- [ ] Generation counter never wraps during reasonable use (AtomicU64)\n\n## Files\n- CREATE: crates/lore-tui/src/task_supervisor.rs\n\n## TDD Anchor\nRED: Write test_submit_cancels_previous that submits two tasks with same key, asserts first task's CancelToken is cancelled.\nGREEN: Implement submit() with cancel-on-supersede logic.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_submit_cancels\n\nAdditional tests:\n- test_is_current_after_supersede: old generation returns false, new returns true\n- test_complete_removes_handle: after complete, key is absent from active map\n- test_complete_ignores_stale: completing with old generation doesn't remove newer task\n- test_generation_monotonic: submit() always returns increasing generation values\n\n## Edge Cases\n- CancelToken uses Relaxed ordering — sufficient for cooperative cancellation polling\n- Generation u64 overflow is theoretical but worth noting (would require 2^64 submissions)\n- submit() must cancel old task BEFORE storing new handle to prevent race conditions\n- InterruptHandle is rusqlite-specific — only set for tasks that lease a reader connection","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T16:56:21.102488Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:23.711426Z","deleted_at":"2026-03-11T18:34:23.711422Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-3lu","title":"Implement lore search CLI command (lexical mode)","description":"## Background\nThe search CLI command is the user-facing entry point for Gate A lexical search. It orchestrates the search pipeline: query parsing -> FTS5 search -> filter application -> result hydration (single round-trip) -> display. Gate B extends this same command with --mode=hybrid and --mode=semantic. The hydration query is critical for performance — it fetches all display fields + labels + paths in one SQL query using json_each() + json_group_array().\n\n## Approach\nCreate `src/cli/commands/search.rs` per PRD Section 3.4.\n\n**Key types:**\n- `SearchResultDisplay` — display-ready result with all fields (dates as ISO via `ms_to_iso`)\n- `ExplainData` — ranking explanation for --explain flag (vector_rank, fts_rank, rrf_score)\n- `SearchResponse` — wrapper with query, mode, total_results, results, warnings\n\n**Core function:**\n```rust\npub fn run_search(\n    config: &Config,\n    query: &str,\n    mode: SearchMode,\n    filters: SearchFilters,\n    explain: bool,\n) -> Result<SearchResponse>\n```\n\n**Pipeline:**\n1. Parse query + filters\n2. Execute search based on mode -> ranked doc_ids (+ explain ranks)\n3. Apply post-retrieval filters via apply_filters() preserving ranking order\n4. Hydrate results in single DB round-trip using json_each + json_group_array\n5. Attach snippets: prefer FTS snippet, fallback to `generate_fallback_snippet()` for semantic-only\n6. Convert timestamps via `ms_to_iso()` from `crate::core::time`\n7. Build SearchResponse\n\n**Hydration query (critical — single round-trip, replaces 60 queries with 1):**\n```sql\nSELECT d.id, d.source_type, d.title, d.url, d.author_username,\n       d.created_at, d.updated_at, d.content_text,\n       p.path_with_namespace AS project_path,\n       (SELECT json_group_array(dl.label_name)\n        FROM document_labels dl WHERE dl.document_id = d.id) AS labels,\n       (SELECT json_group_array(dp.path)\n        FROM document_paths dp WHERE dp.document_id = d.id) AS paths\nFROM json_each(?) AS j\nJOIN documents d ON d.id = j.value\nJOIN projects p ON p.id = d.project_id\nORDER BY j.key\n```\n\n**Human output uses `console::style` for terminal formatting:**\n```rust\nuse console::style;\n// Type prefix in cyan\nprintln!(\"[{}] {} - {} ({})\", i+1, style(type_prefix).cyan(), title, score);\n// URL in dim\nprintln!(\"    {}\", style(url).dim());\n```\n\n**JSON robot mode includes elapsed_ms in meta (PRD Section 3.4):**\n```rust\npub fn print_search_results_json(response: &SearchResponse, elapsed_ms: u64) {\n    let output = serde_json::json!({\n        \"ok\": true,\n        \"data\": response,\n        \"meta\": { \"elapsed_ms\": elapsed_ms }\n    });\n    println!(\"{}\", serde_json::to_string_pretty(&output).unwrap());\n}\n```\n\n**CLI args in `src/cli/mod.rs` (PRD Section 3.4):**\n```rust\n#[derive(Args)]\npub struct SearchArgs {\n    query: String,\n    #[arg(long, default_value = \"hybrid\")]\n    mode: String,\n    #[arg(long, value_name = \"TYPE\")]\n    r#type: Option<String>,\n    #[arg(long)]\n    author: Option<String>,\n    #[arg(long)]\n    project: Option<String>,\n    #[arg(long, action = clap::ArgAction::Append)]\n    label: Vec<String>,\n    #[arg(long)]\n    path: Option<String>,\n    #[arg(long)]\n    after: Option<String>,\n    #[arg(long)]\n    updated_after: Option<String>,\n    #[arg(long, default_value = \"20\")]\n    limit: usize,\n    #[arg(long)]\n    explain: bool,\n    #[arg(long, default_value = \"safe\")]\n    fts_mode: String,\n}\n```\n\n**IMPORTANT: default_value = \"hybrid\"** — When Ollama is unavailable, hybrid mode gracefully degrades to FTS-only with a warning (not an error). `lore search` works without Ollama.\n\n## Acceptance Criteria\n- [ ] Default mode is \"hybrid\" (not \"lexical\") per PRD\n- [ ] Hybrid mode degrades gracefully to FTS-only when Ollama unavailable (warning, not error)\n- [ ] All filters work (type, author, project, label, path, after, updated_after, limit)\n- [ ] Label filter uses `clap::ArgAction::Append` for repeatable --label flags\n- [ ] Hydration in single query (not N+1) — uses json_each + json_group_array\n- [ ] Timestamps converted via `ms_to_iso()` for display (ISO format)\n- [ ] Human output uses `console::style` for colored type prefix (cyan) and dim URLs\n- [ ] JSON robot mode includes `elapsed_ms` in `meta` field\n- [ ] Semantic-only results get fallback snippets via `generate_fallback_snippet()`\n- [ ] Empty results show friendly message: \"No results found for 'query'\"\n- [ ] \"No data indexed\" message if documents table empty\n- [ ] --explain shows vector_rank, fts_rank, rrf_score per result\n- [ ] --fts-mode=safe preserves prefix `*` while escaping special chars\n- [ ] --fts-mode=raw passes FTS5 MATCH syntax through unchanged\n- [ ] --mode=semantic with 0% embedding coverage returns LoreError::EmbeddingsNotBuilt (not OllamaUnavailable)\n- [ ] SearchArgs registered in cli/mod.rs with Clap derive\n- [ ] `cargo build` succeeds\n\n## Files\n- `src/cli/commands/search.rs` — new file\n- `src/cli/commands/mod.rs` — add `pub mod search;`\n- `src/cli/mod.rs` — add SearchArgs struct, wire up search subcommand\n- `src/main.rs` — add search command handler\n\n## TDD Loop\nRED: Integration test requiring DB with documents\n- `test_lexical_search_returns_results` — FTS search returns hits\n- `test_hydration_single_query` — verify no N+1 (mock/inspect query count)\n- `test_json_output_includes_elapsed` — robot mode JSON has meta.elapsed_ms\n- `test_empty_results_message` — zero results shows friendly message\n- `test_fallback_snippet` — semantic-only result uses truncated content\nGREEN: Implement run_search + hydrate_results + print functions\nVERIFY: `cargo build && cargo test search`\n\n## Edge Cases\n- Zero results: display friendly empty message, JSON returns empty array\n- --mode=semantic with 0% embedding coverage: return LoreError::EmbeddingsNotBuilt\n- json_group_array returns \"[]\" for documents with no labels — parse as empty array\n- Very long snippets: truncated at display time\n- Hybrid default works without Ollama: degrades to FTS-only with warning\n- ms_to_iso with epoch 0: return valid ISO string (not crash)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:26:13.109876Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:52:24.320923Z","closed_at":"2026-01-30T17:52:24.320857Z","close_reason":"Implemented search CLI with FTS5 + RRF ranking, single-query hydration (json_each + json_group_array), adaptive recall, all filters, --explain, human + JSON output. Builds clean.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3lu","depends_on_id":"bd-1k1","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-3lu","depends_on_id":"bd-3q2","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-3lu","depends_on_id":"bd-3qs","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-3mj2","title":"WHO: Robot JSON output for all 5 modes","description":"## Background\n\nRobot-mode JSON output following the standard lore envelope: `{\"ok\":true,\"data\":{...},\"meta\":{\"elapsed_ms\":N}}`. Includes both raw CLI args (input) and computed values (resolved_input) for agent reproducibility.\n\n## Approach\n\n### Envelope structs:\n```rust\n#[derive(Serialize)]\nstruct WhoJsonEnvelope { ok: bool, data: WhoJsonData, meta: RobotMeta }\n\n#[derive(Serialize)]\nstruct WhoJsonData {\n    mode: String,\n    input: serde_json::Value,\n    resolved_input: serde_json::Value,\n    #[serde(flatten)]\n    result: serde_json::Value,\n}\n```\n\n### print_who_json(run, args, elapsed_ms):\n- `input`: raw CLI args `{ target, path, project, since, limit }`\n- `resolved_input`: `{ mode, project_id, project_path, since_ms, since_iso, since_mode, limit }`\n- `result`: mode-specific JSON via *_to_json() functions using serde_json::json\\!() macro\n\n### Mode-specific JSON fields:\n- **Expert**: path_query, path_match, truncated, experts[] with ISO last_seen_at\n- **Workload**: username, 4 entity arrays with ref/project_path/ISO timestamps, summary{} counts, truncation{} per-section bools\n- **Reviews**: username, total_diffnotes, categorized_count, mrs_reviewed, categories[] with rounded percentages\n- **Active**: total_unresolved_in_window, truncated, discussions[] with discussion_id + participants + participants_total + participants_truncated\n- **Overlap**: path_query, path_match, truncated, users[] with role + touch counts + mr_refs + mr_refs_total + mr_refs_truncated\n\n### Key implementation detail — #[serde(flatten)] on result field:\nThe `result` field uses `#[serde(flatten)]` so mode-specific keys are merged into the top-level data object rather than nested. This means `data.experts` (not `data.result.experts`).\n\n### Timestamps: all use ms_to_iso() for ISO 8601 format in JSON output\n\n### Percentage rounding: Reviews categories use `(percentage * 10.0).round() / 10.0` for single decimal precision\n\n## Files\n\n- `src/cli/commands/who.rs`\n\n## TDD Loop\n\nNo unit tests for JSON serialization — the serde_json::json\\!() macro produces correct JSON by construction. Verification via manual robot mode invocation.\nVERIFY: `cargo check && cargo run --release -- -J who src/features/global-search/ | python3 -m json.tool`\n\n## Acceptance Criteria\n\n- [ ] cargo check passes\n- [ ] JSON output validates (valid JSON, no trailing content)\n- [ ] input echoes raw CLI args\n- [ ] resolved_input includes since_mode tri-state (default/explicit/none)\n- [ ] All timestamps in ISO 8601 format\n- [ ] Bounded metadata present (participants_total, mr_refs_total, truncation object)\n- [ ] #[serde(flatten)] correctly merges result keys into data object\n\n## Edge Cases\n\n- `#[serde(flatten)]` on the result Value means mode-specific keys must not collide with mode/input/resolved_input — verified by convention (expert uses \"experts\", workload uses \"username\", etc.)\n- serde_json::json\\!() panics are impossible for valid Rust expressions, but verify that all row.get() values in *_to_json() handle None fields correctly (author_username in WorkloadMr is Option<String> — json\\!() serializes None as null, which is correct)\n- ms_to_iso() must handle 0 and very old timestamps gracefully — produces \"1970-01-01T00:00:00Z\" for epoch 0, which is valid\n- Reviews percentage rounding: categories summing to >100% due to rounding is acceptable (display artifact) — agent consumers should not assert sum == 100\n- println\\!() for JSON output (not eprintln\\!) — errors go to stderr, data to stdout, matching all other robot-mode commands\n- If a mode returns empty results, the JSON should still be valid (empty arrays, zero counts) — serde handles this correctly","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-08T02:41:15.280907Z","created_by":"tayloreernisse","updated_at":"2026-02-08T04:10:29.600331Z","closed_at":"2026-02-08T04:10:29.600297Z","close_reason":"Implemented by agent team: migration 017, CLI skeleton, all 5 query modes, human+robot output, 20 tests. All quality gates pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3mj2","depends_on_id":"bd-2711","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-3mj2","depends_on_id":"bd-b51e","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-3mj2","depends_on_id":"bd-m7k1","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-3mj2","depends_on_id":"bd-s3rc","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-3mj2","depends_on_id":"bd-zqpf","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-3mk","title":"[CP1] gi list issues command","description":"List issues from the database.\n\nFlags:\n- --limit=N (default: 20)\n- --project=PATH (filter by project)\n- --state=opened|closed|all (default: all)\n\nOutput: Table with iid, title, state, author, relative time\n\nFiles: src/cli/commands/list.ts\nDone when: List displays issues with proper filtering and formatting","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T15:20:10.400664Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.155211Z","closed_at":"2026-01-25T15:21:35.155211Z","deleted_at":"2026-01-25T15:21:35.155209Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-3n1","title":"[CP1] gi list issues command","description":"## Background\n\nThe `gi list issues` command displays a paginated list of issues from the local database. It supports filtering by project and state, with configurable limit. This provides quick access to synced issues without opening GitLab.\n\n## Approach\n\n### Module: src/cli/commands/list.rs\n\n### Clap Definition\n\n```rust\n#[derive(Args)]\npub struct ListArgs {\n    /// Entity type to list\n    #[arg(value_parser = [\"issues\", \"mrs\"])]\n    pub entity: String,\n\n    /// Maximum results\n    #[arg(long, default_value = \"20\")]\n    pub limit: usize,\n\n    /// Filter by project path\n    #[arg(long)]\n    pub project: Option<String>,\n\n    /// Filter by state\n    #[arg(long, value_parser = [\"opened\", \"closed\", \"all\"])]\n    pub state: Option<String>,\n}\n```\n\n### Handler Function\n\n```rust\npub async fn handle_list(args: ListArgs, conn: &Connection) -> Result<()>\n```\n\n### Query (for issues)\n\n```sql\nSELECT i.iid, i.title, i.state, i.author_username, i.updated_at, p.path\nFROM issues i\nJOIN projects p ON i.project_id = p.id\nWHERE (p.path = ? OR ? IS NULL)\n  AND (i.state = ? OR ? IS NULL OR ? = 'all')\nORDER BY i.updated_at DESC\nLIMIT ?\n```\n\n### Output Format (matches PRD)\n\n```\nIssues (showing 20 of 3,801)\n\n  #1234  Authentication redesign                       opened   @johndoe   3 days ago\n  #1233  Fix memory leak in cache                      closed   @janedoe   5 days ago\n  #1232  Add dark mode support                         opened   @bobsmith  1 week ago\n  ...\n```\n\n### Column Layout\n\n| Column | Width | Alignment |\n|--------|-------|-----------|\n| IID | 6 | right |\n| Title | 45 | left (truncate) |\n| State | 8 | left |\n| Author | 12 | left |\n| Updated | 12 | right (relative) |\n\n### Relative Time Formatting\n\n```rust\nfn format_relative_time(ms_epoch: i64) -> String {\n    let now = now_ms();\n    let diff = now - ms_epoch;\n    match diff {\n        d if d < 60_000 => \"just now\".to_string(),\n        d if d < 3_600_000 => format!(\"{} min ago\", d / 60_000),\n        d if d < 86_400_000 => format!(\"{} hours ago\", d / 3_600_000),\n        d if d < 604_800_000 => format!(\"{} days ago\", d / 86_400_000),\n        d if d < 2_592_000_000 => format!(\"{} weeks ago\", d / 604_800_000),\n        _ => format!(\"{} months ago\", diff / 2_592_000_000),\n    }\n}\n```\n\n## Acceptance Criteria\n\n- [ ] Lists issues ordered by updated_at DESC\n- [ ] Shows \"showing X of Y\" with total count\n- [ ] Respects --limit parameter\n- [ ] --project filters to single project\n- [ ] --state filters to opened/closed/all\n- [ ] Title truncated if longer than column width\n- [ ] Updated time shown as relative (\"3 days ago\")\n\n## Files\n\n- src/cli/commands/mod.rs (add `pub mod list;`)\n- src/cli/commands/list.rs (create)\n- src/cli/mod.rs (add List variant to Commands enum)\n\n## TDD Loop\n\nRED:\n```rust\n#[tokio::test] async fn list_issues_shows_correct_columns()\n#[tokio::test] async fn list_issues_respects_limit()\n#[tokio::test] async fn list_issues_filters_by_project()\n#[tokio::test] async fn list_issues_filters_by_state()\n```\n\nGREEN: Implement handler with query and formatting\n\nVERIFY: `cargo test list_issues`\n\n## Edge Cases\n\n- No issues match filters - show \"No issues found\"\n- Title exactly 45 chars - no truncation\n- Title 46+ chars - truncate with \"...\"\n- --state=all shows both opened and closed\n- Default state filter is all (not just opened)","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-25T17:02:38.336352Z","created_by":"tayloreernisse","updated_at":"2026-01-25T22:58:56.619167Z","closed_at":"2026-01-25T22:58:56.619106Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3n1","depends_on_id":"bd-208","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-3nd","title":"[CP1] Issue transformer with label extraction","description":"## Background\n\nThe issue transformer converts GitLab API responses into our local schema format. It extracts core fields and, critically, the array of label names from each issue. This transformer is pure logic with no I/O, making it easy to test.\n\n## Approach\n\nCreate a transformer module with a function that:\n1. Takes a `GitLabIssue` and returns an `IssueRow` struct\n2. Extracts the `labels: Vec<String>` directly from the issue (GitLab returns label names as strings)\n\n### Structs\n\n```rust\n// src/gitlab/transformers/issue.rs\n\npub struct IssueRow {\n    pub gitlab_id: i64,\n    pub iid: i64,\n    pub project_id: i64,\n    pub title: String,\n    pub description: Option<String>,\n    pub state: String,\n    pub author_username: String,\n    pub created_at: i64,      // ms epoch UTC\n    pub updated_at: i64,      // ms epoch UTC\n    pub web_url: String,\n}\n\npub struct IssueWithLabels {\n    pub issue: IssueRow,\n    pub label_names: Vec<String>,\n}\n```\n\n### Function\n\n```rust\npub fn transform_issue(issue: GitLabIssue) -> Result<IssueWithLabels, TransformError> {\n    // Parse ISO 8601 timestamps to ms epoch\n    // Extract author.username\n    // Return IssueWithLabels with label_names from issue.labels\n}\n```\n\n## Acceptance Criteria\n\n- [ ] `IssueRow` struct exists with all fields from schema\n- [ ] `IssueWithLabels` struct bundles issue + label names\n- [ ] `transform_issue` parses ISO 8601 to ms epoch correctly\n- [ ] `transform_issue` handles missing description (None)\n- [ ] Label names are preserved exactly as received from GitLab\n- [ ] Unit tests cover all edge cases\n\n## Files\n\n- src/gitlab/transformers/mod.rs (create, add `pub mod issue;`)\n- src/gitlab/transformers/issue.rs (create)\n\n## TDD Loop\n\nRED: \n```rust\n// tests/unit/issue_transformer_test.rs\n#[test] fn transforms_issue_with_all_fields()\n#[test] fn handles_missing_description()\n#[test] fn extracts_label_names()\n#[test] fn parses_timestamps_to_ms_epoch()\n```\n\nGREEN: Implement transform_issue function\n\nVERIFY: `cargo test issue_transformer`\n\n## Edge Cases\n\n- GitLab timestamps are ISO 8601 with timezone - use chrono::DateTime::parse_from_rfc3339\n- Description can be null in GitLab API - map to Option<String>\n- Empty labels array is valid - return empty Vec\n- Do NOT parse labels_details - it varies across GitLab versions","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-25T17:02:38.174071Z","created_by":"tayloreernisse","updated_at":"2026-01-25T22:27:11.430611Z","closed_at":"2026-01-25T22:27:11.430439Z","close_reason":"Implemented IssueRow, IssueWithLabels, transform_issue with 6 passing unit tests covering all edge cases","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3nd","depends_on_id":"bd-1np","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-3ng","title":"[CP0] Database setup with migrations and app lock","description":"## Background\n\nThe database is the backbone of gitlab-inbox. SQLite with WAL mode for performance, foreign keys for integrity, and proper pragmas for reliability. Migrations allow schema evolution. App lock prevents concurrent sync corruption.\n\nReference: docs/prd/checkpoint-0.md sections \"Database Schema\", \"SQLite Runtime Pragmas\", \"App Lock Mechanism\"\n\n## Approach\n\n**src/core/db.ts:**\n```typescript\nimport Database from 'better-sqlite3';\nimport { join, dirname } from 'node:path';\nimport { mkdirSync, readdirSync, readFileSync } from 'node:fs';\nimport { getDbPath } from './paths';\nimport { dbLogger } from './logger';\n\nexport function createConnection(dbPath: string): Database.Database {\n  mkdirSync(dirname(dbPath), { recursive: true });\n  const db = new Database(dbPath);\n  \n  // Production-grade pragmas\n  db.pragma('journal_mode = WAL');\n  db.pragma('synchronous = NORMAL');\n  db.pragma('foreign_keys = ON');\n  db.pragma('busy_timeout = 5000');\n  db.pragma('temp_store = MEMORY');\n  \n  return db;\n}\n\nexport function runMigrations(db: Database.Database, migrationsDir: string): void {\n  // Create schema_version table if not exists\n  // Read migration files sorted by version\n  // Apply migrations not yet applied\n  // Track in schema_version table\n}\n```\n\n**migrations/001_initial.sql:**\nFull schema with tables: schema_version, projects, sync_runs, app_locks, sync_cursors, raw_payloads\n\n**src/core/lock.ts:**\nAppLock class with:\n- acquire(force?): acquires lock or throws DatabaseLockError\n- release(): releases lock and stops heartbeat\n- Heartbeat timer that updates heartbeat_at every N seconds\n- Stale lock detection (heartbeat_at > staleLockMinutes ago)\n\n## Acceptance Criteria\n\n- [ ] createConnection() creates parent directories if missing\n- [ ] WAL mode verified: `db.pragma('journal_mode')` returns 'wal'\n- [ ] Foreign keys verified: `db.pragma('foreign_keys')` returns 1\n- [ ] busy_timeout verified: `db.pragma('busy_timeout')` returns 5000\n- [ ] 001_initial.sql creates all 6 tables\n- [ ] schema_version shows version 1 after migration\n- [ ] AppLock.acquire() succeeds for first caller\n- [ ] AppLock.acquire() throws DatabaseLockError for second concurrent caller\n- [ ] Stale lock (heartbeat > 10 min old) can be taken over\n- [ ] tests/unit/db.test.ts passes (8 tests)\n- [ ] tests/integration/app-lock.test.ts passes (6 tests)\n\n## Files\n\nCREATE:\n- src/core/db.ts\n- src/core/lock.ts\n- migrations/001_initial.sql\n- tests/unit/db.test.ts\n- tests/integration/app-lock.test.ts\n\n## TDD Loop\n\nRED:\n```typescript\n// tests/unit/db.test.ts\ndescribe('Database', () => {\n  it('creates database file if not exists')\n  it('applies migrations in order')\n  it('sets WAL journal mode')\n  it('enables foreign keys')\n  it('sets busy_timeout=5000')\n  it('sets synchronous=NORMAL')\n  it('sets temp_store=MEMORY')\n  it('tracks schema version')\n})\n\n// tests/integration/app-lock.test.ts\ndescribe('App Lock', () => {\n  it('acquires lock successfully')\n  it('updates heartbeat during operation')\n  it('detects stale lock and recovers')\n  it('refuses concurrent acquisition')\n  it('allows force override')\n  it('releases lock on completion')\n})\n```\n\nGREEN: Implement db.ts, lock.ts, 001_initial.sql\n\nVERIFY: \n```bash\nnpm run test -- tests/unit/db.test.ts\nnpm run test -- tests/integration/app-lock.test.ts\n```\n\n## Edge Cases\n\n- Migration file with syntax error should rollback and throw MigrationError\n- Lock heartbeat timer must be unref()'d to not block process exit\n- Database file permissions - fail clearly if not writable\n- Concurrent lock tests need separate database files","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-24T16:09:49.481012Z","created_by":"tayloreernisse","updated_at":"2026-01-25T03:08:38.612669Z","closed_at":"2026-01-25T03:08:38.612543Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3ng","depends_on_id":"bd-epj","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-3o0i","title":"NOTE-2E: Generate-docs full rebuild support for notes","description":"## Background\nlore generate-docs --full seeds all issues, MRs, and discussions into the dirty queue. Notes must be seeded too. The seeding happens in src/cli/commands/generate_docs.rs at lines 38-41.\n\n## Approach\nIn src/cli/commands/generate_docs.rs, the run_generate_docs() function (line 25) calls seed_dirty() for each source type when full=true (lines 38-41):\n   result.seeded += seed_dirty(&conn, SourceType::Issue, project_filter)?;\n   result.seeded += seed_dirty(&conn, SourceType::MergeRequest, project_filter)?;\n   result.seeded += seed_dirty(&conn, SourceType::Discussion, project_filter)?;\n\nThe seed_dirty() function (line 61) maps SourceType to table name and does:\n   INSERT INTO dirty_sources SELECT source_type, id, now FROM {table} ...\n\nFor notes, the table name is 'notes' but we need to exclude system notes (is_system = 0). The current seed_dirty function doesn't support WHERE filters. Two options:\n\nOption A (preferred): Add a separate seed_dirty_notes() function that handles the is_system filter:\n   fn seed_dirty_notes(conn: &Connection, project_filter: Option<&str>) -> Result<usize> {\n       INSERT INTO dirty_sources (source_type, source_id, queued_at)\n       SELECT 'note', n.id, ?1 FROM notes n WHERE n.is_system = 0\n       [AND n.project_id IN (SELECT id FROM projects WHERE path_with_namespace LIKE ?)]\n       ON CONFLICT(source_type, source_id) DO UPDATE SET queued_at = excluded.queued_at, attempt_count = 0, last_attempt_at = NULL, last_error = NULL, next_attempt_at = NULL\n   }\n\nOption B: Extend seed_dirty() to accept optional WHERE clause.\n\nAdd the call in run_generate_docs() after the existing 3 seed_dirty calls:\n   result.seeded += seed_dirty_notes(&conn, project_filter)?;\n\n## Files\n- MODIFY: src/cli/commands/generate_docs.rs (add seed_dirty_notes function, call it in run_generate_docs at line 41)\n\n## TDD Anchor\nRED: test_full_seed_includes_notes — setup project, 3 non-system + 1 system note, call run_generate_docs with full=true, assert 3 note entries in dirty_sources.\nGREEN: Implement seed_dirty_notes and wire into run_generate_docs.\nVERIFY: cargo test full_seed_includes_notes -- --nocapture\nTests: test_note_document_count_stable_after_second_generate_docs_full (second run is idempotent)\n\n## Acceptance Criteria\n- [ ] generate-docs --full seeds non-system notes into dirty queue\n- [ ] System notes excluded from seeding (is_system = 0 filter)\n- [ ] Project filter works (--project flag scopes to one project)\n- [ ] Second full rebuild is idempotent (same document count, ON CONFLICT resets attempt counters)\n- [ ] Both tests pass\n\n## Dependency Context\n- Depends on NOTE-2D (bd-2ezb): regenerator must handle SourceType::Note so that seeded dirty entries can actually be processed\n\n## Edge Cases\n- Empty database: no notes = no-op, no errors\n- All system notes: no entries seeded\n- project_filter with no matching notes: 0 seeded, no error","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T17:02:25.747719Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:13:15.553418Z","closed_at":"2026-02-12T18:13:15.553372Z","close_reason":"Implemented by agent swarm","compaction_level":0,"original_size":0,"labels":["per-note","search"]}
{"id":"bd-3pk","title":"OBSERV Epic: Phase 5 - Rate Limit + Retry Instrumentation","description":"Enhanced logging in GitLab HTTP client for rate limits and retries. Structured fields on retry/rate-limit events. StageTiming gets rate_limit_hits and retries counters.\n\nDepends on: Phase 2 (spans for context)\nParallel with: Phase 3\n\nFiles: src/gitlab/client.rs, src/core/metrics.rs\n\nAcceptance criteria (PRD Section 6.5):\n- 429 events log at INFO with path, attempt, retry_after_secs, status_code\n- Retry events log with path, attempt, error\n- StageTiming includes rate_limit_hits and retries (omitted when zero)\n- lore -v sync shows retry activity on stderr\n- Rate limit counts included in metrics_json","status":"closed","priority":2,"issue_type":"epic","created_at":"2026-02-04T15:53:27.517023Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:31:35.239820Z","closed_at":"2026-02-04T17:31:35.239776Z","close_reason":"All Phase 5 tasks complete: structured rate-limit logging (bd-12ae) and rate_limit_hits/retries counters in StageTiming (bd-3vqk)","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-3pk","depends_on_id":"bd-2ni","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-3pm2","title":"Add required TUI indexes via migration","description":"## Background\nThe TUI requires covering indexes for keyset pagination and efficient entity lookups. These must be added via the migration system in the main lore crate before TUI GA. Without them, list screens will full-scan on M-tier datasets.\n\n## Approach\nAdd a new migration to `src/core/db.rs` MIGRATIONS array containing exactly 9 CREATE INDEX IF NOT EXISTS statements:\n\n**List pagination indexes (4):**\n1. `idx_issues_list_default ON issues(project_id, state, updated_at DESC, iid DESC)` — covers default issue list sort\n2. `idx_mrs_list_default ON merge_requests(project_id, state, updated_at DESC, iid DESC)` — covers default MR list sort\n3. `idx_discussions_entity ON discussions(project_id, entity_type, entity_iid, created_at DESC)` — covers discussion list for an entity\n4. `idx_notes_discussion ON notes(discussion_id, created_at ASC)` — covers note list within a discussion\n\n**Filter-path indexes (5):**\n5. `idx_issues_author ON issues(author_id, state)` — author filter\n6. `idx_issues_assignee ON issues(assignee_id, state)` — assignee filter\n7. `idx_mrs_author ON merge_requests(author_id, state)` — author filter\n8. `idx_mrs_reviewer ON merge_requests(reviewer_id, state)` — reviewer filter (first reviewer)\n9. `idx_mrs_target_branch ON merge_requests(target_branch, state)` — branch filter\n\nMigration SQL pattern:\n```sql\nCREATE INDEX IF NOT EXISTS idx_issues_list_default\n  ON issues(project_id, state, updated_at DESC, iid DESC);\n-- ... repeat for all 9\n```\n\nBump `LATEST_SCHEMA_VERSION` = `MIGRATIONS.len() as i32` (automatic).\n\nAdd EXPLAIN QUERY PLAN verification tests to confirm the optimizer uses these indexes for the top TUI queries.\n\n## Acceptance Criteria\n- [ ] Migration adds exactly 9 indexes via CREATE INDEX IF NOT EXISTS\n- [ ] Migration is appended to MIGRATIONS array (not inserted in the middle)\n- [ ] LATEST_SCHEMA_VERSION increments by 1\n- [ ] Existing data is not affected (indexes are additive)\n- [ ] EXPLAIN QUERY PLAN for `SELECT * FROM issues WHERE project_id=? AND state=? ORDER BY updated_at DESC, iid DESC LIMIT 50` shows SEARCH using idx_issues_list_default\n- [ ] EXPLAIN QUERY PLAN for `SELECT * FROM merge_requests WHERE project_id=? AND state=? ORDER BY updated_at DESC, iid DESC LIMIT 50` shows SEARCH using idx_mrs_list_default\n- [ ] EXPLAIN QUERY PLAN for `SELECT * FROM discussions WHERE project_id=? AND entity_type=? AND entity_iid=? ORDER BY created_at DESC` shows SEARCH using idx_discussions_entity\n- [ ] EXPLAIN QUERY PLAN for `SELECT * FROM notes WHERE discussion_id=? ORDER BY created_at ASC` shows SEARCH using idx_notes_discussion\n- [ ] No full table scan (SCAN TABLE) on issues or merge_requests under default TUI filters\n- [ ] Migration passes on empty DB (fresh install)\n- [ ] Migration passes on DB with existing data (upgrade path)\n\n## Files\n- MODIFY: src/core/db.rs (add migration to MIGRATIONS array)\n- CREATE: tests/tui_index_verification.rs (EXPLAIN QUERY PLAN tests)\n\n## TDD Anchor\nRED: Write `test_tui_indexes_exist` that runs all migrations on in-memory DB, queries `SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%'` and asserts all 9 index names are present.\nGREEN: Add migration with 9 CREATE INDEX IF NOT EXISTS statements.\nVERIFY: cargo test test_tui_indexes\n\nAdditional tests:\n- test_explain_issues_list_uses_index (EXPLAIN QUERY PLAN output contains \"idx_issues_list_default\")\n- test_explain_mrs_list_uses_index\n- test_explain_discussions_entity_uses_index\n- test_explain_notes_discussion_uses_index\n- test_migration_idempotent (run migrations twice, no error)\n\n## Edge Cases\n- CREATE INDEX IF NOT EXISTS: safe for re-runs and idempotent migration\n- Indexes on large tables may take significant time during first migration on production DBs (1.5GB) — consider PRAGMA busy_timeout\n- Migration version must be bumped correctly: `LATEST_SCHEMA_VERSION = MIGRATIONS.len() as i32` handles this automatically\n- SQLite may choose a different index if statistics differ — EXPLAIN tests verify the optimizer's actual choice\n- Filter-path indexes use (column, state) not (state, column) because the leading column is more selective\n\n## Dependency Context\nThis modifies the main lore crate (src/core/db.rs), NOT lore-tui. It must be merged before TUI screens rely on these indexes for performance SLOs.\nDependents: bd-35g5 (Dashboard state) and all list screen beads consume these indexes.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T17:06:08.180922Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:24.554691Z","deleted_at":"2026-03-11T18:34:24.554684Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-3pxe","title":"Epic: TUI Phase 2.5 — Vertical Slice Gate","description":"## Background\nPhase 2.5 validates that the core screens work together end-to-end: Dashboard -> Issue List -> Issue Detail -> Sync flows correctly, performance SLOs are met, and there are no stuck-input bugs or cancel latency issues. This is a quality gate before investing in power features.\n\n## Acceptance Criteria\n- [ ] Dashboard + IssueList + IssueDetail + Sync screens integrated and navigable\n- [ ] p95 nav latency < 75ms on M-tier fixtures\n- [ ] Zero stuck-input-mode bugs across full flow\n- [ ] Cancel latency p95 < 2s\n- [ ] Bootstrap screen handles empty/incompatible databases gracefully","status":"tombstone","priority":1,"issue_type":"epic","created_at":"2026-02-12T16:59:47.016586Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:28.627633Z","deleted_at":"2026-03-11T18:34:28.627626Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"epic","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-3pz","title":"OBSERV Epic: Phase 4 - Sync History Enrichment","description":"Wire up sync_runs INSERT/UPDATE lifecycle (table exists but nothing writes to it), schema migration 014, enhanced sync-status with recent runs and metrics.\n\nDepends on: Phase 3 (needs Vec<StageTiming> to store in metrics_json)\nUnblocks: nothing (terminal phase)\n\nFiles: migrations/014_sync_runs_enrichment.sql (new), src/core/sync_run.rs (new), src/cli/commands/sync.rs, src/cli/commands/ingest.rs, src/cli/commands/sync_status.rs\n\nAcceptance criteria (PRD Section 6.4):\n- lore sync creates sync_runs row with status=running, updated to succeeded/failed\n- sync_runs.run_id matches log files and robot JSON\n- metrics_json contains serialized Vec<StageTiming>\n- lore sync-status shows last 10 runs with metrics\n- Failed syncs record error and partial metrics\n- Migration 014 applies cleanly","status":"closed","priority":2,"issue_type":"epic","created_at":"2026-02-04T15:53:27.469149Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:43:07.375047Z","closed_at":"2026-02-04T17:43:07.375Z","close_reason":"Phase 4 complete: migration 014, SyncRunRecorder, wiring, sync-status enhancement","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-3pz","depends_on_id":"bd-3er","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-3q2","title":"Implement search filters module","description":"## Background\nSearch filters are applied post-retrieval to narrow results by source type, author, project, date, labels, and file paths. The filter module must preserve ranking order from the search pipeline (FTS/RRF scores). It uses SQLite's JSON1 extension (json_each) to pass ranked document IDs efficiently and maintain their original order.\n\n## Approach\nCreate `src/search/filters.rs` per PRD Section 3.3. The full implementation is specified in the PRD including the SQL query.\n\n**Key types:**\n- `SearchFilters` struct with all filter fields + `has_any_filter()` + `clamp_limit()`\n- `PathFilter` enum: `Prefix(String)` (trailing `/`) or `Exact(String)`\n\n**Core function:**\n```rust\npub fn apply_filters(\n    conn: &Connection,\n    document_ids: &[i64],\n    filters: &SearchFilters,\n) -> Result<Vec<i64>>\n```\n\n**SQL pattern (JSON1 for ordered ID passing):**\n```sql\nSELECT d.id\nFROM json_each(?) AS j\nJOIN documents d ON d.id = j.value\nWHERE 1=1\n  AND d.source_type = ?      -- if source_type filter set\n  AND d.author_username = ?  -- if author filter set\n  -- ... dynamic WHERE clauses\nORDER BY j.key               -- preserves ranking order\nLIMIT ?\n```\n\n**Filter logic:**\n- Labels: AND logic via `EXISTS (SELECT 1 FROM document_labels dl WHERE dl.document_id = d.id AND dl.label_name = ?)`\n- Path prefix: `LIKE ? ESCAPE '\\\\'` with escaped wildcards\n- Path exact: `= ?`\n- Limit: clamped to [1, 100], default 20\n\n## Acceptance Criteria\n- [ ] source_type filter works (issue, merge_request, discussion)\n- [ ] author filter: exact username match\n- [ ] project_id filter: restricts to single project\n- [ ] after filter: created_at >= value\n- [ ] updated_after filter: updated_at >= value\n- [ ] labels filter: AND logic (all specified labels must be present)\n- [ ] path exact filter: matches exact path string\n- [ ] path prefix filter: trailing `/` triggers LIKE with escaped wildcards\n- [ ] Ranking order preserved (ORDER BY j.key from json_each)\n- [ ] Limit clamped: 0 -> 20 (default), 200 -> 100 (max)\n- [ ] Empty document_ids returns empty Vec\n- [ ] Multiple filters compose correctly (all applied via AND)\n- [ ] `cargo test filters` passes\n\n## Files\n- `src/search/filters.rs` — new file\n- `src/search/mod.rs` — add `pub use filters::{SearchFilters, PathFilter, apply_filters};`\n\n## TDD Loop\nRED: Tests in `filters.rs` `#[cfg(test)] mod tests`:\n- `test_no_filters` — all docs returned up to limit\n- `test_source_type_filter` — only issues returned\n- `test_author_filter` — exact match\n- `test_labels_and_logic` — must have ALL specified labels\n- `test_path_exact` — matches exact path\n- `test_path_prefix` — trailing slash matches prefix\n- `test_limit_clamping` — 0 -> 20, 200 -> 100\n- `test_ranking_preserved` — output order matches input order\n- `test_has_any_filter` — true when any filter set, false when default\nGREEN: Implement apply_filters with dynamic SQL\nVERIFY: `cargo test filters`\n\n## Edge Cases\n- Path containing SQL LIKE wildcards (`%`, `_`): must be escaped before LIKE\n- Empty labels list: no label filter applied (not \"must have zero labels\")\n- `has_any_filter()` returns false for default SearchFilters (no filters set)\n- Large document_ids array (1000+): JSON1 handles efficiently","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:26:13.042512Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:24:38.402483Z","closed_at":"2026-01-30T17:24:38.402302Z","close_reason":"Completed: SearchFilters with has_any_filter/clamp_limit, PathFilter enum, apply_filters with dynamic SQL + json_each ordering, escape_like, 8 tests pass","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3q2","depends_on_id":"bd-36p","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-3q5e","title":"Task 4: Wire timeline excerpt using existing pipeline","description":"## Background\n\nThe timeline excerpt section reuses the existing 5-stage timeline pipeline (SEED -> HYDRATE -> EXPAND -> COLLECT -> RENDER) to provide a chronological event summary. This avoids reimplementing timeline logic.\n\n**Parent bead:** bd-9lbr (lore explain feature)\n**Spec:** specs/SPEC_explain.md — Task 4\n**Phase:** 2 — Core Logic\n**Depends on:** Task 1 (bd-2i3z) — needs ExplainParams, ExplainResult types, run_explain skeleton, and test helpers\n\n## Architecture\n\nImport existing pipeline:\n\\`\\`\\`rust\nuse crate::timeline::seed::seed_timeline_direct;\nuse crate::timeline::collect::collect_events;\nuse crate::timeline::types::{EntityRef, TimelineEvent, TimelineEventType, ExpandedEntityRef};\n\\`\\`\\`\n\n### Exact Function Signatures (verified from codebase):\n\n\\`\\`\\`rust\npub fn seed_timeline_direct(\n    conn: &Connection,\n    entity_type: &str,   // \"issue\" or \"merge_request\" (NOT \"issues\"/\"mrs\")\n    iid: i64,\n    project_id: Option<i64>,\n) -> Result<SeedResult>\n\npub struct SeedResult {\n    pub seed_entities: Vec<EntityRef>,\n    pub evidence_notes: Vec<TimelineEvent>,\n    pub matched_discussions: Vec<MatchedDiscussion>,\n    pub search_mode: String,\n}\n\npub fn collect_events(\n    conn: &Connection,\n    seed_entities: &[EntityRef],\n    expanded_entities: &[ExpandedEntityRef],\n    evidence_notes: &[TimelineEvent],\n    matched_discussions: &[MatchedDiscussion],\n    since_ms: Option<i64>,\n    limit: usize,\n) -> Result<(Vec<TimelineEvent>, usize)>\n// IMPORTANT: Returns TUPLE. Second element is total_before_limit.\n\\`\\`\\`\n\n### Key Types\n\n\\`\\`\\`rust\npub struct EntityRef {\n    pub entity_type: String,   // \"issue\" or \"merge_request\"\n    pub entity_id: i64,        // LOCAL SQLite row id\n    pub entity_iid: i64,\n    pub project_path: String,\n}\n\npub struct TimelineEvent {\n    pub timestamp: i64,        // ms epoch\n    pub entity_type: String,\n    pub entity_id: i64,\n    pub entity_iid: i64,\n    pub project_path: String,\n    pub event_type: TimelineEventType,\n    pub summary: String,\n    pub actor: Option<String>,\n    pub url: Option<String>,\n    pub is_seed: bool,\n}\n\n// Tagged enum with snake_case variant names\n#[serde(tag = \"kind\", rename_all = \"snake_case\")]\npub enum TimelineEventType {\n    Created,\n    StateChanged { state: String },\n    LabelAdded { label: String },\n    LabelRemoved { label: String },\n    MilestoneSet { milestone: String },\n    MilestoneRemoved { milestone: String },\n    Merged,\n    NoteEvidence { note_id: i64, snippet: String, discussion_id: Option<i64> },\n    DiscussionThread { discussion_id: i64, notes: Vec<ThreadNote> },\n    CrossReferenced { target: String },\n}\n\\`\\`\\`\n\n### Simplified Pipeline for Explain\n\nWe skip HYDRATE and EXPAND (those are for cross-entity expansion). Simplified flow:\n1. seed_timeline_direct() -> SeedResult\n2. collect_events() with empty expanded_entities slice\n3. Pass limit=20 directly (collect_events caps internally)\n\n### entity_type Mapping\n\nseed_timeline_direct expects \"issue\" or \"merge_request\" (singular, no \"s\").\nExplainParams has \"issues\" or \"mrs\". Map:\n- \"issues\" -> \"issue\"\n- \"mrs\" -> \"merge_request\"\n\n### TimelineEventType to String Mapping\n\nFor TimelineEventSummary.event_type, match on the enum variant:\n\\`\\`\\`rust\nfn event_type_string(et: &TimelineEventType) -> &'static str {\n    match et {\n        TimelineEventType::Created => \"created\",\n        TimelineEventType::StateChanged { .. } => \"state_changed\",\n        TimelineEventType::LabelAdded { .. } => \"label_added\",\n        TimelineEventType::LabelRemoved { .. } => \"label_removed\",\n        TimelineEventType::MilestoneSet { .. } => \"milestone_set\",\n        TimelineEventType::MilestoneRemoved { .. } => \"milestone_removed\",\n        TimelineEventType::Merged => \"merged\",\n        TimelineEventType::NoteEvidence { .. } => \"note\",\n        TimelineEventType::DiscussionThread { .. } => \"discussion\",\n        TimelineEventType::CrossReferenced { .. } => \"cross_referenced\",\n    }\n}\n\\`\\`\\`\n\n## Logic\n\n\\`\\`\\`rust\nfn fetch_timeline_excerpt(\n    conn: &Connection,\n    params: &ExplainParams,\n    entity_local_id: i64,\n    project_path: &str,\n    project_id: Option<i64>,\n) -> Result<Option<Vec<TimelineEventSummary>>> {\n    if params.no_timeline || !should_include(&params.sections, \"timeline\") {\n        return Ok(None);\n    }\n\n    // Map entity_type for timeline API\n    let timeline_entity_type = if params.entity_type == \"issues\" { \"issue\" } else { \"merge_request\" };\n\n    let seed = seed_timeline_direct(conn, timeline_entity_type, params.iid, project_id)?;\n    let empty_expanded: Vec<ExpandedEntityRef> = vec![];\n    let (events, _total) = collect_events(\n        conn,\n        &seed.seed_entities,\n        &empty_expanded,\n        &seed.evidence_notes,\n        &seed.matched_discussions,\n        params.since,\n        20, // cap at 20 events\n    )?;\n\n    let summaries: Vec<TimelineEventSummary> = events.iter().map(|e| TimelineEventSummary {\n        timestamp: ms_to_iso(e.timestamp),\n        event_type: event_type_string(&e.event_type).to_owned(),\n        actor: e.actor.clone(),\n        summary: e.summary.clone(),\n    }).collect();\n\n    Ok(Some(summaries))\n}\n\\`\\`\\`\n\n--no-timeline takes precedence over --sections timeline (if both specified, skip timeline).\n\n## Files to Modify\n\n- src/cli/commands/explain.rs — add fetch_timeline_excerpt(), event_type_string(), wire into run_explain()\n\n## TDD Workflow (Red-Green)\n\n### RED — Write these tests first, confirm they FAIL:\n\n1. test_explain_timeline_excerpt: Insert issue + at least 1 state event + 1 note (to generate timeline events), call run_explain() with no_timeline: false and sections: None, assert timeline_excerpt is Some and non-empty and len <= 20\n2. test_explain_no_timeline_flag: Call run_explain() with no_timeline: true, assert timeline_excerpt is None\n\nNote: Timeline tests depend on the full pipeline working, so they need a realistic DB state with project, issue/MR, events, discussions, and notes.\n\n### GREEN — Implement fetch_timeline_excerpt() and event_type_string() as shown above. Wire into run_explain().\n\n### Verify:\ncargo test explain::tests::test_explain_timeline && cargo test explain::tests::test_explain_no_timeline_flag && cargo clippy --all-targets -- -D warnings\n\n## Implementation Notes\n\n- seed_timeline_direct builds seeds directly from entity (no search/embedding/Ollama needed)\n- EntityRef needs: entity_type (\"issue\"/\"merge_request\"), entity_id (LOCAL SQLite row id from find_explain_issue/mr), entity_iid, project_path\n- collect_events filters by since_ms internally — pass params.since directly\n- The timeline is the heaviest part of explain — --no-timeline exists for faster execution\n\n## Acceptance Criteria\n\n- [ ] test_explain_timeline_excerpt passes\n- [ ] test_explain_no_timeline_flag passes\n- [ ] Timeline excerpt present with max 20 events when enabled\n- [ ] Skipped entirely when --no-timeline\n- [ ] Uses existing timeline pipeline (no reimplementation)\n- [ ] event_type field uses snake_case variant names\n- [ ] cargo clippy and cargo fmt clean","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-10T17:37:51.617962Z","created_by":"tayloreernisse","updated_at":"2026-03-10T19:03:29.060847Z","closed_at":"2026-03-10T19:03:29.060702Z","close_reason":"Implemented: all explain command sections (activity, open_threads, related, timeline_excerpt, human/robot renderers) merged into main explain.rs, robot_docs updated, autocorrect registered, 1046 tests passing","compaction_level":0,"original_size":0,"labels":["cli-imp","explain"],"dependencies":[{"issue_id":"bd-3q5e","depends_on_id":"bd-2i3z","type":"blocks","created_at":"2026-03-10T17:38:18.792136Z","created_by":"tayloreernisse"},{"issue_id":"bd-3q5e","depends_on_id":"bd-9lbr","type":"parent-child","created_at":"2026-03-10T17:37:51.619764Z","created_by":"tayloreernisse"}]}
{"id":"bd-3qm","title":"[CP1] Final validation - tests, smoke tests, integrity checks","description":"Run all tests and perform data integrity checks.\n\nValidation steps:\n1. Run all unit tests (vitest)\n2. Run all integration tests\n3. Run ESLint\n4. Run TypeScript strict check\n5. Manual smoke tests per PRD table\n6. Data integrity SQL checks:\n   - Issue count matches GitLab\n   - Every issue has raw_payload\n   - Labels in junction exist in labels table\n   - sync_cursors has entry per project\n   - Re-run fetches 0 new items\n   - Discussion count > 0\n   - Every discussion has >= 1 note\n   - individual_note=true has exactly 1 note\n\nFiles: All CP1 files\nDone when: All gate criteria from Definition of Done pass","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:20:51.994183Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.152852Z","closed_at":"2026-01-25T15:21:35.152852Z","deleted_at":"2026-01-25T15:21:35.152849Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-3qn6","title":"Rewrite who --path to use mr_file_changes for authorship signal","description":"## Problem\n\nwho --path currently only queries DiffNote records (notes.position_new_path), so it only finds people who left inline review comments on that exact file. This is highly misleading -- it reports 'no experts' for files that have been actively authored and reviewed, just without inline comments on that specific path.\n\n## Solution\n\nRewrite query_expert() to incorporate mr_file_changes as a primary signal source:\n\n1. MR authorship signal: JOIN mr_file_changes to find MR authors who touched the file (strongest signal)\n2. MR reviewer signal: JOIN mr_file_changes + merge_request_reviewers to find reviewers of MRs that touched the file (even without DiffNotes on that file)\n3. DiffNote signal: Keep existing DiffNote query as a supplementary signal (inline comments show deep familiarity)\n\n### Scoring weights (to tune):\n- MR author who touched the file: 15 points per MR\n- MR reviewer of MR touching the file: 10 points per MR\n- DiffNote reviewer on that file: 20 points per MR + 1 per note (existing)\n- DiffNote MR author: 12 points per MR (existing)\n\n### Path matching:\n- Reuse build_path_query() but extend DB probes to also check mr_file_changes.new_path\n- For prefix matching, LIKE on mr_file_changes.new_path\n\n### Also fix:\n- build_path_query() probes should check mr_file_changes in addition to notes, so path resolution works even when no DiffNotes exist\n\n## Acceptance Criteria\n- who --path returns results for files touched in MRs even without DiffNotes\n- Existing DiffNote-based scoring still contributes\n- build_path_query probes mr_file_changes for path existence\n- Tests cover: MR-only authorship, DiffNote-only, combined scoring\n- Robot mode JSON output unchanged (same schema)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-08T18:16:41.991344Z","created_by":"tayloreernisse","updated_at":"2026-02-08T18:34:25.704024Z","closed_at":"2026-02-08T18:34:25.703965Z","close_reason":"Rewrote query_expert() and query_overlap() in who.rs to incorporate mr_file_changes + mr_reviewers as signal sources alongside existing DiffNote data. Uses 4-branch UNION ALL with COUNT(DISTINCT CASE) for proper deduplication across signal types. 8 new tests, all 397 pass.","compaction_level":0,"original_size":0,"labels":["cli","phase-b","who"],"dependencies":[{"issue_id":"bd-3qn6","depends_on_id":"bd-2yo","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-3qs","title":"Implement lore generate-docs CLI command","description":"## Background\nThe generate-docs CLI command is the user-facing wrapper around the document regeneration pipeline. It has two modes: incremental (default, processes dirty_sources queue only) and full (seeds dirty_sources with ALL entities, then drains). Both modes use the same regenerator codepath to avoid logic divergence. Full mode uses keyset pagination (WHERE id > last_id) for seeding to avoid O(n^2) OFFSET degradation on large tables.\n\n## Approach\nCreate `src/cli/commands/generate_docs.rs` per PRD Section 2.4.\n\n**Core function:**\n```rust\npub fn run_generate_docs(\n    config: &Config,\n    full: bool,\n    project_filter: Option<&str>,\n) -> Result<GenerateDocsResult>\n```\n\n**Full mode seeding (keyset pagination):**\n```rust\nconst FULL_MODE_CHUNK_SIZE: usize = 2000;\n\n// For each source type (issues, MRs, discussions):\nlet mut last_id: i64 = 0;\nloop {\n    let tx = conn.transaction()?;\n    let inserted = tx.execute(\n        \"INSERT INTO dirty_sources (source_type, source_id, queued_at, ...)\n         SELECT 'issue', id, ?, 0, NULL, NULL, NULL\n         FROM issues WHERE id > ? ORDER BY id LIMIT ?\n         ON CONFLICT(source_type, source_id) DO NOTHING\",\n        params![now_ms(), last_id, FULL_MODE_CHUNK_SIZE],\n    )?;\n    if inserted == 0 { tx.commit()?; break; }\n    // Advance keyset cursor...\n    tx.commit()?;\n}\n```\n\n**After draining (full mode only):**\n```sql\nINSERT INTO documents_fts(documents_fts) VALUES('optimize')\n```\n\n**CLI args:**\n```rust\n#[derive(Args)]\npub struct GenerateDocsArgs {\n    #[arg(long)]\n    full: bool,\n    #[arg(long)]\n    project: Option<String>,\n}\n```\n\n**Output:** Human-readable table + JSON robot mode.\n\n## Acceptance Criteria\n- [ ] Default mode (no --full): processes only existing dirty_sources entries\n- [ ] --full mode: seeds dirty_sources with ALL issues, MRs, and discussions\n- [ ] Full mode uses keyset pagination (WHERE id > last_id, not OFFSET)\n- [ ] Full mode chunk size is 2000\n- [ ] Full mode does FTS optimize after completion\n- [ ] Both modes use regenerate_dirty_documents() (same codepath)\n- [ ] Progress bar shown in human mode (via indicatif)\n- [ ] JSON output in robot mode with GenerateDocsResult\n- [ ] GenerateDocsResult has issues/mrs/discussions/total/truncated/skipped counts\n- [ ] `cargo build` succeeds\n\n## Files\n- `src/cli/commands/generate_docs.rs` — new file\n- `src/cli/commands/mod.rs` — add `pub mod generate_docs;`\n- `src/cli/mod.rs` — add GenerateDocsArgs, wire up generate-docs subcommand\n- `src/main.rs` — add generate-docs command handler\n\n## TDD Loop\nRED: Integration test with seeded DB\nGREEN: Implement run_generate_docs with seeding + drain\nVERIFY: `cargo build && cargo test generate_docs`\n\n## Edge Cases\n- Empty database (no issues/MRs/discussions): full mode seeds nothing, returns all-zero counts\n- --project filter in full mode: only seed dirty_sources for entities in that project\n- Interrupted full mode: dirty_sources entries persist (ON CONFLICT DO NOTHING), resume by re-running\n- FTS optimize on empty FTS table: no-op (safe)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:25:55.226666Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:49:23.397157Z","closed_at":"2026-01-30T17:49:23.397098Z","close_reason":"Implemented generate-docs command with incremental + full mode, keyset pagination seeding, FTS optimize, project filter, human + JSON output. Builds clean.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3qs","depends_on_id":"bd-1u1","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-3qs","depends_on_id":"bd-221","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-3rl","title":"Epic: Gate C - Sync MVP","description":"## Background\nGate C adds the sync orchestrator and queue infrastructure that makes the search pipeline incremental and self-maintaining. It introduces dirty source tracking (change detection during ingestion), the discussion fetch queue, and the unified lore sync command that orchestrates the full pipeline. Gate C also adds integrity checks and repair paths.\n\n## Gate C Deliverables\n1. Orchestrated lore sync command with incremental doc regen + re-embedding\n2. Integrity checks + repair paths for FTS/embeddings consistency\n\n## Bead Dependencies (execution order, after Gate A)\n1. **bd-mem** — Shared backoff utility (no deps, shared with Gate B)\n2. **bd-38q** — Dirty source tracking (blocked by bd-36p, bd-hrs, bd-mem)\n3. **bd-1je** — Discussion queue (blocked by bd-hrs, bd-mem)\n4. **bd-1i2** — Integrate dirty tracking into ingestion (blocked by bd-38q)\n5. **bd-1x6** — Sync CLI (blocked by bd-38q, bd-1je, bd-1i2, bd-3qs, bd-2sx)\n\n## Acceptance Criteria\n- [ ] `lore sync` runs full pipeline: ingest -> generate-docs -> embed\n- [ ] `lore sync --full` does full re-sync + regeneration\n- [ ] `lore sync --no-embed` skips embedding stage\n- [ ] Dirty tracking: upserted entities automatically marked for regeneration\n- [ ] Queue draining: dirty_sources fully drained in bounded batch loop\n- [ ] Backoff: failed items use exponential backoff with jitter\n- [ ] `lore stats --check` detects inconsistencies\n- [ ] `lore stats --repair` fixes FTS/embedding inconsistencies","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-30T15:25:13.494698Z","created_by":"tayloreernisse","updated_at":"2026-01-30T18:05:52.121666Z","closed_at":"2026-01-30T18:05:52.121619Z","close_reason":"All Gate C sub-beads complete: backoff utility, dirty tracking, discussion queue, ingestion integration, sync CLI, stats CLI","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3rl","depends_on_id":"bd-1x6","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-3rl","depends_on_id":"bd-pr1","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-3sez","title":"Create surgical.rs core module with preflight fetch, ingest functions, and TOCTOU guards","description":"## Background\n\nThe surgical sync pipeline needs a core module (`src/ingestion/surgical.rs`) that fetches a single issue or MR by IID from GitLab and ingests it into the local SQLite database. This replaces the bulk pagination path (`ingest_issues`/`ingest_merge_requests`) for targeted, on-demand sync of specific entities.\n\nKey constraints:\n- `process_single_issue` (issues.rs:143) and `process_single_mr` (merge_requests.rs:144) are private functions. This bead wraps them with pub(crate) entry points that add TOCTOU guard logic and dirty marking.\n- `updated_at` is a `String` (ISO 8601) in `GitLabIssue`/`GitLabMergeRequest` but stored as `INTEGER` (ms-epoch) in the DB. The TOCTOU guard must parse the ISO string to ms-epoch for comparison.\n- `ProcessMrResult` (merge_requests.rs:138) is a private struct. The MR ingest wrapper returns its own result type or re-exports the needed fields.\n- `SyncRunRecorder` has `succeed()` and `fail()` that consume `self`. Not needed here since surgical.rs is called from the orchestrator which owns the recorder.\n\n## Approach\n\nCreate `src/ingestion/surgical.rs` with:\n\n1. **`preflight_fetch`** (async): Takes `&GitLabClient`, `gitlab_project_id`, and a list of `(entity_type, iid)` targets. Calls `client.get_issue_by_iid()` and `client.get_mr_by_iid()` (from bd-159p). Returns `PreflightResult { issues: Vec<GitLabIssue>, merge_requests: Vec<GitLabMergeRequest>, failures: Vec<PreflightFailure> }`.\n\n2. **`ingest_issue_by_iid`** (sync): Takes `&Connection`, `&Config`, `project_id`, `&GitLabIssue`. Applies TOCTOU guard (compare payload `updated_at` parsed to ms-epoch vs DB `updated_at`), then calls `process_single_issue` (requires making it `pub(crate)` in bd-1sc6), marks dirty via `dirty_tracker::mark_dirty(conn, SourceType::Issue, local_issue_id)`, and returns `IngestIssueResult { upserted: bool, labels_created: usize, skipped_stale: bool, dirty_source_keys: Vec<(SourceType, i64)> }`.\n\n3. **`ingest_mr_by_iid`** (sync): Same pattern for MRs. Calls `process_single_mr` (requires `pub(crate)` in bd-1sc6), returns `IngestMrResult { upserted: bool, labels_created: usize, assignees_linked: usize, reviewers_linked: usize, skipped_stale: bool, dirty_source_keys: Vec<(SourceType, i64)> }`.\n\n4. **TOCTOU guard**: `fn is_stale(payload_updated_at: &str, db_updated_at_ms: Option<i64>) -> Result<bool>`. Parses ISO 8601 string to ms-epoch using `chrono::DateTime::parse_from_rfc3339`. Returns `true` if `payload_ms <= db_ms` (payload is same age or older than what we already have).\n\nWire the module in `src/ingestion/mod.rs`.\n\n## Acceptance Criteria\n\n- [ ] `preflight_fetch` calls GitLabClient by-IID methods and collects successes + failures\n- [ ] `ingest_issue_by_iid` wraps `process_single_issue` with TOCTOU guard and dirty marking\n- [ ] `ingest_mr_by_iid` wraps `process_single_mr` with TOCTOU guard and dirty marking\n- [ ] TOCTOU guard correctly parses ISO 8601 String to ms-epoch for comparison with DB i64\n- [ ] Stale payloads (payload updated_at <= DB updated_at) are skipped, not ingested\n- [ ] `dirty_source_keys` returned include the `(SourceType, source_id)` tuples for downstream scoped doc regen\n- [ ] Module registered in `src/ingestion/mod.rs`\n- [ ] All 14 sync tests pass with in-memory SQLite\n- [ ] Async preflight test passes with wiremock\n- [ ] Test helpers produce valid GitLabIssue/GitLabMergeRequest fixtures that pass `transform_issue`/`transform_merge_request`\n- [ ] No flaky tests: deterministic timestamps, no real network calls\n\n## Files\n\n- `src/ingestion/surgical.rs` (NEW) — production code + `#[cfg(test)] #[path = \"surgical_tests.rs\"] mod tests;`\n- `src/ingestion/surgical_tests.rs` (NEW) — test suite\n- `src/ingestion/mod.rs` (add `pub(crate) mod surgical;`)\n- `src/ingestion/issues.rs` (change `process_single_issue` to `pub(crate)` — done in bd-1sc6)\n- `src/ingestion/merge_requests.rs` (change `process_single_mr` and `ProcessMrResult` to `pub(crate)` — done in bd-1sc6)\n\n## TDD Anchor\n\nTests live in `src/ingestion/surgical_tests.rs`, referenced via `#[cfg(test)] #[path = \"surgical_tests.rs\"] mod tests;` in surgical.rs.\n\n### Test Helpers\n- `setup_db() -> Connection` — in-memory DB with migrations + test project row\n- `make_test_issue(iid: i64, updated_at: &str) -> GitLabIssue` — minimal valid JSON fixture\n- `make_test_mr(iid: i64, updated_at: &str) -> GitLabMergeRequest` — minimal valid JSON fixture\n- `get_db_updated_at(conn, table, iid) -> Option<i64>` — helper to query DB updated_at for assertions\n- `get_dirty_keys(conn) -> Vec<(String, i64)>` — query dirty_sources for assertions\n\n### Sync Tests (13)\n1. `test_ingest_issue_by_iid_upserts_and_marks_dirty` — fresh issue ingest, verify DB row + dirty_sources entry\n2. `test_ingest_mr_by_iid_upserts_and_marks_dirty` — fresh MR ingest, verify DB row + dirty_sources entry\n3. `test_toctou_skips_stale_issue` — insert issue at T1, call ingest with payload at T1, assert skipped_stale=true and no dirty mark\n4. `test_toctou_skips_stale_mr` — same for MRs\n5. `test_toctou_allows_newer_issue` — DB has T1, payload has T2 (T2 > T1), assert upserted=true\n6. `test_toctou_allows_newer_mr` — same for MRs\n7. `test_is_stale_parses_iso8601` — unit test: `\"2026-02-17T12:00:00.000+00:00\"` parses to correct ms-epoch\n8. `test_is_stale_handles_none_db_value` — first ingest, no DB row, assert not stale\n9. `test_is_stale_with_z_suffix` — `\"2026-02-17T12:00:00Z\"` also parses correctly\n10. `test_ingest_issue_returns_dirty_source_keys` — verify `dirty_source_keys` contains `(SourceType::Issue, local_id)`\n11. `test_ingest_mr_returns_dirty_source_keys` — verify MR dirty source keys\n12. `test_ingest_issue_updates_existing` — ingest same IID twice with newer updated_at, verify update\n13. `test_ingest_mr_updates_existing` — same for MRs\n\n### Async Preflight Test (1, wiremock)\n14. `test_preflight_fetch_returns_issues_and_mrs` — wiremock GET `/projects/:id/issues?iids[]=42` returns 200 with fixture, verify PreflightResult.issues has 1 entry\n\n### Key test code:\n\n```rust\n#[test]\nfn test_ingest_issue_by_iid_upserts_and_marks_dirty() {\n    let conn = setup_db();\n    let issue = make_test_issue(42, \"2026-02-17T12:00:00.000+00:00\");\n    let config = Config::default();\n    let result = ingest_issue_by_iid(&conn, &config, /*project_id=*/1, &issue).unwrap();\n    assert!(result.upserted);\n    assert!(!result.skipped_stale);\n    let dirty = get_dirty_keys(&conn);\n    assert!(dirty.contains(&(\"issue\".to_string(), /*local_id from DB*/)));\n}\n\n#[test]\nfn test_toctou_skips_stale_issue() {\n    let conn = setup_db();\n    let issue = make_test_issue(42, \"2026-02-17T12:00:00.000+00:00\");\n    ingest_issue_by_iid(&conn, &Config::default(), 1, &issue).unwrap();\n    // Ingest same timestamp again\n    let result = ingest_issue_by_iid(&conn, &Config::default(), 1, &issue).unwrap();\n    assert!(result.skipped_stale);\n}\n\n#[tokio::test]\nasync fn test_preflight_fetch_returns_issues_and_mrs() {\n    let mock = MockServer::start().await;\n    // ... wiremock setup ...\n}\n```\n\n### Testing constraints\n- In-memory DB pattern: `create_connection(Path::new(\":memory:\"))` + `run_migrations(&conn)`\n- Test project insert: `INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url)` (no `name`/`last_seen_at` columns)\n- `GitLabIssue` required fields: `id`, `iid`, `project_id`, `title`, `state`, `created_at`, `updated_at`, `author`, `web_url`\n- `GitLabMergeRequest` adds: `source_branch`, `target_branch`, `draft`, `merge_status`, `reviewers`\n- `updated_at` is `String` (ISO 8601) in GitLab types, e.g. `\"2026-02-17T12:00:00.000+00:00\"`\n- `SourceType` enum variants: `Issue`, `MergeRequest`, `Discussion`, `Note`\n- `dirty_sources` table: `(source_type TEXT, source_id INTEGER)` primary key\n\nRED: Write all test functions. They will not compile until production code exists.\nGREEN: Implement preflight_fetch, ingest_issue_by_iid, ingest_mr_by_iid, is_stale.\nVERIFY: `cargo test -p lore surgical`\n\n## Edge Cases\n\n- ISO 8601 with timezone offset (GitLab returns `+00:00` not `Z`) must parse correctly\n- `test_is_stale_with_z_suffix` ensures `Z` variant also works\n- First-ever ingest of an IID: no existing DB row, TOCTOU guard must treat as \"not stale\" (db_updated_at is None)\n- GitLab returns 404 for a deleted issue/MR during preflight: failure, not hard error\n- Concurrent surgical syncs for same IID: `process_single_issue` uses `unchecked_transaction()` with UPSERT, so last-writer-wins is safe\n- `process_single_mr` returns `ProcessMrResult` which is private: either make it `pub(crate)` in bd-1sc6 or replicate needed fields\n- `make_test_issue` must produce all required fields or `transform_issue` will fail\n- `make_test_mr` additionally needs `source_branch`, `target_branch`, `draft`, `merge_status`, `reviewers`\n- ISO 8601 fixtures must use `+00:00` suffix (GitLab format), not `Z` (test both)\n- Test DB needs `run_migrations` to create all tables including `dirty_sources`, `documents`, `issues`, `merge_requests`\n\n## Dependency Context\n\n- **Blocked by bd-159p**: `get_issue_by_iid` and `get_mr_by_iid` on GitLabClient (preflight needs these)\n- **Blocked by bd-1sc6**: Visibility changes to `process_single_issue`, `process_single_mr`, `ProcessMrResult` (must be `pub(crate)`)\n- **Blocks bd-1i4i**: Orchestration function calls `preflight_fetch` + `ingest_issue_by_iid` / `ingest_mr_by_iid`\n- **Blocks bd-kanh**: Dependent helpers are called after ingest to fetch discussions, resource events, etc.\n- **Blocks bd-wcja**: SyncResult surgical fields depend on return types from this module\n- **Blocks bd-3jqx**: Integration test stubs from this bead's test file are implemented in bd-3jqx","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-17T19:14:19.449695Z","created_by":"tayloreernisse","updated_at":"2026-02-18T21:05:03.755164Z","closed_at":"2026-02-18T21:05:03.755107Z","close_reason":"Completed: surgical.rs core module with preflight, ingest, TOCTOU, dirty tracking","compaction_level":0,"original_size":0,"labels":["surgical-sync"],"dependencies":[{"issue_id":"bd-3sez","depends_on_id":"bd-1i4i","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-3sez","depends_on_id":"bd-kanh","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-3sez","depends_on_id":"bd-wcja","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-3sh","title":"Add 'lore count events' command with robot mode","description":"## Background\nNeed to verify event ingestion and report counts by type. The existing count command (src/cli/commands/count.rs) handles issues, mrs, discussions, notes with both human and robot output. This adds 'events' as a new count subcommand.\n\n## Approach\nExtend the existing count command in src/cli/commands/count.rs:\n\n1. Add CountTarget::Events variant (or string match) in the count dispatcher\n2. Query each event table with GROUP BY entity type:\n```sql\nSELECT \n  CASE WHEN issue_id IS NOT NULL THEN 'issue' ELSE 'merge_request' END as entity_type,\n  COUNT(*) as count\nFROM resource_state_events\nGROUP BY entity_type;\n-- (repeat for label and milestone events)\n```\n\n3. Human output: table format\n```\nEvent Type       Issues  MRs    Total\nState events     1,234   567    1,801\nLabel events     2,345   890    3,235\nMilestone events   456   123      579\nTotal            4,035   1,580  5,615\n```\n\n4. Robot JSON:\n```json\n{\n  \"ok\": true,\n  \"data\": {\n    \"state_events\": {\"issue\": 1234, \"merge_request\": 567, \"total\": 1801},\n    \"label_events\": {\"issue\": 2345, \"merge_request\": 890, \"total\": 3235},\n    \"milestone_events\": {\"issue\": 456, \"merge_request\": 123, \"total\": 579},\n    \"total\": 5615\n  }\n}\n```\n\n5. Register in CLI: add \"events\" to count's entity_type argument in src/cli/mod.rs\n\n## Acceptance Criteria\n- [ ] `lore count events` shows correct counts by event type and entity type\n- [ ] Robot JSON matches the schema above\n- [ ] Works with empty tables (all zeros)\n- [ ] Does not error if migration 011 hasn't been applied (graceful degradation or \"no event tables\" message)\n\n## Files\n- src/cli/commands/count.rs (add events counting logic)\n- src/cli/mod.rs (add \"events\" to count's accepted entity types)\n\n## TDD Loop\nRED: tests/count_tests.rs (or extend existing):\n- `test_count_events_empty_tables` - verify all zeros on fresh DB\n- `test_count_events_with_data` - seed state + label events, verify correct counts\n- `test_count_events_robot_json` - verify JSON structure\n\nGREEN: Add the events branch to count command\n\nVERIFY: `cargo test count -- --nocapture`\n\n## Edge Cases\n- Tables don't exist if user hasn't run migrate — check table existence first or catch the error\n- COUNT with GROUP BY returns no rows for empty tables — need to handle missing entity types as 0","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-02T21:31:57.379702Z","created_by":"tayloreernisse","updated_at":"2026-02-03T16:21:21.408874Z","closed_at":"2026-02-03T16:21:21.408806Z","close_reason":"Added 'events' to count CLI parser, run_count_events function, print_event_count (table format) and print_event_count_json (structured JSON). Wired into handle_count in main.rs.","compaction_level":0,"original_size":0,"labels":["cli","gate-1","phase-b"],"dependencies":[{"issue_id":"bd-3sh","depends_on_id":"bd-2zl","type":"parent-child","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-3sh","depends_on_id":"bd-hu3","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-3t1b","title":"Implement MR Detail (state + action + view)","description":"## Background\nThe MR Detail shows a single merge request with file changes, diff discussions (position-specific comments), and general discussions. Same progressive hydration pattern as Issue Detail. MR detail has additional sections: file change list and diff-context notes.\n\n## Approach\nState (state/mr_detail.rs):\n- MrDetailState: current_key (Option<EntityKey>), metadata (Option<MrMetadata>), discussions (Vec<Discussion>), diff_discussions (Vec<DiffDiscussion>), file_changes (Vec<FileChange>), cross_refs (Vec<CrossRef>), tree_state (TreePersistState), scroll_offset, active_tab (MrTab: Overview|Files|Discussions)\n- MrMetadata: iid, title, description, state, author, reviewer, assignee, labels, target_branch, source_branch, created_at, updated_at, web_url, draft, merge_status\n- FileChange: old_path, new_path, change_type (added/modified/deleted/renamed), diff_line_count\n- DiffDiscussion: file_path, old_line, new_line, notes (Vec<Note>)\n\nAction (action.rs):\n- fetch_mr_detail(conn, key, clock) -> Result<MrDetail, LoreError>: uses with_read_snapshot\n\nView (view/mr_detail.rs):\n- render_mr_detail(frame, state, area, theme): header, tab bar (Overview|Files|Discussions), tab content\n- Overview tab: description + cross-refs\n- Files tab: file change list with change type indicators (+/-/~)\n- Discussions tab: general discussions + diff discussions grouped by file\n\n## Acceptance Criteria\n- [ ] MR metadata loads in Phase 1\n- [ ] Tab navigation between Overview, Files, Discussions\n- [ ] File changes list shows change type and line count\n- [ ] Diff discussions grouped by file path\n- [ ] General discussions rendered in tree widget\n- [ ] Cross-references navigable (related issues, etc.)\n- [ ] All text sanitized via sanitize_for_terminal()\n- [ ] Esc returns to MR List with state preserved\n\n## Files\n- MODIFY: crates/lore-tui/src/state/mr_detail.rs (expand from stub)\n- MODIFY: crates/lore-tui/src/action.rs (add fetch_mr_detail)\n- CREATE: crates/lore-tui/src/view/mr_detail.rs\n\n## TDD Anchor\nRED: Write test_fetch_mr_detail in action.rs that inserts an MR with 3 file changes, calls fetch_mr_detail, asserts 3 files returned.\nGREEN: Implement fetch_mr_detail with file change query.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_fetch_mr_detail\n\n## Edge Cases\n- MR with no file changes (draft MR created without pushes): show \"No file changes\" message\n- Diff discussions referencing deleted files: show file path with strikethrough style\n- Very large MRs (hundreds of files): paginate file list, don't load all at once\n\n## Dependency Context\nUses discussion tree and cross-ref widgets from \"Implement discussion tree + cross-reference widgets\" task.\nUses same patterns as \"Implement Issue Detail\" task.\nUses MrDetailState from \"Implement AppState composition\" task.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T16:59:38.427124Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:23.788489Z","deleted_at":"2026-03-11T18:34:23.788485Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-3t6r","title":"Epic: TUI Phase 5 — Polish","description":"## Background\nPhase 5 adds polish features: responsive breakpoints for all screens, session state persistence (resume where you left off), single-instance locking, entity/render caches for performance, text width handling for Unicode, snapshot tests, and terminal compatibility test matrix.\n\n## Acceptance Criteria\n- [ ] All screens adapt to terminal width with responsive breakpoints\n- [ ] Session state persisted and restored on relaunch\n- [ ] Single-instance lock prevents concurrent TUI launches\n- [ ] Entity cache enables near-instant detail view reopens\n- [ ] Snapshot tests produce deterministic output with FakeClock\n- [ ] Terminal compat verified across iTerm2, tmux, Alacritty, kitty","status":"tombstone","priority":1,"issue_type":"epic","created_at":"2026-02-12T17:02:47.178645Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:28.386080Z","deleted_at":"2026-03-11T18:34:28.386076Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"epic","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-3ty8","title":"Implement Bootstrap screen + schema preflight","description":"## Background\nThe Bootstrap screen handles first-launch and incompatible-database scenarios. Before entering the TUI event loop, a schema preflight check validates the database is compatible. If not, an actionable error is shown. The Bootstrap screen also guides users through initial sync if the database is empty.\n\n## Approach\n- Schema preflight in lib.rs: check schema version before creating LoreApp. If incompatible, print error with lore migrate suggestion and exit non-zero.\n- Bootstrap screen (Screen::Bootstrap): shown when database has zero issues/MRs. Shows: \"No data found. Run sync to get started.\" with option to start sync inline.\n- State: BootstrapState { has_data: bool, schema_ok: bool, config_valid: bool }\n- Action: check_data_readiness(conn) -> DataReadiness { has_issues: bool, has_mrs: bool, has_documents: bool, schema_version: i32 }\n\n## Acceptance Criteria\n- [ ] Schema preflight yields actionable error for incompatible DB versions\n- [ ] Bootstrap screen shown when database is empty\n- [ ] Bootstrap guides user to start sync\n- [ ] After sync completes, Bootstrap auto-transitions to Dashboard\n- [ ] Non-zero exit code on schema incompatibility\n\n## Files\n- CREATE: crates/lore-tui/src/state/bootstrap.rs\n- CREATE: crates/lore-tui/src/view/bootstrap.rs\n- MODIFY: crates/lore-tui/src/lib.rs (add schema preflight check)\n- MODIFY: crates/lore-tui/src/action.rs (add check_data_readiness)\n\n## TDD Anchor\nRED: Write test_schema_preflight_rejects_old that creates DB at schema version 1, asserts preflight returns error.\nGREEN: Implement schema version check.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_schema_preflight\n\n## Edge Cases\n- Database file doesn't exist: create it, then show Bootstrap\n- Database locked by another process: show DbBusy error with suggestion\n- Config file missing: show error with lore init suggestion","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T17:00:02.185699Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:23.864435Z","deleted_at":"2026-03-11T18:34:23.864431Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-3vqk","title":"OBSERV: Add rate_limit_hits and retries counters to StageTiming","description":"## Background\nMetricsLayer counts span timing but doesn't yet count rate-limit hits and retries. These counters complete the observability picture, showing HOW MUCH time was spent waiting vs. working.\n\n## Approach\n### src/core/metrics.rs - StageTiming struct\n\nAdd two new fields:\n```rust\n#[derive(Debug, Clone, Serialize)]\npub struct StageTiming {\n    // ... existing fields ...\n    #[serde(skip_serializing_if = \"is_zero\")]\n    pub rate_limit_hits: usize,\n    #[serde(skip_serializing_if = \"is_zero\")]\n    pub retries: usize,\n}\n```\n\n### src/core/metrics.rs - MetricsLayer\n\nThe structured log events from bd-12ae use info!() with specific fields (status_code=429, \"Rate limited, retrying\"). MetricsLayer needs to count these events within each span.\n\nAdd to SpanData:\n```rust\nstruct SpanData {\n    // ... existing fields ...\n    rate_limit_hits: usize,\n    retries: usize,\n}\n```\n\nAdd on_event() to MetricsLayer:\n```rust\nfn on_event(&self, event: &tracing::Event<'_>, ctx: Context<'_, S>) {\n    // Check if event message contains rate-limit or retry indicators\n    // Increment counters on the current span\n    if let Some(span_ref) = ctx.event_span(event) {\n        let id = span_ref.id();\n        if let Some(data) = self.spans.lock().unwrap().get_mut(&id.into_u64()) {\n            let mut visitor = EventVisitor::default();\n            event.record(&mut visitor);\n\n            if visitor.status_code == Some(429) {\n                data.rate_limit_hits += 1;\n            }\n            if visitor.is_retry {\n                data.retries += 1;\n            }\n        }\n    }\n}\n```\n\nThe EventVisitor checks for status_code=429 and message containing \"retrying\" to classify events.\n\nOn span close, propagate counts to parent (bubble up):\n```rust\nfn on_close(&self, id: Id, _ctx: Context<'_, S>) {\n    if let Some(data) = self.spans.lock().unwrap().remove(&id.into_u64()) {\n        let timing = StageTiming {\n            // ... existing fields ...\n            rate_limit_hits: data.rate_limit_hits,\n            retries: data.retries,\n        };\n        // ... push to completed\n    }\n}\n```\n\n## Acceptance Criteria\n- [ ] StageTiming has rate_limit_hits and retries fields\n- [ ] Fields omitted when zero in JSON serialization\n- [ ] MetricsLayer counts 429 events as rate_limit_hits\n- [ ] MetricsLayer counts retry events as retries\n- [ ] Counts bubble up to parent spans in extract_timings()\n- [ ] Rate limit counts appear in metrics_json stored in sync_runs\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/core/metrics.rs (add fields to StageTiming, add on_event to MetricsLayer, add EventVisitor)\n\n## TDD Loop\nRED:\n  - test_stage_timing_rate_limit_counts: simulate 3 rate-limit events, extract, assert rate_limit_hits=3\n  - test_stage_timing_retry_counts: simulate 2 retries, extract, assert retries=2\n  - test_rate_limit_fields_omitted_when_zero: StageTiming with zero counts, serialize, assert no keys\nGREEN: Add fields to StageTiming, implement on_event in MetricsLayer\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- Events outside any span: ctx.event_span() returns None. Skip counting. This shouldn't happen in practice since all GitLab calls happen within stage spans.\n- Event classification: rely on structured fields (status_code=429) not message text. More reliable and less fragile.\n- Count bubbling: parent stage should aggregate child counts. In extract_timings(), sum children's counts into parent.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T15:55:02.523778Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:25:25.456758Z","closed_at":"2026-02-04T17:25:25.456708Z","close_reason":"Implemented rate_limit_hits and retries counters in StageTiming with skip_serializing_if for zero values","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-3vqk","depends_on_id":"bd-12ae","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-3vqk","depends_on_id":"bd-1o4h","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-3vqk","depends_on_id":"bd-3pk","type":"parent-child","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-4qd","title":"Write unit tests for core algorithms","description":"## Background\nUnit tests verify the core algorithms in isolation: document extraction formatting, FTS query sanitization, RRF scoring, content hashing, backoff curves, and filter helpers. These tests don't require a database or external services — they test pure functions and logic.\n\n## Approach\nAdd #[cfg(test)] mod tests blocks to each module:\n\n**1. src/documents/extractor.rs:**\n- test_source_type_parse_all_aliases — every alias resolves correctly\n- test_source_type_parse_unknown — returns None\n- test_source_type_as_str_roundtrip — as_str matches parse input\n- test_content_hash_deterministic — same input = same hash\n- test_list_hash_order_independent — sorted before hashing\n- test_list_hash_empty — empty vec produces consistent hash\n\n**2. src/documents/truncation.rs:**\n- test_truncation_edge_cases (per bd-18t TDD Loop)\n\n**3. src/search/fts.rs:**\n- test_to_fts_query_basic — \"auth error\" -> quoted tokens\n- test_to_fts_query_prefix — \"auth*\" preserves prefix\n- test_to_fts_query_special_chars — \"C++\" quoted correctly\n- test_to_fts_query_dash — \"-DWITH_SSL\" quoted (not NOT operator)\n- test_to_fts_query_internal_quotes — escaped by doubling\n- test_to_fts_query_empty — empty string returns empty\n\n**4. src/search/rrf.rs:**\n- test_rrf_dual_list — docs in both lists score higher\n- test_rrf_normalization — best score = 1.0\n- test_rrf_empty — empty returns empty\n\n**5. src/core/backoff.rs:**\n- test_exponential_curve — delays double each attempt\n- test_cap_at_one_hour — high attempt_count capped\n- test_jitter_range — within [0.9, 1.1) factor\n\n**6. src/search/filters.rs:**\n- test_has_any_filter — true/false for various filter combos\n- test_clamp_limit — 0->20, 200->100, 50->50\n- test_path_filter_from_str — trailing slash = Prefix\n\n**7. src/search/hybrid.rs (hydration round-trip):**\n- test_single_round_trip_query — verify hydration SQL produces correct structure\n\n## Acceptance Criteria\n- [ ] All edge cases covered per PRD acceptance criteria\n- [ ] Tests are unit tests (no DB, no network, no Ollama)\n- [ ] `cargo test` passes with all new tests\n- [ ] No test depends on execution order\n- [ ] Tests cover: document extractor formats, truncation, RRF, hashing, FTS sanitization, backoff, filters\n\n## Files\n- In-module tests in: extractor.rs, truncation.rs, fts.rs, rrf.rs, backoff.rs, filters.rs, hybrid.rs\n\n## TDD Loop\nThese tests ARE the TDD loop for their respective beads. Each implementation bead should write its tests first (RED), then implement (GREEN).\nVERIFY: `cargo test`\n\n## Edge Cases\n- Tests with Unicode: include emoji, CJK characters in truncation tests\n- Tests with empty strings: empty queries, empty content, empty labels\n- Tests with boundary values: limit=0, limit=100, limit=101","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-30T15:27:21.712924Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:46:00.059346Z","closed_at":"2026-01-30T17:46:00.059292Z","close_reason":"All acceptance criteria tests already exist across modules. 276 tests passing (189 unit + 87 integration).","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-4qd","depends_on_id":"bd-18t","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-4qd","depends_on_id":"bd-1k1","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-4qd","depends_on_id":"bd-36p","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-4qd","depends_on_id":"bd-3ez","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-4qd","depends_on_id":"bd-mem","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-5ofk","title":"Implement theme configuration (ftui ThemeBuilder)","description":"## Background\nThe TUI uses FrankenTUI's Theme struct with 19 semantic AdaptiveColor slots for consistent styling. Each slot takes an AdaptiveColor::adaptive(light, dark) pair for automatic light/dark mode switching via terminal background detection. The palette is Flexoki by Steph Ango — an ink-inspired color scheme designed in Oklab perceptual color space for balanced contrast in both modes.\n\n## Approach\nCreate `crates/lore-tui/src/theme.rs` with:\n\n### build_theme() -> Theme\nUse `Theme::builder()` from `ftui_style::theme::{Theme, ThemeBuilder, AdaptiveColor}`. Each slot gets `AdaptiveColor::adaptive(light, dark)` with Flexoki hex values:\n\n**Flexoki Base Tones:**\n- Paper #FFFCF0, Base-50 #F2F0E5, Base-100 #E6E4D9, Base-150 #DAD8CE\n- Base-200 #CECDC3, Base-300 #B7B5AC, Base-400 #9F9D96, Base-500 #878580\n- Base-600 #6F6E69, Base-700 #575653, Base-800 #403E3C, Base-850 #343331\n- Base-900 #282726, Base-950 #1C1B1A, Black #100F0F\n\n**Flexoki Accent Colors (light-600 / dark-400):**\n- Red: #AF3029 / #D14D41, Orange: #BC5215 / #DA702C\n- Yellow: #AD8301 / #D0A215, Green: #66800B / #879A39\n- Cyan: #24837B / #3AA99F, Blue: #205EA6 / #4385BE\n- Purple: #5E409D / #8B7EC8, Magenta: #A02F6F / #CE5D97\n\n**19-Slot Mapping** (light / dark):\n\n| Slot | Light | Dark |\n|------|-------|------|\n| primary | Blue-600 #205EA6 | Blue-400 #4385BE |\n| secondary | Cyan-600 #24837B | Cyan-400 #3AA99F |\n| accent | Purple-600 #5E409D | Purple-400 #8B7EC8 |\n| background | Paper #FFFCF0 | Black #100F0F |\n| surface | Base-50 #F2F0E5 | Base-900 #282726 |\n| overlay | Base-100 #E6E4D9 | Base-850 #343331 |\n| text | Base-700 #575653 | Base-200 #CECDC3 |\n| text_muted | Base-500 #878580 | Base-500 #878580 |\n| text_subtle | Base-400 #9F9D96 | Base-600 #6F6E69 |\n| success | Green-600 #66800B | Green-400 #879A39 |\n| warning | Yellow-600 #AD8301 | Yellow-400 #D0A215 |\n| error | Red-600 #AF3029 | Red-400 #D14D41 |\n| info | Blue-600 #205EA6 | Blue-400 #4385BE |\n| border | Base-300 #B7B5AC | Base-700 #575653 |\n| border_focused | Blue-600 #205EA6 | Blue-400 #4385BE |\n| selection_bg | Base-100 #E6E4D9 | Base-800 #403E3C |\n| selection_fg | Base-700 #575653 | Base-100 #E6E4D9 |\n| scrollbar_track | Base-50 #F2F0E5 | Base-900 #282726 |\n| scrollbar_thumb | Base-300 #B7B5AC | Base-700 #575653 |\n\nCode pattern:\n```rust\nuse ftui_style::theme::{Theme, AdaptiveColor};\nuse ftui_style::Color;\n\npub fn build_theme() -> Theme {\n    Theme::builder()\n        .primary(AdaptiveColor::adaptive(\n            Color::rgb(0x20, 0x5E, 0xA6),  // Blue-600\n            Color::rgb(0x43, 0x85, 0xBE),  // Blue-400\n        ))\n        // ... all 19 slots ...\n        .build()\n}\n```\n\n### State Colors\nHelper functions for GitLab entity states:\n- `state_color(state: &str) -> Color` — opened: Green-400 #879A39, closed: Red-400 #D14D41, merged: Purple-400 #8B7EC8, locked: Yellow-400 #D0A215\n- These return fixed Color (not AdaptiveColor) since state colors should be consistent\n\n### Event Type Colors\nFor timeline rendering:\n- created: Green, updated: Blue, closed: Red, merged: Purple, commented: Cyan, labeled: Orange, milestoned: Yellow\n\n### label_style(hex_color: &str) -> Style\nConverts GitLab label hex strings (e.g., \"#FF0000\") to ftui Style:\n- Parse hex to RGB using `u8::from_str_radix`\n- Return `Style::default().fg(Color::rgb(r, g, b))`\n- Invalid hex: return `Style::default().fg(text_muted_color)` as fallback\n\n## Acceptance Criteria\n- [ ] build_theme() returns Theme with all 19 slots using Flexoki AdaptiveColor::adaptive values\n- [ ] Theme::builder() chain compiles and build() returns Theme\n- [ ] state_color(\"opened\") returns Green-400, state_color(\"closed\") returns Red-400, state_color(\"merged\") returns Purple-400, state_color(\"locked\") returns Yellow-400\n- [ ] label_style(\"#FF0000\") returns Style with fg Color::rgb(255, 0, 0)\n- [ ] label_style(\"invalid\") returns Style with muted fallback color (no panic)\n- [ ] label_style(\"#ff0000\") handles lowercase hex\n\n## Files\n- CREATE: crates/lore-tui/src/theme.rs\n- MODIFY: crates/lore-tui/src/lib.rs (add `pub mod theme;`)\n\n## TDD Anchor\nRED: Write `test_build_theme_all_slots_set` that calls build_theme(), resolves for dark mode, and asserts primary == Color::rgb(0x43, 0x85, 0xBE).\nGREEN: Implement build_theme() with full Flexoki mapping.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml theme\n\nAdditional tests:\n- test_theme_light_mode_primary (resolves to #205EA6)\n- test_state_color_opened_is_green\n- test_state_color_unknown_returns_muted\n- test_label_style_valid_hex\n- test_label_style_invalid_hex_fallback\n- test_label_style_lowercase_hex\n\n## Edge Cases\n- Terminal may not support true color (RGB) — AdaptiveColor handles fallback via ftui's backend detection\n- Label colors from GitLab may include or omit the # prefix — handle both\n- Empty string label color — return fallback\n- State color for unknown states (e.g., \"all\") — return text_muted as default\n\n## Dependency Context\nDepends on bd-3ddw (scaffold) for the crate structure to exist and ftui-style dependency in Cargo.toml.\nDependents: bd-26f2 (common widgets) consumes build_theme() to style status bar, breadcrumb, etc.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T16:55:42.582468Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:24.787688Z","deleted_at":"2026-03-11T18:34:24.787679Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-5ta","title":"Add GitLab MR types to types.rs","description":"## Background\nGitLab API types for merge requests. These structs define how we deserialize GitLab API responses. Must handle deprecated field aliases for backward compatibility with older GitLab instances.\n\n## Approach\nAdd new structs to `src/gitlab/types.rs`:\n- `GitLabMergeRequest` - Main MR struct with all fields\n- `GitLabReviewer` - Reviewer with optional approval state\n- `GitLabReferences` - Short and full reference strings\n\nUse serde `#[serde(alias = \"...\")]` for deprecated field fallbacks.\n\n## Files\n- `src/gitlab/types.rs` - Add new structs after existing GitLabIssue\n- `tests/fixtures/gitlab_merge_request.json` - Test fixture\n\n## Acceptance Criteria\n- [ ] `GitLabMergeRequest` struct exists with all fields from PRD\n- [ ] `detailed_merge_status` field exists (non-deprecated)\n- [ ] `#[serde(alias = \"merge_status\")]` on `merge_status_legacy` for fallback\n- [ ] `merge_user` field exists (non-deprecated)\n- [ ] `merged_by` field exists for fallback\n- [ ] `draft` and `work_in_progress` both exist (draft preferred, WIP fallback)\n- [ ] `sha` field maps to `head_sha` in transformer\n- [ ] `references: Option<GitLabReferences>` for short/full refs\n- [ ] `state: String` supports \"opened\", \"merged\", \"closed\", \"locked\"\n- [ ] Fixture deserializes without error\n- [ ] `cargo test` passes\n\n## TDD Loop\nRED: Add test that deserializes fixture -> struct not found\nGREEN: Add GitLabMergeRequest, GitLabReviewer, GitLabReferences structs\nVERIFY: `cargo test gitlab_types`\n\n## Struct Definitions (from PRD)\n```rust\n#[derive(Debug, Clone, Deserialize)]\npub struct GitLabMergeRequest {\n    pub id: i64,\n    pub iid: i64,\n    pub project_id: i64,\n    pub title: String,\n    pub description: Option<String>,\n    pub state: String,                // \"opened\" | \"merged\" | \"closed\" | \"locked\"\n    #[serde(default)]\n    pub draft: bool,\n    #[serde(default)]\n    pub work_in_progress: bool,       // Deprecated fallback\n    pub source_branch: String,\n    pub target_branch: String,\n    pub sha: Option<String>,          // head_sha\n    pub references: Option<GitLabReferences>,\n    pub detailed_merge_status: Option<String>,\n    #[serde(alias = \"merge_status\")]\n    pub merge_status_legacy: Option<String>,\n    pub created_at: String,\n    pub updated_at: String,\n    pub merged_at: Option<String>,\n    pub closed_at: Option<String>,\n    pub author: GitLabAuthor,\n    pub merge_user: Option<GitLabAuthor>,\n    pub merged_by: Option<GitLabAuthor>,\n    #[serde(default)]\n    pub labels: Vec<String>,\n    #[serde(default)]\n    pub assignees: Vec<GitLabAuthor>,\n    #[serde(default)]\n    pub reviewers: Vec<GitLabReviewer>,\n    pub web_url: String,\n}\n\n#[derive(Debug, Clone, Deserialize)]\npub struct GitLabReferences {\n    pub short: String,   // e.g. \"\\!123\"\n    pub full: String,    // e.g. \"group/project\\!123\"\n}\n\n#[derive(Debug, Clone, Deserialize)]\npub struct GitLabReviewer {\n    pub id: i64,\n    pub username: String,\n    pub name: String,\n}\n```\n\n## Test Fixture (create tests/fixtures/gitlab_merge_request.json)\n```json\n{\n  \"id\": 12345,\n  \"iid\": 42,\n  \"project_id\": 100,\n  \"title\": \"Add user authentication\",\n  \"description\": \"Implements JWT auth flow\",\n  \"state\": \"merged\",\n  \"draft\": false,\n  \"work_in_progress\": false,\n  \"source_branch\": \"feature/auth\",\n  \"target_branch\": \"main\",\n  \"sha\": \"abc123def456\",\n  \"references\": { \"short\": \"\\!42\", \"full\": \"group/project\\!42\" },\n  \"detailed_merge_status\": \"mergeable\",\n  \"merge_status\": \"can_be_merged\",\n  \"created_at\": \"2024-01-15T10:00:00Z\",\n  \"updated_at\": \"2024-01-20T14:30:00Z\",\n  \"merged_at\": \"2024-01-20T14:30:00Z\",\n  \"closed_at\": null,\n  \"author\": { \"id\": 1, \"username\": \"johndoe\", \"name\": \"John Doe\" },\n  \"merge_user\": { \"id\": 2, \"username\": \"janedoe\", \"name\": \"Jane Doe\" },\n  \"merged_by\": { \"id\": 2, \"username\": \"janedoe\", \"name\": \"Jane Doe\" },\n  \"labels\": [\"enhancement\", \"auth\"],\n  \"assignees\": [{ \"id\": 3, \"username\": \"bob\", \"name\": \"Bob Smith\" }],\n  \"reviewers\": [{ \"id\": 4, \"username\": \"alice\", \"name\": \"Alice Wong\" }],\n  \"web_url\": \"https://gitlab.example.com/group/project/-/merge_requests/42\"\n}\n```\n\n## Edge Cases\n- `locked` state is transitional (merge in progress) - rare but valid\n- Some older instances may not return `detailed_merge_status`\n- Some older instances may not return `merge_user` (use `merged_by` fallback)\n- `work_in_progress` is deprecated but still returned by some instances","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-26T22:06:40.498088Z","created_by":"tayloreernisse","updated_at":"2026-01-27T00:08:35.520229Z","closed_at":"2026-01-27T00:08:35.520167Z","close_reason":"Added GitLabMergeRequest, GitLabReviewer, GitLabReferences structs. Updated GitLabNotePosition with position_type, line_range, and SHA triplet fields. All 23 type tests passing.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-5ta","depends_on_id":"bd-3ir","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-6pmy","title":"Implement LoreApp Model trait (full update/view skeleton)","description":"## Background\nLoreApp is the central Model implementation for FrankenTUI's Elm Architecture. It owns all state (AppState), the navigation stack, task supervisor, db manager, clock, config, and crash context. The update() method is the single entry point for all state transitions, implementing a 5-stage key dispatch pipeline. The view() method routes to per-screen render functions.\n\n## Approach\nExpand crates/lore-tui/src/app.rs:\n- LoreApp struct fields: config (Config), db (DbManager), state (AppState), navigation (NavigationStack), supervisor (TaskSupervisor), clock (Box<dyn Clock>), input_mode (InputMode), command_registry (CommandRegistry), crash_context (CrashContext)\n- init() -> Cmd<Msg>: install crash_context panic hook, return Cmd::task that loads dashboard data\n- update(msg: Msg) -> Option<Cmd<Msg>>: push CrashEvent to crash_context FIRST, then full dispatch with 5-stage interpret_key pipeline:\n  1. Quit check (q in Normal mode, Ctrl+C always)\n  2. InputMode routing (Text->delegate to text widget, Palette->delegate to palette, GoPrefix->check timeout+destination)\n  3. Global shortcuts (H=Home, Esc=back, Ctrl+P=palette, g=prefix, Ctrl+O/I=jump)\n  4. Screen-local keys (delegate to AppState::interpret_screen_key)\n  5. Fallback (unhandled key, no-op)\n\n**Key normalization pass in interpret_key():**\nBefore the 5-stage pipeline, normalize terminal key variants:\n- Backspace variants: map Delete/Backspace to canonical Backspace\n- Alt key variants: map Meta+key to Alt+key\n- Shift+Tab: map BackTab to Shift+Tab\n- This ensures consistent behavior across terminals (iTerm2, Alacritty, Terminal.app, tmux)\n\n- For non-key messages: match on Msg variants, update state, optionally return Cmd::task for async work\n- Stale result guard: check supervisor.is_current() before applying *Loaded results\n- view(frame): match navigation.current() to dispatch to per-screen view functions (stub initially)\n- subscriptions(): tick timer (250ms for spinner animation), debounce timers\n\n## Acceptance Criteria\n- [ ] LoreApp struct compiles with all required fields including crash_context\n- [ ] init() installs panic hook and returns a Cmd that triggers dashboard load\n- [ ] update() pushes CrashEvent to crash_context before dispatching\n- [ ] update() handles Msg::Quit by returning None\n- [ ] update() handles NavigateTo by pushing nav stack and spawning load_screen\n- [ ] update() handles GoBack by popping nav stack\n- [ ] interpret_key normalizes Backspace/Alt/Shift+Tab variants before dispatch\n- [ ] interpret_key 5-stage pipeline dispatches correctly per InputMode\n- [ ] GoPrefix times out after 500ms (checked via clock.now())\n- [ ] Stale results dropped: IssueListLoaded with old generation ignored\n- [ ] view() routes to correct screen render function based on navigation.current()\n- [ ] subscriptions() returns tick timer\n\n## Files\n- MODIFY: crates/lore-tui/src/app.rs (expand from minimal to full implementation)\n\n## TDD Anchor\nRED: Write test_quit_returns_none that creates LoreApp (with FakeClock, in-memory DB), calls update(Msg::Quit), asserts it returns None.\nGREEN: Implement update() with Quit match arm.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_quit\n\nAdditional tests:\n- test_navigate_to_pushes_stack: update(NavigateTo(IssueList)) changes navigation.current()\n- test_go_back_pops_stack: after push, GoBack returns to previous screen\n- test_stale_result_dropped: IssueListLoaded with old generation doesn't update state\n- test_go_prefix_timeout: GoPrefix cancels after 500ms (using FakeClock)\n- test_key_normalization_backspace: both Delete and Backspace map to canonical Backspace\n- test_crash_context_records_events: after update(), crash_context.events.len() increases\n\n## Edge Cases\n- update() must handle rapid-fire messages without blocking (no long computations in update)\n- Ctrl+C must always quit regardless of InputMode (safety escape)\n- GoPrefix must cancel on any non-destination key, not just on timeout\n- Text mode must pass Esc through to blur text input first, then Normal mode handles Esc for navigation\n- Key normalization must handle unknown/exotic key codes gracefully (pass through unchanged)\n\n## Dependency Context\nUses DbManager from \"Implement DbManager\" (bd-2kop).\nUses Clock/FakeClock from \"Implement Clock trait\" (bd-2lg6).\nUses Msg, Screen, InputMode from \"Implement core types\" (bd-c9gk).\nUses NavigationStack from \"Implement NavigationStack\" (bd-1qpp).\nUses TaskSupervisor from \"Implement TaskSupervisor\" (bd-3le2).\nUses CrashContext from \"Implement crash_context ring buffer\" (bd-2fr7).\nUses CommandRegistry from \"Implement CommandRegistry\" (bd-38lb).\nUses AppState from \"Implement AppState composition\" (bd-1v9m).","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T16:55:27.130909Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:23.943715Z","deleted_at":"2026-03-11T18:34:23.943711Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-88m","title":"[CP1] Issue ingestion module","description":"Fetch and store issues with cursor-based incremental sync.\n\n## Module\nsrc/ingestion/issues.rs\n\n## Key Structs\n\n### IngestIssuesResult\n- fetched: usize\n- upserted: usize\n- labels_created: usize\n- issues_needing_discussion_sync: Vec<IssueForDiscussionSync>\n\n### IssueForDiscussionSync\n- local_issue_id: i64\n- iid: i64\n- updated_at: i64\n\n## Main Function\npub async fn ingest_issues(conn, client, config, project_id, gitlab_project_id) -> Result<IngestIssuesResult>\n\n## Logic\n1. Get current cursor from sync_cursors (updated_at_cursor, tie_breaker_id)\n2. Paginate through issues updated after cursor with cursor_rewind_seconds\n3. Apply local filtering for tuple cursor semantics:\n   - Skip if issue.updated_at < cursor_updated_at\n   - Skip if issue.updated_at == cursor_updated_at AND issue.id <= cursor_gitlab_id\n4. For each issue passing filter:\n   - Begin transaction\n   - Store raw payload (compressed)\n   - Transform and upsert issue\n   - Clear existing label links (DELETE FROM issue_labels)\n   - Extract and upsert labels\n   - Link issue to labels via junction\n   - Commit transaction\n   - Track for discussion sync eligibility\n5. Incremental cursor update every 100 issues\n6. Final cursor update\n7. Determine issues needing discussion sync: where updated_at > discussions_synced_for_updated_at\n\n## Helper Functions\n- get_cursor(conn, project_id) -> (Option<i64>, Option<i64>)\n- get_discussions_synced_at(conn, issue_id) -> Option<i64>\n- upsert_issue(conn, issue, payload_id) -> usize\n- get_local_issue_id(conn, gitlab_id) -> i64\n- clear_issue_labels(conn, issue_id)\n- upsert_label(conn, label) -> bool\n- get_label_id(conn, project_id, name) -> i64\n- link_issue_label(conn, issue_id, label_id)\n- update_cursor(conn, project_id, resource_type, updated_at, gitlab_id)\n\nFiles: src/ingestion/mod.rs, src/ingestion/issues.rs\nTests: tests/issue_ingestion_tests.rs\nDone when: Issues, labels, issue_labels populated correctly with resumable cursor","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T16:57:35.655708Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.806982Z","closed_at":"2026-01-25T17:02:01.806982Z","deleted_at":"2026-01-25T17:02:01.806977Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-8ab7","title":"Implement Issue Detail (state + action + view)","description":"## Background\nThe Issue Detail screen shows a single issue with progressive hydration: Phase 1 loads metadata (fast), Phase 2 loads discussions asynchronously, Phase 3 loads thread bodies on expand. All subqueries run inside a single read transaction for snapshot consistency.\n\n## Approach\nState (state/issue_detail.rs):\n- IssueDetailState: current_key (Option<EntityKey>), metadata (Option<IssueMetadata>), discussions (Vec<Discussion>), discussions_loaded (bool), cross_refs (Vec<CrossRef>), tree_state (TreePersistState), scroll_offset (usize)\n- IssueMetadata: iid, title, description, state, author, assignee, labels, milestone, created_at, updated_at, web_url, status_name, status_icon, closing_mr_iids, related_issue_iids\n- handle_key(): j/k scroll, Enter expand discussion thread, d open description, x cross-refs, o open in browser, t scoped timeline, Esc back to list\n\nAction (action.rs):\n- fetch_issue_detail(conn, key, clock) -> Result<IssueDetail, LoreError>: uses with_read_snapshot for snapshot consistency. Fetches metadata, discussion count, cross-refs in single transaction.\n- fetch_discussions(conn, key) -> Result<Vec<Discussion>, LoreError>: loads discussions for the issue, separate async call (Phase 2 of hydration)\n\nView (view/issue_detail.rs):\n- render_issue_detail(frame, state, area, theme): header (IID, title, state badge, labels), description (markdown rendered with sanitization), discussions (tree widget), cross-references section\n- Header: \"Issue #42 — Fix auth flow [opened]\" with colored state badge\n- Description: rendered markdown, scrollable\n- Discussions: loaded async, shown with spinner until ready\n- Cross-refs: closing MRs, related issues as navigable links\n\n## Acceptance Criteria\n- [ ] Metadata loads in Phase 1 (p95 < 75ms on M-tier)\n- [ ] Discussions load async in Phase 2 (spinner shown while loading)\n- [ ] All detail subqueries run inside single read transaction (snapshot consistency)\n- [ ] Description text sanitized via sanitize_for_terminal()\n- [ ] Discussion tree renders with expand/collapse\n- [ ] Cross-references navigable via Enter\n- [ ] Esc returns to Issue List with cursor position preserved\n- [ ] Open in browser (o) uses classify_safe_url before launching\n- [ ] Scoped timeline (t) navigates to Timeline filtered for this entity\n\n## Files\n- MODIFY: crates/lore-tui/src/state/issue_detail.rs (expand from stub)\n- MODIFY: crates/lore-tui/src/action.rs (add fetch_issue_detail, fetch_discussions)\n- CREATE: crates/lore-tui/src/view/issue_detail.rs\n\n## TDD Anchor\nRED: Write test_fetch_issue_detail_snapshot in action.rs that inserts an issue with 2 discussions, calls fetch_issue_detail, asserts metadata and discussion count are correct.\nGREEN: Implement fetch_issue_detail with read transaction.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_fetch_issue_detail\n\n## Edge Cases\n- Issue with no description: show placeholder \"[No description]\"\n- Issue with hundreds of discussions: paginate or lazy-load beyond first 50\n- Cross-refs to entities not in local DB: show as text-only (not navigable)\n- Issue description with embedded images: show [image] placeholder (no inline rendering)\n- Entity cache (future): near-instant reopen during Enter/Esc drill workflows\n\n## Dependency Context\nUses discussion tree and cross-ref widgets from \"Implement discussion tree + cross-reference widgets\" task.\nUses EntityKey, Msg from \"Implement core types\" task.\nUses with_read_snapshot from DbManager from \"Implement DbManager\" task.\nUses sanitize_for_terminal from \"Implement terminal safety module\" task.\nUses Clock for timestamps from \"Implement Clock trait\" task.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T16:59:10.081146Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:24.020228Z","deleted_at":"2026-03-11T18:34:24.020221Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-8con","title":"lore related: semantic similarity discovery","description":"## Background\nGiven any entity or free text, find semantically related entities using vector embeddings. No other GitLab tool does this — glab, GitLab Advanced Search, and even paid tiers are keyword-only. This finds conceptual connections humans miss.\n\n## Current Infrastructure (Verified 2026-02-12)\n- sqlite-vec extension loaded via sqlite3_vec_init in src/core/db.rs:84\n- Embeddings stored in: embedding_metadata table (chunk info) + vec0 virtual table named `embeddings` (vectors)\n- Migration 009 creates embedding infrastructure\n- search_vector() at src/search/vector.rs:43 — works with sqlite-vec KNN queries\n- OllamaClient::embed_batch() at src/embedding/ollama.rs:103 — batch embedding\n- Model: nomic-embed-text, 768 dimensions, context_length=2048 tokens (~1500 bytes)\n- 61K documents in DB, embedding coverage TBD\n\n### sqlite-vec Distance Metric\nThe `embeddings` virtual table is `vec0(embedding float[768])`. sqlite-vec's MATCH query returns L2 (Euclidean) distance by default. Lower distance = more similar. The `search_vector()` function returns `VectorResult { document_id: i64, distance: f64 }`.\n\n## Approach\n\n### Entity Mode: lore related issues N\n1. Look up document for issue N:\n```sql\nSELECT d.id, d.content_text\nFROM documents d\nJOIN issues i ON d.source_type = 'issue' AND d.source_id = i.id\nWHERE i.iid = ?1 AND i.project_id = (SELECT id FROM projects WHERE ...)\n```\nNOTE: `documents.source_id` is the internal DB id from the source table (issues.id), NOT the GitLab IID. See migration 007 comment: `source_id INTEGER NOT NULL -- local DB id in the source table`.\n\n2. Get its embedding: Look up via embedding_metadata which maps document_id -> rowid in the vec0 table:\n```sql\nSELECT em.rowid\nFROM embedding_metadata em\nWHERE em.document_id = ?1\nLIMIT 1  -- use first chunk's embedding as representative\n```\nThen extract the embedding vector from the vec0 table to use as the KNN query.\n\nAlternatively, embed the document's content_text on-the-fly via OllamaClient (simpler, more robust):\n```rust\nlet embedding = client.embed_batch(&[&doc.content_text]).await?[0].clone();\n```\n\n3. Call search_vector(conn, &embedding, limit * 2) for KNN — multiply limit to have room after filtering self\n4. Exclude self (filter out source document_id from results)\n5. Hydrate results: join documents -> issues/mrs/discussions for title, url, labels, author\n6. Compute shared_labels: parse `documents.label_names` (JSON array string) for both source and each result, intersect\n7. Return ranked list\n\n### Query Mode: lore related 'free text'\n1. Embed query via OllamaClient::embed_batch(&[query_text])\n2. Call search_vector(conn, &query_embedding, limit)\n3. Hydrate and return (same as entity mode minus self-exclusion)\n\n### Key Design Decision\nThis is intentionally SIMPLER than hybrid search. No FTS, no RRF. Pure vector similarity. The point is conceptual relatedness, not keyword matching.\n\n### Distance to Similarity Score Conversion\nsqlite-vec returns L2 (Euclidean) distance. Convert to 0-1 similarity:\n```rust\n/// Convert L2 distance to a 0-1 similarity score.\n/// Uses inverse relationship: closer (lower distance) = higher similarity.\n/// The +1 prevents division by zero and ensures score is in (0, 1].\nfn distance_to_similarity(distance: f64) -> f64 {\n    1.0 / (1.0 + distance)\n}\n```\nFor normalized embeddings (which nomic-embed-text produces), L2 distance ranges roughly 0-2. This formula maps:\n- distance 0.0 -> similarity 1.0 (identical)\n- distance 1.0 -> similarity 0.5\n- distance 2.0 -> similarity 0.33\n\n### Label Extraction for shared_labels\n```rust\nfn parse_label_names(label_names_json: &Option<String>) -> HashSet<String> {\n    label_names_json\n        .as_deref()\n        .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())\n        .unwrap_or_default()\n        .into_iter()\n        .collect()\n}\n\nlet source_labels = parse_label_names(&source_doc.label_names);\nlet result_labels = parse_label_names(&result_doc.label_names);\nlet shared: Vec<String> = source_labels.intersection(&result_labels).cloned().collect();\n```\n\n## Function Signatures\n\n```rust\n// New: src/cli/commands/related.rs\npub struct RelatedArgs {\n    pub entity_type: Option<String>,  // \"issues\" or \"mrs\"\n    pub entity_iid: Option<i64>,\n    pub query: Option<String>,        // free text mode\n    pub project: Option<String>,\n    pub limit: Option<usize>,\n}\n\npub async fn run_related(\n    config: &Config,\n    args: RelatedArgs,\n) -> Result<RelatedResponse>\n\n// Reuse from src/search/vector.rs:43\npub fn search_vector(\n    conn: &Connection,\n    query_embedding: &[f32],\n    limit: usize,\n) -> Result<Vec<VectorResult>>\n// VectorResult { document_id: i64, distance: f64 }\n\n// Reuse from src/embedding/ollama.rs:103\npub async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>>\n```\n\n## Robot Mode Output Schema\n```json\n{\n  \"ok\": true,\n  \"data\": {\n    \"source\": { \"type\": \"issue\", \"iid\": 3864, \"title\": \"...\" },\n    \"query\": \"switch throw time...\",\n    \"results\": [{\n      \"source_type\": \"issue\",\n      \"iid\": 3800,\n      \"title\": \"Rail Break Card\",\n      \"url\": \"...\",\n      \"similarity_score\": 0.87,\n      \"shared_labels\": [\"customer:BNSF\"],\n      \"shared_authors\": [],\n      \"project_path\": \"vs/typescript-code\"\n    }]\n  },\n  \"meta\": { \"elapsed_ms\": 42, \"mode\": \"entity\", \"embedding_dims\": 768, \"distance_metric\": \"l2\" }\n}\n```\n\n## Clap Registration\n```rust\n// In src/main.rs Commands enum, add:\nRelated {\n    /// Entity type (\"issues\" or \"mrs\") or free text query\n    query_or_type: String,\n    /// Entity IID (when first arg is entity type)\n    iid: Option<i64>,\n    /// Maximum results\n    #[arg(short = 'n', long, default_value = \"10\")]\n    limit: usize,\n    /// Scope to project (fuzzy match)\n    #[arg(short, long)]\n    project: Option<String>,\n},\n```\n\n## TDD Loop\nRED: Tests in src/cli/commands/related.rs:\n- test_related_entity_excludes_self: insert doc + embedding for issue, query related, assert source doc not in results\n- test_related_shared_labels: insert 2 docs with overlapping labels (JSON in label_names), assert shared_labels computed correctly\n- test_related_empty_embeddings: no embeddings in DB, assert exit code 14 with helpful error\n- test_related_query_mode: embed free text via mock, assert results returned\n- test_related_similarity_score_range: all scores between 0.0 and 1.0\n- test_distance_to_similarity: unit test the conversion function (0.0->1.0, 1.0->0.5, large->~0.0)\n\nGREEN: Implement related command using search_vector + hydration\n\nVERIFY:\n```bash\ncargo test related:: && cargo clippy --all-targets -- -D warnings\ncargo run --release -- -J related issues 3864 -n 5 | jq '.data.results[0].similarity_score'\n```\n\n## Acceptance Criteria\n- [ ] lore related issues N returns top-K semantically similar entities\n- [ ] lore related mrs N works for merge requests\n- [ ] lore related 'free text' works as concept search (requires Ollama)\n- [ ] Results exclude the input entity itself\n- [ ] similarity_score is 0-1 range (higher = more similar), converted from L2 distance\n- [ ] Robot mode includes shared_labels (from documents.label_names JSON), shared_authors per result\n- [ ] Human mode shows ranked list with titles, scores, common labels\n- [ ] No embeddings in DB: exit code 14 with message \"Run 'lore embed' first\"\n- [ ] Ollama unavailable (query mode only): exit code 14 with suggestion\n- [ ] Performance: <1s for 61K documents\n- [ ] Command registered in main.rs and robot-docs\n\n## Edge Cases\n- Entity has no embedding (added after last lore embed): embed its content_text on-the-fly via OllamaClient, or exit 14 if Ollama unavailable\n- All results have very low similarity (<0.3): include warning \"No strongly related entities found\"\n- Entity is a discussion (not issue/MR): should still work (documents table has discussion docs)\n- Multiple documents per entity (discussion docs): use the entity-level document, not discussion subdocs\n- Free text query very short (1-2 words): may produce noisy results, add warning\n- Entity not found in DB: exit code 17 with suggestion to sync\n- Ambiguous project: exit code 18 with suggestion to use -p flag\n- documents.label_names may be NULL or invalid JSON — parse_label_names handles both gracefully\n\n## Dependency Context\n- **bd-1ksf (hybrid search)**: BLOCKER. Shares OllamaClient infrastructure. Also ensures async search.rs patterns are established. Related reuses the same vector search infrastructure.\n\n## Files to Create/Modify\n- NEW: src/cli/commands/related.rs\n- src/cli/commands/mod.rs (add pub mod related; re-export)\n- src/main.rs (register Related subcommand in Commands enum, add handle_related fn)\n- Reuse: search_vector() from src/search/vector.rs, OllamaClient from src/embedding/ollama.rs","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-12T15:46:58.665923Z","created_by":"tayloreernisse","updated_at":"2026-02-26T14:21:12.786863Z","closed_at":"2026-02-26T14:21:12.786818Z","close_reason":"Implemented: entity mode (issues/mrs), query mode (free text), similarity scoring, shared labels, robot/human output","compaction_level":0,"original_size":0,"labels":["cli-imp","intelligence","search"],"dependencies":[{"issue_id":"bd-8con","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-8t4","title":"Extract cross-references from resource_state_events","description":"## Background\nresource_state_events includes source_merge_request (with iid) for 'closed by MR' events. After state events are stored (Gate 1), post-processing extracts these into entity_references for the cross-reference graph.\n\n## Approach\nCreate src/core/references.rs (new module) or add to events_db.rs:\n\n```rust\n/// Extract cross-references from stored state events and insert into entity_references.\n/// Looks for state events with source_merge_request_id IS NOT NULL (meaning \"closed by MR\").\n/// \n/// Directionality: source = MR (that caused the close), target = issue (that was closed)\npub fn extract_refs_from_state_events(\n    conn: &Connection,\n    project_id: i64,\n) -> Result<usize>  // returns count of new references inserted\n```\n\nSQL logic:\n```sql\nINSERT OR IGNORE INTO entity_references (\n    source_entity_type, source_entity_id,\n    target_entity_type, target_entity_id,\n    reference_type, source_method, created_at\n)\nSELECT\n    'merge_request',\n    mr.id,\n    'issue',\n    rse.issue_id,\n    'closes',\n    'api_state_event',\n    rse.created_at\nFROM resource_state_events rse\nJOIN merge_requests mr ON mr.project_id = rse.project_id AND mr.iid = rse.source_merge_request_id\nWHERE rse.source_merge_request_id IS NOT NULL\n  AND rse.issue_id IS NOT NULL\n  AND rse.project_id = ?1;\n```\n\nKey: source_merge_request_id stores the MR iid, so we JOIN on merge_requests.iid to get the local DB id.\n\nRegister in src/core/mod.rs: `pub mod references;`\n\nCall this after drain_dependent_queue in the sync pipeline (after all state events are stored).\n\n## Acceptance Criteria\n- [ ] State events with source_merge_request_id produce 'closes' references\n- [ ] Source = MR (resolved by iid), target = issue\n- [ ] source_method = 'api_state_event'\n- [ ] INSERT OR IGNORE prevents duplicates with api_closes_issues data\n- [ ] Returns count of newly inserted references\n- [ ] No-op when no state events have source_merge_request_id\n\n## Files\n- src/core/references.rs (new)\n- src/core/mod.rs (add `pub mod references;`)\n- src/cli/commands/sync.rs (call after drain step)\n\n## TDD Loop\nRED: tests/references_tests.rs:\n- `test_extract_refs_from_state_events_basic` - seed a \"closed\" state event with source_merge_request_id, verify entity_reference created\n- `test_extract_refs_dedup_with_closes_issues` - insert ref from closes_issues API first, verify state event extraction doesn't duplicate\n- `test_extract_refs_no_source_mr` - state events without source_merge_request_id produce no refs\n\nSetup: create_test_db with migrations 001-011, seed project + issue + MR + state events.\n\nGREEN: Implement extract_refs_from_state_events\n\nVERIFY: `cargo test references -- --nocapture`\n\n## Edge Cases\n- source_merge_request_id may reference an MR not synced locally (cross-project close) — the JOIN will produce no match, which is correct behavior (ref simply not created)\n- Multiple state events can reference the same MR for the same issue (reopen + re-close) — INSERT OR IGNORE handles dedup\n- The merge_requests table might not have the MR yet if sync is still running — call this after all dependent fetches complete","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:32:33.619606Z","created_by":"tayloreernisse","updated_at":"2026-02-04T20:13:28.219791Z","closed_at":"2026-02-04T20:13:28.219633Z","compaction_level":0,"original_size":0,"labels":["extraction","gate-2","phase-b"],"dependencies":[{"issue_id":"bd-8t4","depends_on_id":"bd-1ep","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-8t4","depends_on_id":"bd-1se","type":"parent-child","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-8t4","depends_on_id":"bd-hu3","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-91j1","title":"Comprehensive robot-docs as agent bootstrap","description":"## Background\nAgents reach for glab because they already know it from training data. lore robot-docs exists but is not comprehensive enough to serve as a zero-training bootstrap. An agent encountering lore for the first time should be able to use any command correctly after reading robot-docs output alone.\n\n## Current State (Verified 2026-02-12)\n- `handle_robot_docs()` at src/main.rs:2069\n- Called at no-args in robot mode (main.rs:165) and via Commands::RobotDocs { brief } (main.rs:229)\n- Current output top-level keys: name, version, description, activation, commands, aliases, exit_codes, clap_error_codes, error_format, workflows\n- Missing: response_schema per command, example_output per command, quick_start section, glab equivalence table\n- --brief flag exists but returns shorter version of same structure\n- main.rs is 2579 lines total\n\n## Current robot-docs Output Structure\n```json\n{\n  \"name\": \"lore\",\n  \"version\": \"0.6.1\",\n  \"description\": \"...\",\n  \"activation\": { \"flags\": [\"--robot\", \"-J\"], \"env\": \"LORE_ROBOT=1\", \"auto_detect\": \"non-TTY\" },\n  \"commands\": [{ \"name\": \"...\", \"description\": \"...\", \"flags\": [...], \"example\": \"...\" }],\n  \"aliases\": { ... },\n  \"exit_codes\": { ... },\n  \"clap_error_codes\": { ... },\n  \"error_format\": { ... },\n  \"workflows\": { ... }\n}\n```\n\n## Approach\n\n### 1. Add quick_start section\nTop-level key with glab-to-lore translation and lore-exclusive feature summary:\n```json\n\"quick_start\": {\n  \"glab_equivalents\": [\n    { \"glab\": \"glab issue list\", \"lore\": \"lore -J issues -n 50\", \"note\": \"Richer: includes labels, status, closing MRs\" },\n    { \"glab\": \"glab issue view 123\", \"lore\": \"lore -J issues 123\", \"note\": \"Includes discussions, work-item status\" },\n    { \"glab\": \"glab mr list\", \"lore\": \"lore -J mrs\", \"note\": \"Includes draft status, reviewers\" },\n    { \"glab\": \"glab mr view 456\", \"lore\": \"lore -J mrs 456\", \"note\": \"Includes discussions, file changes\" },\n    { \"glab\": \"glab api '/projects/:id/issues'\", \"lore\": \"lore -J issues -p project\", \"note\": \"Fuzzy project matching\" }\n  ],\n  \"lore_exclusive\": [\n    \"search: FTS5 + vector hybrid search across all entities\",\n    \"who: Expert/workload/reviews analysis per file path or person\",\n    \"timeline: Chronological event reconstruction across entities\",\n    \"stats: Database statistics with document/note/discussion counts\",\n    \"count: Entity counts with state breakdowns\"\n  ]\n}\n```\n\n### 2. Add response_schema per command\nFor each command in the commands array, add a `response_schema` field showing the JSON shape:\n```json\n{\n  \"name\": \"issues\",\n  \"response_schema\": {\n    \"ok\": \"boolean\",\n    \"data\": { \"type\": \"array|object\", \"fields\": [\"iid\", \"title\", \"state\", \"...\"] },\n    \"meta\": { \"elapsed_ms\": \"integer\" }\n  }\n}\n```\nCommands with multiple output shapes (list vs detail) need both documented.\n\n### 3. Add example_output per command\nRealistic truncated JSON for each command. Keep each example under 500 bytes.\n\n### 4. Token budget enforcement\n- --brief mode: ONLY quick_start + command names + invocation syntax. Target <4000 tokens (~16000 bytes).\n- Full mode: everything. Target <12000 tokens (~48000 bytes).\n- Measure with: `cargo run --release -- --robot robot-docs --brief | wc -c`\n\n## TDD Loop\nRED: Tests in src/main.rs or new src/cli/commands/robot_docs.rs:\n- test_robot_docs_has_quick_start: parse output JSON, assert quick_start.glab_equivalents array has >= 5 entries\n- test_robot_docs_brief_size: --brief output < 16000 bytes\n- test_robot_docs_full_size: full output < 48000 bytes\n- test_robot_docs_has_response_schemas: every command entry has response_schema key\n- test_robot_docs_commands_complete: assert all registered commands appear (issues, mrs, search, who, timeline, count, stats, sync, embed, doctor, health, ingest, generate-docs, show)\n\nGREEN: Add quick_start, response_schema, example_output to robot-docs output\n\nVERIFY:\n```bash\ncargo test robot_docs && cargo clippy --all-targets -- -D warnings\ncargo run --release -- --robot robot-docs | jq '.quick_start.glab_equivalents | length'\n# Should return >= 5\ncargo run --release -- --robot robot-docs --brief | wc -c\n# Should be < 16000\n```\n\n## Acceptance Criteria\n- [ ] robot-docs JSON has quick_start.glab_equivalents array with >= 5 entries\n- [ ] robot-docs JSON has quick_start.lore_exclusive array\n- [ ] Every command entry has response_schema showing the JSON shape\n- [ ] Every command entry has example_output with realistic truncated data\n- [ ] --brief output is under 16000 bytes (~4000 tokens)\n- [ ] Full output is under 48000 bytes (~12000 tokens)\n- [ ] An agent reading ONLY robot-docs can correctly invoke any lore command\n- [ ] cargo test passes with new robot_docs tests\n\n## Edge Cases\n- Commands with multiple output shapes (e.g., issues list vs issues detail via iid) need both schemas documented\n- --fields flag changes output shape -- document the effect in the response_schema\n- robot-docs output must be stable across versions (agents may cache it)\n- Version field should match Cargo.toml version\n\n## Files to Modify\n- src/main.rs fn handle_robot_docs() (~line 2069) — add quick_start section, response_schema, example_output\n- Consider extracting to src/cli/commands/robot_docs.rs if the function exceeds 200 lines","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-12T15:44:40.495479Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:49:01.043915Z","closed_at":"2026-02-12T16:49:01.043832Z","close_reason":"Robot-docs enhanced with quick_start (glab equivalents, lore exclusives, read/write split) and example_output for issues/mrs/search/who","compaction_level":0,"original_size":0,"labels":["cli","cli-imp","robot-mode"],"dependencies":[{"issue_id":"bd-91j1","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-9av","title":"[CP1] gi sync-status enhancement","description":"Enhance sync-status from CP0 stub to show issue cursors.\n\n## Changes to src/cli/commands/sync_status.rs\n\nUpdate the existing stub to show:\n- Last run timestamp and duration\n- Cursor positions per project (issues resource_type)\n- Entity counts (issues, discussions, notes)\n\n## Output Format\nLast sync: 2026-01-25 10:30:00 (succeeded, 45s)\n\nCursors:\n  group/project-one\n    issues: 2026-01-25T10:25:00Z (gitlab_id: 12345678)\n\nCounts:\n  Issues: 1,234\n  Discussions: 5,678\n  Notes: 23,456 (4,567 system)\n\nFiles: src/cli/commands/sync_status.rs\nDone when: Shows cursor positions and counts after ingestion","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T16:58:27.246825Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.968507Z","closed_at":"2026-01-25T17:02:01.968507Z","deleted_at":"2026-01-25T17:02:01.968503Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-9cob","title":"Render MR sections (authored + reviewing) in human mode","description":"## Background\nRender the two MR sections in `lore me` human mode: authored MRs and reviewing MRs. Both share layout primitives but differ in section-specific fields.\n\n## Approach\nImplement in `src/cli/commands/me/render_human.rs`:\n```rust\npub fn render_authored_mrs_section(mrs: &[MeMrAuthored], single_project: bool) -> String\npub fn render_reviewing_mrs_section(mrs: &[MeMrReviewing], single_project: bool) -> String\n```\n\nShared row layout:\n- Line 1: attention icon + `!iid` + title + section-specific fields + relative time.\n- Line 2: dimmed `project_path` unless `single_project == true`.\n\nSection-specific fields:\n- Authored: optional `detailed_merge_status` and optional `DRAFT` indicator.\n- Reviewing: `@author_username` and optional `DRAFT` indicator.\n\nStyling:\n- `Theme::mr_ref()` for `!iid`\n- `Theme::username()` for `@author`\n- `Theme::state_draft()` for `DRAFT`\n- `Theme::dim()` for recency and project path\n- `section_divider()` for headers\n\nTimestamp conversion rule:\n- Convert `updated_at_iso` via `crate::core::time::iso_to_ms` before `format_relative_time`.\n- On parse failure, render `updated_at_iso` text fallback; do not panic.\n\nAttention rule:\n- `NotReady` renders no attention icon; `DRAFT` remains visible.\n\n## Acceptance Criteria\n- [ ] Headers are `Authored MRs (N)` and `Reviewing MRs (N)` via `section_divider`\n- [ ] `!iid` uses MR ref styling\n- [ ] `DRAFT` appears only when `draft=true`\n- [ ] Authored rows show `detailed_merge_status` only when `Some`\n- [ ] Reviewing rows show `@author_username`\n- [ ] Attention icon rendering matches state/tier rules\n- [ ] `NotReady` shows no icon but keeps draft indicator\n- [ ] Project path hidden when `single_project=true`\n- [ ] Empty authored section -> `No authored MRs`\n- [ ] Empty reviewing section -> `No MRs to review`\n- [ ] Invalid `updated_at_iso` does not panic and renders fallback text\n\n## Files\n- MODIFY: `src/cli/commands/me/render_human.rs`\n\n## TDD Anchor\nRED:\n- `test_authored_mr_shows_merge_status`\n- `test_authored_mr_shows_draft_indicator`\n- `test_authored_mr_hides_draft_when_false`\n- `test_reviewing_mr_shows_author_username`\n- `test_reviewing_mr_empty_section`\n- `test_authored_mr_omits_status_when_none`\n- `test_mr_section_invalid_iso_fallback`\n\nGREEN:\n- Implement both section renderers with shared helper + safe timestamp conversion.\n\nVERIFY:\n- `cargo test mr_section`\n\n## Edge Cases\n- Same MR can appear in both sections (author and reviewer); render independently.\n- `detailed_merge_status` may be absent; omit cleanly.\n- Sorting remains handler-owned; renderer does no resorting.\n\n## Dependency Context\nConsumes `MeMrAuthored`/`MeMrReviewing` from `bd-3bwh`, icon/summary context from `bd-1vxq`, and is called by `bd-1vv8`.\n\nDependencies:\n  -> bd-1vxq (blocks) - Render summary header and attention legend\n\nDependents:\n  <- bd-1vv8 (blocks) - Implement me command handler: wire queries to renderers","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-19T19:39:25.770680Z","created_by":"tayloreernisse","updated_at":"2026-02-20T16:09:13.061026Z","closed_at":"2026-02-20T16:09:13.060991Z","close_reason":"Implemented by lore-me agent swarm","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-9cob","depends_on_id":"bd-1vxq","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-9dd","title":"Implement 'lore trace' command with human and robot output","description":"## Background\n\nThe trace command is Gate 5's capstone CLI. It answers 'Why was this code introduced?' by building file -> MR -> issue -> discussion chains.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 5.3.\n\n## Codebase Context\n\n- CLI pattern: same as file-history (Commands enum, handler in main.rs)\n- trace.rs (bd-2n4): run_trace() returns TraceResult with chains\n- Path parsing: support 'src/foo.rs:45' syntax (line number for future Tier 2)\n- merge_requests.merged_at exists (migration 006) — use COALESCE(merged_at, updated_at) for ordering\n\n## Approach\n\n### 1. TraceArgs (`src/cli/mod.rs`):\n```rust\n#[derive(Parser)]\npub struct TraceArgs {\n    pub path: String,                      // supports :line suffix\n    #[arg(short = 'p', long)] pub project: Option<String>,\n    #[arg(long)] pub discussions: bool,\n    #[arg(long = \"no-follow-renames\")] pub no_follow_renames: bool,\n    #[arg(short = 'n', long = \"limit\", default_value = \"20\")] pub limit: usize,\n}\n```\n\n### 2. Path parsing:\n```rust\nfn parse_trace_path(input: &str) -> (String, Option<u32>) {\n    if let Some((path, line)) = input.rsplit_once(':') {\n        if let Ok(n) = line.parse::<u32>() { return (path.to_string(), Some(n)); }\n    }\n    (input.to_string(), None)\n}\n```\nIf line present: warn 'Line-level tracing requires Tier 2. Showing file-level results.'\n\n### 3. Human output shows chains with MR -> issue -> discussion context\n\n### 4. Robot JSON:\n```json\n{\"ok\": true, \"data\": {\"path\": \"...\", \"resolved_paths\": [...], \"trace_chains\": [...]}, \"meta\": {\"tier\": \"api_only\", \"line_requested\": null}}\n```\n\n## Acceptance Criteria\n\n- [ ] `lore trace src/foo.rs` with human output\n- [ ] `lore --robot trace src/foo.rs` with JSON\n- [ ] :line suffix parses and emits Tier 2 warning\n- [ ] -p, --discussions, --no-follow-renames, -n all work\n- [ ] Rename-aware via resolve_rename_chain\n- [ ] meta.tier = 'api_only'\n- [ ] Added to VALID_COMMANDS and robot-docs\n- [ ] `cargo check --all-targets` passes\n\n## Files\n\n- `src/cli/mod.rs` (TraceArgs + Commands::Trace)\n- `src/cli/commands/trace.rs` (NEW)\n- `src/cli/commands/mod.rs` (re-export)\n- `src/main.rs` (handler + VALID_COMMANDS + robot-docs)\n\n## TDD Loop\n\nRED:\n- `test_parse_trace_path_simple` - \"src/foo.rs\" -> (path, None)\n- `test_parse_trace_path_with_line` - \"src/foo.rs:42\" -> (path, Some(42))\n- `test_parse_trace_path_windows` - \"C:/foo.rs\" -> (path, None) — don't misparse drive letter\n\nGREEN: Implement CLI wiring and handlers.\n\nVERIFY: `cargo check --all-targets`\n\n## Edge Cases\n\n- Windows paths: don't misparse C: as line number\n- No MR data: friendly message with suggestion to sync\n- Very deep rename chain: bounded by resolve_rename_chain","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:34:32.788530Z","created_by":"tayloreernisse","updated_at":"2026-02-18T21:10:55.708488Z","closed_at":"2026-02-18T21:10:55.708445Z","close_reason":"Trace CLI implemented","compaction_level":0,"original_size":0,"labels":["cli","gate-5","phase-b"],"dependencies":[{"issue_id":"bd-9dd","depends_on_id":"bd-1ht","type":"parent-child","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-9dd","depends_on_id":"bd-2n4","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-9lbr","title":"lore explain: auto-generate issue/MR narrative","description":"## Background\nGiven an issue or MR, auto-generate a structured narrative of what happened: who was involved, what decisions were made, what changed, and what is unresolved. Template-based v1 (no LLM dependency), deterministic and reproducible.\n\n## Current Infrastructure (Verified 2026-02-12)\n- show.rs: IssueDetail (line 69) and MrDetail (line 14) — entity detail with discussions\n- timeline.rs: 5-stage pipeline SHIPPED — chronological event reconstruction\n- notes table: 282K rows with body, author, created_at, is_system, discussion_id\n- discussions table: links notes to parent entity (noteable_type, noteable_id), has resolved flag\n- resource_state_events table: state changes with created_at, user_username (src/core/events_db.rs)\n- resource_label_events table: label add/remove with created_at, user_username\n- entity_references table (src/core/references.rs): cross-references between entities (closing MRs, related issues). Column names: `source_entity_type`, `source_entity_id`, `target_entity_type`, `target_entity_id`, `target_project_path`, `target_entity_iid`, `reference_type`, `source_method`\n\n## Approach\nNew command: `lore explain issues N` / `lore explain mrs N`\n\n### Data Assembly (reuse existing internals as library calls)\n1. Entity detail: reuse show.rs query logic for IssueDetail/MrDetail\n2. Timeline events: reuse timeline pipeline with entity-scoped seed\n3. Discussion notes:\n```sql\nSELECT n.id, n.body, n.author_username, n.created_at\nFROM notes n\nJOIN discussions d ON n.discussion_id = d.id\nWHERE d.noteable_type = ? AND d.noteable_id = ?\n  AND n.is_system = 0\nORDER BY n.created_at\n```\n4. Cross-references:\n```sql\nSELECT target_entity_type, target_entity_id, target_project_path,\n       target_entity_iid, reference_type, source_method\nFROM entity_references\nWHERE (source_entity_type = ?1 AND source_entity_id = ?2)\nUNION ALL\nSELECT source_entity_type, source_entity_id, NULL,\n       NULL, reference_type, source_method\nFROM entity_references\nWHERE (target_entity_type = ?1 AND target_entity_id = ?2)\n```\n\n### Key Decisions Heuristic\nNotes from assignees/author that follow state or label changes within 1 hour:\n```rust\nstruct StateOrLabelEvent {\n    created_at: i64,  // ms epoch\n    user: String,\n    description: String,  // e.g. \"state: opened -> closed\" or \"label: +bug\"\n}\n\nfn extract_key_decisions(\n    state_events: &[ResourceStateEvent],\n    label_events: &[ResourceLabelEvent],\n    notes: &[Note],\n) -> Vec<KeyDecision> {\n    // Merge both event types into a unified chronological list\n    let mut events: Vec<StateOrLabelEvent> = Vec::new();\n    for e in state_events {\n        events.push(StateOrLabelEvent {\n            created_at: e.created_at,\n            user: e.user_username.clone(),\n            description: format!(\"state: {} -> {}\", e.from_state.as_deref().unwrap_or(\"?\"), e.to_state),\n        });\n    }\n    for e in label_events {\n        let action = if e.action == \"add\" { \"+\" } else { \"-\" };\n        events.push(StateOrLabelEvent {\n            created_at: e.created_at,\n            user: e.user_username.clone(),\n            description: format!(\"label: {}{}\", action, e.label_name.as_deref().unwrap_or(\"?\")),\n        });\n    }\n    events.sort_by_key(|e| e.created_at);\n\n    let mut decisions = Vec::new();\n    let one_hour_ms: i64 = 60 * 60 * 1000;\n\n    for event in &events {\n        // Find notes by same actor within 60 min after the event\n        for note in notes {\n            if note.author_username == event.user\n                && note.created_at >= event.created_at\n                && note.created_at <= event.created_at + one_hour_ms\n            {\n                decisions.push(KeyDecision {\n                    timestamp: event.created_at,\n                    actor: event.user.clone(),\n                    action: event.description.clone(),\n                    context_note: truncate(&note.body, 500),\n                });\n                break;  // one note per event\n            }\n        }\n    }\n    decisions.truncate(10);  // Cap at 10 key decisions\n    decisions\n}\n```\n\n### Narrative Sections\n1. **Header**: title, author, opened date, state, assignees, labels, status_name\n2. **Description excerpt**: first 500 chars of description (or full if shorter)\n3. **Key decisions**: notes correlated with state/label changes (heuristic above)\n4. **Activity summary**: counts of state changes, label changes, notes, time range\n5. **Open threads**: discussions WHERE resolved = false\n6. **Related entities**: closing MRs (with state), related issues from entity_references\n7. **Timeline excerpt**: first 20 events from timeline pipeline\n\n## Robot Mode Output Schema\n```json\n{\n  \"ok\": true,\n  \"data\": {\n    \"entity\": {\n      \"type\": \"issue\", \"iid\": 3864, \"title\": \"...\", \"state\": \"opened\",\n      \"author\": \"teernisse\", \"assignees\": [\"teernisse\"],\n      \"labels\": [\"customer:BNSF\"], \"created_at\": \"...\", \"updated_at\": \"...\",\n      \"url\": \"...\", \"status_name\": \"In progress\"\n    },\n    \"description_excerpt\": \"First 500 chars of description...\",\n    \"key_decisions\": [{\n      \"timestamp\": \"2026-01-15T...\",\n      \"actor\": \"teernisse\",\n      \"action\": \"state: opened -> in_progress\",\n      \"context_note\": \"Starting work on the BNSF throw time integration...\"\n    }],\n    \"activity\": {\n      \"state_changes\": 3, \"label_changes\": 5, \"notes\": 42,\n      \"first_event\": \"2026-01-10T...\", \"last_event\": \"2026-02-12T...\"\n    },\n    \"open_threads\": [{\n      \"discussion_id\": \"abc123\",\n      \"started_by\": \"cseiber\",\n      \"started_at\": \"2026-02-01T...\",\n      \"note_count\": 5,\n      \"last_note_at\": \"2026-02-10T...\"\n    }],\n    \"related\": {\n      \"closing_mrs\": [{ \"iid\": 200, \"title\": \"...\", \"state\": \"merged\" }],\n      \"related_issues\": [{ \"iid\": 3800, \"title\": \"Rail Break Card\", \"relation\": \"related\" }]\n    },\n    \"timeline_excerpt\": [{ \"timestamp\": \"...\", \"event_type\": \"...\", \"actor\": \"...\", \"summary\": \"...\" }]\n  },\n  \"meta\": { \"elapsed_ms\": 350 }\n}\n```\n\n## Clap Registration\n```rust\n// In src/main.rs Commands enum, add:\nExplain {\n    /// Entity type: \"issues\" or \"mrs\"\n    entity_type: String,\n    /// Entity IID\n    iid: i64,\n    /// Scope to project (fuzzy match)\n    #[arg(short, long)]\n    project: Option<String>,\n},\n```\n\n## TDD Loop\nRED: Tests in src/cli/commands/explain.rs:\n- test_explain_issue_basic: insert issue + notes + state events, run explain, assert all sections present (entity, description_excerpt, key_decisions, activity, open_threads, related, timeline_excerpt)\n- test_explain_key_decision_heuristic: insert state change event + note by same author within 30 min, assert note appears in key_decisions\n- test_explain_key_decision_ignores_unrelated_notes: insert note by different author, assert it does NOT appear in key_decisions\n- test_explain_open_threads: insert 2 discussions (1 resolved, 1 unresolved), assert only unresolved in open_threads\n- test_explain_no_notes: issue with zero notes produces header + description + empty sections\n- test_explain_mr: insert MR with merged_at, assert entity includes type=\"merge_request\"\n- test_explain_activity_counts: insert 3 state events + 2 label events + 10 notes, assert counts match\n\nGREEN: Implement explain command with section assembly\n\nVERIFY:\n```bash\ncargo test explain:: && cargo clippy --all-targets -- -D warnings\ncargo run --release -- -J explain issues 3864 | jq '.data | keys'\n# Should include: entity, description_excerpt, key_decisions, activity, open_threads, related, timeline_excerpt\n```\n\n## Acceptance Criteria\n- [ ] lore explain issues N produces structured output for any synced issue\n- [ ] lore explain mrs N produces structured output for any synced MR\n- [ ] Robot mode returns all 7 sections\n- [ ] Human mode renders readable narrative with headers and indentation\n- [ ] Key decisions heuristic: captures notes within 60 min of state/label changes by same actor\n- [ ] Works fully offline (no API calls, no LLM)\n- [ ] Performance: <500ms for issue with 50 notes\n- [ ] Command registered in main.rs and robot-docs\n- [ ] key_decisions capped at 10, timeline_excerpt capped at 20 events\n\n## Edge Cases\n- Issue with empty description: description_excerpt = \"(no description)\"\n- Issue with 500+ notes: timeline_excerpt capped at 20, key_decisions capped at 10\n- Issue not found in local DB: exit code 17 with suggestion to sync\n- Ambiguous project: exit code 18 with suggestion to use -p flag\n- MR with no review activity: activity section shows zeros\n- Cross-project references: show as unresolved with project path hint\n- Notes that are pure code blocks: include in key_decisions if correlated with events (they may contain implementation decisions)\n- ResourceStateEvent/ResourceLabelEvent field names: check src/core/events_db.rs for exact struct definitions before implementing\n\n## Dependency Context\n- **bd-2g50 (data gaps)**: BLOCKER. Provides `closed_at` field on IssueDetail for the header section. Without it, explain can still show state=\"closed\" but won't have the exact close timestamp.\n\n## Files to Create/Modify\n- NEW: src/cli/commands/explain.rs\n- src/cli/commands/mod.rs (add pub mod explain; re-export)\n- src/main.rs (register Explain subcommand in Commands enum, add handle_explain fn)\n- Reuse: show.rs queries, timeline pipeline, notes/discussions/resource_events queries from src/core/events_db.rs","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-12T15:46:41.386454Z","created_by":"tayloreernisse","updated_at":"2026-03-10T19:03:58.948327Z","closed_at":"2026-03-10T19:03:58.948272Z","close_reason":"All 5 subtasks complete: entity resolution, key_decisions, activity/threads/related, timeline_excerpt, human+robot renderers. 1046 tests passing, clippy clean, autocorrect registered.","compaction_level":0,"original_size":0,"labels":["cli-imp","intelligence"],"dependencies":[{"issue_id":"bd-9lbr","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-9wl5","title":"NOTE-2G: Parent metadata change propagation to note documents","description":"## Background\nNote documents inherit labels and title from parent issue/MR. When parent metadata changes, note documents become stale. The existing pipeline already marks discussion documents dirty on parent changes — note documents need the same treatment.\n\n## Approach\nFind where ingestion detects parent entity changes and marks discussion documents dirty. The dirty marking for discussions happens in:\n- src/ingestion/discussions.rs line 127: mark_dirty_tx(&tx, SourceType::Discussion, local_discussion_id)\n- src/ingestion/mr_discussions.rs line 162 and 362: mark_dirty_tx(&tx, SourceType::Discussion, local_discussion_id)\n\nThese fire when a discussion is upserted (which happens when parent entity is re-ingested). For note documents, we need to additionally mark all non-system notes of that discussion as dirty:\n\nAfter each mark_dirty_tx for Discussion, add:\n   // Mark child note documents dirty (they inherit parent metadata)\n   let note_ids: Vec<i64> = tx.prepare(\"SELECT id FROM notes WHERE discussion_id = ? AND is_system = 0\")?\n       .query_map([local_discussion_id], |r| r.get(0))?\n       .collect::<std::result::Result<Vec<_>, _>>()?;\n   for note_id in note_ids {\n       dirty_tracker::mark_dirty_tx(&tx, SourceType::Note, note_id)?;\n   }\n\nAlternative (more efficient, set-based):\n   INSERT INTO dirty_sources (source_type, source_id, queued_at)\n   SELECT 'note', n.id, ?1\n   FROM notes n\n   WHERE n.discussion_id = ?2 AND n.is_system = 0\n   ON CONFLICT(source_type, source_id) DO UPDATE SET queued_at = excluded.queued_at, attempt_count = 0\n\nUse the set-based approach for better performance with large discussions.\n\n## Files\n- MODIFY: src/ingestion/discussions.rs (add note dirty marking after line 127)\n- MODIFY: src/ingestion/mr_discussions.rs (add note dirty marking after lines 162 and 362)\n\n## TDD Anchor\nRED: test_parent_title_change_marks_notes_dirty — change issue title, re-ingest discussions, assert note documents appear in dirty_sources.\nGREEN: Add set-based INSERT INTO dirty_sources after discussion dirty marking.\nVERIFY: cargo test parent_title_change_marks_notes -- --nocapture\nTests: test_parent_label_change_marks_notes_dirty (modify issue labels, re-ingest, check dirty queue)\n\n## Acceptance Criteria\n- [ ] Discussion upsert for issue marks child non-system note documents dirty\n- [ ] Discussion upsert for MR marks child non-system note documents dirty (both call sites)\n- [ ] Only non-system notes marked dirty (is_system = 0 filter)\n- [ ] Set-based SQL (not per-note loop) for performance\n- [ ] Both tests pass\n\n## Dependency Context\n- Depends on NOTE-2D (bd-2ezb): dirty tracking infrastructure for notes must exist (dirty_sources accepts source_type='note', regenerator handles it)\n\n## Edge Cases\n- Discussion with 0 non-system notes: set-based INSERT is a no-op\n- Discussion with 100+ notes: set-based approach handles efficiently in one SQL statement\n- Concurrent discussion ingestion: ON CONFLICT DO UPDATE handles race safely","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T17:02:40.292874Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:13:15.717576Z","closed_at":"2026-02-12T18:13:15.717528Z","close_reason":"Implemented by agent swarm","compaction_level":0,"original_size":0,"labels":["per-note","search"]}
{"id":"bd-a7ba","title":"Implement project scope resolution for me command","description":"## Background\nThe `lore me` command needs to resolve which project(s) to query. There are three modes: single project (--project), all projects (--all), or default (use default_project if set, else all). The existing `Config.effective_project()` method already handles CLI flag > default_project fallback, and `resolve_project()` in `src/core/project.rs` handles fuzzy matching.\n\n## Approach\nCreate in `src/cli/commands/me/mod.rs` (or a submodule):\n```rust\npub enum ProjectScope {\n    Single(i64),  // internal DB project.id (NOT gitlab_project_id)\n    All,\n}\n\npub fn resolve_project_scope(\n    project_flag: Option<&str>,\n    all_flag: bool,\n    config: &Config,\n    conn: &Connection,\n) -> Result<ProjectScope> {\n    // AC-8.4: mutual exclusivity\n    if project_flag.is_some() && all_flag {\n        return Err(LoreError::UsageError(\n            \"Cannot use --project and --all together.\".to_string()\n        ));\n    }\n    // AC-8.2: --all overrides everything\n    if all_flag {\n        return Ok(ProjectScope::All);\n    }\n    // Use effective_project: CLI flag > config.default_project\n    let effective = project_flag.or(config.default_project.as_deref());\n    match effective {\n        Some(p) => Ok(ProjectScope::Single(resolve_project(conn, p)?)),\n        None => Ok(ProjectScope::All),  // AC-8.1: no default → all\n    }\n}\n```\n\nImports:\n```rust\nuse crate::Config;\nuse crate::core::error::{LoreError, Result};\nuse crate::core::project::resolve_project;\nuse rusqlite::Connection;\n```\n\nNote: `resolve_project()` returns the internal DB `id` (not `gitlab_project_id`). Match strategy: exact → case-insensitive → suffix → substring. Returns `LoreError::Ambiguous` (exit 18) on multiple matches, `LoreError::Other` on no match.\n\n## Acceptance Criteria\n- [ ] `--project` and `--all` both passed → `LoreError::UsageError` with exit code 2 (AC-8.4)\n- [ ] `--project \"repo\"` → resolves via `resolve_project()` → `Single(id)` (AC-8.3)\n- [ ] `--all` → `All` (AC-8.2)\n- [ ] No flags + `config.default_project` set → resolve it → `Single(id)` (AC-8.1)\n- [ ] No flags + no default_project → `All` (AC-8.1)\n- [ ] Unknown --project → `LoreError::Other` (from resolve_project)\n- [ ] Ambiguous --project → `LoreError::Ambiguous` exit 18 (from resolve_project)\n\n## Files\n- MODIFY: src/cli/commands/me/mod.rs (ProjectScope enum + resolve function)\n\n## TDD Anchor\nRED: Write `test_project_and_all_mutually_exclusive` in an in-memory DB test. Pass both flags, assert error matches `LoreError::UsageError`.\nGREEN: Implement resolve_project_scope.\nVERIFY: `cargo test project_scope`\n\nAdditional tests:\n- test_project_flag_resolves (insert project, pass --project with matching path)\n- test_all_flag_returns_all\n- test_default_project_used (no CLI flag, config has default_project)\n- test_no_default_no_flags_returns_all\n\n## Edge Cases\n- `resolve_project` returns internal DB `id`, not `gitlab_project_id` — use the returned id for WHERE clauses on `project_id`\n- default_project value in config might not exist in DB (deleted/unsynced) — resolve_project will error\n\n## Dependency Context\nUses `LoreError::UsageError` variant from bd-1f1f.\nUses `resolve_project` from `src/core/project.rs` (existing, no bead needed).\nUses `Config.default_project` field from `src/core/config.rs`.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-19T19:35:50.328852Z","created_by":"tayloreernisse","updated_at":"2026-02-20T16:09:13.047676Z","closed_at":"2026-02-20T16:09:13.047627Z","close_reason":"Implemented by lore-me agent swarm","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-a7ba","depends_on_id":"bd-utt4","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-am7","title":"Implement embedding pipeline with chunking","description":"## Background\nThe embedding pipeline takes documents, chunks them (paragraph-boundary splitting with overlap), sends chunks to Ollama for embedding via async HTTP, and stores vectors in sqlite-vec + metadata. It uses keyset pagination, concurrent HTTP requests via FuturesUnordered, per-batch transactions, and dimension validation.\n\n## Approach\nCreate \\`src/embedding/pipeline.rs\\` per PRD Section 4.4. **The pipeline is async.**\n\n**Constants (per PRD):**\n```rust\nconst BATCH_SIZE: usize = 32;           // texts per Ollama API call\nconst DB_PAGE_SIZE: usize = 500;        // keyset pagination page size\nconst EXPECTED_DIMS: usize = 768;       // nomic-embed-text dimensions\nconst CHUNK_MAX_CHARS: usize = 32_000;  // max chars per chunk\nconst CHUNK_OVERLAP_CHARS: usize = 500; // overlap between chunks\n```\n\n**Core async function:**\n```rust\npub async fn embed_documents(\n    conn: &Connection,\n    client: &OllamaClient,\n    selection: EmbedSelection,\n    concurrency: usize,              // max in-flight HTTP requests\n    progress_callback: Option<Box<dyn Fn(usize, usize)>>,\n) -> Result<EmbedResult>\n```\n\n**EmbedSelection:** Pending | RetryFailed\n**EmbedResult:** { embedded, failed, skipped }\n\n**Algorithm (per PRD):**\n1. count_pending_documents(conn, selection) for progress total\n2. Keyset pagination loop: find_pending_documents(conn, DB_PAGE_SIZE, last_id, selection)\n3. For each page:\n   a. Begin transaction\n   b. For each doc: clear_document_embeddings(&tx, doc.id), split_into_chunks(&doc.content)\n   c. Build ChunkWork items with doc_hash + chunk_hash\n   d. Commit clearing transaction\n4. Batch ChunkWork texts into Ollama calls (BATCH_SIZE=32)\n5. Use **FuturesUnordered** for concurrent HTTP, cap at \\`concurrency\\`\n6. collect_writes() in per-batch transactions: validate dims (768), store LE bytes, write metadata\n7. On error: record_embedding_error per chunk (not abort)\n8. Advance keyset cursor\n\n**ChunkWork struct:**\n```rust\nstruct ChunkWork {\n    doc_id: i64,\n    chunk_index: usize,\n    doc_hash: String,    // SHA-256 of FULL document (staleness detection)\n    chunk_hash: String,  // SHA-256 of THIS chunk (provenance)\n    text: String,\n}\n```\n\n**Splitting:** split_into_chunks(content) -> Vec<(usize, String)>\n- Documents <= CHUNK_MAX_CHARS: single chunk (index 0)\n- Longer: split at paragraph boundaries (\\\\n\\\\n), fallback to sentence/word, with CHUNK_OVERLAP_CHARS overlap\n\n**Storage:** embeddings as raw LE bytes, rowid = encode_rowid(doc_id, chunk_idx)\n**Staleness detection:** uses document_hash (not chunk_hash) because it's document-level\n\nAlso create \\`src/embedding/change_detector.rs\\` (referenced in PRD module structure):\n```rust\npub fn detect_embedding_changes(conn: &Connection) -> Result<Vec<PendingDocument>>;\n```\n\n## Acceptance Criteria\n- [ ] Pipeline is async (uses FuturesUnordered for concurrent HTTP)\n- [ ] concurrency parameter caps in-flight HTTP requests\n- [ ] progress_callback reports (processed, total)\n- [ ] New documents embedded, changed re-embedded, unchanged skipped\n- [ ] clear_document_embeddings before re-embedding (range delete vec0 + metadata)\n- [ ] Chunking at paragraph boundaries with 500-char overlap\n- [ ] Short documents (<32k chars) produce exactly 1 chunk\n- [ ] Embeddings stored as raw LE bytes in vec0\n- [ ] Rowids encoded via encode_rowid(doc_id, chunk_index)\n- [ ] Dimension validation: 768 floats per embedding (mismatch -> record error, not store)\n- [ ] Per-batch transactions for writes\n- [ ] Errors recorded in embedding_metadata per chunk (last_error, attempt_count)\n- [ ] Keyset pagination (d.id > last_id, not OFFSET)\n- [ ] Pending detection uses document_hash (not chunk_hash)\n- [ ] \\`cargo build\\` succeeds\n\n## Files\n- \\`src/embedding/pipeline.rs\\` — new file (async)\n- \\`src/embedding/change_detector.rs\\` — new file\n- \\`src/embedding/mod.rs\\` — add \\`pub mod pipeline; pub mod change_detector;\\` + re-exports\n\n## TDD Loop\nRED: Unit tests for chunking:\n- \\`test_short_document_single_chunk\\` — <32k produces [(0, full_content)]\n- \\`test_long_document_multiple_chunks\\` — >32k splits at paragraph boundaries\n- \\`test_chunk_overlap\\` — adjacent chunks share 500-char overlap\n- \\`test_no_paragraph_boundary\\` — falls back to char boundary\nIntegration tests need Ollama or mock.\nGREEN: Implement split_into_chunks, embed_documents (async)\nVERIFY: \\`cargo test pipeline\\`\n\n## Edge Cases\n- Empty document content_text: skip (don't embed)\n- No paragraph boundaries: split at CHUNK_MAX_CHARS with overlap\n- Ollama error for one batch: record error per chunk, continue with next batch\n- Dimension mismatch (model returns 512 instead of 768): record error, don't store corrupt data\n- Document deleted between pagination and embedding: skip gracefully","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:26:34.093701Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:58:58.908585Z","closed_at":"2026-01-30T17:58:58.908525Z","close_reason":"Implemented embedding pipeline: chunking at paragraph boundaries with 500-char overlap, change detector (keyset pagination, hash-based staleness), async embed via Ollama with batch processing, dimension validation, per-chunk error recording, LE byte vector storage. 7 chunking tests pass. 289 total tests.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-am7","depends_on_id":"bd-1y8","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-am7","depends_on_id":"bd-2ac","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-am7","depends_on_id":"bd-335","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-apmo","title":"OBSERV: Create migration 014 for sync_runs enrichment","description":"## Background\nThe sync_runs table (created in migration 001) has columns id, started_at, heartbeat_at, finished_at, status, command, error, metrics_json but NOTHING writes to it. This migration adds columns for the observability correlation ID and aggregate counts, enabling queryable sync history.\n\n## Approach\nCreate migrations/014_sync_runs_enrichment.sql:\n\n```sql\n-- Migration 014: sync_runs enrichment for observability\n-- Adds correlation ID and aggregate counts for queryable sync history\n\nALTER TABLE sync_runs ADD COLUMN run_id TEXT;\nALTER TABLE sync_runs ADD COLUMN total_items_processed INTEGER DEFAULT 0;\nALTER TABLE sync_runs ADD COLUMN total_errors INTEGER DEFAULT 0;\n\n-- Index for correlation queries (find run by run_id from logs)\nCREATE INDEX IF NOT EXISTS idx_sync_runs_run_id ON sync_runs(run_id);\n```\n\nMigration naming convention: check migrations/ directory. Current latest is 013_resource_event_watermarks.sql. Next is 014.\n\nNote: SQLite ALTER TABLE ADD COLUMN is always safe -- it sets NULL for existing rows. DEFAULT 0 applies to new INSERTs only.\n\n## Acceptance Criteria\n- [ ] Migration 014 applies cleanly on a fresh DB (all migrations 001-014)\n- [ ] Migration 014 applies cleanly on existing DB with 001-013 already applied\n- [ ] sync_runs table has run_id TEXT column\n- [ ] sync_runs table has total_items_processed INTEGER DEFAULT 0 column\n- [ ] sync_runs table has total_errors INTEGER DEFAULT 0 column\n- [ ] idx_sync_runs_run_id index exists\n- [ ] Existing sync_runs rows (if any) have NULL run_id, 0 for counts\n- [ ] cargo clippy --all-targets -- -D warnings passes (no code changes, but verify migration is picked up)\n\n## Files\n- migrations/014_sync_runs_enrichment.sql (new file)\n\n## TDD Loop\nRED:\n  - test_migration_014_applies: apply all migrations on fresh in-memory DB, query sync_runs schema\n  - test_migration_014_idempotent: CREATE INDEX IF NOT EXISTS makes re-run safe; ALTER TABLE ADD COLUMN is NOT idempotent in SQLite (will error). Consider: skip this test or use IF NOT EXISTS workaround\nGREEN: Create migration file\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- ALTER TABLE ADD COLUMN in SQLite: NOT idempotent. Running migration twice will error \"duplicate column name.\" The migration system should prevent re-runs, but IF NOT EXISTS is not available for ALTER TABLE in SQLite. Rely on migration tracking.\n- Migration numbering conflict: if another PR adds 014 first, renumber to 015. Check before merging.\n- metrics_json already exists (from migration 001): we don't touch it. The new columns supplement it with queryable aggregates.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T15:54:51.311879Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:34:05.309761Z","closed_at":"2026-02-04T17:34:05.309714Z","close_reason":"Created migration 014 adding run_id TEXT, total_items_processed INTEGER, total_errors INTEGER to sync_runs, with idx_sync_runs_run_id index","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-apmo","depends_on_id":"bd-3pz","type":"parent-child","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-arka","title":"Extend SyncRunRecorder with surgical mode lifecycle methods","description":"## Background\nThe existing `SyncRunRecorder` in `src/core/sync_run.rs` manages sync run lifecycle with three methods: `start()` (creates row, returns Self), `succeed(self, ...)` (consumes self, sets succeeded), and `fail(self, ...)` (consumes self, sets failed). Both `succeed()` and `fail()` take ownership of `self` — this is intentional to prevent double-finalization.\n\nSurgical sync needs additional lifecycle methods to:\n1. Set surgical-specific metadata (mode, phase, IIDs JSON) after `start()`\n2. Record per-entity results (increment counters, store entity-level outcomes)\n3. Cancel a run (distinct from failure — user-initiated or timeout)\n4. Update phase progression during the surgical pipeline\n\nThese methods operate on the columns added by migration 027 (bead bd-tiux).\n\n## Approach\n\n### Step 1: Add `set_surgical_metadata` method\n\nCalled once after `start()` to set the surgical mode columns:\n\n```rust\npub fn set_surgical_metadata(\n    &self,\n    conn: &Connection,\n    mode: &str,\n    phase: &str,\n    iids_json: &str,\n) -> Result<()> {\n    conn.execute(\n        \"UPDATE sync_runs SET mode = ?1, phase = ?2, surgical_iids_json = ?3 WHERE id = ?4\",\n        rusqlite::params![mode, phase, iids_json, self.row_id],\n    )?;\n    Ok(())\n}\n```\n\nTakes `&self` (not `self`) because the recorder continues to be used after metadata is set.\n\n### Step 2: Add `update_phase` method\n\nCalled as the surgical pipeline progresses through phases:\n\n```rust\npub fn update_phase(&self, conn: &Connection, phase: &str) -> Result<()> {\n    conn.execute(\n        \"UPDATE sync_runs SET phase = ?1, heartbeat_at = ?2 WHERE id = ?3\",\n        rusqlite::params![phase, now_ms(), self.row_id],\n    )?;\n    Ok(())\n}\n```\n\n### Step 3: Add `record_entity_result` method\n\nCalled after each entity (issue or MR) is processed to increment counters:\n\n```rust\npub fn record_entity_result(\n    &self,\n    conn: &Connection,\n    entity_type: &str,\n    stage: &str,\n) -> Result<()> {\n    let column = match (entity_type, stage) {\n        (\"issue\", \"fetched\") => \"issues_fetched\",\n        (\"issue\", \"ingested\") => \"issues_ingested\",\n        (\"mr\", \"fetched\") => \"mrs_fetched\",\n        (\"mr\", \"ingested\") => \"mrs_ingested\",\n        (\"issue\" | \"mr\", \"skipped_stale\") => \"skipped_stale\",\n        (\"doc\", \"regenerated\") => \"docs_regenerated\",\n        (\"doc\", \"embedded\") => \"docs_embedded\",\n        (_, \"warning\") => \"warnings_count\",\n        _ => return Ok(()),  // Unknown combinations are silently ignored\n    };\n    conn.execute(\n        &format!(\"UPDATE sync_runs SET {column} = {column} + 1 WHERE id = ?1\"),\n        rusqlite::params![self.row_id],\n    )?;\n    Ok(())\n}\n```\n\nNote: The column name comes from a hardcoded match, NOT from user input — no SQL injection risk.\n\n### Step 4: Add `cancel` method\n\nConsumes self (like succeed/fail) to finalize the run as cancelled:\n\n```rust\npub fn cancel(self, conn: &Connection, reason: &str) -> Result<()> {\n    let now = now_ms();\n    conn.execute(\n        \"UPDATE sync_runs SET finished_at = ?1, cancelled_at = ?2, status = 'cancelled', error = ?3 WHERE id = ?4\",\n        rusqlite::params![now, now, reason, self.row_id],\n    )?;\n    Ok(())\n}\n```\n\nTakes `self` (ownership) like `succeed()` and `fail()` — prevents further use after cancellation.\n\n### Step 5: Expose `row_id` getter\n\nThe orchestrator (bd-1i4i) may need the row_id for logging/tracing:\n\n```rust\npub fn row_id(&self) -> i64 {\n    self.row_id\n}\n```\n\n## Acceptance Criteria\n- [ ] `set_surgical_metadata(&self, conn, mode, phase, iids_json)` writes mode/phase/surgical_iids_json columns\n- [ ] `update_phase(&self, conn, phase)` updates phase and heartbeat_at\n- [ ] `record_entity_result(&self, conn, entity_type, stage)` increments the correct counter column\n- [ ] `record_entity_result` silently ignores unknown entity_type/stage combinations\n- [ ] `cancel(self, conn, reason)` consumes self, sets status='cancelled', finished_at, cancelled_at, error\n- [ ] `row_id()` returns the internal row_id\n- [ ] `succeed(self, ...)` still works after `set_surgical_metadata` + `record_entity_result` calls\n- [ ] `fail(self, ...)` still works after `set_surgical_metadata` + `update_phase` calls\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n- [ ] All existing sync_run tests continue to pass\n\n## Files\n- MODIFY: src/core/sync_run.rs (add methods to SyncRunRecorder impl block)\n- MODIFY: src/core/sync_run_tests.rs (add new tests)\n\n## TDD Anchor\nRED: Write tests in `src/core/sync_run_tests.rs`:\n\n```rust\n#[test]\nfn surgical_lifecycle_start_metadata_succeed() {\n    let conn = setup_test_db();\n    let recorder = SyncRunRecorder::start(&conn, \"sync\", \"surg001\").unwrap();\n    let row_id = recorder.row_id();\n\n    recorder.set_surgical_metadata(\n        &conn, \"surgical\", \"preflight\", r#\"{\"issues\":[7,8],\"mrs\":[101]}\"#,\n    ).unwrap();\n\n    recorder.update_phase(&conn, \"ingest\").unwrap();\n    recorder.record_entity_result(&conn, \"issue\", \"fetched\").unwrap();\n    recorder.record_entity_result(&conn, \"issue\", \"fetched\").unwrap();\n    recorder.record_entity_result(&conn, \"issue\", \"ingested\").unwrap();\n    recorder.record_entity_result(&conn, \"mr\", \"fetched\").unwrap();\n    recorder.record_entity_result(&conn, \"mr\", \"ingested\").unwrap();\n\n    recorder.succeed(&conn, &[], 3, 0).unwrap();\n\n    let (mode, phase, iids, issues_fetched, mrs_fetched, issues_ingested, mrs_ingested, status): (\n        String, String, String, i64, i64, i64, i64, String,\n    ) = conn.query_row(\n        \"SELECT mode, phase, surgical_iids_json, issues_fetched, mrs_fetched, issues_ingested, mrs_ingested, status\n         FROM sync_runs WHERE id = ?1\",\n        [row_id],\n        |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?, r.get(7)?)),\n    ).unwrap();\n\n    assert_eq!(mode, \"surgical\");\n    assert_eq!(phase, \"ingest\");  // Last phase set before succeed\n    assert!(iids.contains(\"101\"));\n    assert_eq!(issues_fetched, 2);\n    assert_eq!(mrs_fetched, 1);\n    assert_eq!(issues_ingested, 1);\n    assert_eq!(mrs_ingested, 1);\n    assert_eq!(status, \"succeeded\");\n}\n\n#[test]\nfn surgical_lifecycle_cancel() {\n    let conn = setup_test_db();\n    let recorder = SyncRunRecorder::start(&conn, \"sync\", \"cancel01\").unwrap();\n    let row_id = recorder.row_id();\n\n    recorder.set_surgical_metadata(&conn, \"surgical\", \"preflight\", \"{}\").unwrap();\n    recorder.cancel(&conn, \"User requested cancellation\").unwrap();\n\n    let (status, error, cancelled_at, finished_at): (String, Option<String>, Option<i64>, Option<i64>) = conn.query_row(\n        \"SELECT status, error, cancelled_at, finished_at FROM sync_runs WHERE id = ?1\",\n        [row_id],\n        |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),\n    ).unwrap();\n\n    assert_eq!(status, \"cancelled\");\n    assert_eq!(error.as_deref(), Some(\"User requested cancellation\"));\n    assert!(cancelled_at.is_some());\n    assert!(finished_at.is_some());\n}\n\n#[test]\nfn record_entity_result_ignores_unknown() {\n    let conn = setup_test_db();\n    let recorder = SyncRunRecorder::start(&conn, \"sync\", \"unk001\").unwrap();\n    // Should not panic or error on unknown combinations\n    recorder.record_entity_result(&conn, \"widget\", \"exploded\").unwrap();\n}\n\n#[test]\nfn record_entity_result_json_counters() {\n    let conn = setup_test_db();\n    let recorder = SyncRunRecorder::start(&conn, \"sync\", \"cnt001\").unwrap();\n    let row_id = recorder.row_id();\n\n    recorder.record_entity_result(&conn, \"doc\", \"regenerated\").unwrap();\n    recorder.record_entity_result(&conn, \"doc\", \"regenerated\").unwrap();\n    recorder.record_entity_result(&conn, \"doc\", \"embedded\").unwrap();\n    recorder.record_entity_result(&conn, \"issue\", \"skipped_stale\").unwrap();\n\n    let (docs_regen, docs_embed, skipped): (i64, i64, i64) = conn.query_row(\n        \"SELECT docs_regenerated, docs_embedded, skipped_stale FROM sync_runs WHERE id = ?1\",\n        [row_id],\n        |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),\n    ).unwrap();\n\n    assert_eq!(docs_regen, 2);\n    assert_eq!(docs_embed, 1);\n    assert_eq!(skipped, 1);\n}\n```\n\nGREEN: Add all methods to `SyncRunRecorder`.\nVERIFY: `cargo test surgical_lifecycle && cargo test record_entity_result`\n\n## Edge Cases\n- `succeed()` and `fail()` consume `self` — the compiler enforces that no methods are called after finalization. `cancel()` also consumes self for the same reason.\n- `set_surgical_metadata`, `update_phase`, and `record_entity_result` take `&self` — they can be called multiple times before finalization.\n- The `record_entity_result` match uses a hardcoded column name derived from known string constants, not user input. The `format!` is safe because the column name is always one of the hardcoded strings.\n- `record_entity_result` silently returns Ok(()) for unknown entity_type/stage combos rather than erroring — this avoids breaking the pipeline for non-critical telemetry.\n- Phase is NOT overwritten by `succeed()`/`fail()`/`cancel()` — the last phase set via `update_phase()` is preserved as the \"phase at completion\" for observability.\n\n## Dependency Context\nDepends on bd-tiux (migration 027) for the surgical columns to exist. Downstream beads bd-1i4i (orchestrator) and bd-3jqx (integration tests) use these methods.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-17T19:13:50.827946Z","created_by":"tayloreernisse","updated_at":"2026-02-18T21:04:58.568108Z","closed_at":"2026-02-18T21:04:58.568067Z","close_reason":"Completed: all implementation work done, code reviewed, tests passing","compaction_level":0,"original_size":0,"labels":["surgical-sync"],"dependencies":[{"issue_id":"bd-arka","depends_on_id":"bd-1i4i","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-b3r3","title":"Implement activity feed: note and resource event queries","description":"## Background\nThe activity feed (AC-5.4) aggregates events from multiple sources. This bead covers the first 4 sources: human comments (notes.is_system=0), state events, label events, and milestone events. Each source is a SQL subquery that returns a unified column set for UNION ALL assembly.\n\n## Approach\nCreate `src/cli/commands/me/activity.rs`. Define individual query-building functions that return SQL strings or execute directly and return `Vec<MeActivityItem>`.\n\n### Schema context\n\n**notes** (migration 002): `id, discussion_id (FK→discussions), is_system (0/1), author_username, body, created_at (ms epoch)`\n\n**discussions** (migration 002): `id, project_id, issue_id (nullable FK→issues), merge_request_id (nullable), noteable_type ('Issue'|'MergeRequest')`\n- CHECK constraint: exactly one of issue_id/merge_request_id is non-NULL\n\n**resource_state_events** (migration 011): `id, gitlab_id, project_id, issue_id (nullable), merge_request_id (nullable), state TEXT NOT NULL, actor_username TEXT, created_at (ms epoch)`\n- CHECK constraint: exactly one of issue_id/merge_request_id is non-NULL\n\n**resource_label_events** (migration 011): `id, gitlab_id, project_id, issue_id (nullable), merge_request_id (nullable), action TEXT ('add'|'remove'), label_name TEXT NOT NULL, actor_username TEXT, created_at (ms epoch)`\n- CHECK: one of issue_id/merge_request_id non-NULL. Note: `label_name` is NOT NULL in migration 011 but was made NULLABLE in migration 012.\n\n**resource_milestone_events** (migration 011): `id, gitlab_id, project_id, issue_id (nullable), merge_request_id (nullable), action TEXT ('add'|'remove'), milestone_title TEXT NOT NULL, actor_username TEXT, created_at (ms epoch)`\n- CHECK: one of issue_id/merge_request_id non-NULL. Note: `milestone_title` is NOT NULL in 011, NULLABLE after 012.\n\n**issue_assignees**: `issue_id, username` (PK both). Index: `idx_issue_assignees_username(username, issue_id)`.\n\n**mr_reviewers**: `merge_request_id, username` (PK both).\n\n### Unified output columns (all 4 sources must match)\n\n```\ntimestamp (ms epoch) | event_type | entity_type | entity_iid | project_path | actor | is_own | summary | body_preview\n```\n\n### Source 1: Human comments (notes with is_system=0)\n```sql\nSELECT n.created_at AS timestamp,\n       'note' AS event_type,\n       CASE WHEN d.issue_id IS NOT NULL THEN 'issue' ELSE 'merge_request' END AS entity_type,\n       COALESCE(i.iid, mr.iid) AS entity_iid,\n       p.path_with_namespace AS project_path,\n       n.author_username AS actor,\n       (n.author_username = ?username) AS is_own,\n       'commented' AS summary,\n       SUBSTR(n.body, 1, 200) AS body_preview\nFROM notes n\nJOIN discussions d ON n.discussion_id = d.id\nLEFT JOIN issues i ON d.issue_id = i.id\nLEFT JOIN merge_requests mr ON d.merge_request_id = mr.id\nJOIN projects p ON p.id = COALESCE(i.project_id, mr.project_id)\nWHERE n.is_system = 0\n  AND n.created_at >= ?since_ms\n  AND (my_items_filter)\n  AND (project_scope_filter)\n```\n\n### Source 2: State events\n```sql\nSELECT rse.created_at AS timestamp,\n       'status' AS event_type,\n       CASE WHEN rse.issue_id IS NOT NULL THEN 'issue' ELSE 'merge_request' END AS entity_type,\n       COALESCE(i.iid, mr.iid) AS entity_iid,\n       p.path_with_namespace AS project_path,\n       rse.actor_username AS actor,\n       (rse.actor_username = ?username) AS is_own,\n       rse.state || ' ' || CASE WHEN rse.issue_id IS NOT NULL THEN 'issue' ELSE 'MR' END AS summary,\n       NULL AS body_preview\nFROM resource_state_events rse\nLEFT JOIN issues i ON rse.issue_id = i.id\nLEFT JOIN merge_requests mr ON rse.merge_request_id = mr.id\nJOIN projects p ON p.id = rse.project_id\nWHERE rse.created_at >= ?since_ms\n  AND (my_items_filter)\n  AND (project_scope_filter)\n```\n\n### Source 3: Label events\n```sql\nSELECT rle.created_at AS timestamp,\n       'label' AS event_type,\n       CASE WHEN rle.issue_id IS NOT NULL THEN 'issue' ELSE 'merge_request' END AS entity_type,\n       COALESCE(i.iid, mr.iid) AS entity_iid,\n       p.path_with_namespace AS project_path,\n       rle.actor_username AS actor,\n       (rle.actor_username = ?username) AS is_own,\n       rle.action || ' label ' || COALESCE(rle.label_name, '?') AS summary,\n       NULL AS body_preview\nFROM resource_label_events rle\nLEFT JOIN issues i ON rle.issue_id = i.id\nLEFT JOIN merge_requests mr ON rle.merge_request_id = mr.id\nJOIN projects p ON p.id = rle.project_id\nWHERE rle.created_at >= ?since_ms\n  AND (my_items_filter)\n  AND (project_scope_filter)\n```\n\n### Source 4: Milestone events\n```sql\nSELECT rme.created_at AS timestamp,\n       'milestone' AS event_type,\n       CASE WHEN rme.issue_id IS NOT NULL THEN 'issue' ELSE 'merge_request' END AS entity_type,\n       COALESCE(i.iid, mr.iid) AS entity_iid,\n       p.path_with_namespace AS project_path,\n       rme.actor_username AS actor,\n       (rme.actor_username = ?username) AS is_own,\n       rme.action || ' milestone ' || COALESCE(rme.milestone_title, '?') AS summary,\n       NULL AS body_preview\nFROM resource_milestone_events rme\nLEFT JOIN issues i ON rme.issue_id = i.id\nLEFT JOIN merge_requests mr ON rme.merge_request_id = mr.id\nJOIN projects p ON p.id = rme.project_id\nWHERE rme.created_at >= ?since_ms\n  AND (my_items_filter)\n  AND (project_scope_filter)\n```\n\n### \"My items\" filter (AC-3.6 — current associations)\n```sql\n(\n  (d.issue_id IS NOT NULL AND EXISTS (\n    SELECT 1 FROM issue_assignees ia WHERE ia.issue_id = d.issue_id AND ia.username = ?username\n  ))\n  OR\n  (d.merge_request_id IS NOT NULL AND (\n    EXISTS (SELECT 1 FROM merge_requests m2 WHERE m2.id = d.merge_request_id AND m2.author_username = ?username)\n    OR EXISTS (SELECT 1 FROM mr_reviewers mrr WHERE mrr.merge_request_id = d.merge_request_id AND mrr.username = ?username)\n  ))\n)\n```\nFor resource event tables, use `rse.issue_id`/`rse.merge_request_id` instead of `d.issue_id`/`d.merge_request_id`.\n\n### ProjectScope filter\nUse the `ProjectScope` enum from bd-a7ba:\n- `All` → no filter\n- `Single(project_id)` → `AND p.id = ?project_id`\n\n### Function signature\n```rust\npub fn build_notes_activity_sql(scope: &ProjectScope) -> String\npub fn build_state_events_sql(scope: &ProjectScope) -> String\npub fn build_label_events_sql(scope: &ProjectScope) -> String\npub fn build_milestone_events_sql(scope: &ProjectScope) -> String\n```\n\nOr a single function that returns all 4 SQL fragments for UNION ALL assembly.\n\n## Acceptance Criteria\n- [ ] Notes query returns human comments (is_system=0) on my items within --since window\n- [ ] State events query returns state changes on my items within --since window\n- [ ] Label events query returns label changes on my items within --since window\n- [ ] Milestone events query returns milestone changes on my items within --since window\n- [ ] All 4 queries return identical column shape: timestamp, event_type, entity_type, entity_iid, project_path, actor, is_own, summary, body_preview\n- [ ] body_preview: SUBSTR(body, 1, 200) for notes (AC-7.6), NULL for events\n- [ ] is_own flag: `(actor_username = ?username)` returns 0/1 (AC-5.4)\n- [ ] \"My items\" uses CURRENT association only (AC-3.6): assignee, MR author, or MR reviewer\n- [ ] Includes activity on items regardless of open/closed state (AC-5.4)\n- [ ] Respects ProjectScope filtering\n- [ ] label_name and milestone_title handled as potentially NULL (COALESCE with '?')\n\n## Files\n- CREATE: src/cli/commands/me/activity.rs\n- MODIFY: src/cli/commands/me/mod.rs (add `pub mod activity;`)\n\n## TDD Anchor\nRED: Write `test_note_activity_returns_human_comments` using in-memory DB. Insert: project, issue, issue_assignee(username=me), discussion(issue_id=issue, noteable_type='Issue'), note(is_system=0, created_at within since window). Execute notes query. Assert 1 result with event_type=\"note\".\n\nGREEN: Implement the notes query.\nVERIFY: `cargo test note_activity`\n\nAdditional tests:\n- test_note_activity_excludes_system_notes (insert is_system=1 note, assert 0 results)\n- test_note_activity_respects_since_window (insert note before since_ms, assert 0 results)\n- test_state_event_appears_in_activity (insert resource_state_event with issue_id set)\n- test_label_event_appears_in_activity (insert resource_label_event)\n- test_milestone_event_appears_in_activity (insert resource_milestone_event)\n- test_activity_only_for_currently_associated_items (insert note on unassigned issue, assert 0)\n- test_is_own_flag_correct (two notes: one by me, one by other; check is_own on each)\n\n## Edge Cases\n- resource event tables use CHECK constraint: exactly one of issue_id/merge_request_id is non-NULL — LEFT JOINs handle this\n- `label_name` is NULLABLE after migration 012 — use `COALESCE(rle.label_name, '?')` in summary\n- `milestone_title` is NULLABLE after migration 012 — same COALESCE\n- `actor_username` on resource events can be NULL (rare: API returns null actor) — handle with COALESCE in actor column\n- The \"my items\" filter joins differently for notes (through discussions) vs resource events (direct issue_id/merge_request_id columns)\n- Activity on closed items SHOULD appear — no `state = 'opened'` filter\n\n## Dependency Context\nUses `MeActivityItem` struct from bd-3bwh. Uses `ProjectScope` enum from bd-a7ba.\nConsumed by bd-2tl5 (UNION ALL assembly) and bd-2nl3 (assignment detection adds a 5th source).","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-19T19:37:56.988620Z","created_by":"tayloreernisse","updated_at":"2026-02-20T16:09:13.055105Z","closed_at":"2026-02-20T16:09:13.055064Z","close_reason":"Implemented by lore-me agent swarm","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-b3r3","depends_on_id":"bd-3bwh","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-b3r3","depends_on_id":"bd-a7ba","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-b51e","title":"WHO: Overlap mode query (query_overlap)","description":"## Background\n\nOverlap mode answers \"Who else has MRs/notes touching my files?\" — helps identify potential reviewers, collaborators, or conflicting work at a path. Tracks author and reviewer roles separately for richer signal.\n\n## Approach\n\n### SQL: two static variants (prefix/exact) with reviewer + author UNION ALL\n\nBoth branches return: username, role, touch_count (COUNT DISTINCT m.id), last_seen_at, mr_refs (GROUP_CONCAT of project-qualified refs).\n\nKey differences from Expert:\n- No scoring formula — just touch_count ranking\n- mr_refs collected for actionable output (group/project!iid format)\n- Rust-side merge needed (can't fully aggregate in SQL due to HashSet dedup of mr_refs across branches)\n\n### Reviewer branch includes:\n- Self-review exclusion: `n.author_username != m.author_username`\n- MR state filter: `m.state IN ('opened','merged')`\n- Project-qualified refs: `GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid))`\n\n### Rust accumulator pattern:\n```rust\nstruct OverlapAcc {\n    username: String,\n    author_touch_count: u32,\n    review_touch_count: u32,\n    touch_count: u32,\n    last_seen_at: i64,\n    mr_refs: HashSet<String>,  // O(1) dedup from the start\n}\n// Build HashMap<String, OverlapAcc> from rows\n// Convert to Vec, sort, bound mr_refs\n```\n\n### Bounded mr_refs:\n```rust\nconst MAX_MR_REFS_PER_USER: usize = 50;\nlet mr_refs_total = mr_refs.len() as u32;\nlet mr_refs_truncated = mr_refs.len() > MAX_MR_REFS_PER_USER;\n```\n\n### Deterministic sort: touch_count DESC, last_seen_at DESC, username ASC\n\n### format_overlap_role():\n```rust\nfn format_overlap_role(user: &OverlapUser) -> &'static str {\n    match (user.author_touch_count > 0, user.review_touch_count > 0) {\n        (true, true) => \"A+R\", (true, false) => \"A\",\n        (false, true) => \"R\", (false, false) => \"-\",\n    }\n}\n```\n\n### OverlapResult/OverlapUser structs include path_match (\"exact\"/\"prefix\"), truncated bool, per-user mr_refs_total + mr_refs_truncated\n\n## Files\n\n- `src/cli/commands/who.rs`\n\n## TDD Loop\n\nRED:\n```\ntest_overlap_dual_roles — user is author of MR 1 and reviewer of MR 2 at same path; verify A+R role, both touch counts > 0, mr_refs contain \"team/backend!\"\ntest_overlap_multi_project_mr_refs — same iid 100 in two projects; verify both \"team/backend!100\" and \"team/frontend!100\" present\ntest_overlap_excludes_self_review_notes — author comments on own MR; review_touch_count must be 0\n```\n\nGREEN: Implement query_overlap with both SQL variants + accumulator\nVERIFY: `cargo test -- overlap`\n\n## Acceptance Criteria\n\n- [ ] test_overlap_dual_roles passes (A+R role detection)\n- [ ] test_overlap_multi_project_mr_refs passes (project-qualified refs unique)\n- [ ] test_overlap_excludes_self_review_notes passes\n- [ ] Default since window: 30d\n- [ ] mr_refs sorted alphabetically for deterministic output\n- [ ] touch_count uses coherent units (COUNT DISTINCT m.id on BOTH branches)\n\n## Edge Cases\n\n- Both branches count MRs (not DiffNotes) for coherent touch_count — mixing units produces misleading totals\n- mr_refs from GROUP_CONCAT may contain duplicates across branches — HashSet handles dedup\n- Project scoping on n.project_id (not m.project_id) for index alignment\n- mr_refs sorted before output (HashSet iteration is nondeterministic)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-08T02:40:46.729921Z","created_by":"tayloreernisse","updated_at":"2026-02-08T04:10:29.598708Z","closed_at":"2026-02-08T04:10:29.598673Z","close_reason":"Implemented by agent team: migration 017, CLI skeleton, all 5 query modes, human+robot output, 20 tests. All quality gates pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-b51e","depends_on_id":"bd-2ldg","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-b51e","depends_on_id":"bd-34rr","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-bcte","title":"Implement filter DSL parser state machine","description":"## Background\n\nThe Issue List and MR List filter bars accept typed filter expressions (e.g., `state:opened author:@asmith label:\"high priority\" -milestone:v2.0`). The PRD Appendix B defines a full state machine: Inactive -> Active -> FieldSelect/FreeText -> ValueInput. The parser needs to handle field:value pairs, negation prefix (`-`), quoted values with spaces, bare text as free-text search, and inline error diagnostics when an unrecognized field name is typed. This is a substantial subsystem that the entity table filter bar widget (bd-18qs) depends on for its core functionality.\n\n## Approach\n\nCreate a `filter_dsl.rs` module with:\n\n1. **FilterToken enum** — `Field { name: String, value: String, negated: bool }` | `FreeText(String)` | `Error { position: usize, message: String }`\n2. **`parse_filter(input: &str) -> Vec<FilterToken>`** — Tokenizer that handles:\n   - `field:value` — recognized fields: state, author, assignee, label, milestone, since, project (issue); + reviewer, draft, target, source (MR)\n   - `-field:value` — negation prefix strips the `-` and sets `negated: true`\n   - `field:\"quoted value\"` — double-quoted values preserve spaces\n   - bare words — collected as `FreeText` tokens\n   - unrecognized field names — produce `Error` token with position and message\n3. **FilterBarState** state machine:\n   - `Inactive` — filter bar not focused\n   - `Active(Typing)` — user typing, no suggestion yet\n   - `Active(Suggesting)` — 200ms pause triggers field name suggestions\n   - `FieldSelect` — dropdown showing recognized field names after `:`\n   - `ValueInput` — context-dependent completions (e.g., state values: opened/closed/all)\n4. **`apply_issue_filter(tokens: &[FilterToken]) -> IssueFilterParams`** — converts tokens to query parameters\n5. **`apply_mr_filter(tokens: &[FilterToken]) -> MrFilterParams`** — MR variant with reviewer, draft, target/source fields\n\n## Acceptance Criteria\n- [ ] `parse_filter(\"state:opened\")` returns one Field token with name=\"state\", value=\"opened\", negated=false\n- [ ] `parse_filter(\"-label:bug\")` returns one Field with negated=true\n- [ ] `parse_filter('author:\"Jane Doe\"')` returns one Field with value=\"Jane Doe\" (quotes stripped)\n- [ ] `parse_filter(\"foo:bar\")` where \"foo\" is not a recognized field returns Error token with position\n- [ ] `parse_filter(\"state:opened some text\")` returns Field + FreeText tokens\n- [ ] `parse_filter(\"\")` returns empty vec\n- [ ] FilterBarState transitions match the Appendix B state machine diagram\n- [ ] apply_issue_filter correctly maps all 7 issue fields (state, author, assignee, label, milestone, since, project)\n- [ ] apply_mr_filter correctly maps additional MR fields (reviewer, draft, target, source)\n- [ ] Inline error diagnostics include the character position of the unrecognized field\n\n## Files\n- CREATE: crates/lore-tui/src/widgets/filter_dsl.rs\n- MODIFY: crates/lore-tui/src/widgets/mod.rs (add `pub mod filter_dsl;`)\n\n## TDD Anchor\nRED: Write `test_parse_simple_field_value` that asserts `parse_filter(\"state:opened\")` returns `[Field { name: \"state\", value: \"opened\", negated: false }]`.\nGREEN: Implement the tokenizer for the simplest case.\nVERIFY: cargo test -p lore-tui parse_simple\n\nAdditional tests:\n- test_parse_negation\n- test_parse_quoted_value\n- test_parse_unrecognized_field_produces_error\n- test_parse_mixed_tokens\n- test_parse_empty_input\n- test_apply_issue_filter_maps_all_fields\n- test_apply_mr_filter_maps_additional_fields\n- test_filter_bar_state_transitions\n\n## Edge Cases\n- Unclosed quote (`author:\"Jane`) — treat rest of input as the value, produce warning token\n- Empty value (`state:`) — produce Error token, not a Field with empty value\n- Multiple colons (`field:val:ue`) — first colon splits, rest is part of value\n- Unicode in field values (`author:@rene`) — must handle multi-byte chars correctly\n- Very long filter strings (>1000 chars) — must not allocate unbounded; truncate with error\n\n## Dependency Context\n- Depends on bd-18qs (entity table + filter bar widgets) which provides the TextInput widget and filter bar rendering. This bead provides the PARSER that bd-18qs's filter bar CALLS.\n- Consumed by bd-3ei1 (Issue List) and bd-2kr0 (MR List) for converting user filter input into query parameters.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T19:29:37.516695Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:21.740632Z","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-bjo","title":"Implement vector search function","description":"## Background\nVector search queries the sqlite-vec virtual table for nearest-neighbor documents. Because documents may have multiple chunks, the raw KNN results need deduplication by document_id (keeping the best/lowest distance per document). The function over-fetches 3x to ensure enough unique documents after dedup.\n\n## Approach\nCreate `src/search/vector.rs`:\n\n```rust\npub struct VectorResult {\n    pub document_id: i64,\n    pub distance: f64,  // Lower = closer match\n}\n\n/// Search documents using sqlite-vec KNN query.\n/// Over-fetches 3x limit to handle chunk dedup.\npub fn search_vector(\n    conn: &Connection,\n    query_embedding: &[f32],  // 768-dim embedding of search query\n    limit: usize,\n) -> Result<Vec<VectorResult>>\n```\n\n**SQL (KNN query):**\n```sql\nSELECT rowid, distance\nFROM embeddings\nWHERE embedding MATCH ?\n  AND k = ?\nORDER BY distance\n```\n\n**Algorithm:**\n1. Convert query_embedding to raw LE bytes\n2. Execute KNN with k = limit * 3 (over-fetch for dedup)\n3. Decode each rowid via decode_rowid() -> (document_id, chunk_index)\n4. Group by document_id, keep minimum distance (best chunk)\n5. Sort by distance ascending\n6. Take first `limit` results\n\n## Acceptance Criteria\n- [ ] Returns deduplicated document-level results (not chunk-level)\n- [ ] Best chunk distance kept per document (lowest distance wins)\n- [ ] KNN with k parameter (3x limit)\n- [ ] Query embedding passed as raw LE bytes\n- [ ] Results sorted by distance ascending (closest first)\n- [ ] Returns at most `limit` results\n- [ ] Empty embeddings table returns empty Vec\n- [ ] `cargo build` succeeds\n\n## Files\n- `src/search/vector.rs` — new file\n- `src/search/mod.rs` — add `pub use vector::{search_vector, VectorResult};`\n\n## TDD Loop\nRED: Integration tests need sqlite-vec + seeded embeddings:\n- `test_vector_search_basic` — finds nearest document\n- `test_vector_search_dedup` — multi-chunk doc returns once with best distance\n- `test_vector_search_empty` — empty table returns empty\n- `test_vector_search_limit` — respects limit parameter\nGREEN: Implement search_vector\nVERIFY: `cargo test vector`\n\n## Edge Cases\n- All chunks belong to same document: returns single result\n- Query embedding wrong dimension: sqlite-vec may error — handle gracefully\n- Over-fetch returns fewer than limit unique docs: return what we have\n- Distance = 0.0: exact match (valid result)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:26:50.270357Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:44:56.233611Z","closed_at":"2026-01-30T17:44:56.233512Z","close_reason":"Implemented search_vector with KNN query, 3x over-fetch, chunk dedup. 3 tests pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-bjo","depends_on_id":"bd-1y8","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-bjo","depends_on_id":"bd-2ac","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-bqoc","title":"Phase 1: Build HTTP adapter layer (src/http.rs)","description":"## What\nCreate src/http.rs (~100 LOC) — an adapter layer wrapping asupersync HttpClient with ergonomic methods matching gitlore usage patterns.\n\n## Why\nAsupersync HttpClient is lower-level than reqwest:\n- Headers: Vec<(String, String)> not typed HeaderMap/HeaderValue\n- Body: Vec<u8> not a builder with .json()\n- Status: raw u16 not StatusCode enum\n- Response: body already buffered, no async .json().await\n- No per-request timeout\n\nWithout an adapter, every call site becomes 5-6 lines of boilerplate. The adapter also isolates gitlore from asupersync pre-1.0 HTTP API changes.\n\n## API Design\n\n### Client struct\n```rust\npub struct Client {\n    inner: HttpClient,\n    timeout: Duration,\n}\n```\n\n### Methods\n- Client::with_timeout(Duration) -> Self — constructor with connection pool config\n- client.get(url, headers) -> Result<Response>\n- client.get_with_query(url, params, headers) -> Result<Response>\n- client.post_json(url, headers, body: &T) -> Result<Response>\n- (private) client.execute(method, url, headers, body) -> Result<Response>\n\n### Response struct\n```rust\npub struct Response {\n    pub status: u16,\n    pub reason: String,\n    pub headers: Vec<(String, String)>,\n    body: Vec<u8>,  // private, accessed via methods\n}\n```\n\n### Response methods\n- response.is_success() -> bool (200..300)\n- response.json::<T>() -> Result<T>\n- response.text() -> Result<String>\n- response.header(name) -> Option<&str> (case-insensitive)\n- response.headers_all(name) -> Vec<&str> (multi-value, for Link header pagination)\n\n### Timeout behavior\nEvery request wrapped with asupersync::time::timeout(self.timeout, ...). Maps to:\n- Timeout -> LoreError::GitLabNetworkError { kind: Timeout }\n- Transport error -> classified via classify_transport_error(&e) -> NetworkErrorKind\n\n### Response body size guard\nMAX_RESPONSE_BODY_BYTES = 64 MiB. Safety net for runaways. Normal responses are <1 MiB.\n\n### URL query helper\nappend_query_params(url, params) handles:\n- URLs with existing ?query -> appends with &\n- URLs with #fragment -> inserts query before fragment\n- Empty params -> returns URL unchanged\n- Repeated keys -> preserved (GitLab uses repeated labels[])\n\n### Connection pool config\n- max_connections_per_host: 6\n- max_total_connections: 100\n- idle_timeout: 90s\n\n## Default timeouts by consumer\n- GitLab REST/GraphQL: 30s\n- Ollama: configurable (default 60s)\n- Ollama health check: 5s\n\n## Files Changed\n- src/http.rs (NEW, ~100 LOC)\n- src/main.rs or src/lib.rs (add pub mod http)\n\n## Testing\n- Unit tests for append_query_params edge cases (existing query, fragments, empty params, repeated keys)\n- Unit tests for Response::header case-insensitivity\n- cargo check --all-targets\n- cargo clippy --all-targets -- -D warnings\n\n## Depends On\n- Phase 0d (error types: NetworkErrorKind must exist)\n- Decision Gate (must verify asupersync compiles first)","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-03-06T18:39:26.325053Z","created_by":"tayloreernisse","updated_at":"2026-03-06T21:11:12.566789Z","closed_at":"2026-03-06T21:11:12.566744Z","close_reason":"asupersync migration complete — 5 commits pushed to asupersync-migration branch","compaction_level":0,"original_size":0,"labels":["asupersync","phase-1"],"dependencies":[{"issue_id":"bd-bqoc","depends_on_id":"bd-1iuj","type":"blocks","created_at":"2026-03-06T18:42:49.922028Z","created_by":"tayloreernisse"},{"issue_id":"bd-bqoc","depends_on_id":"bd-1lj5","type":"parent-child","created_at":"2026-03-06T18:39:26.329649Z","created_by":"tayloreernisse"},{"issue_id":"bd-bqoc","depends_on_id":"bd-1lli","type":"blocks","created_at":"2026-03-06T18:42:50.116708Z","created_by":"tayloreernisse"}]}
{"id":"bd-c9gk","title":"Implement core types (Msg, Screen, EntityKey, AppError, InputMode)","description":"## Background\nThe core types form the message-passing backbone of the Elm Architecture. Every user action and async result flows through the Msg enum. Screen identifies navigation targets. EntityKey provides safe cross-project entity identity. AppError enables structured error display. InputMode controls key dispatch routing.\n\n## Approach\nCreate crates/lore-tui/src/message.rs with:\n- Msg enum (~40 variants): RawEvent, Tick, Resize, NavigateTo, GoBack, GoForward, GoHome, JumpBack, JumpForward, OpenCommandPalette, CloseCommandPalette, CommandPaletteInput, CommandPaletteSelect, IssueListLoaded{generation, rows}, IssueListFilterChanged, IssueListSortChanged, IssueSelected, MrListLoaded{generation, rows}, MrListFilterChanged, MrSelected, IssueDetailLoaded{generation, key, detail}, MrDetailLoaded{generation, key, detail}, DiscussionsLoaded{generation, discussions}, SearchQueryChanged, SearchRequestStarted{generation, query}, SearchExecuted{generation, results}, SearchResultSelected, SearchModeChanged, SearchCapabilitiesLoaded, TimelineLoaded, TimelineEntitySelected, WhoResultLoaded, WhoModeChanged, SyncStarted, SyncProgress, SyncProgressBatch, SyncLogLine, SyncBackpressureDrop, SyncCompleted, SyncCancelled, SyncFailed, SyncStreamStats, SearchDebounceArmed, SearchDebounceFired, DashboardLoaded, Error, ShowHelp, ShowCliEquivalent, OpenInBrowser, BlurTextInput, ScrollToTopCurrentScreen, Quit\n- impl From<Event> for Msg (FrankenTUI requirement) — maps Resize, Tick, and wraps everything else in RawEvent\n- Screen enum: Dashboard, IssueList, IssueDetail(EntityKey), MrList, MrDetail(EntityKey), Search, Timeline, Who, Sync, Stats, Doctor, Bootstrap\n- Screen::label() -> &str and Screen::is_detail_or_entity() -> bool\n- EntityKey { project_id: i64, iid: i64, kind: EntityKind } with EntityKey::issue() and EntityKey::mr() constructors\n- EntityKind enum: Issue, MergeRequest\n- AppError enum: DbBusy, DbCorruption(String), NetworkRateLimited{retry_after_secs}, NetworkUnavailable, AuthFailed, ParseError(String), Internal(String) with Display impl\n- InputMode enum: Normal, Text, Palette, GoPrefix{started_at: Instant} with Default -> Normal\n\n## Acceptance Criteria\n- [ ] Msg enum compiles with all ~40 variants\n- [ ] From<Event> impl converts Resize->Msg::Resize, Tick->Msg::Tick, other->Msg::RawEvent\n- [ ] Screen enum has all 12 variants with label() and is_detail_or_entity() methods\n- [ ] EntityKey::issue() and EntityKey::mr() constructors work correctly\n- [ ] EntityKey derives Debug, Clone, PartialEq, Eq, Hash\n- [ ] AppError Display shows user-friendly messages for each variant\n- [ ] InputMode defaults to Normal\n\n## Files\n- CREATE: crates/lore-tui/src/message.rs\n\n## TDD Anchor\nRED: Write test_entity_key_equality that asserts EntityKey::issue(1, 42) == EntityKey::issue(1, 42) and EntityKey::issue(1, 42) != EntityKey::mr(1, 42).\nGREEN: Implement EntityKey with derives.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_entity_key\n\n## Edge Cases\n- Generation fields (u64) in Msg variants are critical for stale result detection — must be present on all async result variants\n- EntityKey equality must include both project_id AND iid AND kind — bare iid is unsafe with multi-project datasets\n- AppError::NetworkRateLimited retry_after_secs is Option<u64> — GitLab may not provide Retry-After header","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T16:53:37.143607Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:24.096852Z","deleted_at":"2026-03-11T18:34:24.096847Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-cbo","title":"[CP1] Cargo.toml updates - async-stream and futures","description":"Add required dependencies for async pagination streams.\n\n## Changes\nAdd to Cargo.toml:\n- async-stream = \"0.3\"\n- futures = \"0.3\"\n\n## Why\nThe pagination methods use async generators which require async-stream crate.\nfutures crate provides StreamExt for consuming the streams.\n\n## Done When\n- cargo check passes with new deps\n- No unused dependency warnings\n\nFiles: Cargo.toml","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:42:31.143927Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.661666Z","closed_at":"2026-01-25T17:02:01.661666Z","deleted_at":"2026-01-25T17:02:01.661662Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-cq2","title":"[CP1] Integration tests for label linkage","description":"Integration tests verifying label linkage and stale removal.\n\n## Tests (tests/label_linkage_tests.rs)\n\n- clears_existing_labels_before_linking_new_set\n- removes_stale_label_links_on_issue_update\n- handles_issue_with_all_labels_removed\n- preserves_labels_that_still_exist\n\n## Test Scenario\n1. Create issue with labels [A, B]\n2. Verify issue_labels has links to A and B\n3. Update issue with labels [B, C]\n4. Verify A link removed, B preserved, C added\n\n## Why This Matters\nThe clear-and-relink pattern ensures GitLab reality is reflected locally.\nIf we only INSERT, removed labels would persist incorrectly.\n\nFiles: tests/label_linkage_tests.rs\nDone when: Stale label links correctly removed on resync","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T16:59:10.665771Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:02.062192Z","closed_at":"2026-01-25T17:02:02.062192Z","deleted_at":"2026-01-25T17:02:02.062188Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-czk","title":"Add entity_references table to migration 010","description":"## Background\nThe entity_references table is now part of migration 011 (combined with resource event tables and dependent fetch queue). This bead is satisfied by bd-hu3 since the entity_references table schema is included in the same migration.\n\n## Approach\nThis bead's work is folded into bd-hu3 (Write migration 011). The entity_references table from Phase B spec §2.2 is included in migrations/011_resource_events.sql alongside the event tables and queue.\n\nThe entity_references schema includes:\n- source/target entity type + id with reference_type and source_method\n- Unresolved reference support (target_entity_id NULL with target_project_path + target_entity_iid)\n- UNIQUE constraint using COALESCE for nullable columns\n- Partial indexes for source, target (where not null), and unresolved refs\n\nNo separate migration file needed — this is in 011.\n\n## Acceptance Criteria\n- [ ] entity_references table exists in migration 011 (verified by bd-hu3)\n- [ ] UNIQUE constraint handles NULL columns via COALESCE\n- [ ] Indexes created: source composite, target composite (partial), unresolved (partial)\n- [ ] reference_type CHECK includes 'closes', 'mentioned', 'related'\n- [ ] source_method CHECK includes 'api_closes_issues', 'api_state_event', 'system_note_parse'\n\n## Files\n- migrations/011_resource_events.sql (part of bd-hu3)\n\n## TDD Loop\nCovered by bd-hu3's test_migration_011_entity_references_dedup test.\n\nVERIFY: `cargo test migration_tests -- --nocapture`\n\n## Edge Cases\n- Same as bd-hu3's entity_references edge cases","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:32:33.506883Z","created_by":"tayloreernisse","updated_at":"2026-02-02T22:42:06.104237Z","closed_at":"2026-02-02T22:42:06.104190Z","close_reason":"Work folded into bd-hu3 (migration 011 includes entity_references table)","compaction_level":0,"original_size":0,"labels":["gate-2","phase-b","schema"]}
{"id":"bd-dty","title":"Implement timeline robot mode JSON output","description":"## Background\n\nRobot mode JSON for timeline follows the {ok, data, meta} envelope pattern. The JSON schema MUST match spec Section 3.5 exactly — this is the contract for AI agent consumers.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.5 (Robot Mode JSON).\n\n## Codebase Context\n\n- Robot mode pattern: all commands use {ok: true, data: {...}, meta: {...}} envelope\n- Timestamps: internal ms epoch UTC -> output ISO 8601 via core::time::ms_to_iso()\n- source_method values in DB: 'api', 'note_parse', 'description_parse' (NOT spec's api_closes_issues etc.)\n- Serde rename: use #[serde(rename = \"type\")] for entity objects per spec\n\n## Approach\n\nCreate `print_timeline_json()` in `src/cli/commands/timeline.rs`:\n\n### Key JSON structure (spec Section 3.5):\n- data.seed_entities: [{type, iid, project}] — note \"type\" not \"entity_type\", \"project\" not \"project_path\"\n- data.expanded_entities: [{type, iid, project, depth, via: {from: {type,iid,project}, reference_type, source_method}}]\n- data.unresolved_references: [{source: {type,iid,project}, target_project, target_type, target_iid, reference_type}]\n- data.events: [{timestamp (ISO 8601), entity_type, entity_iid, project, event_type, summary, actor, url, is_seed, details}]\n- meta: {search_mode: \"lexical\", expansion_depth, expand_mentions, total_entities, total_events, evidence_notes_included, unresolved_references, showing}\n\n### Details object per event type:\n- created: {labels: [...]}\n- note_evidence: {note_id, snippet}\n- state_changed: {state}\n- label_added: {label}\n\n### Rust JSON Structs\n\n```rust\n#[derive(Serialize)]\nstruct TimelineJson {\n    ok: bool,\n    data: TimelineDataJson,\n    meta: TimelineMetaJson,\n}\n\n#[derive(Serialize)]\nstruct TimelineDataJson {\n    query: String,\n    event_count: usize,\n    seed_entities: Vec<EntityJson>,\n    expanded_entities: Vec<ExpandedEntityJson>,\n    unresolved_references: Vec<UnresolvedRefJson>,\n    events: Vec<EventJson>,\n}\n\n#[derive(Serialize)]\nstruct EntityJson {\n    #[serde(rename = \"type\")]\n    entity_type: String,\n    iid: i64,\n    project: String,\n}\n\n#[derive(Serialize)]\nstruct TimelineMetaJson {\n    search_mode: String,        // always \"lexical\"\n    expansion_depth: u32,\n    expand_mentions: bool,\n    total_entities: usize,\n    total_events: usize,        // before limit\n    evidence_notes_included: usize,\n    unresolved_references: usize,\n    showing: usize,             // after limit\n}\n```\n\n### source_method values: use CODEBASE values (api/note_parse/description_parse), not spec values\n\n## Acceptance Criteria\n\n- [ ] Valid JSON to stdout\n- [ ] {ok, data, meta} envelope\n- [ ] ISO 8601 timestamps\n- [ ] Entity objects use \"type\" and \"project\" keys per spec\n- [ ] Nested \"via\" object on expanded entities per spec\n- [ ] Events include url and details fields\n- [ ] meta.total_events before limit; meta.showing after limit\n- [ ] source_method uses codebase values\n- [ ] `cargo check --all-targets` passes\n\n## Files\n\n- `src/cli/commands/timeline.rs` (add print_timeline_json + JSON structs)\n- `src/cli/commands/mod.rs` (re-export)\n\n## TDD Loop\n\nVerify: `lore --robot timeline \"test\" | jq '.data.expanded_entities[0].via.from'`\n\n## Edge Cases\n\n- Empty results: events=[], meta.showing=0\n- Null actor/url: serialize as null (not omitted)\n- source_method: use actual DB values, not spec originals","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:33:28.374690Z","created_by":"tayloreernisse","updated_at":"2026-02-06T13:49:12.653118Z","closed_at":"2026-02-06T13:49:12.653067Z","close_reason":"Implemented print_timeline_json_with_meta() robot JSON output in src/cli/commands/timeline.rs with {ok,data,meta} envelope, ISO timestamps, entity/expanded/unresolved JSON structs, event details per type","compaction_level":0,"original_size":0,"labels":["cli","gate-3","phase-b","robot-mode"],"dependencies":[{"issue_id":"bd-dty","depends_on_id":"bd-3as","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-dty","depends_on_id":"bd-ike","type":"parent-child","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-e48d","title":"Render activity feed with event badges in human mode","description":"## Background\nHuman-mode activity output needs fast scanning: event badge, actor, entity reference, summary, time, optional preview, and optional project path. Badge color semantics must match AC-6.4 while covering all event types emitted by me activity queries (`note`, `status`, `label`, `milestone`, `assign`, `unassign`, `review_request`).\n\n## Approach\nImplement activity rendering in `src/cli/commands/me/render_human.rs`.\n\n### 1. Badge renderer\nAdd helper:\n```rust\nfn render_event_badge(event_type: &str) -> String\n```\n\nMapping:\n- `note` -> cyan\n- `status` -> amber\n- `label` -> purple\n- `assign` / `unassign` / `review_request` -> green\n- `milestone` -> magenta\n- fallback -> dim bracket label\n\nMode behavior:\n- Color-capable mode: background pill style (fg/bg contrast).\n- ASCII/no-color mode: bracketed text label using foreground color only (`[note]`, `[status]`, etc).\n\n### 2. Row renderer\nFor each activity item:\n- line 1: badge + `@actor` + entity ref (`#iid`/`!iid`) + summary + optional `(you)` + relative timestamp\n- line 2: quoted `body_preview` (only for `note`, newline-collapsed)\n- line 3: `project_path` dimmed unless single-project scope\n\n### 3. Own-action styling\nIf `is_own`:\n- append `(you)`\n- dim the full first line to reduce visual priority\n\n## Acceptance Criteria\n- [ ] Badge colors: note=cyan, status=amber, label=purple, assign/unassign/review_request=green, milestone=magenta\n- [ ] ASCII fallback renders bracket labels with colored foreground text\n- [ ] `@actor` rendered with username color\n- [ ] Issue refs (`#iid`) and MR refs (`!iid`) use existing ref palette\n- [ ] Own actions include `(you)` and dimmed emphasis\n- [ ] `body_preview` only appears for `note` events\n- [ ] `body_preview` newlines are replaced with spaces\n- [ ] Project path line suppressed for single-project scope\n- [ ] Section header is `Activity (N)` via `section_divider`\n- [ ] Empty state renders `No recent activity`\n\n## Files\n- MODIFY: `src/cli/commands/me/render_human.rs`\n\n## TDD Anchor\nRED:\n- `test_event_badge_color_mapping_includes_unassign`\n- `test_activity_own_action_includes_you_suffix`\n- `test_activity_preview_only_for_note`\n- `test_activity_project_path_suppressed_single_project`\n- `test_activity_ascii_badges`\n\nGREEN:\n- Implement badge + row rendering with event coverage parity.\n\nVERIFY:\n- `cargo test me_render_activity`\n\n## Edge Cases\n- Unknown event types should still render safely with a neutral badge.\n- `actor` may be empty/system fallback; renderer must not panic.\n- `review_request` labels are long; spacing should remain readable without fixed-width assumptions.\n\n## Dependency Context\nConsumes `MeActivityItem` from `bd-3bwh` and event types produced by `bd-b3r3` + `bd-2nl3`.\nIntegrated by `bd-1vv8` handler.\n\nDependencies:\n  -> bd-1vxq (blocks) - Render summary header and attention legend\n\nDependents:\n  <- bd-1vv8 (blocks) - Implement me command handler: wire queries to renderers","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-19T19:39:46.381793Z","created_by":"tayloreernisse","updated_at":"2026-02-20T16:09:13.062218Z","closed_at":"2026-02-20T16:09:13.062179Z","close_reason":"Implemented by lore-me agent swarm","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-e48d","depends_on_id":"bd-1vxq","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-ef0u","title":"NOTE-2B: SourceType enum extension for notes","description":"## Background\nThe SourceType enum in src/documents/extractor.rs (line 15-19) needs a Note variant for the document pipeline to handle note-type documents.\n\n## Approach\nIn src/documents/extractor.rs:\n1. Add Note variant to SourceType enum (line 15-19, after Discussion):\n   pub enum SourceType { Issue, MergeRequest, Discussion, Note }\n\n2. Add match arm to as_str() (line 22-28): Self::Note => \"note\"\n\n3. Add parse aliases (line 30-37): \"note\" | \"notes\" => Some(Self::Note)\n\n4. Display impl (line 40-43) already delegates to as_str() — no change needed.\n\n5. IMPORTANT: Also update seed_dirty() in src/cli/commands/generate_docs.rs (line 66-70) which has a match on SourceType that maps to table names. SourceType::Note should NOT be added to this match — notes are seeded differently (by querying the notes table, not by table name pattern). This is handled by NOTE-2E.\n\n## Files\n- MODIFY: src/documents/extractor.rs (SourceType enum at line 15, as_str at line 22, parse at line 30)\n\n## TDD Anchor\nRED: test_source_type_parse_note — assert SourceType::parse(\"note\") == Some(SourceType::Note)\nGREEN: Add Note variant and match arms.\nVERIFY: cargo test source_type_parse_note -- --nocapture\nTests: test_source_type_note_as_str (assert as_str() == \"note\"), test_source_type_note_display (assert format!(\"{}\", SourceType::Note) == \"note\"), test_source_type_parse_notes_alias (assert parse(\"notes\") works)\n\n## Acceptance Criteria\n- [ ] SourceType::Note variant exists\n- [ ] as_str() returns \"note\"\n- [ ] parse() accepts \"note\", \"notes\" (case-insensitive via to_lowercase)\n- [ ] Display trait works via as_str delegation\n- [ ] No change to seed_dirty() match — that's a separate bead (NOTE-2E)\n- [ ] All 4 tests pass, clippy clean\n- [ ] CRITICAL: regenerate_one() in src/documents/regenerator.rs (line 86-91) has exhaustive match on SourceType — adding Note variant will cause a compile error until NOTE-2D adds the match arm. Either add a temporary todo!() or coordinate with NOTE-2D.\n\n## Dependency Context\n- Depends on NOTE-2A (bd-1oi7): migration 024 must exist so test DBs accept source_type='note' in documents/dirty_sources tables\n\n## Edge Cases\n- Exhaustive match: Adding the variant breaks regenerate_one() (line 86-91) and seed_dirty() (line 66-70) until downstream beads handle it. Agent should add temporary unreachable!() arms with comments referencing the downstream bead IDs.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T17:01:45.555568Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:13:24.004157Z","closed_at":"2026-02-12T18:13:24.004106Z","close_reason":"Implemented by agent swarm","compaction_level":0,"original_size":0,"labels":["per-note","search"],"dependencies":[{"issue_id":"bd-ef0u","depends_on_id":"bd-18yh","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-ef0u","depends_on_id":"bd-2ezb","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-epj","title":"[CP0] Config loading with Zod validation","description":"## Background\n\nConfig loading is critical infrastructure - every CLI command needs the config. Uses Zod for schema validation with sensible defaults. Must handle missing files gracefully with typed errors.\n\nReference: docs/prd/checkpoint-0.md sections \"Configuration Schema\", \"Config Resolution Order\"\n\n## Approach\n\n**src/core/config.ts:**\n```typescript\nimport { z } from 'zod';\nimport { readFileSync } from 'node:fs';\nimport { ConfigNotFoundError, ConfigValidationError } from './errors';\nimport { getConfigPath } from './paths';\n\nexport const ConfigSchema = z.object({\n  gitlab: z.object({\n    baseUrl: z.string().url(),\n    tokenEnvVar: z.string().default('GITLAB_TOKEN'),\n  }),\n  projects: z.array(z.object({\n    path: z.string().min(1),\n  })).min(1),\n  sync: z.object({\n    backfillDays: z.number().int().positive().default(14),\n    staleLockMinutes: z.number().int().positive().default(10),\n    heartbeatIntervalSeconds: z.number().int().positive().default(30),\n    cursorRewindSeconds: z.number().int().nonnegative().default(2),\n    primaryConcurrency: z.number().int().positive().default(4),\n    dependentConcurrency: z.number().int().positive().default(2),\n  }).default({}),\n  storage: z.object({\n    dbPath: z.string().optional(),\n    backupDir: z.string().optional(),\n    compressRawPayloads: z.boolean().default(true),\n  }).default({}),\n  embedding: z.object({\n    provider: z.literal('ollama').default('ollama'),\n    model: z.string().default('nomic-embed-text'),\n    baseUrl: z.string().url().default('http://localhost:11434'),\n    concurrency: z.number().int().positive().default(4),\n  }).default({}),\n});\n\nexport type Config = z.infer<typeof ConfigSchema>;\n\nexport function loadConfig(cliOverride?: string): Config {\n  const path = getConfigPath(cliOverride);\n  // throws ConfigNotFoundError if missing\n  // throws ConfigValidationError if invalid\n}\n```\n\n## Acceptance Criteria\n\n- [ ] `loadConfig()` returns validated Config object\n- [ ] `loadConfig()` throws ConfigNotFoundError if file missing\n- [ ] `loadConfig()` throws ConfigValidationError with Zod errors if invalid\n- [ ] Empty optional fields get default values\n- [ ] projects array must have at least 1 item\n- [ ] gitlab.baseUrl must be valid URL\n- [ ] All number fields must be positive integers\n- [ ] tests/unit/config.test.ts passes (8 tests)\n\n## Files\n\nCREATE:\n- src/core/config.ts\n- tests/unit/config.test.ts\n- tests/fixtures/mock-responses/valid-config.json\n- tests/fixtures/mock-responses/invalid-config.json\n\n## TDD Loop\n\nRED:\n```typescript\n// tests/unit/config.test.ts\ndescribe('Config', () => {\n  it('loads config from file path')\n  it('throws ConfigNotFoundError if file missing')\n  it('throws ConfigValidationError if required fields missing')\n  it('validates project paths are non-empty strings')\n  it('applies default values for optional fields')\n  it('loads from XDG path by default')\n  it('respects GI_CONFIG_PATH override')\n  it('respects --config flag override')\n})\n```\n\nGREEN: Implement loadConfig() function\n\nVERIFY: `npm run test -- tests/unit/config.test.ts`\n\n## Edge Cases\n\n- JSON parse error should wrap in ConfigValidationError\n- Zod error messages should be human-readable\n- File exists but empty → ConfigValidationError\n- File has extra fields → should pass (Zod strips by default)","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-24T16:09:49.091078Z","created_by":"tayloreernisse","updated_at":"2026-01-25T03:04:32.592139Z","closed_at":"2026-01-25T03:04:32.592003Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-epj","depends_on_id":"bd-gg1","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-flwo","title":"Interactive path selection for ambiguous matches (TTY picker)","description":"When a partial file path matches multiple files, show an interactive numbered picker in TTY mode instead of a hard error. In robot mode, return candidates as structured JSON in the error envelope. Use dialoguer crate for selection UI. The path_resolver module already detects ambiguity via SuffixResult::Ambiguous and limits to 11 candidates.","status":"open","priority":3,"issue_type":"feature","created_at":"2026-02-13T16:31:50.005222Z","created_by":"tayloreernisse","updated_at":"2026-02-13T16:31:50.007520Z","compaction_level":0,"original_size":0,"labels":["cli-ux","gate-4"]}
{"id":"bd-g0d5","title":"WHO: Verification gate — check, clippy, fmt, EXPLAIN QUERY PLAN","description":"## Background\n\nFinal verification gate before the who epic is considered complete. Confirms code quality, test coverage, and index utilization against real data.\n\n## Approach\n\n### Step 1: Compiler checks\n```bash\ncargo check --all-targets\ncargo clippy --all-targets -- -D warnings\ncargo fmt --check\ncargo test\n```\n\n### Step 2: Manual smoke test (against real DB)\n```bash\ncargo run --release -- who src/features/global-search/\ncargo run --release -- who @asmith\ncargo run --release -- who @asmith --reviews\ncargo run --release -- who --active\ncargo run --release -- who --active --since 30d\ncargo run --release -- who --overlap libs/shared-frontend/src/features/global-search/\ncargo run --release -- who --path README.md\ncargo run --release -- who --path Makefile\ncargo run --release -- -J who src/features/global-search/  # robot mode\ncargo run --release -- -J who @asmith                       # robot mode\ncargo run --release -- who src/features/global-search/ -p typescript  # project scoped\n```\n\n### Step 3: EXPLAIN QUERY PLAN verification\n```bash\n# Expert: should use idx_notes_diffnote_path_created\nsqlite3 ~/.local/share/lore/lore.db \"\n  EXPLAIN QUERY PLAN\n  SELECT n.author_username, COUNT(*), MAX(n.created_at)\n  FROM notes n\n  WHERE n.note_type = 'DiffNote' AND n.is_system = 0\n    AND n.position_new_path LIKE 'src/features/global-search/%' ESCAPE '\\\\'\n    AND n.created_at >= 0\n  GROUP BY n.author_username;\"\n\n# Active global: should use idx_discussions_unresolved_recent_global\nsqlite3 ~/.local/share/lore/lore.db \"\n  EXPLAIN QUERY PLAN\n  SELECT d.id, d.last_note_at FROM discussions d\n  WHERE d.resolvable = 1 AND d.resolved = 0 AND d.last_note_at >= 0\n  ORDER BY d.last_note_at DESC LIMIT 20;\"\n\n# Active scoped: should use idx_discussions_unresolved_recent\nsqlite3 ~/.local/share/lore/lore.db \"\n  EXPLAIN QUERY PLAN\n  SELECT d.id, d.last_note_at FROM discussions d\n  WHERE d.resolvable = 1 AND d.resolved = 0 AND d.project_id = 1\n    AND d.last_note_at >= 0\n  ORDER BY d.last_note_at DESC LIMIT 20;\"\n```\n\n## Files\n\nNo files modified — verification only.\n\n## TDD Loop\n\nThis bead is the TDD VERIFY phase for the entire epic. No code written.\nVERIFY: All commands in Steps 1-3 must succeed. Document results.\n\n## Acceptance Criteria\n\n- [ ] cargo check --all-targets: 0 errors\n- [ ] cargo clippy --all-targets -- -D warnings: 0 warnings\n- [ ] cargo fmt --check: no formatting changes needed\n- [ ] cargo test: all tests pass (including 20+ who tests)\n- [ ] Expert EXPLAIN shows idx_notes_diffnote_path_created\n- [ ] Active global EXPLAIN shows idx_discussions_unresolved_recent_global\n- [ ] Active scoped EXPLAIN shows idx_discussions_unresolved_recent\n- [ ] All 5 modes produce reasonable output against real data\n- [ ] Robot mode produces valid JSON for all modes\n\n## Edge Cases\n\n- DB path may differ from ~/.local/share/lore/lore.db — check config with `lore -J doctor` first to get actual db_path\n- EXPLAIN QUERY PLAN output format varies by SQLite version — look for the index name in any output column, not an exact string match\n- If the DB has not been synced recently, smoke tests may return empty results — run `lore sync` first if needed\n- Project name \"typescript\" in the -p flag may not exist — use an actual project from `lore -J status` output\n- The real DB may not have migration 017 yet — run `cargo run --release -- migrate` first if the who command fails with a missing index error\n- clippy::pedantic + clippy::nursery are enabled — common issues: arrays vs vec![] for sorted collections, too_many_arguments on test helpers (use #[allow])","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-08T02:41:42.642988Z","created_by":"tayloreernisse","updated_at":"2026-02-08T04:10:29.606672Z","closed_at":"2026-02-08T04:10:29.606631Z","close_reason":"Implemented by agent team: migration 017, CLI skeleton, all 5 query modes, human+robot output, 20 tests. All quality gates pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-g0d5","depends_on_id":"bd-tfh3","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-g0d5","depends_on_id":"bd-zibc","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-gba","title":"OBSERV: Add tracing-appender dependency to Cargo.toml","description":"## Background\ntracing-appender provides non-blocking, daily-rotating file writes for the tracing ecosystem. It's the canonical solution used by tokio-rs projects. We need it for the file logging layer (Phase 1) that writes JSON logs to ~/.local/share/lore/logs/.\n\n## Approach\nAdd tracing-appender to [dependencies] in Cargo.toml (line ~54, after the existing tracing-subscriber entry):\n\n```toml\ntracing-appender = \"0.2\"\n```\n\nAlso add the \"json\" feature to tracing-subscriber since the file layer and --log-format json both need it:\n\n```toml\ntracing-subscriber = { version = \"0.3\", features = [\"env-filter\", \"json\"] }\n```\n\nCurrent tracing deps (Cargo.toml lines 53-54):\n  tracing = \"0.1\"\n  tracing-subscriber = { version = \"0.3\", features = [\"env-filter\"] }\n\n## Acceptance Criteria\n- [ ] cargo check --all-targets succeeds with tracing-appender available\n- [ ] tracing_appender::rolling::daily() is importable\n- [ ] tracing-subscriber json feature is available (fmt::layer().json() compiles)\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- Cargo.toml (modify lines 53-54 region)\n\n## TDD Loop\nRED: Not applicable (dependency addition)\nGREEN: Add deps, run cargo check\nVERIFY: cargo check --all-targets && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- Ensure tracing-appender 0.2 is compatible with tracing-subscriber 0.3 (both from tokio-rs/tracing monorepo, always compatible)\n- The \"json\" feature on tracing-subscriber pulls in serde_json, which is already a dependency","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-04T15:53:55.364100Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:10:22.520471Z","closed_at":"2026-02-04T17:10:22.520423Z","close_reason":"Added tracing-appender 0.2 and json feature to tracing-subscriber","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-gba","depends_on_id":"bd-2nx","type":"parent-child","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-gcnx","title":"NOTE-TEST: Test bead","description":"type: task","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T16:58:40.129030Z","updated_at":"2026-02-12T16:58:47.794167Z","closed_at":"2026-02-12T16:58:47.794116Z","close_reason":"test","compaction_level":0,"original_size":0}
{"id":"bd-gg1","title":"[CP0] Core utilities - paths, time, errors, logger","description":"## Background\n\nCore utilities provide the foundation for all other modules. Path resolution enables XDG-compliant config/data locations. Time utilities ensure consistent timestamp handling (ms epoch for DB, ISO for API). Error classes provide typed exceptions for clean error handling. Logger provides structured logging to stderr.\n\nReference: docs/prd/checkpoint-0.md sections \"Config + Data Locations\", \"Timestamp Convention\", \"Error Classes\", \"Logging Configuration\"\n\n## Approach\n\n**src/core/paths.ts:**\n- `getConfigPath(cliOverride?)`: resolution order is CLI flag → GI_CONFIG_PATH env → XDG default → local fallback\n- `getDataDir()`: uses XDG_DATA_HOME or ~/.local/share/gi\n- `getDbPath(configOverride?)`: returns data dir + data.db\n- `getBackupDir(configOverride?)`: returns data dir + backups/\n\n**src/core/time.ts:**\n- `isoToMs(isoString)`: converts GitLab API ISO 8601 → ms epoch\n- `msToIso(ms)`: converts ms epoch → ISO 8601\n- `nowMs()`: returns Date.now() for DB storage\n\n**src/core/errors.ts:**\nError hierarchy (all extend GiError base class with code and cause):\n- ConfigNotFoundError, ConfigValidationError\n- GitLabAuthError, GitLabNotFoundError, GitLabRateLimitError, GitLabNetworkError\n- DatabaseLockError, MigrationError\n- TokenNotSetError\n\n**src/core/logger.ts:**\n- pino logger to stderr (fd 2) with pino-pretty in dev\n- Child loggers: dbLogger, gitlabLogger, configLogger\n- LOG_LEVEL env var support (default: info)\n\n## Acceptance Criteria\n\n- [ ] `getConfigPath()` returns ~/.config/gi/config.json when no overrides\n- [ ] `getConfigPath()` respects GI_CONFIG_PATH env var\n- [ ] `getConfigPath(\"./custom.json\")` returns \"./custom.json\"\n- [ ] `isoToMs(\"2024-01-27T00:00:00.000Z\")` returns 1706313600000\n- [ ] `msToIso(1706313600000)` returns \"2024-01-27T00:00:00.000Z\"\n- [ ] All error classes have correct code property\n- [ ] Logger outputs to stderr (not stdout)\n- [ ] tests/unit/paths.test.ts passes\n- [ ] tests/unit/errors.test.ts passes\n\n## Files\n\nCREATE:\n- src/core/paths.ts\n- src/core/time.ts\n- src/core/errors.ts\n- src/core/logger.ts\n- tests/unit/paths.test.ts\n- tests/unit/errors.test.ts\n\n## TDD Loop\n\nRED: Write tests first\n```typescript\n// tests/unit/paths.test.ts\ndescribe('getConfigPath', () => {\n  it('uses XDG_CONFIG_HOME if set')\n  it('falls back to ~/.config/gi if XDG not set')\n  it('prefers --config flag over environment')\n  it('prefers environment over XDG default')\n  it('falls back to local gi.config.json in dev')\n})\n```\n\nGREEN: Implement paths.ts, errors.ts, time.ts, logger.ts\n\nVERIFY: `npm run test -- tests/unit/paths.test.ts tests/unit/errors.test.ts`\n\n## Edge Cases\n\n- XDG_CONFIG_HOME may not exist - don't create, just return path\n- existsSync() check for local fallback - only return if file exists\n- Time conversion must handle timezone edge cases - always use UTC\n- Logger must work even if pino-pretty not installed (production)","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-24T16:09:48.604382Z","created_by":"tayloreernisse","updated_at":"2026-01-25T02:53:26.527997Z","closed_at":"2026-01-25T02:53:26.527862Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-gg1","depends_on_id":"bd-327","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-hbo","title":"[CP1] Discussion ingestion module","description":"## Background\n\nDiscussion ingestion fetches all discussions and notes for a single issue. It is called as part of dependent sync - only for issues whose `updated_at` has advanced beyond `discussions_synced_for_updated_at`. After successful sync, it updates the watermark to prevent redundant refetches.\n\n## Approach\n\n### Module: src/ingestion/discussions.rs\n\n### Key Structs\n\n```rust\n#[derive(Debug, Default)]\npub struct IngestDiscussionsResult {\n    pub discussions_fetched: usize,\n    pub discussions_upserted: usize,\n    pub notes_upserted: usize,\n    pub system_notes_count: usize,\n}\n```\n\n### Main Function\n\n```rust\npub async fn ingest_issue_discussions(\n    conn: &Connection,\n    client: &GitLabClient,\n    config: &Config,\n    project_id: i64,           // Local DB project ID\n    gitlab_project_id: i64,    // GitLab project ID\n    issue_iid: i64,\n    local_issue_id: i64,\n    issue_updated_at: i64,     // For watermark update\n) -> Result<IngestDiscussionsResult>\n```\n\n### Logic\n\n1. Stream discussions via `client.paginate_issue_discussions()`\n2. For each discussion:\n   - Begin transaction\n   - Store raw payload (compressed based on config)\n   - Transform to NormalizedDiscussion\n   - Upsert discussion\n   - Get local discussion ID\n   - Transform notes via `transform_notes()`\n   - For each note: store raw payload, upsert note\n   - Track system_notes_count\n   - Commit transaction\n3. After all discussions processed: `mark_discussions_synced(conn, local_issue_id, issue_updated_at)`\n\n### Helper Functions\n\n```rust\nfn upsert_discussion(conn, discussion, payload_id) -> Result<()>\nfn get_local_discussion_id(conn, project_id, gitlab_id) -> Result<i64>\nfn upsert_note(conn, discussion_id, note, payload_id) -> Result<()>\nfn mark_discussions_synced(conn, issue_id, issue_updated_at) -> Result<()>\n```\n\n### Critical Invariant\n\n`discussions_synced_for_updated_at` MUST be updated only AFTER all discussions are successfully synced. This watermark prevents redundant refetches on subsequent runs.\n\n## Acceptance Criteria\n\n- [ ] `ingest_issue_discussions` streams all discussions for an issue\n- [ ] Each discussion wrapped in transaction for atomicity\n- [ ] Raw payloads stored for discussions and notes\n- [ ] `discussions_synced_for_updated_at` updated after successful sync\n- [ ] System notes tracked in result.system_notes_count\n- [ ] Notes linked to correct discussion via local discussion ID\n\n## Files\n\n- src/ingestion/mod.rs (add `pub mod discussions;`)\n- src/ingestion/discussions.rs (create)\n\n## TDD Loop\n\nRED:\n```rust\n// tests/discussion_watermark_tests.rs\n#[tokio::test] async fn fetches_discussions_when_updated_at_advanced()\n#[tokio::test] async fn updates_watermark_after_successful_discussion_sync()\n#[tokio::test] async fn does_not_update_watermark_on_discussion_sync_failure()\n#[tokio::test] async fn stores_raw_payload_for_each_discussion()\n#[tokio::test] async fn stores_raw_payload_for_each_note()\n```\n\nGREEN: Implement ingest_issue_discussions with watermark logic\n\nVERIFY: `cargo test discussion_watermark`\n\n## Edge Cases\n\n- Issue with 0 discussions - mark synced anyway (empty is valid)\n- Discussion with 0 notes - should not happen per GitLab API (discussions always have >= 1 note)\n- Network failure mid-sync - watermark NOT updated, next run retries\n- individual_note=true discussions - have exactly 1 note","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-25T17:02:38.267582Z","created_by":"tayloreernisse","updated_at":"2026-01-25T22:52:47.500700Z","closed_at":"2026-01-25T22:52:47.500644Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-hbo","depends_on_id":"bd-1qf","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-hbo","depends_on_id":"bd-2iq","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-hbo","depends_on_id":"bd-xhz","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-hrs","title":"Create migration 007_documents.sql","description":"## Background\nMigration 007 creates the document storage layer that Gate A's entire search pipeline depends on. It introduces 5 tables: `documents` (the searchable unit), `document_labels` and `document_paths` (for filtered search), and two queue tables (`dirty_sources`, `pending_discussion_fetches`) that drive incremental document regeneration and discussion fetching in Gate C. This is the most-depended-on bead in the project (6 downstream beads block on it).\n\n## Approach\nCreate `migrations/007_documents.sql` with the exact SQL from PRD Section 1.1. The schema is fully specified in the PRD — no design decisions remain.\n\nKey implementation details:\n- `documents` table has `UNIQUE(source_type, source_id)` constraint for upsert support\n- `document_labels` and `document_paths` use `WITHOUT ROWID` for compact storage\n- `dirty_sources` uses composite PK `(source_type, source_id)` with `ON CONFLICT` upsert semantics\n- `pending_discussion_fetches` uses composite PK `(project_id, noteable_type, noteable_iid)`\n- Both queue tables have `next_attempt_at` indexed for efficient backoff queries\n- `labels_hash` and `paths_hash` on documents enable write optimization (skip unchanged labels/paths)\n\nRegister the migration in `src/core/db.rs` by adding entry 7 to the `MIGRATIONS` array.\n\n## Acceptance Criteria\n- [ ] `migrations/007_documents.sql` file exists with all 5 CREATE TABLE statements\n- [ ] Migration applies cleanly on fresh DB (`cargo test migration_tests`)\n- [ ] Migration applies cleanly after CP2 schema (migrations 001-006 already applied)\n- [ ] All foreign keys enforced: `documents.project_id -> projects(id)`, `document_labels.document_id -> documents(id) ON DELETE CASCADE`, `document_paths.document_id -> documents(id) ON DELETE CASCADE`, `pending_discussion_fetches.project_id -> projects(id)`\n- [ ] All indexes created: `idx_documents_project_updated`, `idx_documents_author`, `idx_documents_source`, `idx_documents_hash`, `idx_document_labels_label`, `idx_document_paths_path`, `idx_dirty_sources_next_attempt`, `idx_pending_discussions_next_attempt`\n- [ ] `labels_hash TEXT NOT NULL DEFAULT ''` and `paths_hash TEXT NOT NULL DEFAULT ''` columns present on `documents`\n- [ ] Schema version 7 recorded in `schema_version` table\n- [ ] `cargo build` succeeds after registering migration in db.rs\n\n## Files\n- `migrations/007_documents.sql` — new file (copy exact SQL from PRD Section 1.1)\n- `src/core/db.rs` — add migration 7 to `MIGRATIONS` array\n\n## TDD Loop\nRED: Add migration to db.rs, run `cargo test migration_tests` — fails because SQL file missing\nGREEN: Create `migrations/007_documents.sql` with full schema\nVERIFY: `cargo test migration_tests && cargo build`\n\n## Edge Cases\n- Migration must be idempotent-safe if applied twice (INSERT into schema_version will fail on second run — this is expected and handled by the migration runner's version check)\n- `WITHOUT ROWID` tables (document_labels, document_paths) require explicit PK — already defined\n- `CHECK` constraint on `documents.source_type` must match exactly: `'issue','merge_request','discussion'`\n- `CHECK` constraint on `documents.truncated_reason` allows NULL or one of 4 specific values","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:25:25.734380Z","created_by":"tayloreernisse","updated_at":"2026-01-30T16:54:12.854351Z","closed_at":"2026-01-30T16:54:12.854149Z","close_reason":"Completed: migration 007_documents.sql with 5 tables (documents, document_labels, document_paths, dirty_sources, pending_discussion_fetches), 8 indexes, registered in db.rs, cargo build + migration tests pass","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-hrs","depends_on_id":"bd-3lc","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-hs6j","title":"Implement run_generate_docs_for_sources scoped doc regeneration","description":"## Background\n\nCurrently `regenerate_dirty_documents()` in `src/documents/regenerator.rs` processes ALL entries in the `dirty_sources` table. The surgical sync pipeline needs a scoped variant that only regenerates documents for specific `(source_type, source_id)` pairs — the ones produced by the surgical ingest step.\n\nThe dirty_sources table schema: `(source_type TEXT, source_id INTEGER)` primary key, where source_type is one of `'issue'`, `'merge_request'`, `'discussion'`, `'note'`. After `ingest_issue_by_iid` or `ingest_mr_by_iid` calls `mark_dirty()`, these rows exist in dirty_sources with the matching keys.\n\nThe existing `regenerate_one(conn, source_type, source_id, cache)` private function does the actual work for a single source. The scoped function can call it directly for each provided key, without going through `get_dirty_sources()` which pulls from the full table.\n\nKey requirement: the function must return the `document_id` values of regenerated documents so the scoped embedding step (bd-1elx) can process only those documents.\n\n## Approach\n\nAdd `regenerate_dirty_documents_for_sources()` to `src/documents/regenerator.rs`:\n\n```rust\npub struct RegenerateForSourcesResult {\n    pub regenerated: usize,\n    pub unchanged: usize,\n    pub errored: usize,\n    pub document_ids: Vec<i64>,  // IDs of regenerated docs for scoped embedding\n}\n\npub fn regenerate_dirty_documents_for_sources(\n    conn: &Connection,\n    source_keys: &[(SourceType, i64)],\n) -> Result<RegenerateForSourcesResult>\n```\n\nImplementation:\n1. Create a `ParentMetadataCache` (same as bulk path).\n2. Iterate over provided `source_keys`.\n3. For each key, call `regenerate_one(conn, source_type, source_id, &mut cache)`.\n4. On success (changed=true): call `clear_dirty()`, query `documents` table for the document_id by `(source_type, source_id)`, push to `document_ids` vec.\n5. On success (changed=false): call `clear_dirty()`, still query for document_id (content unchanged but may need re-embedding if model changed).\n6. On error: call `record_dirty_error()`, increment errored count.\n\nAlso export from `src/documents/mod.rs`: `pub use regenerator::{RegenerateForSourcesResult, regenerate_dirty_documents_for_sources};`\n\n## Acceptance Criteria\n\n- [ ] `regenerate_dirty_documents_for_sources` only processes the provided source_keys, not all dirty_sources\n- [ ] Returns `document_ids` for all successfully processed documents (both regenerated and unchanged)\n- [ ] Clears dirty_sources entries for successfully processed sources\n- [ ] Records errors for failed sources without aborting the batch\n- [ ] Exported from `src/documents/mod.rs`\n- [ ] Existing `regenerate_dirty_documents` is unchanged (no regression)\n\n## Files\n\n- `src/documents/regenerator.rs` (add new function + result struct)\n- `src/documents/mod.rs` (export new function + struct)\n\n## TDD Anchor\n\nTests in `src/documents/regenerator_tests.rs` (add to existing test file):\n\n```rust\n#[test]\nfn test_scoped_regen_only_processes_specified_sources() {\n    let conn = setup_test_db();\n    // Insert 2 issues with dirty markers\n    insert_test_issue(&conn, 1, \"Issue A\");\n    insert_test_issue(&conn, 2, \"Issue B\");\n    mark_dirty(&conn, SourceType::Issue, 1).unwrap();\n    mark_dirty(&conn, SourceType::Issue, 2).unwrap();\n\n    // Regenerate only issue 1\n    let result = regenerate_dirty_documents_for_sources(\n        &conn,\n        &[(SourceType::Issue, 1)],\n    ).unwrap();\n\n    assert!(result.regenerated >= 1 || result.unchanged >= 1);\n    // Issue 1 dirty cleared, issue 2 still dirty\n    let remaining = get_dirty_sources(&conn).unwrap();\n    assert_eq!(remaining.len(), 1);\n    assert_eq!(remaining[0], (SourceType::Issue, 2));\n}\n\n#[test]\nfn test_scoped_regen_returns_document_ids() {\n    let conn = setup_test_db();\n    insert_test_issue(&conn, 1, \"Issue A\");\n    mark_dirty(&conn, SourceType::Issue, 1).unwrap();\n\n    let result = regenerate_dirty_documents_for_sources(\n        &conn,\n        &[(SourceType::Issue, 1)],\n    ).unwrap();\n\n    assert!(!result.document_ids.is_empty());\n    // Verify document_id exists in documents table\n    let exists: bool = conn.query_row(\n        \"SELECT EXISTS(SELECT 1 FROM documents WHERE id = ?1)\",\n        [result.document_ids[0]], |r| r.get(0),\n    ).unwrap();\n    assert!(exists);\n}\n\n#[test]\nfn test_scoped_regen_handles_missing_source() {\n    let conn = setup_test_db();\n    // Source key not in dirty_sources, regenerate_one will fail or return None\n    let result = regenerate_dirty_documents_for_sources(\n        &conn,\n        &[(SourceType::Issue, 9999)],\n    ).unwrap();\n    // Should handle gracefully: either errored=1 or unchanged with no doc_id\n    assert_eq!(result.document_ids.len(), 0);\n}\n```\n\n## Edge Cases\n\n- Source key exists in dirty_sources but the underlying entity was deleted: `regenerate_one` returns `None` from the extractor, calls `delete_document`, returns `Ok(true)`. No document_id to return.\n- Source key not in dirty_sources at all (already cleared by concurrent process): `regenerate_one` still works (it reads from the entity tables, not dirty_sources). But `clear_dirty` is a no-op DELETE.\n- Same source_key appears twice in the input slice: second call is idempotent (dirty already cleared, doc already up to date).\n- `unchanged` documents: content_hash matches, but we still need the document_id for embedding (model version may have changed). Include in `document_ids`.\n- Error in one source must not abort processing of remaining sources.\n\n## Dependency Context\n\n- **No blockers**: Uses only existing functions (`regenerate_one`, `clear_dirty`, `record_dirty_error`) which are all private to the regenerator module. New function lives in same module.\n- **Blocks bd-1i4i**: Orchestration function calls this after surgical ingest to get document_ids for scoped embedding.\n- **Feeds bd-1elx**: `document_ids` output is the input for `run_embed_for_document_ids`.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-17T19:16:14.014030Z","created_by":"tayloreernisse","updated_at":"2026-02-18T21:03:44.734135Z","closed_at":"2026-02-18T21:03:44.733986Z","close_reason":"Completed: regenerate_dirty_documents_for_sources with scoped dirty tracking, 3 tests","compaction_level":0,"original_size":0,"labels":["surgical-sync"],"dependencies":[{"issue_id":"bd-hs6j","depends_on_id":"bd-1i4i","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-hu3","title":"Write migration 011: resource event tables, entity_references, and dependent fetch queue","description":"## Background\nPhase B needs three new event tables and a generic dependent fetch queue to power temporal queries (timeline, file-history, trace). These tables store structured event data from GitLab Resource Events APIs, replacing fragile system note parsing for state/label/milestone changes.\n\nMigration 010_chunk_config.sql already exists, so Phase B starts at migration 011.\n\n## Approach\nCreate migrations/011_resource_events.sql with the exact schema from the Phase B spec (§1.2 + §2.2):\n\n**Event tables:**\n- resource_state_events: state changes (opened/closed/reopened/merged/locked) with source_merge_request_id for \"closed by MR\" linking\n- resource_label_events: label add/remove with label_name\n- resource_milestone_events: milestone add/remove with milestone_title + milestone_id\n\n**Cross-reference table (Gate 2):**\n- entity_references: source/target entity pairs with reference_type (closes/mentioned/related), source_method provenance, and unresolved reference support (target_entity_id NULL with target_project_path + target_entity_iid)\n\n**Dependent fetch queue:**\n- pending_dependent_fetches: generic job queue with job_type IN ('resource_events', 'mr_closes_issues', 'mr_diffs'), locked_at crash recovery, exponential backoff via attempts + next_retry_at\n\n**All tables must have:**\n- CHECK constraints for entity exclusivity (issue XOR merge_request) on event tables\n- UNIQUE constraints (gitlab_id + project_id for events, composite for queue, multi-column for references)\n- Partial indexes (WHERE issue_id IS NOT NULL, WHERE target_entity_id IS NULL, etc.)\n- CASCADE deletes on project_id and entity FKs\n\nRegister in src/core/db.rs MIGRATIONS array:\n```rust\n(\"011\", include_str!(\"../../migrations/011_resource_events.sql\")),\n```\n\nEnd migration with:\n```sql\nINSERT INTO schema_version (version, applied_at, description)\nVALUES (11, strftime('%s', 'now') * 1000, 'Resource events, entity references, and dependent fetch queue');\n```\n\n## Acceptance Criteria\n- [ ] migrations/011_resource_events.sql exists with all 4 tables + indexes + constraints\n- [ ] src/core/db.rs MIGRATIONS array includes (\"011\", include_str!(...))\n- [ ] `cargo build` succeeds (migration SQL compiles into binary)\n- [ ] `cargo test migration` passes (migration applies cleanly on fresh DB)\n- [ ] All CHECK constraints enforced (issue XOR merge_request on event tables)\n- [ ] All UNIQUE constraints present (prevents duplicate events/refs/jobs)\n- [ ] entity_references UNIQUE handles NULL coalescing correctly\n- [ ] pending_dependent_fetches job_type CHECK includes all three types\n\n## Files\n- migrations/011_resource_events.sql (new)\n- src/core/db.rs (add to MIGRATIONS array, line ~46)\n\n## TDD Loop\nRED: Add test in tests/migration_tests.rs:\n- `test_migration_011_creates_event_tables` - verify all 4 tables exist after migration\n- `test_migration_011_entity_exclusivity_constraint` - verify CHECK rejects both NULL and both non-NULL for issue_id/merge_request_id\n- `test_migration_011_event_dedup` - verify UNIQUE(gitlab_id, project_id) rejects duplicate events\n- `test_migration_011_entity_references_dedup` - verify UNIQUE constraint with NULL coalescing\n- `test_migration_011_queue_dedup` - verify UNIQUE(project_id, entity_type, entity_iid, job_type)\n\nGREEN: Write the migration SQL + register in db.rs\n\nVERIFY: `cargo test migration_tests -- --nocapture`\n\n## Edge Cases\n- entity_references UNIQUE uses COALESCE for NULLable columns — test with both resolved and unresolved refs\n- pending_dependent_fetches job_type CHECK — ensure 'mr_diffs' is included (Gate 4 needs it)\n- SQLite doesn't enforce CHECK on INSERT OR REPLACE — verify constraint behavior\n- The entity exclusivity CHECK must allow exactly one of issue_id/merge_request_id to be non-NULL\n- Verify CASCADE deletes work (delete project → all events/refs/jobs deleted)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:31:23.933894Z","created_by":"tayloreernisse","updated_at":"2026-02-03T16:06:28.918228Z","closed_at":"2026-02-03T16:06:28.917906Z","close_reason":"Already completed in prior session, re-closing after accidental reopen","compaction_level":0,"original_size":0,"labels":["gate-1","phase-b","schema"],"dependencies":[{"issue_id":"bd-hu3","depends_on_id":"bd-2zl","type":"parent-child","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-iba","title":"Add GitLab client MR pagination methods","description":"## Background\nGitLab client pagination for merge requests and discussions. Must support robust pagination with fallback chain because some GitLab instances/proxies strip headers.\n\n## Approach\nAdd to existing `src/gitlab/client.rs`:\n1. `MergeRequestPage` struct - Items + pagination metadata\n2. `parse_link_header_next()` - RFC 8288 Link header parsing\n3. `fetch_merge_requests_page()` - Single page fetch with metadata\n4. `paginate_merge_requests()` - Async stream for all MRs\n5. `paginate_mr_discussions()` - Async stream for MR discussions\n\n## Files\n- `src/gitlab/client.rs` - Add pagination methods\n\n## Acceptance Criteria\n- [ ] `MergeRequestPage` struct exists with `items`, `next_page`, `is_last_page`\n- [ ] `parse_link_header_next()` extracts `rel=\"next\"` URL from Link header\n- [ ] Pagination fallback chain: Link header > x-next-page > full-page heuristic\n- [ ] `paginate_merge_requests()` returns `Pin<Box<dyn Stream<Item = Result<GitLabMergeRequest>>>>`\n- [ ] `paginate_mr_discussions()` returns `Pin<Box<dyn Stream<Item = Result<GitLabDiscussion>>>>`\n- [ ] MR endpoint uses `scope=all&state=all` to include all MRs\n- [ ] `cargo test client` passes\n\n## TDD Loop\nRED: `cargo test fetch_merge_requests` -> method not found\nGREEN: Add pagination methods\nVERIFY: `cargo test client`\n\n## Struct Definitions\n```rust\n#[derive(Debug)]\npub struct MergeRequestPage {\n    pub items: Vec<GitLabMergeRequest>,\n    pub next_page: Option<u32>,\n    pub is_last_page: bool,\n}\n```\n\n## Link Header Parsing (RFC 8288)\n```rust\n/// Parse Link header to extract rel=\"next\" URL.\nfn parse_link_header_next(headers: &reqwest::header::HeaderMap) -> Option<String> {\n    headers\n        .get(\"link\")\n        .and_then(|v| v.to_str().ok())\n        .and_then(|link_str| {\n            // Format: <url>; rel=\"next\", <url>; rel=\"last\"\n            for part in link_str.split(',') {\n                let part = part.trim();\n                if part.contains(\"rel=\\\"next\\\"\") || part.contains(\"rel=next\") {\n                    if let Some(start) = part.find('<') {\n                        if let Some(end) = part.find('>') {\n                            return Some(part[start + 1..end].to_string());\n                        }\n                    }\n                }\n            }\n            None\n        })\n}\n```\n\n## Pagination Fallback Chain\n```rust\nlet next_page = match (link_next, x_next_page, items.len() as u32 == per_page) {\n    (Some(_), _, _) => Some(page + 1),     // Link header present: continue\n    (None, Some(np), _) => Some(np),       // x-next-page present: use it\n    (None, None, true) => Some(page + 1),  // Full page, no headers: try next\n    (None, None, false) => None,           // Partial page: we're done\n};\n```\n\n## Fetch Single Page\n```rust\npub async fn fetch_merge_requests_page(\n    &self,\n    gitlab_project_id: i64,\n    updated_after: Option<i64>,\n    cursor_rewind_seconds: u32,\n    page: u32,\n    per_page: u32,\n) -> Result<MergeRequestPage> {\n    let mut params = vec![\n        (\"scope\", \"all\".to_string()),\n        (\"state\", \"all\".to_string()),\n        (\"order_by\", \"updated_at\".to_string()),\n        (\"sort\", \"asc\".to_string()),\n        (\"per_page\", per_page.to_string()),\n        (\"page\", page.to_string()),\n    ];\n    // Apply cursor rewind for safety\n    // ...\n}\n```\n\n## Async Stream Pattern\n```rust\npub fn paginate_merge_requests(\n    &self,\n    gitlab_project_id: i64,\n    updated_after: Option<i64>,\n    cursor_rewind_seconds: u32,\n) -> Pin<Box<dyn Stream<Item = Result<GitLabMergeRequest>> + Send + '_>> {\n    Box::pin(async_stream::try_stream! {\n        let mut page = 1u32;\n        let per_page = 100u32;\n        loop {\n            let page_result = self.fetch_merge_requests_page(...).await?;\n            for mr in page_result.items {\n                yield mr;\n            }\n            if page_result.is_last_page {\n                break;\n            }\n            match page_result.next_page {\n                Some(np) => page = np,\n                None => break,\n            }\n        }\n    })\n}\n```\n\n## Edge Cases\n- `scope=all` required to include all MRs (not just authored by current user)\n- `state=all` required to include merged/closed (GitLab defaults may exclude)\n- `locked` state cannot be filtered server-side (use local SQL filtering)\n- Cursor rewind should clamp to 0 to avoid negative timestamps","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-26T22:06:41.633065Z","created_by":"tayloreernisse","updated_at":"2026-01-27T00:13:05.613625Z","closed_at":"2026-01-27T00:13:05.613440Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-iba","depends_on_id":"bd-5ta","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-ike","title":"Epic: Gate 3 - Decision Timeline (lore timeline)","description":"## Background\n\nGate 3 is the first user-facing temporal feature: `lore timeline <query>`. It answers \"What happened with X?\" by finding matching entities via FTS5, expanding cross-references, collecting all temporal events, and rendering a chronological narrative.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Gate 3 (Sections 3.1-3.6).\n\n## Prerequisites (All Complete)\n\n- Gates 1-2 COMPLETE: resource_state_events, resource_label_events, resource_milestone_events, entity_references all populated\n- FTS5 search index (CP3): working search infrastructure for keyword matching\n- Migration 015 (commit SHAs, closes watermark) exists on disk (registered by bd-1oo)\n\n## Architecture — 5-Stage Pipeline\n\n```\n1. SEED:    FTS5 keyword search -> matched document IDs (issues, MRs, notes)\n2. HYDRATE: Map document IDs -> source entities + top matched notes as evidence\n3. EXPAND:  BFS over entity_references (depth-limited, edge-type filtered)\n4. COLLECT: Gather events from all tables for seed + expanded entities\n5. RENDER:  Sort chronologically, format as human or robot output\n```\n\nNo new tables required. All reads are from existing tables at query time.\n\n## Children (Execution Order)\n\n1. **bd-20e** — Define TimelineEvent model and TimelineEventType enum (types first)\n2. **bd-32q** — Implement timeline seed phase: FTS5 keyword search to entity IDs\n3. **bd-ypa** — Implement timeline expand phase: BFS cross-reference expansion\n4. **bd-3as** — Implement timeline event collection and chronological interleaving\n5. **bd-1nf** — Register lore timeline command with all flags (CLI wiring)\n6. **bd-2f2** — Implement timeline human output renderer\n7. **bd-dty** — Implement timeline robot mode JSON output\n\n## Gate Completion Criteria\n\n- [ ] `lore timeline <query>` returns chronologically ordered events\n- [ ] Seed entities found via FTS5 keyword search (issues, MRs, and notes)\n- [ ] State, label, and milestone events interleaved from resource event tables\n- [ ] Entity creation and merge events included\n- [ ] Evidence-bearing notes included as note_evidence events (top FTS5 matches, bounded default 10)\n- [ ] Cross-reference expansion follows entity_references to configurable depth\n- [ ] Default: follows closes + related edges; --expand-mentions adds mentioned\n- [ ] --depth 0 disables expansion\n- [ ] --since filters by event timestamp\n- [ ] -p scopes to project\n- [ ] Human output is colored and readable\n- [ ] Robot mode returns structured JSON with expansion provenance\n- [ ] Unresolved (external) references included in JSON output\n","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-02-02T21:31:01.036474Z","created_by":"tayloreernisse","updated_at":"2026-02-06T13:49:21.285350Z","closed_at":"2026-02-06T13:49:21.285302Z","close_reason":"Gate 3 complete: all 7 children closed. Timeline pipeline fully implemented with SEED->HYDRATE->EXPAND->COLLECT->RENDER stages, human+robot renderers, CLI wiring with 9 flags, robot-docs manifest entry","compaction_level":0,"original_size":0,"labels":["epic","gate-3","phase-b"],"dependencies":[{"issue_id":"bd-ike","depends_on_id":"bd-1se","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-ike","depends_on_id":"bd-2zl","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-jbfw","title":"NOTE-0D: Capture immutable author_id in note upserts","description":"## Background\nCore use case is year-scale reviewer profiling. GitLab usernames are mutable — a user can change username mid-year, fragmenting author queries. GitLab note payloads include note.author.id (immutable integer). Capturing this provides a stable identity anchor for longitudinal analysis.\n\n## Approach\n1. The author_id column and index are added by migration 022 (NOTE-1E, bd-296a). This bead only handles the ingestion code changes.\n\n2. Populate author_id during upsert: In both upsert_note_for_issue() (from NOTE-0A) and upsert_note() in mr_discussions.rs, add author_id to INSERT column list and ON CONFLICT DO UPDATE SET clause. The value comes from the GitLab API note.author.id field.\n\n3. Check NormalizedNote in src/gitlab/transformers.rs — if it doesn't have an author_id field yet, add it there. The GitLab REST API returns notes with: { \"author\": { \"id\": 12345, \"username\": \"jdefting\", ... } }. Extract author.id in the transformer.\n\n4. Semantic change detection: author_id changes do NOT trigger changed_semantics = true. It's an identity anchor, not content. Do not include author_id in the pre-read comparison fields.\n\n## Files\n- MODIFY: src/ingestion/discussions.rs (add author_id to upsert_note_for_issue INSERT and ON CONFLICT SET)\n- MODIFY: src/ingestion/mr_discussions.rs (add author_id to upsert_note INSERT and ON CONFLICT SET, line 470)\n- MODIFY: src/gitlab/transformers.rs (add author_id: Option<i64> to NormalizedNote if missing, extract from API note.author.id)\n\n## TDD Anchor\nRED: test_issue_note_upsert_captures_author_id — insert note with author_id=12345, assert stored correctly.\nGREEN: Add author_id to INSERT/UPDATE clauses and transformer.\nVERIFY: cargo test captures_author_id -- --nocapture\nTests: test_mr_note_upsert_captures_author_id, test_note_upsert_author_id_nullable (old API responses without author.id), test_note_author_id_survives_username_change\n\n## Acceptance Criteria\n- [ ] author_id populated in upsert_note_for_issue INSERT and ON CONFLICT SET\n- [ ] author_id populated in MR upsert_note INSERT and ON CONFLICT SET\n- [ ] NormalizedNote has author_id: Option<i64> field\n- [ ] Transformer extracts author.id from GitLab API note payload\n- [ ] author_id = None handled gracefully (older API responses)\n- [ ] author_id change does NOT trigger changed_semantics\n- [ ] All 4 tests pass\n\n## Dependency Context\n- Depends on NOTE-0A (bd-3bpk): modifies upsert functions created in NOTE-0A\n- Depends on NOTE-1E (bd-296a): migration 022 adds the author_id column + index to the notes table. Column must exist before ingestion code can write to it.\n\n## Edge Cases\n- Old GitLab instances: author.id may be missing from API response — use None\n- Self-hosted GitLab: some versions may not include author block — handle gracefully\n- author_id is nullable INTEGER — no NOT NULL constraint","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T16:59:55.097158Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:13:15.254328Z","closed_at":"2026-02-12T18:13:15.254247Z","close_reason":"Implemented by agent swarm","compaction_level":0,"original_size":0,"labels":["per-note","search"]}
{"id":"bd-jec","title":"Add fetchMrFileChanges config flag","description":"## Background\n\nConfig flag controlling whether MR diff fetching is enabled, following the fetchResourceEvents pattern.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 4.2.\n\n## Codebase Context\n\n- src/core/config.rs has SyncConfig with fetch_resource_events: bool (serde rename 'fetchResourceEvents', default true)\n- Default impl exists for SyncConfig\n- CLI sync options in src/cli/mod.rs have --no-events flag pattern\n- Orchestrator checks config.sync.fetch_resource_events before enqueuing resource_events jobs\n\n## Approach\n\n### 1. Add to SyncConfig (`src/core/config.rs`):\n```rust\n#[serde(rename = \"fetchMrFileChanges\", default = \"default_true\")]\npub fetch_mr_file_changes: bool,\n```\n\nUpdate Default impl to include fetch_mr_file_changes: true.\n\n### 2. CLI override (`src/cli/mod.rs`):\n```rust\n#[arg(long = \"no-file-changes\")]\npub no_file_changes: bool,\n```\n\n### 3. Apply in main.rs:\n```rust\nif args.no_file_changes { config.sync.fetch_mr_file_changes = false; }\n```\n\n### 4. Guard in orchestrator:\n```rust\nif config.sync.fetch_mr_file_changes { enqueue mr_diffs jobs }\n```\n\n## Acceptance Criteria\n\n- [ ] fetchMrFileChanges in SyncConfig, default true\n- [ ] Config without field defaults to true\n- [ ] --no-file-changes disables diff fetching\n- [ ] Orchestrator skips mr_diffs when false\n- [ ] `cargo check --all-targets` passes\n\n## Files\n\n- `src/core/config.rs` (add field + Default)\n- `src/cli/mod.rs` (add --no-file-changes)\n- `src/main.rs` (apply override)\n- `src/ingestion/orchestrator.rs` (guard enqueue)\n\n## TDD Loop\n\nRED:\n- `test_config_default_fetch_mr_file_changes` - default is true\n- `test_config_deserialize_false` - JSON with false\n\nGREEN: Add field, default, serde attribute.\n\nVERIFY: `cargo test --lib -- config`\n\n## Edge Cases\n\n- Config missing fetchMrFileChanges key entirely: serde default_true fills in true\n- Config explicitly set to false: no mr_diffs jobs enqueued, mr_file_changes table empty\n- --no-file-changes with --full sync: overrides config, no diffs fetched even on full resync\n- sync.fetchMrFileChanges = false in config + no --no-file-changes flag: respects config (no override)","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-02T21:34:08.892666Z","created_by":"tayloreernisse","updated_at":"2026-02-08T18:18:36.409511Z","closed_at":"2026-02-08T18:18:36.409467Z","close_reason":"Added fetch_mr_file_changes to SyncConfig (default true, serde rename fetchMrFileChanges), --no-file-changes CLI flag in SyncArgs, override in main.rs. Orchestrator guard deferred to bd-2yo which implements the actual drain.","compaction_level":0,"original_size":0,"labels":["config","gate-4","phase-b"],"dependencies":[{"issue_id":"bd-jec","depends_on_id":"bd-14q","type":"parent-child","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-joja","title":"Implement open issues query for me command","description":"## Background\nThe first dashboard section shows issues assigned to the current user in opened state. Issues are linked to users via `issue_assignees(issue_id, username)` junction table. Labels are in `issue_labels(issue_id, label_id)` joined to `labels(id, name, ...)` — NOT a column on issues.\n\n## Approach\nCreate `src/cli/commands/me/issues.rs`:\n```rust\nuse rusqlite::Connection;\nuse crate::core::error::Result;\nuse super::types::{MeIssue, ProjectScope, AttentionState};\n\npub fn fetch_my_issues(\n    conn: &Connection,\n    username: &str,\n    scope: &ProjectScope,\n) -> Result<Vec<MeIssue>> {\n    let project_filter = match scope {\n        ProjectScope::Single(id) => format!(\"AND p.id = {id}\"),\n        ProjectScope::All => String::new(),\n    };\n    let sql = format!(r#\"\n        SELECT p.path_with_namespace, i.iid, i.title, i.state,\n               i.status_name, i.updated_at, i.web_url,\n               (SELECT GROUP_CONCAT(l.name, ',')\n                FROM issue_labels il\n                JOIN labels l ON l.id = il.label_id\n                WHERE il.issue_id = i.id) AS label_names\n        FROM issues i\n        JOIN issue_assignees ia ON ia.issue_id = i.id\n        JOIN projects p ON p.id = i.project_id\n        WHERE ia.username = ?1\n          AND i.state = 'opened'\n          {project_filter}\n        ORDER BY i.updated_at DESC\n    \"#);\n    let mut stmt = conn.prepare(&sql)?;\n    let rows = stmt.query_map([username], |row| {\n        let label_str: Option<String> = row.get(7)?;\n        Ok(MeIssue {\n            project_path: row.get(0)?,\n            iid: row.get(1)?,\n            title: row.get(2)?,\n            state: row.get(3)?,\n            status_name: row.get(4)?,\n            attention_state: AttentionState::NotStarted, // placeholder — enriched later\n            labels: label_str\n                .map(|s| s.split(',').map(String::from).collect())\n                .unwrap_or_default(),\n            updated_at_iso: crate::core::time::ms_to_iso(row.get::<_, i64>(5)?),\n            web_url: row.get(6)?,\n        })\n    })?;\n    rows.collect::<std::result::Result<Vec<_>, _>>()\n        .map_err(Into::into)\n}\n```\n\nKey details:\n- Labels via GROUP_CONCAT subquery on `issue_labels` + `labels` tables (NOT a column on issues)\n- `updated_at` stored as epoch ms — convert to ISO with `crate::core::time::ms_to_iso()`\n- `attention_state` set to placeholder — enriched by separate attention bead (bd-1xuf)\n- ProjectScope::Single uses `p.id` (internal DB id from resolve_project)\n\n## Acceptance Criteria\n- [ ] Returns Vec<MeIssue> for issues where `issue_assignees.username` matches (AC-3.1)\n- [ ] Only returns `state = 'opened'` issues (AC-5.1)\n- [ ] Includes `project_path` from `projects.path_with_namespace` (AC-8.5)\n- [ ] Includes `status_name` (nullable) from issues table (AC-7.4)\n- [ ] Labels fetched via GROUP_CONCAT on `issue_labels` JOIN `labels`\n- [ ] Respects `ProjectScope::Single(id)` — filters by `p.id`\n- [ ] Respects `ProjectScope::All` — no project filter\n- [ ] `updated_at` converted to ISO string via `ms_to_iso()`\n- [ ] No limit, no truncation (AC-5.1)\n- [ ] Issues where user is author but NOT assignee do NOT appear (AC-3.1)\n\n## Files\n- CREATE: src/cli/commands/me/issues.rs\n- MODIFY: src/cli/commands/me/mod.rs (add `pub mod issues;`)\n\n## TDD Anchor\nRED: Write `test_fetch_my_issues_returns_assigned` using in-memory DB:\n1. `run_migrations(&conn)`\n2. Insert project (gitlab_project_id, path_with_namespace, web_url)\n3. Insert issue (project_id, iid=42, state='opened', title, updated_at, web_url, last_seen_at)\n4. Insert issue_assignee (issue_id, username=\"jdoe\")\n5. Call `fetch_my_issues(&conn, \"jdoe\", &ProjectScope::All)`\n6. Assert returns 1 issue with iid=42\n\nGREEN: Implement the SQL query.\nVERIFY: `cargo test fetch_my_issues`\n\nAdditional tests:\n- test_fetch_my_issues_excludes_closed (state='closed' → not returned)\n- test_fetch_my_issues_excludes_unassigned (no assignee row → not returned)\n- test_fetch_my_issues_filters_by_project (Single scope)\n- test_fetch_my_issues_includes_labels (insert label + issue_label, verify labels vec)\n\n## Edge Cases\n- Issues with zero labels → labels vec is empty (GROUP_CONCAT returns NULL → unwrap_or_default)\n- Multiple assignees on same issue → user is one of them → appears once (JOIN, not DISTINCT needed since PK is unique)\n- `status_name` may be NULL (not all issues have work item status enrichment)\n\n## Dependency Context\nUses `MeIssue` and `ProjectScope` from bd-3bwh and bd-a7ba.\nUses `ms_to_iso` from `src/core/time.rs`.\nIn-memory DB test pattern: `create_connection(Path::new(\":memory:\"))` + `run_migrations(&conn)` from `src/core/db.rs`.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-19T19:36:46.162335Z","created_by":"tayloreernisse","updated_at":"2026-02-20T16:09:13.050929Z","closed_at":"2026-02-20T16:09:13.050889Z","close_reason":"Implemented by lore-me agent swarm","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-joja","depends_on_id":"bd-3bwh","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-joja","depends_on_id":"bd-a7ba","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-jov","title":"[CP1] Discussion and note transformers","description":"Transform GitLab discussion/note payloads to normalized database schema.\n\n## Module\nsrc/gitlab/transformers/discussion.rs\n\n## Structs\n\n### NormalizedDiscussion\n- gitlab_discussion_id: String\n- project_id: i64\n- issue_id: i64\n- noteable_type: String (\"Issue\")\n- individual_note: bool\n- first_note_at, last_note_at: Option<i64>\n- last_seen_at: i64\n- resolvable, resolved: bool\n\n### NormalizedNote\n- gitlab_id: i64\n- project_id: i64\n- note_type: Option<String>\n- is_system: bool\n- author_username: String\n- body: String\n- created_at, updated_at, last_seen_at: i64\n- position: i32 (array index in notes[])\n- resolvable, resolved: bool\n- resolved_by: Option<String>\n- resolved_at: Option<i64>\n\n## Functions\n\n### transform_discussion(gitlab_discussion, local_project_id, local_issue_id) -> NormalizedDiscussion\n- Compute first_note_at/last_note_at from notes array min/max created_at\n- Compute resolvable (any note resolvable)\n- Compute resolved (resolvable AND all resolvable notes resolved)\n\n### transform_notes(gitlab_discussion, local_project_id) -> Vec<NormalizedNote>\n- Enumerate notes to get position (array index)\n- Set is_system from note.system\n- Convert timestamps to ms epoch\n\nFiles: src/gitlab/transformers/discussion.rs\nTests: tests/discussion_transformer_tests.rs\nDone when: Unit tests pass for discussion/note transformation with system note flagging","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:43:04.481361Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.759691Z","closed_at":"2026-01-25T17:02:01.759691Z","deleted_at":"2026-01-25T17:02:01.759684Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-k7b","title":"[CP1] gi show issue command","description":"Show issue details with discussions.\n\n## Module\nsrc/cli/commands/show.rs\n\n## Clap Definition\nShow {\n    #[arg(value_parser = [\"issue\", \"mr\"])]\n    entity: String,\n    \n    iid: i64,\n    \n    #[arg(long)]\n    project: Option<String>,\n}\n\n## Output Format\nIssue #1234: Authentication redesign\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nProject:  group/project-one\nState:    opened\nAuthor:   @johndoe\nCreated:  2024-01-15\nUpdated:  2024-03-20\nLabels:   enhancement, auth\nURL:      https://gitlab.example.com/group/project-one/-/issues/1234\n\nDescription:\n  We need to redesign the authentication flow to support...\n\nDiscussions (5):\n\n  @janedoe (2024-01-16):\n    I agree we should move to JWT-based auth...\n\n    @johndoe (2024-01-16):\n      What about refresh token strategy?\n\n  @bobsmith (2024-01-17):\n    Have we considered OAuth2?\n\n## Ambiguity Handling\nIf multiple projects have same iid, either:\n- Prompt for --project flag\n- Show error listing which projects have that iid\n\nFiles: src/cli/commands/show.rs\nDone when: Issue detail view displays all fields including threaded discussions","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T16:58:26.904813Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.944183Z","closed_at":"2026-01-25T17:02:01.944183Z","deleted_at":"2026-01-25T17:02:01.944179Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-kanh","title":"Extract orchestrator per-entity logic and implement inline dependent helpers","description":"## Background\n\nThe orchestrator's drain functions (`drain_resource_events` at line 932, `drain_mr_closes_issues` at line 1254, `drain_mr_diffs` at line 1514) are private and tightly coupled to the job queue system (`pending_dependent_fetches`, `claim_jobs`, `complete_job`). They batch-process all entities for a project, not individual ones. Surgical sync needs per-entity versions of these operations.\n\nThe underlying storage functions already exist and are usable:\n- `store_resource_events(conn, project_id, entity_type, entity_local_id, state_events, label_events, milestone_events)` (orchestrator.rs:1100) — calls `upsert_state_events`, `upsert_label_events`, `upsert_milestone_events`\n- `store_closes_issues_refs(conn, project_id, mr_local_id, closes_issues)` (orchestrator.rs:1409) — inserts entity references\n- `upsert_mr_file_changes(conn, project_id, mr_local_id, diffs)` (mr_diffs.rs:26) — already pub\n\nThe GitLabClient methods for fetching are also already pub:\n- `fetch_all_resource_events(gitlab_project_id, entity_type, iid)` -> (state, label, milestone) events\n- `fetch_mr_closes_issues(gitlab_project_id, iid)` -> Vec<GitLabIssueRef>\n- `fetch_mr_diffs(gitlab_project_id, iid)` -> Vec<GitLabMrDiff>\n\nThe gap: no standalone per-entity functions that fetch + store for a single entity without the job queue machinery.\n\n## Approach\n\nCreate standalone helper functions in `src/ingestion/surgical.rs` (or a new `src/ingestion/surgical_dependents.rs` sub-module) that surgical.rs calls after ingesting each entity:\n\n1. **`fetch_and_store_resource_events_for_entity`** (async): Takes `client`, `conn`, `project_id`, `gitlab_project_id`, `entity_type` (\"issue\"|\"merge_request\"), `entity_iid`, `entity_local_id`. Calls `client.fetch_all_resource_events()`, then `store_resource_events()` (needs `pub(crate)` visibility, currently private in orchestrator.rs). Updates the watermark column (`resource_events_synced_for_updated_at`).\n\n2. **`fetch_and_store_discussions_for_entity`** (async): For issues, calls existing `ingest_issue_discussions()`. For MRs, calls `ingest_mr_discussions()`. Both are already pub. This is a thin routing wrapper.\n\n3. **`fetch_and_store_closes_issues_for_entity`** (async, MR-only): Calls `client.fetch_mr_closes_issues()`, then `store_closes_issues_refs()` (needs `pub(crate)`). Updates watermark.\n\n4. **`fetch_and_store_file_changes_for_entity`** (async, MR-only): Calls `client.fetch_mr_diffs()`, then `upsert_mr_file_changes()` (already pub). Updates watermark.\n\nVisibility changes needed in orchestrator.rs (part of bd-1sc6):\n- `store_resource_events` -> `pub(crate)`\n- `store_closes_issues_refs` -> `pub(crate)`\n- `update_resource_event_watermark_tx` -> `pub(crate)` (or inline the SQL)\n- `update_closes_issues_watermark_tx` -> `pub(crate)` (or inline)\n\n## Acceptance Criteria\n\n- [ ] `fetch_and_store_resource_events_for_entity` fetches all 3 event types and stores them in one transaction\n- [ ] `fetch_and_store_discussions_for_entity` routes to correct discussion ingest function by entity type\n- [ ] `fetch_and_store_closes_issues_for_entity` fetches and stores closes_issues refs for MRs\n- [ ] `fetch_and_store_file_changes_for_entity` fetches and stores MR diffs\n- [ ] Each helper updates the appropriate watermark column after successful store\n- [ ] Each helper returns a result struct with counts (fetched, stored, skipped)\n- [ ] All helpers are `pub(crate)` for use by the orchestration function (bd-1i4i)\n- [ ] Config-gated: resource events only fetched if `config.sync.fetch_resource_events == true`, file changes only if `config.sync.fetch_mr_file_changes == true`\n\n## Files\n\n- `src/ingestion/surgical.rs` (add helper functions, or create `surgical_dependents.rs` sub-module)\n- `src/ingestion/orchestrator.rs` (change `store_resource_events`, `store_closes_issues_refs`, watermark functions to `pub(crate)` — via bd-1sc6)\n\n## TDD Anchor\n\nTests in `src/ingestion/surgical_tests.rs` (bd-x8oq):\n\n```rust\n#[tokio::test]\nasync fn test_fetch_and_store_resource_events_for_issue() {\n    let conn = setup_db();\n    let mock = MockServer::start().await;\n    // Mock state/label/milestone event endpoints\n    Mock::given(method(\"GET\"))\n        .and(path_regex(r\"/api/v4/projects/\\d+/issues/\\d+/resource_state_events\"))\n        .respond_with(ResponseTemplate::new(200).set_body_json(json!([])))\n        .mount(&mock).await;\n    // ... similar for label and milestone\n    let client = make_test_client(&mock);\n    let result = fetch_and_store_resource_events_for_entity(\n        &client, &conn, /*project_id=*/1, /*gitlab_project_id=*/100,\n        \"issue\", /*iid=*/42, /*local_id=*/1,\n    ).await.unwrap();\n    assert_eq!(result.fetched, 0); // empty events\n    // Verify watermark updated\n    let watermark: Option<i64> = conn.query_row(\n        \"SELECT resource_events_synced_for_updated_at FROM issues WHERE id = 1\",\n        [], |r| r.get(0),\n    ).unwrap();\n    assert!(watermark.is_some());\n}\n\n#[tokio::test]\nasync fn test_fetch_and_store_closes_issues_for_mr() {\n    let conn = setup_db();\n    let mock = MockServer::start().await;\n    Mock::given(method(\"GET\"))\n        .and(path_regex(r\"/api/v4/projects/\\d+/merge_requests/\\d+/closes_issues\"))\n        .respond_with(ResponseTemplate::new(200).set_body_json(json!([\n            {\"iid\": 10, \"project_id\": 100}\n        ])))\n        .mount(&mock).await;\n    let client = make_test_client(&mock);\n    let result = fetch_and_store_closes_issues_for_entity(\n        &client, &conn, 1, 100, /*mr_iid=*/5, /*mr_local_id=*/1,\n    ).await.unwrap();\n    assert_eq!(result.stored, 1);\n}\n\n#[tokio::test]\nasync fn test_fetch_and_store_file_changes_for_mr() {\n    // Similar: mock /diffs endpoint, verify upsert_mr_file_changes called\n}\n\n#[tokio::test]\nasync fn test_resource_events_skipped_when_config_disabled() {\n    // config.sync.fetch_resource_events = false -> returns Ok with 0 counts\n}\n```\n\n## Edge Cases\n\n- `fetch_all_resource_events` returns 3 separate Results (state, label, milestone). If one fails (e.g., 403 on milestone events), the others should still be stored. Partial success handling.\n- `fetch_mr_closes_issues` on a deleted MR returns 404: `coalesce_not_found` already handles this in the client, returning empty vec.\n- Watermark update must happen AFTER successful store, not before, to avoid marking as synced when store failed.\n- Discussion ingest for MRs uses `prefetch_mr_discussions` (async) + `write_prefetched_mr_discussions` (sync) two-phase pattern. The helper must handle both phases.\n- If `config.sync.fetch_resource_events` is false, skip resource event fetch entirely (return empty result).\n- If `config.sync.fetch_mr_file_changes` is false, skip file changes fetch entirely.\n\n## Dependency Context\n\n- **Blocked by bd-3sez**: surgical.rs must exist before adding helpers to it\n- **Blocked by bd-1sc6 (indirectly via bd-3sez)**: `store_resource_events` and `store_closes_issues_refs` need `pub(crate)` visibility\n- **Blocks bd-1i4i**: Orchestration function calls these helpers after each entity ingest\n- **Blocks bd-3jqx**: Integration tests exercise the full surgical pipeline including these helpers\n- **Uses existing pub APIs**: `GitLabClient::fetch_all_resource_events`, `fetch_mr_closes_issues`, `fetch_mr_diffs`, `upsert_mr_file_changes`, `ingest_issue_discussions`, `ingest_mr_discussions`","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-17T19:15:42.863072Z","created_by":"tayloreernisse","updated_at":"2026-02-18T21:04:58.569185Z","closed_at":"2026-02-18T21:04:58.569141Z","close_reason":"Completed: all implementation work done, code reviewed, tests passing","compaction_level":0,"original_size":0,"labels":["surgical-sync"],"dependencies":[{"issue_id":"bd-kanh","depends_on_id":"bd-1i4i","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-kvij","title":"Rewrite agent skills to mandate lore for all reads","description":"## Background\nAgent skills and AGENTS.md files currently allow agents to choose between glab and lore for read operations. Agents default to glab (familiar from training data) even though lore returns richer data. Need a clean, enforced boundary: lore=reads, glab=writes.\n\n## Approach\n1. Audit all config files for glab read patterns\n2. Replace each with lore equivalent\n3. Add explicit Read/Write Split section to AGENTS.md and CLAUDE.md\n\n## Translation Table\n| glab (remove)                      | lore (replace with)              |\n|------------------------------------|----------------------------------|\n| glab issue view N                  | lore -J issues N                 |\n| glab issue list                    | lore -J issues -n 50             |\n| glab issue list -l bug             | lore -J issues --label bug       |\n| glab mr view N                     | lore -J mrs N                    |\n| glab mr list                       | lore -J mrs                      |\n| glab mr list -s opened             | lore -J mrs -s opened            |\n| glab api '/projects/:id/issues'    | lore -J issues -p project        |\n\n## Files to Audit\n\n### Project-level\n- /Users/tayloreernisse/projects/gitlore/AGENTS.md — primary project instructions\n\n### Global Claude config\n- ~/.claude/CLAUDE.md — global instructions (already has lore section, verify no glab reads)\n\n### Skills directory\nScan all .md files under ~/.claude/skills/ for glab read patterns.\nLikely candidates: any skill that references GitLab data retrieval.\n\n### Rules directory\nScan all .md files under ~/.claude/rules/ for glab read patterns.\n\n### Work-ghost templates\n- ~/projects/work-ghost/tasks/*.md — task templates that reference glab reads\n\n## Verification Commands\nAfter all changes:\n```bash\n# Should return ZERO matches (no glab read commands remain)\nrg 'glab issue view|glab issue list|glab mr view|glab mr list|glab api.*issues|glab api.*merge_requests' ~/.claude/ AGENTS.md --type md\n\n# These should REMAIN (write operations stay with glab)\nrg 'glab (issue|mr) (create|update|close|delete|approve|merge|note|rebase)' ~/.claude/ AGENTS.md --type md\n```\n\n## Read/Write Split Section to Add\nAdd to AGENTS.md and ~/.claude/CLAUDE.md:\n```markdown\n## Read/Write Split: lore vs glab\n\n| Operation | Tool | Why |\n|-----------|------|-----|\n| List issues/MRs | lore | Richer: includes status, discussions, closing MRs |\n| View issue/MR detail | lore | Pre-joined discussions, work-item status |\n| Search across entities | lore | FTS5 + vector hybrid search |\n| Expert/workload analysis | lore | who command — no glab equivalent |\n| Timeline reconstruction | lore | Chronological narrative — no glab equivalent |\n| Create/update/close | glab | Write operations |\n| Approve/merge MR | glab | Write operations |\n| CI/CD pipelines | glab | Not in lore scope |\n```\n\n## TDD Loop\nThis is a config-only task — no Rust code changes. Verification is via grep:\n\nRED: Run verification commands above, expect matches (glab reads still present)\nGREEN: Replace all glab read references with lore equivalents\nVERIFY: Run verification commands, expect zero glab read matches\n\n## Acceptance Criteria\n- [ ] Zero glab read references in AGENTS.md\n- [ ] Zero glab read references in ~/.claude/CLAUDE.md\n- [ ] Zero glab read references in ~/.claude/skills/**/*.md\n- [ ] Zero glab read references in ~/.claude/rules/**/*.md\n- [ ] glab write references preserved (create, update, close, approve, merge, CI)\n- [ ] Read/Write Split section added to AGENTS.md\n- [ ] Read/Write Split section added to ~/.claude/CLAUDE.md\n- [ ] Fresh agent session uses lore for reads without prompting (manual verification)\n\n## Edge Cases\n- Skills that use glab api for data NOT in lore (e.g., CI pipeline data, project settings) — these should remain\n- glab MCP server references — evaluate case-by-case (keep for write operations)\n- Shell aliases or env vars that invoke glab for reads — out of scope unless in config files\n- Skills that use `glab issue list | jq` for ad-hoc queries — replace with `lore -J issues | jq`\n- References to glab in documentation context (explaining what tools exist) vs operational context (telling agent to use glab) — only replace operational references","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-12T15:44:56.530081Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:49:04.598735Z","closed_at":"2026-02-12T16:49:04.598679Z","close_reason":"Agent skills rewritten: AGENTS.md and CLAUDE.md updated with read/write split mandating lore for reads, glab for writes","compaction_level":0,"original_size":0,"labels":["cli","cli-imp"],"dependencies":[{"issue_id":"bd-kvij","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-lcb","title":"Epic: CP2 Gate E - CLI Complete","description":"## Background\nGate E validates all CLI commands are functional and user-friendly. This is the final usability gate - even if all data is correct, users need good CLI UX to access it.\n\n## Acceptance Criteria (Pass/Fail)\n\n### List Command\n- [ ] `gi list mrs` shows MR table with columns: iid, title, state, author, branches, updated\n- [ ] `gi list mrs --state=opened` filters to only opened MRs\n- [ ] `gi list mrs --state=merged` filters to only merged MRs\n- [ ] `gi list mrs --state=closed` filters to only closed MRs\n- [ ] `gi list mrs --state=locked` filters locally (not server-side filter)\n- [ ] `gi list mrs --draft` shows only draft MRs\n- [ ] `gi list mrs --no-draft` excludes draft MRs\n- [ ] Draft MRs show `[DRAFT]` prefix in title column\n- [ ] `gi list mrs --author=username` filters by author\n- [ ] `gi list mrs --assignee=username` filters by assignee\n- [ ] `gi list mrs --reviewer=username` filters by reviewer\n- [ ] `gi list mrs --target-branch=main` filters by target branch\n- [ ] `gi list mrs --source-branch=feature/x` filters by source branch\n- [ ] `gi list mrs --label=bugfix` filters by label\n- [ ] `gi list mrs --limit=N` limits output\n\n### Show Command\n- [ ] `gi show mr <iid>` displays full MR detail\n- [ ] Show includes: title, description, state, draft status, author\n- [ ] Show includes: assignees, reviewers, labels\n- [ ] Show includes: source_branch, target_branch\n- [ ] Show includes: detailed_merge_status (e.g., \"mergeable\")\n- [ ] Show includes: merge_user and merged_at for merged MRs\n- [ ] Show includes: discussions with author and date\n- [ ] DiffNote shows file context: `[src/file.ts:45]`\n- [ ] Multi-line DiffNote shows range: `[src/file.ts:45-48]`\n- [ ] Resolved discussions show `[RESOLVED]` marker\n\n### Count Command\n- [ ] `gi count mrs` shows total count\n- [ ] Count shows state breakdown: opened, merged, closed\n\n### Sync Status\n- [ ] `gi sync-status` shows MR cursor position\n- [ ] Sync status shows last sync timestamp\n\n## Validation Script\n```bash\n#!/bin/bash\nset -e\n\nDB_PATH=\"${XDG_DATA_HOME:-$HOME/.local/share}/gitlab-inbox/db.sqlite3\"\n\necho \"=== Gate E: CLI Complete ===\"\n\n# 1. Test list command (basic)\necho \"Step 1: Basic list...\"\ngi list mrs --limit=5 || { echo \"FAIL: list mrs failed\"; exit 1; }\n\n# 2. Test state filters\necho \"Step 2: State filters...\"\nfor state in opened merged closed; do\n  echo \"  Testing --state=$state\"\n  gi list mrs --state=$state --limit=3 || echo \"  Warning: No $state MRs\"\ndone\n\n# 3. Test draft filters\necho \"Step 3: Draft filters...\"\ngi list mrs --draft --limit=3 || echo \"  Note: No draft MRs found\"\ngi list mrs --no-draft --limit=3 || echo \"  Note: All MRs are drafts?\"\n\n# 4. Check [DRAFT] prefix\necho \"Step 4: Check [DRAFT] prefix...\"\nDRAFT_IID=$(sqlite3 \"$DB_PATH\" \"SELECT iid FROM merge_requests WHERE draft = 1 LIMIT 1;\")\nif [ -n \"$DRAFT_IID\" ]; then\n  if gi list mrs --limit=100 | grep -q \"\\[DRAFT\\]\"; then\n    echo \"  PASS: [DRAFT] prefix found\"\n  else\n    echo \"  FAIL: Draft MR exists but no [DRAFT] prefix in output\"\n  fi\nelse\n  echo \"  Skip: No draft MRs to test\"\nfi\n\n# 5. Test author/assignee/reviewer filters\necho \"Step 5: User filters...\"\nAUTHOR=$(sqlite3 \"$DB_PATH\" \"SELECT author_username FROM merge_requests LIMIT 1;\")\nif [ -n \"$AUTHOR\" ]; then\n  echo \"  Testing --author=$AUTHOR\"\n  gi list mrs --author=\"$AUTHOR\" --limit=3\nfi\n\nREVIEWER=$(sqlite3 \"$DB_PATH\" \"SELECT username FROM mr_reviewers LIMIT 1;\")\nif [ -n \"$REVIEWER\" ]; then\n  echo \"  Testing --reviewer=$REVIEWER\"\n  gi list mrs --reviewer=\"$REVIEWER\" --limit=3\nfi\n\n# 6. Test branch filters\necho \"Step 6: Branch filters...\"\nTARGET=$(sqlite3 \"$DB_PATH\" \"SELECT target_branch FROM merge_requests LIMIT 1;\")\nif [ -n \"$TARGET\" ]; then\n  echo \"  Testing --target-branch=$TARGET\"\n  gi list mrs --target-branch=\"$TARGET\" --limit=3\nfi\n\n# 7. Test show command\necho \"Step 7: Show command...\"\nMR_IID=$(sqlite3 \"$DB_PATH\" \"SELECT iid FROM merge_requests LIMIT 1;\")\ngi show mr \"$MR_IID\" || { echo \"FAIL: show mr failed\"; exit 1; }\n\n# 8. Test show with DiffNote context\necho \"Step 8: Show with DiffNote...\"\nDIFFNOTE_MR=$(sqlite3 \"$DB_PATH\" \"\n  SELECT DISTINCT m.iid\n  FROM merge_requests m\n  JOIN discussions d ON d.merge_request_id = m.id\n  JOIN notes n ON n.discussion_id = d.id\n  WHERE n.position_new_path IS NOT NULL\n  LIMIT 1;\n\")\nif [ -n \"$DIFFNOTE_MR\" ]; then\n  echo \"  Testing MR with DiffNotes: !$DIFFNOTE_MR\"\n  OUTPUT=$(gi show mr \"$DIFFNOTE_MR\")\n  if echo \"$OUTPUT\" | grep -qE '\\[[^]]+:[0-9]+\\]'; then\n    echo \"  PASS: File context [path:line] found\"\n  else\n    echo \"  FAIL: DiffNote should show [path:line] context\"\n  fi\nelse\n  echo \"  Skip: No MRs with DiffNotes\"\nfi\n\n# 9. Test count command\necho \"Step 9: Count command...\"\ngi count mrs || { echo \"FAIL: count mrs failed\"; exit 1; }\n\n# 10. Test sync-status\necho \"Step 10: Sync status...\"\ngi sync-status || echo \"  Note: sync-status may need implementation\"\n\necho \"\"\necho \"=== Gate E: PASSED ===\"\n```\n\n## Test Commands (Quick Verification)\n```bash\n# List with all column types visible:\ngi list mrs --limit=10\n\n# Show a specific MR:\ngi show mr 42\n\n# Count with breakdown:\ngi count mrs\n\n# Complex filter:\ngi list mrs --state=opened --reviewer=alice --target-branch=main --limit=5\n```\n\n## Expected Output Formats\n\n### gi list mrs\n```\nMerge Requests (showing 5 of 1,234)\n\n  !847  Refactor auth to use JWT tokens           merged   @johndoe   main <- feature/jwt   3d ago\n  !846  Fix memory leak in websocket handler      opened   @janedoe   main <- fix/websocket 5d ago\n  !845  [DRAFT] Add dark mode CSS variables       opened   @bobsmith  main <- ui/dark-mode  1w ago\n  !844  Update dependencies to latest versions    closed   @alice     main <- chore/deps    2w ago\n```\n\n### gi show mr 847\n```\nMerge Request !847: Refactor auth to use JWT tokens\n================================================================================\n\nProject:        group/project-one\nState:          merged\nDraft:          No\nAuthor:         @johndoe\nAssignees:      @janedoe, @bobsmith\nReviewers:      @alice, @charlie\nLabels:         enhancement, auth, reviewed\nSource:         feature/jwt\nTarget:         main\nMerge Status:   merged\nMerged By:      @alice\nMerged At:      2024-03-20 14:30:00\n\nDescription:\n  Moving away from session cookies to JWT-based authentication...\n\nDiscussions (3):\n\n  @janedoe (2024-03-16) [src/auth/jwt.ts:45]:\n    Should we use a separate signing key for refresh tokens?\n\n    @johndoe (2024-03-16):\n      Good point. I'll add a separate key with rotation support.\n\n  @alice (2024-03-18) [RESOLVED]:\n    Looks good! Just one nit about the token expiry constant.\n```\n\n### gi count mrs\n```\nMerge Requests: 1,234\n  opened: 89\n  merged: 1,045\n  closed: 100\n```\n\n## Dependencies\nThis gate requires:\n- bd-3js (CLI commands implementation)\n- All previous gates must pass first\n\n## Edge Cases\n- Ambiguous MR iid across projects: should prompt for `--project` or show error\n- Very long titles: should truncate with `...` in list view\n- Empty description: should show \"No description\" or empty section\n- No discussions: should show \"No discussions\" message\n- Unicode in titles/descriptions: should render correctly","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-26T22:06:02.411132Z","created_by":"tayloreernisse","updated_at":"2026-01-27T00:48:21.061166Z","closed_at":"2026-01-27T00:48:21.061125Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-lcb","depends_on_id":"bd-3js","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-lhj8","title":"Phase 2a: Migrate gitlab/client.rs to HTTP adapter","description":"## What\nReplace all reqwest usage in gitlab/client.rs with the new crate::http adapter.\n\n## Why\nThis is the largest HTTP module (~80 LOC changed). It handles REST API calls, pagination, rate limiting, and response parsing.\n\n## Changes\n\n### Imports\n\\`\\`\\`rust\n// Remove\nuse reqwest::header::{ACCEPT, HeaderMap, HeaderValue};\nuse reqwest::{Client, Response, StatusCode};\n// Add\nuse crate::http::{Client, Response};\n\\`\\`\\`\n\n### Client construction (lines ~68-96)\n\\`\\`\\`rust\n// Before: reqwest::Client::builder().default_headers(h).timeout(d).build()\n// After:\nlet client = Client::with_timeout(Duration::from_secs(30));\n\\`\\`\\`\n\n### request() method (lines ~129-170)\n\\`\\`\\`rust\n// Before\nlet response = self.client.get(&url)\n    .header(\\\"PRIVATE-TOKEN\\\", &self.token)\n    .send().await?;\n// After\nlet response = self.client.get(&url, &[\n    (\\\"PRIVATE-TOKEN\\\", &self.token),\n    (\\\"Accept\\\", \\\"application/json\\\"),\n]).await?;\n\\`\\`\\`\n\n### request_with_headers() method (lines ~510-559)\n\\`\\`\\`rust\n// Before: self.client.get(&url).query(params).header(...)\n// After: self.client.get_with_query(&url, params, &[...])\n// headers already owned in response.headers — no .clone() needed\n\\`\\`\\`\n\n### handle_response() — becomes sync\n\\`\\`\\`rust\n// Before: async fn (consumed body with .text().await)\n// After: fn (body already buffered in Response)\nfn handle_response<T: DeserializeOwned>(&self, response: Response, path: &str) -> Result<T> {\n    match response.status {\n        401 => Err(LoreError::GitLabAuthFailed),\n        404 => Err(LoreError::GitLabNotFound { resource: path.into() }),\n        429 => {\n            let retry_after = response.header(\\\"retry-after\\\")\n                .and_then(|v| v.parse().ok())\n                .unwrap_or(60);\n            Err(LoreError::GitLabRateLimited { retry_after })\n        }\n        s if (200..300).contains(&s) => response.json::<T>(),\n        s => Err(LoreError::Other(format!(\\\"GitLab API error: {} {}\\\", s, response.reason))),\n    }\n}\n\\`\\`\\`\n\n### Pagination — minimal structural changes\nasync_stream::stream! and header parsing stay the same. Only response type changes:\n\\`\\`\\`rust\n// Before: headers.get(\\\"x-next-page\\\").and_then(|v| v.to_str().ok())\n// After:  response.header(\\\"x-next-page\\\")\n\\`\\`\\`\n\n### parse_link_header_next — signature change\nChange from (headers: &HeaderMap) to (headers: &[(String, String)]) and find by case-insensitive name. Use response.headers_all(\\\"link\\\") for multi-value Link headers.\n\n## WIREMOCK TEST COMPATIBILITY WARNING\nAfter this migration, the 4 client_tests.rs tests run on #[tokio::test] but call production code that now uses crate::http::Client (backed by asupersync::HttpClient). This may cause runtime failures if asupersync HTTP requires the asupersync runtime context.\n\n**Possible resolutions (decide during implementation):**\na) asupersync HTTP client is runtime-agnostic (works under tokio) — ideal, no changes needed\nb) Switch client_tests to #[asupersync::test] — may conflict with wiremock which needs tokio\nc) Add a test-only constructor that accepts an injected HTTP client trait — cleanest but most work\nd) Use reqwest in dev-deps as a test double behind the adapter interface\n\nVerify option (a) first during the Decision Gate. If it doesn't work, option (b) or (d) is the fallback. This is flagged as escape hatch trigger #4 in the epic.\n\n## Files Changed\n- src/gitlab/client.rs (~80 LOC changed)\n\n## Testing\n- All 4 client_tests.rs tests must pass (verify runtime compatibility per warning above)\n- cargo check --all-targets\n- cargo clippy --all-targets -- -D warnings","status":"closed","priority":1,"issue_type":"task","created_at":"2026-03-06T18:39:44.065450Z","created_by":"tayloreernisse","updated_at":"2026-03-06T21:11:12.570436Z","closed_at":"2026-03-06T21:11:12.569778Z","close_reason":"asupersync migration complete — 5 commits pushed to asupersync-migration branch","compaction_level":0,"original_size":0,"labels":["asupersync","phase-2"],"dependencies":[{"issue_id":"bd-lhj8","depends_on_id":"bd-1lj5","type":"parent-child","created_at":"2026-03-06T18:39:44.069082Z","created_by":"tayloreernisse"},{"issue_id":"bd-lhj8","depends_on_id":"bd-bqoc","type":"blocks","created_at":"2026-03-06T18:42:50.433154Z","created_by":"tayloreernisse"}]}
{"id":"bd-ljf","title":"Add embedding error variants to LoreError","description":"## Background\nGate B introduces Ollama-dependent operations that need distinct error variants for clear diagnostics. Each error has a unique exit code, a descriptive message, and an actionable suggestion. These errors must integrate with the existing LoreError enum pattern (renamed from GiError in bd-3lc).\n\n## Approach\nExtend `src/core/error.rs` with 4 new variants per PRD Section 4.3.\n\n**ErrorCode additions:**\n```rust\npub enum ErrorCode {\n    // ... existing (InternalError=1 through TransformError=13)\n    OllamaUnavailable,      // exit code 14\n    OllamaModelNotFound,    // exit code 15\n    EmbeddingFailed,        // exit code 16\n}\n```\n\n**LoreError additions:**\n```rust\n/// Ollama-specific connection failure. Use instead of Http for Ollama errors\n/// because it includes base_url for actionable error messages.\n#[error(\"Cannot connect to Ollama at {base_url}. Is it running?\")]\nOllamaUnavailable {\n    base_url: String,\n    #[source]\n    source: Option<reqwest::Error>,\n},\n\n#[error(\"Ollama model '{model}' not found. Run: ollama pull {model}\")]\nOllamaModelNotFound { model: String },\n\n#[error(\"Embedding failed for document {document_id}: {reason}\")]\nEmbeddingFailed { document_id: i64, reason: String },\n\n#[error(\"No embeddings found. Run: lore embed\")]\nEmbeddingsNotBuilt,\n```\n\n**code() mapping:**\n- OllamaUnavailable => ErrorCode::OllamaUnavailable\n- OllamaModelNotFound => ErrorCode::OllamaModelNotFound\n- EmbeddingFailed => ErrorCode::EmbeddingFailed\n- EmbeddingsNotBuilt => ErrorCode::EmbeddingFailed (shares exit code 16)\n\n**suggestion() mapping:**\n- OllamaUnavailable => \"Start Ollama: ollama serve\"\n- OllamaModelNotFound => \"Pull the model: ollama pull nomic-embed-text\"\n- EmbeddingFailed => \"Check Ollama logs or retry with 'lore embed --retry-failed'\"\n- EmbeddingsNotBuilt => \"Generate embeddings first: lore embed\"\n\n## Acceptance Criteria\n- [ ] All 4 error variants compile\n- [ ] Exit codes: OllamaUnavailable=14, OllamaModelNotFound=15, EmbeddingFailed=16\n- [ ] EmbeddingsNotBuilt shares exit code 16 (mapped to ErrorCode::EmbeddingFailed)\n- [ ] OllamaUnavailable has `base_url: String` and `source: Option<reqwest::Error>`\n- [ ] EmbeddingFailed has `document_id: i64` and `reason: String`\n- [ ] Each variant has actionable .suggestion() text per PRD\n- [ ] ErrorCode Display: OLLAMA_UNAVAILABLE, OLLAMA_MODEL_NOT_FOUND, EMBEDDING_FAILED\n- [ ] Robot mode JSON includes code + suggestion for each variant\n- [ ] `cargo build` succeeds\n\n## Files\n- `src/core/error.rs` — extend LoreError enum + ErrorCode enum + impl blocks\n\n## TDD Loop\nRED: Add variants, `cargo build` fails on missing match arms\nGREEN: Add match arms in code(), exit_code(), suggestion(), to_robot_error(), Display\nVERIFY: `cargo build && cargo test error`\n\n## Edge Cases\n- OllamaUnavailable with source=None: still valid (used when no HTTP error available)\n- EmbeddingFailed with document_id=0: used for batch-level failures (not per-doc)\n- EmbeddingsNotBuilt vs OllamaUnavailable: former means \"never ran embed\", latter means \"Ollama down right now\"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:26:33.994316Z","created_by":"tayloreernisse","updated_at":"2026-01-30T16:51:20.385574Z","closed_at":"2026-01-30T16:51:20.385369Z","close_reason":"Completed: Added 4 LoreError variants (OllamaUnavailable, OllamaModelNotFound, EmbeddingFailed, EmbeddingsNotBuilt) and 3 ErrorCode variants with exit codes 14-16. cargo build succeeds.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-ljf","depends_on_id":"bd-3lc","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-lsz","title":"Epic: Gate B - Hybrid MVP","description":"## Background\nGate B adds semantic search capabilities via Ollama embeddings and sqlite-vec vector storage. It builds on Gate A's document layer, adding the embedding pipeline, vector search, RRF-based hybrid ranking, and graceful degradation when Ollama is unavailable. Gate B is independently shippable on top of Gate A.\n\n## Gate B Deliverables\n1. Ollama-powered embedding pipeline with sqlite-vec storage\n2. Hybrid search (RRF-ranked vector + lexical) with rich filtering + graceful degradation\n\n## Bead Dependencies (execution order, after Gate A)\n1. **bd-mem** — Shared backoff utility (no deps)\n2. **bd-1y8** — Chunk ID encoding (no deps)\n3. **bd-3ez** — RRF ranking (no deps)\n4. **bd-ljf** — Embedding error variants (blocked by bd-3lc)\n5. **bd-2ac** — Migration 009 embeddings (blocked by bd-hrs)\n6. **bd-335** — Ollama API client (blocked by bd-ljf)\n7. **bd-am7** — Embedding pipeline (blocked by bd-335, bd-2ac, bd-1y8)\n8. **bd-bjo** — Vector search (blocked by bd-2ac, bd-1y8)\n9. **bd-2sx** — Embed CLI (blocked by bd-am7)\n10. **bd-3eu** — Hybrid search (blocked by bd-3ez, bd-bjo, bd-1k1, bd-3q2)\n\n## Acceptance Criteria\n- [ ] `lore embed` builds embeddings for all documents via Ollama\n- [ ] `lore embed --retry-failed` re-attempts failed embeddings\n- [ ] `lore search --mode=hybrid \"query\"` uses both FTS + vector\n- [ ] `lore search --mode=semantic \"query\"` uses vector only\n- [ ] Graceful degradation: Ollama down -> FTS fallback with warning\n- [ ] `lore search --explain` shows vector_rank, fts_rank, rrf_score\n- [ ] sqlite-vec loaded before migration 009","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-30T15:25:13.462602Z","created_by":"tayloreernisse","updated_at":"2026-01-30T18:02:57.669194Z","closed_at":"2026-01-30T18:02:57.669142Z","close_reason":"All Gate B sub-beads complete: backoff, chunk IDs, RRF, error variants, migration 009, Ollama client, embedding pipeline, vector search, embed CLI, hybrid search","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-lsz","depends_on_id":"bd-2sx","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-lsz","depends_on_id":"bd-3eu","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-m7k1","title":"WHO: Active mode query (query_active)","description":"## Background\n\nActive mode answers \"What discussions are actively in progress?\" by finding unresolved resolvable discussions with recent activity. This is the most complex query due to the CTE structure and the dual SQL variant requirement.\n\n## Approach\n\n### Two static SQL variants (CRITICAL — not nullable-OR):\nActive mode uses separate global vs project-scoped SQL strings because:\n- With (?N IS NULL OR d.project_id = ?N), SQLite can't commit to either index at prepare time\n- Global queries need idx_discussions_unresolved_recent_global (single-column last_note_at)\n- Scoped queries need idx_discussions_unresolved_recent (project_id, last_note_at)\n- Selected at runtime: `match project_id { None => sql_global, Some(pid) => sql_scoped }`\n\n### CTE structure (4 stages):\n```sql\nWITH picked AS (\n    -- Stage 1: Select limited discussions using the right index\n    SELECT d.id, d.noteable_type, d.issue_id, d.merge_request_id,\n           d.project_id, d.last_note_at\n    FROM discussions d\n    WHERE d.resolvable = 1 AND d.resolved = 0\n      AND d.last_note_at >= ?1\n    ORDER BY d.last_note_at DESC LIMIT ?2\n),\nnote_counts AS (\n    -- Stage 2: Count all non-system notes per discussion (ACTUAL note count)\n    SELECT n.discussion_id, COUNT(*) AS note_count\n    FROM notes n JOIN picked p ON p.id = n.discussion_id\n    WHERE n.is_system = 0\n    GROUP BY n.discussion_id\n),\nparticipants AS (\n    -- Stage 3: Distinct usernames per discussion, then GROUP_CONCAT\n    SELECT x.discussion_id, GROUP_CONCAT(x.author_username, X'1F') AS participants\n    FROM (\n        SELECT DISTINCT n.discussion_id, n.author_username\n        FROM notes n JOIN picked p ON p.id = n.discussion_id\n        WHERE n.is_system = 0 AND n.author_username IS NOT NULL\n    ) x\n    GROUP BY x.discussion_id\n)\n-- Stage 4: Join everything\nSELECT p.id, p.noteable_type, COALESCE(i.iid, m.iid), COALESCE(i.title, m.title),\n       proj.path_with_namespace, p.last_note_at,\n       COALESCE(nc.note_count, 0), COALESCE(pa.participants, '')\nFROM picked p\nJOIN projects proj ON p.project_id = proj.id\nLEFT JOIN issues i ON p.issue_id = i.id\nLEFT JOIN merge_requests m ON p.merge_request_id = m.id\nLEFT JOIN note_counts nc ON nc.discussion_id = p.id\nLEFT JOIN participants pa ON pa.discussion_id = p.id\nORDER BY p.last_note_at DESC\n```\n\n### CRITICAL BUG PREVENTION: note_counts and participants MUST be separate CTEs.\nA single CTE with `SELECT DISTINCT discussion_id, author_username` then `COUNT(*)` produces a PARTICIPANT count, not a NOTE count. A discussion with 5 notes from 2 people would show note_count: 2 instead of 5.\n\n### Participants post-processing in Rust:\n```rust\nlet mut participants: Vec<String> = csv.split('\\x1F').map(String::from).collect();\nparticipants.sort();  // deterministic — GROUP_CONCAT order is undefined\nconst MAX_PARTICIPANTS: usize = 50;\nlet participants_total = participants.len() as u32;\nlet participants_truncated = participants.len() > MAX_PARTICIPANTS;\n```\n\n### Total count also uses two variants (global/scoped), same match pattern.\n\n### Unit separator X'1F' for GROUP_CONCAT (not comma — usernames could theoretically contain commas)\n\n## Files\n\n- `src/cli/commands/who.rs`\n\n## TDD Loop\n\nRED:\n```\ntest_active_query — insert discussion + 2 notes by same user; verify:\n  - total_unresolved_in_window = 1\n  - discussions.len() = 1\n  - participants = [\"reviewer_b\"]\n  - note_count = 2 (NOT 1 — this was a real regression in iteration 4)\n  - discussion_id > 0\ntest_active_participants_sorted — insert notes by zebra_user then alpha_user; verify sorted [\"alpha_user\", \"zebra_user\"]\n```\n\nGREEN: Implement query_active with both SQL variants and the shared map_row closure\nVERIFY: `cargo test -- active`\n\n## Acceptance Criteria\n\n- [ ] test_active_query passes with note_count = 2 (not participant count)\n- [ ] test_active_participants_sorted passes (alphabetical order)\n- [ ] discussion_id included in output (stable entity ID for agents)\n- [ ] Default since window: 7d\n- [ ] Bounded participants: cap 50, with total + truncated metadata\n\n## Edge Cases\n\n- note_count vs participant_count: MUST be separate CTEs (see bug prevention above)\n- GROUP_CONCAT order is undefined — sort participants in Rust after parsing\n- SQLite doesn't support GROUP_CONCAT(DISTINCT col, separator) — use subquery with SELECT DISTINCT then GROUP_CONCAT\n- Two SQL variants: prepare exactly ONE statement per invocation (don't prepare both)\n- entity_type mapping: \"MergeRequest\" -> \"MR\", else \"Issue\"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-08T02:40:38.995549Z","created_by":"tayloreernisse","updated_at":"2026-02-08T04:10:29.598085Z","closed_at":"2026-02-08T04:10:29.598047Z","close_reason":"Implemented by agent team: migration 017, CLI skeleton, all 5 query modes, human+robot output, 20 tests. All quality gates pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-m7k1","depends_on_id":"bd-2ldg","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-m7k1","depends_on_id":"bd-34rr","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-mem","title":"Implement shared backoff utility","description":"## Background\nBoth `dirty_sources` and `pending_discussion_fetches` tables use exponential backoff with `next_attempt_at` timestamps. Without a shared utility, each module would duplicate the backoff curve logic, risking drift. The shared backoff module ensures consistent retry behavior across all queue consumers in Gate C.\n\n## Approach\nCreate `src/core/backoff.rs` per PRD Section 6.X.\n\n**IMPORTANT — PRD-exact signature and implementation:**\n```rust\nuse rand::Rng;\n\n/// Compute next_attempt_at with exponential backoff and jitter.\n///\n/// Formula: now + min(3600000, 1000 * 2^attempt_count) * (0.9 to 1.1)\n/// - Capped at 1 hour to prevent runaway delays\n/// - ±10% jitter prevents synchronized retries after outages\n///\n/// Used by:\n/// - `dirty_sources` retry scheduling (document regeneration failures)\n/// - `pending_discussion_fetches` retry scheduling (API fetch failures)\n///\n/// Having one implementation prevents subtle divergence between queues\n/// (e.g., different caps or jitter ranges).\npub fn compute_next_attempt_at(now: i64, attempt_count: i64) -> i64 {\n    // Cap attempt_count to prevent overflow (2^30 > 1 hour anyway)\n    let capped_attempts = attempt_count.min(30) as u32;\n    let base_delay_ms = 1000_i64.saturating_mul(1 << capped_attempts);\n    let capped_delay_ms = base_delay_ms.min(3_600_000); // 1 hour cap\n\n    // Add ±10% jitter\n    let jitter_factor = rand::thread_rng().gen_range(0.9..=1.1);\n    let delay_with_jitter = (capped_delay_ms as f64 * jitter_factor) as i64;\n\n    now + delay_with_jitter\n}\n```\n\n**Key PRD details (must match exactly):**\n- `attempt_count` parameter is `i64` (not `u32`) — matches SQLite integer type from DB columns\n- Overflow prevention: `.min(30) as u32` caps before shift\n- Base delay: `1000_i64.saturating_mul(1 << capped_attempts)` — uses `saturating_mul` for safety\n- Cap: `3_600_000` (1 hour)\n- Jitter: `gen_range(0.9..=1.1)` — inclusive range\n- Return: `i64` (milliseconds epoch)\n\n**Cargo.toml change:** Add `rand = \"0.8\"` to `[dependencies]`.\n\n## Acceptance Criteria\n- [ ] Single shared implementation used by both dirty_tracker and discussion_queue\n- [ ] Signature: `pub fn compute_next_attempt_at(now: i64, attempt_count: i64) -> i64`\n- [ ] attempt_count is i64 (matches SQLite column type), not u32\n- [ ] Overflow prevention: `.min(30) as u32` before shift\n- [ ] Base delay uses `1000_i64.saturating_mul(1 << capped_attempts)`\n- [ ] Cap at 1 hour (3,600,000 ms)\n- [ ] Jitter: `gen_range(0.9..=1.1)` inclusive range\n- [ ] Exponential curve: 1s, 2s, 4s, 8s, ... up to 1h cap\n- [ ] `cargo test backoff` passes\n\n## Files\n- `src/core/backoff.rs` — new file\n- `src/core/mod.rs` — add `pub mod backoff;`\n- `Cargo.toml` — add `rand = \"0.8\"`\n\n## TDD Loop\nRED: `src/core/backoff.rs` with `#[cfg(test)] mod tests`:\n- `test_exponential_curve` — verify delays double each attempt (within jitter range)\n- `test_cap_at_one_hour` — attempt 20+ still produces delay <= MAX_DELAY_MS * 1.1\n- `test_jitter_range` — run 100 iterations, all delays within [0.9x, 1.1x] of base\n- `test_first_retry_is_about_one_second` — attempt 1 produces ~1000ms delay\n- `test_overflow_safety` — very large attempt_count doesn't panic\nGREEN: Implement compute_next_attempt_at()\nVERIFY: `cargo test backoff`\n\n## Edge Cases\n- `attempt_count` > 30: `.min(30)` caps, saturating_mul prevents overflow\n- `attempt_count` = 0: not used in practice (callers pass `attempt_count + 1`)\n- `attempt_count` = 1: delay is ~1 second (first retry)\n- Negative attempt_count: `.min(30)` still works, shift of negative-as-u32 wraps but saturating_mul handles it","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-30T15:27:09.474Z","created_by":"tayloreernisse","updated_at":"2026-01-30T16:57:24.900137Z","closed_at":"2026-01-30T16:57:24.899942Z","close_reason":"Completed: compute_next_attempt_at with exp backoff (1s base, 1h cap, +-10% jitter), i64 params matching SQLite, overflow-safe, 5 tests pass","compaction_level":0,"original_size":0}
{"id":"bd-mk3","title":"Update ingest command for merge_requests type","description":"## Background\nCLI entry point for MR ingestion. Routes `--type=merge_requests` to the orchestrator. Must ensure `--full` resets both MR cursor AND discussion watermarks. This is the user-facing command that kicks off the entire MR sync pipeline.\n\n## Approach\nUpdate `src/cli/commands/ingest.rs` to handle `merge_requests` type:\n1. Add `merge_requests` branch to the resource type match statement\n2. Validate resource type early with helpful error message\n3. Pass `full` flag through to orchestrator (it handles the watermark reset internally)\n\n## Files\n- `src/cli/commands/ingest.rs` - Add merge_requests branch to `run_ingest`\n\n## Acceptance Criteria\n- [ ] `gi ingest --type=merge_requests` runs MR ingestion successfully\n- [ ] `gi ingest --type=merge_requests --full` resets cursor AND discussion watermarks\n- [ ] `gi ingest --type=invalid` returns helpful error listing valid types\n- [ ] Progress output shows MR counts, discussion counts, and skip counts\n- [ ] Default type remains `issues` for backward compatibility\n- [ ] `cargo test ingest_command` passes\n\n## TDD Loop\nRED: `gi ingest --type=merge_requests` -> \"invalid type: merge_requests\"\nGREEN: Add merge_requests to match statement in run_ingest\nVERIFY: `gi ingest --type=merge_requests --help` shows merge_requests as valid\n\n## Function Signature\n```rust\npub async fn run_ingest(\n    config: &Config,\n    args: &IngestArgs,\n) -> Result<(), GiError>\n```\n\n## IngestArgs Reference (existing)\n```rust\n#[derive(Parser, Debug)]\npub struct IngestArgs {\n    /// Resource type to ingest\n    #[arg(long, short = 't', default_value = \"issues\")]\n    pub r#type: String,\n    \n    /// Filter to specific project (by path or ID)\n    #[arg(long, short = 'p')]\n    pub project: Option<String>,\n    \n    /// Force run even if another ingest is in progress\n    #[arg(long, short = 'f')]\n    pub force: bool,\n    \n    /// Full sync - reset cursor and refetch all\n    #[arg(long)]\n    pub full: bool,\n}\n```\n\n## Code Change\n```rust\nuse crate::core::errors::GiError;\nuse crate::ingestion::orchestrator::Orchestrator;\n\npub async fn run_ingest(\n    config: &Config,\n    args: &IngestArgs,\n) -> Result<(), GiError> {\n    let resource_type = args.r#type.as_str();\n    \n    // Validate resource type early\n    match resource_type {\n        \"issues\" | \"merge_requests\" => {}\n        _ => {\n            return Err(GiError::InvalidArgument {\n                name: \"type\".to_string(),\n                value: resource_type.to_string(),\n                expected: \"issues or merge_requests\".to_string(),\n            });\n        }\n    }\n    \n    // Acquire single-flight lock (unless --force)\n    if !args.force {\n        acquire_ingest_lock(config, resource_type)?;\n    }\n    \n    // Get projects to ingest (filtered if --project specified)\n    let projects = get_projects_to_ingest(config, args.project.as_deref())?;\n    \n    for project in projects {\n        println!(\"Ingesting {} for {}...\", resource_type, project.path);\n        \n        let orchestrator = Orchestrator::new(\n            &config,\n            project.id,\n            project.gitlab_id,\n        )?;\n        \n        let result = orchestrator.run_ingestion(resource_type, args.full).await?;\n        \n        // Print results based on resource type\n        match resource_type {\n            \"issues\" => {\n                println!(\"  {}: {} issues fetched, {} upserted\",\n                    project.path, result.issues_fetched, result.issues_upserted);\n            }\n            \"merge_requests\" => {\n                println!(\"  {}: {} MRs fetched, {} new labels, {} assignees, {} reviewers\",\n                    project.path,\n                    result.mrs_fetched,\n                    result.labels_created,\n                    result.assignees_linked,\n                    result.reviewers_linked,\n                );\n                println!(\"  Discussions: {} synced, {} notes ({} DiffNotes)\",\n                    result.discussions_synced,\n                    result.notes_synced,\n                    result.diffnotes_count,\n                );\n                if result.mrs_skipped_discussion_sync > 0 {\n                    println!(\"  Skipped discussion sync for {} unchanged MRs\",\n                        result.mrs_skipped_discussion_sync);\n                }\n                if result.failed_discussion_syncs > 0 {\n                    eprintln!(\"  Warning: {} MRs failed discussion sync (will retry next run)\",\n                        result.failed_discussion_syncs);\n                }\n            }\n            _ => unreachable!(),\n        }\n    }\n    \n    // Release lock\n    if !args.force {\n        release_ingest_lock(config, resource_type)?;\n    }\n    \n    Ok(())\n}\n```\n\n## Output Format\n```\nIngesting merge_requests for group/project-one...\n  group/project-one: 567 MRs fetched, 12 new labels, 89 assignees, 45 reviewers\n  Discussions: 456 synced, 1,234 notes (89 DiffNotes)\n  Skipped discussion sync for 444 unchanged MRs\n\nTotal: 567 MRs, 456 discussions, 1,234 notes\n```\n\n## Full Sync Behavior\nWhen `--full` is passed:\n1. MR cursor reset to NULL (handled by `ingest_merge_requests` with `full_sync: true`)\n2. Discussion watermarks reset to NULL (handled by `reset_discussion_watermarks` called from ingestion)\n3. All MRs re-fetched from GitLab API\n4. All discussions re-fetched for every MR\n\n## Error Types (from GiError enum)\n```rust\n// In src/core/errors.rs\npub enum GiError {\n    InvalidArgument {\n        name: String,\n        value: String,\n        expected: String,\n    },\n    LockError {\n        resource: String,\n        message: String,\n    },\n    // ... other variants\n}\n```\n\n## Edge Cases\n- Default type is `issues` for backward compatibility with CP1\n- Project filter (`--project`) can limit to specific project by path or ID\n- Force flag (`--force`) bypasses single-flight lock for debugging\n- If no projects configured, return helpful error about running `gi project add` first\n- Empty project (no MRs): completes successfully with \"0 MRs fetched\"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-26T22:06:43.034952Z","created_by":"tayloreernisse","updated_at":"2026-01-27T00:28:52.711235Z","closed_at":"2026-01-27T00:28:52.711166Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-mk3","depends_on_id":"bd-10f","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-nj7f","title":"Task 5: Robot mode JSON output and human-readable rendering","description":"## Background\n\nThis task implements the output layer for lore explain: robot mode JSON (structured envelope) and human-readable terminal rendering. Also registers the command in robot-docs manifest.\n\n**Parent bead:** bd-9lbr (lore explain feature)\n**Spec:** specs/SPEC_explain.md — Task 5\n**Phase:** 3 — Output Rendering\n**Depends on:** Task 1 (bd-2i3z), Task 2 (bd-wtrm), Task 3 (bd-wb0b), Task 4 (bd-3q5e) — needs all sections populated\n\n## Robot Mode Output\n\nWrap ExplainResult in standard envelope:\n{\"ok\": true, \"data\": <ExplainResult>, \"meta\": {\"elapsed_ms\": N}}\n\nPattern from other commands:\nlet start = std::time::Instant::now();\n// ... run_explain() ...\nlet elapsed = start.elapsed();\nlet response = serde_json::json!({\n    \"ok\": true,\n    \"data\": result,\n    \"meta\": { \"elapsed_ms\": elapsed.as_millis() }\n});\nprintln!(\"{}\", serde_json::to_string(&response)?);\n\nskip_serializing_if on optional sections handles filtering automatically — when sections are None they are omitted from JSON.\n\n### Example output shape:\n{\n  \"ok\": true,\n  \"data\": {\n    \"entity\": { \"type\": \"issue\", \"iid\": 42, \"title\": \"...\", \"state\": \"opened\", \"author\": \"teernisse\", \"assignees\": [\"teernisse\"], \"labels\": [\"customer:BNSF\"], \"created_at\": \"2026-01-10T...\", \"updated_at\": \"2026-02-12T...\", \"url\": \"...\", \"status_name\": \"In progress\" },\n    \"description_excerpt\": \"First 500 chars...\",\n    \"key_decisions\": [{ \"timestamp\": \"...\", \"actor\": \"...\", \"action\": \"state: closed\", \"context_note\": \"...\" }],\n    \"activity\": { \"state_changes\": 3, \"label_changes\": 5, \"notes\": 42, \"first_event\": \"...\", \"last_event\": \"...\" },\n    \"open_threads\": [{ \"discussion_id\": \"abc123\", \"started_by\": \"cseiber\", \"started_at\": \"...\", \"note_count\": 5, \"last_note_at\": \"...\" }],\n    \"related\": { \"closing_mrs\": [{ \"iid\": 200, \"title\": \"...\", \"state\": \"merged\" }], \"related_issues\": [{ \"entity_type\": \"issue\", \"iid\": 3800, \"title\": \"...\", \"reference_type\": \"related\" }] },\n    \"timeline_excerpt\": [{ \"timestamp\": \"...\", \"event_type\": \"state_changed\", \"actor\": \"teernisse\", \"summary\": \"State changed to closed\" }]\n  },\n  \"meta\": { \"elapsed_ms\": 350 }\n}\n\n## Human Mode Rendering\n\nUse Theme::bold(), Icons, render::truncate() from crate::cli::render.\nFollow timeline command's rendering pattern (src/cli/commands/timeline.rs): header with entity info -> separator line -> sections.\n\nEach section has a header (bold, colored), then indented content:\n- Entity: title, state, author, assignees, labels, status — on a few compact lines\n- Description: truncated excerpt (500 chars)\n- Key Decisions: one block per decision — timestamp + actor + action, indented context_note below\n- Activity: single summary line with counts and date range\n- Open Threads: list of unresolved discussions with started_by, note_count, last activity\n- Related: closing MRs (iid, title, state) and related issues (iid, title, type)\n- Timeline: chronological events, each as one compact line\n\nCheck params.sections before rendering each section (human mode skips unselected sections).\nEntity section always rendered (needed for identification).\n\n## Robot-Docs Registration\n\nRegister in src/app/robot_docs.rs in the commands JSON object. Follow the pattern of other commands like \"drift\" or \"timeline\":\n\n\"explain\": {\n    \"description\": \"Auto-generate a structured narrative of an issue or MR\",\n    \"flags\": [\"<entity_type: issues|mrs|issue|mr>\", \"<IID>\", \"-p/--project <path>\", \"--sections <list>\", \"--no-timeline\", \"--max-decisions <N>\", \"--since <duration>\"],\n    \"example\": \"lore --robot explain issues 42\",\n    \"sections\": [\"entity\", \"description\", \"key_decisions\", \"activity\", \"open_threads\", \"related\", \"timeline\"],\n    \"response_schema\": {\n        \"ok\": \"bool\",\n        \"data\": {\"entity\": \"{type, iid, title, state, author, assignees, labels, created_at, updated_at, url, status_name}\", ...},\n        \"meta\": {\"elapsed_ms\": \"int\"}\n    }\n}\n\n## Error Handling\n\n- Entity not found: exit code 17, robot JSON error with code \"NOT_FOUND\", suggestion \"Entity not in local DB. Try: lore sync\"\n- Ambiguous project: exit code 18, robot JSON error with code \"AMBIGUOUS_MATCH\", suggestion \"Use -p to specify project\"\n- These should be handled by the existing error pipeline (LoreError -> exit code mapping)\n\n## Files to Modify\n\n- src/cli/commands/explain.rs — add print_explain_json() and print_explain() functions, wire into handle_explain\n- src/app/robot_docs.rs — add \"explain\" entry to commands JSON\n\n## TDD Workflow (Red-Green)\n\n### RED — Write these tests first, confirm they FAIL:\n\n1. test_explain_robot_output_shape: Call run_explain() with all sections, serialize to JSON, assert all 7 top-level keys present in data\n2. test_explain_sections_filter_robot: Call run_explain() with sections: Some(vec![\"key_decisions\", \"activity\"]), serialize, assert only entity + key_decisions + activity keys present (entity always included), assert description_excerpt, open_threads, related, timeline_excerpt are absent from JSON\n\n### GREEN — Implement:\n\n- print_explain_json(): wrap ExplainResult in envelope, print to stdout\n- print_explain(): render human-readable output with colored headers and formatted content\n- Register in robot-docs manifest\n- Wire into handle_explain: if robot_mode { print_explain_json } else { print_explain }\n\n### Verify:\ncargo test explain::tests::test_explain_robot && cargo test explain::tests::test_explain_sections_filter && cargo clippy --all-targets -- -D warnings\n\n## Acceptance Criteria\n\n- [ ] test_explain_robot_output_shape passes\n- [ ] test_explain_sections_filter_robot passes\n- [ ] Robot JSON matches spec schema\n- [ ] Section filtering works in both robot and human mode\n- [ ] Command appears in lore robot-docs output\n- [ ] Error cases: exit 17 (not found) with suggestion to sync, exit 18 (ambiguous) with suggestion to use -p\n- [ ] Performance: <500ms for issue with 50 notes\n- [ ] cargo clippy and cargo fmt clean","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-10T17:38:11.609363Z","created_by":"tayloreernisse","updated_at":"2026-03-10T19:03:29.063559Z","closed_at":"2026-03-10T19:03:29.063506Z","close_reason":"Implemented: all explain command sections (activity, open_threads, related, timeline_excerpt, human/robot renderers) merged into main explain.rs, robot_docs updated, autocorrect registered, 1046 tests passing","compaction_level":0,"original_size":0,"labels":["cli-imp","explain"],"dependencies":[{"issue_id":"bd-nj7f","depends_on_id":"bd-3q5e","type":"blocks","created_at":"2026-03-10T17:38:19.012533Z","created_by":"tayloreernisse"},{"issue_id":"bd-nj7f","depends_on_id":"bd-9lbr","type":"parent-child","created_at":"2026-03-10T17:38:11.611068Z","created_by":"tayloreernisse"},{"issue_id":"bd-nj7f","depends_on_id":"bd-wb0b","type":"blocks","created_at":"2026-03-10T17:38:18.944373Z","created_by":"tayloreernisse"},{"issue_id":"bd-nj7f","depends_on_id":"bd-wtrm","type":"blocks","created_at":"2026-03-10T17:38:18.869452Z","created_by":"tayloreernisse"}]}
{"id":"bd-nu0d","title":"Implement resize storm + rapid keypress + event fuzz tests","description":"## Background\nStress tests verify the TUI handles adverse input conditions without panic: rapid terminal resizes, fast keypress sequences, and randomized event traces. The event fuzz suite uses deterministic seed replay for reproducibility.\n\n## Approach\nResize storm:\n- Send 100 resize events in rapid succession (varying sizes from 20x10 to 300x80)\n- Assert no panic, no layout corruption, final render is valid for final size\n- FrankenTUI's BOCPD resize coalescing should handle this — verify it works\n\nRapid keypress:\n- Send 50 key events in <100ms: mix of navigation, filter input, mode switches\n- Assert no panic, no stuck input mode, final state is consistent\n- Verify Ctrl+C always exits regardless of state\n\nEvent fuzz (deterministic):\n- Generate 10k randomized event traces from: key events, resize events, paste events, tick events\n- Use seeded RNG for reproducibility\n- Replay each trace, check invariants after each event:\n  - Navigation stack depth >= 1 (always has at least Dashboard)\n  - InputMode transitions are valid (no impossible state combinations)\n  - No panic\n  - LoadState transitions are valid (no Idle->Refreshing without LoadingInitial first for initial load)\n- On invariant violation: log seed + event index for reproduction\n\n## Acceptance Criteria\n- [ ] 100 rapid resizes: no panic, valid final render\n- [ ] 50 rapid keys: no stuck input mode, Ctrl+C exits\n- [ ] 10k fuzz traces: zero invariant violations\n- [ ] Fuzz tests deterministically reproducible via seed\n- [ ] Navigation invariant: stack always has at least Dashboard\n- [ ] InputMode invariant: valid transitions only\n\n## Files\n- CREATE: crates/lore-tui/tests/stress_tests.rs\n- CREATE: crates/lore-tui/tests/fuzz_tests.rs\n\n## TDD Anchor\nRED: Write test_resize_storm_no_panic that sends 100 resize events to LoreApp, asserts no panic.\nGREEN: Ensure view() handles all terminal sizes gracefully.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_resize_storm\n\n## Edge Cases\n- Zero-size terminal (0x0): must not panic, skip rendering\n- Very large terminal (500x200): must not allocate unbounded memory\n- Paste events can contain arbitrary bytes including control chars — sanitize\n- Fuzz seed must be logged at test start for reproduction\n\n## Dependency Context\nUses LoreApp from \"Implement LoreApp Model\" task.\nUses NavigationStack from \"Implement NavigationStack\" task.\nUses FakeClock for deterministic time in fuzz tests.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T17:04:42.012118Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:24.172491Z","deleted_at":"2026-03-11T18:34:24.172487Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-nwux","title":"Epic: TUI Phase 3 — Power Features","description":"## Background\nPhase 3 adds the power-user screens: Search (3 modes with preview), Timeline (5-stage pipeline visualization), Who (5 expert/workload modes), and Command Palette (fuzzy match). These screens leverage the foundation from Phases 1-2.\n\n## Acceptance Criteria\n- [ ] Search supports lexical, hybrid, and semantic modes with split-pane preview\n- [ ] Search capability detection enables/disables modes based on available indexes\n- [ ] Timeline renders chronological event stream with color-coded event types\n- [ ] Who supports Expert, Workload, Reviews, Active, and Overlap modes\n- [ ] Command palette provides fuzzy-match access to all commands","status":"tombstone","priority":1,"issue_type":"epic","created_at":"2026-02-12T17:00:27.375421Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:28.547667Z","deleted_at":"2026-03-11T18:34:28.547661Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"epic","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-o7b","title":"[CP1] gi show issue command","description":"## Background\n\nThe `gi show issue <iid>` command displays detailed information about a single issue including metadata, description, labels, and all discussions with their notes. It provides a complete view similar to the GitLab web UI.\n\n## Approach\n\n### Module: src/cli/commands/show.rs\n\n### Clap Definition\n\n```rust\n#[derive(Args)]\npub struct ShowArgs {\n    /// Entity type\n    #[arg(value_parser = [\"issue\", \"mr\"])]\n    pub entity: String,\n\n    /// Entity IID\n    pub iid: i64,\n\n    /// Project path (required if ambiguous)\n    #[arg(long)]\n    pub project: Option<String>,\n}\n```\n\n### Handler Function\n\n```rust\npub async fn handle_show(args: ShowArgs, conn: &Connection) -> Result<()>\n```\n\n### Logic (for entity=\"issue\")\n\n1. **Find issue**: Query by iid, optionally filtered by project\n   - If multiple projects have same iid, require --project or error\n2. **Load metadata**: title, state, author, created_at, updated_at, web_url\n3. **Load labels**: JOIN through issue_labels to labels table\n4. **Load discussions**: All discussions for this issue\n5. **Load notes**: All notes for each discussion, ordered by position\n6. **Format output**: Rich display with sections\n\n### Output Format (matches PRD)\n\n```\nIssue #1234: Authentication redesign\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nProject:  group/project-one\nState:    opened\nAuthor:   @johndoe\nCreated:  2024-01-15\nUpdated:  2024-03-20\nLabels:   enhancement, auth\nURL:      https://gitlab.example.com/group/project-one/-/issues/1234\n\nDescription:\n  We need to redesign the authentication flow to support...\n\nDiscussions (5):\n\n  @janedoe (2024-01-16):\n    I agree we should move to JWT-based auth...\n\n    @johndoe (2024-01-16):\n      What about refresh token strategy?\n\n  @bobsmith (2024-01-17):\n    Have we considered OAuth2?\n```\n\n### Queries\n\n```sql\n-- Find issue\nSELECT i.*, p.path as project_path\nFROM issues i\nJOIN projects p ON i.project_id = p.id\nWHERE i.iid = ? AND (p.path = ? OR ? IS NULL)\n\n-- Get labels\nSELECT l.name FROM labels l\nJOIN issue_labels il ON l.id = il.label_id\nWHERE il.issue_id = ?\n\n-- Get discussions with notes\nSELECT d.*, n.* FROM discussions d\nJOIN notes n ON d.id = n.discussion_id\nWHERE d.issue_id = ?\nORDER BY d.first_note_at, n.position\n```\n\n## Acceptance Criteria\n\n- [ ] Shows issue metadata (title, state, author, dates, URL)\n- [ ] Shows labels as comma-separated list\n- [ ] Shows description (truncated if very long)\n- [ ] Shows discussions grouped with notes indented\n- [ ] Handles --project filter correctly\n- [ ] Errors clearly if iid is ambiguous without --project\n\n## Files\n\n- src/cli/commands/mod.rs (add `pub mod show;`)\n- src/cli/commands/show.rs (create)\n- src/cli/mod.rs (add Show variant to Commands enum)\n\n## TDD Loop\n\nRED:\n```rust\n#[tokio::test] async fn show_issue_displays_metadata()\n#[tokio::test] async fn show_issue_displays_labels()\n#[tokio::test] async fn show_issue_displays_discussions()\n#[tokio::test] async fn show_issue_requires_project_when_ambiguous()\n```\n\nGREEN: Implement handler with queries and formatting\n\nVERIFY: `cargo test show_issue`\n\n## Edge Cases\n\n- Issue with no labels - show \"Labels: (none)\"\n- Issue with no discussions - show \"Discussions: (none)\"\n- Issue with very long description - truncate with \"...\"\n- System notes in discussions - filter out or show with [system] prefix\n- Individual notes (not threaded) - show without reply indentation","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-25T17:02:38.384702Z","created_by":"tayloreernisse","updated_at":"2026-01-25T23:05:25.688102Z","closed_at":"2026-01-25T23:05:25.688043Z","close_reason":"Implemented gi show issue command with metadata, labels, and discussions display","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-o7b","depends_on_id":"bd-208","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-o7b","depends_on_id":"bd-hbo","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-ozy","title":"[CP1] Ingestion orchestrator","description":"## Background\n\nThe ingestion orchestrator coordinates issue sync followed by dependent discussion sync. It implements the CP1 canonical pattern: fetch issues, identify which need discussion sync (updated_at advanced), then execute discussion sync with bounded concurrency.\n\n## Approach\n\n### Module: src/ingestion/orchestrator.rs\n\n### Main Function\n\n```rust\npub async fn ingest_project_issues(\n    conn: &Connection,\n    client: &GitLabClient,\n    config: &Config,\n    project_id: i64,           // Local DB project ID\n    gitlab_project_id: i64,\n) -> Result<IngestProjectResult>\n\n#[derive(Debug, Default)]\npub struct IngestProjectResult {\n    pub issues_fetched: usize,\n    pub issues_upserted: usize,\n    pub labels_created: usize,\n    pub discussions_fetched: usize,\n    pub notes_fetched: usize,\n    pub system_notes_count: usize,\n    pub issues_skipped_discussion_sync: usize,\n}\n```\n\n### Orchestration Steps\n\n1. **Call issue ingestion**: `ingest_issues(conn, client, config, project_id, gitlab_project_id)`\n2. **Get issues needing discussion sync**: From IngestIssuesResult.issues_needing_discussion_sync\n3. **Execute bounded discussion sync**:\n   - Use `tokio::task::LocalSet` for single-threaded runtime\n   - Respect `config.sync.dependent_concurrency` (default: 5)\n   - For each IssueForDiscussionSync:\n     - Call `ingest_issue_discussions(...)`\n     - Aggregate results\n4. **Calculate skipped count**: total_issues - issues_needing_discussion_sync.len()\n\n### Bounded Concurrency Pattern\n\n```rust\nuse futures::stream::{self, StreamExt};\n\nlet local_set = LocalSet::new();\nlocal_set.run_until(async {\n    stream::iter(issues_needing_sync)\n        .map(|issue| async {\n            ingest_issue_discussions(\n                conn, client, config,\n                project_id, gitlab_project_id,\n                issue.iid, issue.local_issue_id, issue.updated_at,\n            ).await\n        })\n        .buffer_unordered(config.sync.dependent_concurrency)\n        .try_collect::<Vec<_>>()\n        .await\n}).await\n```\n\nNote: Single-threaded runtime means concurrency is I/O-bound, not parallel execution.\n\n## Acceptance Criteria\n\n- [ ] Orchestrator calls issue ingestion first\n- [ ] Only issues with updated_at > discussions_synced_for_updated_at get discussion sync\n- [ ] Bounded concurrency respects dependent_concurrency config\n- [ ] Results aggregated from both issue and discussion ingestion\n- [ ] issues_skipped_discussion_sync accurately reflects unchanged issues\n\n## Files\n\n- src/ingestion/mod.rs (add `pub mod orchestrator;`)\n- src/ingestion/orchestrator.rs (create)\n\n## TDD Loop\n\nRED:\n```rust\n// tests/orchestrator_tests.rs\n#[tokio::test] async fn orchestrates_issue_then_discussion_sync()\n#[tokio::test] async fn skips_discussion_sync_for_unchanged_issues()\n#[tokio::test] async fn respects_bounded_concurrency()\n#[tokio::test] async fn aggregates_results_correctly()\n```\n\nGREEN: Implement orchestrator with bounded concurrency\n\nVERIFY: `cargo test orchestrator`\n\n## Edge Cases\n\n- All issues unchanged - no discussion sync calls\n- All issues new - all get discussion sync\n- dependent_concurrency=1 - sequential discussion fetches\n- Issue ingestion fails - orchestrator returns error, no discussion sync","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-25T17:02:38.289941Z","created_by":"tayloreernisse","updated_at":"2026-01-25T22:54:07.447647Z","closed_at":"2026-01-25T22:54:07.447577Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-ozy","depends_on_id":"bd-208","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-ozy","depends_on_id":"bd-hbo","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-pgdw","title":"OBSERV: Add root tracing span with run_id to sync and ingest","description":"## Background\nA root tracing span per command invocation provides the top of the span hierarchy. All child spans (ingest_issues, fetch_pages, etc.) inherit the run_id field, making every log line within a run filterable by jq.\n\n## Approach\nIn run_sync() (src/cli/commands/sync.rs:54), after generating run_id, create a root span:\n\n```rust\npub async fn run_sync(config: &Config, options: SyncOptions) -> Result<SyncResult> {\n    let run_id = &uuid::Uuid::new_v4().to_string()[..8];\n    let _root = tracing::info_span!(\"sync\", %run_id).entered();\n    // ... existing sync pipeline code\n}\n```\n\nIn run_ingest() (src/cli/commands/ingest.rs:107), same pattern:\n\n```rust\npub async fn run_ingest(...) -> Result<IngestResult> {\n    let run_id = &uuid::Uuid::new_v4().to_string()[..8];\n    let _root = tracing::info_span!(\"ingest\", %run_id, resource_type).entered();\n    // ... existing ingest code\n}\n```\n\nCRITICAL: The _root guard must live for the entire function scope. If it drops early (e.g., shadowed or moved into a block), child spans lose their parent context. Use let _root (underscore prefix) to signal intentional unused binding that's kept alive for its Drop impl.\n\nFor async functions, use .entered() NOT .enter(). In async Rust, Span::enter() returns a guard that is NOT Send, which prevents the future from being sent across threads. However, .entered() on an info_span! creates an Entered<Span> which is also !Send. For async, prefer:\n\n```rust\nlet root_span = tracing::info_span!(\"sync\", %run_id);\nasync move {\n    // ... body\n}.instrument(root_span).await\n```\n\nOr use #[instrument] on the function itself with the run_id field.\n\n## Acceptance Criteria\n- [ ] Root span established for every sync and ingest invocation\n- [ ] run_id appears in span context of all child log lines\n- [ ] jq 'select(.spans[]? | .run_id)' can extract all lines from a run\n- [ ] Span is active for entire function duration (not dropped early)\n- [ ] Works correctly with async/await (span propagated across .await points)\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/cli/commands/sync.rs (add root span in run_sync, line ~54)\n- src/cli/commands/ingest.rs (add root span in run_ingest, line ~107)\n\n## TDD Loop\nRED: test_root_span_propagates_run_id (capture JSON log output, verify run_id in span context)\nGREEN: Add root spans to run_sync and run_ingest\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- Async span propagation: .entered() is !Send. For async functions, use .instrument() or #[instrument]. The run_sync function is async (line 54: pub async fn run_sync).\n- Nested command calls: run_sync calls run_ingest internally. If both create root spans, we get a nested hierarchy: sync > ingest. This is correct behavior -- the ingest span becomes a child of sync.\n- Span storage: tracing-subscriber registry handles span storage automatically. No manual setup needed beyond adding the layer.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-04T15:54:07.771605Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:19:33.006274Z","closed_at":"2026-02-04T17:19:33.006227Z","close_reason":"Added root tracing spans with run_id to run_sync() and run_ingest() using .instrument() pattern for async compatibility","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-pgdw","depends_on_id":"bd-2ni","type":"parent-child","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-pgdw","depends_on_id":"bd-37qw","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-pr1","title":"Implement lore stats CLI command","description":"## Background\nThe stats command provides visibility into the document/search/embedding pipeline health. It reports counts (DocumentStats, EmbeddingStats, FtsStats, QueueStats), verifies consistency between tables (--check), and repairs inconsistencies (--repair). This is essential for diagnosing sync issues and validating Gate A/B/C correctness.\n\n## Approach\nCreate `src/cli/commands/stats.rs` per PRD Section 4.6.\n\n**Stats structs (PRD-exact):**\n```rust\n#[derive(Debug, Serialize)]\npub struct Stats {\n    pub documents: DocumentStats,\n    pub embeddings: EmbeddingStats,\n    pub fts: FtsStats,\n    pub queues: QueueStats,\n}\n\n#[derive(Debug, Serialize)]\npub struct DocumentStats {\n    pub issues: usize,\n    pub mrs: usize,\n    pub discussions: usize,\n    pub total: usize,\n    pub truncated: usize,\n}\n\n#[derive(Debug, Serialize)]\npub struct EmbeddingStats {\n    /// Documents with at least one embedding (chunk_index=0 exists in embedding_metadata)\n    pub embedded: usize,\n    pub pending: usize,\n    pub failed: usize,\n    /// embedded / total_documents * 100 (document-level, not chunk-level)\n    pub coverage_pct: f64,\n    /// Total chunks across all embedded documents\n    pub total_chunks: usize,\n}\n\n#[derive(Debug, Serialize)]\npub struct FtsStats { pub indexed: usize }\n\n#[derive(Debug, Serialize)]\npub struct QueueStats {\n    pub dirty_sources: usize,\n    pub dirty_sources_failed: usize,\n    pub pending_discussion_fetches: usize,\n    pub pending_discussion_fetches_failed: usize,\n}\n```\n\n**IntegrityCheck struct (PRD-exact):**\n```rust\n#[derive(Debug, Serialize)]\npub struct IntegrityCheck {\n    pub documents_count: usize,\n    pub fts_count: usize,\n    pub embeddings_count: usize,\n    pub metadata_count: usize,\n    pub orphaned_embeddings: usize,\n    pub hash_mismatches: usize,\n    pub ok: bool,\n}\n```\n\n**RepairResult struct (PRD-exact):**\n```rust\n#[derive(Debug, Serialize)]\npub struct RepairResult {\n    pub orphaned_embeddings_deleted: usize,\n    pub stale_embeddings_cleared: usize,\n    pub missing_fts_repopulated: usize,\n}\n```\n\n**Core functions:**\n- `run_stats(config) -> Result<Stats>` — gather all stats\n- `run_integrity_check(config) -> Result<IntegrityCheck>` — verify consistency\n- `run_repair(config) -> Result<RepairResult>` — fix issues\n\n**Integrity checks (per PRD):**\n1. documents count == documents_fts count\n2. All `embeddings.rowid / 1000` map to valid `documents.id` (orphan detection)\n3. `embedding_metadata.document_hash == documents.content_hash` for chunk_index=0 rows (staleness uses `document_hash`, NOT `chunk_hash`)\n\n**Repair operations (PRD-exact):**\n1. Delete orphaned embedding_metadata (document_id NOT IN documents)\n2. Delete orphaned vec0 rows: `DELETE FROM embeddings WHERE rowid / 1000 NOT IN (SELECT id FROM documents)` — uses `rowid / 1000` for chunked scheme\n3. Clear stale embeddings: find documents where `embedding_metadata.document_hash != documents.content_hash` (chunk_index=0 comparison), delete ALL chunks for those docs (range-based: `rowid >= doc_id * 1000 AND rowid < (doc_id + 1) * 1000`)\n4. FTS rebuild: `INSERT INTO documents_fts(documents_fts) VALUES('rebuild')` — full rebuild, NOT optimize. PRD note: partial fix is fragile with external-content FTS; rebuild is guaranteed correct.\n\n**CLI args (PRD-exact):**\n```rust\n#[derive(Args)]\npub struct StatsArgs {\n    #[arg(long)]\n    check: bool,\n    #[arg(long, requires = \"check\")]\n    repair: bool,  // --repair requires --check\n}\n```\n\n## Acceptance Criteria\n- [ ] Document counts by type: issues, mrs, discussions, total, truncated\n- [ ] Embedding coverage is document-level (not chunk-level): `embedded / total * 100`\n- [ ] Embedding stats include total_chunks count\n- [ ] FTS indexed count reported\n- [ ] Queue stats: dirty_sources + dirty_sources_failed, pending_discussion_fetches + pending_discussion_fetches_failed\n- [ ] --check verifies: FTS count == documents count, orphan embeddings, hash mismatches\n- [ ] Orphan detection uses `rowid / 1000` for chunked embedding scheme\n- [ ] Hash mismatch uses `document_hash` (not `chunk_hash`) for document-level staleness\n- [ ] --repair deletes orphaned embeddings (range-based for chunks)\n- [ ] --repair clears stale metadata (document_hash != content_hash at chunk_index=0)\n- [ ] --repair uses FTS `rebuild` (not `optimize`) for correct-by-construction repair\n- [ ] --repair requires --check (Clap `requires` attribute)\n- [ ] Human output: formatted with aligned columns\n- [ ] JSON output: `{\"ok\": true, \"data\": stats}`\n- [ ] `cargo build` succeeds\n\n## Files\n- `src/cli/commands/stats.rs` — new file\n- `src/cli/commands/mod.rs` — add `pub mod stats;`\n- `src/cli/mod.rs` — add StatsArgs, wire up stats subcommand\n- `src/main.rs` — add stats command handler\n\n## TDD Loop\nRED: Integration tests:\n- `test_stats_empty_db` — all counts 0, coverage 0%\n- `test_stats_with_documents` — correct counts by type\n- `test_integrity_check_healthy` — ok=true when consistent\n- `test_integrity_check_fts_mismatch` — detects FTS/doc count divergence\n- `test_integrity_check_orphan_embeddings` — detects orphaned rowids\n- `test_repair_rebuilds_fts` — FTS count matches after repair\n- `test_repair_cleans_orphans` — orphaned embeddings deleted\n- `test_repair_clears_stale` — stale metadata cleared (doc_hash mismatch)\nGREEN: Implement stats, integrity check, repair\nVERIFY: `cargo build && cargo test stats`\n\n## Edge Cases\n- Empty database: all counts 0, coverage 0%, no integrity issues\n- Gate A only (no embeddings table): skip embedding stats gracefully\n- --repair on healthy DB: no-op, reports \"no issues found\" / zero counts\n- FTS rebuild on large DB: may be slow\n- --repair without --check: Clap rejects (requires attribute enforces dependency)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:26:50.232629Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:54:31.065586Z","closed_at":"2026-01-30T17:54:31.065501Z","close_reason":"Implemented stats CLI with document counts by type, embedding coverage, FTS index count, queue stats, --check integrity (FTS mismatch, orphan embeddings, stale metadata), --repair (rebuild FTS, delete orphans, clear stale). Human + JSON output. Builds clean.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-pr1","depends_on_id":"bd-3qs","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-qpk3","title":"Add gitlab.username config field","description":"## Background\nThe `lore me` command needs to know the current user's GitLab username to query for assigned issues, authored MRs, and reviewing MRs. Currently `GitLabConfig` in `src/core/config.rs` has `base_url`, `token_env_var`, and optional `token` — but no username field.\n\n## Approach\nAdd an optional `username` field to `GitLabConfig` at `src/core/config.rs:8-19`:\n```rust\n#[derive(Debug, Clone, Deserialize)]\npub struct GitLabConfig {\n    #[serde(rename = \"baseUrl\")]\n    pub base_url: String,\n\n    #[serde(rename = \"tokenEnvVar\", default = \"default_token_env_var\")]\n    pub token_env_var: String,\n\n    #[serde(default)]\n    pub token: Option<String>,\n\n    #[serde(default)]\n    pub username: Option<String>,  // NEW — AC-1.1\n}\n```\n\nThe field is single-word so it needs NO `serde(rename)` — just `#[serde(default)]` so existing configs without it parse cleanly (same pattern as `token`). Username is case-sensitive (AC-1.3) — store as-is, no normalization.\n\n## Acceptance Criteria\n- [ ] `GitLabConfig` has `pub username: Option<String>` field\n- [ ] Field uses `#[serde(default)]` (like `token` does on line 17)\n- [ ] Field deserializes from `gitlab.username` in config.json\n- [ ] Field is optional — existing configs without it still parse correctly (Option + default)\n- [ ] Username stored as-is (case-sensitive, no lowercasing)\n- [ ] cargo test passes (no existing tests should break)\n\n## Files\n- MODIFY: src/core/config.rs (add field to GitLabConfig struct around line 18)\n\n## TDD Anchor\nRED: Write `test_config_parses_username` in `src/core/config.rs` tests:\n```rust\nlet json = r#\"{\"gitlab\":{\"baseUrl\":\"https://gitlab.com\",\"username\":\"jdoe\"},\"projects\":[]}\"#;\nlet config: Config = serde_json::from_str(json).unwrap();\nassert_eq!(config.gitlab.username, Some(\"jdoe\".to_string()));\n```\nAlso test missing field:\n```rust\nlet json = r#\"{\"gitlab\":{\"baseUrl\":\"https://gitlab.com\"},\"projects\":[]}\"#;\nlet config: Config = serde_json::from_str(json).unwrap();\nassert_eq!(config.gitlab.username, None);\n```\nGREEN: Add the field to the struct.\nVERIFY: `cargo test config_parses_username`\n\n## Edge Cases\n- Existing config files without the field must not break (Option + #[serde(default)] handles this)\n- Empty string `\"\"` → Some(\"\") — let the resolution function (bd-1f1f) handle treating it as None\n- The `ScoringConfig.excluded_usernames: Vec<String>` is unrelated — different purpose\n\n## Dependency Context\nNo upstream dependencies. Consumed by bd-1f1f (username resolution reads this field).","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-19T19:35:03.806072Z","created_by":"tayloreernisse","updated_at":"2026-02-20T16:09:13.039716Z","closed_at":"2026-02-20T16:09:13.039655Z","close_reason":"Implemented by lore-me agent swarm","compaction_level":0,"original_size":0}
{"id":"bd-qpq6","title":"Phase 3d: Signal handler rewrite for asupersync","description":"## What\nRewrite the signal handler in core/shutdown.rs (extracted in Phase 0a) to use asupersync Cx instead of tokio::spawn.\n\n## Rearchitecture Context (2026-03-06)\n- Signal handler call sites are in src/app/handlers.rs (physically), include\\!'d into main.rs scope\n- After Phase 0a extracts them to core/shutdown.rs, the callers will be in src/app/handlers.rs\n\n## Implementation\n\n```rust\n// Phase 0a version (tokio):\npub fn install_ctrl_c_handler(signal: ShutdownSignal) {\n    tokio::spawn(async move { ... });\n}\n\n// Phase 3d version (asupersync):\npub async fn install_ctrl_c_handler(cx: &Cx, signal: ShutdownSignal) {\n    cx.spawn(\"ctrl-c-handler\", async move |cx| {\n        cx.shutdown_signal().await;\n        eprintln\\!(\"\\nInterrupted, finishing current batch... (Ctrl+C again to force quit)\");\n        signal.cancel();\n        cx.shutdown_signal().await;\n        std::process::exit(130);\n    });\n}\n```\n\n## Cleanup Concern\nstd::process::exit(130) on second Ctrl+C bypasses all drop guards, flush operations, and asupersync region cleanup. This is intentional (user demanded hard exit) but means any in-progress DB transaction will be abandoned mid-write. SQLite journaling makes this safe (uncommitted transactions rolled back on next open).\n\nConsider logging a warning before exit so users understand incomplete operations may need re-sync.\n\nVerify this holds for WAL mode if enabled.\n\n## Signature Change\n- fn -> async fn\n- Takes cx: &Cx as first parameter\n- Callers in src/app/handlers.rs must pass cx (these are include\\!'d into main.rs scope)\n\n## Files Changed\n- src/core/shutdown.rs (~10 LOC rewrite)\n- src/app/handlers.rs (update call sites to pass cx — physically here, logically in main.rs via include\\!())\n\n## Testing\n- cargo check --all-targets\n- Manual test: launch lore sync, press Ctrl+C, verify graceful shutdown message\n\n## Depends On\n- Phase 0a (signal handler must be extracted first)\n- Phase 3c (entrypoint must provide Cx)","status":"closed","priority":1,"issue_type":"task","created_at":"2026-03-06T18:40:42.496146Z","created_by":"tayloreernisse","updated_at":"2026-03-06T21:11:12.631927Z","closed_at":"2026-03-06T21:11:12.631882Z","close_reason":"asupersync migration complete — 5 commits pushed to asupersync-migration branch","compaction_level":0,"original_size":0,"labels":["asupersync","phase-3"],"dependencies":[{"issue_id":"bd-qpq6","depends_on_id":"bd-1lj5","type":"parent-child","created_at":"2026-03-06T18:40:42.501252Z","created_by":"tayloreernisse"},{"issue_id":"bd-qpq6","depends_on_id":"bd-21fb","type":"blocks","created_at":"2026-03-06T18:42:52.410229Z","created_by":"tayloreernisse"},{"issue_id":"bd-qpq6","depends_on_id":"bd-2wok","type":"blocks","created_at":"2026-03-06T18:42:52.540421Z","created_by":"tayloreernisse"}]}
{"id":"bd-r3wm","title":"Description","description":"Another test","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T16:52:04.745618Z","updated_at":"2026-02-12T16:52:10.757707Z","closed_at":"2026-02-12T16:52:10.757667Z","close_reason":"test artifacts","compaction_level":0,"original_size":0}
{"id":"bd-s3rc","title":"WHO: Workload mode query (query_workload)","description":"## Background\n\nWorkload mode answers \"What is person X working on?\" — a four-section snapshot of a user's active work items: assigned issues, authored MRs, MRs they're reviewing, and unresolved discussions they participate in.\n\n## Approach\n\nFour independent SQL queries, all using the same parameter pattern: `rusqlite::params![username, project_id, since_ms, limit_plus_one]`\n\n### Key design decisions:\n- **since_ms is Option<i64>**: unlike other modes, Workload has NO default time window. Unresolved discussions and open issues are relevant regardless of age. When --since is explicitly provided, (?3 IS NULL OR ...) activates filtering.\n- **Canonical refs**: SQL computes project-qualified references directly:\n  - Issues: `p.path_with_namespace || '#' || i.iid` -> \"group/project#42\"\n  - MRs: `p.path_with_namespace || '!' || m.iid` -> \"group/project!100\"\n- **Discussions**: use EXISTS subquery to check user participation, CASE for ref separator (# vs !)\n\n### Query 1: Open issues assigned to user\n```sql\nSELECT i.iid, (p.path_with_namespace || '#' || i.iid) AS ref,\n       i.title, p.path_with_namespace, i.updated_at\nFROM issues i\nJOIN issue_assignees ia ON ia.issue_id = i.id\nJOIN projects p ON i.project_id = p.id\nWHERE ia.username = ?1 AND i.state = 'opened'\n  AND (?2 IS NULL OR i.project_id = ?2)\n  AND (?3 IS NULL OR i.updated_at >= ?3)\nORDER BY i.updated_at DESC LIMIT ?4\n```\n\n### Query 2: Open MRs authored (similar pattern, m.author_username = ?1)\n### Query 3: Open MRs where user is reviewer (JOIN mr_reviewers, includes m.author_username in output)\n### Query 4: Unresolved discussions where user participated (EXISTS notes subquery)\n\n### Per-section truncation:\n```rust\nlet assigned_issues_truncated = assigned_issues.len() > limit;\nlet assigned_issues = assigned_issues.into_iter().take(limit).collect();\n// ... same for all 4 sections\n```\n\n### WorkloadResult struct:\n```rust\npub struct WorkloadResult {\n    pub username: String,\n    pub assigned_issues: Vec<WorkloadIssue>,\n    pub authored_mrs: Vec<WorkloadMr>,\n    pub reviewing_mrs: Vec<WorkloadMr>,\n    pub unresolved_discussions: Vec<WorkloadDiscussion>,\n    pub assigned_issues_truncated: bool,\n    pub authored_mrs_truncated: bool,\n    pub reviewing_mrs_truncated: bool,\n    pub unresolved_discussions_truncated: bool,\n}\n```\n\n## Files\n\n- `src/cli/commands/who.rs`\n\n## TDD Loop\n\nRED: `test_workload_query` — insert project, issue+assignee, MR; verify assigned_issues.len()=1, authored_mrs.len()=1\nGREEN: Implement all 4 queries with prepare_cached()\nVERIFY: `cargo test -- workload`\n\n## Acceptance Criteria\n\n- [ ] test_workload_query passes\n- [ ] Canonical refs contain project path (group/project#iid format)\n- [ ] since_ms=None means no time filtering (all open items returned)\n- [ ] All 4 sections have independent truncation flags\n\n## Edge Cases\n\n- since_ms is Option<i64> (not i64) — Workload is the only mode with optional time window\n- Discussions: --since filters on d.last_note_at (recent activity), not creation time\n- Reviewing MRs: include m.author_username in output (who wrote the MR being reviewed)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-08T02:40:27.800169Z","created_by":"tayloreernisse","updated_at":"2026-02-08T04:10:29.597273Z","closed_at":"2026-02-08T04:10:29.597228Z","close_reason":"Implemented by agent team: migration 017, CLI skeleton, all 5 query modes, human+robot output, 20 tests. All quality gates pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-s3rc","depends_on_id":"bd-2ldg","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-s3rc","depends_on_id":"bd-34rr","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-ser","title":"Implement MR ingestion module","description":"## Background\nMR ingestion module with cursor-based sync. Follows the same pattern as issue ingestion from CP1. Discussion sync eligibility is determined via DB query AFTER ingestion (not in-memory collection) to avoid memory growth on large projects.\n\n## Approach\nCreate `src/ingestion/merge_requests.rs` with:\n1. `IngestMergeRequestsResult` - Aggregated stats\n2. `ingest_merge_requests()` - Main ingestion function\n3. `upsert_merge_request()` - Single MR upsert\n4. Helper functions for labels, assignees, reviewers, cursor management\n\n## Files\n- `src/ingestion/merge_requests.rs` - New module\n- `src/ingestion/mod.rs` - Export new module\n- `tests/mr_ingestion_tests.rs` - Integration tests\n\n## Acceptance Criteria\n- [ ] `IngestMergeRequestsResult` has: fetched, upserted, labels_created, assignees_linked, reviewers_linked\n- [ ] `ingest_merge_requests()` returns `Result<IngestMergeRequestsResult>`\n- [ ] Page-boundary cursor updates (not item-count modulo)\n- [ ] Tuple-based cursor filtering: `(updated_at, gitlab_id)`\n- [ ] Transaction per MR for atomicity\n- [ ] Raw payload stored for each MR\n- [ ] Labels: clear-and-relink pattern (removes stale)\n- [ ] Assignees: clear-and-relink pattern\n- [ ] Reviewers: clear-and-relink pattern\n- [ ] `reset_discussion_watermarks()` for --full sync\n- [ ] `cargo test mr_ingestion` passes\n\n## TDD Loop\nRED: `cargo test ingest_mr` -> module not found\nGREEN: Add ingestion module with full logic\nVERIFY: `cargo test mr_ingestion`\n\n## Main Function Signature\n```rust\npub async fn ingest_merge_requests(\n    conn: &Connection,\n    client: &GitLabClient,\n    config: &Config,\n    project_id: i64,           // Local DB project ID\n    gitlab_project_id: i64,    // GitLab project ID\n    full_sync: bool,           // Reset cursor if true\n) -> Result<IngestMergeRequestsResult>\n```\n\n## Ingestion Loop (page-based)\n```rust\nlet mut page = 1u32;\nloop {\n    let page_result = client.fetch_merge_requests_page(...).await?;\n    \n    for mr in &page_result.items {\n        // Tuple cursor filtering\n        if let (Some(cursor_ts), Some(cursor_id)) = (cursor_updated_at, cursor_gitlab_id) {\n            if mr_updated_at < cursor_ts { continue; }\n            if mr_updated_at == cursor_ts && mr.id <= cursor_id { continue; }\n        }\n        \n        // Begin transaction\n        let tx = conn.unchecked_transaction()?;\n        \n        // Store raw payload\n        let payload_id = store_payload(&tx, ...)?;\n        \n        // Transform and upsert\n        let transformed = transform_merge_request(&mr, project_id)?;\n        let upsert_result = upsert_merge_request(&tx, &transformed.merge_request, payload_id)?;\n        \n        // Clear-and-relink labels\n        clear_mr_labels(&tx, local_mr_id)?;\n        for label in &labels { ... }\n        \n        // Clear-and-relink assignees\n        clear_mr_assignees(&tx, local_mr_id)?;\n        for username in &transformed.assignee_usernames { ... }\n        \n        // Clear-and-relink reviewers\n        clear_mr_reviewers(&tx, local_mr_id)?;\n        for username in &transformed.reviewer_usernames { ... }\n        \n        tx.commit()?;\n        \n        // Track for cursor\n        last_updated_at = Some(mr_updated_at);\n        last_gitlab_id = Some(mr.id);\n    }\n    \n    // Page-boundary cursor flush\n    if let (Some(updated_at), Some(gitlab_id)) = (last_updated_at, last_gitlab_id) {\n        update_cursor(conn, project_id, \"merge_requests\", updated_at, gitlab_id)?;\n    }\n    \n    if page_result.is_last_page { break; }\n    page = page_result.next_page.unwrap_or(page + 1);\n}\n```\n\n## Full Sync Watermark Reset\n```rust\nfn reset_discussion_watermarks(conn: &Connection, project_id: i64) -> Result<()> {\n    conn.execute(\n        \"UPDATE merge_requests\n         SET discussions_synced_for_updated_at = NULL,\n             discussions_sync_attempts = 0,\n             discussions_sync_last_error = NULL\n         WHERE project_id = ?\",\n        [project_id],\n    )?;\n    Ok(())\n}\n```\n\n## DB Helper Functions\n- `get_cursor(conn, project_id) -> (Option<i64>, Option<i64>)` - Get (updated_at, gitlab_id)\n- `update_cursor(conn, project_id, resource_type, updated_at, gitlab_id)`\n- `reset_cursor(conn, project_id, resource_type)`\n- `upsert_merge_request(conn, mr, payload_id) -> Result<UpsertMergeRequestResult>`\n- `clear_mr_labels(conn, mr_id)`\n- `link_mr_label(conn, mr_id, label_id)`\n- `clear_mr_assignees(conn, mr_id)`\n- `upsert_mr_assignee(conn, mr_id, username)`\n- `clear_mr_reviewers(conn, mr_id)`\n- `upsert_mr_reviewer(conn, mr_id, username)`\n\n## Edge Cases\n- Cursor rewind may cause refetch of already-seen MRs (tuple filtering handles this)\n- Large projects: 10k+ MRs - page-based cursor prevents massive refetch on crash\n- Labels/assignees/reviewers may change - clear-and-relink ensures correctness","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-26T22:06:41.967459Z","created_by":"tayloreernisse","updated_at":"2026-01-27T00:15:24.526208Z","closed_at":"2026-01-27T00:15:24.526142Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-ser","depends_on_id":"bd-34o","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-ser","depends_on_id":"bd-3ir","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-ser","depends_on_id":"bd-iba","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-sqw","title":"Add Resource Events API endpoints to GitLab client","description":"## Background\nNeed paginated fetching of state/label/milestone events per entity from GitLab Resource Events APIs. The existing client uses reqwest with rate limiting and has stream_issues/stream_merge_requests patterns for paginated endpoints. However, resource events are per-entity (not project-wide), so they should return Vec rather than use streaming.\n\nExisting pagination pattern in client.rs: follow Link headers with per_page=100.\n\n## Approach\nAdd to src/gitlab/client.rs a generic helper and 6 endpoint methods:\n\n1. Generic paginated fetch helper (if not already present):\n```rust\nasync fn fetch_all_pages<T: DeserializeOwned>(&self, url: &str) -> Result<Vec<T>> {\n    let mut results = Vec::new();\n    let mut next_url = Some(url.to_string());\n    while let Some(current_url) = next_url {\n        self.rate_limiter.lock().unwrap().wait();\n        let resp = self.client.get(&current_url)\n            .header(\"PRIVATE-TOKEN\", &self.token)\n            .query(&[(\"per_page\", \"100\")])\n            .send().await?;\n        // ... parse Link header for next page\n        let page: Vec<T> = resp.json().await?;\n        results.extend(page);\n        next_url = parse_next_link(&resp_headers);\n    }\n    Ok(results)\n}\n```\n\n2. Six endpoint methods:\n```rust\npub async fn fetch_issue_state_events(&self, project_id: i64, iid: i64) -> Result<Vec<GitLabStateEvent>>\npub async fn fetch_issue_label_events(&self, project_id: i64, iid: i64) -> Result<Vec<GitLabLabelEvent>>\npub async fn fetch_issue_milestone_events(&self, project_id: i64, iid: i64) -> Result<Vec<GitLabMilestoneEvent>>\npub async fn fetch_mr_state_events(&self, project_id: i64, iid: i64) -> Result<Vec<GitLabStateEvent>>\npub async fn fetch_mr_label_events(&self, project_id: i64, iid: i64) -> Result<Vec<GitLabLabelEvent>>\npub async fn fetch_mr_milestone_events(&self, project_id: i64, iid: i64) -> Result<Vec<GitLabMilestoneEvent>>\n```\n\nURL patterns:\n- Issues: `/api/v4/projects/{project_id}/issues/{iid}/resource_{type}_events`\n- MRs: `/api/v4/projects/{project_id}/merge_requests/{iid}/resource_{type}_events`\n\n3. Consider a convenience method that fetches all 3 event types for an entity in one call:\n```rust\npub async fn fetch_all_resource_events(&self, project_id: i64, entity_type: &str, iid: i64) \n    -> Result<(Vec<GitLabStateEvent>, Vec<GitLabLabelEvent>, Vec<GitLabMilestoneEvent>)>\n```\n\n## Acceptance Criteria\n- [ ] All 6 endpoints construct correct URLs\n- [ ] Pagination follows Link headers (handles entities with >100 events)\n- [ ] Rate limiter respected for each page request\n- [ ] 404 returns GitLabNotFound error (entity may have been deleted)\n- [ ] Network errors wrapped in GitLabNetworkError\n- [ ] Types from bd-2fm used for deserialization\n\n## Files\n- src/gitlab/client.rs (add methods + optionally generic helper)\n\n## TDD Loop\nRED: Add to tests/gitlab_client_tests.rs (or new file):\n- `test_fetch_issue_state_events_url` - verify URL construction (mock or inspect)\n- `test_fetch_mr_label_events_url` - verify URL construction\n- Note: Full integration tests require a mock HTTP server (mockito or wiremock). If the project doesn't already have one, write URL-construction unit tests only.\n\nGREEN: Implement the 6 methods using the generic helper\n\nVERIFY: `cargo test gitlab_client -- --nocapture && cargo build`\n\n## Edge Cases\n- project_id here is the GitLab project ID (not local DB id) — callers must pass gitlab_project_id\n- Empty results (new entity with no events) should return Ok(Vec::new()), not error\n- GitLab returns 403 for projects where Resource Events API is disabled — map to appropriate error\n- Very old entities may have thousands of events — pagination is essential\n- Rate limiter must be called per-page, not per-entity","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:31:24.137296Z","created_by":"tayloreernisse","updated_at":"2026-02-03T16:19:18.432602Z","closed_at":"2026-02-03T16:19:18.432559Z","close_reason":"Added fetch_all_pages<T> generic paginator, 6 per-entity endpoint methods (state/label/milestone for issues and MRs), and fetch_all_resource_events convenience method in src/gitlab/client.rs.","compaction_level":0,"original_size":0,"labels":["api","gate-1","phase-b"],"dependencies":[{"issue_id":"bd-sqw","depends_on_id":"bd-2fm","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-sqw","depends_on_id":"bd-2zl","type":"parent-child","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-tfh3","title":"WHO: Comprehensive test suite","description":"## Background\n\n20+ tests covering mode resolution, path query construction, SQL queries, and edge cases. All tests use in-memory SQLite with run_migrations().\n\n## Approach\n\n### Test helpers (shared across all tests):\n```rust\nfn setup_test_db() -> Connection {\n    let conn = create_connection(Path::new(\":memory:\")).unwrap();\n    run_migrations(&conn).unwrap();\n    conn\n}\nfn insert_project(conn, id, path) // gitlab_project_id=id*100, web_url from path\nfn insert_mr(conn, id, project_id, iid, author, state) // gitlab_id=id*10, timestamps=now_ms()\nfn insert_issue(conn, id, project_id, iid, author) // state='opened'\nfn insert_discussion(conn, id, project_id, mr_id, issue_id, resolvable, resolved)\n#[allow(clippy::too_many_arguments)]\nfn insert_diffnote(conn, id, discussion_id, project_id, author, file_path, body)\nfn insert_assignee(conn, issue_id, username)\nfn insert_reviewer(conn, mr_id, username)\n```\n\n### Test list with key assertions:\n\n**Mode resolution:**\n- test_is_file_path_discrimination: src/auth/ -> Expert, asmith -> Workload, @asmith -> Workload, asmith+--reviews -> Reviews, --path README.md -> Expert, --path Makefile -> Expert\n\n**Path queries:**\n- test_build_path_query: trailing/ -> prefix, no-dot-no-slash -> prefix, file.ext -> exact, root.md -> exact, .github/workflows/ -> prefix, v1.2/auth/ -> prefix, test_files/ -> escaped prefix\n- test_build_path_query_exact_does_not_escape: README_with_underscore.md -> raw (no \\\\_)\n- test_path_flag_dotless_root_file_is_exact: Makefile -> exact, Dockerfile -> exact\n- test_build_path_query_dotless_subdir_file_uses_db_probe: src/Dockerfile with DB data -> exact; without -> prefix\n- test_build_path_query_probe_is_project_scoped: data in proj 1, unscoped -> exact; scoped proj 2 -> prefix; scoped proj 1 -> exact\n- test_escape_like: normal->normal, has_underscore->has\\\\_underscore, has%percent->has\\\\%percent\n- test_normalize_repo_path: ./src/ -> src/, /src/ -> src/, ././src -> src, backslash conversion, // collapse, whitespace trim\n\n**Queries:**\n- test_expert_query: 3 experts ranked correctly, reviewer_b first\n- test_expert_excludes_self_review_notes: author_a review_mr_count=0, author_mr_count>0\n- test_expert_truncation: limit=2 truncated=true len=2; limit=10 truncated=false\n- test_workload_query: assigned_issues.len()=1, authored_mrs.len()=1\n- test_reviews_query: total=3, categorized=2, categories.len()=2\n- test_normalize_review_prefix: suggestion/Suggestion:/nit/nitpick/non-blocking/TODO\n- test_active_query: total=1, discussions.len()=1, note_count=2 (NOT 1), discussion_id>0\n- test_active_participants_sorted: [\"alpha_user\", \"zebra_user\"]\n- test_overlap_dual_roles: A+R role, both touch counts >0, mr_refs contain project path\n- test_overlap_multi_project_mr_refs: team/backend!100 AND team/frontend!100 present\n- test_overlap_excludes_self_review_notes: review_touch_count=0\n- test_lookup_project_path: round-trip \"team/backend\"\n\n## Files\n\n- `src/cli/commands/who.rs` (inside #[cfg(test)] mod tests)\n\n## TDD Loop\n\nTests are written alongside each query bead (RED phase). This bead tracks the full test suite as a verification gate.\nVERIFY: `cargo test -- who`\n\n## Acceptance Criteria\n\n- [ ] All 20+ tests pass\n- [ ] cargo test -- who shows 0 failures\n- [ ] No clippy warnings from test code (use #[allow(clippy::too_many_arguments)] on insert_diffnote)\n\n## Edge Cases\n\n- In-memory DB includes migration 017 (indexes created but no real data perf benefit)\n- Test timestamps use now_ms() — tests are time-independent (since_ms=0 in most queries)\n- insert_mr uses gitlab_id=id*10 to avoid conflicts","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-08T02:41:25.839065Z","created_by":"tayloreernisse","updated_at":"2026-02-08T04:10:29.601284Z","closed_at":"2026-02-08T04:10:29.601248Z","close_reason":"Implemented by agent team: migration 017, CLI skeleton, all 5 query modes, human+robot output, 20 tests. All quality gates pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-tfh3","depends_on_id":"bd-1rdi","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-tfh3","depends_on_id":"bd-2711","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-tfh3","depends_on_id":"bd-3mj2","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-tfh3","depends_on_id":"bd-b51e","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-tfh3","depends_on_id":"bd-m7k1","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-tfh3","depends_on_id":"bd-s3rc","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-tfh3","depends_on_id":"bd-zqpf","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-tir","title":"Implement generic dependent fetch queue (enqueue + drain)","description":"## Background\nThe pending_dependent_fetches table (migration 011) provides a generic job queue for all dependent resource fetches across Gates 1, 2, and 4. This module implements the queue operations: enqueue, claim, complete, fail, and stale lock reclamation. It generalizes the existing discussion_queue.rs pattern.\n\n## Approach\nCreate src/core/dependent_queue.rs with:\n\n```rust\nuse rusqlite::Connection;\nuse super::error::Result;\n\n/// A pending job from the dependent fetch queue.\npub struct PendingJob {\n    pub id: i64,\n    pub project_id: i64,\n    pub entity_type: String,      // \"issue\" | \"merge_request\"\n    pub entity_iid: i64,\n    pub entity_local_id: i64,\n    pub job_type: String,         // \"resource_events\" | \"mr_closes_issues\" | \"mr_diffs\"\n    pub payload_json: Option<String>,\n    pub attempts: i32,\n}\n\n/// Enqueue a dependent fetch job. Idempotent via UNIQUE constraint (INSERT OR IGNORE).\npub fn enqueue_job(\n    conn: &Connection,\n    project_id: i64,\n    entity_type: &str,\n    entity_iid: i64,\n    entity_local_id: i64,\n    job_type: &str,\n    payload_json: Option<&str>,\n) -> Result<bool>  // returns true if actually inserted (not deduped)\n\n/// Claim a batch of jobs for processing. Atomically sets locked_at.\n/// Only claims jobs where locked_at IS NULL AND (next_retry_at IS NULL OR next_retry_at <= now).\npub fn claim_jobs(\n    conn: &Connection,\n    job_type: &str,\n    batch_size: usize,\n) -> Result<Vec<PendingJob>>\n\n/// Mark a job as complete (DELETE the row).\npub fn complete_job(conn: &Connection, job_id: i64) -> Result<()>\n\n/// Mark a job as failed. Increment attempts, set next_retry_at with exponential backoff, clear locked_at.\n/// Backoff: 30s * 2^(attempts-1), capped at 480s.\npub fn fail_job(conn: &Connection, job_id: i64, error: &str) -> Result<()>\n\n/// Reclaim stale locks (locked_at older than threshold).\n/// Returns count of reclaimed jobs.\npub fn reclaim_stale_locks(conn: &Connection, stale_threshold_minutes: u32) -> Result<usize>\n\n/// Count pending jobs by job_type (for stats/progress).\npub fn count_pending_jobs(conn: &Connection) -> Result<HashMap<String, usize>>\n```\n\nRegister in src/core/mod.rs: `pub mod dependent_queue;`\n\n**Key implementation details:**\n- claim_jobs uses a two-step approach: SELECT ids WHERE available, then UPDATE SET locked_at for those ids. Use a single transaction.\n- enqueued_at = current time in ms epoch UTC\n- locked_at = current time in ms epoch UTC when claimed\n- Backoff formula: next_retry_at = now + min(30_000 * 2^(attempts-1), 480_000) ms\n\n## Acceptance Criteria\n- [ ] enqueue_job is idempotent (INSERT OR IGNORE on UNIQUE constraint)\n- [ ] enqueue_job returns true on insert, false on dedup\n- [ ] claim_jobs only claims unlocked, non-retrying jobs\n- [ ] claim_jobs respects batch_size limit\n- [ ] complete_job DELETEs the row\n- [ ] fail_job increments attempts, sets next_retry_at, clears locked_at, records last_error\n- [ ] Backoff: 30s, 60s, 120s, 240s, 480s (capped)\n- [ ] reclaim_stale_locks clears locked_at for jobs older than threshold\n- [ ] count_pending_jobs returns accurate counts by job_type\n\n## Files\n- src/core/dependent_queue.rs (new)\n- src/core/mod.rs (add `pub mod dependent_queue;`)\n\n## TDD Loop\nRED: tests/dependent_queue_tests.rs (new):\n- `test_enqueue_job_basic` - enqueue a job, verify it exists\n- `test_enqueue_job_idempotent` - enqueue same job twice, verify single row\n- `test_claim_jobs_batch` - enqueue 5, claim 3, verify 3 returned and locked\n- `test_claim_jobs_skips_locked` - lock a job, claim again, verify it's skipped\n- `test_claim_jobs_respects_retry_at` - set next_retry_at in future, verify skipped\n- `test_claim_jobs_includes_retryable` - set next_retry_at in past, verify claimed\n- `test_complete_job_deletes` - complete a job, verify gone\n- `test_fail_job_backoff` - fail 3 times, verify exponential next_retry_at values\n- `test_reclaim_stale_locks` - set old locked_at, reclaim, verify cleared\n\nSetup: create_test_db() with migrations 001-011, seed project + issue.\n\nGREEN: Implement all functions\n\nVERIFY: `cargo test dependent_queue -- --nocapture`\n\n## Edge Cases\n- claim_jobs with batch_size=0 should return empty vec (not error)\n- enqueue_job with invalid job_type will be rejected by CHECK constraint — map rusqlite error to LoreError\n- fail_job on a non-existent job_id should be a no-op (job may have been completed by another path)\n- reclaim_stale_locks with 0 threshold would reclaim everything — ensure threshold is reasonable (minimum 1 min)\n- Timestamps must use consistent ms epoch UTC (not seconds)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:31:57.290181Z","created_by":"tayloreernisse","updated_at":"2026-02-03T16:19:14.222626Z","closed_at":"2026-02-03T16:19:14.222579Z","close_reason":"Implemented PendingJob struct, enqueue_job, claim_jobs, complete_job, fail_job (with exponential backoff), reclaim_stale_locks, count_pending_jobs in src/core/dependent_queue.rs.","compaction_level":0,"original_size":0,"labels":["gate-1","phase-b","queue"],"dependencies":[{"issue_id":"bd-tir","depends_on_id":"bd-2zl","type":"parent-child","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-tir","depends_on_id":"bd-hu3","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-tiux","title":"Add sync_runs migration 027 for surgical mode columns","description":"## Background\nThe `sync_runs` table (created in migration 001, enriched in 014) tracks sync run lifecycle for observability and crash recovery. Surgical sync needs additional columns to track its distinct mode, phase progression, IID targeting, and per-stage counters. This is a schema-only change — no Rust struct changes beyond registering the migration SQL file.\n\nThe migration system uses a `MIGRATIONS` array in `src/core/db.rs`. Each entry is a `(version, sql_file_name)` tuple. SQL files live in `src/core/migrations/`. The current latest migration is 026 (`026_scoring_indexes.sql`), so this will be migration 027. `LATEST_SCHEMA_VERSION` is computed as `MIGRATIONS.len() as i32` and automatically becomes 27.\n\n## Approach\n\n### Step 1: Create migration SQL file: `src/core/migrations/027_surgical_sync_runs.sql`\n\n```sql\n-- Migration 027: Extend sync_runs for surgical sync observability\n-- Adds mode/phase tracking and surgical-specific counters.\n\nALTER TABLE sync_runs ADD COLUMN mode TEXT;\nALTER TABLE sync_runs ADD COLUMN phase TEXT;\nALTER TABLE sync_runs ADD COLUMN surgical_iids_json TEXT;\nALTER TABLE sync_runs ADD COLUMN issues_fetched INTEGER NOT NULL DEFAULT 0;\nALTER TABLE sync_runs ADD COLUMN mrs_fetched INTEGER NOT NULL DEFAULT 0;\nALTER TABLE sync_runs ADD COLUMN issues_ingested INTEGER NOT NULL DEFAULT 0;\nALTER TABLE sync_runs ADD COLUMN mrs_ingested INTEGER NOT NULL DEFAULT 0;\nALTER TABLE sync_runs ADD COLUMN skipped_stale INTEGER NOT NULL DEFAULT 0;\nALTER TABLE sync_runs ADD COLUMN docs_regenerated INTEGER NOT NULL DEFAULT 0;\nALTER TABLE sync_runs ADD COLUMN docs_embedded INTEGER NOT NULL DEFAULT 0;\nALTER TABLE sync_runs ADD COLUMN warnings_count INTEGER NOT NULL DEFAULT 0;\nALTER TABLE sync_runs ADD COLUMN cancelled_at INTEGER;\n\nCREATE INDEX IF NOT EXISTS idx_sync_runs_mode_started\n    ON sync_runs(mode, started_at DESC);\nCREATE INDEX IF NOT EXISTS idx_sync_runs_status_phase_started\n    ON sync_runs(status, phase, started_at DESC);\n```\n\n**Column semantics:**\n- `mode`: \"standard\" or \"surgical\" (NULL for pre-migration rows)\n- `phase`: preflight, ingest, dependents, docs, embed, done, failed, cancelled\n- `surgical_iids_json`: JSON like `{\"issues\":[7,8],\"mrs\":[101]}`\n- Counter columns: integers with DEFAULT 0 for backward compat\n- `cancelled_at`: ms-epoch timestamp, NULL unless cancelled\n\n### Step 2: Register in MIGRATIONS array (src/core/db.rs)\n\nAdd to the `MIGRATIONS` array (currently 26 entries ending with `026_scoring_indexes.sql`):\n\n```rust\n(27, include_str!(\"migrations/027_surgical_sync_runs.sql\")),\n```\n\n## Acceptance Criteria\n- [ ] File `src/core/migrations/027_surgical_sync_runs.sql` exists with all ALTER TABLE and CREATE INDEX statements\n- [ ] Migration 027 is registered in MIGRATIONS array in `src/core/db.rs`\n- [ ] `LATEST_SCHEMA_VERSION` evaluates to 27\n- [ ] Migration runs successfully on fresh databases (in-memory test)\n- [ ] Pre-existing sync_runs rows are unaffected (NULL mode/phase, 0 counters)\n- [ ] New columns accept expected values via INSERT and SELECT round-trip\n- [ ] NULL defaults work for mode, phase, surgical_iids_json, cancelled_at\n- [ ] DEFAULT 0 works for all counter columns\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo test` passes (all migration tests use in-memory DB)\n\n## Files\n- CREATE: src/core/migrations/027_surgical_sync_runs.sql\n- MODIFY: src/core/db.rs (add entry to MIGRATIONS array)\n\n## TDD Anchor\nRED: Write tests in `src/core/sync_run_tests.rs` (which is already `#[path]`-included from `sync_run.rs`):\n\n```rust\n#[test]\nfn sync_run_surgical_columns_exist() {\n    let conn = setup_test_db();\n    conn.execute(\n        \"INSERT INTO sync_runs (started_at, heartbeat_at, status, command, mode, phase, surgical_iids_json)\n         VALUES (1000, 1000, 'running', 'sync', 'surgical', 'preflight', '{\\\"issues\\\":[7],\\\"mrs\\\":[]}')\",\n        [],\n    ).unwrap();\n    let (mode, phase, iids_json): (String, String, String) = conn.query_row(\n        \"SELECT mode, phase, surgical_iids_json FROM sync_runs WHERE mode = 'surgical'\",\n        [],\n        |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),\n    ).unwrap();\n    assert_eq!(mode, \"surgical\");\n    assert_eq!(phase, \"preflight\");\n    assert!(iids_json.contains(\"7\"));\n}\n\n#[test]\nfn sync_run_counter_defaults_are_zero() {\n    let conn = setup_test_db();\n    conn.execute(\n        \"INSERT INTO sync_runs (started_at, heartbeat_at, status, command)\n         VALUES (2000, 2000, 'running', 'sync')\",\n        [],\n    ).unwrap();\n    let row_id = conn.last_insert_rowid();\n    let (issues_fetched, mrs_fetched, docs_regenerated, warnings_count): (i64, i64, i64, i64) = conn.query_row(\n        \"SELECT issues_fetched, mrs_fetched, docs_regenerated, warnings_count FROM sync_runs WHERE id = ?1\",\n        [row_id],\n        |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),\n    ).unwrap();\n    assert_eq!(issues_fetched, 0);\n    assert_eq!(mrs_fetched, 0);\n    assert_eq!(docs_regenerated, 0);\n    assert_eq!(warnings_count, 0);\n}\n\n#[test]\nfn sync_run_nullable_columns_default_to_null() {\n    let conn = setup_test_db();\n    conn.execute(\n        \"INSERT INTO sync_runs (started_at, heartbeat_at, status, command)\n         VALUES (3000, 3000, 'running', 'sync')\",\n        [],\n    ).unwrap();\n    let row_id = conn.last_insert_rowid();\n    let (mode, phase, cancelled_at): (Option<String>, Option<String>, Option<i64>) = conn.query_row(\n        \"SELECT mode, phase, cancelled_at FROM sync_runs WHERE id = ?1\",\n        [row_id],\n        |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),\n    ).unwrap();\n    assert!(mode.is_none());\n    assert!(phase.is_none());\n    assert!(cancelled_at.is_none());\n}\n\n#[test]\nfn sync_run_counter_round_trip() {\n    let conn = setup_test_db();\n    conn.execute(\n        \"INSERT INTO sync_runs (started_at, heartbeat_at, status, command, mode, issues_fetched, mrs_ingested, docs_embedded)\n         VALUES (4000, 4000, 'succeeded', 'sync', 'surgical', 3, 2, 5)\",\n        [],\n    ).unwrap();\n    let row_id = conn.last_insert_rowid();\n    let (issues_fetched, mrs_ingested, docs_embedded): (i64, i64, i64) = conn.query_row(\n        \"SELECT issues_fetched, mrs_ingested, docs_embedded FROM sync_runs WHERE id = ?1\",\n        [row_id],\n        |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),\n    ).unwrap();\n    assert_eq!(issues_fetched, 3);\n    assert_eq!(mrs_ingested, 2);\n    assert_eq!(docs_embedded, 5);\n}\n```\n\nGREEN: Create the SQL file and register the migration.\nVERIFY: `cargo test sync_run_surgical && cargo test sync_run_counter && cargo test sync_run_nullable`\n\n## Edge Cases\n- SQLite ALTER TABLE ADD COLUMN requires DEFAULT for NOT NULL columns. All counter columns use `DEFAULT 0`.\n- mode/phase/surgical_iids_json/cancelled_at are nullable TEXT/INTEGER — no DEFAULT needed.\n- Pre-migration rows get NULL for new nullable columns and 0 for counter columns — backward compatible.\n- The indexes (`idx_sync_runs_mode_started`, `idx_sync_runs_status_phase_started`) use `IF NOT EXISTS` for idempotency.\n\n## Dependency Context\nThis is a leaf/foundation bead with no upstream dependencies. Downstream bead bd-arka (SyncRunRecorder extensions) depends on these columns existing to write surgical mode lifecycle data.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-17T19:13:19.914672Z","created_by":"tayloreernisse","updated_at":"2026-02-18T21:04:58.565943Z","closed_at":"2026-02-18T21:04:58.565894Z","close_reason":"Completed: all implementation work done, code reviewed, tests passing","compaction_level":0,"original_size":0,"labels":["surgical-sync"],"dependencies":[{"issue_id":"bd-tiux","depends_on_id":"bd-1i4i","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-u7se","title":"Implement Who screen (5 modes: expert/workload/reviews/active/overlap)","description":"## Background\nThe Who screen is the people explorer, showing contributor expertise and workload across 5 modes. Each mode renders differently: Expert shows file-path expertise scores, Workload shows issue/MR assignment counts, Reviews shows review activity, Active shows recent contributors, Overlap shows shared file knowledge.\n\n## Approach\nState (state/who.rs):\n- WhoState: mode (WhoMode), results (WhoResult), path (String), path_input (TextInput), path_focused (bool), selected_index (usize)\n- WhoMode: Expert, Workload, Reviews, Active, Overlap\n- WhoResult: variant per mode with different data shapes\n\nAction (action.rs):\n- fetch_who(conn, mode, path, limit) -> Result<WhoResult, LoreError>: dispatches to existing who query functions in lore CLI (query_experts, query_workload, etc.)\n\nView (view/who.rs):\n- Mode tabs at top: E(xpert) | W(orkload) | R(eviews) | A(ctive) | O(verlap)\n- Expert: path input + sorted table of authors by expertise score + bar chart\n- Workload: stacked bar chart of open issues/MRs per person\n- Reviews: table of review counts (given/received) per person\n- Active: time-sorted list of recent contributors\n- Overlap: matrix or pair-wise table showing shared file knowledge\n- Keyboard: 1-5 or Tab to switch modes, j/k scroll, / focus path input\n\n## Acceptance Criteria\n- [ ] 5 modes switchable via Tab or number keys\n- [ ] Expert mode: path input filters by file path, shows expertise scores\n- [ ] Workload mode: shows assignment counts per person\n- [ ] Reviews mode: shows review activity counts\n- [ ] Active mode: shows recent contributors sorted by activity\n- [ ] Overlap mode: shows shared knowledge between contributors\n- [ ] Each mode renders appropriate visualization\n- [ ] Enter on a person navigates to their issues (scoped issue list)\n\n## Files\n- MODIFY: crates/lore-tui/src/state/who.rs (expand from stub)\n- MODIFY: crates/lore-tui/src/action.rs (add fetch_who)\n- CREATE: crates/lore-tui/src/view/who.rs\n\n## TDD Anchor\nRED: Write test_fetch_who_expert that creates notes with diff paths, calls fetch_who(Expert, \"src/\"), asserts authors sorted by expertise score.\nGREEN: Implement fetch_who dispatching to existing who queries.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_fetch_who\n\n## Edge Cases\n- Empty results for a mode: show \"No data for this mode\" message\n- Expert mode with no diff notes: explain that expert data requires diff notes to be synced\n- Very long file paths in Expert mode: truncate from left (show ...path/to/file.rs)\n\n## Dependency Context\nUses existing who query functions from src/cli/commands/who.rs (made pub).\nUses WhoState from \"Implement AppState composition\" task.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T17:01:22.734056Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:24.249877Z","deleted_at":"2026-03-11T18:34:24.249870Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-utt4","title":"Define MeArgs struct and register me subcommand","description":"## Background\nThe `lore me` command needs a CLI argument struct and handler wiring. The existing pattern is `WhoArgs` in `src/cli/mod.rs` (line ~964) and `Commands::Who(WhoArgs)` variant. The handler in `src/main.rs` follows: `Some(Commands::Who(args)) => handle_who(cli.config.as_deref(), args, robot_mode)`.\n\n## Approach\n1. Define `MeArgs` in `src/cli/mod.rs` (alongside WhoArgs):\n```rust\n#[derive(Args, Debug)]\npub struct MeArgs {\n    /// Show only issues section\n    #[arg(long)]\n    pub issues: bool,\n    /// Show only MRs section (authored + reviewing)\n    #[arg(long)]\n    pub mrs: bool,\n    /// Show only activity feed\n    #[arg(long)]\n    pub activity: bool,\n    /// Activity window (e.g., \"30d\", \"7d\") — default 30d\n    #[arg(long, default_value = \"30d\")]\n    pub since: String,\n    /// Scope to one project (fuzzy match)\n    #[arg(short, long)]\n    pub project: Option<String>,\n    /// Override configured username\n    #[arg(long)]\n    pub user: Option<String>,\n    /// Show all synced projects (overrides default_project)\n    #[arg(long)]\n    pub all: bool,\n    /// Select output fields (preset: \"minimal\", or comma-separated)\n    #[arg(long, value_delimiter = ',')]\n    pub fields: Option<Vec<String>>,\n}\n```\n\n2. Add variant to Commands enum (around line 114):\n```rust\n/// Personal work dashboard — my issues, MRs, and activity\nMe(MeArgs),\n```\n\n3. Create `src/cli/commands/me/mod.rs` with stub:\n```rust\nuse crate::Config;\nuse crate::cli::MeArgs;\nuse crate::core::error::Result;\n\npub fn handle_me(\n    config_override: Option<&str>,\n    args: MeArgs,\n    robot_mode: bool,\n) -> std::result::Result<(), Box<dyn std::error::Error>> {\n    eprintln!(\"lore me: not yet implemented\");\n    std::process::exit(1);\n}\n```\n\n4. Wire in `src/main.rs` — add match arm (near line ~3164 where handle_who is):\n```rust\nSome(Commands::Me(args)) => handle_me(cli.config.as_deref(), args, robot_mode),\n```\n\n5. Add `pub mod me;` to `src/cli/commands/mod.rs`.\n\n## Acceptance Criteria\n- [ ] `MeArgs` struct defined with all flags: --issues, --mrs, --activity, --since, --project, --user, --all, --fields\n- [ ] --since has default_value \"30d\"\n- [ ] --fields uses value_delimiter=',' for comma-separated list\n- [ ] `Me(MeArgs)` variant in Commands enum\n- [ ] `src/cli/commands/me/mod.rs` exists with stub handler\n- [ ] Handler wired in main.rs match arm\n- [ ] `lore me --help` shows all flags with descriptions\n- [ ] `lore me` runs without panic (stub prints \"not yet implemented\" and exits)\n- [ ] Standard global flags (--robot/-J, --color, --icons) inherited from Cli struct\n- [ ] --project and --all are separate flags (mutual exclusivity is runtime, not clap)\n\n## Files\n- MODIFY: src/cli/mod.rs (MeArgs struct + Commands::Me variant)\n- CREATE: src/cli/commands/me/mod.rs (stub handler)\n- MODIFY: src/cli/commands/mod.rs (add `pub mod me;`)\n- MODIFY: src/main.rs (add match arm calling handle_me)\n\n## TDD Anchor\nRED: Write `test_me_args_parse` that parses `[\"lore\", \"me\", \"--issues\", \"--since\", \"7d\", \"--user\", \"jdoe\"]` via `Cli::try_parse_from` and asserts `issues=true, since=\"7d\", user=Some(\"jdoe\")`.\nGREEN: Define MeArgs struct and Commands variant.\nVERIFY: `cargo test me_args`\n\n## Edge Cases\n- `--since` default must be \"30d\" when not provided (test by parsing without --since)\n- `--fields` with no value should be None, `--fields minimal` should be Some(vec![\"minimal\"])\n- The handler signature matches the who pattern: `(config_override, args, robot_mode)`\n\n## Dependency Context\nUses resolve_username from bd-1f1f (called within the handler when fully implemented).\nPattern reference: WhoArgs at `src/cli/mod.rs:964`, handle_who at `src/main.rs:3164`.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-19T19:35:34.340060Z","created_by":"tayloreernisse","updated_at":"2026-02-20T16:09:13.046435Z","closed_at":"2026-02-20T16:09:13.046391Z","close_reason":"Implemented by lore-me agent swarm","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-utt4","depends_on_id":"bd-1f1f","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-v6i","title":"[CP1] gi ingest --type=issues command","description":"## Background\n\nThe `gi ingest --type=issues` command is the main entry point for issue ingestion. It acquires a single-flight lock, calls the orchestrator for each configured project, and outputs progress/summary to the user.\n\n## Approach\n\n### Module: src/cli/commands/ingest.rs\n\n### Clap Definition\n\n```rust\n#[derive(Args)]\npub struct IngestArgs {\n    /// Resource type to ingest\n    #[arg(long, value_parser = [\"issues\", \"merge_requests\"])]\n    pub r#type: String,\n\n    /// Filter to single project\n    #[arg(long)]\n    pub project: Option<String>,\n\n    /// Override stale sync lock\n    #[arg(long)]\n    pub force: bool,\n}\n```\n\n### Handler Function\n\n```rust\npub async fn handle_ingest(args: IngestArgs, config: &Config) -> Result<()>\n```\n\n### Logic\n\n1. **Acquire single-flight lock**: `acquire_sync_lock(conn, args.force)?`\n2. **Get projects to sync**:\n   - If `args.project` specified, filter to that one\n   - Otherwise, get all configured projects from DB\n3. **For each project**:\n   - Print \"Ingesting issues for {project_path}...\"\n   - Call `ingest_project_issues(conn, client, config, project_id, gitlab_project_id)`\n   - Print \"{N} issues fetched, {M} new labels\"\n4. **Print discussion sync summary**:\n   - \"Fetching discussions ({N} issues with updates)...\"\n   - \"{N} discussions, {M} notes (excluding {K} system notes)\"\n   - \"Skipped discussion sync for {N} unchanged issues.\"\n5. **Release lock**: Lock auto-released when handler returns\n\n### Output Format (matches PRD)\n\n```\nIngesting issues...\n\n  group/project-one: 1,234 issues fetched, 45 new labels\n\nFetching discussions (312 issues with updates)...\n\n  group/project-one: 312 issues → 1,234 discussions, 5,678 notes\n\nTotal: 1,234 issues, 1,234 discussions, 5,678 notes (excluding 1,234 system notes)\nSkipped discussion sync for 922 unchanged issues.\n```\n\n## Acceptance Criteria\n\n- [ ] Clap args parse --type, --project, --force correctly\n- [ ] Single-flight lock acquired before sync starts\n- [ ] Lock error message is clear if concurrent run attempted\n- [ ] Progress output shows per-project counts\n- [ ] Summary includes unchanged issues skipped count\n- [ ] --force flag allows overriding stale lock\n\n## Files\n\n- src/cli/commands/mod.rs (add `pub mod ingest;`)\n- src/cli/commands/ingest.rs (create)\n- src/cli/mod.rs (add Ingest variant to Commands enum)\n\n## TDD Loop\n\nRED:\n```rust\n// tests/cli_ingest_tests.rs\n#[tokio::test] async fn ingest_issues_acquires_lock()\n#[tokio::test] async fn ingest_issues_fails_on_concurrent_run()\n#[tokio::test] async fn ingest_issues_respects_project_filter()\n#[tokio::test] async fn ingest_issues_force_overrides_stale_lock()\n```\n\nGREEN: Implement handler with lock and orchestrator calls\n\nVERIFY: `cargo test cli_ingest`\n\n## Edge Cases\n\n- No projects configured - return early with helpful message\n- Project filter matches nothing - error with \"project not found\"\n- Lock already held - clear error \"Sync already in progress\"\n- Ctrl-C during sync - lock should be released (via Drop or SIGINT handler)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-25T17:02:38.312565Z","created_by":"tayloreernisse","updated_at":"2026-01-25T22:56:44.090142Z","closed_at":"2026-01-25T22:56:44.090086Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-v6i","depends_on_id":"bd-ozy","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-v6tc","title":"Description","description":"This is a test","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T16:52:04.745618Z","updated_at":"2026-02-12T16:52:10.755235Z","closed_at":"2026-02-12T16:52:10.755188Z","close_reason":"test artifacts","compaction_level":0,"original_size":0}
{"id":"bd-wb0b","title":"Task 3: Implement open threads, activity summary, and cross-references","description":"## Background\n\nThis task implements three data-fetching functions for the explain command: open threads (unresolved discussions), activity summary (event/note counts), and cross-references (related entities).\n\n**Parent bead:** bd-9lbr (lore explain feature)\n**Spec:** specs/SPEC_explain.md — Task 3\n**Phase:** 2 — Core Logic\n**Depends on:** Task 1 (bd-2i3z) — needs ExplainParams, ExplainResult types, run_explain skeleton, and test helpers\n\n## Function Signatures\n\n\\`\\`\\`rust\npub fn fetch_open_threads(conn: &Connection, entity_type: &str, entity_id: i64) -> Result<Vec<OpenThread>>\n\npub fn build_activity_summary(\n    conn: &Connection,\n    entity_type: &str,\n    entity_id: i64,\n    since: Option<i64>,\n) -> Result<ActivitySummary>\n\npub fn fetch_related_entities(conn: &Connection, entity_type: &str, entity_id: i64) -> Result<RelatedEntities>\n\npub fn extract_description_excerpt(description: Option<&str>) -> String\n\\`\\`\\`\n\nWire into run_explain():\n\\`\\`\\`rust\nlet description_excerpt = if should_include(&params.sections, \"description\") {\n    Some(extract_description_excerpt(description.as_deref()))\n} else { None };\n\nlet open_threads = if should_include(&params.sections, \"open_threads\") {\n    Some(fetch_open_threads(conn, &params.entity_type, entity_local_id)?)\n} else { None };\n\nlet activity = if should_include(&params.sections, \"activity\") {\n    Some(build_activity_summary(conn, &params.entity_type, entity_local_id, params.since)?)\n} else { None };\n\nlet related = if should_include(&params.sections, \"related\") {\n    Some(fetch_related_entities(conn, &params.entity_type, entity_local_id)?)\n} else { None };\n\\`\\`\\`\n\n## Data Assembly\n\n### Open Threads (fetch_open_threads)\n\n\\`\\`\\`rust\nlet id_col = if entity_type == \"issues\" { \"issue_id\" } else { \"merge_request_id\" };\n\\`\\`\\`\n\nQuery unresolved discussions:\n\\`\\`\\`sql\nSELECT d.id, d.gitlab_discussion_id, d.first_note_at, d.last_note_at\nFROM discussions d\nWHERE d.{id_col} = ?1 AND d.resolvable = 1 AND d.resolved = 0\nORDER BY d.last_note_at DESC\n\\`\\`\\`\n\nFor each discussion row, two sub-queries:\n\nFirst note author:\n\\`\\`\\`sql\nSELECT author_username FROM notes WHERE discussion_id = ?1 ORDER BY created_at ASC LIMIT 1\n\\`\\`\\`\n\nNon-system note count:\n\\`\\`\\`sql\nSELECT COUNT(*) FROM notes WHERE discussion_id = ?1 AND is_system = 0\n\\`\\`\\`\n\nIMPORTANT: discussions.id is the LOCAL SQLite row id. notes.discussion_id references this local id. discussions.gitlab_discussion_id is the GitLab string id for display output.\n\nResult: OpenThread { discussion_id: gitlab_discussion_id, started_by: first note author, started_at: ms_to_iso(first_note_at), note_count, last_note_at: ms_to_iso(last_note_at) }\n\n### Activity Summary (build_activity_summary)\n\nThree aggregate queries:\n\n\\`\\`\\`sql\n-- State events\nSELECT COUNT(*), MIN(created_at), MAX(created_at)\nFROM resource_state_events WHERE {id_col} = ?1 AND (?2 IS NULL OR created_at >= ?2)\n\n-- Label events\nSELECT COUNT(*), MIN(created_at), MAX(created_at)\nFROM resource_label_events WHERE {id_col} = ?1 AND (?2 IS NULL OR created_at >= ?2)\n\n-- Non-system notes\nSELECT COUNT(*), MIN(n.created_at), MAX(n.created_at)\nFROM notes n JOIN discussions d ON n.discussion_id = d.id\nWHERE d.{id_col} = ?1 AND n.is_system = 0 AND (?2 IS NULL OR n.created_at >= ?2)\n\\`\\`\\`\n\nCombine min/max across all three for first_event/last_event:\n\\`\\`\\`rust\nlet first_event = [state_min, label_min, note_min].iter().flatten().copied().min();\nlet last_event = [state_max, label_max, note_max].iter().flatten().copied().max();\n\\`\\`\\`\n\nResult: ActivitySummary { state_changes, label_changes, notes, first_event: first_event.map(ms_to_iso), last_event: last_event.map(ms_to_iso) }\n\n### Cross-References (fetch_related_entities)\n\n**Closing MRs** (only for issues) — copy this exact query from show/issue.rs get_closing_mrs():\n\\`\\`\\`sql\nSELECT mr.iid, mr.title, mr.state, mr.web_url\nFROM entity_references er\nJOIN merge_requests mr ON mr.id = er.source_entity_id\nWHERE er.target_entity_type = 'issue'\n  AND er.target_entity_id = ?\n  AND er.source_entity_type = 'merge_request'\n  AND er.reference_type = 'closes'\nORDER BY mr.iid\n\\`\\`\\`\n\nNOTE: entity_references stores source=MR, target=issue for closing relationships.\n\n**Related entities** — JOIN to get iid+title:\n\\`\\`\\`sql\n-- Outgoing (this entity references others, excluding closes)\nSELECT er.target_entity_type, er.target_entity_iid, er.reference_type,\n       COALESCE(i.title, mr.title) as title\nFROM entity_references er\nLEFT JOIN issues i ON er.target_entity_type = 'issue' AND i.id = er.target_entity_id\nLEFT JOIN merge_requests mr ON er.target_entity_type = 'merge_request' AND mr.id = er.target_entity_id\nWHERE er.source_entity_type = ?1 AND er.source_entity_id = ?2\n  AND er.reference_type != 'closes'\n\n-- Incoming (others reference this entity, excluding closes)\nSELECT er.source_entity_type, COALESCE(i.iid, mr.iid) as iid, er.reference_type,\n       COALESCE(i.title, mr.title) as title\nFROM entity_references er\nLEFT JOIN issues i ON er.source_entity_type = 'issue' AND i.id = er.source_entity_id\nLEFT JOIN merge_requests mr ON er.source_entity_type = 'merge_request' AND mr.id = er.source_entity_id\nWHERE er.target_entity_type = ?1 AND er.target_entity_id = ?2\n  AND er.reference_type != 'closes'\n\\`\\`\\`\n\nSkip closing MRs section when entity_type is \"mrs\" (closing MRs only apply to issues).\n\n### Description Excerpt\n\n\\`\\`\\`rust\npub fn extract_description_excerpt(description: Option<&str>) -> String {\n    match description {\n        Some(d) if !d.trim().is_empty() => truncate(d, 500),\n        _ => \"(no description)\".to_owned(),\n    }\n}\n\\`\\`\\`\n\n## Files to Modify\n\n- src/cli/commands/explain.rs — add fetch_open_threads(), build_activity_summary(), fetch_related_entities(), extract_description_excerpt(), wire into run_explain()\n\n## DB Schema Reference\n\n- discussions: id (local PK), gitlab_discussion_id (TEXT), project_id, issue_id (FK), merge_request_id (FK), noteable_type, individual_note (0/1), first_note_at (ms), last_note_at (ms), last_seen_at, resolvable (0/1), resolved (0/1). CHECK: exactly one of issue_id/merge_request_id NOT NULL.\n- notes: id (local PK), gitlab_id, discussion_id (FK->discussions.id), project_id, note_type, is_system (0/1), author_username, body, created_at (ms), updated_at, last_seen_at\n- entity_references: source_entity_type (issue|merge_request), source_entity_id (FK), target_entity_type, target_entity_id (nullable), target_project_path, target_entity_iid, reference_type (closes|mentioned|related), source_method\n\n## TDD Workflow (Red-Green)\n\n### RED — Write these tests first, confirm they FAIL:\n\nTest helpers (reuse from Task 1; add new insert helpers as needed):\n\n\\`\\`\\`rust\nfn insert_discussion_full(conn: &Connection, issue_id: i64, gitlab_id: &str, resolvable: bool, resolved: bool) -> i64 {\n    conn.execute(\n        \"INSERT INTO discussions (gitlab_discussion_id, project_id, issue_id, noteable_type, individual_note, first_note_at, last_note_at, last_seen_at, resolvable, resolved) VALUES (?1, 1, ?2, 'Issue', 0, 1000000, 2000000, 2000000, ?3, ?4)\",\n        rusqlite::params![gitlab_id, issue_id, resolvable as i32, resolved as i32],\n    ).unwrap();\n    conn.last_insert_rowid()\n}\n\\`\\`\\`\n\nTests:\n1. test_explain_open_threads: Insert 2 discussions (1 unresolved+resolvable, 1 resolved+resolvable), each with 2 notes. Assert only unresolved appears in result, with correct started_by (first note author) and note_count (non-system notes).\n2. test_explain_activity_counts: Insert 3 state events + 2 label events + 10 non-system notes (via discussion), assert activity.state_changes=3, label_changes=2, notes=10. Verify first_event and last_event bracket the correct ms range.\n3. test_explain_no_notes: Insert issue with zero notes, zero events, NULL description. Assert open_threads is empty, activity is all zeros with None timestamps, description_excerpt = \"(no description)\".\n\n### GREEN — Implement the four functions and wire into run_explain()\n\n### Verify:\ncargo test explain::tests::test_explain_open_threads && cargo test explain::tests::test_explain_activity_counts && cargo test explain::tests::test_explain_no_notes && cargo clippy --all-targets -- -D warnings\n\n## Acceptance Criteria\n\n- [ ] test_explain_open_threads passes\n- [ ] test_explain_activity_counts passes\n- [ ] test_explain_no_notes passes\n- [ ] Open threads correctly filtered (only unresolved+resolvable)\n- [ ] Activity counts accurate with correct first/last event timestamps\n- [ ] Cross-references include closing MRs with iid/title/state (for issues)\n- [ ] Empty entity handled gracefully (no panics, sensible defaults)\n- [ ] cargo clippy and cargo fmt clean","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-10T17:37:35.249719Z","created_by":"tayloreernisse","updated_at":"2026-03-10T19:03:29.054858Z","closed_at":"2026-03-10T19:03:29.054782Z","close_reason":"Implemented: all explain command sections (activity, open_threads, related, timeline_excerpt, human/robot renderers) merged into main explain.rs, robot_docs updated, autocorrect registered, 1046 tests passing","compaction_level":0,"original_size":0,"labels":["cli-imp","explain"],"dependencies":[{"issue_id":"bd-wb0b","depends_on_id":"bd-2i3z","type":"blocks","created_at":"2026-03-10T17:38:18.716761Z","created_by":"tayloreernisse"},{"issue_id":"bd-wb0b","depends_on_id":"bd-9lbr","type":"parent-child","created_at":"2026-03-10T17:37:35.251885Z","created_by":"tayloreernisse"}]}
{"id":"bd-wcja","title":"Extend SyncResult with surgical mode fields for robot output","description":"## Background\n\nRobot mode (`--robot`) serializes `SyncResult` as JSON for machine consumers. Currently `SyncResult` (lines 31-52 of `src/cli/commands/sync.rs`) only has fields for normal full sync. Surgical sync needs additional metadata in the JSON response: whether surgical mode was active, which IIDs were requested, per-entity outcomes, and whether it was a preflight-only run. These must be `Option` fields so normal sync serialization is unchanged (serde `skip_serializing_if = \"Option::is_none\"`).\n\n## Approach\n\nAdd four `Option` fields to the existing `SyncResult` struct:\n\n```rust\n#[serde(skip_serializing_if = \"Option::is_none\")]\npub surgical_mode: Option<bool>,\n\n#[serde(skip_serializing_if = \"Option::is_none\")]\npub surgical_iids: Option<SurgicalIids>,\n\n#[serde(skip_serializing_if = \"Option::is_none\")]\npub entity_results: Option<Vec<EntitySyncResult>>,\n\n#[serde(skip_serializing_if = \"Option::is_none\")]\npub preflight_only: Option<bool>,\n```\n\nDefine two new supporting structs in the same file:\n\n```rust\n#[derive(Debug, Default, Serialize)]\npub struct SurgicalIids {\n    pub issues: Vec<u64>,\n    pub merge_requests: Vec<u64>,\n}\n\n#[derive(Debug, Serialize)]\npub struct EntitySyncResult {\n    pub entity_type: String,  // \"issue\" or \"merge_request\"\n    pub iid: u64,\n    pub outcome: String,      // \"synced\", \"skipped_toctou\", \"failed\", \"not_found\"\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub error: Option<String>,\n    #[serde(skip_serializing_if = \"Option::is_none\")]\n    pub toctou_reason: Option<String>,\n}\n```\n\nBecause `SyncResult` derives `Default`, the new `Option` fields default to `None` automatically. Non-surgical callers need zero changes.\n\n## Acceptance Criteria\n\n1. `SyncResult` compiles with all four new `Option` fields\n2. `SurgicalIids` and `EntitySyncResult` are defined with `Serialize` derive\n3. Serializing a `SyncResult` with surgical fields set produces JSON with `surgical_mode`, `surgical_iids`, `entity_results`, `preflight_only` keys\n4. Serializing a default `SyncResult` (all `None`) produces JSON identical to current output (no surgical keys)\n5. `SyncResult::default()` still works without specifying new fields\n6. All existing tests pass unchanged\n\n## Files\n\n- `src/cli/commands/sync.rs` — add fields to `SyncResult`, define `SurgicalIids` and `EntitySyncResult`\n\n## TDD Anchor\n\nAdd a test module or extend the existing one in `src/cli/commands/sync.rs` (or a new `sync_tests.rs` file):\n\n```rust\n#[cfg(test)]\nmod surgical_result_tests {\n    use super::*;\n\n    #[test]\n    fn sync_result_default_omits_surgical_fields() {\n        let result = SyncResult::default();\n        let json = serde_json::to_value(&result).unwrap();\n        assert!(json.get(\"surgical_mode\").is_none());\n        assert!(json.get(\"surgical_iids\").is_none());\n        assert!(json.get(\"entity_results\").is_none());\n        assert!(json.get(\"preflight_only\").is_none());\n    }\n\n    #[test]\n    fn sync_result_with_surgical_fields_serializes_correctly() {\n        let result = SyncResult {\n            surgical_mode: Some(true),\n            surgical_iids: Some(SurgicalIids {\n                issues: vec![7, 42],\n                merge_requests: vec![10],\n            }),\n            entity_results: Some(vec![\n                EntitySyncResult {\n                    entity_type: \"issue\".to_string(),\n                    iid: 7,\n                    outcome: \"synced\".to_string(),\n                    error: None,\n                    toctou_reason: None,\n                },\n                EntitySyncResult {\n                    entity_type: \"issue\".to_string(),\n                    iid: 42,\n                    outcome: \"skipped_toctou\".to_string(),\n                    error: None,\n                    toctou_reason: Some(\"updated_at changed\".to_string()),\n                },\n            ]),\n            preflight_only: Some(false),\n            ..SyncResult::default()\n        };\n        let json = serde_json::to_value(&result).unwrap();\n        assert_eq!(json[\"surgical_mode\"], true);\n        assert_eq!(json[\"surgical_iids\"][\"issues\"], serde_json::json!([7, 42]));\n        assert_eq!(json[\"entity_results\"].as_array().unwrap().len(), 2);\n        assert_eq!(json[\"entity_results\"][1][\"outcome\"], \"skipped_toctou\");\n        assert_eq!(json[\"preflight_only\"], false);\n    }\n\n    #[test]\n    fn entity_sync_result_omits_none_fields() {\n        let entity = EntitySyncResult {\n            entity_type: \"merge_request\".to_string(),\n            iid: 10,\n            outcome: \"synced\".to_string(),\n            error: None,\n            toctou_reason: None,\n        };\n        let json = serde_json::to_value(&entity).unwrap();\n        assert!(json.get(\"error\").is_none());\n        assert!(json.get(\"toctou_reason\").is_none());\n        assert!(json.get(\"entity_type\").is_some());\n    }\n}\n```\n\n## Edge Cases\n\n- `entity_results: Some(vec![])` — empty vec serializes as `[]`, not omitted. This is correct for \"surgical mode ran but had no entities to process.\"\n- `surgical_iids` with empty vecs — valid for edge case where user passes `--issue` but all IIDs are filtered out before sync.\n- Ensure `EntitySyncResult.outcome` uses a fixed set of string values. Consider a future enum, but `String` is fine for initial implementation to keep serialization simple.\n\n## Dependency Context\n\n- **No upstream dependencies** — this bead only adds struct fields, no behavioral changes.\n- **Downstream**: bd-1i4i (orchestrator) populates these fields. bd-3bec (wiring) passes them through.\n- The `#[derive(Default)]` on `SyncResult` means all `Option` fields are `None` by default, so this is a fully additive change.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-17T19:17:03.915330Z","created_by":"tayloreernisse","updated_at":"2026-02-18T21:03:46.649727Z","closed_at":"2026-02-18T21:03:46.649679Z","close_reason":"Completed: SyncResult extended with surgical_mode, surgical_iids, entity_results fields","compaction_level":0,"original_size":0,"labels":["surgical-sync"],"dependencies":[{"issue_id":"bd-wcja","depends_on_id":"bd-1i4i","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-wnuo","title":"Implement performance benchmark fixtures (S/M/L tiers)","description":"## Background\nTiered performance fixtures validate latency at three data scales. S and M tiers are CI-enforced gates; L tier is advisory. Fixtures are synthetic SQLite databases with realistic data distributions.\n\n## Approach\nFixture generator (benches/ or tests/fixtures/):\n- S-tier: 10k issues, 5k MRs, 50k notes, 10k docs\n- M-tier: 100k issues, 50k MRs, 500k notes, 50k docs\n- L-tier: 250k issues, 100k MRs, 1M notes, 100k docs\n- Realistic distributions: state (60% closed, 30% opened, 10% other), authors from pool of 50 names, labels from pool of 20, dates spanning 2 years\n\nBenchmarks:\n- p95 first-paint latency: Dashboard load, Issue List load, MR List load\n- p95 keyset pagination: next page fetch\n- p95 search latency: lexical and hybrid modes\n- Memory ceiling: RSS after full dashboard + list load\n- SLO assertions per tier (see Phase 0 criteria)\n\nRequired indexes must be present in fixture DBs:\n- idx_issues_list_default, idx_mrs_list_default, idx_discussions_entity, idx_notes_discussion\n\n## Acceptance Criteria\n- [ ] S-tier fixture generated with correct counts\n- [ ] M-tier fixture generated with correct counts\n- [ ] L-tier fixture generated (on-demand, not CI)\n- [ ] p95 first-paint < 50ms (S), < 75ms (M), < 150ms (L)\n- [ ] p95 keyset pagination < 50ms (S), < 75ms (M), < 100ms (L)\n- [ ] p95 search latency < 100ms (S), < 200ms (M), < 400ms (L)\n- [ ] Memory < 150MB RSS (S), < 250MB RSS (M)\n- [ ] All required indexes present in fixtures\n- [ ] EXPLAIN QUERY PLAN shows index usage for top 10 queries\n\n## Files\n- CREATE: crates/lore-tui/benches/perf_benchmarks.rs\n- CREATE: crates/lore-tui/tests/fixtures/generate_fixtures.rs\n\n## TDD Anchor\nRED: Write benchmark_dashboard_load_s_tier that generates S-tier fixture, measures Dashboard load time, asserts p95 < 50ms.\nGREEN: Implement fetch_dashboard with efficient queries.\nVERIFY: cargo bench --manifest-path crates/lore-tui/Cargo.toml\n\n## Edge Cases\n- Fixture generation must be deterministic (seeded RNG) for reproducible benchmarks\n- CI machines may be slower — use generous multipliers or relative thresholds\n- S-tier fits in memory; M-tier requires WAL mode for concurrent access\n- Benchmark warmup: discard first 5 iterations\n\n## Dependency Context\nUses all action.rs query functions from Phase 2/3 tasks.\nUses DbManager from \"Implement DbManager\" task.\nUses required index migrations from the main lore crate.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T17:05:12.867291Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:24.326479Z","deleted_at":"2026-03-11T18:34:24.326472Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-wrw1","title":"Implement CLI/TUI parity tests (counts, lists, detail, search, sanitization)","description":"## Background\nParity tests ensure the TUI and CLI show the same data. Both interfaces query the same SQLite database, but through different code paths (TUI action functions vs CLI command handlers). Drift can occur when query functions are duplicated or modified independently. These tests catch drift by running both code paths against the same in-memory DB and comparing results.\n\n## Approach\n\n### Test Strategy: Library-Level (Same Process)\nTests run in the same process with a shared in-memory SQLite DB. No binary execution, no JSON parsing, no process spawning. Both TUI action functions and CLI query functions are called as library code.\n\nSetup pattern:\n```rust\nuse lore::core::db::{create_connection, run_migrations};\nuse std::path::Path;\n\nfn setup_parity_db() -> rusqlite::Connection {\n    let conn = create_connection(Path::new(\":memory:\")).unwrap();\n    run_migrations(&conn).unwrap();\n    insert_fixture_data(&conn);  // shared fixture with known counts\n    conn\n}\n```\n\n### Fixture Data\nCreate a deterministic fixture with known quantities:\n- 1 project (gitlab_project_id=1, path_with_namespace=\"group/repo\", web_url=\"https://gitlab.example.com/group/repo\")\n- 15 issues (5 opened, 5 closed, 5 with various states)\n- 10 merge_requests (3 opened, 3 merged, 2 closed, 2 draft)\n- 30 discussions (20 for issues, 10 for MRs)\n- 60 notes (2 per discussion)\n- Insert via direct SQL (same pattern as existing tests in src/core/db.rs)\n\n### Parity Checks\n\n**Dashboard Count Parity:**\n- TUI: call the dashboard fetch function that returns entity counts\n- CLI: call the same count query functions used by `lore --robot count`\n- Assert: issue_count, mr_count, discussion_count, note_count all match\n\n**Issue List Parity:**\n- TUI: call issue list action with default filter (state=all, limit=50, sort=updated_at DESC)\n- CLI: call the issue list query used by `lore --robot issues`\n- Assert: same IIDs in same order, same state values for each\n\n**MR List Parity:**\n- TUI: call MR list action with default filter\n- CLI: call the MR list query used by `lore --robot mrs`\n- Assert: same IIDs in same order, same state values, same draft flags\n\n**Issue Detail Parity:**\n- TUI: call issue detail fetch for a specific IID\n- CLI: call the issue detail query used by `lore --robot issues <iid>`\n- Assert: same metadata fields (title, state, author, labels, created_at, updated_at), same discussion count\n\n**Search Parity:**\n- TUI: call search action with a known query term\n- CLI: call the search function used by `lore --robot search`\n- Assert: same document IDs returned in same rank order\n\n**Sanitization Parity:**\n- Insert an issue with ANSI escape sequences in the title: \"Normal \\x1b[31mRED\\x1b[0m text\"\n- TUI: fetch and sanitize via terminal safety module\n- CLI: fetch and render via robot mode (which strips ANSI)\n- Assert: both produce clean output without raw escape sequences\n\n## Acceptance Criteria\n- [ ] Dashboard counts: TUI == CLI for issues, MRs, discussions, notes on shared fixture\n- [ ] Issue list: TUI returns same IIDs in same order as CLI query function\n- [ ] MR list: TUI returns same IIDs in same order as CLI query function\n- [ ] Issue detail: TUI metadata matches CLI for title, state, author, discussion count\n- [ ] Search results: same document IDs in same rank order\n- [ ] Sanitization: both strip ANSI escape sequences from issue titles\n- [ ] All tests use in-memory DB (no file I/O, no binary spawning)\n- [ ] Tests are deterministic (fixed fixture, no wall clock dependency)\n\n## Files\n- CREATE: crates/lore-tui/tests/parity_tests.rs\n\n## TDD Anchor\nRED: Write `test_dashboard_count_parity` that creates shared fixture DB, calls both TUI dashboard fetch and CLI count query functions, asserts all counts equal.\nGREEN: Ensure TUI query functions exist and match CLI query logic.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml parity\n\nAdditional tests:\n- test_issue_list_parity\n- test_mr_list_parity\n- test_issue_detail_parity\n- test_search_parity\n- test_sanitization_parity\n\n## Edge Cases\n- CLI and TUI may use different default sort orders — normalize to same ORDER BY in test setup\n- CLI list commands default to limit=50, TUI may default to page size — test with explicit limit\n- Fixture must include edge cases: NULL labels, empty descriptions, issues with work item status set\n- Schema version must match between both code paths (same migration version)\n- FTS index must be populated for search parity (call generate-docs equivalent on fixture)\n\n## Dependency Context\n- Uses TUI action functions from Phase 2/3 screen beads (must exist as library code)\n- Uses CLI query functions from src/cli/ (already exist as `lore` library exports)\n- Uses lore::core::db for shared DB setup\n- Uses terminal safety module (bd-3ir1) for sanitization comparison\n- Depends on bd-14hv (soak tests) being complete per phase ordering","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T17:05:51.620596Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:24.633135Z","deleted_at":"2026-03-11T18:34:24.633128Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-wtrm","title":"Task 2: Implement key-decisions heuristic","description":"## Background\n\nThe key-decisions heuristic is the core intelligence of lore explain. It identifies notes that explain WHY state/label changes were made by correlating events with notes by the same actor within 60 minutes.\n\n**Parent bead:** bd-9lbr (lore explain feature)\n**Spec:** specs/SPEC_explain.md — Task 2\n**Phase:** 2 — Core Logic\n**Depends on:** Task 1 (bd-2i3z) — needs ExplainParams, ExplainResult types, run_explain skeleton, and test helpers (setup_explain_db, insert_test_issue)\n\n## Function Signature\n\n\\`\\`\\`rust\npub fn extract_key_decisions(\n    conn: &Connection,\n    entity_type: &str,   // \"issues\" or \"mrs\"\n    entity_id: i64,      // LOCAL SQLite row id (from find_explain_issue/mr)\n    since: Option<i64>,  // ms epoch threshold, or None for all time\n    max_decisions: usize, // cap on returned decisions\n) -> Result<Vec<KeyDecision>>\n\\`\\`\\`\n\nWire into run_explain():\n\\`\\`\\`rust\nlet key_decisions = if should_include(&params.sections, \"key_decisions\") {\n    Some(extract_key_decisions(conn, &params.entity_type, entity_local_id, params.since, params.max_decisions)?)\n} else { None };\n\\`\\`\\`\n\n## Algorithm\n\n1. Query resource_state_events for the entity (with optional --since filter)\n2. Query resource_label_events for the entity (with optional --since filter)\n3. Query non-system notes for the entity (with optional --since filter)\n4. Merge state + label events into unified chronological list with (timestamp, actor, description)\n5. For each event, find the FIRST non-system note by the SAME actor within 60 minutes AFTER the event\n6. Pair them as a KeyDecision\n7. Cap at max_decisions\n\n## Local Structs (define in explain.rs)\n\n\\`\\`\\`rust\nstruct UnifiedEvent {\n    created_at: i64,  // ms epoch\n    actor: String,\n    description: String,  // \"state: closed\" or \"label: +bugfix\"\n}\n\nstruct NoteRow {\n    body: String,\n    author_username: String,\n    created_at: i64,  // ms epoch\n}\n\\`\\`\\`\n\n## SQL Queries\n\n### id_col resolution\n\\`\\`\\`rust\nlet id_col = if entity_type == \"issues\" { \"issue_id\" } else { \"merge_request_id\" };\n\\`\\`\\`\n\n### State events\n\\`\\`\\`sql\nSELECT state, actor_username, created_at\nFROM resource_state_events\nWHERE {id_col} = ?1 AND (?2 IS NULL OR created_at >= ?2)\nORDER BY created_at ASC\n\\`\\`\\`\n\n### Label events\n\\`\\`\\`sql\nSELECT action, label_name, actor_username, created_at\nFROM resource_label_events\nWHERE {id_col} = ?1 AND (?2 IS NULL OR created_at >= ?2)\nORDER BY created_at ASC\n\\`\\`\\`\n\n### Notes for correlation\n\\`\\`\\`sql\nSELECT n.body, n.author_username, n.created_at\nFROM notes n\nJOIN discussions d ON n.discussion_id = d.id\nWHERE d.{id_col} = ?1 AND n.is_system = 0 AND (?2 IS NULL OR n.created_at >= ?2)\nORDER BY n.created_at ASC\n\\`\\`\\`\n\nWhere {id_col} is issue_id or merge_request_id based on entity_type.\nPass since (Option<i64>) as the ?2 parameter — rusqlite handles None as SQL NULL.\n\n## CRITICAL: State column format\n\nThe resource_state_events table has a SINGLE `state` TEXT column containing only the NEW state value (e.g., \"opened\", \"closed\", \"reopened\", \"merged\"). There is NO from_state column.\n\nAction string format for state events: \"state: closed\" (just the new state).\nAction string format for label events: \"label: +bugfix\" (add) or \"label: -wontfix\" (remove).\n\nNOTE: label_name is NULLABLE (migration 012). Use unwrap_or(\"[deleted label]\").\nNOTE: actor_username on both event tables is Option<String>. Skip events with None actor (can't correlate).\n\n## Correlation Logic\n\n\\`\\`\\`rust\nconst ONE_HOUR_MS: i64 = 60 * 60 * 1000;\n\nlet mut decisions = Vec::new();\nfor event in &events {  // sorted by created_at\n    // Find first note by same actor within 60min after event\n    if let Some(note) = notes.iter().find(|n|\n        n.author_username == event.actor\n        && n.created_at >= event.created_at\n        && n.created_at <= event.created_at + ONE_HOUR_MS\n    ) {\n        decisions.push(KeyDecision {\n            timestamp: ms_to_iso(event.created_at),\n            actor: event.actor.clone(),\n            action: event.description.clone(),\n            context_note: truncate(&note.body, 500),\n        });\n    }\n}\ndecisions.truncate(max_decisions);\n\\`\\`\\`\n\nUse crate::core::time::ms_to_iso() for timestamp conversion.\nUse crate::cli::render::truncate() for context_note truncation.\n\n## Files to Modify\n\n- src/cli/commands/explain.rs — add extract_key_decisions() function with local structs, wire into run_explain()\n\n## TDD Workflow (Red-Green)\n\n### RED — Write these tests first, confirm they FAIL:\n\nTest data insertion helpers (add to explain.rs test module, reuse setup_explain_db and insert_test_issue from Task 1):\n\n\\`\\`\\`rust\nfn insert_state_event(conn: &Connection, issue_id: i64, state: &str, actor: &str, created_at: i64) {\n    conn.execute(\n        \"INSERT INTO resource_state_events (gitlab_id, project_id, issue_id, state, actor_username, created_at) VALUES (?1, 1, ?2, ?3, ?4, ?5)\",\n        rusqlite::params![created_at, issue_id, state, actor, created_at],\n    ).unwrap();\n}\n\nfn insert_label_event(conn: &Connection, issue_id: i64, action: &str, label: &str, actor: &str, created_at: i64) {\n    conn.execute(\n        \"INSERT INTO resource_label_events (gitlab_id, project_id, issue_id, action, label_name, actor_username, created_at) VALUES (?1, 1, ?2, ?3, ?4, ?5, ?6)\",\n        rusqlite::params![created_at, issue_id, action, label, actor, created_at],\n    ).unwrap();\n}\n\nfn insert_note(conn: &Connection, discussion_id: i64, author: &str, body: &str, created_at: i64) {\n    conn.execute(\n        \"INSERT INTO notes (gitlab_id, discussion_id, project_id, is_system, author_username, body, created_at, updated_at, last_seen_at) VALUES (?1, ?2, 1, 0, ?3, ?4, ?5, ?5, ?5)\",\n        rusqlite::params![created_at, discussion_id, author, body, created_at],\n    ).unwrap();\n}\n\nfn insert_discussion(conn: &Connection, issue_id: i64) -> i64 {\n    conn.execute(\n        \"INSERT INTO discussions (gitlab_discussion_id, project_id, issue_id, noteable_type, individual_note, first_note_at, last_note_at, last_seen_at, resolvable, resolved) VALUES ('disc-1', 1, ?1, 'Issue', 0, 0, 0, 0, 0, 0)\",\n        [issue_id],\n    ).unwrap();\n    conn.last_insert_rowid()\n}\n\\`\\`\\`\n\nTests:\n1. test_explain_key_decision_heuristic: Insert state change event at T=1000000, insert note by SAME author at T+30min (T+1800000), call extract_key_decisions(), assert 1 decision with action containing \"state:\" and non-empty context_note\n2. test_explain_key_decision_ignores_unrelated_notes: Insert state change by \"alice\", insert note by \"bob\" at T+30min, assert 0 decisions\n3. test_explain_key_decision_label_event: Insert label add event (\"add\", \"bugfix\") + correlated note by same author, assert decision.action starts with \"label: +\"\n4. test_explain_max_decisions: Insert 5 correlated event+note pairs, call with max_decisions: 3, assert exactly 3 decisions returned\n5. test_explain_since_scopes_events: Insert event at T-60d and event at T-10d, call with since: Some(T-30d epoch), assert only recent event appears\n\n### GREEN — Implement extract_key_decisions() as described above\n\n### Verify:\ncargo test explain::tests::test_explain_key_decision && cargo clippy --all-targets -- -D warnings\n\n## Acceptance Criteria\n\n- [ ] test_explain_key_decision_heuristic passes\n- [ ] test_explain_key_decision_ignores_unrelated_notes passes\n- [ ] test_explain_key_decision_label_event passes\n- [ ] test_explain_max_decisions passes\n- [ ] test_explain_since_scopes_events passes\n- [ ] Heuristic correctly correlates events with explanatory notes\n- [ ] --max-decisions and --since respected\n- [ ] cargo clippy and cargo fmt clean","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-10T17:37:15.975293Z","created_by":"tayloreernisse","updated_at":"2026-03-10T18:37:44.492583Z","closed_at":"2026-03-10T18:37:44.492531Z","close_reason":"Key-decisions heuristic implemented with state/label event correlation, 5 tests passing","compaction_level":0,"original_size":0,"labels":["cli-imp","explain"],"dependencies":[{"issue_id":"bd-wtrm","depends_on_id":"bd-2i3z","type":"blocks","created_at":"2026-03-10T17:38:18.638294Z","created_by":"tayloreernisse"},{"issue_id":"bd-wtrm","depends_on_id":"bd-9lbr","type":"parent-child","created_at":"2026-03-10T17:37:15.976973Z","created_by":"tayloreernisse"}]}
{"id":"bd-wzqi","title":"Implement Command Palette (state + view)","description":"## Background\nThe Command Palette is a modal overlay (Ctrl+P) that provides fuzzy-match access to all commands. It uses FrankenTUI's built-in CommandPalette widget and is populated from the CommandRegistry.\n\n## Approach\nState (state/command_palette.rs):\n- CommandPaletteState: wraps ftui CommandPalette widget state\n- input (String), filtered_commands (Vec<CommandDef>), selected_index (usize), visible (bool)\n\nView (view/command_palette.rs):\n- Modal overlay centered on screen (60% width, 50% height)\n- Text input at top for fuzzy search\n- Scrollable list of matching commands with keybinding hints\n- Enter executes selected command, Esc closes palette\n- Fuzzy matching: subsequence match on command label and help text\n\nIntegration:\n- Ctrl+P from any screen opens palette (handled in interpret_key stage 2)\n- execute_palette_action() in app.rs converts selected command to Msg\n\n## Acceptance Criteria\n- [ ] Ctrl+P opens palette from any screen in Normal mode\n- [ ] Fuzzy matching filters commands as user types\n- [ ] Commands show label + keybinding + help text\n- [ ] Enter executes selected command\n- [ ] Esc closes palette without action\n- [ ] Palette populated from CommandRegistry (single source of truth)\n- [ ] Modal renders on top of current screen content\n\n## Files\n- MODIFY: crates/lore-tui/src/state/command_palette.rs (expand from stub)\n- CREATE: crates/lore-tui/src/view/command_palette.rs\n\n## TDD Anchor\nRED: Write test_palette_fuzzy_match that creates registry with 5 commands, filters with \"iss\", asserts Issue-related commands match.\nGREEN: Implement fuzzy matching on command labels.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_palette_fuzzy\n\n## Edge Cases\n- Empty search shows all commands\n- Very long command labels: truncate with ellipsis\n- Command not available on current screen: show but gray out\n- Palette should not steal focus from text inputs — only opens in Normal mode\n\n## Dependency Context\nUses CommandRegistry from \"Implement CommandRegistry\" task.\nUses ftui CommandPalette widget from FrankenTUI.\nUses InputMode::Palette from \"Implement core types\" task.","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-02-12T17:01:37.250065Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:24.403044Z","deleted_at":"2026-03-11T18:34:24.403038Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-x8oq","title":"Write surgical_tests.rs with TDD test suite","description":"## Background\n\nThe surgical sync module (`src/ingestion/surgical.rs` from bd-3sez) needs a comprehensive test suite. Tests use in-memory SQLite (no real GitLab or Ollama) and wiremock for HTTP mocks. The test file lives at `src/ingestion/surgical_tests.rs` and is included via `#[cfg(test)] #[path = \"surgical_tests.rs\"] mod tests;` in surgical.rs.\n\nKey testing constraints:\n- In-memory DB pattern: `create_connection(Path::new(\":memory:\"))` + `run_migrations(&conn)`\n- Test project insert: `INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url)` (no `name`/`last_seen_at` columns)\n- `GitLabIssue` required fields: `id`, `iid`, `project_id`, `title`, `state`, `created_at`, `updated_at`, `author`, `web_url`\n- `GitLabMergeRequest` adds: `source_branch`, `target_branch`, `draft`, `merge_status`, `reviewers`\n- `updated_at` is `String` (ISO 8601) in GitLab types, e.g. `\"2026-02-17T12:00:00.000+00:00\"`\n- `SourceType` enum variants: `Issue`, `MergeRequest`, `Discussion`, `Note`\n- `dirty_sources` table: `(source_type TEXT, source_id INTEGER)` primary key\n\n## Approach\n\nCreate `src/ingestion/surgical_tests.rs` with:\n\n### Test Helpers\n- `setup_db() -> Connection` — in-memory DB with migrations + test project row\n- `make_test_issue(iid: i64, updated_at: &str) -> GitLabIssue` — minimal valid JSON fixture\n- `make_test_mr(iid: i64, updated_at: &str) -> GitLabMergeRequest` — minimal valid JSON fixture\n- `get_db_updated_at(conn, table, iid) -> Option<i64>` — helper to query DB updated_at for assertions\n- `get_dirty_keys(conn) -> Vec<(String, i64)>` — query dirty_sources for assertions\n\n### Sync Tests (13)\n1. `test_ingest_issue_by_iid_upserts_and_marks_dirty` — fresh issue ingest, verify DB row + dirty_sources entry\n2. `test_ingest_mr_by_iid_upserts_and_marks_dirty` — fresh MR ingest, verify DB row + dirty_sources entry\n3. `test_toctou_skips_stale_issue` — insert issue at T1, call ingest with payload at T1, assert skipped_stale=true and no dirty mark\n4. `test_toctou_skips_stale_mr` — same for MRs\n5. `test_toctou_allows_newer_issue` — DB has T1, payload has T2 (T2 > T1), assert upserted=true\n6. `test_toctou_allows_newer_mr` — same for MRs\n7. `test_is_stale_parses_iso8601` — unit test: `\"2026-02-17T12:00:00.000+00:00\"` parses to correct ms-epoch\n8. `test_is_stale_handles_none_db_value` — first ingest, no DB row, assert not stale\n9. `test_is_stale_with_z_suffix` — `\"2026-02-17T12:00:00Z\"` also parses correctly\n10. `test_ingest_issue_returns_dirty_source_keys` — verify `dirty_source_keys` contains `(SourceType::Issue, local_id)`\n11. `test_ingest_mr_returns_dirty_source_keys` — verify MR dirty source keys\n12. `test_ingest_issue_updates_existing` — ingest same IID twice with newer updated_at, verify update\n13. `test_ingest_mr_updates_existing` — same for MRs\n\n### Async Preflight Test (1, wiremock)\n14. `test_preflight_fetch_returns_issues_and_mrs` — wiremock GET `/projects/:id/issues?iids[]=42` returns 200 with fixture, verify PreflightResult.issues has 1 entry\n\n### Integration Stubs (4, for bd-3jqx)\n15. `test_surgical_cancellation_during_preflight` — stub: signal.cancel() before preflight, verify early return\n16. `test_surgical_timeout_during_fetch` — stub: wiremock delay exceeds timeout\n17. `test_surgical_embed_isolation` — stub: verify only surgical docs get embedded\n18. `test_surgical_payload_integrity` — stub: verify ingested data matches GitLab payload exactly\n\n## Acceptance Criteria\n\n- [ ] All 13 sync tests pass with in-memory SQLite\n- [ ] Async preflight test passes with wiremock\n- [ ] 4 integration stubs compile and are marked `#[ignore]` (implemented in bd-3jqx)\n- [ ] Test helpers produce valid GitLabIssue/GitLabMergeRequest fixtures that pass `transform_issue`/`transform_merge_request`\n- [ ] No flaky tests: deterministic timestamps, no real network calls\n- [ ] File wired into surgical.rs via `#[cfg(test)] #[path = \"surgical_tests.rs\"] mod tests;`\n\n## Files\n\n- `src/ingestion/surgical_tests.rs` (NEW)\n- `src/ingestion/surgical.rs` (add `#[cfg(test)]` module path — created in bd-3sez)\n\n## TDD Anchor\n\nThis bead IS the test suite. Tests are written first (TDD red phase), then bd-3sez implements the production code to make them pass (green phase). Specific test signatures:\n\n```rust\n#[test]\nfn test_ingest_issue_by_iid_upserts_and_marks_dirty() {\n    let conn = setup_db();\n    let issue = make_test_issue(42, \"2026-02-17T12:00:00.000+00:00\");\n    let config = Config::default();\n    let result = ingest_issue_by_iid(&conn, &config, /*project_id=*/1, &issue).unwrap();\n    assert!(result.upserted);\n    assert!(!result.skipped_stale);\n    let dirty = get_dirty_keys(&conn);\n    assert!(dirty.contains(&(\"issue\".to_string(), /*local_id from DB*/)));\n}\n\n#[test]\nfn test_toctou_skips_stale_issue() {\n    let conn = setup_db();\n    let issue = make_test_issue(42, \"2026-02-17T12:00:00.000+00:00\");\n    ingest_issue_by_iid(&conn, &Config::default(), 1, &issue).unwrap();\n    // Ingest same timestamp again\n    let result = ingest_issue_by_iid(&conn, &Config::default(), 1, &issue).unwrap();\n    assert!(result.skipped_stale);\n}\n\n#[tokio::test]\nasync fn test_preflight_fetch_returns_issues_and_mrs() {\n    let mock = MockServer::start().await;\n    // ... wiremock setup ...\n}\n```\n\n## Edge Cases\n\n- `make_test_issue` must produce all required fields (`id`, `iid`, `project_id`, `title`, `state`, `created_at`, `updated_at`, `author` with `username` and `id`, `web_url`) or `transform_issue` will fail\n- `make_test_mr` additionally needs `source_branch`, `target_branch`, `draft`, `merge_status`, `reviewers`\n- ISO 8601 fixtures must use `+00:00` suffix (GitLab format), not `Z`\n- Integration stubs must be `#[ignore]` so they do not fail CI before bd-3jqx implements them\n- Test DB needs `run_migrations` to create all tables including `dirty_sources`, `documents`, `issues`, `merge_requests`\n\n## Dependency Context\n\n- **Blocked by bd-3sez**: Cannot compile tests until surgical.rs module exists (circular co-dependency — develop together)\n- **Blocks bd-3jqx**: Integration test stubs are implemented in that bead\n- **No other blockers**: Uses only in-memory DB and wiremock, no external dependencies","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-17T19:15:05.498388Z","created_by":"tayloreernisse","updated_at":"2026-02-18T19:25:57.434371Z","closed_at":"2026-02-18T19:25:57.434313Z","close_reason":"Merged into bd-3sez: tests belong with the code they test, not in a separate bead. TDD is the default workflow, not a separate deliverable.","compaction_level":0,"original_size":0,"labels":["surgical-sync"]}
{"id":"bd-xhz","title":"[CP1] GitLab client pagination methods","description":"## Background\n\nGitLab pagination methods enable fetching large result sets (issues, discussions) as async streams. The client uses `x-next-page` headers to determine continuation and applies cursor rewind for tuple-based incremental sync.\n\n## Approach\n\nAdd pagination methods to GitLabClient using `async-stream` crate:\n\n### Methods to Add\n\n```rust\nimpl GitLabClient {\n    /// Paginate through issues for a project.\n    pub fn paginate_issues(\n        &self,\n        gitlab_project_id: i64,\n        updated_after: Option<i64>,      // ms epoch cursor\n        cursor_rewind_seconds: u32,\n    ) -> Pin<Box<dyn Stream<Item = Result<GitLabIssue>> + Send + '_>>\n\n    /// Paginate through discussions for an issue.\n    pub fn paginate_issue_discussions(\n        &self,\n        gitlab_project_id: i64,\n        issue_iid: i64,\n    ) -> Pin<Box<dyn Stream<Item = Result<GitLabDiscussion>> + Send + '_>>\n\n    /// Make request and return response with headers for pagination.\n    async fn request_with_headers<T: DeserializeOwned>(\n        &self,\n        path: &str,\n        params: &[(&str, String)],\n    ) -> Result<(T, HeaderMap)>\n}\n```\n\n### Pagination Logic\n\n1. Start at page 1, per_page=100\n2. For issues: add scope=all, state=all, order_by=updated_at, sort=asc\n3. Apply cursor rewind: `updated_after = cursor - rewind_seconds` (clamped to 0)\n4. Yield each item from response\n5. Check `x-next-page` header for continuation\n6. Stop when header is empty/absent OR response is empty\n\n### Cursor Rewind\n\n```rust\nif let Some(ts) = updated_after {\n    let rewind_ms = (cursor_rewind_seconds as i64) * 1000;\n    let rewound = (ts - rewind_ms).max(0);  // Clamp to avoid underflow\n    // Convert to ISO 8601 for updated_after param\n}\n```\n\n## Acceptance Criteria\n\n- [ ] `paginate_issues` returns Stream of GitLabIssue\n- [ ] `paginate_issues` adds scope=all, state=all, order_by=updated_at, sort=asc\n- [ ] `paginate_issues` applies cursor rewind with max(0) clamping\n- [ ] `paginate_issue_discussions` returns Stream of GitLabDiscussion\n- [ ] Both methods follow x-next-page header until empty\n- [ ] Both methods stop on empty response (fallback)\n- [ ] `request_with_headers` returns (T, HeaderMap) tuple\n\n## Files\n\n- src/gitlab/client.rs (edit - add methods)\n\n## TDD Loop\n\nRED:\n```rust\n// tests/pagination_tests.rs\n#[tokio::test] async fn fetches_all_pages_when_multiple_exist()\n#[tokio::test] async fn respects_per_page_parameter()\n#[tokio::test] async fn follows_x_next_page_header_until_empty()\n#[tokio::test] async fn falls_back_to_empty_page_stop_if_headers_missing()\n#[tokio::test] async fn applies_cursor_rewind_for_tuple_semantics()\n#[tokio::test] async fn clamps_negative_rewind_to_zero()\n```\n\nGREEN: Implement pagination methods with async-stream\n\nVERIFY: `cargo test pagination`\n\n## Edge Cases\n\n- cursor_updated_at near zero - rewind must not underflow (use max(0))\n- GitLab returns empty x-next-page - treat as end of pages\n- GitLab omits pagination headers entirely - use empty response as stop condition\n- DateTime conversion fails - omit updated_after and fetch all (safe fallback)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-25T17:02:38.222168Z","created_by":"tayloreernisse","updated_at":"2026-01-25T22:28:39.192876Z","closed_at":"2026-01-25T22:28:39.192815Z","close_reason":"Implemented paginate_issues and paginate_issue_discussions with async-stream, cursor rewind with max(0) clamping, x-next-page header following, 4 unit tests passing","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-xhz","depends_on_id":"bd-1np","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-xhz","depends_on_id":"bd-2ys","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-xsgw","title":"NOTE-TEST2: Another test bead","description":"type: task","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T16:58:53.392214Z","updated_at":"2026-02-12T16:59:02.051710Z","closed_at":"2026-02-12T16:59:02.051663Z","close_reason":"test","compaction_level":0,"original_size":0}
{"id":"bd-y095","title":"Implement SyncDeltaLedger for post-sync filtered navigation","description":"## Background\n\nAfter a sync completes, the Sync Summary screen shows delta counts (+12 new issues, +3 new MRs). Pressing `i` or `m` should navigate to Issue/MR List filtered to show ONLY the entities that changed in this sync run. The SyncDeltaLedger is an in-memory data structure (not persisted to DB) that records the exact IIDs of new/updated entities during a sync run. It lives for the duration of one TUI session and is cleared when a new sync starts. If the ledger is unavailable (e.g., after app restart), the Sync Summary falls back to a timestamp-based filter using `sync_status.last_completed_at`.\n\n## Approach\n\nCreate a `sync_delta.rs` module with:\n\n1. **`SyncDeltaLedger` struct**:\n   ```rust\n   pub struct SyncDeltaLedger {\n       issues_new: Vec<i64>,        // IIDs of newly created issues\n       issues_updated: Vec<i64>,    // IIDs of updated (not new) issues\n       mrs_new: Vec<i64>,           // IIDs of newly created MRs\n       mrs_updated: Vec<i64>,       // IIDs of updated MRs\n       discussions_new: usize,      // count only (too many to track individually)\n       events_new: usize,           // count only\n       completed_at: Option<i64>,   // timestamp when sync finished (fallback anchor)\n   }\n   ```\n2. **Builder pattern** — `SyncDeltaLedger::new()` starts empty, populated during sync via:\n   - `record_issue(iid: i64, is_new: bool)`\n   - `record_mr(iid: i64, is_new: bool)`\n   - `record_discussions(count: usize)`\n   - `record_events(count: usize)`\n   - `finalize(completed_at: i64)` — marks ledger as complete\n3. **Query methods**:\n   - `new_issue_iids() -> &[i64]` — for `i` key navigation in Summary mode\n   - `new_mr_iids() -> &[i64]` — for `m` key navigation\n   - `all_changed_issue_iids() -> Vec<i64>` — new + updated combined\n   - `all_changed_mr_iids() -> Vec<i64>` — new + updated combined\n   - `is_available() -> bool` — true if finalize() was called\n   - `fallback_timestamp() -> Option<i64>` — completed_at for timestamp-based fallback\n4. **`clear()`** — resets all fields when a new sync starts\n\nThe ledger is owned by `SyncState` (part of `AppState`) and populated by the sync action handler when processing `SyncResult` from `run_sync()`. The existing `SyncResult` struct (src/cli/commands/sync.rs:30) already tracks `issues_updated` and `mrs_updated` counts but not individual IIDs — the TUI sync action will need to collect IIDs from the ingest callbacks.\n\n## Acceptance Criteria\n- [ ] `SyncDeltaLedger::new()` creates an empty ledger with `is_available() == false`\n- [ ] `record_issue(42, true)` adds 42 to `issues_new`; `record_issue(43, false)` adds to `issues_updated`\n- [ ] `new_issue_iids()` returns only new IIDs, `all_changed_issue_iids()` returns new + updated\n- [ ] `finalize(ts)` sets `is_available() == true` and stores the timestamp\n- [ ] `clear()` resets everything back to empty with `is_available() == false`\n- [ ] `fallback_timestamp()` returns None before finalize, Some(ts) after\n- [ ] Ledger handles >10,000 IIDs without issues (just Vec growth)\n\n## Files\n- CREATE: crates/lore-tui/src/sync_delta.rs\n- MODIFY: crates/lore-tui/src/lib.rs (add `pub mod sync_delta;`)\n\n## TDD Anchor\nRED: Write `test_empty_ledger_not_available` that asserts `SyncDeltaLedger::new().is_available() == false` and `new_issue_iids().is_empty()`.\nGREEN: Implement the struct with new() and is_available().\nVERIFY: cargo test -p lore-tui sync_delta\n\nAdditional tests:\n- test_record_and_query_issues\n- test_record_and_query_mrs\n- test_finalize_makes_available\n- test_clear_resets_everything\n- test_all_changed_combines_new_and_updated\n- test_fallback_timestamp\n\n## Edge Cases\n- Recording the same IID twice (e.g., issue updated twice during sync) — should deduplicate or allow duplicates? Allow duplicates (Vec, not HashSet) for simplicity; consumers can deduplicate if needed.\n- Very large syncs with >50,000 entities — Vec is fine, no cap needed.\n- Calling query methods before finalize — returns data so far (is_available=false signals incompleteness).\n\n## Dependency Context\n- Depends on bd-2x2h (Sync screen) which owns SyncState and drives the sync lifecycle. The ledger is a field of SyncState.\n- Consumed by Sync Summary mode's `i`/`m` key handlers to produce filtered Issue/MR List navigation with exact IID sets.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T19:29:38.738460Z","created_by":"tayloreernisse","updated_at":"2026-03-11T18:34:23.094696Z","compaction_level":0,"original_size":0,"labels":["TUI"]}
{"id":"bd-ymd","title":"[CP1] Final validation - Gate A through D","description":"Run all tests and verify all internal gates pass.\n\n## Gate A: Issues Only (Must Pass First)\n- [ ] gi ingest --type=issues fetches all issues from configured projects\n- [ ] Issues stored with correct schema, including last_seen_at\n- [ ] Cursor-based sync is resumable (re-run fetches only new/updated)\n- [ ] Incremental cursor updates every 100 issues\n- [ ] Raw payloads stored for each issue\n- [ ] gi list issues and gi count issues work\n\n## Gate B: Labels Correct (Must Pass)\n- [ ] Labels extracted and stored (name-only)\n- [ ] Label links created correctly\n- [ ] Stale label links removed on re-sync (verified with test)\n- [ ] Label count per issue matches GitLab\n\n## Gate C: Dependent Discussion Sync (Must Pass)\n- [ ] Discussions fetched for issues with updated_at advancement\n- [ ] Notes stored with is_system flag correctly set\n- [ ] Raw payloads stored for discussions and notes\n- [ ] discussions_synced_for_updated_at watermark updated after sync\n- [ ] Unchanged issues skip discussion refetch (verified with test)\n- [ ] Bounded concurrency (dependent_concurrency respected)\n\n## Gate D: Resumability Proof (Must Pass)\n- [ ] Kill mid-run, rerun; bounded redo (cursor progress preserved)\n- [ ] No redundant discussion refetch after crash recovery\n- [ ] Single-flight lock prevents concurrent runs\n\n## Final Gate (Must Pass)\n- [ ] All unit tests pass (cargo test)\n- [ ] All integration tests pass (mocked with wiremock)\n- [ ] cargo clippy passes with no warnings\n- [ ] cargo fmt --check passes\n- [ ] Compiles with --release\n\n## Validation Commands\ncargo test\ncargo clippy -- -D warnings\ncargo fmt --check\ncargo build --release\n\nFiles: All CP1 files\nDone when: All gate criteria pass","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T16:59:26.795633Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:02.132613Z","closed_at":"2026-01-25T17:02:02.132613Z","deleted_at":"2026-01-25T17:02:02.132608Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0}
{"id":"bd-ypa","title":"Implement timeline expand phase: BFS cross-reference expansion","description":"## Background\n\nThe expand phase is step 3 of the timeline pipeline (spec Section 3.2). Starting from seed entities, it performs BFS over entity_references to discover related entities not matched by keywords.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.2 step 3, Section 3.5 (expanded_entities JSON).\n\n## Codebase Context\n\n- entity_references table exists (migration 011) with columns: source_entity_type, source_entity_id, target_entity_type, target_entity_id, target_project_path, target_entity_iid, reference_type, source_method, created_at\n- reference_type CHECK: `'closes' | 'mentioned' | 'related'`\n- source_method CHECK: `'api' | 'note_parse' | 'description_parse'` — use these values in provenance, NOT the spec's original values\n- Indexes: idx_entity_refs_source (source_entity_type, source_entity_id), idx_entity_refs_target (target_entity_id WHERE NOT NULL)\n\n## Approach\n\nCreate `src/core/timeline_expand.rs`:\n\n```rust\nuse std::collections::{HashSet, VecDeque};\nuse rusqlite::Connection;\nuse crate::core::timeline::{EntityRef, ExpandedEntityRef, UnresolvedRef};\n\npub struct ExpandResult {\n    pub expanded_entities: Vec<ExpandedEntityRef>,\n    pub unresolved_references: Vec<UnresolvedRef>,\n}\n\npub fn expand_timeline(\n    conn: &Connection,\n    seeds: &[EntityRef],\n    depth: u32,                  // 0=no expansion, 1=default, 2+=deep\n    include_mentions: bool,      // --expand-mentions flag\n    max_entities: usize,         // cap at 100 to prevent explosion\n) -> Result<ExpandResult> { ... }\n```\n\n### BFS Algorithm\n\n```\nvisited: HashSet<(String, i64)> = seeds as set (entity_type, entity_id)\nqueue: VecDeque<(EntityRef, u32)> for multi-hop\n\nFor each seed:\n  query_neighbors(conn, seed, edge_types) -> outgoing + incoming refs\n  - Outgoing: SELECT target_* FROM entity_references WHERE source_entity_type=? AND source_entity_id=? AND reference_type IN (...)\n  - Incoming: SELECT source_* FROM entity_references WHERE target_entity_type=? AND target_entity_id=? AND reference_type IN (...)\n  - Unresolved (target_entity_id IS NULL): collect in UnresolvedRef, don't traverse\n  - New resolved: add to expanded with provenance (via_from, via_reference_type, via_source_method)\n  - If current_depth < depth: enqueue for further BFS\n```\n\n### Edge Type Filtering\n\n```rust\nfn edge_types(include_mentions: bool) -> Vec<&'static str> {\n    if include_mentions {\n        vec![\"closes\", \"related\", \"mentioned\"]\n    } else {\n        vec![\"closes\", \"related\"]\n    }\n}\n```\n\n### Provenance (Critical for spec compliance)\n\nEach expanded entity needs via object per spec Section 3.5:\n- via_from: EntityRef of the entity that referenced this one\n- via_reference_type: from entity_references.reference_type column\n- via_source_method: from entity_references.source_method column (**codebase values: 'api', 'note_parse', 'description_parse'**)\n\nRegister in `src/core/mod.rs`: `pub mod timeline_expand;`\n\n## Acceptance Criteria\n\n- [ ] BFS traverses outgoing AND incoming edges in entity_references\n- [ ] Default: only \"closes\" and \"related\" edges (not \"mentioned\")\n- [ ] --expand-mentions: also traverses \"mentioned\" edges\n- [ ] depth=0: returns empty expanded list\n- [ ] max_entities cap prevents explosion (default 100)\n- [ ] Provenance: via_source_method uses codebase values (api/note_parse/description_parse), NOT spec values\n- [ ] Unresolved references (target_entity_id IS NULL) collected, not traversed\n- [ ] No duplicates: visited set by (entity_type, entity_id)\n- [ ] Self-references skipped\n- [ ] Module registered in src/core/mod.rs\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/core/timeline_expand.rs` (NEW)\n- `src/core/mod.rs` (add `pub mod timeline_expand;`)\n\n## TDD Loop\n\nRED: Tests in `src/core/timeline_expand.rs`:\n- `test_expand_depth_zero` - returns empty\n- `test_expand_finds_linked_entity` - seed issue -> closes -> linked MR\n- `test_expand_bidirectional` - starting from target also finds source\n- `test_expand_respects_max_entities`\n- `test_expand_skips_mentions_by_default`\n- `test_expand_includes_mentions_when_flagged`\n- `test_expand_collects_unresolved`\n- `test_expand_tracks_provenance` - verify via_source_method is 'api' not 'api_closes_issues'\n\nTests need in-memory DB with migrations 001-014 applied + entity_references test data.\n\nGREEN: Implement BFS.\n\nVERIFY: `cargo test --lib -- timeline_expand`\n\n## Edge Cases\n\n- Circular references: visited set prevents infinite loop\n- Entity referenced from multiple seeds: first-come provenance wins\n- Empty entity_references: returns empty, not error\n- Cross-project refs with NULL target_entity_id: add to unresolved","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:33:08.659381Z","created_by":"tayloreernisse","updated_at":"2026-02-05T21:49:46.868460Z","closed_at":"2026-02-05T21:49:46.868410Z","close_reason":"Completed: Created src/core/timeline_expand.rs with BFS cross-reference expansion. Bidirectional traversal, depth limiting, mention filtering, max entity cap, provenance tracking, unresolved reference collection. 10 tests pass. All quality gates pass.","compaction_level":0,"original_size":0,"labels":["gate-3","phase-b","query"],"dependencies":[{"issue_id":"bd-ypa","depends_on_id":"bd-32q","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-ypa","depends_on_id":"bd-3ia","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-ypa","depends_on_id":"bd-ike","type":"parent-child","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-yq49","title":"Phase 3e: Rate limiter sleep migration","description":"## What\nReplace tokio::time::sleep with asupersync::time::sleep in the rate limiter backoff.\n\n## Implementation\n```rust\n// Before\nuse tokio::time::sleep;\nsleep(delay).await;\n\n// After\nuse asupersync::time::sleep;\nsleep(delay).await;\n```\n\n## Why This Is Trivial\nThe sleep API is identical — both take a Duration and return a future that resolves after the delay. The only change is the import path.\n\n## Files Changed\n- src/gitlab/client.rs (~2 LOC: 1 import + 1 usage, or just the import if usage is identical)\n\n## Testing\n- cargo check --all-targets\n\n## Depends On\n- Phase 3a (asupersync must be in Cargo.toml)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-03-06T18:40:48.567703Z","created_by":"tayloreernisse","updated_at":"2026-03-06T21:11:12.637953Z","closed_at":"2026-03-06T21:11:12.637608Z","close_reason":"asupersync migration complete — 5 commits pushed to asupersync-migration branch","compaction_level":0,"original_size":0,"labels":["asupersync","phase-3"],"dependencies":[{"issue_id":"bd-yq49","depends_on_id":"bd-1lj5","type":"parent-child","created_at":"2026-03-06T18:40:48.569191Z","created_by":"tayloreernisse"},{"issue_id":"bd-yq49","depends_on_id":"bd-39yp","type":"blocks","created_at":"2026-03-06T18:42:53.372486Z","created_by":"tayloreernisse"}]}
{"id":"bd-z0s","title":"[CP1] Final validation - Gate A through D","description":"Run all tests and verify all internal gates pass.\n\n## Gate A: Issues Only (Must Pass First)\n- [ ] gi ingest --type=issues fetches all issues from configured projects\n- [ ] Issues stored with correct schema, including last_seen_at\n- [ ] Cursor-based sync is resumable (re-run fetches only new/updated)\n- [ ] Incremental cursor updates every 100 issues\n- [ ] Raw payloads stored for each issue\n- [ ] gi list issues and gi count issues work\n\n## Gate B: Labels Correct (Must Pass)\n- [ ] Labels extracted and stored (name-only)\n- [ ] Label links created correctly\n- [ ] **Stale label links removed on re-sync** (verified with test)\n- [ ] Label count per issue matches GitLab\n\n## Gate C: Dependent Discussion Sync (Must Pass)\n- [ ] Discussions fetched for issues with updated_at advancement\n- [ ] Notes stored with is_system flag correctly set\n- [ ] Raw payloads stored for discussions and notes\n- [ ] discussions_synced_for_updated_at watermark updated after sync\n- [ ] **Unchanged issues skip discussion refetch** (verified with test)\n- [ ] Bounded concurrency (dependent_concurrency respected)\n\n## Gate D: Resumability Proof (Must Pass)\n- [ ] Kill mid-run, rerun; bounded redo (cursor progress preserved)\n- [ ] No redundant discussion refetch after crash recovery\n- [ ] Single-flight lock prevents concurrent runs\n\n## Final Gate (Must Pass)\n- [ ] All unit tests pass (cargo test)\n- [ ] All integration tests pass (mocked with wiremock)\n- [ ] cargo clippy passes with no warnings\n- [ ] cargo fmt --check passes\n- [ ] Compiles with --release\n\n## Validation Commands\ncargo test\ncargo clippy -- -D warnings\ncargo fmt --check\ncargo build --release\n\n## Data Integrity Checks\n- SELECT COUNT(*) FROM issues matches GitLab issue count\n- Every issue has a raw_payloads row\n- Every discussion has a raw_payloads row\n- Labels in issue_labels junction all exist in labels table\n- Re-running gi ingest --type=issues fetches 0 new items\n- After removing a label in GitLab and re-syncing, the link is removed\n\nFiles: All CP1 files\nDone when: All gate criteria pass","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-25T17:02:38.459095Z","created_by":"tayloreernisse","updated_at":"2026-01-25T23:27:09.567537Z","closed_at":"2026-01-25T23:27:09.567478Z","close_reason":"All gates pass: 71 tests, clippy clean, fmt clean, release build successful","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-z0s","depends_on_id":"bd-17v","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-z0s","depends_on_id":"bd-2f0","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-z0s","depends_on_id":"bd-39w","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-z0s","depends_on_id":"bd-3n1","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-z0s","depends_on_id":"bd-o7b","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-z0s","depends_on_id":"bd-v6i","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-z94","title":"Implement 'lore file-history' command with human and robot output","description":"## Background\n\nThe file-history command is Gate 4's user-facing CLI. It answers \"which MRs touched this file, and why?\"\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 4.4-4.5.\n\n## Codebase Context\n\n- CLI pattern: Commands enum in src/cli/mod.rs, handler in src/main.rs, output in src/cli/commands/\n- Project resolution: resolve_project() returns project_id or exit 18 (Ambiguous)\n- Robot mode: {ok, data, meta} envelope pattern\n- merge_requests.merged_at exists (migration 006) — order by COALESCE(merged_at, updated_at) DESC\n- discussions table: issue_id, merge_request_id\n- notes table: position_new_path for DiffNotes (used for --discussions flag)\n- **mr_file_changes table**: migration 016 — already exists and is populated by drain_mr_diffs() (orchestrator.rs lines 708-726, 1514+)\n- resolve_rename_chain() from bd-1yx (src/core/file_history.rs) for rename handling\n- VALID_COMMANDS array in src/main.rs (line ~448)\n- **26 migrations** exist (001-026). LATEST_SCHEMA_VERSION derived from MIGRATIONS.len().\n\n## Approach\n\n### 1. FileHistoryArgs subcommand (`src/cli/mod.rs`):\n```rust\n/// Show MRs that touched a file, with linked issues and discussions\n#[command(name = \"file-history\")]\nFileHistory(FileHistoryArgs),\n```\n\n```rust\n#[derive(Parser, Debug)]\npub struct FileHistoryArgs {\n    /// File path to trace history for\n    pub path: String,\n    /// Scope to a specific project (fuzzy match)\n    #[arg(short = 'p', long)]\n    pub project: Option<String>,\n    /// Include discussion snippets from DiffNotes on this file\n    #[arg(long)]\n    pub discussions: bool,\n    /// Disable rename chain resolution\n    #[arg(long = \"no-follow-renames\")]\n    pub no_follow_renames: bool,\n    /// Only show merged MRs\n    #[arg(long)]\n    pub merged: bool,\n    /// Maximum results\n    #[arg(short = 'n', long = \"limit\", default_value = \"50\")]\n    pub limit: usize,\n}\n```\n\n### 2. Query logic (`src/cli/commands/file_history.rs`):\n\n1. Resolve project (exit 18 on ambiguous)\n2. Call resolve_rename_chain() unless --no-follow-renames\n3. Query mr_file_changes for all resolved paths\n4. JOIN merge_requests for MR details\n5. Optionally fetch DiffNote discussions on the file (notes.position_new_path)\n6. Order by COALESCE(merged_at, updated_at) DESC\n7. Apply --merged filter and --limit\n\n### 3. Human output:\n```\nFile History: src/auth/oauth.rs (via 3 paths, 5 MRs)\nRename chain: src/authentication/oauth.rs -> src/auth/oauth.rs\n\n  !456  \"Implement OAuth2 flow\"     merged  @alice  2024-01-22  modified\n  !489  \"Fix OAuth token expiry\"    merged  @bob    2024-02-15  modified\n  !512  \"Refactor auth module\"      merged  @carol  2024-03-01  renamed\n```\n\n### 4. Robot JSON:\n```json\n{\n  \"ok\": true,\n  \"data\": {\n    \"path\": \"src/auth/oauth.rs\",\n    \"rename_chain\": [\"src/authentication/oauth.rs\", \"src/auth/oauth.rs\"],\n    \"merge_requests\": [\n      {\n        \"iid\": 456,\n        \"title\": \"Implement OAuth2 flow\",\n        \"state\": \"merged\",\n        \"author\": \"alice\",\n        \"merged_at\": \"2024-01-22T...\",\n        \"change_type\": \"modified\",\n        \"discussion_count\": 12,\n        \"file_discussion_count\": 4,\n        \"merge_commit_sha\": \"abc123\"\n      }\n    ]\n  },\n  \"meta\": {\n    \"total_mrs\": 5,\n    \"renames_followed\": true,\n    \"paths_searched\": 2\n  }\n}\n```\n\n## Acceptance Criteria\n\n- [ ] `lore file-history src/foo.rs` works with human output\n- [ ] `lore --robot file-history src/foo.rs` works with JSON envelope\n- [ ] Rename chain displayed in human output when renames detected\n- [ ] Robot JSON includes rename_chain array\n- [ ] --no-follow-renames disables resolution (queries literal path only)\n- [ ] --merged filters to merged MRs only\n- [ ] --discussions includes DiffNote snippets from notes.position_new_path matching\n- [ ] -p for project scoping (exit 18 on ambiguous)\n- [ ] -n limits results\n- [ ] No MR history: friendly message (exit 0, not error)\n- [ ] \"file-history\" added to VALID_COMMANDS array\n- [ ] robot-docs manifest includes file-history command\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n- [ ] `cargo fmt --check` passes\n\n## Files\n\n- MODIFY: src/cli/mod.rs (FileHistoryArgs struct + Commands::FileHistory variant)\n- CREATE: src/cli/commands/file_history.rs (query + human + robot output)\n- MODIFY: src/cli/commands/mod.rs (add pub mod file_history + re-exports)\n- MODIFY: src/main.rs (handler dispatch + VALID_COMMANDS + robot-docs entry)\n\n## TDD Anchor\n\nRED: No unit tests for CLI wiring — verify with cargo check + manual run.\n\nGREEN: Implement query, human renderer, robot renderer.\n\nVERIFY:\n```bash\ncargo check --all-targets\ncargo run --release -- file-history --help\ncargo run --release -- file-history src/main.rs\ncargo run --release -- --robot file-history src/main.rs\n```\n\n## Edge Cases\n\n- File path with spaces: clap handles quoting\n- Path not in any MR: empty result, friendly message, exit 0 (not error)\n- MRs ordered by COALESCE(merged_at, updated_at) DESC (unmerged MRs use updated_at)\n- --discussions with no DiffNotes: empty discussion section, not error\n- rename_chain omitted from robot JSON when --no-follow-renames is set\n- mr_file_changes table empty (sync hasn't fetched diffs yet): friendly message suggesting `lore sync`\n\n## Dependency Context\n\n- **bd-1yx (resolve_rename_chain)**: provides resolve_rename_chain() in src/core/file_history.rs — takes a path and returns Vec<String> of all historical paths. MUST be implemented before this bead.\n- **bd-2yo / migration 016 (mr_file_changes)**: provides the mr_file_changes table with new_path, old_path, change_type columns. Already exists and is populated by drain_mr_diffs() in orchestrator.rs (lines 708-726, 1514+).\n- **bd-3ia (closes_issues)**: provides entity_references with reference_type='closes' linking MRs to issues. Used for \"linked issues\" column if extended later.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:34:09.027259Z","created_by":"tayloreernisse","updated_at":"2026-02-17T17:57:21.258978Z","closed_at":"2026-02-17T17:57:21.258929Z","close_reason":"Implemented file-history command with human/robot output, rename chain resolution, DiffNote discussions, --merged/--no-follow-renames filters, autocorrect registry, robot-docs manifest","compaction_level":0,"original_size":0,"labels":["cli","gate-4","phase-b"],"dependencies":[{"issue_id":"bd-z94","depends_on_id":"bd-14q","type":"parent-child","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-z94","depends_on_id":"bd-1yx","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-z94","depends_on_id":"bd-2yo","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-z94","depends_on_id":"bd-3ia","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-zgke","title":"Phase 3f-Step1: Cx threading — orchestration path","description":"## What\nThread Cx from main() through command dispatch into the orchestrator and ingestion modules. Replace join_all batches with region-scoped tasks. This is the BIG PAYOFF of the migration.\n\n## Why\nThis is where structured cancellation actually matters. If Ctrl+C fires during a prefetch batch, the region cancels all in-flight HTTP requests with bounded cleanup instead of silently dropping them.\n\n## Rearchitecture Context (2026-03-06)\nA major code reorganization restructured several files referenced by this bead:\n- main.rs is now thin (419 LOC). Command dispatch + handlers live in src/app/handlers.rs (via include\\!() chain from main.rs)\n- cli/commands/sync.rs -> split into cli/commands/sync/ folder (mod.rs, run.rs, render.rs, surgical.rs, sync_tests.rs)\n- cli/commands/ingest.rs -> split into cli/commands/ingest/ folder (mod.rs, run.rs, render.rs)\n- cli/commands/sync_surgical.rs -> moved to cli/commands/sync/surgical.rs\n\n## Functions That Need cx: &Cx Added\n\n| Module | Functions |\n|--------|-----------|\n| src/app/handlers.rs | Command dispatch handler functions for sync, ingest (physically in handlers.rs, logically in main.rs scope via include\\!()) |\n| cli/commands/sync/run.rs | run_sync() |\n| cli/commands/ingest/run.rs | run_ingest_command(), run_ingest() |\n| cli/commands/sync/surgical.rs | run_sync_surgical() |\n| ingestion/orchestrator.rs | ingest_issues(), ingest_merge_requests(), ingest_discussions(), etc. |\n| ingestion/surgical.rs | surgical_sync() |\n\n## Region Wrapping for join_all Batches (orchestrator.rs)\n\n```rust\n// Before\nlet prefetched_batch = join_all(prefetch_futures).await;\n\n// After — cancel-correct region\n// NOTE: Pattern depends on asupersync region API. If scope.spawn() returns JoinHandle<T>,\n// prefer collecting handles and awaiting them (preserves ordering).\n```\n\n## CRITICAL: Semantic Differences from join_all\n\n1. **Ordering**: join_all preserves input order. Channel-based collection does NOT. If downstream assumes positional alignment, send (index, result) tuples and sort. Or use JoinHandle if available.\n\n2. **Error aggregation**: join_all runs all futures even if some fail. Region cancellation may lose some results. Decide per call site: partial results OK, or retry entire batch?\n\n3. **Backpressure**: Verify asupersync region API doesn't impose implicit concurrency caps.\n\n4. **Late result loss on cancellation**: Tasks that completed but haven't been received may be lost. Caller must treat cancelled region results as incomplete.\n\nAUDIT EVERY join_all CALL SITE for all 4 assumptions before choosing the pattern.\n\n## Estimated Scope\n~10 functions gain cx: &Cx parameter in this step. Focus on the orchestration hot path only.\n\n## Files Changed\n- src/app/handlers.rs (dispatch handler functions — physically here, logically in main.rs via include\\!())\n- src/cli/commands/sync/run.rs (was cli/commands/sync.rs)\n- src/cli/commands/ingest/run.rs (was cli/commands/ingest.rs)\n- src/cli/commands/sync/surgical.rs (was cli/commands/sync_surgical.rs)\n- src/ingestion/orchestrator.rs (~30 LOC for region wrapping)\n- src/ingestion/surgical.rs\n\n## Testing\n- cargo check --all-targets\n- cargo test (all existing tests must pass)\n- Manual: lore sync with Ctrl+C mid-batch — verify graceful drain\n\n## Depends On\n- Phase 3c (entrypoint provides Cx)","status":"closed","priority":1,"issue_type":"task","created_at":"2026-03-06T18:41:08.482800Z","created_by":"tayloreernisse","updated_at":"2026-03-06T21:11:12.644670Z","closed_at":"2026-03-06T21:11:12.644623Z","close_reason":"asupersync migration complete — 5 commits pushed to asupersync-migration branch","compaction_level":0,"original_size":0,"labels":["asupersync","phase-3"],"dependencies":[{"issue_id":"bd-zgke","depends_on_id":"bd-1lj5","type":"parent-child","created_at":"2026-03-06T18:41:08.486544Z","created_by":"tayloreernisse"},{"issue_id":"bd-zgke","depends_on_id":"bd-2wok","type":"blocks","created_at":"2026-03-06T18:42:53.810343Z","created_by":"tayloreernisse"}]}
{"id":"bd-zibc","title":"WHO: VALID_COMMANDS + robot-docs manifest","description":"## Background\n\nRegister the who command in main.rs so that typo suggestions work and robot-docs manifest includes the command for agent self-discovery.\n\n## Approach\n\n### 1. VALID_COMMANDS array (~line 471 in suggest_similar_command):\nAdd \"who\" after \"timeline\":\n```rust\nconst VALID_COMMANDS: &[&str] = &[\n    \"issues\", \"mrs\", /* ... existing ... */ \"timeline\", \"who\",\n];\n```\n\n### 2. robot-docs manifest (handle_robot_docs, after \"timeline\" entry):\n```json\n\"who\": {\n    \"description\": \"People intelligence: experts, workload, active discussions, overlap, review patterns\",\n    \"flags\": [\"<target>\", \"--path <path>\", \"--active\", \"--overlap <path>\", \"--reviews\", \"--since <duration>\", \"-p/--project\", \"-n/--limit\"],\n    \"modes\": {\n        \"expert\": \"lore who <file-path> — Who knows about this area? (also: --path for root files)\",\n        \"workload\": \"lore who <username> — What is someone working on?\",\n        \"reviews\": \"lore who <username> --reviews — Review pattern analysis\",\n        \"active\": \"lore who --active — Active unresolved discussions\",\n        \"overlap\": \"lore who --overlap <path> — Who else is touching these files?\"\n    },\n    \"example\": \"lore --robot who src/features/auth/\",\n    \"response_schema\": {\n        \"ok\": \"bool\",\n        \"data\": {\n            \"mode\": \"string\",\n            \"input\": {\"target\": \"string|null\", \"path\": \"string|null\", \"project\": \"string|null\", \"since\": \"string|null\", \"limit\": \"int\"},\n            \"resolved_input\": {\"mode\": \"string\", \"project_id\": \"int|null\", \"project_path\": \"string|null\", \"since_ms\": \"int\", \"since_iso\": \"string\", \"since_mode\": \"string (default|explicit|none)\", \"limit\": \"int\"},\n            \"...\": \"mode-specific fields\"\n        },\n        \"meta\": {\"elapsed_ms\": \"int\"}\n    }\n}\n```\n\n### 3. workflows JSON — add people_intelligence:\n```json\n\"people_intelligence\": [\n    \"lore --robot who src/path/to/feature/\",\n    \"lore --robot who @username\",\n    \"lore --robot who @username --reviews\",\n    \"lore --robot who --active --since 7d\",\n    \"lore --robot who --overlap src/path/\",\n    \"lore --robot who --path README.md\"\n]\n```\n\n## Files\n\n- `src/main.rs`\n\n## TDD Loop\n\nVERIFY: `cargo check && cargo run --release -- robot-docs | python3 -c \"import json,sys; d=json.load(sys.stdin); assert 'who' in d['commands']\"`\n\n## Acceptance Criteria\n\n- [ ] \"who\" in VALID_COMMANDS\n- [ ] `lore robot-docs` JSON contains who command with all 5 modes\n- [ ] workflows contains people_intelligence array\n- [ ] cargo check passes\n\n## Edge Cases\n\n- The VALID_COMMANDS array is used for typo suggestion via Levenshtein distance — ensure \"who\" does not collide with other short commands (it does not; closest is \"show\" at distance 2)\n- robot-docs JSON is constructed via serde_json::json!() macro inside a raw string — ensure no trailing commas or JSON syntax errors in the manually-written JSON block\n- The response_schema in robot-docs is documentation-only (not validated at runtime) — ensure it matches actual output structure from bd-3mj2\n- If handle_robot_docs location has changed since plan was written, search for \"robot-docs\" or \"robot_docs\" in main.rs to find current location","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-08T02:41:35.098890Z","created_by":"tayloreernisse","updated_at":"2026-02-08T04:10:29.601819Z","closed_at":"2026-02-08T04:10:29.601785Z","close_reason":"Implemented by agent team: migration 017, CLI skeleton, all 5 query modes, human+robot output, 20 tests. All quality gates pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-zibc","depends_on_id":"bd-2rk9","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
{"id":"bd-zqpf","title":"WHO: Expert mode query (query_expert)","description":"## Background\n\nExpert mode answers \"Who should I talk to about this feature/file?\" by analyzing DiffNote activity at a given path. It scores users by a combination of review breadth (distinct MRs reviewed), authorship breadth (distinct MRs authored), and review intensity (DiffNote count). This is the primary use case for lore who.\n\n## Approach\n\nSingle CTE with two UNION ALL branches (reviewer + author), then SQL-level aggregation, scoring, sorting, and LIMIT.\n\n### Key SQL pattern (prefix variant — exact variant replaces LIKE with =):\n\n```sql\nWITH activity AS (\n    -- Reviewer branch: DiffNotes on other people's MRs\n    SELECT n.author_username AS username, 'reviewer' AS role,\n           COUNT(DISTINCT m.id) AS mr_cnt, COUNT(*) AS note_cnt,\n           MAX(n.created_at) AS last_seen_at\n    FROM notes n\n    JOIN discussions d ON n.discussion_id = d.id\n    JOIN merge_requests m ON d.merge_request_id = m.id\n    WHERE n.note_type = 'DiffNote' AND n.is_system = 0\n      AND n.author_username IS NOT NULL\n      AND (m.author_username IS NULL OR n.author_username != m.author_username)  -- self-review exclusion\n      AND m.state IN ('opened','merged')\n      AND n.position_new_path LIKE ?1 ESCAPE '\\'\n      AND n.created_at >= ?2\n      AND (?3 IS NULL OR n.project_id = ?3)\n    GROUP BY n.author_username\n\n    UNION ALL\n\n    -- Author branch: MR authors with DiffNote activity at this path\n    SELECT m.author_username AS username, 'author' AS role,\n           COUNT(DISTINCT m.id) AS mr_cnt, 0 AS note_cnt,\n           MAX(n.created_at) AS last_seen_at\n    FROM merge_requests m\n    JOIN discussions d ON d.merge_request_id = m.id\n    JOIN notes n ON n.discussion_id = d.id\n    WHERE n.note_type = 'DiffNote' AND n.is_system = 0\n      AND m.author_username IS NOT NULL\n      AND n.position_new_path LIKE ?1 ESCAPE '\\'\n      AND n.created_at >= ?2\n      AND (?3 IS NULL OR n.project_id = ?3)\n    GROUP BY m.author_username\n)\nSELECT username,\n       SUM(CASE WHEN role='reviewer' THEN mr_cnt ELSE 0 END) AS review_mr_count,\n       SUM(CASE WHEN role='reviewer' THEN note_cnt ELSE 0 END) AS review_note_count,\n       SUM(CASE WHEN role='author' THEN mr_cnt ELSE 0 END) AS author_mr_count,\n       MAX(last_seen_at) AS last_seen_at,\n       (SUM(CASE WHEN role='reviewer' THEN mr_cnt ELSE 0 END) * 20 +\n        SUM(CASE WHEN role='author' THEN mr_cnt ELSE 0 END) * 12 +\n        SUM(CASE WHEN role='reviewer' THEN note_cnt ELSE 0 END) * 1) AS score\nFROM activity\nGROUP BY username\nORDER BY score DESC, last_seen_at DESC, username ASC\nLIMIT ?4\n```\n\n### Two static SQL strings selected via `if pq.is_prefix { sql_prefix } else { sql_exact }` — the only difference is LIKE vs = on position_new_path. Both use prepare_cached().\n\n### Scoring formula: review_mr * 20 + author_mr * 12 + review_notes * 1\n- MR breadth dominates (prevents \"comment storm\" gaming)\n- Integer arithmetic (no f64 display issues)\n\n### LIMIT+1 truncation pattern:\n```rust\nlet limit_plus_one = (limit + 1) as i64;\n// ... query with limit_plus_one ...\nlet truncated = experts.len() > limit;\nlet experts = experts.into_iter().take(limit).collect();\n```\n\n### ExpertResult struct:\n```rust\npub struct ExpertResult {\n    pub path_query: String,\n    pub path_match: String,  // \"exact\" or \"prefix\"\n    pub experts: Vec<Expert>,\n    pub truncated: bool,\n}\npub struct Expert {\n    pub username: String, pub score: i64,\n    pub review_mr_count: u32, pub review_note_count: u32,\n    pub author_mr_count: u32, pub last_seen_ms: i64,\n}\n```\n\n## Files\n\n- `src/cli/commands/who.rs`\n\n## TDD Loop\n\nRED:\n```\ntest_expert_query — insert project, MR, discussion, 3 DiffNotes; verify expert ranking\ntest_expert_excludes_self_review_notes — author_a comments on own MR; review_mr_count must be 0\ntest_expert_truncation — 3 experts, limit=2 -> truncated=true, len=2; limit=10 -> false\n```\n\nGREEN: Implement query_expert with both SQL variants\nVERIFY: `cargo test -- expert`\n\n## Acceptance Criteria\n\n- [ ] test_expert_query passes (reviewer_b ranked first by score)\n- [ ] test_expert_excludes_self_review_notes passes (author_a has review_mr_count=0)\n- [ ] test_expert_truncation passes (truncated flag correct at both limits)\n- [ ] Default since window: 6m\n\n## Edge Cases\n\n- Self-review: MR author commenting on own diff must NOT count as reviewer (filter n.author_username != m.author_username with IS NULL guard on m.author_username)\n- MR state: only 'opened' and 'merged' — closed/unmerged MRs are noise\n- Project scoping is on n.project_id (not m.project_id) to maximize index usage\n- Author branch also filters n.is_system = 0 for consistency","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-08T02:40:20.990590Z","created_by":"tayloreernisse","updated_at":"2026-02-08T04:10:29.596337Z","closed_at":"2026-02-08T04:10:29.596299Z","close_reason":"Implemented by agent team: migration 017, CLI skeleton, all 5 query modes, human+robot output, 20 tests. All quality gates pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-zqpf","depends_on_id":"bd-2ldg","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"},{"issue_id":"bd-zqpf","depends_on_id":"bd-34rr","type":"blocks","created_at":"2026-03-04T20:02:52Z","created_by":"import"}]}
