From 94c8613420e4a7967c03d0804fc989fe6b04bbdc Mon Sep 17 00:00:00 2001 From: teernisse Date: Thu, 12 Feb 2026 14:32:11 -0500 Subject: [PATCH] feat(bd-226s): implement time-decay expert scoring model Replace 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). Changes: - ScoringConfig: 8 new fields with validation (config.rs) - half_life_decay() and normalize_query_path() pure functions (who.rs) - CTE-based SQL with dual-path matching, mr_activity, reviewer_participation (who.rs) - Rust-side decay aggregation with deterministic f64 ordering (who.rs) - Path resolution probes check old_path columns (who.rs) - Migration 026: 5 new indexes for dual-path and reviewer participation - Default --since changed from 6m to 24m - 31 new tests (example-based + invariant), 621 total who tests passing - Autocorrect registry updated with new flags Closes: bd-226s, bd-2w1p, bd-1soz, bd-18dn, bd-2ao4, bd-2yu5, bd-1b50, bd-1hoq, bd-1h3f, bd-13q8, bd-11mg, bd-1vti, bd-1j5o --- .beads/issues.jsonl | 30 +- .beads/last-touched | 2 +- migrations/026_scoring_indexes.sql | 20 + plans/time-decay-expert-scoring.md | 2 +- src/cli/autocorrect.rs | 4 + src/cli/commands/who.rs | 3037 ++++++++++++++++++++++++++-- src/cli/mod.rs | 20 + src/core/config.rs | 225 +++ src/core/db.rs | 4 + 9 files changed, 3133 insertions(+), 211 deletions(-) create mode 100644 migrations/026_scoring_indexes.sql diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 781f634..6afe48b 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,11 +1,11 @@ {"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 {\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 {\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 = {\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::, _>>()?\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> = \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, // 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-01-26T22:08:54.915469Z","created_by":"tayloreernisse"},{"issue_id":"bd-10f","depends_on_id":"bd-ser","type":"blocks","created_at":"2026-01-26T22:08:54.805860Z","created_by":"tayloreernisse"}]} {"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-01-26T22:08:55.875790Z","created_by":"tayloreernisse"}]} -{"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 as_of_ms, explain_score, and include_bots params (from bd-13q8).\n\n## Approach\nModify WhoArgs struct and run_who() (who.rs:275-405). 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,\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:275-405):\n1. Default --since: \"6m\" -> \"24m\" at line 309\n2. Parse --as-of: RFC3339 or YYYY-MM-DD (append T23:59:59.999Z) -> i64 millis. Default: now_ms()\n3. Parse --all-history: set since_ms = 0\n4. Thread as_of_ms, explain_score, include_bots through to query_expert()\n\n### Robot JSON resolved_input additions:\nscoring_model_version: 2, as_of_ms/iso, window metadata, since_mode, excluded_usernames_applied\n\n### Robot JSON per-expert (explain_score): score_raw + components object\n### Human output (explain_score): parenthetical after score\n\n## TDD Loop\n\n### RED (write these 8 tests first):\n```rust\n#[test]\nfn test_explain_score_components_sum_to_total() {\n let conn = setup_test_db();\n let now = now_ms();\n insert_project(&conn, 1, \"team/backend\");\n insert_mr_at(&conn, 1, 1, 100, \"alice\", \"merged\", now - 30*DAY_MS, Some(now - 30*DAY_MS), None);\n insert_file_change(&conn, 1, 1, \"src/app.rs\", \"modified\");\n insert_reviewer(&conn, 1, \"bob\");\n insert_discussion(&conn, 1, 1, Some(1), None, true, false);\n insert_diffnote_at(&conn, 1, 1, 1, \"bob\", \"src/app.rs\", None, \"substantive review comment here\", now - 30*DAY_MS);\n let result = query_expert(&conn, \"src/app.rs\", None, 0, now, 20, &default_scoring(), false, true, false).unwrap();\n for expert in &result.experts {\n let c = expert.components.as_ref().unwrap();\n let sum = c.author + c.reviewer_participated + c.reviewer_assigned + c.notes;\n assert!((sum - expert.score_raw.unwrap()).abs() < 0.001,\n \"Components don't sum to total for {}: {sum} vs {}\", expert.username, expert.score_raw.unwrap());\n }\n}\n\n#[test]\nfn test_as_of_produces_deterministic_results() {\n // Two runs with same as_of -> identical. Later as_of -> lower scores (more decay).\n let conn = setup_test_db();\n let now = now_ms();\n let as_of = now - 60*DAY_MS; // 60 days ago\n // ... setup data at known timestamps ...\n let r1 = query_expert(&conn, \"src/app.rs\", None, 0, as_of, 20, &default_scoring(), false, false, false).unwrap();\n let r2 = query_expert(&conn, \"src/app.rs\", None, 0, as_of, 20, &default_scoring(), false, false, false).unwrap();\n assert_eq!(r1.experts.len(), r2.experts.len());\n for (a, b) in r1.experts.iter().zip(r2.experts.iter()) {\n assert_eq!(a.username, b.username);\n assert_eq!(a.score, b.score);\n }\n}\n\n#[test]\nfn test_as_of_excludes_future_events() {\n // Event after as_of is excluded entirely, not just decayed.\n // ... insert event at T2 > as_of ...\n // ... query with as_of between T1 and T2 ...\n // ... assert only T1 event appears ...\n}\n\n#[test]\nfn test_as_of_exclusive_upper_bound() {\n // Event at exactly as_of_ms is excluded (strict <, not <=).\n}\n\n#[test]\nfn test_since_relative_to_as_of_clock() {\n // --since window calculated from as_of, not wall clock.\n}\n\n#[test]\nfn test_explain_and_detail_are_mutually_exclusive() {\n // Clap try_parse with both flags should fail.\n use clap::Parser;\n let result = WhoArgs::try_parse_from([\"who\", \"--mode\", \"expert\", \"--path\", \"x\", \"--explain-score\", \"--detail\"]);\n assert!(result.is_err());\n}\n\n#[test]\nfn test_excluded_usernames_filters_bots() {\n // \"renovate-bot\" in excluded_usernames -> filtered from results.\n // \"jsmith\" -> present.\n}\n\n#[test]\nfn test_include_bots_flag_disables_filtering() {\n // Same setup but include_bots=true -> both appear.\n}\n```\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_explain_and_detail test_excluded_usernames 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 in resolved_input\n- [ ] Robot JSON includes score_raw + components when --explain-score\n- [ ] Human output appends component parenthetical when --explain-score\n\n## Files\n- src/cli/commands/who.rs (WhoArgs struct, run_who at 275-405, robot/human output rendering)\n\n## Edge Cases\n- YYYY-MM-DD parsing: chrono NaiveDate::parse_from_str then to end-of-day UTC\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- test_explain_and_detail: needs WhoArgs accessible for try_parse_from — check if it's public or test-accessible","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-09T17:00:11.115322Z","created_by":"tayloreernisse","updated_at":"2026-02-09T17:16:32.629084Z","compaction_level":0,"original_size":0,"labels":["cli","scoring"],"dependencies":[{"issue_id":"bd-11mg","depends_on_id":"bd-13q8","type":"blocks","created_at":"2026-02-09T17:01:11.315245Z","created_by":"tayloreernisse"}]} +{"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,\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-02-09T17:01:11.315245Z","created_by":"tayloreernisse"},{"issue_id":"bd-11mg","depends_on_id":"bd-18dn","type":"blocks","created_at":"2026-02-12T19:34:52.083812Z","created_by":"tayloreernisse"}]} {"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-02-04T15:55:02.450343Z","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 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 --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-01-24T16:13:09.370408Z","created_by":"tayloreernisse"}]} {"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 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,\n}\n```\n\n### 2. Update IssueDetail struct (line ~59)\n```rust\npub struct IssueDetail {\n // ... existing fields ...\n pub closing_merge_requests: Vec, // 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,\n}\n```\n\n### 4. Update IssueDetailJson struct (line ~770)\n```rust\npub struct IssueDetailJson {\n // ... existing fields ...\n pub closing_merge_requests: Vec, // 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> {\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::, _>>()?;\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 ` 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 ` 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 . 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:776-803) 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:637-810) to:\n1. Call build_expert_sql() instead of the inline SQL\n2. Execute and iterate rows: (username, signal, mr_id, qty, ts, mr_state)\n3. Accumulate into per-user UserAccum structs\n4. Compute decayed scores with deterministic ordering\n5. 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,\n since_ms: i64,\n as_of_ms: i64, // NEW\n limit: usize,\n scoring: &ScoringConfig,\n detail: bool,\n explain_score: bool, // NEW\n include_bots: bool, // NEW\n) -> Result\n```\n\n### Per-user accumulator:\n```rust\nstruct UserAccum {\n author_mrs: HashMap,\n reviewer_participated: HashMap,\n reviewer_assigned: HashMap,\n notes_per_mr: HashMap,\n last_seen: i64,\n components: Option<[f64; 4]>, // [author, review_part, review_asgn, notes] when explain\n}\n```\n\n### Signal routing:\n- 'diffnote_author' / 'file_author' -> author_mrs\n- 'diffnote_reviewer' / 'file_reviewer_participated' -> reviewer_participated\n- 'file_reviewer_assigned' -> reviewer_assigned (skip if mr_id in reviewer_participated)\n- 'note_group' -> notes_per_mr\n\n### Deterministic score computation:\nSort each HashMap's entries by mr_id ASC, then sum:\n```\nstate_mult = if state == \"closed\" { closed_mr_multiplier } else { 1.0 }\n```\n\n### Expert struct additions (who.rs:141-154):\n```rust\npub score_raw: Option,\npub components: Option,\n```\n\n### Bot filtering:\nPost-query: if !include_bots, filter excluded_usernames (case-insensitive .to_lowercase()).\n\n## TDD Loop\n\n### RED (write these 13 tests first):\n\n**Core decay integration:**\n```rust\n#[test]\nfn test_expert_scores_decay_with_time() {\n // Two authors: recent (10d ago) vs old (360d ago). Recent scores ~24, old ~6.\n let conn = setup_test_db();\n let now = now_ms();\n insert_project(&conn, 1, \"team/backend\");\n insert_mr_at(&conn, 1, 1, 100, \"recent_author\", \"merged\", now - 10*DAY_MS, Some(now - 10*DAY_MS), None);\n insert_mr_at(&conn, 2, 1, 101, \"old_author\", \"merged\", now - 360*DAY_MS, Some(now - 360*DAY_MS), None);\n insert_file_change(&conn, 1, 1, \"src/app.rs\", \"modified\");\n insert_file_change(&conn, 2, 1, \"src/app.rs\", \"modified\");\n let result = query_expert(&conn, \"src/app.rs\", None, 0, now, 20, &default_scoring(), false, false, false).unwrap();\n assert_eq!(result.experts[0].username, \"recent_author\");\n assert!(result.experts[0].score > result.experts[1].score);\n // recent: 25 * 2^(-10/180) ≈ 24.1\n // old: 25 * 2^(-360/180) = 25 * 0.25 = 6.25\n}\n```\n\n**Plus these 12 additional tests** (listed with key assertion, full bodies follow the pattern above):\n- test_expert_reviewer_decays_faster_than_author: author > reviewer at 90d\n- test_reviewer_participated_vs_assigned_only: participated ~10*decay > 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 not created_at\n- test_old_path_match_credits_expertise: query old path -> author appears\n- test_closed_mr_multiplier: closed MR contributes at 0.5x merged\n- test_trivial_note_does_not_count_as_participation: 4-char \"LGTM\" -> assigned-only\n- test_null_timestamp_fallback_to_created_at: merged MR with NULL merged_at\n- test_row_order_independence: same data, 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\n### GREEN: Wire build_expert_sql into query_expert, implement UserAccum + scoring loop.\n### VERIFY: `cargo test -p lore -- test_expert_scores_decay test_reviewer_participated test_note_diminishing test_file_change_timestamp test_open_mr_uses test_old_path_match test_closed_mr test_trivial_note test_null_timestamp test_row_order test_reviewer_split test_deterministic`\n\n## Acceptance Criteria\n- [ ] All 13 tests pass green\n- [ ] Existing who tests pass (decay ~1.0 for now_ms() data, integer scores unchanged)\n- [ ] Score uses f64 with decay, sorted before rounding\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\n## Files\n- src/cli/commands/who.rs (query_expert at 637-810, Expert struct at 141-154)\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() — enables bd-11mg's --as-of","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-09T17:00:01.764110Z","created_by":"tayloreernisse","updated_at":"2026-02-09T17:17:23.593173Z","compaction_level":0,"original_size":0,"labels":["scoring"],"dependencies":[{"issue_id":"bd-13q8","depends_on_id":"bd-1hoq","type":"blocks","created_at":"2026-02-09T17:01:11.214908Z","created_by":"tayloreernisse"},{"issue_id":"bd-13q8","depends_on_id":"bd-1soz","type":"blocks","created_at":"2026-02-09T17:01:11.268428Z","created_by":"tayloreernisse"},{"issue_id":"bd-13q8","depends_on_id":"bd-2yu5","type":"blocks","created_at":"2026-02-09T17:17:23.593155Z","created_by":"tayloreernisse"}]} +{"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,\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\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, // mr_id -> (max_ts, state_mult)\n reviewer_participated: HashMap, // mr_id -> (max_ts, state_mult)\n reviewer_assigned: HashMap, // mr_id -> (max_ts, state_mult)\n notes_per_mr: HashMap, // 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, // unrounded f64, only when explain_score\npub components: Option, // 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-02-09T17:01:11.214908Z","created_by":"tayloreernisse"},{"issue_id":"bd-13q8","depends_on_id":"bd-1soz","type":"blocks","created_at":"2026-02-09T17:01:11.268428Z","created_by":"tayloreernisse"},{"issue_id":"bd-13q8","depends_on_id":"bd-2yu5","type":"blocks","created_at":"2026-02-09T17:17:23.593155Z","created_by":"tayloreernisse"}]} {"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","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":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T17:05:28.130516Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:11:38.546708Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-14hv","depends_on_id":"bd-1b6k","type":"blocks","created_at":"2026-02-12T18:11:38.546682Z","created_by":"tayloreernisse"},{"issue_id":"bd-14hv","depends_on_id":"bd-wnuo","type":"blocks","created_at":"2026-02-12T17:10:02.986572Z","created_by":"tayloreernisse"}]} {"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 ` 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":"open","priority":1,"issue_type":"feature","created_at":"2026-02-02T21:31:01.094024Z","created_by":"tayloreernisse","updated_at":"2026-02-05T20:56:53.434796Z","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-02-02T21:34:16.913465Z","created_by":"tayloreernisse"},{"issue_id":"bd-14q","depends_on_id":"bd-2zl","type":"blocks","created_at":"2026-02-02T21:34:16.870058Z","created_by":"tayloreernisse"}]} @@ -14,11 +14,12 @@ {"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,\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-02-04T15:53:55.471849Z","created_by":"tayloreernisse"}]} {"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-01-25T17:04:05.749433Z","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 widget: generic over row type\n- TableRow trait: fn cells(&self) -> Vec, 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\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":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T16:58:07.586225Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:11:28.085981Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-18qs","depends_on_id":"bd-1cl9","type":"blocks","created_at":"2026-02-12T18:11:28.085954Z","created_by":"tayloreernisse"},{"issue_id":"bd-18qs","depends_on_id":"bd-6pmy","type":"blocks","created_at":"2026-02-12T17:09:48.569569Z","created_by":"tayloreernisse"}]} {"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,\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-01-30T15:29:15.947679Z","created_by":"tayloreernisse"}]} {"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> 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), 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-02-12T17:04:49.598730Z","created_by":"tayloreernisse"},{"issue_id":"bd-18yh","depends_on_id":"bd-3cjp","type":"blocks","created_at":"2026-02-12T17:04:50.015759Z","created_by":"tayloreernisse"}]} {"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-02-04T15:55:20.162069Z","created_by":"tayloreernisse"},{"issue_id":"bd-1b0n","depends_on_id":"bd-3er","type":"parent-child","created_at":"2026-02-04T15:54:32.110706Z","created_by":"tayloreernisse"}]} -{"id":"bd-1b50","title":"Update existing tests for new ScoringConfig fields","description":"## Background\nThe existing test test_expert_scoring_weights_are_configurable (who.rs:3508-3531) constructs a ScoringConfig with only the original 3 fields. After bd-2w1p adds 8 new fields, this test won't compile without ..Default::default().\n\n## Approach\nFind the test at who.rs:3508-3531. The flipped config construction:\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 — it likely calls ScoringConfig::default() which already works.\n\n### Why existing assertions don't 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\n## Files\n- src/cli/commands/who.rs (test at lines 3508-3531, any other ScoringConfig literals in tests)\n\n## Edge Cases\n- Search for ALL ScoringConfig { ... } literals in test module — there may be more than one\n- The default_scoring() helper may need updating if it creates ScoringConfig without Default","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-09T17:00:45.084472Z","created_by":"tayloreernisse","updated_at":"2026-02-09T17:09:18.813359Z","compaction_level":0,"original_size":0,"labels":["scoring","test"],"dependencies":[{"issue_id":"bd-1b50","depends_on_id":"bd-2w1p","type":"blocks","created_at":"2026-02-09T17:01:11.362893Z","created_by":"tayloreernisse"}]} +{"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-02-09T17:01:11.362893Z","created_by":"tayloreernisse"}]} {"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":"open","priority":1,"issue_type":"epic","created_at":"2026-02-12T17:04:04.486702Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:11:51.508682Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-1b6k","depends_on_id":"bd-3t6r","type":"blocks","created_at":"2026-02-12T18:11:51.508655Z","created_by":"tayloreernisse"}]} {"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, status_category: Option,\n status_color: Option, status_icon_name: Option,\n status_synced_at: Option\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-02-11T06:42:16.216809Z","created_by":"tayloreernisse"},{"issue_id":"bd-1b91","depends_on_id":"bd-3dum","type":"blocks","created_at":"2026-02-11T06:42:44.444990Z","created_by":"tayloreernisse"}]} {"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-01-24T16:13:10.427307Z","created_by":"tayloreernisse"},{"issue_id":"bd-1cb","depends_on_id":"bd-1l1","type":"blocks","created_at":"2026-01-24T16:13:10.478469Z","created_by":"tayloreernisse"},{"issue_id":"bd-1cb","depends_on_id":"bd-3ng","type":"blocks","created_at":"2026-01-24T16:13:10.461940Z","created_by":"tayloreernisse"},{"issue_id":"bd-1cb","depends_on_id":"bd-epj","type":"blocks","created_at":"2026-01-24T16:13:10.443612Z","created_by":"tayloreernisse"}]} @@ -33,13 +34,13 @@ {"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","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-01-24T16:13:10.058655Z","created_by":"tayloreernisse"},{"issue_id":"bd-1gu","depends_on_id":"bd-1l1","type":"blocks","created_at":"2026-01-24T16:13:10.077581Z","created_by":"tayloreernisse"}]} {"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 }\n ProjectNode { work_items: Option } (serde rename workItems)\n WorkItemConnection { nodes: Vec, page_info: PageInfo } (serde rename pageInfo)\n WorkItemNode { iid: String, widgets: Vec }\n PageInfo { end_cursor: Option, has_next_page: bool } (serde renames)\n StatusWidget { status: Option }\n\nPublic types:\n UnsupportedReason enum: GraphqlEndpointMissing, AuthForbidden (Debug, Clone)\n FetchStatusResult struct:\n statuses: HashMap\n all_fetched_iids: HashSet\n unsupported_reason: Option\n partial_error_count: usize\n first_partial_error: Option\n\nis_complexity_or_timeout_error(msg) -> bool: lowercase contains \"complexity\" or \"timeout\"\n\nfetch_issue_statuses(client, project_path) -> Result:\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 — match __typename field, then deserialize matching widgets\n- let-chain syntax: if is_status_widget && let Ok(sw) = serde_json::from_value::(...)\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-02-11T06:42:41.801667Z","created_by":"tayloreernisse"},{"issue_id":"bd-1gvg","depends_on_id":"bd-2y79","type":"parent-child","created_at":"2026-02-11T06:42:00.389311Z","created_by":"tayloreernisse"}]} -{"id":"bd-1h3f","title":"Add rename awareness to path resolution probes","description":"## Background\nThe path resolution layer (build_path_query at who.rs:467-578 and suffix_probe at who.rs:594-625) 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-535):\n\nProbe 1 (exact_exists, ~line 476-497):\n- Notes query: add OR position_old_path = ?1\n- File changes query: add OR old_path = ?1\n\nProbe 2 (prefix_exists, ~line 500-526):\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:594-625):\n\nAdd two UNION branches to the existing query (who.rs:599-611):\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### Current SuffixResult enum (who.rs:581-590):\nNo changes needed.\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 // File was renamed: old_path=src/old/foo.rs -> new_path=src/new/foo.rs\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 here\", 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 // Suffix probe for \"utils.rs\" should find both old and new paths\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 // If deduped to one, either is acceptable\n assert\\!(p == \"src/utils.rs\" || p == \"legacy/utils.rs\");\n }\n other => panic\\!(\"Expected Ambiguous or Unique, got {other:?}\"),\n }\n\n // Suffix probe for \"legacy/utils.rs\" should resolve uniquely via old_path\n let result = suffix_probe(&conn, \"legacy/utils.rs\", None).unwrap();\n assert\\!(matches\\!(result, SuffixResult::Unique(ref p) if p == \"legacy/utils.rs\"));\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 (exact + prefix resolution via old_path)\n- [ ] test_suffix_probe_uses_old_path_sources passes (old_path appears in suffix candidates)\n- [ ] Existing path probe tests still pass (NULL old_path never matches, no false positives)\n- [ ] No changes to PathQuery or SuffixResult enums\n\n## Files\n- src/cli/commands/who.rs (lines 467-535: build_path_query probes, lines 594-625: suffix_probe)\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- Need insert_file_change_with_old_path helper from bd-2yu5","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-09T16:59:51.706482Z","created_by":"tayloreernisse","updated_at":"2026-02-09T17:17:23.544945Z","compaction_level":0,"original_size":0,"labels":["scoring"],"dependencies":[{"issue_id":"bd-1h3f","depends_on_id":"bd-2ao4","type":"blocks","created_at":"2026-02-09T17:01:11.161008Z","created_by":"tayloreernisse"},{"issue_id":"bd-1h3f","depends_on_id":"bd-2yu5","type":"blocks","created_at":"2026-02-09T17:17:23.544925Z","created_by":"tayloreernisse"}]} +{"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-02-09T17:01:11.161008Z","created_by":"tayloreernisse"},{"issue_id":"bd-1h3f","depends_on_id":"bd-2yu5","type":"blocks","created_at":"2026-02-09T17:17:23.544925Z","created_by":"tayloreernisse"}]} {"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","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:637-810 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_count) where params are: ?1=path, ?2=since_ms, ?3=project_id, ?4=as_of_ms\nfn build_expert_sql(path_op: &str, reviewer_min_note_chars: u32) -> String {\n // ... format the SQL with {path_op} and {reviewer_min_note_chars} and {state_aware_ts} inlined\n}\n```\n\n### SQL structure (7 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. reviewer_participation: substantive DiffNotes WHERE LENGTH(TRIM(body)) >= {reviewer_min_note_chars}\n6. raw: 5 signals (diffnote_reviewer, diffnote_author, file_author, file_reviewer_participated, file_reviewer_assigned) with mr_state\n7. aggregated: MR-level GROUP BY + note_group with COUNT\n\n{state_aware_ts} = CASE WHEN m.state='merged' THEN COALESCE(m.merged_at, m.created_at) WHEN m.state='closed' THEN COALESCE(m.closed_at, m.created_at) ELSE COALESCE(m.updated_at, m.created_at) END\n\nReturns rows: (username TEXT, signal TEXT, mr_id INTEGER, qty INTEGER, ts INTEGER, mr_state TEXT)\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 // MR authored by alice, reviewed by bob (assigned), carol left DiffNote\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\", 20);\n let mut stmt = conn.prepare(&sql).unwrap();\n let rows: Vec<(String, String, i64, i64, i64, String)> = stmt\n .query_map(\n rusqlite::params\\![\"src/app.rs\", 0_i64, Option::::None, now_ms() + 1000],\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 should appear as file_author\n assert\\!(rows.iter().any(|(u, s, ..)| u == \"alice\" && s == \"file_author\"));\n // carol should appear as file_reviewer_participated (left substantive DiffNote)\n assert\\!(rows.iter().any(|(u, s, ..)| u == \"carol\" && s == \"file_reviewer_participated\"));\n // bob should appear as file_reviewer_assigned (no DiffNotes)\n assert\\!(rows.iter().any(|(u, s, ..)| u == \"bob\" && s == \"file_reviewer_assigned\"));\n // carol should appear as diffnote_reviewer with note_group\n assert\\!(rows.iter().any(|(u, s, ..)| u == \"carol\" && s == \"note_group\"));\n // alice should appear as diffnote_author (MR author on DiffNote thread)\n assert\\!(rows.iter().any(|(u, s, ..)| u == \"alice\" && s == \"diffnote_author\"));\n // All rows have mr_state = \"merged\"\n assert\\!(rows.iter().all(|(.., state)| state == \"merged\"));\n}\n```\n\n### GREEN: Implement build_expert_sql() with the 7 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 appear correctly)\n- [ ] SQL compiles against :memory: DB with indexes from bd-2ao4\n- [ ] 6 columns returned: username, signal, mr_id, qty, ts, mr_state\n- [ ] reviewer_participation CTE correctly classifies carol as participated (>= 20 chars) and bob as assigned-only\n- [ ] Existing query_expert() and all existing tests UNTOUCHED (still pass)\n- [ ] build_expert_sql() is a pure function (no side effects, no Connection param)\n\n## Files\n- src/cli/commands/who.rs (new build_expert_sql function + test)\n\n## Edge Cases\n- {reviewer_min_note_chars} inlined as SQL literal (not bound param) to avoid param index shifting\n- COALESCE fallback to created_at for NULL merged_at/closed_at\n- Dedup in matched_notes/matched_file_changes prevents double-counting when old_path = new_path","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-09T16:59:44.665314Z","created_by":"tayloreernisse","updated_at":"2026-02-09T17:14:51.065926Z","compaction_level":0,"original_size":0,"labels":["scoring"],"dependencies":[{"issue_id":"bd-1hoq","depends_on_id":"bd-1soz","type":"blocks","created_at":"2026-02-09T17:01:11.108727Z","created_by":"tayloreernisse"},{"issue_id":"bd-1hoq","depends_on_id":"bd-2ao4","type":"blocks","created_at":"2026-02-09T17:01:11.053353Z","created_by":"tayloreernisse"},{"issue_id":"bd-1hoq","depends_on_id":"bd-2w1p","type":"blocks","created_at":"2026-02-09T17:01:10.996731Z","created_by":"tayloreernisse"}]} +{"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::::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-02-09T17:01:11.108727Z","created_by":"tayloreernisse"},{"issue_id":"bd-1hoq","depends_on_id":"bd-2ao4","type":"blocks","created_at":"2026-02-09T17:01:11.053353Z","created_by":"tayloreernisse"},{"issue_id":"bd-1hoq","depends_on_id":"bd-2w1p","type":"blocks","created_at":"2026-02-09T17:01:10.996731Z","created_by":"tayloreernisse"}]} {"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 ` 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":"open","priority":1,"issue_type":"feature","created_at":"2026-02-02T21:31:01.141053Z","created_by":"tayloreernisse","updated_at":"2026-02-05T20:57:12.357740Z","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-02-02T21:34:38.033428Z","created_by":"tayloreernisse"},{"issue_id":"bd-1ht","depends_on_id":"bd-1se","type":"blocks","created_at":"2026-02-02T21:34:37.987232Z","created_by":"tayloreernisse"}]} {"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 = 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::, _>>()?;\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-01-30T15:29:35.105551Z","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 015 (mr_file_changes), fetch_mr_diffs, drain_mr_diffs — this test validates the full chain\n- Migrations 001-014 exist. Migration 015 (bd-1oo) adds mr_file_changes + commit SHAs.\n- Orchestrator has drain_resource_events() and drain_mr_closes_issues(). Gate 4 adds drain_mr_diffs().\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 migrations (001-015)\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 (bd-jec)\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 migrations 001-015\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 migrations 001-015, 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- tests/phase_b_integration.rs (NEW)\n\n## TDD Loop\n\nRED: Write test with all assertions — may fail if Gate 4 draining not yet wired.\n\nGREEN: Fix pipeline wiring (drain_mr_diffs in orchestrator).\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","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-02T22:42:26.355071Z","created_by":"tayloreernisse","updated_at":"2026-02-05T20:16:55.266005Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1j1","depends_on_id":"bd-1ji","type":"blocks","created_at":"2026-02-02T22:43:27.941002Z","created_by":"tayloreernisse"},{"issue_id":"bd-1j1","depends_on_id":"bd-1se","type":"parent-child","created_at":"2026-02-02T22:43:40.577709Z","created_by":"tayloreernisse"},{"issue_id":"bd-1j1","depends_on_id":"bd-3ia","type":"blocks","created_at":"2026-02-02T22:43:28.048311Z","created_by":"tayloreernisse"},{"issue_id":"bd-1j1","depends_on_id":"bd-8t4","type":"blocks","created_at":"2026-02-02T22:43:27.996061Z","created_by":"tayloreernisse"}]} -{"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 27+ 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:\n- matched_notes_raw branch 1 uses 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- Document 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 index usage 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":"open","priority":3,"issue_type":"task","created_at":"2026-02-09T17:00:59.287720Z","created_by":"tayloreernisse","updated_at":"2026-02-09T18:07:46.553898Z","compaction_level":0,"original_size":0,"labels":["scoring"],"dependencies":[{"issue_id":"bd-1j5o","depends_on_id":"bd-1b50","type":"blocks","created_at":"2026-02-09T17:01:11.693095Z","created_by":"tayloreernisse"},{"issue_id":"bd-1j5o","depends_on_id":"bd-1vti","type":"blocks","created_at":"2026-02-09T17:01:11.600519Z","created_by":"tayloreernisse"}]} +{"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-02-09T17:01:11.693095Z","created_by":"tayloreernisse"},{"issue_id":"bd-1j5o","depends_on_id":"bd-1vti","type":"blocks","created_at":"2026-02-09T17:01:11.600519Z","created_by":"tayloreernisse"}]} {"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>;\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-01-30T15:29:35.034753Z","created_by":"tayloreernisse"},{"issue_id":"bd-1je","depends_on_id":"bd-mem","type":"blocks","created_at":"2026-01-30T15:29:35.071573Z","created_by":"tayloreernisse"}]} {"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, // None = same project\n}\n\nlazy_static! {\n static ref MENTIONED_RE: Regex = Regex::new(\n r\"mentioned in (?:(?P[\\w\\-]+/[\\w\\-]+))?(?P[#!])(?P\\d+)\"\n ).unwrap();\n static ref CLOSED_BY_RE: Regex = Regex::new(\n r\"closed by (?:(?P[\\w\\-]+/[\\w\\-]+))?(?P[#!])(?P\\d+)\"\n ).unwrap();\n}\n\n/// Parse a system note body for cross-references.\npub fn parse_cross_refs(body: &str) -> Vec\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\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-02-02T21:32:33.665218Z","created_by":"tayloreernisse"},{"issue_id":"bd-1ji","depends_on_id":"bd-hu3","type":"blocks","created_at":"2026-02-02T22:41:50.672947Z","created_by":"tayloreernisse"}]} {"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>`\n - Uses `bm25(documents_fts)` for ranking\n - Uses `snippet(documents_fts, 1, '', '', '...', 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 `` 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-01-30T15:29:24.374108Z","created_by":"tayloreernisse"}]} @@ -59,6 +60,7 @@ {"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-02-12T17:04:47.854894Z","created_by":"tayloreernisse"},{"issue_id":"bd-1oi7","depends_on_id":"bd-22ai","type":"blocks","created_at":"2026-02-12T17:04:49.940178Z","created_by":"tayloreernisse"},{"issue_id":"bd-1oi7","depends_on_id":"bd-ef0u","type":"blocks","created_at":"2026-02-12T17:04:49.301709Z","created_by":"tayloreernisse"}]} {"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-02-02T21:34:08.843541Z","created_by":"tayloreernisse"},{"issue_id":"bd-1oo","depends_on_id":"bd-hu3","type":"blocks","created_at":"2026-02-02T21:34:16.505965Z","created_by":"tayloreernisse"}]} {"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 \", \"--author/-a \", \"--note-type \", \"--contains \", \"--for-issue \", \"--for-mr \", \"-p/--project \", \"--since \", \"--until \", \"--path \", \"--resolution \", \"--sort \", \"--asc\", \"--include-system\", \"--note-id \", \"--gitlab-note-id \", \"--discussion-id \", \"--format \", \"--fields \", \"--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-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` 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`:\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` — 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` 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` (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 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-02-12T19:29:46.129189Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-1pzj","depends_on_id":"bd-3ddw","type":"blocks","created_at":"2026-02-12T19:29:46.129012Z","created_by":"tayloreernisse"}]} {"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, // min(note.created_at) in ms epoch\n pub last_note_at: Option, // 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, // \"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,\n pub resolved_at: Option,\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\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-01-25T17:04:05.347218Z","created_by":"tayloreernisse"}]} {"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), current (Screen), forward_stack (Vec), jump_list (Vec), jump_index (usize), browse_snapshots (HashMap)\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: pops from back_stack, pushes current to forward_stack\n- go_forward() -> Option: 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":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T16:56:01.365386Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:11:25.569021Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-1qpp","depends_on_id":"bd-2tr4","type":"blocks","created_at":"2026-02-12T18:11:25.568994Z","created_by":"tayloreernisse"},{"issue_id":"bd-1qpp","depends_on_id":"bd-c9gk","type":"blocks","created_at":"2026-02-12T17:09:39.257349Z","created_by":"tayloreernisse"}]} @@ -68,7 +70,7 @@ {"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","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-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-02-02T21:32:43.028033Z","created_by":"tayloreernisse"}]} {"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)\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":"open","priority":3,"issue_type":"task","created_at":"2026-02-12T17:03:37.555484Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:11:34.537405Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-1ser","depends_on_id":"bd-1df9","type":"blocks","created_at":"2026-02-12T18:11:34.537376Z","created_by":"tayloreernisse"},{"issue_id":"bd-1ser","depends_on_id":"bd-26lp","type":"blocks","created_at":"2026-02-12T17:10:02.914142Z","created_by":"tayloreernisse"}]} -{"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":"open","priority":2,"issue_type":"task","created_at":"2026-02-09T16:59:22.913281Z","created_by":"tayloreernisse","updated_at":"2026-02-09T17:13:52.211331Z","compaction_level":0,"original_size":0,"labels":["scoring"]} +{"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 ` 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-01-26T22:08:55.778989Z","created_by":"tayloreernisse"}]} {"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","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-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\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\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> {\n use rusqlite::OptionalExtension;\n let hash: Option = 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\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-01-30T15:29:16.020686Z","created_by":"tayloreernisse"},{"issue_id":"bd-1u1","depends_on_id":"bd-247","type":"blocks","created_at":"2026-01-30T15:29:15.982772Z","created_by":"tayloreernisse"},{"issue_id":"bd-1u1","depends_on_id":"bd-2fp","type":"blocks","created_at":"2026-01-30T15:29:16.055043Z","created_by":"tayloreernisse"}]} @@ -77,7 +79,7 @@ {"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\": [\"\", \"-p \", \"--since \", \"--depth \", \"--expand-mentions\", \"-n \"],\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\": [\"\", \"-p \", \"--discussions\", \"--no-follow-renames\", \"--merged\", \"-n \"],\n \"example\": \"lore --robot file-history src/auth/oauth.rs\"\n},\n\"trace\": {\n \"description\": \"Trace file -> MR -> issue -> discussions decision chain\",\n \"flags\": [\"\", \"-p \", \"--discussions\", \"--no-follow-renames\", \"-n \"],\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 '' for chronological event history\",\n \"lore file-history for file-level MR history\",\n \"lore trace 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":"open","priority":3,"issue_type":"task","created_at":"2026-02-02T22:43:07.859092Z","created_by":"tayloreernisse","updated_at":"2026-02-05T20:17:38.827205Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1v8","depends_on_id":"bd-1ht","type":"parent-child","created_at":"2026-02-02T22:43:40.760196Z","created_by":"tayloreernisse"},{"issue_id":"bd-1v8","depends_on_id":"bd-2ez","type":"blocks","created_at":"2026-02-02T22:43:33.990140Z","created_by":"tayloreernisse"},{"issue_id":"bd-1v8","depends_on_id":"bd-2n4","type":"blocks","created_at":"2026-02-02T22:43:33.937157Z","created_by":"tayloreernisse"}]} {"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 (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,\n pub color: Option,\n #[serde(rename = \"iconName\")]\n pub icon_name: Option,\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, 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-02-11T06:41:42.791014Z","created_by":"tayloreernisse"}]} {"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), show_help (bool), terminal_size ((u16, u16))\n- LoadState enum: Idle, LoadingInitial, Refreshing, Error(String)\n- ScreenLoadStateMap: wraps HashMap, 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":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T16:56:42.023482Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:11:25.732861Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-1v9m","depends_on_id":"bd-2tr4","type":"blocks","created_at":"2026-02-12T18:11:25.732834Z","created_by":"tayloreernisse"},{"issue_id":"bd-1v9m","depends_on_id":"bd-c9gk","type":"blocks","created_at":"2026-02-12T17:09:39.276847Z","created_by":"tayloreernisse"}]} -{"id":"bd-1vti","title":"Write decay and scoring example-based tests (TDD)","description":"## Background\nAll implementation beads (bd-1soz through bd-11mg) now 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 (2 + 1 + 1 + 2 + 13 + 8 = 27 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\nThis is NOT a code-writing bead — it's 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\n- [ ] Total test count: existing + 27 new = all pass\n\n## TDD Loop\nN/A — this bead verifies, doesn't write code.\nVERIFY: `cargo test -p lore`\n\n## Files\nNone modified — read-only verification.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-09T17:00:29.453420Z","created_by":"tayloreernisse","updated_at":"2026-02-09T17:16:54.911799Z","compaction_level":0,"original_size":0,"labels":["scoring","test"],"dependencies":[{"issue_id":"bd-1vti","depends_on_id":"bd-11mg","type":"blocks","created_at":"2026-02-09T17:01:11.458083Z","created_by":"tayloreernisse"},{"issue_id":"bd-1vti","depends_on_id":"bd-1b50","type":"blocks","created_at":"2026-02-09T17:16:54.911778Z","created_by":"tayloreernisse"},{"issue_id":"bd-1vti","depends_on_id":"bd-1h3f","type":"blocks","created_at":"2026-02-09T17:01:11.505050Z","created_by":"tayloreernisse"},{"issue_id":"bd-1vti","depends_on_id":"bd-1soz","type":"blocks","created_at":"2026-02-09T17:16:54.816724Z","created_by":"tayloreernisse"},{"issue_id":"bd-1vti","depends_on_id":"bd-2w1p","type":"blocks","created_at":"2026-02-09T17:16:54.864235Z","created_by":"tayloreernisse"},{"issue_id":"bd-1vti","depends_on_id":"bd-2yu5","type":"blocks","created_at":"2026-02-09T17:01:11.409428Z","created_by":"tayloreernisse"}]} +{"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-02-09T17:01:11.458083Z","created_by":"tayloreernisse"},{"issue_id":"bd-1vti","depends_on_id":"bd-18dn","type":"blocks","created_at":"2026-02-12T19:34:52.168390Z","created_by":"tayloreernisse"},{"issue_id":"bd-1vti","depends_on_id":"bd-1b50","type":"blocks","created_at":"2026-02-09T17:16:54.911778Z","created_by":"tayloreernisse"},{"issue_id":"bd-1vti","depends_on_id":"bd-1h3f","type":"blocks","created_at":"2026-02-09T17:01:11.505050Z","created_by":"tayloreernisse"},{"issue_id":"bd-1vti","depends_on_id":"bd-1soz","type":"blocks","created_at":"2026-02-09T17:16:54.816724Z","created_by":"tayloreernisse"},{"issue_id":"bd-1vti","depends_on_id":"bd-2w1p","type":"blocks","created_at":"2026-02-09T17:16:54.864235Z","created_by":"tayloreernisse"},{"issue_id":"bd-1vti","depends_on_id":"bd-2yu5","type":"blocks","created_at":"2026-02-09T17:01:11.409428Z","created_by":"tayloreernisse"}]} {"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)\n3. Implement heuristic query functions in lore core (NOT in TUI crate)\n4. Wire insights into DashboardState as Optional>\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-02-12T18:11:50.980246Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-1wa2","depends_on_id":"bd-35g5","type":"blocks","created_at":"2026-02-12T18:11:50.980054Z","created_by":"tayloreernisse"}]} {"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\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-01-30T15:29:35.287132Z","created_by":"tayloreernisse"},{"issue_id":"bd-1x6","depends_on_id":"bd-1je","type":"blocks","created_at":"2026-01-30T15:29:35.250622Z","created_by":"tayloreernisse"},{"issue_id":"bd-1x6","depends_on_id":"bd-2sx","type":"blocks","created_at":"2026-01-30T15:29:35.179059Z","created_by":"tayloreernisse"},{"issue_id":"bd-1x6","depends_on_id":"bd-38q","type":"blocks","created_at":"2026-01-30T15:29:35.213566Z","created_by":"tayloreernisse"},{"issue_id":"bd-1x6","depends_on_id":"bd-3qs","type":"blocks","created_at":"2026-01-30T15:29:35.144296Z","created_by":"tayloreernisse"}]} {"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"]} @@ -93,7 +95,7 @@ {"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`\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\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-01-26T22:08:54.649094Z","created_by":"tayloreernisse"},{"issue_id":"bd-20h","depends_on_id":"bd-3j6","type":"blocks","created_at":"2026-01-26T22:08:54.686066Z","created_by":"tayloreernisse"},{"issue_id":"bd-20h","depends_on_id":"bd-iba","type":"blocks","created_at":"2026-01-26T22:08:54.722746Z","created_by":"tayloreernisse"}]} {"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, total_count: i64\n- NoteListResultJson: notes: Vec, total_count: i64, showing: usize\n- NoteListFilters: limit (usize), project (Option), author (Option), note_type (Option), include_system (bool), for_issue_iid (Option), for_mr_iid (Option), note_id (Option), gitlab_note_id (Option), discussion_id (Option), since (Option), until (Option), path (Option), contains (Option), resolution (Option), sort (String), order (String)\n\nQuery function pub fn query_notes(conn: &Connection, filters: &NoteListFilters, config: &Config) -> Result:\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-02-12T17:04:48.306654Z","created_by":"tayloreernisse"},{"issue_id":"bd-20p9","depends_on_id":"bd-25hb","type":"blocks","created_at":"2026-02-12T17:04:48.233764Z","created_by":"tayloreernisse"},{"issue_id":"bd-20p9","depends_on_id":"bd-3iod","type":"blocks","created_at":"2026-02-12T17:04:48.158610Z","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-01-30T15:29:15.574576Z","created_by":"tayloreernisse"}]} -{"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 5, target 8)\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\n### Layer 1 — Schema + Helpers (depends on Layer 0)\n- **bd-2ao4** — Add migration for dual-path and reviewer participation indexes\n- **bd-2yu5** — Add timestamp-aware test helpers\n- **bd-1b50** — Update existing tests for new ScoringConfig fields\n\n### Layer 2 — SQL + Path Probes (depends on Layer 1)\n- **bd-1hoq** — Restructure expert SQL with CTE-based dual-path matching\n- **bd-1h3f** — Add rename awareness to path resolution probes\n\n### Layer 3 — Rust Aggregation (depends on Layer 2)\n- **bd-13q8** — Implement Rust-side decay aggregation with reviewer split\n\n### Layer 4 — CLI (depends on Layer 3)\n- **bd-11mg** — Add CLI flags: --as-of, --explain-score, --include-bots, --all-history\n\n### Layer 5 — Verification (depends on Layer 4)\n- **bd-1vti** — Run full test suite and verify all 27+ new tests pass\n- **bd-1j5o** — Quality gates, query plan check, real-world validation\n\n## Files Modified\n- src/core/config.rs (ScoringConfig struct, validation)\n- src/cli/commands/who.rs (decay function, SQL, aggregation, CLI flags, tests)\n- src/core/db.rs (migration registration)\n- migrations/021_scoring_indexes.sql (new indexes)\n\n## Acceptance Criteria\n- [ ] All 27+ 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 indexes used (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\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 uniformly to all signal types\n- Trivial notes (< reviewer_min_note_chars) classified as assigned-only\n- Exclusive upper bound on --as-of prevents future event leakage","status":"open","priority":1,"issue_type":"epic","created_at":"2026-02-09T16:58:58.007560Z","created_by":"tayloreernisse","updated_at":"2026-02-09T18:07:24.278177Z","compaction_level":0,"original_size":0} +{"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,\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","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 {\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 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-02-04T15:55:20.287655Z","created_by":"tayloreernisse"},{"issue_id":"bd-22li","depends_on_id":"bd-3pz","type":"parent-child","created_at":"2026-02-04T15:54:51.365721Z","created_by":"tayloreernisse"},{"issue_id":"bd-22li","depends_on_id":"bd-apmo","type":"blocks","created_at":"2026-02-04T15:55:20.236008Z","created_by":"tayloreernisse"}]} @@ -110,7 +112,7 @@ {"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), 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: 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":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T17:01:05.605968Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:11:33.993830Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-29qw","depends_on_id":"bd-1zow","type":"blocks","created_at":"2026-02-12T17:10:02.833324Z","created_by":"tayloreernisse"},{"issue_id":"bd-29qw","depends_on_id":"bd-nwux","type":"blocks","created_at":"2026-02-12T18:11:33.993788Z","created_by":"tayloreernisse"}]} {"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-01-30T15:29:24.594861Z","created_by":"tayloreernisse"}]} {"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> {\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,\n pub status: String,\n pub command: String,\n pub error: Option,\n pub run_id: Option, // NEW\n pub total_items_processed: i64, // NEW\n pub total_errors: i64, // NEW\n pub stages: Option>, // NEW: parsed from metrics_json\n}\n```\n\n3. Parse metrics_json into Vec:\n```rust\nlet stages: Option> = row.get::<_, Option>(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 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-02-04T15:55:20.449881Z","created_by":"tayloreernisse"},{"issue_id":"bd-2am8","depends_on_id":"bd-3pz","type":"parent-child","created_at":"2026-02-04T15:54:51.468728Z","created_by":"tayloreernisse"}]} -{"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.\n\n## Approach\nCreate migration file migrations/021_scoring_indexes.sql. Add entry to MIGRATIONS array at db.rs:11-68 (currently 20 entries ending with 020). LATEST_SCHEMA_VERSION at db.rs:9 auto-increments via MIGRATIONS.len().\n\n### Migration SQL:\n```sql\n-- Support old_path leg of matched_notes CTE\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-- 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-- 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-- 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\n### MIGRATIONS array addition (db.rs, after line ~68):\n```rust\n(\"021\", include_str!(\"../../migrations/021_scoring_indexes.sql\")),\n```\n\n## Acceptance Criteria\n- [ ] migrations/021_scoring_indexes.sql exists with 4 CREATE INDEX statements\n- [ ] MIGRATIONS array has 21 entries\n- [ ] LATEST_SCHEMA_VERSION == 21\n- [ ] cargo test (migration tests pass on both fresh and migrated :memory: DBs)\n- [ ] Existing indexes unaffected (017_who_indexes.sql untouched)\n\n## Files\n- migrations/021_scoring_indexes.sql (new file)\n- src/core/db.rs (line ~68: add to MIGRATIONS array)\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 in partial indexes)","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-09T16:59:30.746899Z","created_by":"tayloreernisse","updated_at":"2026-02-09T17:06:18.194925Z","compaction_level":0,"original_size":0,"labels":["db","scoring"]} +{"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","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\n- state: String (\"opened\" | \"closed\")\n- created_at, updated_at: String (ISO 8601)\n- closed_at: Option\n- author: GitLabAuthor\n- labels: Vec (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\n\n### GitLabNote\n- id: i64\n- note_type: Option (\"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\n- resolved_at: Option\n- position: Option\n\n### GitLabNotePosition\n- old_path, new_path: Option\n- old_line, new_line: Option\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","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} @@ -156,14 +158,15 @@ {"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":"open","priority":1,"issue_type":"epic","created_at":"2026-02-12T16:55:02.650495Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:11:51.059729Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-2tr4","depends_on_id":"bd-1cj0","type":"blocks","created_at":"2026-02-12T18:11:51.059704Z","created_by":"tayloreernisse"}]} {"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,\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","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:149-173) 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:149. Add new fields with #[serde(default)] and camelCase rename to match existing convention (authorWeight, reviewerWeight, noteBonus). Extend the Default impl at config.rs:167 with new defaults. Extend validate_scoring() at config.rs:245-262 (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, // default: vec![]\n```\n\n### Validation additions to validate_scoring() (config.rs:245):\n- All *_half_life_days must be > 0\n- reviewer_assignment_weight must be >= 0\n- closed_mr_multiplier must be > 0.0 and <= 1.0\n- excluded_usernames entries must be non-empty strings\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 // Default should be valid\n assert!(validate_scoring(&cfg).is_ok());\n\n // Zero half-life -> ConfigInvalid\n cfg.author_half_life_days = 0;\n let err = validate_scoring(&cfg).unwrap_err();\n assert!(matches!(err, LoreError::ConfigInvalid { .. }));\n\n // Reset, try zero reviewer half-life\n cfg.author_half_life_days = 180;\n cfg.reviewer_half_life_days = 0;\n assert!(validate_scoring(&cfg).is_err());\n\n // closed_mr_multiplier out of range\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; // boundary: 1.0 is valid\n assert!(validate_scoring(&cfg).is_ok());\n}\n```\n\nNote: validate_scoring is in config.rs (not who.rs), so this test goes in a #[cfg(test)] mod in config.rs (or use the existing test module pattern in config.rs). Check if config.rs has an existing test module; if not, add one.\n\n### GREEN: Add fields to struct + Default impl + validation rules.\n### VERIFY: `cargo test -p lore -- test_config_validation_rejects_zero_half_life`\n\n## Acceptance Criteria\n- [ ] test_config_validation_rejects_zero_half_life passes (covers all 4 new validation rules)\n- [ ] ScoringConfig::default() returns correct values for all 11 fields\n- [ ] cargo check --all-targets passes (downstream code compiles with ..Default::default())\n- [ ] Existing config deserialization works (serde default fills new fields from old JSON)\n- [ ] validate_scoring() is pub(crate) or accessible from config.rs test module\n\n## Files\n- src/core/config.rs (lines 149-262: struct, Default impl, validate_scoring)\n\n## Edge Cases\n- f64 comparison for closed_mr_multiplier: use > 0.0 and <= 1.0 (not ==)\n- Vec default: use Vec::new()\n- Serde: #[serde(default)] on struct level already present, but new fields still need individual defaults in the Default impl","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-09T16:59:14.654469Z","created_by":"tayloreernisse","updated_at":"2026-02-09T17:14:16.334063Z","compaction_level":0,"original_size":0,"labels":["scoring"]} +{"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, // 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 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-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-02-12T15:46:16.250013Z","created_by":"tayloreernisse"}]} {"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), updated_issue_iids (Vec), new_mr_iids (Vec), updated_mr_iids (Vec)\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\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":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T17:02:09.481354Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:11:34.266057Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-2x2h","depends_on_id":"bd-1df9","type":"blocks","created_at":"2026-02-12T18:11:34.266030Z","created_by":"tayloreernisse"},{"issue_id":"bd-2x2h","depends_on_id":"bd-3le2","type":"blocks","created_at":"2026-02-12T18:11:13.405879Z","created_by":"tayloreernisse"},{"issue_id":"bd-2x2h","depends_on_id":"bd-u7se","type":"blocks","created_at":"2026-02-12T17:10:02.861920Z","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 `, 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":"open","priority":1,"issue_type":"feature","created_at":"2026-02-05T18:32:39.287957Z","created_by":"tayloreernisse","updated_at":"2026-02-10T19:45:28.686499Z","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-02-12T19:29:52.743563Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-2ygk","depends_on_id":"bd-1mju","type":"blocks","created_at":"2026-02-12T19:29:51.843432Z","created_by":"tayloreernisse"},{"issue_id":"bd-2ygk","depends_on_id":"bd-1zow","type":"blocks","created_at":"2026-02-12T19:29:52.419228Z","created_by":"tayloreernisse"},{"issue_id":"bd-2ygk","depends_on_id":"bd-29qw","type":"blocks","created_at":"2026-02-12T19:29:52.498629Z","created_by":"tayloreernisse"},{"issue_id":"bd-2ygk","depends_on_id":"bd-2kr0","type":"blocks","created_at":"2026-02-12T19:29:52.256838Z","created_by":"tayloreernisse"},{"issue_id":"bd-2ygk","depends_on_id":"bd-2nfs","type":"blocks","created_at":"2026-02-12T19:29:51.931101Z","created_by":"tayloreernisse"},{"issue_id":"bd-2ygk","depends_on_id":"bd-2x2h","type":"blocks","created_at":"2026-02-12T19:29:52.743530Z","created_by":"tayloreernisse"},{"issue_id":"bd-2ygk","depends_on_id":"bd-35g5","type":"blocks","created_at":"2026-02-12T19:29:52.013419Z","created_by":"tayloreernisse"},{"issue_id":"bd-2ygk","depends_on_id":"bd-3ei1","type":"blocks","created_at":"2026-02-12T19:29:52.099343Z","created_by":"tayloreernisse"},{"issue_id":"bd-2ygk","depends_on_id":"bd-3t1b","type":"blocks","created_at":"2026-02-12T19:29:52.337215Z","created_by":"tayloreernisse"},{"issue_id":"bd-2ygk","depends_on_id":"bd-8ab7","type":"blocks","created_at":"2026-02-12T19:29:52.178117Z","created_by":"tayloreernisse"},{"issue_id":"bd-2ygk","depends_on_id":"bd-u7se","type":"blocks","created_at":"2026-02-12T19:29:52.580082Z","created_by":"tayloreernisse"},{"issue_id":"bd-2ygk","depends_on_id":"bd-wzqi","type":"blocks","created_at":"2026-02-12T19:29:52.665763Z","created_by":"tayloreernisse"}]} {"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> {\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 {\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-02-02T21:34:08.941359Z","created_by":"tayloreernisse"},{"issue_id":"bd-2yo","depends_on_id":"bd-1oo","type":"blocks","created_at":"2026-02-02T21:34:16.555239Z","created_by":"tayloreernisse"},{"issue_id":"bd-2yo","depends_on_id":"bd-jec","type":"blocks","created_at":"2026-02-02T21:34:16.656402Z","created_by":"tayloreernisse"},{"issue_id":"bd-2yo","depends_on_id":"bd-tir","type":"blocks","created_at":"2026-02-02T21:34:16.605198Z","created_by":"tayloreernisse"}]} {"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","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:2434-2563) 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 two new helpers in the test module (who.rs after line ~2563), patterned after existing helpers:\n\n### insert_mr_at() — variant of insert_mr (who.rs:2434-2451):\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, // NEW: for state-aware timestamp tests\n closed_at_ms: Option, // NEW: for closed MR tests\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:2506-2532):\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>, // NEW: for dual-path matching tests\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### Also add insert_file_change_with_old_path() for rename tests:\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 // Pattern from existing insert_file_change (who.rs:2550-2563) but with old_path\n}\n```\n\n## Acceptance Criteria\n- [ ] insert_mr_at compiles and inserts with explicit 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 old_path\n- [ ] Existing helpers unchanged (no breakage)\n- [ ] New helpers used by bd-1vti tests\n\n## Files\n- src/cli/commands/who.rs (test module, after line ~2563)\n\n## Edge Cases\n- merged_at/closed_at as Option — NULL when not applicable\n- old_path as Option<&str> — NULL when no rename\n- created_at_ms used for both created_at and updated_at on notes (matching existing pattern)","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-09T17:00:19.594086Z","created_by":"tayloreernisse","updated_at":"2026-02-09T17:08:18.098489Z","compaction_level":0,"original_size":0,"labels":["scoring","test"],"dependencies":[{"issue_id":"bd-2yu5","depends_on_id":"bd-2w1p","type":"blocks","created_at":"2026-02-09T17:01:10.940287Z","created_by":"tayloreernisse"}]} +{"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,\n closed_at_ms: Option,\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 — 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-02-09T17:01:10.940287Z","created_by":"tayloreernisse"}]} {"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\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\n- Paginate through discussions for single issue\n- per_page=100\n- Follow x-next-page header\n\n### request_with_headers(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","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","deleted_at":"2026-01-25T15:21:35.156571Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0} @@ -211,6 +214,7 @@ {"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, 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, 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(¬e.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-01-26T22:08:54.207801Z","created_by":"tayloreernisse"},{"issue_id":"bd-3j6","depends_on_id":"bd-5ta","type":"blocks","created_at":"2026-01-26T22:08:54.244201Z","created_by":"tayloreernisse"}]} {"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 ` 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, // opened|merged|closed|locked|all\n #[arg(long)]\n draft: bool,\n #[arg(long)]\n no_draft: bool,\n #[arg(long)]\n author: Option,\n #[arg(long)]\n assignee: Option,\n #[arg(long)]\n reviewer: Option,\n #[arg(long)]\n target_branch: Option,\n #[arg(long)]\n source_branch: Option,\n #[arg(long)]\n label: Vec,\n #[arg(long)]\n project: Option,\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-01-26T22:08:55.209249Z","created_by":"tayloreernisse"},{"issue_id":"bd-3js","depends_on_id":"bd-ser","type":"blocks","created_at":"2026-01-26T22:08:55.117728Z","created_by":"tayloreernisse"}]} {"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-01-24T16:13:10.810953Z","created_by":"tayloreernisse"},{"issue_id":"bd-3kj","depends_on_id":"bd-3ng","type":"blocks","created_at":"2026-01-24T16:13:10.827689Z","created_by":"tayloreernisse"}]} +{"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":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T19:29:40.785182Z","created_by":"tayloreernisse","updated_at":"2026-02-12T19:29:49.341576Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-3l56","depends_on_id":"bd-26lp","type":"blocks","created_at":"2026-02-12T19:29:49.341556Z","created_by":"tayloreernisse"}]} {"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 = std::result::Result;`\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 for GiError` blocks must become `impl From 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), interrupt (Option)\n- TaskSupervisor struct: active (HashMap), 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":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T16:56:21.102488Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:11:25.651333Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-3le2","depends_on_id":"bd-2tr4","type":"blocks","created_at":"2026-02-12T18:11:25.651307Z","created_by":"tayloreernisse"},{"issue_id":"bd-3le2","depends_on_id":"bd-c9gk","type":"blocks","created_at":"2026-02-12T17:09:39.267113Z","created_by":"tayloreernisse"}]} {"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\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,\n #[arg(long)]\n author: Option,\n #[arg(long)]\n project: Option,\n #[arg(long, action = clap::ArgAction::Append)]\n label: Vec,\n #[arg(long)]\n path: Option,\n #[arg(long)]\n after: Option,\n #[arg(long)]\n updated_after: Option,\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-01-30T15:29:24.482877Z","created_by":"tayloreernisse"},{"issue_id":"bd-3lu","depends_on_id":"bd-3q2","type":"blocks","created_at":"2026-01-30T15:29:24.520379Z","created_by":"tayloreernisse"},{"issue_id":"bd-3lu","depends_on_id":"bd-3qs","type":"blocks","created_at":"2026-01-30T15:29:24.556323Z","created_by":"tayloreernisse"}]} @@ -250,6 +254,7 @@ {"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>,\n) -> Result\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>;\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-01-30T15:29:24.697418Z","created_by":"tayloreernisse"},{"issue_id":"bd-am7","depends_on_id":"bd-2ac","type":"blocks","created_at":"2026-01-30T15:29:24.732567Z","created_by":"tayloreernisse"},{"issue_id":"bd-am7","depends_on_id":"bd-335","type":"blocks","created_at":"2026-01-30T15:29:24.660199Z","created_by":"tayloreernisse"}]} {"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-02-04T15:54:51.314770Z","created_by":"tayloreernisse"}]} {"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, // O(1) dedup from the start\n}\n// Build HashMap 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-02-08T02:43:37.563924Z","created_by":"tayloreernisse"},{"issue_id":"bd-b51e","depends_on_id":"bd-34rr","type":"blocks","created_at":"2026-02-08T02:43:37.618217Z","created_by":"tayloreernisse"}]} +{"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`** — 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-02-12T19:29:47.312394Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-bcte","depends_on_id":"bd-18qs","type":"blocks","created_at":"2026-02-12T19:29:47.312364Z","created_by":"tayloreernisse"}]} {"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>\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-01-30T15:29:24.842469Z","created_by":"tayloreernisse"},{"issue_id":"bd-bjo","depends_on_id":"bd-2ac","type":"blocks","created_at":"2026-01-30T15:29:24.878048Z","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 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 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 — GitLab may not provide Retry-After header","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T16:53:37.143607Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:11:22.248862Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-c9gk","depends_on_id":"bd-1cj0","type":"blocks","created_at":"2026-02-12T18:11:22.248836Z","created_by":"tayloreernisse"},{"issue_id":"bd-c9gk","depends_on_id":"bd-3ddw","type":"blocks","created_at":"2026-02-12T17:09:28.566535Z","created_by":"tayloreernisse"}]} {"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","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} @@ -298,6 +303,7 @@ {"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), 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":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T17:01:37.250065Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:11:34.175286Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-wzqi","depends_on_id":"bd-35g5","type":"blocks","created_at":"2026-02-12T17:10:02.852753Z","created_by":"tayloreernisse"},{"issue_id":"bd-wzqi","depends_on_id":"bd-nwux","type":"blocks","created_at":"2026-02-12T18:11:34.175260Z","created_by":"tayloreernisse"}]} {"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, // ms epoch cursor\n cursor_rewind_seconds: u32,\n ) -> Pin> + 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> + Send + '_>>\n\n /// Make request and return response with headers for pagination.\n async fn request_with_headers(\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-01-25T17:04:05.398212Z","created_by":"tayloreernisse"},{"issue_id":"bd-xhz","depends_on_id":"bd-2ys","type":"blocks","created_at":"2026-01-25T17:04:05.371440Z","created_by":"tayloreernisse"}]} {"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, // IIDs of newly created issues\n issues_updated: Vec, // IIDs of updated (not new) issues\n mrs_new: Vec, // IIDs of newly created MRs\n mrs_updated: Vec, // 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, // 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` — new + updated combined\n - `all_changed_mr_iids() -> Vec` — new + updated combined\n - `is_available() -> bool` — true if finalize() was called\n - `fallback_timestamp() -> Option` — 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-02-12T19:29:48.475698Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-y095","depends_on_id":"bd-2x2h","type":"blocks","created_at":"2026-02-12T19:29:48.475674Z","created_by":"tayloreernisse"}]} {"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","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,\n pub unresolved_references: Vec,\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 { ... }\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-02-02T21:33:37.448515Z","created_by":"tayloreernisse"},{"issue_id":"bd-ypa","depends_on_id":"bd-3ia","type":"blocks","created_at":"2026-02-02T21:33:37.528233Z","created_by":"tayloreernisse"},{"issue_id":"bd-ypa","depends_on_id":"bd-ike","type":"parent-child","created_at":"2026-02-02T21:33:08.661036Z","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-01-25T17:04:05.889114Z","created_by":"tayloreernisse"},{"issue_id":"bd-z0s","depends_on_id":"bd-2f0","type":"blocks","created_at":"2026-01-25T17:04:05.841210Z","created_by":"tayloreernisse"},{"issue_id":"bd-z0s","depends_on_id":"bd-39w","type":"blocks","created_at":"2026-01-25T17:04:05.913316Z","created_by":"tayloreernisse"},{"issue_id":"bd-z0s","depends_on_id":"bd-3n1","type":"blocks","created_at":"2026-01-25T17:04:05.817830Z","created_by":"tayloreernisse"},{"issue_id":"bd-z0s","depends_on_id":"bd-o7b","type":"blocks","created_at":"2026-01-25T17:04:05.864480Z","created_by":"tayloreernisse"},{"issue_id":"bd-z0s","depends_on_id":"bd-v6i","type":"blocks","created_at":"2026-01-25T17:04:05.794555Z","created_by":"tayloreernisse"}]} diff --git a/.beads/last-touched b/.beads/last-touched index 423acd6..ea9f7be 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -bd-20p9 +bd-226s diff --git a/migrations/026_scoring_indexes.sql b/migrations/026_scoring_indexes.sql new file mode 100644 index 0000000..04599ba --- /dev/null +++ b/migrations/026_scoring_indexes.sql @@ -0,0 +1,20 @@ +-- Indexes for time-decay expert scoring: dual-path matching and reviewer participation. + +CREATE INDEX IF NOT EXISTS idx_notes_old_path_author + ON notes(position_old_path, author_username, created_at) + WHERE note_type = 'DiffNote' AND is_system = 0 AND position_old_path IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_mfc_old_path_project_mr + ON mr_file_changes(old_path, project_id, merge_request_id) + WHERE old_path IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_mfc_new_path_project_mr + ON mr_file_changes(new_path, project_id, merge_request_id); + +CREATE INDEX IF NOT EXISTS idx_notes_diffnote_discussion_author + ON notes(discussion_id, author_username, created_at) + WHERE note_type = 'DiffNote' AND is_system = 0; + +CREATE INDEX IF NOT EXISTS idx_notes_old_path_project_created + ON notes(position_old_path, project_id, created_at) + WHERE note_type = 'DiffNote' AND is_system = 0 AND position_old_path IS NOT NULL; diff --git a/plans/time-decay-expert-scoring.md b/plans/time-decay-expert-scoring.md index facf601..192a9f2 100644 --- a/plans/time-decay-expert-scoring.md +++ b/plans/time-decay-expert-scoring.md @@ -4,7 +4,7 @@ title: "" status: iterating iteration: 6 target_iterations: 8 -beads_revision: 1 +beads_revision: 2 related_plans: [] created: 2026-02-08 updated: 2026-02-12 diff --git a/src/cli/autocorrect.rs b/src/cli/autocorrect.rs index 9f577c7..ffd0fe5 100644 --- a/src/cli/autocorrect.rs +++ b/src/cli/autocorrect.rs @@ -183,6 +183,10 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[ "--fields", "--detail", "--no-detail", + "--as-of", + "--explain-score", + "--include-bots", + "--all-history", ], ), ("drift", &["--threshold", "--project"]), diff --git a/src/cli/commands/who.rs b/src/cli/commands/who.rs index 53ce477..884deac 100644 --- a/src/cli/commands/who.rs +++ b/src/cli/commands/who.rs @@ -99,6 +99,31 @@ fn normalize_repo_path(input: &str) -> String { s } +/// Normalize a user-supplied query path for scoring and path resolution. +/// +/// Purely syntactic — no filesystem or database lookups: +/// - trims leading/trailing whitespace +/// - strips leading "./" +/// - collapses repeated "//" +/// - preserves trailing "/" (signals explicit prefix intent) +#[allow(dead_code)] // Used by decay scoring pipeline (task #9, bd-13q8) +fn normalize_query_path(input: &str) -> String { + let trimmed = input.trim(); + if trimmed.is_empty() { + return String::new(); + } + let mut result = trimmed.to_string(); + // Strip leading ./ + while result.starts_with("./") { + result = result[2..].to_string(); + } + // Collapse repeated // + while result.contains("//") { + result = result.replace("//", "/"); + } + result +} + // ─── Result Types ──────────────────────────────────────────────────────────── /// Top-level run result: carries resolved inputs + the mode-specific result. @@ -141,6 +166,10 @@ pub struct ExpertResult { pub struct Expert { pub username: String, pub score: i64, + /// Unrounded f64 score (only populated when explain_score is set). + pub score_raw: Option, + /// Per-component score breakdown (only populated when explain_score is set). + pub components: Option, pub review_mr_count: u32, pub review_note_count: u32, pub author_mr_count: u32, @@ -153,6 +182,14 @@ pub struct Expert { pub details: Option>, } +/// Per-component score breakdown for explain mode. +pub struct ScoreComponents { + pub author: f64, + pub reviewer_participated: f64, + pub reviewer_assigned: f64, + pub notes: f64, +} + #[derive(Clone)] pub struct ExpertMrDetail { pub mr_ref: String, @@ -306,16 +343,31 @@ pub fn run_who(config: &Config, args: &WhoArgs) -> Result { match mode { WhoMode::Expert { path } => { - let since_ms = resolve_since(args.since.as_deref(), "6m")?; + let since_ms = if args.all_history { + 0 + } else { + resolve_since(args.since.as_deref(), "24m")? + }; + let as_of_ms = match &args.as_of { + Some(v) => parse_since(v).ok_or_else(|| { + LoreError::Other(format!( + "Invalid --as-of value: '{v}'. Use a duration (30d, 6m) or date (2024-01-15)" + )) + })?, + None => now_ms(), + }; let limit = usize::from(args.limit); let result = query_expert( &conn, &path, project_id, since_ms, + as_of_ms, limit, &config.scoring, args.detail, + args.explain_score, + args.include_bots, )?; Ok(WhoRun { resolved_input: WhoResolvedInput { @@ -473,6 +525,7 @@ fn build_path_query(conn: &Connection, path: &str, project_id: Option) -> R let looks_like_file = !forced_dir && (is_root || last_segment.contains('.')); // Probe 1: exact file exists in DiffNotes OR mr_file_changes (project-scoped) + // Checks both new_path and old_path to support querying renamed files. // Exact-match probes already use the partial index, but LIKE probes below // benefit from the INDEXED BY hint (same planner issue as expert query). let exact_exists = conn @@ -480,7 +533,7 @@ fn build_path_query(conn: &Connection, path: &str, project_id: Option) -> R "SELECT 1 FROM notes INDEXED BY idx_notes_diffnote_path_created WHERE note_type = 'DiffNote' AND is_system = 0 - AND position_new_path = ?1 + AND (position_new_path = ?1 OR position_old_path = ?1) AND (?2 IS NULL OR project_id = ?2) LIMIT 1", rusqlite::params![trimmed, project_id], @@ -490,7 +543,7 @@ fn build_path_query(conn: &Connection, path: &str, project_id: Option) -> R || conn .query_row( "SELECT 1 FROM mr_file_changes - WHERE new_path = ?1 + WHERE (new_path = ?1 OR old_path = ?1) AND (?2 IS NULL OR project_id = ?2) LIMIT 1", rusqlite::params![trimmed, project_id], @@ -499,6 +552,7 @@ fn build_path_query(conn: &Connection, path: &str, project_id: Option) -> R .is_ok(); // Probe 2: directory prefix exists in DiffNotes OR mr_file_changes (project-scoped) + // Checks both new_path and old_path to support querying renamed directories. let prefix_exists = if !forced_dir && !exact_exists { let escaped = escape_like(trimmed); let pat = format!("{escaped}/%"); @@ -506,7 +560,7 @@ fn build_path_query(conn: &Connection, path: &str, project_id: Option) -> R "SELECT 1 FROM notes INDEXED BY idx_notes_diffnote_path_created WHERE note_type = 'DiffNote' AND is_system = 0 - AND position_new_path LIKE ?1 ESCAPE '\\' + AND (position_new_path LIKE ?1 ESCAPE '\\' OR position_old_path LIKE ?1 ESCAPE '\\') AND (?2 IS NULL OR project_id = ?2) LIMIT 1", rusqlite::params![pat, project_id], @@ -516,7 +570,7 @@ fn build_path_query(conn: &Connection, path: &str, project_id: Option) -> R || conn .query_row( "SELECT 1 FROM mr_file_changes - WHERE new_path LIKE ?1 ESCAPE '\\' + WHERE (new_path LIKE ?1 ESCAPE '\\' OR old_path LIKE ?1 ESCAPE '\\') AND (?2 IS NULL OR project_id = ?2) LIMIT 1", rusqlite::params![pat, project_id], @@ -592,6 +646,7 @@ enum SuffixResult { } /// Probe both notes and mr_file_changes for paths ending with the given suffix. +/// Searches both new_path and old_path columns to support renamed file resolution. /// Returns up to 11 distinct candidates (enough to detect ambiguity + show a useful list). fn suffix_probe(conn: &Connection, suffix: &str, project_id: Option) -> Result { let escaped = escape_like(suffix); @@ -609,6 +664,18 @@ fn suffix_probe(conn: &Connection, suffix: &str, project_id: Option) -> Res SELECT new_path AS full_path FROM mr_file_changes WHERE (new_path LIKE ?1 ESCAPE '\\' OR new_path = ?2) AND (?3 IS NULL OR project_id = ?3) + UNION + SELECT position_old_path AS full_path FROM notes + WHERE note_type = 'DiffNote' + AND is_system = 0 + AND position_old_path IS NOT NULL + AND (position_old_path LIKE ?1 ESCAPE '\\' OR position_old_path = ?2) + AND (?3 IS NULL OR project_id = ?3) + UNION + SELECT old_path AS full_path FROM mr_file_changes + WHERE old_path IS NOT NULL + AND (old_path LIKE ?1 ESCAPE '\\' OR old_path = ?2) + AND (?3 IS NULL OR project_id = ?3) ) ORDER BY full_path LIMIT 11", @@ -635,6 +702,27 @@ fn escape_like(input: &str) -> String { .replace('_', "\\_") } +// ─── Scoring Helpers ───────────────────────────────────────────────────────── + +/// Exponential half-life decay: `2^(-days / half_life)`. +/// +/// Returns a value in `[0.0, 1.0]` representing how much of an original signal +/// is retained after `elapsed_ms` milliseconds, given a `half_life_days` period. +/// At `elapsed=0` the signal is fully retained (1.0); at `elapsed=half_life` +/// exactly half remains (0.5); the signal halves again for each additional +/// half-life period. +/// +/// Returns `0.0` when `half_life_days` is zero (prevents division by zero). +/// Negative elapsed values are clamped to zero (future events retain full weight). +fn half_life_decay(elapsed_ms: i64, half_life_days: u32) -> f64 { + let days = (elapsed_ms as f64 / 86_400_000.0).max(0.0); + let hl = f64::from(half_life_days); + if hl <= 0.0 { + return 0.0; + } + 2.0_f64.powf(-days / hl) +} + // ─── Query: Expert Mode ───────────────────────────────────────────────────── #[allow(clippy::too_many_arguments)] @@ -643,179 +731,203 @@ fn query_expert( path: &str, project_id: Option, since_ms: i64, + as_of_ms: i64, limit: usize, scoring: &ScoringConfig, detail: bool, + explain_score: bool, + include_bots: bool, ) -> Result { let pq = build_path_query(conn, path, project_id)?; - let limit_plus_one = (limit + 1) as i64; - - // Build SQL with 4 signal sources (UNION ALL), deduplicating via COUNT(DISTINCT mr_id): - // 1. DiffNote reviewer — left inline review comments (not self-review) - // 2. DiffNote MR author — authored MR that has DiffNotes on this path - // 3. File-change author — authored MR that touched this path (mr_file_changes) - // 4. File-change reviewer — assigned reviewer on MR that touched this path - // Each branch now JOINs projects to produce mr_ref for aggregation. - let path_op = if pq.is_prefix { - "LIKE ?1 ESCAPE '\\'" - } else { - "= ?1" - }; - // When scanning DiffNotes with a LIKE prefix, SQLite's planner picks the - // low-selectivity idx_notes_system (38% of rows) instead of the much more - // selective partial index idx_notes_diffnote_path_created (9.3% of rows). - // INDEXED BY forces the correct index: measured 64x speedup (1.22s → 0.019s). - // For exact matches SQLite already picks the partial index, but the hint - // is harmless and keeps behavior consistent. - let notes_indexed_by = "INDEXED BY idx_notes_diffnote_path_created"; - let author_w = scoring.author_weight; - let reviewer_w = scoring.reviewer_weight; - let note_b = scoring.note_bonus; - let sql = format!( - " - WITH signals AS ( - -- 1. DiffNote reviewer (individual notes for note_cnt) - SELECT - n.author_username AS username, - 'diffnote_reviewer' AS signal, - m.id AS mr_id, - n.id AS note_id, - n.created_at AS seen_at, - (p.path_with_namespace || '!' || CAST(m.iid AS TEXT)) AS mr_ref - FROM notes n {notes_indexed_by} - JOIN discussions d ON n.discussion_id = d.id - JOIN merge_requests m ON d.merge_request_id = m.id - JOIN projects p ON m.project_id = p.id - WHERE n.note_type = 'DiffNote' - AND n.is_system = 0 - AND n.author_username IS NOT NULL - AND (m.author_username IS NULL OR n.author_username != m.author_username) - AND m.state IN ('opened','merged') - AND n.position_new_path {path_op} - AND n.created_at >= ?2 - AND (?3 IS NULL OR n.project_id = ?3) - - UNION ALL - - -- 2. DiffNote MR author - SELECT - m.author_username AS username, - 'diffnote_author' AS signal, - m.id AS mr_id, - NULL AS note_id, - MAX(n.created_at) AS seen_at, - (p.path_with_namespace || '!' || CAST(m.iid AS TEXT)) AS mr_ref - FROM merge_requests m - JOIN discussions d ON d.merge_request_id = m.id - JOIN notes n {notes_indexed_by} ON n.discussion_id = d.id - JOIN projects p ON m.project_id = p.id - WHERE n.note_type = 'DiffNote' - AND n.is_system = 0 - AND m.author_username IS NOT NULL - AND m.state IN ('opened','merged') - AND n.position_new_path {path_op} - AND n.created_at >= ?2 - AND (?3 IS NULL OR n.project_id = ?3) - GROUP BY m.author_username, m.id - - UNION ALL - - -- 3. MR author via file changes - SELECT - m.author_username AS username, - 'file_author' AS signal, - m.id AS mr_id, - NULL AS note_id, - m.updated_at AS seen_at, - (p.path_with_namespace || '!' || CAST(m.iid AS TEXT)) AS mr_ref - FROM mr_file_changes fc - JOIN merge_requests m ON fc.merge_request_id = m.id - JOIN projects p ON m.project_id = p.id - WHERE m.author_username IS NOT NULL - AND m.state IN ('opened','merged') - AND fc.new_path {path_op} - AND m.updated_at >= ?2 - AND (?3 IS NULL OR fc.project_id = ?3) - - UNION ALL - - -- 4. MR reviewer via file changes + mr_reviewers - SELECT - r.username AS username, - 'file_reviewer' AS signal, - m.id AS mr_id, - NULL AS note_id, - m.updated_at AS seen_at, - (p.path_with_namespace || '!' || CAST(m.iid AS TEXT)) AS mr_ref - FROM mr_file_changes fc - JOIN merge_requests m ON fc.merge_request_id = m.id - JOIN projects p ON m.project_id = p.id - JOIN mr_reviewers r ON r.merge_request_id = m.id - WHERE r.username IS NOT NULL - AND (m.author_username IS NULL OR r.username != m.author_username) - AND m.state IN ('opened','merged') - AND fc.new_path {path_op} - AND m.updated_at >= ?2 - AND (?3 IS NULL OR fc.project_id = ?3) - ) - SELECT - username, - COUNT(DISTINCT CASE WHEN signal IN ('diffnote_reviewer', 'file_reviewer') - THEN mr_id END) AS review_mr_count, - COUNT(CASE WHEN signal = 'diffnote_reviewer' THEN note_id END) AS review_note_count, - COUNT(DISTINCT CASE WHEN signal IN ('diffnote_author', 'file_author') - THEN mr_id END) AS author_mr_count, - MAX(seen_at) AS last_seen_at, - ( - (COUNT(DISTINCT CASE WHEN signal IN ('diffnote_reviewer', 'file_reviewer') - THEN mr_id END) * {reviewer_w}) + - (COUNT(DISTINCT CASE WHEN signal IN ('diffnote_author', 'file_author') - THEN mr_id END) * {author_w}) + - (COUNT(CASE WHEN signal = 'diffnote_reviewer' THEN note_id END) * {note_b}) - ) AS score, - GROUP_CONCAT(DISTINCT mr_ref) AS mr_refs_csv - FROM signals - GROUP BY username - ORDER BY score DESC, last_seen_at DESC, username ASC - LIMIT ?4 - " - ); + let sql = build_expert_sql_v2(pq.is_prefix); let mut stmt = conn.prepare_cached(&sql)?; - let experts: Vec = stmt - .query_map( - rusqlite::params![pq.value, since_ms, project_id, limit_plus_one], - |row| { - let mr_refs_csv: Option = row.get(6)?; - let mut mr_refs: Vec = mr_refs_csv - .as_deref() - .map(|csv| csv.split(',').map(|s| s.trim().to_string()).collect()) - .unwrap_or_default(); - mr_refs.sort(); - let mr_refs_total = mr_refs.len() as u32; - let mr_refs_truncated = mr_refs.len() > MAX_MR_REFS_PER_USER; - if mr_refs_truncated { - mr_refs.truncate(MAX_MR_REFS_PER_USER); - } - Ok(Expert { - username: row.get(0)?, - review_mr_count: row.get(1)?, - review_note_count: row.get(2)?, - author_mr_count: row.get(3)?, - last_seen_ms: row.get(4)?, - score: row.get(5)?, - mr_refs, - mr_refs_total, - mr_refs_truncated, - details: None, - }) - }, - )? - .collect::, _>>()?; + // Params: ?1=path, ?2=since_ms, ?3=project_id, ?4=as_of_ms, + // ?5=closed_mr_multiplier, ?6=reviewer_min_note_chars + let rows = stmt.query_map( + rusqlite::params![ + pq.value, + since_ms, + project_id, + as_of_ms, + scoring.closed_mr_multiplier, + scoring.reviewer_min_note_chars, + ], + |row| { + Ok(SignalRow { + username: row.get(0)?, + signal: row.get(1)?, + mr_id: row.get(2)?, + qty: row.get(3)?, + ts: row.get(4)?, + state_mult: row.get(5)?, + }) + }, + )?; - let truncated = experts.len() > limit; - let mut experts: Vec = experts.into_iter().take(limit).collect(); + // Per-user accumulator keyed by username. + let mut accum: HashMap = HashMap::new(); + + for row_result in rows { + let r = row_result?; + let entry = accum + .entry(r.username.clone()) + .or_insert_with(|| UserAccum { + contributions: Vec::new(), + last_seen_ms: 0, + mr_ids_author: HashSet::new(), + mr_ids_reviewer: HashSet::new(), + note_count: 0, + }); + + if r.ts > entry.last_seen_ms { + entry.last_seen_ms = r.ts; + } + + match r.signal.as_str() { + "diffnote_author" | "file_author" => { + entry.mr_ids_author.insert(r.mr_id); + } + "file_reviewer_participated" | "file_reviewer_assigned" => { + entry.mr_ids_reviewer.insert(r.mr_id); + } + "note_group" => { + entry.note_count += r.qty as u32; + // DiffNote reviewers are also reviewer activity. + entry.mr_ids_reviewer.insert(r.mr_id); + } + _ => {} + } + + entry.contributions.push(Contribution { + signal: r.signal, + mr_id: r.mr_id, + qty: r.qty, + ts: r.ts, + state_mult: r.state_mult, + }); + } + + // Bot filtering: exclude configured bot usernames (case-insensitive). + if !include_bots && !scoring.excluded_usernames.is_empty() { + let excluded: HashSet = scoring + .excluded_usernames + .iter() + .map(|u| u.to_lowercase()) + .collect(); + accum.retain(|username, _| !excluded.contains(&username.to_lowercase())); + } + + // Compute decayed scores with deterministic ordering. + let mut scored: Vec = accum + .into_iter() + .map(|(username, mut ua)| { + // Sort contributions by mr_id ASC for deterministic f64 summation. + ua.contributions.sort_by_key(|c| c.mr_id); + + let mut comp_author = 0.0_f64; + let mut comp_reviewer_participated = 0.0_f64; + let mut comp_reviewer_assigned = 0.0_f64; + let mut comp_notes = 0.0_f64; + + for c in &ua.contributions { + let elapsed = as_of_ms - c.ts; + match c.signal.as_str() { + "diffnote_author" | "file_author" => { + let decay = half_life_decay(elapsed, scoring.author_half_life_days); + comp_author += + f64::from(scoring.author_weight as i32) * decay * c.state_mult; + } + "file_reviewer_participated" => { + let decay = half_life_decay(elapsed, scoring.reviewer_half_life_days); + comp_reviewer_participated += + f64::from(scoring.reviewer_weight as i32) * decay * c.state_mult; + } + "file_reviewer_assigned" => { + let decay = + half_life_decay(elapsed, scoring.reviewer_assignment_half_life_days); + comp_reviewer_assigned += + f64::from(scoring.reviewer_assignment_weight as i32) + * decay + * c.state_mult; + } + "note_group" => { + let decay = half_life_decay(elapsed, scoring.note_half_life_days); + // Diminishing returns: log2(1 + count) per MR. + let note_value = (1.0 + c.qty as f64).log2(); + comp_notes += f64::from(scoring.note_bonus as i32) + * note_value + * decay + * c.state_mult; + } + _ => {} + } + } + + let raw_score = + comp_author + comp_reviewer_participated + comp_reviewer_assigned + comp_notes; + ScoredUser { + username, + raw_score, + components: ScoreComponents { + author: comp_author, + reviewer_participated: comp_reviewer_participated, + reviewer_assigned: comp_reviewer_assigned, + notes: comp_notes, + }, + accum: ua, + } + }) + .collect(); + + // Sort: raw_score DESC, last_seen DESC, username ASC (deterministic tiebreaker). + scored.sort_by(|a, b| { + b.raw_score + .partial_cmp(&a.raw_score) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| b.accum.last_seen_ms.cmp(&a.accum.last_seen_ms)) + .then_with(|| a.username.cmp(&b.username)) + }); + + let truncated = scored.len() > limit; + scored.truncate(limit); + + // Build Expert structs with MR refs. + let mut experts: Vec = scored + .into_iter() + .map(|su| { + let mut mr_refs = build_mr_refs_for_user(conn, &su.accum); + mr_refs.sort(); + let mr_refs_total = mr_refs.len() as u32; + let mr_refs_truncated = mr_refs.len() > MAX_MR_REFS_PER_USER; + if mr_refs_truncated { + mr_refs.truncate(MAX_MR_REFS_PER_USER); + } + Expert { + username: su.username, + score: su.raw_score.round() as i64, + score_raw: if explain_score { + Some(su.raw_score) + } else { + None + }, + components: if explain_score { + Some(su.components) + } else { + None + }, + review_mr_count: su.accum.mr_ids_reviewer.len() as u32, + review_note_count: su.accum.note_count, + author_mr_count: su.accum.mr_ids_author.len() as u32, + last_seen_ms: su.accum.last_seen_ms, + mr_refs, + mr_refs_total, + mr_refs_truncated, + details: None, + } + }) + .collect(); // Populate per-MR detail when --detail is requested if detail && !experts.is_empty() { @@ -839,6 +951,513 @@ fn query_expert( }) } +/// Raw signal row from the v2 CTE query. +struct SignalRow { + username: String, + signal: String, + mr_id: i64, + qty: i64, + ts: i64, + state_mult: f64, +} + +/// Per-user signal accumulator used during Rust-side scoring. +struct UserAccum { + contributions: Vec, + last_seen_ms: i64, + mr_ids_author: HashSet, + mr_ids_reviewer: HashSet, + note_count: u32, +} + +/// A single contribution to a user's score (one signal row). +struct Contribution { + signal: String, + mr_id: i64, + qty: i64, + ts: i64, + state_mult: f64, +} + +/// Intermediate scored user before building Expert structs. +struct ScoredUser { + username: String, + raw_score: f64, + components: ScoreComponents, + accum: UserAccum, +} + +/// Build MR refs (e.g. "group/project!123") for a user from their accumulated MR IDs. +fn build_mr_refs_for_user(conn: &Connection, ua: &UserAccum) -> Vec { + let all_mr_ids: HashSet = ua + .mr_ids_author + .iter() + .chain(ua.mr_ids_reviewer.iter()) + .copied() + .chain(ua.contributions.iter().map(|c| c.mr_id)) + .collect(); + + if all_mr_ids.is_empty() { + return Vec::new(); + } + + let placeholders: Vec = (1..=all_mr_ids.len()).map(|i| format!("?{i}")).collect(); + let sql = format!( + "SELECT p.path_with_namespace || '!' || CAST(m.iid AS TEXT) + FROM merge_requests m + JOIN projects p ON m.project_id = p.id + WHERE m.id IN ({})", + placeholders.join(",") + ); + + let mut stmt = match conn.prepare(&sql) { + Ok(s) => s, + Err(_) => return Vec::new(), + }; + + let mut mr_ids_vec: Vec = all_mr_ids.into_iter().collect(); + mr_ids_vec.sort_unstable(); + let params: Vec<&dyn rusqlite::types::ToSql> = mr_ids_vec + .iter() + .map(|id| id as &dyn rusqlite::types::ToSql) + .collect(); + + stmt.query_map(&*params, |row| row.get::<_, String>(0)) + .map(|rows| rows.filter_map(|r| r.ok()).collect()) + .unwrap_or_default() +} + +/// Build the CTE-based expert SQL for time-decay scoring (v2). +/// +/// Returns raw signal rows `(username, signal, mr_id, qty, ts, state_mult)` that +/// Rust aggregates with per-signal decay and `log2(1+count)` for note groups. +/// +/// Parameters: `?1` = path, `?2` = since_ms, `?3` = project_id (nullable), +/// `?4` = as_of_ms, `?5` = closed_mr_multiplier, `?6` = reviewer_min_note_chars +fn build_expert_sql_v2(is_prefix: bool) -> String { + let path_op = if is_prefix { + "LIKE ?1 ESCAPE '\\'" + } else { + "= ?1" + }; + // INDEXED BY hints for each branch: + // - new_path branch: idx_notes_diffnote_path_created (existing) + // - old_path branch: idx_notes_old_path_author (migration 026) + format!( + " +WITH matched_notes_raw AS ( + -- Branch 1: match on position_new_path + SELECT n.id, n.discussion_id, n.author_username, n.created_at, n.project_id + FROM notes n INDEXED BY idx_notes_diffnote_path_created + WHERE n.note_type = 'DiffNote' + AND n.is_system = 0 + AND n.author_username IS NOT NULL + AND n.created_at >= ?2 + AND n.created_at < ?4 + AND (?3 IS NULL OR n.project_id = ?3) + AND n.position_new_path {path_op} + UNION ALL + -- Branch 2: match on position_old_path + SELECT n.id, n.discussion_id, n.author_username, n.created_at, n.project_id + FROM notes n INDEXED BY idx_notes_old_path_author + WHERE n.note_type = 'DiffNote' + AND n.is_system = 0 + AND n.author_username IS NOT NULL + AND n.created_at >= ?2 + AND n.created_at < ?4 + AND (?3 IS NULL OR n.project_id = ?3) + AND n.position_old_path IS NOT NULL + AND n.position_old_path {path_op} +), +matched_notes AS ( + -- Dedup: prevent double-counting when old_path = new_path (no rename) + SELECT DISTINCT id, discussion_id, author_username, created_at, project_id + FROM matched_notes_raw +), +matched_file_changes_raw AS ( + -- Branch 1: match on new_path + SELECT fc.merge_request_id, fc.project_id + FROM mr_file_changes fc INDEXED BY idx_mfc_new_path_project_mr + WHERE (?3 IS NULL OR fc.project_id = ?3) + AND fc.new_path {path_op} + UNION ALL + -- Branch 2: match on old_path + SELECT fc.merge_request_id, fc.project_id + FROM mr_file_changes fc INDEXED BY idx_mfc_old_path_project_mr + WHERE (?3 IS NULL OR fc.project_id = ?3) + AND fc.old_path IS NOT NULL + AND fc.old_path {path_op} +), +matched_file_changes AS ( + -- Dedup: prevent double-counting when old_path = new_path (no rename) + SELECT DISTINCT merge_request_id, project_id + FROM matched_file_changes_raw +), +mr_activity AS ( + -- Centralized state-aware timestamps and state multiplier. + -- Scoped to MRs matched by file changes to avoid materializing the full MR table. + SELECT DISTINCT + m.id AS mr_id, + m.author_username, + m.state, + CASE + WHEN m.state = 'merged' THEN COALESCE(m.merged_at, m.created_at) + WHEN m.state = 'closed' THEN COALESCE(m.closed_at, m.created_at) + ELSE COALESCE(m.updated_at, m.created_at) + END AS activity_ts, + CASE WHEN m.state = 'closed' THEN ?5 ELSE 1.0 END AS state_mult + FROM merge_requests m + JOIN matched_file_changes mfc ON mfc.merge_request_id = m.id + WHERE m.state IN ('opened','merged','closed') +), +reviewer_participation AS ( + -- Precompute which (mr_id, username) pairs have substantive DiffNote participation. + SELECT DISTINCT d.merge_request_id AS mr_id, mn.author_username AS username + FROM matched_notes mn + JOIN discussions d ON mn.discussion_id = d.id + JOIN notes n_body ON mn.id = n_body.id + WHERE d.merge_request_id IS NOT NULL + AND LENGTH(TRIM(COALESCE(n_body.body, ''))) >= ?6 +), +raw AS ( + -- Signal 1: DiffNote reviewer (individual notes for note_cnt) + SELECT mn.author_username AS username, 'diffnote_reviewer' AS signal, + m.id AS mr_id, mn.id AS note_id, mn.created_at AS seen_at, + CASE WHEN m.state = 'closed' THEN ?5 ELSE 1.0 END AS state_mult + FROM matched_notes mn + JOIN discussions d ON mn.discussion_id = d.id + JOIN merge_requests m ON d.merge_request_id = m.id + WHERE (m.author_username IS NULL OR mn.author_username != m.author_username) + AND m.state IN ('opened','merged','closed') + + UNION ALL + + -- Signal 2: DiffNote MR author + SELECT m.author_username AS username, 'diffnote_author' AS signal, + m.id AS mr_id, NULL AS note_id, MAX(mn.created_at) AS seen_at, + CASE WHEN m.state = 'closed' THEN ?5 ELSE 1.0 END AS state_mult + FROM merge_requests m + JOIN discussions d ON d.merge_request_id = m.id + JOIN matched_notes mn ON mn.discussion_id = d.id + WHERE m.author_username IS NOT NULL + AND m.state IN ('opened','merged','closed') + GROUP BY m.author_username, m.id + + UNION ALL + + -- Signal 3: MR author via file changes (uses mr_activity CTE) + SELECT a.author_username AS username, 'file_author' AS signal, + a.mr_id, NULL AS note_id, + a.activity_ts AS seen_at, a.state_mult + FROM mr_activity a + WHERE a.author_username IS NOT NULL + AND a.activity_ts >= ?2 + AND a.activity_ts < ?4 + + UNION ALL + + -- Signal 4a: Reviewer participated (in mr_reviewers AND left DiffNotes on path) + SELECT r.username AS username, 'file_reviewer_participated' AS signal, + a.mr_id, NULL AS note_id, + a.activity_ts AS seen_at, a.state_mult + FROM mr_activity a + JOIN mr_reviewers r ON r.merge_request_id = a.mr_id + JOIN reviewer_participation rp ON rp.mr_id = a.mr_id AND rp.username = r.username + WHERE r.username IS NOT NULL + AND (a.author_username IS NULL OR r.username != a.author_username) + AND a.activity_ts >= ?2 + AND a.activity_ts < ?4 + + UNION ALL + + -- Signal 4b: Reviewer assigned-only (in mr_reviewers, NO DiffNotes on path) + SELECT r.username AS username, 'file_reviewer_assigned' AS signal, + a.mr_id, NULL AS note_id, + a.activity_ts AS seen_at, a.state_mult + FROM mr_activity a + JOIN mr_reviewers r ON r.merge_request_id = a.mr_id + LEFT JOIN reviewer_participation rp ON rp.mr_id = a.mr_id AND rp.username = r.username + WHERE rp.username IS NULL + AND r.username IS NOT NULL + AND (a.author_username IS NULL OR r.username != a.author_username) + AND a.activity_ts >= ?2 + AND a.activity_ts < ?4 +), +aggregated AS ( + -- MR-level signals: 1 row per (username, signal_class, mr_id) with MAX(ts) + SELECT username, signal, mr_id, 1 AS qty, MAX(seen_at) AS ts, MAX(state_mult) AS state_mult + FROM raw WHERE signal != 'diffnote_reviewer' + GROUP BY username, signal, mr_id + UNION ALL + -- Note signals: 1 row per (username, mr_id) with note_count and max_ts + SELECT username, 'note_group' AS signal, mr_id, COUNT(*) AS qty, MAX(seen_at) AS ts, + MAX(state_mult) AS state_mult + FROM raw WHERE signal = 'diffnote_reviewer' AND note_id IS NOT NULL + GROUP BY username, mr_id +) +SELECT username, signal, mr_id, qty, ts, state_mult FROM aggregated WHERE username IS NOT NULL + " + ) +} + +/// Per-user signal accumulator for decay-based scoring. +#[allow(dead_code)] // Wired into production in a later task +struct UserAccumulator { + /// (mr_id -> (max_ts, state_mult)) for MRs where user is the author + author_mrs: HashMap, + /// (mr_id -> (max_ts, state_mult)) for MRs where user left substantive DiffNotes + reviewer_participated: HashMap, + /// (mr_id -> (max_ts, state_mult)) for MRs where user was assigned but no DiffNotes + reviewer_assigned: HashMap, + /// (mr_id -> (note_count, max_ts, state_mult)) for DiffNote groups + notes_per_mr: HashMap, + /// Max timestamp across all signals + last_seen: i64, +} + +#[allow(dead_code)] +impl UserAccumulator { + fn new() -> Self { + Self { + author_mrs: HashMap::new(), + reviewer_participated: HashMap::new(), + reviewer_assigned: HashMap::new(), + notes_per_mr: HashMap::new(), + last_seen: 0, + } + } + + /// Compute the decayed score with deterministic ordering (sorted by mr_id). + fn compute_score(&self, scoring: &ScoringConfig, as_of_ms: i64) -> f64 { + let mut total = 0.0_f64; + + // Author contributions: sorted by mr_id for determinism + let mut author_keys: Vec = self.author_mrs.keys().copied().collect(); + author_keys.sort_unstable(); + for mr_id in author_keys { + let (ts, state_mult) = self.author_mrs[&mr_id]; + let elapsed = as_of_ms - ts; + total += (scoring.author_weight as f64) + * state_mult + * half_life_decay(elapsed, scoring.author_half_life_days); + } + + // Reviewer participated contributions + let mut part_keys: Vec = self.reviewer_participated.keys().copied().collect(); + part_keys.sort_unstable(); + for mr_id in part_keys { + let (ts, state_mult) = self.reviewer_participated[&mr_id]; + let elapsed = as_of_ms - ts; + total += (scoring.reviewer_weight as f64) + * state_mult + * half_life_decay(elapsed, scoring.reviewer_half_life_days); + } + + // Reviewer assigned-only contributions + let mut assigned_keys: Vec = self.reviewer_assigned.keys().copied().collect(); + assigned_keys.sort_unstable(); + for mr_id in assigned_keys { + let (ts, state_mult) = self.reviewer_assigned[&mr_id]; + let elapsed = as_of_ms - ts; + total += (scoring.reviewer_assignment_weight as f64) + * state_mult + * half_life_decay(elapsed, scoring.reviewer_assignment_half_life_days); + } + + // Note contributions: log2(1 + count) diminishing returns per MR + let mut note_keys: Vec = self.notes_per_mr.keys().copied().collect(); + note_keys.sort_unstable(); + for mr_id in note_keys { + let (count, ts, state_mult) = self.notes_per_mr[&mr_id]; + let elapsed = as_of_ms - ts; + total += (scoring.note_bonus as f64) + * state_mult + * (1.0 + f64::from(count)).log2() + * half_life_decay(elapsed, scoring.note_half_life_days); + } + + total + } + + fn review_mr_count(&self) -> u32 { + (self.reviewer_participated.len() + self.reviewer_assigned.len()) as u32 + } + + fn review_note_count(&self) -> u32 { + self.notes_per_mr.values().map(|(count, _, _)| count).sum() + } + + fn author_mr_count(&self) -> u32 { + self.author_mrs.len() as u32 + } +} + +/// Query expert scores using decay-based CTE SQL (v2). +/// +/// Parameters match `query_expert` plus `as_of_ms` for reproducible scoring. +/// The function runs the v2 SQL, accumulates per-user signals, and applies +/// exponential half-life decay in Rust with deterministic ordering. +#[allow(clippy::too_many_arguments, dead_code)] +fn query_expert_v2( + conn: &Connection, + path: &str, + project_id: Option, + since_ms: i64, + as_of_ms: i64, + limit: usize, + scoring: &ScoringConfig, + detail: bool, +) -> Result { + let pq = build_path_query(conn, path, project_id)?; + let sql = build_expert_sql_v2(pq.is_prefix); + + let mut stmt = conn.prepare_cached(&sql)?; + let rows = stmt.query_map( + rusqlite::params![ + pq.value, + since_ms, + project_id, + as_of_ms, + scoring.closed_mr_multiplier, + scoring.reviewer_min_note_chars, + ], + |row| { + Ok(( + row.get::<_, String>(0)?, // username + row.get::<_, String>(1)?, // signal + row.get::<_, i64>(2)?, // mr_id + row.get::<_, i64>(3)?, // qty + row.get::<_, i64>(4)?, // ts + row.get::<_, f64>(5)?, // state_mult + )) + }, + )?; + + // Accumulate per-user signals + let mut accumulators: HashMap = HashMap::new(); + for row_result in rows { + let (username, signal, mr_id, qty, ts, state_mult) = row_result?; + let acc = accumulators + .entry(username) + .or_insert_with(UserAccumulator::new); + + if ts > acc.last_seen { + acc.last_seen = ts; + } + + match signal.as_str() { + "diffnote_author" | "file_author" => { + let entry = acc.author_mrs.entry(mr_id).or_insert((0, 0.0)); + if ts > entry.0 { + entry.0 = ts; + } + if state_mult > entry.1 { + entry.1 = state_mult; + } + } + "file_reviewer_participated" => { + let entry = acc.reviewer_participated.entry(mr_id).or_insert((0, 0.0)); + if ts > entry.0 { + entry.0 = ts; + } + if state_mult > entry.1 { + entry.1 = state_mult; + } + } + "file_reviewer_assigned" => { + // Only add to assigned if not already in participated for this MR + if !acc.reviewer_participated.contains_key(&mr_id) { + let entry = acc.reviewer_assigned.entry(mr_id).or_insert((0, 0.0)); + if ts > entry.0 { + entry.0 = ts; + } + if state_mult > entry.1 { + entry.1 = state_mult; + } + } + } + "note_group" => { + let entry = acc.notes_per_mr.entry(mr_id).or_insert((0, 0, 0.0)); + entry.0 += qty as u32; + if ts > entry.1 { + entry.1 = ts; + } + if state_mult > entry.2 { + entry.2 = state_mult; + } + } + _ => {} // Unknown signal type -- skip + } + } + + // Filter excluded usernames + let excluded: HashSet = scoring + .excluded_usernames + .iter() + .map(|u| u.to_lowercase()) + .collect(); + if !excluded.is_empty() { + accumulators.retain(|username, _| !excluded.contains(&username.to_lowercase())); + } + + // Compute scores and build Expert vec + let mut scored: Vec<(String, f64, UserAccumulator)> = accumulators + .into_iter() + .map(|(username, acc)| { + let score = acc.compute_score(scoring, as_of_ms); + (username, score, acc) + }) + .collect(); + + // Sort: raw f64 score DESC, last_seen DESC, username ASC + scored.sort_by(|a, b| { + b.1.partial_cmp(&a.1) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| b.2.last_seen.cmp(&a.2.last_seen)) + .then_with(|| a.0.cmp(&b.0)) + }); + + let truncated = scored.len() > limit; + scored.truncate(limit); + + let mut experts: Vec = scored + .into_iter() + .map(|(username, raw_score, acc)| Expert { + username, + score: raw_score.round() as i64, + score_raw: None, + components: None, + review_mr_count: acc.review_mr_count(), + review_note_count: acc.review_note_count(), + author_mr_count: acc.author_mr_count(), + last_seen_ms: acc.last_seen, + mr_refs: Vec::new(), + mr_refs_total: 0, + mr_refs_truncated: false, + details: None, + }) + .collect(); + + // Populate per-MR detail when --detail is requested + if detail && !experts.is_empty() { + let details_map = query_expert_details(conn, &pq, &experts, since_ms, project_id)?; + for expert in &mut experts { + expert.details = details_map.get(&expert.username).cloned(); + } + } + + Ok(ExpertResult { + path_query: if pq.is_prefix { + path.trim_end_matches('/').to_string() + } else { + pq.value.clone() + }, + path_match: if pq.is_prefix { "prefix" } else { "exact" }.to_string(), + experts, + truncated, + }) +} + /// Query per-MR detail for a set of experts. Returns a map of username -> Vec. fn query_expert_details( conn: &Connection, @@ -2196,6 +2815,10 @@ pub fn print_who_json(run: &WhoRun, args: &WhoArgs, elapsed_ms: u64) { "since": args.since, "limit": args.limit, "detail": args.detail, + "as_of": args.as_of, + "explain_score": args.explain_score, + "include_bots": args.include_bots, + "all_history": args.all_history, }); // Resolved/computed values -- what actually ran @@ -2264,6 +2887,7 @@ fn expert_to_json(r: &ExpertResult) -> serde_json::Value { serde_json::json!({ "path_query": r.path_query, "path_match": r.path_match, + "scoring_model_version": 2, "truncated": r.truncated, "experts": r.experts.iter().map(|e| { let mut obj = serde_json::json!({ @@ -2277,6 +2901,17 @@ fn expert_to_json(r: &ExpertResult) -> serde_json::Value { "mr_refs_total": e.mr_refs_total, "mr_refs_truncated": e.mr_refs_truncated, }); + if let Some(raw) = e.score_raw { + obj["score_raw"] = serde_json::json!(raw); + } + if let Some(comp) = &e.components { + obj["components"] = serde_json::json!({ + "author": comp.author, + "reviewer_participated": comp.reviewer_participated, + "reviewer_assigned": comp.reviewer_assigned, + "notes": comp.notes, + }); + } if let Some(details) = &e.details { obj["details"] = serde_json::json!(details.iter().map(|d| serde_json::json!({ "mr_ref": d.mr_ref, @@ -2452,6 +3087,11 @@ mod tests { ScoringConfig::default() } + /// as_of_ms value for tests: 1 second after now, giving near-zero decay. + fn test_as_of_ms() -> i64 { + now_ms() + 1000 + } + fn insert_project(conn: &Connection, id: i64, path: &str) { conn.execute( "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url) @@ -2467,9 +3107,10 @@ mod tests { } fn insert_mr(conn: &Connection, id: i64, project_id: i64, iid: i64, author: &str, state: &str) { + let ts = now_ms(); conn.execute( - "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, updated_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, updated_at, created_at, merged_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", rusqlite::params![ id, id * 10, @@ -2478,8 +3119,10 @@ mod tests { format!("MR {iid}"), author, state, - now_ms(), - now_ms() + ts, + ts, + ts, + if state == "merged" { Some(ts) } else { None:: } ], ) .unwrap(); @@ -2597,6 +3240,85 @@ mod tests { .unwrap(); } + #[allow(clippy::too_many_arguments, dead_code)] + fn insert_mr_at( + conn: &Connection, + id: i64, + project_id: i64, + iid: i64, + author: &str, + state: &str, + updated_at_ms: i64, + merged_at_ms: Option, + closed_at_ms: Option, + ) { + conn.execute( + "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, updated_at, merged_at, closed_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + rusqlite::params![ + id, + id * 10, + project_id, + iid, + format!("MR {iid}"), + author, + state, + now_ms(), + updated_at_ms, + merged_at_ms, + closed_at_ms + ], + ) + .unwrap(); + } + + #[allow(clippy::too_many_arguments, dead_code)] + fn insert_diffnote_at( + conn: &Connection, + id: i64, + discussion_id: i64, + project_id: i64, + author: &str, + new_path: &str, + old_path: Option<&str>, + body: &str, + created_at_ms: i64, + ) { + conn.execute( + "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, last_seen_at) + VALUES (?1, ?2, ?3, ?4, ?5, 'DiffNote', 0, ?6, ?7, ?8, ?9, ?9, ?9)", + rusqlite::params![ + id, + id * 10, + project_id, + discussion_id, + author, + new_path, + old_path, + body, + created_at_ms + ], + ) + .unwrap(); + } + + #[allow(dead_code)] + fn insert_file_change_with_old_path( + conn: &Connection, + mr_id: i64, + project_id: i64, + new_path: &str, + old_path: Option<&str>, + change_type: &str, + ) { + conn.execute( + "INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, old_path, change_type) + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![mr_id, project_id, new_path, old_path, change_type], + ) + .unwrap(); + } + #[test] fn test_is_file_path_discrimination() { // Contains '/' -> file path @@ -2613,6 +3335,10 @@ mod tests { detail: false, no_detail: false, fields: None, + as_of: None, + explain_score: false, + include_bots: false, + all_history: false, }) .unwrap(), WhoMode::Expert { .. } @@ -2632,6 +3358,10 @@ mod tests { detail: false, no_detail: false, fields: None, + as_of: None, + explain_score: false, + include_bots: false, + all_history: false, }) .unwrap(), WhoMode::Workload { .. } @@ -2651,6 +3381,10 @@ mod tests { detail: false, no_detail: false, fields: None, + as_of: None, + explain_score: false, + include_bots: false, + all_history: false, }) .unwrap(), WhoMode::Workload { .. } @@ -2670,6 +3404,10 @@ mod tests { detail: false, no_detail: false, fields: None, + as_of: None, + explain_score: false, + include_bots: false, + all_history: false, }) .unwrap(), WhoMode::Reviews { .. } @@ -2689,6 +3427,10 @@ mod tests { detail: false, no_detail: false, fields: None, + as_of: None, + explain_score: false, + include_bots: false, + all_history: false, }) .unwrap(), WhoMode::Expert { .. } @@ -2708,6 +3450,10 @@ mod tests { detail: false, no_detail: false, fields: None, + as_of: None, + explain_score: false, + include_bots: false, + all_history: false, }) .unwrap(), WhoMode::Expert { .. } @@ -2728,6 +3474,10 @@ mod tests { detail: true, no_detail: false, fields: None, + as_of: None, + explain_score: false, + include_bots: false, + all_history: false, }; let mode = resolve_mode(&args).unwrap(); let err = validate_mode_flags(&mode, &args).unwrap_err(); @@ -2752,6 +3502,10 @@ mod tests { detail: true, no_detail: false, fields: None, + as_of: None, + explain_score: false, + include_bots: false, + all_history: false, }; let mode = resolve_mode(&args).unwrap(); assert!(validate_mode_flags(&mode, &args).is_ok()); @@ -2875,8 +3629,19 @@ mod tests { "looks good", ); - let result = - query_expert(&conn, "src/auth/", None, 0, 20, &default_scoring(), false).unwrap(); + let result = query_expert( + &conn, + "src/auth/", + None, + 0, + test_as_of_ms(), + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); assert_eq!(result.experts.len(), 3); // author_a, reviewer_b, reviewer_c assert_eq!(result.experts[0].username, "author_a"); // highest score (authorship dominates) } @@ -3123,8 +3888,19 @@ mod tests { "looks good", ); - let result = - query_expert(&conn, "src/auth/", None, 0, 20, &default_scoring(), false).unwrap(); + let result = query_expert( + &conn, + "src/auth/", + None, + 0, + test_as_of_ms(), + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); // author_a should appear as author only, not as reviewer let author = result .experts @@ -3204,14 +3980,36 @@ mod tests { } // limit = 2, should return truncated = true - let result = - query_expert(&conn, "src/auth/", None, 0, 2, &default_scoring(), false).unwrap(); + let result = query_expert( + &conn, + "src/auth/", + None, + 0, + test_as_of_ms(), + 2, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); assert!(result.truncated); assert_eq!(result.experts.len(), 2); // limit = 10, should return truncated = false - let result = - query_expert(&conn, "src/auth/", None, 0, 10, &default_scoring(), false).unwrap(); + let result = query_expert( + &conn, + "src/auth/", + None, + 0, + test_as_of_ms(), + 10, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); assert!(!result.truncated); } @@ -3228,9 +4026,12 @@ mod tests { "src/auth/login.rs", None, 0, + test_as_of_ms(), 20, &default_scoring(), false, + false, + false, ) .unwrap(); assert_eq!(result.experts.len(), 1); @@ -3254,9 +4055,12 @@ mod tests { "src/auth/login.rs", None, 0, + test_as_of_ms(), 20, &default_scoring(), false, + false, + false, ) .unwrap(); let reviewer = result @@ -3293,9 +4097,12 @@ mod tests { "src/auth/login.rs", None, 0, + test_as_of_ms(), 20, &default_scoring(), false, + false, + false, ) .unwrap(); let reviewer = result @@ -3321,8 +4128,19 @@ mod tests { insert_mr(&conn, 2, 1, 200, "author_a", "merged"); insert_file_change(&conn, 2, 1, "src/auth/session.rs", "added"); - let result = - query_expert(&conn, "src/auth/", None, 0, 20, &default_scoring(), false).unwrap(); + let result = query_expert( + &conn, + "src/auth/", + None, + 0, + test_as_of_ms(), + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); let author = result .experts .iter() @@ -3341,8 +4159,19 @@ mod tests { insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified"); insert_file_change(&conn, 1, 1, "src/auth/session.rs", "added"); - let result = - query_expert(&conn, "src/auth/", None, 0, 20, &default_scoring(), false).unwrap(); + let result = query_expert( + &conn, + "src/auth/", + None, + 0, + test_as_of_ms(), + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); assert_eq!(result.path_match, "prefix"); assert_eq!(result.experts.len(), 1); assert_eq!(result.experts[0].username, "author_a"); @@ -3400,9 +4229,12 @@ mod tests { "src/auth/login.rs", None, 0, + test_as_of_ms(), 20, &default_scoring(), false, + false, + false, ) .unwrap(); @@ -3556,11 +4388,31 @@ mod tests { insert_mr(&conn, 1, 1, 100, "the_author", "merged"); insert_file_change(&conn, 1, 1, "src/app.rs", "modified"); insert_discussion(&conn, 1, 1, Some(1), None, true, false); - insert_diffnote(&conn, 1, 1, 1, "the_reviewer", "src/app.rs", "lgtm"); + insert_diffnote( + &conn, + 1, + 1, + 1, + "the_reviewer", + "src/app.rs", + "lgtm -- a substantive review comment", + ); + insert_reviewer(&conn, 1, "the_reviewer"); // Default weights: author=25, reviewer=10 → author wins - let result = - query_expert(&conn, "src/app.rs", None, 0, 20, &default_scoring(), false).unwrap(); + let result = query_expert( + &conn, + "src/app.rs", + None, + 0, + test_as_of_ms(), + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); assert_eq!(result.experts[0].username, "the_author"); // Custom weights: flip so reviewer dominates @@ -3568,8 +4420,21 @@ mod tests { author_weight: 5, reviewer_weight: 30, note_bonus: 1, + ..Default::default() }; - let result = query_expert(&conn, "src/app.rs", None, 0, 20, &flipped, false).unwrap(); + let result = query_expert( + &conn, + "src/app.rs", + None, + 0, + test_as_of_ms(), + 20, + &flipped, + false, + false, + false, + ) + .unwrap(); assert_eq!(result.experts[0].username, "the_reviewer"); } @@ -3584,8 +4449,19 @@ mod tests { insert_diffnote(&conn, 1, 1, 1, "reviewer_b", "src/auth/login.rs", "note1"); insert_diffnote(&conn, 2, 2, 1, "reviewer_b", "src/auth/login.rs", "note2"); - let result = - query_expert(&conn, "src/auth/", None, 0, 20, &default_scoring(), false).unwrap(); + let result = query_expert( + &conn, + "src/auth/", + None, + 0, + test_as_of_ms(), + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); // reviewer_b should have MR refs let reviewer = result @@ -3621,8 +4497,19 @@ mod tests { insert_diffnote(&conn, 1, 1, 1, "reviewer_x", "src/auth/login.rs", "review"); insert_diffnote(&conn, 2, 2, 2, "reviewer_x", "src/auth/login.rs", "review"); - let result = - query_expert(&conn, "src/auth/", None, 0, 20, &default_scoring(), false).unwrap(); + let result = query_expert( + &conn, + "src/auth/", + None, + 0, + test_as_of_ms(), + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); let reviewer = result .experts .iter() @@ -3634,6 +4521,483 @@ mod tests { assert_eq!(reviewer.mr_refs_total, 2); } + #[test] + fn test_half_life_decay_math() { + // elapsed=0 -> 1.0 (no decay) + assert!((half_life_decay(0, 180) - 1.0).abs() < f64::EPSILON); + + // elapsed=half_life -> 0.5 + let half_life_ms = 180 * 86_400_000_i64; + assert!((half_life_decay(half_life_ms, 180) - 0.5).abs() < 1e-10); + + // elapsed=2*half_life -> 0.25 + assert!((half_life_decay(2 * half_life_ms, 180) - 0.25).abs() < 1e-10); + + // half_life_days=0 -> 0.0 (guard against div-by-zero) + assert!((half_life_decay(1_000_000, 0)).abs() < f64::EPSILON); + + // negative elapsed clamped to 0 -> 1.0 + assert!((half_life_decay(-5_000_000, 180) - 1.0).abs() < f64::EPSILON); + } + + #[test] + fn test_path_normalization_handles_dot_and_double_slash() { + assert_eq!(normalize_query_path("./src//foo.rs"), "src/foo.rs"); + assert_eq!(normalize_query_path(" src/bar.rs "), "src/bar.rs"); + assert_eq!(normalize_query_path("src/foo.rs"), "src/foo.rs"); + assert_eq!(normalize_query_path(""), ""); + } + + #[test] + fn test_path_normalization_preserves_prefix_semantics() { + // Trailing slash preserved for prefix intent + assert_eq!(normalize_query_path("./src/dir/"), "src/dir/"); + // No trailing slash = file, not prefix + assert_eq!(normalize_query_path("src/dir"), "src/dir"); + } + + #[test] + fn test_expert_sql_v2_prepares_exact() { + let conn = setup_test_db(); + let sql = build_expert_sql_v2(false); + // Verify the SQL is syntactically valid and INDEXED BY references exist + conn.prepare_cached(&sql) + .expect("v2 exact SQL should parse"); + } + + #[test] + fn test_expert_sql_v2_prepares_prefix() { + let conn = setup_test_db(); + let sql = build_expert_sql_v2(true); + conn.prepare_cached(&sql) + .expect("v2 prefix SQL should parse"); + } + + #[test] + fn test_expert_sql_v2_returns_signals() { + let conn = setup_test_db(); + let now = now_ms(); + insert_project(&conn, 1, "team/backend"); + insert_mr_at(&conn, 1, 1, 100, "author_a", "merged", now, Some(now), None); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_diffnote( + &conn, + 1, + 1, + 1, + "reviewer_b", + "src/auth/login.rs", + "substantive review comment here", + ); + insert_file_change(&conn, 1, 1, "src/auth/login.rs", "modified"); + insert_reviewer(&conn, 1, "reviewer_b"); + + let sql = build_expert_sql_v2(false); + let mut stmt = conn.prepare_cached(&sql).unwrap(); + let rows: Vec<(String, String, i64, i64, i64, f64)> = stmt + .query_map( + rusqlite::params![ + "src/auth/login.rs", // ?1 path + 0_i64, // ?2 since_ms + Option::::None, // ?3 project_id + now + 1000, // ?4 as_of_ms (slightly in future to include test data) + 0.5_f64, // ?5 closed_mr_multiplier + 20_i64, // ?6 reviewer_min_note_chars + ], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)?, + row.get::<_, f64>(5)?, + )) + }, + ) + .unwrap() + .collect::, _>>() + .unwrap(); + + // Should have signals for both author_a and reviewer_b + let usernames: Vec<&str> = rows.iter().map(|r| r.0.as_str()).collect(); + assert!(usernames.contains(&"author_a"), "should contain author_a"); + assert!( + usernames.contains(&"reviewer_b"), + "should contain reviewer_b" + ); + + // Verify signal types are from the expected set + let valid_signals = [ + "diffnote_author", + "file_author", + "file_reviewer_participated", + "file_reviewer_assigned", + "note_group", + ]; + for row in &rows { + assert!( + valid_signals.contains(&row.1.as_str()), + "unexpected signal type: {}", + row.1 + ); + } + + // state_mult should be 1.0 for merged MRs + for row in &rows { + assert!( + (row.5 - 1.0).abs() < f64::EPSILON, + "merged MR should have state_mult=1.0, got {}", + row.5 + ); + } + } + + #[test] + fn test_expert_sql_v2_reviewer_split() { + let conn = setup_test_db(); + let now = now_ms(); + insert_project(&conn, 1, "team/backend"); + insert_mr_at(&conn, 1, 1, 100, "author_a", "merged", now, Some(now), None); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + // reviewer_b leaves a substantive note (>= 20 chars) + insert_diffnote( + &conn, + 1, + 1, + 1, + "reviewer_b", + "src/app.rs", + "This looks correct, good refactoring work here", + ); + insert_file_change(&conn, 1, 1, "src/app.rs", "modified"); + insert_reviewer(&conn, 1, "reviewer_b"); + // reviewer_c is assigned but leaves no notes + insert_reviewer(&conn, 1, "reviewer_c"); + + let sql = build_expert_sql_v2(false); + let mut stmt = conn.prepare_cached(&sql).unwrap(); + let rows: Vec<(String, String)> = stmt + .query_map( + rusqlite::params![ + "src/app.rs", + 0_i64, + Option::::None, + now + 1000, + 0.5_f64, + 20_i64, + ], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .unwrap() + .collect::, _>>() + .unwrap(); + + // reviewer_b should be "file_reviewer_participated" + let b_signals: Vec<&str> = rows + .iter() + .filter(|r| r.0 == "reviewer_b") + .map(|r| r.1.as_str()) + .collect(); + assert!( + b_signals.contains(&"file_reviewer_participated"), + "reviewer_b should be participated, got: {:?}", + b_signals + ); + assert!( + !b_signals.contains(&"file_reviewer_assigned"), + "reviewer_b should NOT be assigned-only" + ); + + // reviewer_c should be "file_reviewer_assigned" + let c_signals: Vec<&str> = rows + .iter() + .filter(|r| r.0 == "reviewer_c") + .map(|r| r.1.as_str()) + .collect(); + assert!( + c_signals.contains(&"file_reviewer_assigned"), + "reviewer_c should be assigned-only, got: {:?}", + c_signals + ); + assert!( + !c_signals.contains(&"file_reviewer_participated"), + "reviewer_c should NOT be participated" + ); + } + + #[test] + fn test_expert_sql_v2_closed_mr_multiplier() { + let conn = setup_test_db(); + let now = now_ms(); + insert_project(&conn, 1, "team/backend"); + insert_mr_at(&conn, 1, 1, 100, "author_a", "closed", now, None, Some(now)); + insert_file_change(&conn, 1, 1, "src/app.rs", "modified"); + + let sql = build_expert_sql_v2(false); + let mut stmt = conn.prepare_cached(&sql).unwrap(); + let rows: Vec<(String, String, f64)> = stmt + .query_map( + rusqlite::params![ + "src/app.rs", + 0_i64, + Option::::None, + now + 1000, + 0.5_f64, + 20_i64, + ], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, f64>(5)?, + )) + }, + ) + .unwrap() + .collect::, _>>() + .unwrap(); + + // All signals from closed MR should have state_mult=0.5 + for row in &rows { + assert!( + (row.2 - 0.5).abs() < f64::EPSILON, + "closed MR should have state_mult=0.5, got {} for signal {}", + row.2, + row.1 + ); + } + } + + #[test] + fn test_expert_v2_decay_scoring() { + let conn = setup_test_db(); + let now = now_ms(); + let day_ms = 86_400_000_i64; + insert_project(&conn, 1, "team/backend"); + + // Recent author: 10 days ago + insert_mr_at( + &conn, + 1, + 1, + 100, + "recent_author", + "merged", + now - 10 * day_ms, + Some(now - 10 * day_ms), + None, + ); + insert_file_change(&conn, 1, 1, "src/app.rs", "modified"); + + // Old author: 360 days ago + insert_mr_at( + &conn, + 2, + 1, + 101, + "old_author", + "merged", + now - 360 * day_ms, + Some(now - 360 * day_ms), + None, + ); + insert_file_change(&conn, 2, 1, "src/app.rs", "modified"); + + let scoring = default_scoring(); + let result = query_expert_v2( + &conn, + "src/app.rs", + None, + 0, + now + 1000, + 20, + &scoring, + false, + ) + .unwrap(); + + assert_eq!(result.experts.len(), 2); + // Recent author should rank first + assert_eq!(result.experts[0].username, "recent_author"); + assert_eq!(result.experts[1].username, "old_author"); + // Recent author score should be much higher than old author + assert!(result.experts[0].score > result.experts[1].score); + } + + #[test] + fn test_expert_v2_reviewer_split_scoring() { + let conn = setup_test_db(); + let now = now_ms(); + let day_ms = 86_400_000_i64; + insert_project(&conn, 1, "team/backend"); + + // MR from 30 days ago + insert_mr_at( + &conn, + 1, + 1, + 100, + "the_author", + "merged", + now - 30 * day_ms, + Some(now - 30 * day_ms), + None, + ); + insert_file_change(&conn, 1, 1, "src/app.rs", "modified"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + + // Reviewer A: participated (left substantive DiffNotes) + insert_diffnote( + &conn, + 1, + 1, + 1, + "participated_reviewer", + "src/app.rs", + "Substantive review comment here about the code", + ); + insert_reviewer(&conn, 1, "participated_reviewer"); + + // Reviewer B: assigned only (no DiffNotes) + insert_reviewer(&conn, 1, "assigned_reviewer"); + + let scoring = default_scoring(); + let result = query_expert_v2( + &conn, + "src/app.rs", + None, + 0, + now + 1000, + 20, + &scoring, + false, + ) + .unwrap(); + + let participated = result + .experts + .iter() + .find(|e| e.username == "participated_reviewer"); + let assigned = result + .experts + .iter() + .find(|e| e.username == "assigned_reviewer"); + assert!( + participated.is_some(), + "participated reviewer should appear" + ); + assert!(assigned.is_some(), "assigned reviewer should appear"); + + // Participated reviewer should score higher (weight=10 vs weight=3) + assert!( + participated.unwrap().score > assigned.unwrap().score, + "participated ({}) should score higher than assigned ({})", + participated.unwrap().score, + assigned.unwrap().score + ); + } + + #[test] + fn test_expert_v2_excluded_usernames() { + let conn = setup_test_db(); + let now = now_ms(); + insert_project(&conn, 1, "team/backend"); + insert_mr_at( + &conn, + 1, + 1, + 100, + "real_user", + "merged", + now, + Some(now), + None, + ); + insert_mr_at( + &conn, + 2, + 1, + 101, + "renovate-bot", + "merged", + now, + Some(now), + None, + ); + insert_file_change(&conn, 1, 1, "src/app.rs", "modified"); + insert_file_change(&conn, 2, 1, "src/app.rs", "modified"); + + let mut scoring = default_scoring(); + scoring.excluded_usernames = vec!["renovate-bot".to_string()]; + let result = query_expert_v2( + &conn, + "src/app.rs", + None, + 0, + now + 1000, + 20, + &scoring, + false, + ) + .unwrap(); + + let usernames: Vec<&str> = result.experts.iter().map(|e| e.username.as_str()).collect(); + assert!(usernames.contains(&"real_user")); + assert!( + !usernames.contains(&"renovate-bot"), + "bot should be excluded" + ); + } + + #[test] + fn test_expert_v2_deterministic_ordering() { + let conn = setup_test_db(); + let now = now_ms(); + let day_ms = 86_400_000_i64; + insert_project(&conn, 1, "team/backend"); + + // Create 5 MRs with varied timestamps + for i in 0_i64..5 { + insert_mr_at( + &conn, + i + 1, + 1, + 100 + i, + "test_user", + "merged", + now - (i + 1) * 30 * day_ms, + Some(now - (i + 1) * 30 * day_ms), + None, + ); + insert_file_change(&conn, i + 1, 1, "src/app.rs", "modified"); + } + + let scoring = default_scoring(); + // Run 10 times and verify identical scores + let mut scores: Vec = Vec::new(); + for _ in 0..10 { + let result = query_expert_v2( + &conn, + "src/app.rs", + None, + 0, + now + 1000, + 20, + &scoring, + false, + ) + .unwrap(); + assert_eq!(result.experts.len(), 1); + scores.push(result.experts[0].score); + } + assert!( + scores.windows(2).all(|w| w[0] == w[1]), + "scores should be identical across runs: {:?}", + scores + ); + } + #[test] fn test_expert_detail_mode() { let conn = setup_test_db(); @@ -3647,15 +5011,37 @@ mod tests { insert_diffnote(&conn, 3, 2, 1, "reviewer_b", "src/auth/session.rs", "note3"); // Without detail: details should be None - let result = - query_expert(&conn, "src/auth/", None, 0, 20, &default_scoring(), false).unwrap(); + let result = query_expert( + &conn, + "src/auth/", + None, + 0, + test_as_of_ms(), + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); for expert in &result.experts { assert!(expert.details.is_none()); } // With detail: details should be populated - let result = - query_expert(&conn, "src/auth/", None, 0, 20, &default_scoring(), true).unwrap(); + let result = query_expert( + &conn, + "src/auth/", + None, + 0, + test_as_of_ms(), + 20, + &default_scoring(), + true, + false, + false, + ) + .unwrap(); let reviewer = result .experts .iter() @@ -3690,4 +5076,1261 @@ mod tests { ); } } + + #[test] + fn test_old_path_probe_exact_and_prefix() { + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_mr(&conn, 1, 1, 100, "alice", "merged"); + insert_file_change_with_old_path( + &conn, + 1, + 1, + "src/new/foo.rs", + Some("src/old/foo.rs"), + "renamed", + ); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_diffnote_at( + &conn, + 1, + 1, + 1, + "alice", + "src/new/foo.rs", + Some("src/old/foo.rs"), + "review comment", + now_ms(), + ); + + // Exact probe by OLD path should resolve + let pq = build_path_query(&conn, "src/old/foo.rs", None).unwrap(); + assert!(!pq.is_prefix, "old exact path should resolve as exact"); + assert_eq!(pq.value, "src/old/foo.rs"); + + // Prefix probe by OLD directory should resolve + let pq = build_path_query(&conn, "src/old/", None).unwrap(); + assert!(pq.is_prefix, "old directory should resolve as prefix"); + + // New path still works + let pq = build_path_query(&conn, "src/new/foo.rs", None).unwrap(); + assert!(!pq.is_prefix, "new exact path should still resolve"); + assert_eq!(pq.value, "src/new/foo.rs"); + } + + #[test] + fn test_suffix_probe_uses_old_path_sources() { + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + insert_mr(&conn, 1, 1, 100, "alice", "merged"); + insert_file_change_with_old_path( + &conn, + 1, + 1, + "src/utils.rs", + Some("legacy/utils.rs"), + "renamed", + ); + + let result = suffix_probe(&conn, "utils.rs", None).unwrap(); + match result { + SuffixResult::Ambiguous(paths) => { + assert!( + paths.contains(&"src/utils.rs".to_string()), + "should contain new path" + ); + assert!( + paths.contains(&"legacy/utils.rs".to_string()), + "should contain old path" + ); + } + SuffixResult::Unique(p) => { + panic!("Expected Ambiguous with both paths, got Unique({p})"); + } + SuffixResult::NoMatch => panic!("Expected Ambiguous, got NoMatch"), + SuffixResult::NotAttempted => panic!("Expected Ambiguous, got NotAttempted"), + } + } + + // ─── Plan Section 8: New Tests ────────────────────────────────────────────── + + #[test] + fn test_expert_scores_decay_with_time() { + // Two authors, one recent (10 days), one old (360 days). + // With default author_half_life_days=180, recent ≈ 24.1, old ≈ 6.3. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + let day_ms: i64 = 86_400_000; + let recent_ts = now - 10 * day_ms; // 10 days ago + let old_ts = now - 360 * day_ms; // 360 days ago + + // Recent author + insert_mr_at( + &conn, + 1, + 1, + 100, + "recent_author", + "merged", + recent_ts, + Some(recent_ts), + None, + ); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + + // Old author + insert_mr_at( + &conn, + 2, + 1, + 200, + "old_author", + "merged", + old_ts, + Some(old_ts), + None, + ); + insert_file_change(&conn, 2, 1, "src/lib.rs", "modified"); + + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + + assert_eq!(result.experts.len(), 2); + assert_eq!(result.experts[0].username, "recent_author"); + assert_eq!(result.experts[1].username, "old_author"); + + // Recent author scores significantly higher + let recent_score = result.experts[0].score_raw.unwrap(); + let old_score = result.experts[1].score_raw.unwrap(); + assert!( + recent_score > old_score * 2.0, + "recent ({recent_score:.1}) should be >2x old ({old_score:.1})" + ); + } + + #[test] + fn test_expert_reviewer_decays_faster_than_author() { + // Same MR, same age (90 days). Author half-life=180d, reviewer half-life=90d. + // Author retains 2^(-90/180)=0.707 of weight, reviewer retains 2^(-90/90)=0.5. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + let day_ms: i64 = 86_400_000; + let age_ts = now - 90 * day_ms; + + insert_mr_at( + &conn, + 1, + 1, + 100, + "the_author", + "merged", + age_ts, + Some(age_ts), + None, + ); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_diffnote_at( + &conn, + 1, + 1, + 1, + "the_reviewer", + "src/lib.rs", + None, + "a substantive review comment here", + age_ts, + ); + insert_reviewer(&conn, 1, "the_reviewer"); + + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + + // Author gets file_author (25*0.707) + diffnote_author (25*0.707) ≈ 35.4 + // Reviewer gets file_reviewer_participated (10*0.5) + note_group (1*1.0*0.5) ≈ 5.5 + assert_eq!(result.experts[0].username, "the_author"); + let author_score = result.experts[0].score_raw.unwrap(); + let reviewer_score = result.experts[1].score_raw.unwrap(); + assert!( + author_score > reviewer_score * 3.0, + "author ({author_score:.1}) should dominate reviewer ({reviewer_score:.1})" + ); + } + + #[test] + fn test_reviewer_participated_vs_assigned_only() { + // Two reviewers on the same MR. One left substantive DiffNotes (participated), + // one didn't (assigned-only). Participated gets reviewer_weight, assigned-only + // gets reviewer_assignment_weight (much lower). + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + insert_mr(&conn, 1, 1, 100, "the_author", "merged"); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_diffnote( + &conn, + 1, + 1, + 1, + "active_reviewer", + "src/lib.rs", + "This needs refactoring because...", + ); + insert_reviewer(&conn, 1, "active_reviewer"); + insert_reviewer(&conn, 1, "passive_reviewer"); + + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + + let active = result + .experts + .iter() + .find(|e| e.username == "active_reviewer") + .unwrap(); + let passive = result + .experts + .iter() + .find(|e| e.username == "passive_reviewer") + .unwrap(); + let active_score = active.score_raw.unwrap(); + let passive_score = passive.score_raw.unwrap(); + + // Default: reviewer_weight=10, reviewer_assignment_weight=3 + // Active: 10 * ~1.0 + note_group ≈ 11 + // Passive: 3 * ~1.0 = 3 + assert!( + active_score > passive_score * 2.0, + "active ({active_score:.1}) should be >2x passive ({passive_score:.1})" + ); + } + + #[test] + fn test_note_diminishing_returns_per_mr() { + // One reviewer with 1 note on MR-A and another with 20 notes on MR-B. + // The 20-note reviewer should score ~log2(21)/log2(2) ≈ 4.4x, NOT 20x. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + // MR 1 with 1 note + insert_mr(&conn, 1, 1, 100, "author_x", "merged"); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_diffnote( + &conn, + 1, + 1, + 1, + "one_note_reviewer", + "src/lib.rs", + "a single substantive review note", + ); + insert_reviewer(&conn, 1, "one_note_reviewer"); + + // MR 2 with 20 notes from another reviewer + insert_mr(&conn, 2, 1, 200, "author_y", "merged"); + insert_file_change(&conn, 2, 1, "src/lib.rs", "modified"); + insert_discussion(&conn, 2, 1, Some(2), None, true, false); + for i in 0_i64..20 { + insert_diffnote( + &conn, + 100 + i, + 2, + 1, + "many_note_reviewer", + "src/lib.rs", + &format!("substantive review comment number {i}"), + ); + } + insert_reviewer(&conn, 2, "many_note_reviewer"); + + let scoring = ScoringConfig { + reviewer_weight: 0, + reviewer_assignment_weight: 0, + author_weight: 0, + note_bonus: 10, // High bonus to isolate note contribution + ..Default::default() + }; + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &scoring, + false, + true, + false, + ) + .unwrap(); + + let one = result + .experts + .iter() + .find(|e| e.username == "one_note_reviewer") + .unwrap(); + let many = result + .experts + .iter() + .find(|e| e.username == "many_note_reviewer") + .unwrap(); + let one_score = one.score_raw.unwrap(); + let many_score = many.score_raw.unwrap(); + + // log2(1+1)=1.0, log2(1+20)≈4.39. Ratio should be ~4.4x, not 20x. + let ratio = many_score / one_score; + assert!( + ratio < 6.0, + "ratio ({ratio:.1}) should be ~4.4, not 20 (diminishing returns)" + ); + assert!( + ratio > 3.0, + "ratio ({ratio:.1}) should be ~4.4, reflecting log2 scaling" + ); + } + + #[test] + fn test_file_change_timestamp_uses_merged_at() { + // A merged MR should use merged_at for decay, not updated_at. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + let day_ms: i64 = 86_400_000; + let old_merged = now - 300 * day_ms; // merged 300 days ago + let recent_updated = now - day_ms; // updated yesterday + + // MR merged long ago but recently updated (e.g., label change) + insert_mr_at( + &conn, + 1, + 1, + 100, + "the_author", + "merged", + recent_updated, + Some(old_merged), + None, + ); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + + assert_eq!(result.experts.len(), 1); + let score = result.experts[0].score_raw.unwrap(); + // With half_life=180d and elapsed=300d, decay = 2^(-300/180) ≈ 0.315 + // Score ≈ 25 * 0.315 ≈ 7.9 (file_author only, no diffnote_author without notes) + // If it incorrectly used updated_at (1 day), score ≈ 25 * ~1.0 = 25 + assert!( + score < 15.0, + "score ({score:.1}) should reflect old merged_at, not recent updated_at" + ); + } + + #[test] + fn test_open_mr_uses_updated_at() { + // An opened MR should use updated_at for decay, not created_at. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + let day_ms: i64 = 86_400_000; + + // MR-A: opened, recently updated (decay ≈ 1.0) + conn.execute( + "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, updated_at, created_at) + VALUES (1, 10, 1, 100, 'MR 100', 'recent_update', 'opened', ?1, ?2, ?3)", + rusqlite::params![now, now - 5 * day_ms, now - 200 * day_ms], + ).unwrap(); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + + // MR-B: opened, old updated_at (decay significant) + conn.execute( + "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, updated_at, created_at) + VALUES (2, 20, 1, 200, 'MR 200', 'old_update', 'opened', ?1, ?2, ?3)", + rusqlite::params![now, now - 200 * day_ms, now - 200 * day_ms], + ).unwrap(); + insert_file_change(&conn, 2, 1, "src/lib.rs", "modified"); + + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + + // recent_update should rank first (higher score from fresher updated_at) + assert_eq!(result.experts[0].username, "recent_update"); + let recent = result.experts[0].score_raw.unwrap(); + let old = result.experts[1].score_raw.unwrap(); + assert!( + recent > old * 2.0, + "recent ({recent:.1}) should beat old ({old:.1}) by updated_at" + ); + } + + #[test] + fn test_old_path_match_credits_expertise() { + // DiffNote with old_path should credit expertise when queried by old path. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + insert_mr(&conn, 1, 1, 100, "the_author", "merged"); + insert_file_change_with_old_path(&conn, 1, 1, "src/new.rs", Some("src/old.rs"), "renamed"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_diffnote_at( + &conn, + 1, + 1, + 1, + "reviewer_a", + "src/new.rs", + Some("src/old.rs"), + "substantive review of the renamed file", + now, + ); + + // Query by old path + let result_old = query_expert( + &conn, + "src/old.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); + + // Query by new path + let result_new = query_expert( + &conn, + "src/new.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); + + // Both queries should find the author + assert!( + result_old + .experts + .iter() + .any(|e| e.username == "the_author"), + "author should appear via old_path query" + ); + assert!( + result_new + .experts + .iter() + .any(|e| e.username == "the_author"), + "author should appear via new_path query" + ); + } + + #[test] + fn test_explain_score_components_sum_to_total() { + // With explain_score, component subtotals should sum to score_raw. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + insert_mr(&conn, 1, 1, 100, "the_author", "merged"); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + insert_diffnote( + &conn, + 1, + 1, + 1, + "the_reviewer", + "src/lib.rs", + "a substantive enough review comment", + ); + insert_reviewer(&conn, 1, "the_reviewer"); + + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + + for expert in &result.experts { + let raw = expert.score_raw.unwrap(); + let comp = expert.components.as_ref().unwrap(); + let sum = + comp.author + comp.reviewer_participated + comp.reviewer_assigned + comp.notes; + assert!( + (raw - sum).abs() < 1e-10, + "components ({sum:.6}) should sum to score_raw ({raw:.6}) for {}", + expert.username + ); + } + } + + #[test] + fn test_as_of_produces_deterministic_results() { + // Same as_of value produces identical results across multiple runs. + // Later as_of produces lower scores (more decay). + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + let day_ms: i64 = 86_400_000; + let mr_ts = now - 30 * day_ms; + + insert_mr_at( + &conn, + 1, + 1, + 100, + "the_author", + "merged", + mr_ts, + Some(mr_ts), + None, + ); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + + let as_of_early = now; + let as_of_late = now + 180 * day_ms; + + let result1 = query_expert( + &conn, + "src/lib.rs", + None, + 0, + as_of_early, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + let result2 = query_expert( + &conn, + "src/lib.rs", + None, + 0, + as_of_early, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + + // Same as_of -> identical scores + assert_eq!( + result1.experts[0].score_raw.unwrap().to_bits(), + result2.experts[0].score_raw.unwrap().to_bits(), + "same as_of should produce bit-identical scores" + ); + + // Later as_of -> lower score + let result_late = query_expert( + &conn, + "src/lib.rs", + None, + 0, + as_of_late, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + assert!( + result1.experts[0].score_raw.unwrap() > result_late.experts[0].score_raw.unwrap(), + "later as_of should produce lower scores" + ); + } + + #[test] + fn test_trivial_note_does_not_count_as_participation() { + // A reviewer with only a short note ("LGTM") should be classified as + // assigned-only, not participated, when reviewer_min_note_chars = 20. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + insert_mr(&conn, 1, 1, 100, "the_author", "merged"); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + // Short note (4 chars, below threshold of 20) + insert_diffnote(&conn, 1, 1, 1, "trivial_reviewer", "src/lib.rs", "LGTM"); + insert_reviewer(&conn, 1, "trivial_reviewer"); + + // Another reviewer with substantive note + insert_diffnote( + &conn, + 2, + 1, + 1, + "substantive_reviewer", + "src/lib.rs", + "This function needs better error handling for the edge case...", + ); + insert_reviewer(&conn, 1, "substantive_reviewer"); + + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + + let trivial = result + .experts + .iter() + .find(|e| e.username == "trivial_reviewer") + .unwrap(); + let substantive = result + .experts + .iter() + .find(|e| e.username == "substantive_reviewer") + .unwrap(); + + let trivial_comp = trivial.components.as_ref().unwrap(); + let substantive_comp = substantive.components.as_ref().unwrap(); + + // Trivial should get reviewer_assigned (3), not reviewer_participated (10) + assert!( + trivial_comp.reviewer_assigned > 0.0, + "trivial reviewer should get assigned-only signal" + ); + assert!( + trivial_comp.reviewer_participated < 0.01, + "trivial reviewer should NOT get participated signal" + ); + + // Substantive should get reviewer_participated + assert!( + substantive_comp.reviewer_participated > 0.0, + "substantive reviewer should get participated signal" + ); + } + + #[test] + fn test_closed_mr_multiplier() { + // Two MRs with the same author: one merged, one closed. + // Closed should contribute author_weight * closed_mr_multiplier * decay. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + // Merged MR + insert_mr(&conn, 1, 1, 100, "merged_author", "merged"); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + + // Closed MR (same path, different author) + insert_mr(&conn, 2, 1, 200, "closed_author", "closed"); + insert_file_change(&conn, 2, 1, "src/lib.rs", "modified"); + + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + + let merged = result + .experts + .iter() + .find(|e| e.username == "merged_author") + .unwrap(); + let closed = result + .experts + .iter() + .find(|e| e.username == "closed_author") + .unwrap(); + let merged_score = merged.score_raw.unwrap(); + let closed_score = closed.score_raw.unwrap(); + + // Default closed_mr_multiplier=0.5, so closed should be roughly half + let ratio = closed_score / merged_score; + assert!( + (ratio - 0.5).abs() < 0.1, + "closed/merged ratio ({ratio:.2}) should be ≈0.5" + ); + } + + #[test] + fn test_as_of_excludes_future_events() { + // Events after as_of should not appear in results. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + let day_ms: i64 = 86_400_000; + let past_ts = now - 30 * day_ms; + let future_ts = now + 30 * day_ms; + + // Past MR (should appear) + insert_mr_at( + &conn, + 1, + 1, + 100, + "past_author", + "merged", + past_ts, + Some(past_ts), + None, + ); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + + // Future MR (should NOT appear) + insert_mr_at( + &conn, + 2, + 1, + 200, + "future_author", + "merged", + future_ts, + Some(future_ts), + None, + ); + insert_file_change(&conn, 2, 1, "src/lib.rs", "modified"); + + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now, + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); + + assert!( + result.experts.iter().any(|e| e.username == "past_author"), + "past author should appear" + ); + assert!( + !result.experts.iter().any(|e| e.username == "future_author"), + "future author should be excluded by as_of" + ); + } + + #[test] + fn test_as_of_exclusive_upper_bound() { + // An event with timestamp exactly equal to as_of should be excluded (strict <). + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + let boundary_ts = now; + + insert_mr_at( + &conn, + 1, + 1, + 100, + "boundary_author", + "merged", + boundary_ts, + Some(boundary_ts), + None, + ); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + boundary_ts, + 20, + &default_scoring(), + false, + false, + false, + ) + .unwrap(); + + assert!( + !result + .experts + .iter() + .any(|e| e.username == "boundary_author"), + "event at exactly as_of should be excluded (half-open interval)" + ); + } + + #[test] + fn test_excluded_usernames_filters_bots() { + // Bot users in excluded_usernames should be filtered out. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + insert_mr(&conn, 1, 1, 100, "renovate-bot", "merged"); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + insert_mr(&conn, 2, 1, 200, "jsmith", "merged"); + insert_file_change(&conn, 2, 1, "src/lib.rs", "modified"); + + let scoring = ScoringConfig { + excluded_usernames: vec!["renovate-bot".to_string()], + ..Default::default() + }; + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &scoring, + false, + false, + false, + ) + .unwrap(); + + assert_eq!(result.experts.len(), 1); + assert_eq!(result.experts[0].username, "jsmith"); + } + + #[test] + fn test_include_bots_flag_disables_filtering() { + // With include_bots=true, excluded_usernames should be ignored. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + insert_mr(&conn, 1, 1, 100, "renovate-bot", "merged"); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + insert_mr(&conn, 2, 1, 200, "jsmith", "merged"); + insert_file_change(&conn, 2, 1, "src/lib.rs", "modified"); + + let scoring = ScoringConfig { + excluded_usernames: vec!["renovate-bot".to_string()], + ..Default::default() + }; + // include_bots = true + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &scoring, + false, + false, + true, + ) + .unwrap(); + + assert_eq!(result.experts.len(), 2); + assert!(result.experts.iter().any(|e| e.username == "renovate-bot")); + assert!(result.experts.iter().any(|e| e.username == "jsmith")); + } + + #[test] + fn test_null_timestamp_fallback_to_created_at() { + // A merged MR with merged_at=NULL should fall back to created_at. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + let day_ms: i64 = 86_400_000; + let old_ts = now - 100 * day_ms; + + // Insert merged MR with merged_at=NULL, created_at set + conn.execute( + "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, created_at, merged_at, updated_at) + VALUES (1, 10, 1, 100, 'MR 100', 'the_author', 'merged', ?1, ?2, NULL, NULL)", + rusqlite::params![now, old_ts], + ).unwrap(); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + + // Should still find the author (not panic or return empty) + assert_eq!(result.experts.len(), 1); + assert_eq!(result.experts[0].username, "the_author"); + // Score should reflect old_ts (100 days ago), not 0 or now + let score = result.experts[0].score_raw.unwrap(); + // 25 * 2^(-100/180) ≈ 17.1 + assert!( + score > 5.0 && score < 22.0, + "score ({score:.1}) should reflect created_at fallback" + ); + } + + // ─── Invariant Tests ──────────────────────────────────────────────────────── + + #[test] + fn test_score_monotonicity_by_age() { + // For any single signal, older timestamp must never produce a higher score. + for half_life in [1_u32, 7, 45, 90, 180, 365] { + let mut prev_decay = f64::MAX; + for days in 0..=730 { + let elapsed_ms = i64::from(days) * 86_400_000; + let decay = half_life_decay(elapsed_ms, half_life); + assert!( + decay <= prev_decay, + "monotonicity violated: half_life={half_life}, day={days}, decay={decay} > prev={prev_decay}" + ); + prev_decay = decay; + } + } + } + + #[test] + fn test_row_order_independence() { + // Same signals inserted in different order should produce identical results. + let conn1 = setup_test_db(); + let conn2 = setup_test_db(); + + let now = now_ms(); + let day_ms: i64 = 86_400_000; + + // Setup identical data in both DBs but in different insertion order + for conn in [&conn1, &conn2] { + insert_project(conn, 1, "team/backend"); + } + + // Forward order in conn1 + for i in 1_i64..=5 { + let ts = now - i * 30 * day_ms; + insert_mr_at( + &conn1, + i, + 1, + i * 100, + &format!("author_{i}"), + "merged", + ts, + Some(ts), + None, + ); + insert_file_change(&conn1, i, 1, "src/lib.rs", "modified"); + } + + // Reverse order in conn2 + for i in (1_i64..=5).rev() { + let ts = now - i * 30 * day_ms; + insert_mr_at( + &conn2, + i, + 1, + i * 100, + &format!("author_{i}"), + "merged", + ts, + Some(ts), + None, + ); + insert_file_change(&conn2, i, 1, "src/lib.rs", "modified"); + } + + let r1 = query_expert( + &conn1, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + let r2 = query_expert( + &conn2, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + + assert_eq!(r1.experts.len(), r2.experts.len()); + for (e1, e2) in r1.experts.iter().zip(r2.experts.iter()) { + assert_eq!(e1.username, e2.username, "ranking order differs"); + assert_eq!( + e1.score_raw.unwrap().to_bits(), + e2.score_raw.unwrap().to_bits(), + "scores differ for {}", + e1.username + ); + } + } + + #[test] + fn test_reviewer_split_is_exhaustive() { + // A reviewer on an MR must appear in exactly one of: participated or assigned-only. + // Three cases: (1) substantive notes -> participated, (2) no notes -> assigned, + // (3) trivial notes -> assigned. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + insert_mr(&conn, 1, 1, 100, "the_author", "merged"); + insert_file_change(&conn, 1, 1, "src/lib.rs", "modified"); + insert_discussion(&conn, 1, 1, Some(1), None, true, false); + + // Case 1: substantive notes + insert_diffnote( + &conn, + 1, + 1, + 1, + "reviewer_substantive", + "src/lib.rs", + "this is a long enough substantive review comment", + ); + insert_reviewer(&conn, 1, "reviewer_substantive"); + + // Case 2: no notes at all + insert_reviewer(&conn, 1, "reviewer_no_notes"); + + // Case 3: trivial notes only + insert_diffnote(&conn, 2, 1, 1, "reviewer_trivial", "src/lib.rs", "ok"); + insert_reviewer(&conn, 1, "reviewer_trivial"); + + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + now + 1000, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + + // Check each reviewer's components + let subst = result + .experts + .iter() + .find(|e| e.username == "reviewer_substantive") + .unwrap(); + let subst_comp = subst.components.as_ref().unwrap(); + assert!( + subst_comp.reviewer_participated > 0.0, + "case 1: should be participated" + ); + assert!( + subst_comp.reviewer_assigned < 0.01, + "case 1: should NOT be assigned" + ); + + let no_notes = result + .experts + .iter() + .find(|e| e.username == "reviewer_no_notes") + .unwrap(); + let nn_comp = no_notes.components.as_ref().unwrap(); + assert!( + nn_comp.reviewer_assigned > 0.0, + "case 2: should be assigned" + ); + assert!( + nn_comp.reviewer_participated < 0.01, + "case 2: should NOT be participated" + ); + + let trivial = result + .experts + .iter() + .find(|e| e.username == "reviewer_trivial") + .unwrap(); + let tr_comp = trivial.components.as_ref().unwrap(); + assert!( + tr_comp.reviewer_assigned > 0.0, + "case 3: should be assigned" + ); + assert!( + tr_comp.reviewer_participated < 0.01, + "case 3: should NOT be participated" + ); + } + + #[test] + fn test_deterministic_accumulation_order() { + // Same data queried 50 times must produce bit-identical f64 scores. + let conn = setup_test_db(); + insert_project(&conn, 1, "team/backend"); + + let now = now_ms(); + let day_ms: i64 = 86_400_000; + + // 10 MRs at varied ages + for i in 1_i64..=10 { + let ts = now - i * 20 * day_ms; + insert_mr_at( + &conn, + i, + 1, + i * 100, + "the_author", + "merged", + ts, + Some(ts), + None, + ); + insert_file_change(&conn, i, 1, "src/lib.rs", "modified"); + } + + let as_of = now + 1000; + let first_result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + as_of, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + let expected_bits = first_result.experts[0].score_raw.unwrap().to_bits(); + + for run in 1..50 { + let result = query_expert( + &conn, + "src/lib.rs", + None, + 0, + as_of, + 20, + &default_scoring(), + false, + true, + false, + ) + .unwrap(); + assert_eq!( + result.experts[0].score_raw.unwrap().to_bits(), + expected_bits, + "run {run}: score bits diverged (HashMap iteration order leaking)" + ); + } + } } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index b092c5f..1ad3e42 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -910,6 +910,26 @@ pub struct WhoArgs { #[arg(long = "no-detail", hide = true, overrides_with = "detail")] pub no_detail: bool, + + /// Score as if "now" is this date (ISO 8601 or duration like 30d). Expert mode only. + #[arg(long = "as-of", help_heading = "Scoring")] + pub as_of: Option, + + /// Show per-component score breakdown in output. Expert mode only. + #[arg(long = "explain-score", help_heading = "Scoring")] + pub explain_score: bool, + + /// Include bot users in results (normally excluded via scoring.excluded_usernames). + #[arg(long = "include-bots", help_heading = "Scoring")] + pub include_bots: bool, + + /// Remove the default time window (query all history). Conflicts with --since. + #[arg( + long = "all-history", + help_heading = "Filters", + conflicts_with = "since" + )] + pub all_history: bool, } #[derive(Parser)] diff --git a/src/core/config.rs b/src/core/config.rs index 39182f1..eee368f 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -164,6 +164,38 @@ pub struct ScoringConfig { /// Bonus points per individual inline review comment (DiffNote). #[serde(rename = "noteBonus")] pub note_bonus: i64, + + /// Points per MR where the user was assigned as a reviewer. + #[serde(rename = "reviewerAssignmentWeight")] + pub reviewer_assignment_weight: i64, + + /// Half-life in days for author contribution decay. + #[serde(rename = "authorHalfLifeDays")] + pub author_half_life_days: u32, + + /// Half-life in days for reviewer contribution decay. + #[serde(rename = "reviewerHalfLifeDays")] + pub reviewer_half_life_days: u32, + + /// Half-life in days for reviewer assignment decay. + #[serde(rename = "reviewerAssignmentHalfLifeDays")] + pub reviewer_assignment_half_life_days: u32, + + /// Half-life in days for note/comment contribution decay. + #[serde(rename = "noteHalfLifeDays")] + pub note_half_life_days: u32, + + /// Multiplier applied to scores from closed (not merged) MRs. + #[serde(rename = "closedMrMultiplier")] + pub closed_mr_multiplier: f64, + + /// Minimum character count for a review note to earn note_bonus. + #[serde(rename = "reviewerMinNoteChars")] + pub reviewer_min_note_chars: u32, + + /// Usernames excluded from expert/scoring results. + #[serde(rename = "excludedUsernames")] + pub excluded_usernames: Vec, } impl Default for ScoringConfig { @@ -172,6 +204,14 @@ impl Default for ScoringConfig { author_weight: 25, reviewer_weight: 10, note_bonus: 1, + reviewer_assignment_weight: 3, + author_half_life_days: 180, + reviewer_half_life_days: 90, + reviewer_assignment_half_life_days: 45, + note_half_life_days: 45, + closed_mr_multiplier: 0.5, + reviewer_min_note_chars: 20, + excluded_usernames: vec![], } } } @@ -287,6 +327,55 @@ fn validate_scoring(scoring: &ScoringConfig) -> Result<()> { details: "scoring.noteBonus must be >= 0".to_string(), }); } + if scoring.reviewer_assignment_weight < 0 { + return Err(LoreError::ConfigInvalid { + details: "scoring.reviewerAssignmentWeight must be >= 0".to_string(), + }); + } + if scoring.author_half_life_days == 0 || scoring.author_half_life_days > 3650 { + return Err(LoreError::ConfigInvalid { + details: "scoring.authorHalfLifeDays must be in 1..=3650".to_string(), + }); + } + if scoring.reviewer_half_life_days == 0 || scoring.reviewer_half_life_days > 3650 { + return Err(LoreError::ConfigInvalid { + details: "scoring.reviewerHalfLifeDays must be in 1..=3650".to_string(), + }); + } + if scoring.reviewer_assignment_half_life_days == 0 + || scoring.reviewer_assignment_half_life_days > 3650 + { + return Err(LoreError::ConfigInvalid { + details: "scoring.reviewerAssignmentHalfLifeDays must be in 1..=3650".to_string(), + }); + } + if scoring.note_half_life_days == 0 || scoring.note_half_life_days > 3650 { + return Err(LoreError::ConfigInvalid { + details: "scoring.noteHalfLifeDays must be in 1..=3650".to_string(), + }); + } + if !scoring.closed_mr_multiplier.is_finite() + || scoring.closed_mr_multiplier <= 0.0 + || scoring.closed_mr_multiplier > 1.0 + { + return Err(LoreError::ConfigInvalid { + details: "scoring.closedMrMultiplier must be finite and in (0.0, 1.0]".to_string(), + }); + } + if scoring.reviewer_min_note_chars > 4096 { + return Err(LoreError::ConfigInvalid { + details: "scoring.reviewerMinNoteChars must be <= 4096".to_string(), + }); + } + if scoring + .excluded_usernames + .iter() + .any(|u| u.trim().is_empty()) + { + return Err(LoreError::ConfigInvalid { + details: "scoring.excludedUsernames entries must be non-empty".to_string(), + }); + } Ok(()) } @@ -561,4 +650,140 @@ mod tests { "set default_project should be present: {json}" ); } + + #[test] + fn test_config_validation_rejects_zero_half_life() { + let scoring = ScoringConfig { + author_half_life_days: 0, + ..Default::default() + }; + let err = validate_scoring(&scoring).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("authorHalfLifeDays"), + "unexpected error: {msg}" + ); + } + + #[test] + fn test_config_validation_rejects_absurd_half_life() { + let scoring = ScoringConfig { + author_half_life_days: 5000, + ..Default::default() + }; + let err = validate_scoring(&scoring).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("authorHalfLifeDays"), + "unexpected error: {msg}" + ); + } + + #[test] + fn test_config_validation_rejects_nan_multiplier() { + let scoring = ScoringConfig { + closed_mr_multiplier: f64::NAN, + ..Default::default() + }; + let err = validate_scoring(&scoring).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("closedMrMultiplier"), + "unexpected error: {msg}" + ); + } + + #[test] + fn test_config_validation_rejects_zero_multiplier() { + let scoring = ScoringConfig { + closed_mr_multiplier: 0.0, + ..Default::default() + }; + let err = validate_scoring(&scoring).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("closedMrMultiplier"), + "unexpected error: {msg}" + ); + } + + #[test] + fn test_config_validation_rejects_negative_reviewer_assignment_weight() { + let scoring = ScoringConfig { + reviewer_assignment_weight: -1, + ..Default::default() + }; + let err = validate_scoring(&scoring).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("reviewerAssignmentWeight"), + "unexpected error: {msg}" + ); + } + + #[test] + fn test_config_validation_rejects_oversized_min_note_chars() { + let scoring = ScoringConfig { + reviewer_min_note_chars: 5000, + ..Default::default() + }; + let err = validate_scoring(&scoring).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("reviewerMinNoteChars"), + "unexpected error: {msg}" + ); + } + + #[test] + fn test_config_validation_rejects_empty_excluded_username() { + let scoring = ScoringConfig { + excluded_usernames: vec!["valid".to_string(), " ".to_string()], + ..Default::default() + }; + let err = validate_scoring(&scoring).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("excludedUsernames"), "unexpected error: {msg}"); + } + + #[test] + fn test_config_validation_accepts_valid_new_fields() { + let scoring = ScoringConfig { + author_half_life_days: 365, + reviewer_half_life_days: 180, + reviewer_assignment_half_life_days: 90, + note_half_life_days: 60, + closed_mr_multiplier: 0.5, + reviewer_min_note_chars: 20, + reviewer_assignment_weight: 3, + excluded_usernames: vec!["bot-user".to_string()], + ..Default::default() + }; + validate_scoring(&scoring).unwrap(); + } + + #[test] + fn test_config_validation_accepts_boundary_half_life() { + // 1 and 3650 are both valid boundaries + let scoring_min = ScoringConfig { + author_half_life_days: 1, + ..Default::default() + }; + validate_scoring(&scoring_min).unwrap(); + + let scoring_max = ScoringConfig { + author_half_life_days: 3650, + ..Default::default() + }; + validate_scoring(&scoring_max).unwrap(); + } + + #[test] + fn test_config_validation_accepts_multiplier_at_one() { + let scoring = ScoringConfig { + closed_mr_multiplier: 1.0, + ..Default::default() + }; + validate_scoring(&scoring).unwrap(); + } } diff --git a/src/core/db.rs b/src/core/db.rs index 8105f30..1085c7a 100644 --- a/src/core/db.rs +++ b/src/core/db.rs @@ -85,6 +85,10 @@ const MIGRATIONS: &[(&str, &str)] = &[ "025", include_str!("../../migrations/025_note_dirty_backfill.sql"), ), + ( + "026", + include_str!("../../migrations/026_scoring_indexes.sql"), + ), ]; pub fn create_connection(db_path: &Path) -> Result {