diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 0684e69..d9265e9 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,8 +1,10 @@ {"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-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-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-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-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"}]} {"id":"bd-157","title":"[CP1] Issue transformer with label extraction","description":"Transform GitLab issue payloads to normalized database schema.\n\n## Module\nsrc/gitlab/transformers/issue.rs\n\n## Structs\n\n### NormalizedIssue\n- gitlab_id: i64\n- project_id: i64 (local DB project ID)\n- iid: i64\n- title: String\n- description: Option\n- state: String\n- author_username: String\n- created_at, updated_at, last_seen_at: i64 (ms epoch)\n- web_url: String\n\n### NormalizedLabel (CP1: name-only)\n- project_id: i64\n- name: String\n\n## Functions\n\n### transform_issue(gitlab_issue: &GitLabIssue, local_project_id: i64) -> NormalizedIssue\n- Convert ISO timestamps to ms epoch using iso_to_ms()\n- Set last_seen_at to now_ms()\n- Clone string fields\n\n### extract_labels(gitlab_issue: &GitLabIssue, local_project_id: i64) -> Vec\n- Map labels vec to NormalizedLabel structs\n\nFiles: \n- src/gitlab/transformers/mod.rs\n- src/gitlab/transformers/issue.rs\nTests: tests/issue_transformer_tests.rs\nDone when: Unit tests pass for payload transformation and label extraction","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:42:47.719562Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.736142Z","deleted_at":"2026-01-25T17:02:01.736129Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0} @@ -11,15 +13,21 @@ {"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-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-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-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"}]} {"id":"bd-1d5","title":"[CP1] GitLab client pagination methods","description":"Add async generator methods for paginated GitLab API calls.\n\nMethods to add to src/gitlab/client.ts:\n- paginateIssues(gitlabProjectId, updatedAfter?) → AsyncGenerator\n- paginateIssueDiscussions(gitlabProjectId, issueIid) → AsyncGenerator\n- requestWithHeaders(path) → { data: T, headers: Headers }\n\nImplementation:\n- Use scope=all, state=all for issues\n- Order by updated_at ASC\n- Follow X-Next-Page header until empty/absent\n- Apply cursor rewind (subtract cursorRewindSeconds) for tuple semantics\n- Fall back to empty-page detection if headers missing\n\nFiles: src/gitlab/client.ts\nTests: tests/unit/pagination.test.ts\nDone when: Pagination handles multiple pages and respects cursors","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:19:43.069869Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.156881Z","deleted_at":"2026-01-25T15:21:35.156877Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-1ep","title":"Wire resource event fetching into sync pipeline","description":"## Background\nAfter issue/MR primary ingestion and discussion fetch, changed entities need resource_events jobs enqueued and drained. This is the integration point that connects the queue (bd-tir), API client (bd-sqw), DB upserts (bd-1uc), and config flag (bd-2e8).\n\n## Approach\nModify the sync pipeline to add two new phases after discussion sync:\n\n**Phase 1 — Enqueue during ingestion:**\nIn src/ingestion/orchestrator.rs, after each entity upsert (issue or MR), call:\n```rust\nif config.sync.fetch_resource_events {\n enqueue_job(conn, project_id, \"issue\", iid, local_id, \"resource_events\", None)?;\n}\n// For MRs, also enqueue mr_closes_issues (always) and mr_diffs (when fetchMrFileChanges)\n```\n\nThe \"changed entity\" detection uses the existing dirty tracker: if an entity was inserted or updated during this sync run, it gets enqueued. On --full sync, all entities are enqueued.\n\n**Phase 2 — Drain dependent queue:**\nAdd a new drain step in src/cli/commands/sync.rs (or new src/core/drain.rs), called after discussion sync:\n```rust\npub async fn drain_dependent_queue(\n conn: &Connection,\n client: &GitLabClient,\n config: &Config,\n progress: Option,\n) -> Result\n```\n\nFlow:\n1. reclaim_stale_locks(conn, config.sync.stale_lock_minutes)\n2. Loop: claim_jobs(conn, \"resource_events\", batch_size=10)\n3. For each job:\n a. Fetch 3 event types via client (fetch_issue_state_events etc.)\n b. Store via upsert functions (upsert_state_events etc.)\n c. complete_job(conn, job.id) on success\n d. fail_job(conn, job.id, error_msg) on failure\n4. Report progress: \"Fetching resource events... [N/M]\"\n5. Repeat until no more claimable jobs\n\n**Progress reporting:**\nAdd new ProgressEvent variants:\n```rust\nResourceEventsFetchStart { total: usize },\nResourceEventsFetchProgress { completed: usize, total: usize },\nResourceEventsFetchComplete { fetched: usize, failed: usize },\n```\n\n## Acceptance Criteria\n- [ ] Full sync enqueues resource_events jobs for all issues and MRs\n- [ ] Incremental sync only enqueues for entities changed since last sync\n- [ ] --no-events prevents enqueueing resource_events jobs\n- [ ] Drain step fetches all 3 event types per entity\n- [ ] Successful fetches stored and job completed\n- [ ] Failed fetches recorded with error, job retried on next sync\n- [ ] Stale locks reclaimed at drain start\n- [ ] Progress displayed: \"Fetching resource events... [N/M]\"\n- [ ] Robot mode progress suppressed (quiet mode)\n\n## Files\n- src/ingestion/orchestrator.rs (add enqueue calls during upsert)\n- src/cli/commands/sync.rs (add drain step after discussions)\n- src/core/drain.rs (new, optional — or inline in sync.rs)\n\n## TDD Loop\nRED: tests/sync_pipeline_tests.rs (or extend existing):\n- `test_sync_enqueues_resource_events_for_changed_entities` - mock sync, verify jobs enqueued\n- `test_sync_no_events_flag_skips_enqueue` - verify no jobs when flag false\n- `test_drain_completes_jobs_on_success` - mock API responses, verify jobs deleted\n- `test_drain_fails_jobs_on_error` - mock API failure, verify job attempts incremented\n\nNote: Full pipeline integration tests may need mock HTTP server. Start with unit tests on enqueue/drain logic using the real DB with mock API responses.\n\nGREEN: Implement enqueue hooks + drain step\n\nVERIFY: `cargo test sync -- --nocapture && cargo build`\n\n## Edge Cases\n- Entity deleted between enqueue and drain: API returns 404, fail_job with \"entity not found\" (retry won't help but backoff caps it)\n- Rate limiting during drain: GitLabRateLimited error should fail_job with retry (transient)\n- Network error during drain: GitLabNetworkError should fail_job with retry\n- Multiple sync runs competing: locked_at prevents double-processing; stale lock reclaim handles crashes\n- Drain should have a max iterations guard to prevent infinite loop if jobs keep failing and being retried within the same run","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:31:57.334527Z","created_by":"tayloreernisse","updated_at":"2026-02-03T17:46:51.336138Z","closed_at":"2026-02-03T17:46:51.336077Z","close_reason":"Implemented: enqueue + drain resource events in orchestrator, wired counts through ingest→sync pipeline, added progress events, 4 new tests, all 209 tests pass","compaction_level":0,"original_size":0,"labels":["gate-1","phase-b","pipeline"],"dependencies":[{"issue_id":"bd-1ep","depends_on_id":"bd-1uc","type":"blocks","created_at":"2026-02-02T21:32:06.225837Z","created_by":"tayloreernisse"},{"issue_id":"bd-1ep","depends_on_id":"bd-2e8","type":"blocks","created_at":"2026-02-02T21:32:06.142442Z","created_by":"tayloreernisse"},{"issue_id":"bd-1ep","depends_on_id":"bd-2zl","type":"parent-child","created_at":"2026-02-02T21:31:57.335847Z","created_by":"tayloreernisse"},{"issue_id":"bd-1ep","depends_on_id":"bd-sqw","type":"blocks","created_at":"2026-02-02T21:32:06.183287Z","created_by":"tayloreernisse"},{"issue_id":"bd-1ep","depends_on_id":"bd-tir","type":"blocks","created_at":"2026-02-02T21:32:06.267800Z","created_by":"tayloreernisse"}]} {"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-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-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-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"}]} @@ -41,13 +49,17 @@ {"id":"bd-1re","title":"[CP1] gi show issue command","description":"Show issue details with discussions.\n\nFlags:\n- --project=PATH (required if iid is ambiguous across projects)\n\nOutput:\n- Title, project, state, author, dates, labels, URL\n- Description text\n- All discussions with notes (formatted thread view)\n\nHandle ambiguity: If multiple projects have same iid, prompt for --project or show error.\n\nFiles: src/cli/commands/show.ts\nDone when: Issue detail view displays all fields including threaded discussions","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T15:20:29.826786Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.153211Z","deleted_at":"2026-01-25T15:21:35.153208Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-1s1","title":"[CP1] Integration tests for issue ingestion","description":"Full integration tests for issue ingestion module.\n\n## Tests (tests/issue_ingestion_tests.rs)\n\n- inserts_issues_into_database\n- creates_labels_from_issue_payloads\n- links_issues_to_labels_via_junction_table\n- removes_stale_label_links_on_resync\n- stores_raw_payload_for_each_issue\n- stores_raw_payload_for_each_discussion\n- updates_cursor_incrementally_per_page\n- resumes_from_cursor_on_subsequent_runs\n- handles_issues_with_no_labels\n- upserts_existing_issues_on_refetch\n- skips_discussion_refetch_for_unchanged_issues\n\n## Test Setup\n- tempfile::TempDir for isolated database\n- wiremock::MockServer for GitLab API\n- Mock handlers returning fixture data\n\nFiles: tests/issue_ingestion_tests.rs\nDone when: All integration tests pass with mocked GitLab","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T16:59:12.158586Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:02.109109Z","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-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-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"}]} {"id":"bd-1uc","title":"Implement DB upsert functions for resource events","description":"## Background\nNeed to store fetched resource events into the three event tables created by migration 011. The existing DB pattern uses rusqlite prepared statements with named parameters. Timestamps from GitLab are ISO 8601 strings that need conversion to ms epoch UTC (matching the existing time.rs parse_datetime_to_ms function).\n\n## Approach\nCreate src/core/events_db.rs (new module) with three upsert functions:\n\n```rust\nuse rusqlite::Connection;\nuse super::error::Result;\n\n/// Upsert state events for an entity.\n/// Uses INSERT OR REPLACE keyed on UNIQUE(gitlab_id, project_id).\npub fn upsert_state_events(\n conn: &Connection,\n project_id: i64, // local DB project id\n entity_type: &str, // \"issue\" | \"merge_request\"\n entity_local_id: i64, // local DB id of the issue/MR\n events: &[GitLabStateEvent],\n) -> Result\n\n/// Upsert label events for an entity.\npub fn upsert_label_events(\n conn: &Connection,\n project_id: i64,\n entity_type: &str,\n entity_local_id: i64,\n events: &[GitLabLabelEvent],\n) -> Result\n\n/// Upsert milestone events for an entity.\npub fn upsert_milestone_events(\n conn: &Connection,\n project_id: i64,\n entity_type: &str,\n entity_local_id: i64,\n events: &[GitLabMilestoneEvent],\n) -> Result\n```\n\nEach function:\n1. Prepares INSERT OR REPLACE statement\n2. For each event, maps GitLab types to DB columns:\n - `actor_gitlab_id` = event.user.map(|u| u.id)\n - `actor_username` = event.user.map(|u| u.username.clone())\n - `created_at` = parse_datetime_to_ms(&event.created_at)?\n - Set issue_id or merge_request_id based on entity_type\n3. Returns count of upserted rows\n4. Wraps in a savepoint for atomicity per entity\n\nRegister module in src/core/mod.rs:\n```rust\npub mod events_db;\n```\n\n## Acceptance Criteria\n- [ ] All three upsert functions compile and handle all event fields\n- [ ] Upserts are idempotent (re-inserting same event doesn't duplicate)\n- [ ] Timestamps converted to ms epoch UTC via parse_datetime_to_ms\n- [ ] actor_gitlab_id and actor_username populated from event.user (handles None)\n- [ ] entity_type correctly maps to issue_id/merge_request_id (other is NULL)\n- [ ] source_merge_request_id populated for state events (iid from source_merge_request)\n- [ ] source_commit populated for state events\n- [ ] label_name populated for label events\n- [ ] milestone_title and milestone_id populated for milestone events\n- [ ] Returns upserted count\n\n## Files\n- src/core/events_db.rs (new)\n- src/core/mod.rs (add `pub mod events_db;`)\n\n## TDD Loop\nRED: tests/events_db_tests.rs (new):\n- `test_upsert_state_events_basic` - insert 3 events, verify count and data\n- `test_upsert_state_events_idempotent` - insert same events twice, verify no duplicates\n- `test_upsert_label_events_with_actor` - verify actor fields populated\n- `test_upsert_milestone_events_null_user` - verify user: null doesn't crash\n- `test_upsert_state_events_entity_exclusivity` - verify only one of issue_id/merge_request_id set\n\nSetup: create_test_db() helper that applies migrations 001-011, inserts a test project + issue + MR.\n\nGREEN: Implement the three functions\n\nVERIFY: `cargo test events_db -- --nocapture`\n\n## Edge Cases\n- parse_datetime_to_ms must handle GitLab's format: \"2024-03-15T10:30:00.000Z\" and \"2024-03-15T10:30:00.000+00:00\"\n- INSERT OR REPLACE will fire CASCADE deletes if there are FK references to these rows — currently no other table references event rows, so this is safe\n- entity_type must be validated (\"issue\" or \"merge_request\") — panic or error on invalid\n- source_merge_request field contains an MR ref object, not an ID — extract .iid for DB column","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:31:57.242549Z","created_by":"tayloreernisse","updated_at":"2026-02-03T16:19:14.169437Z","closed_at":"2026-02-03T16:19:14.169233Z","close_reason":"Implemented upsert_state_events, upsert_label_events, upsert_milestone_events, count_events in src/core/events_db.rs. Uses savepoints for atomicity, LoreError::Database via ? operator for clean error handling.","compaction_level":0,"original_size":0,"labels":["db","gate-1","phase-b"],"dependencies":[{"issue_id":"bd-1uc","depends_on_id":"bd-2zl","type":"parent-child","created_at":"2026-02-02T21:31:57.246078Z","created_by":"tayloreernisse"},{"issue_id":"bd-1uc","depends_on_id":"bd-hu3","type":"blocks","created_at":"2026-02-02T21:31:57.247258Z","created_by":"tayloreernisse"}]} {"id":"bd-1ut","title":"[CP0] Final validation - tests, lint, typecheck","description":"## Background\n\nFinal validation ensures everything works together before marking CP0 complete. This is the integration gate - all unit tests, integration tests, lint, and type checking must pass. Manual smoke tests verify the full user experience.\n\nReference: docs/prd/checkpoint-0.md sections \"Definition of Done\", \"Manual Smoke Tests\"\n\n## Approach\n\n**Automated checks:**\n```bash\n# All tests pass\nnpm run test\n\n# TypeScript strict mode\nnpm run build # or: npx tsc --noEmit\n\n# ESLint with no errors\nnpm run lint\n```\n\n**Manual smoke tests (from PRD table):**\n\n| Command | Expected | Pass Criteria |\n|---------|----------|---------------|\n| `gi --help` | Command list | Shows all commands |\n| `gi version` | Version number | Shows installed version |\n| `gi init` | Interactive prompts | Creates valid config |\n| `gi init` (config exists) | Confirmation prompt | Warns before overwriting |\n| `gi init --force` | No prompt | Overwrites without asking |\n| `gi auth-test` | `Authenticated as @username` | Shows GitLab username |\n| `GITLAB_TOKEN=invalid gi auth-test` | Error message | Non-zero exit, clear error |\n| `gi doctor` | Status table | All required checks pass |\n| `gi doctor --json` | JSON object | Valid JSON, `success: true` |\n| `gi backup` | Backup path | Creates timestamped backup |\n| `gi sync-status` | No runs message | Stub output works |\n\n**Definition of Done gate items:**\n- [ ] `gi init` writes config to XDG path and validates projects against GitLab\n- [ ] `gi auth-test` succeeds with real PAT\n- [ ] `gi doctor` reports DB ok + GitLab ok\n- [ ] DB migrations apply; WAL + FK enabled; busy_timeout + synchronous set\n- [ ] App lock mechanism works (concurrent runs blocked)\n- [ ] All unit tests pass\n- [ ] All integration tests pass (mocked)\n- [ ] ESLint passes with no errors\n- [ ] TypeScript compiles with strict mode\n\n## Acceptance Criteria\n\n- [ ] `npm run test` exits 0 (all tests pass)\n- [ ] `npm run build` exits 0 (TypeScript compiles)\n- [ ] `npm run lint` exits 0 (no ESLint errors)\n- [ ] All 11 manual smoke tests pass\n- [ ] All 9 Definition of Done gate items verified\n\n## Files\n\nNo new files created. This bead verifies existing work.\n\n## TDD Loop\n\nThis IS the final verification step:\n\n```bash\n# Automated\nnpm run test\nnpm run build\nnpm run lint\n\n# Manual (requires GITLAB_TOKEN set with valid token)\ngi --help\ngi version\ngi init # go through setup\ngi auth-test\ngi doctor\ngi doctor --json | jq .success # should output true\ngi backup\ngi sync-status\ngi reset --confirm\ngi init # re-setup\n```\n\n## Edge Cases\n\n- Test coverage should be reasonable (aim for 80%+ on core modules)\n- Integration tests may flake on CI - check MSW setup\n- Manual tests require real GitLab token - document in README\n- ESLint may warn vs error - only errors block\n- TypeScript noImplicitAny catches missed types","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-24T16:09:52.078907Z","created_by":"tayloreernisse","updated_at":"2026-01-25T03:37:51.858558Z","closed_at":"2026-01-25T03:37:51.858474Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1ut","depends_on_id":"bd-1cb","type":"blocks","created_at":"2026-01-24T16:13:11.184261Z","created_by":"tayloreernisse"},{"issue_id":"bd-1ut","depends_on_id":"bd-1gu","type":"blocks","created_at":"2026-01-24T16:13:11.168637Z","created_by":"tayloreernisse"},{"issue_id":"bd-1ut","depends_on_id":"bd-1kh","type":"blocks","created_at":"2026-01-24T16:13:11.219042Z","created_by":"tayloreernisse"},{"issue_id":"bd-1ut","depends_on_id":"bd-38e","type":"blocks","created_at":"2026-01-24T16:13:11.150286Z","created_by":"tayloreernisse"},{"issue_id":"bd-1ut","depends_on_id":"bd-3kj","type":"blocks","created_at":"2026-01-24T16:13:11.200998Z","created_by":"tayloreernisse"}]} {"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-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-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"]} {"id":"bd-1y8","title":"Implement chunk ID encoding module","description":"## Background\nsqlite-vec uses a single integer rowid for embeddings. To store multiple chunks per document, we encode (document_id, chunk_index) into a single rowid using a multiplier. This module is shared between the embedding pipeline (encode on write) and vector search (decode on read). The encoding scheme supports up to 1000 chunks per document.\n\n## Approach\nCreate `src/embedding/chunk_ids.rs`:\n\n```rust\n/// Multiplier for encoding (document_id, chunk_index) into a single rowid.\n/// Supports up to 1000 chunks per document (32M chars at 32k/chunk).\npub const CHUNK_ROWID_MULTIPLIER: i64 = 1000;\n\n/// Encode (document_id, chunk_index) into a sqlite-vec rowid.\n///\n/// rowid = document_id * CHUNK_ROWID_MULTIPLIER + chunk_index\npub fn encode_rowid(document_id: i64, chunk_index: i64) -> i64 {\n document_id * CHUNK_ROWID_MULTIPLIER + chunk_index\n}\n\n/// Decode a sqlite-vec rowid back into (document_id, chunk_index).\npub fn decode_rowid(rowid: i64) -> (i64, i64) {\n let document_id = rowid / CHUNK_ROWID_MULTIPLIER;\n let chunk_index = rowid % CHUNK_ROWID_MULTIPLIER;\n (document_id, chunk_index)\n}\n```\n\nAlso create the parent module `src/embedding/mod.rs`:\n```rust\npub mod chunk_ids;\n// Later beads add: pub mod ollama; pub mod pipeline;\n```\n\nUpdate `src/lib.rs`: add `pub mod embedding;`\n\n## Acceptance Criteria\n- [ ] `encode_rowid(42, 0)` == 42000\n- [ ] `encode_rowid(42, 5)` == 42005\n- [ ] `decode_rowid(42005)` == (42, 5)\n- [ ] Roundtrip: decode(encode(doc_id, chunk_idx)) == (doc_id, chunk_idx) for all valid inputs\n- [ ] CHUNK_ROWID_MULTIPLIER is 1000\n- [ ] `cargo test chunk_ids` passes\n\n## Files\n- `src/embedding/chunk_ids.rs` — new file\n- `src/embedding/mod.rs` — new file (module root)\n- `src/lib.rs` — add `pub mod embedding;`\n\n## TDD Loop\nRED: Tests in `#[cfg(test)] mod tests`:\n- `test_encode_single_chunk` — encode(1, 0) == 1000\n- `test_encode_multi_chunk` — encode(1, 5) == 1005\n- `test_decode_roundtrip` — property test over range of doc_ids and chunk_indices\n- `test_decode_zero_chunk` — decode(42000) == (42, 0)\n- `test_multiplier_value` — assert CHUNK_ROWID_MULTIPLIER == 1000\nGREEN: Implement encode_rowid, decode_rowid\nVERIFY: `cargo test chunk_ids`\n\n## Edge Cases\n- chunk_index >= 1000: not expected (documents that large would be pathological), but no runtime panic — just incorrect decode. The embedding pipeline caps chunks well below this.\n- document_id = 0: valid (encode returns chunk_index directly)","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-30T15:26:34.060769Z","created_by":"tayloreernisse","updated_at":"2026-01-30T16:51:59.048910Z","closed_at":"2026-01-30T16:51:59.048843Z","close_reason":"Completed: chunk_ids module with encode_rowid/decode_rowid, CHUNK_ROWID_MULTIPLIER=1000, 6 tests pass","compaction_level":0,"original_size":0} {"id":"bd-1yu","title":"[CP1] GitLab types for issues, discussions, notes","description":"Add TypeScript interfaces for GitLab API responses.\n\nTypes to add to src/gitlab/types.ts:\n- GitLabIssue: id, iid, project_id, title, description, state, timestamps, author, labels[], labels_details?, web_url\n- GitLabDiscussion: id (string), individual_note, notes[]\n- GitLabNote: id, type, body, author, timestamps, system, resolvable, resolved, resolved_by, resolved_at, position?\n\nFiles: src/gitlab/types.ts\nDone when: Types compile and match GitLab API documentation","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:19:00.558718Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.153996Z","deleted_at":"2026-01-25T15:21:35.153993Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-1yx","title":"Implement rename chain resolution for file-history","description":"## Background\n\nRename chain resolution is the core algorithm for Gate 4. When querying history of src/auth.rs, it finds MRs that touched the file when it was previously named src/authentication.rs. This is reused by Gate 5 (trace) as well.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 4.6 (Rename Handling).\n\n## Codebase Context\n\n- mr_file_changes table (migration 016, bd-1oo): merge_request_id, project_id, old_path, new_path, change_type\n- change_type='renamed' rows have both old_path and new_path populated\n- Partial index `idx_mfc_renamed` on (project_id, change_type) WHERE change_type='renamed' optimizes BFS queries\n- Also `idx_mfc_project_path` on (project_id, new_path) and `idx_mfc_project_old_path` partial index\n- No timeline/trace/file_history modules exist yet in src/core/\n\n## Approach\n\nCreate `src/core/file_history.rs`:\n\n```rust\nuse std::collections::HashSet;\nuse std::collections::VecDeque;\nuse rusqlite::Connection;\nuse crate::core::error::Result;\n\n/// Resolves a file path through its rename history.\n/// Returns all equivalent paths (original + renames) for use in queries.\n/// BFS in both directions: forward (old_path -> new_path) and backward (new_path -> old_path).\npub fn resolve_rename_chain(\n conn: &Connection,\n project_id: i64,\n path: &str,\n max_hops: usize, // default 10 from CLI\n) -> Result> {\n let mut visited: HashSet = HashSet::new();\n let mut queue: VecDeque = VecDeque::new();\n\n visited.insert(path.to_string());\n queue.push_back(path.to_string());\n\n let forward_sql = \"SELECT mfc.new_path FROM mr_file_changes mfc \\\n WHERE mfc.project_id = ?1 AND mfc.old_path = ?2 AND mfc.change_type = 'renamed'\";\n let backward_sql = \"SELECT mfc.old_path FROM mr_file_changes mfc \\\n WHERE mfc.project_id = ?1 AND mfc.new_path = ?2 AND mfc.change_type = 'renamed'\";\n\n while let Some(current) = queue.pop_front() {\n if visited.len() > max_hops + 1 { break; }\n\n // Forward: current was the old name -> discover new names\n let mut stmt = conn.prepare(forward_sql)?;\n let forward: Vec = stmt.query_map(\n rusqlite::params\\![project_id, current],\n |row| row.get(0),\n )?.filter_map(|r| r.ok()).collect();\n\n // Backward: current was the new name -> discover old names\n let mut stmt = conn.prepare(backward_sql)?;\n let backward: Vec = stmt.query_map(\n rusqlite::params\\![project_id, current],\n |row| row.get(0),\n )?.filter_map(|r| r.ok()).collect();\n\n for discovered in forward.into_iter().chain(backward) {\n if visited.insert(discovered.clone()) {\n queue.push_back(discovered);\n }\n }\n }\n\n Ok(visited.into_iter().collect())\n}\n```\n\nRegister in `src/core/mod.rs`: add `pub mod file_history;`\n\n## Acceptance Criteria\n\n- [ ] `resolve_rename_chain()` follows renames in both directions (forward + backward)\n- [ ] Cycles detected via HashSet (same path never visited twice)\n- [ ] Bounded at max_hops (default 10)\n- [ ] No renames found: returns vec with just the original path\n- [ ] max_hops=0: returns just original path without querying DB\n- [ ] Module registered in src/core/mod.rs as `pub mod file_history;`\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/core/file_history.rs` (NEW)\n- `src/core/mod.rs` (add `pub mod file_history;`)\n\n## TDD Loop\n\nRED:\n- `test_rename_chain_no_renames` — returns just original path\n- `test_rename_chain_forward` — a.rs -> b.rs -> c.rs: starting from a.rs finds all three\n- `test_rename_chain_backward` — starting from c.rs finds a.rs and b.rs\n- `test_rename_chain_cycle_detection` — a->b->a terminates without infinite loop\n- `test_rename_chain_max_hops_zero` — returns just original path\n- `test_rename_chain_max_hops_bounded` — chain longer than max is truncated\n\nTests need in-memory DB with migrations applied through 016 + mr_file_changes test data with change_type='renamed'.\n\nGREEN: Implement BFS with visited set.\n\nVERIFY: `cargo test --lib -- file_history`\n\n## Edge Cases\n\n- File never renamed: single-element vec\n- Circular rename (a->b->a): visited set prevents infinite loop\n- max_hops=0: return just original path, no queries executed\n- Case sensitivity: paths are case-sensitive (Linux default, matches GitLab behavior)\n- Multiple renames from same old_path: BFS discovers all branches\n","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-02T21:34:08.985345Z","created_by":"tayloreernisse","updated_at":"2026-02-05T20:54:52.423441Z","compaction_level":0,"original_size":0,"labels":["gate-4","phase-b","query"],"dependencies":[{"issue_id":"bd-1yx","depends_on_id":"bd-14q","type":"parent-child","created_at":"2026-02-02T21:34:08.986730Z","created_by":"tayloreernisse"},{"issue_id":"bd-1yx","depends_on_id":"bd-1oo","type":"blocks","created_at":"2026-02-02T21:34:16.698782Z","created_by":"tayloreernisse"}]} @@ -58,6 +70,7 @@ {"id":"bd-20e","title":"Define TimelineEvent model and TimelineEventType enum","description":"## Background\n\nThe TimelineEvent model is the foundational data type for Gate 3's timeline feature. All pipeline stages (seed, expand, collect, interleave) produce or consume TimelineEvents. This must be defined first because every downstream bead (bd-32q, bd-ypa, bd-3as, bd-dty, bd-2f2) depends on these types.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.3 (Event Model).\n\n## Codebase Context\n\n- Migration 011 created: resource_state_events, resource_label_events, resource_milestone_events, entity_references, pending_dependent_fetches\n- source_method CHECK constraint: `'api' | 'note_parse' | 'description_parse'` (NOT spec's 'api_closes_issues' etc.)\n- reference_type CHECK constraint: `'closes' | 'mentioned' | 'related'`\n- LATEST_SCHEMA_VERSION = 14\n\n## Approach\n\nCreate `src/core/timeline.rs` with the following types:\n\n```rust\n/// The core timeline event. All pipeline stages produce or consume these.\n/// Spec ref: Section 3.3 \"Event Model\"\n#[derive(Debug, Clone, Serialize)]\npub struct TimelineEvent {\n pub timestamp: i64, // ms epoch UTC\n pub entity_type: String, // \"issue\" | \"merge_request\"\n pub entity_id: i64, // local DB id (internal, not in JSON output)\n pub entity_iid: i64,\n pub project_path: String,\n pub event_type: TimelineEventType,\n pub summary: String, // human-readable one-liner\n pub actor: Option, // username or None for system\n pub url: Option, // web URL for the event source\n pub is_seed: bool, // true if from seed phase, false if expanded\n}\n\n/// Per spec Section 3.3. Serde tagged enum for JSON output.\n/// IMPORTANT: entity_type is String (not &'static str) because serde Serialize\n/// requires owned types for struct fields when deriving.\n#[derive(Debug, Clone, Serialize)]\n#[serde(tag = \"kind\", rename_all = \"snake_case\")]\npub enum TimelineEventType {\n Created,\n StateChanged { state: String }, // spec: just the target state\n LabelAdded { label: String },\n LabelRemoved { label: String },\n MilestoneSet { milestone: String },\n MilestoneRemoved { milestone: String },\n Merged, // spec: unit variant\n NoteEvidence {\n note_id: i64, // spec: required\n snippet: String, // first ~200 chars of matching note body\n discussion_id: Option, // spec: optional\n },\n CrossReferenced { target: String }, // compact target ref like \"\\!567\" or \"#234\"\n}\n\n/// Internal entity reference used across pipeline stages.\n#[derive(Debug, Clone, Serialize)]\npub struct EntityRef {\n pub entity_type: String, // String not &'static str — needed for Serialize\n pub entity_id: i64,\n pub entity_iid: i64,\n pub project_path: String,\n}\n\n/// An entity discovered via BFS expansion.\n/// Spec ref: Section 3.5 \"expanded_entities\" JSON structure.\n#[derive(Debug, Clone, Serialize)]\npub struct ExpandedEntityRef {\n pub entity_ref: EntityRef,\n pub depth: u32,\n pub via_from: EntityRef, // the entity that referenced this one\n pub via_reference_type: String, // \"closes\", \"mentioned\", \"related\"\n pub via_source_method: String, // \"api\", \"note_parse\", \"description_parse\"\n}\n\n/// Reference to an unsynced external entity.\n/// Spec ref: Section 3.5 \"unresolved_references\" JSON structure.\n#[derive(Debug, Clone, Serialize)]\npub struct UnresolvedRef {\n pub source: EntityRef,\n pub target_project: Option,\n pub target_type: String,\n pub target_iid: i64,\n pub reference_type: String,\n}\n\n/// Complete result from the timeline pipeline.\n#[derive(Debug, Clone, Serialize)]\npub struct TimelineResult {\n pub query: String,\n pub events: Vec,\n pub seed_entities: Vec,\n pub expanded_entities: Vec,\n pub unresolved_references: Vec,\n}\n```\n\nImplement `Ord` on `TimelineEvent` for chronological sort: primary key `timestamp`, tiebreak by `entity_id` then event_type discriminant.\n\nAlso implement `PartialEq`, `Eq`, `PartialOrd` (required by Ord).\n\nRegister in `src/core/mod.rs`: `pub mod timeline;`\n\n## Acceptance Criteria\n\n- [ ] `src/core/timeline.rs` compiles with no warnings\n- [ ] All struct fields use `String` not `&'static str` (required for `#[derive(Serialize)]`)\n- [ ] `TimelineEventType` has exactly 9 variants matching spec Section 3.3\n- [ ] `NoteEvidence` has `note_id: i64`, `snippet: String`, `discussion_id: Option`\n- [ ] `ExpandedEntityRef.via_source_method` documents codebase values: api, note_parse, description_parse\n- [ ] `Ord` impl sorts by (timestamp, entity_id, event_type discriminant)\n- [ ] `PartialEq`, `Eq`, `PartialOrd` derived or implemented\n- [ ] Module registered in `src/core/mod.rs`\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/core/timeline.rs` (NEW)\n- `src/core/mod.rs` (add `pub mod timeline;`)\n\n## TDD Loop\n\nRED: Create `src/core/timeline.rs` with `#[cfg(test)] mod tests`:\n- `test_timeline_event_sort_by_timestamp` - events sort chronologically\n- `test_timeline_event_sort_tiebreak` - same-timestamp events sort stably\n- `test_timeline_event_type_serializes_tagged` - serde JSON uses `kind` tag\n- `test_note_evidence_has_note_id` - note_id present in serialized output\n\nGREEN: Implement the types and Ord trait.\n\nVERIFY: `cargo test --lib -- timeline`\n\n## Edge Cases\n\n- Ord must be consistent and total for all valid TimelineEvent pairs\n- NoteEvidence snippet truncated to 200 chars at construction, not in the type\n- entity_type uses String to satisfy serde Serialize derive requirements\n- url field: constructed from project_path + entity_type + iid; None for entities without web_url","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:33:08.569126Z","created_by":"tayloreernisse","updated_at":"2026-02-05T21:43:02.449502Z","closed_at":"2026-02-05T21:43:02.449454Z","close_reason":"Completed: Created src/core/timeline.rs with TimelineEvent, TimelineEventType (9 variants), EntityRef, ExpandedEntityRef, UnresolvedRef, TimelineResult. Ord impl sorts by (timestamp, entity_id, event_type discriminant). entity_id skipped in serde output. 6 tests pass. All quality gates pass.","compaction_level":0,"original_size":0,"labels":["gate-3","phase-b","types"],"dependencies":[{"issue_id":"bd-20e","depends_on_id":"bd-ike","type":"parent-child","created_at":"2026-02-02T21:33:08.573079Z","created_by":"tayloreernisse"}]} {"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-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-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-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"}]} {"id":"bd-23a4","title":"OBSERV: Wire SyncRunRecorder into sync and ingest commands","description":"## Background\nWith SyncRunRecorder implemented and MetricsLayer available, we wire them into the actual sync and ingest command handlers. This makes every sync/ingest invocation create a database record with full metrics.\n\n## Approach\n### src/cli/commands/sync.rs - run_sync() (line ~54)\n\nBefore the pipeline:\n```rust\nlet recorder = SyncRunRecorder::start(&conn, \"sync\", &run_id)?;\n```\n\nAfter pipeline succeeds:\n```rust\nlet stages = metrics_handle.extract_timings();\nlet total_items = stages.iter().map(|s| s.items_processed).sum::();\nlet total_errors = stages.iter().map(|s| s.errors).sum::();\nrecorder.succeed(&conn, &stages, total_items, total_errors)?;\n```\n\nOn pipeline failure (wrap pipeline in match or use a helper):\n```rust\nmatch pipeline_result {\n Ok(result) => {\n let stages = metrics_handle.extract_timings();\n recorder.succeed(&conn, &stages, total_items, total_errors)?;\n Ok(result)\n }\n Err(e) => {\n let stages = metrics_handle.extract_timings();\n recorder.fail(&conn, &e.to_string(), Some(&stages))?;\n Err(e)\n }\n}\n```\n\n### src/cli/commands/ingest.rs - run_ingest() (line ~107)\n\nSame pattern: start before pipeline, succeed/fail after.\n\nNote: run_sync() calls run_ingest() internally. Both will create sync_runs records. This is intentional -- standalone ingest should also be tracked. But when run_sync calls run_ingest, the ingest record is a child operation. Consider: should we skip the ingest recorder when called from sync? Decision: keep both records. The run_id differs, and sync-status can distinguish by the \"command\" column.\n\nActually, re-reading the code: run_sync() (line 54-178) calls run_ingest() for issues and MRs. If both create sync_runs rows, we get 3 rows per sync (1 sync + 2 ingest). This is fine -- command='sync' vs command='ingest:issues' distinguishes them.\n\n### Connection sharing\nrun_sync and run_ingest already have access to a Connection. SyncRunRecorder::start takes &Connection.\n\n### MetricsLayer handle\nmetrics_handle must be passed from main.rs through handle_sync_cmd/handle_ingest to run_sync/run_ingest. This requires adding a parameter. Alternative: use a thread-local or global. Prefer parameter passing for testability.\n\n## Acceptance Criteria\n- [ ] Every lore sync creates a sync_runs row with status transitioning running -> succeeded/failed\n- [ ] Every lore ingest creates a sync_runs row\n- [ ] metrics_json contains serialized Vec on success\n- [ ] Failed syncs record error message and partial metrics\n- [ ] sync_runs.run_id matches run_id in log files and robot JSON\n- [ ] total_items_processed and total_errors are populated\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/cli/commands/sync.rs (wire SyncRunRecorder + extract_timings in run_sync)\n- src/cli/commands/ingest.rs (wire SyncRunRecorder in run_ingest)\n- src/main.rs (pass metrics_handle to command handlers)\n\n## TDD Loop\nRED: test_sync_creates_run_record (integration: run sync, query sync_runs, assert row exists with metrics)\nGREEN: Wire SyncRunRecorder into run_sync and run_ingest\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- Database locked: SyncRunRecorder operations happen on the main connection. If a concurrent process holds the lock, the INSERT/UPDATE will wait (WAL mode) or error. Use existing lock handling.\n- Partial failure: if ingest issues succeeds but ingest MRs fails, the sync recorder should fail() with partial metrics (stages from issues but not MRs).\n- metrics_handle lifetime: must outlive the root span. Since it's an Arc clone, this is guaranteed.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T15:54:51.414504Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:41:04.963794Z","closed_at":"2026-02-04T17:41:04.963749Z","close_reason":"Wired SyncRunRecorder into handle_sync_cmd and handle_ingest in main.rs","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-23a4","depends_on_id":"bd-22li","type":"blocks","created_at":"2026-02-04T15:55:20.346104Z","created_by":"tayloreernisse"},{"issue_id":"bd-23a4","depends_on_id":"bd-34ek","type":"blocks","created_at":"2026-02-04T15:55:20.401842Z","created_by":"tayloreernisse"},{"issue_id":"bd-23a4","depends_on_id":"bd-3pz","type":"parent-child","created_at":"2026-02-04T15:54:51.415435Z","created_by":"tayloreernisse"}]} @@ -67,10 +80,12 @@ {"id":"bd-2711","title":"WHO: Reviews mode query (query_reviews)","description":"## Background\n\nReviews mode answers \"What review patterns does person X have?\" by analyzing the **prefix** convention in DiffNote bodies (e.g., **suggestion**: ..., **question**: ..., **nit**: ...). Only counts DiffNotes on MRs the user did NOT author (m.author_username != ?1).\n\n## Approach\n\n### Three queries:\n1. **Total DiffNotes**: COUNT(*) of DiffNotes by user on others' MRs\n2. **Distinct MRs reviewed**: COUNT(DISTINCT m.id) \n3. **Category extraction**: SQL-level prefix parsing + Rust normalization\n\n### Category extraction SQL:\n```sql\nSELECT\n SUBSTR(ltrim(n.body), 3, INSTR(SUBSTR(ltrim(n.body), 3), '**') - 1) AS raw_prefix,\n COUNT(*) AS cnt\nFROM notes n\nJOIN discussions d ON n.discussion_id = d.id\nJOIN merge_requests m ON d.merge_request_id = m.id\nWHERE n.author_username = ?1\n AND n.note_type = 'DiffNote' AND n.is_system = 0\n AND m.author_username != ?1\n AND ltrim(n.body) LIKE '**%**%' -- only bodies with **prefix** pattern\n AND n.created_at >= ?2\n AND (?3 IS NULL OR n.project_id = ?3)\nGROUP BY raw_prefix ORDER BY cnt DESC\n```\n\nKey: `ltrim(n.body)` tolerates leading whitespace before **prefix** (common in practice).\n\n### normalize_review_prefix() in Rust:\n```rust\nfn normalize_review_prefix(raw: &str) -> String {\n let s = raw.trim().trim_end_matches(':').trim().to_lowercase();\n // Strip parentheticals like \"(non-blocking)\"\n let s = if let Some(idx) = s.find('(') { s[..idx].trim().to_string() } else { s };\n // Merge nit/nitpick variants\n match s.as_str() {\n \"nitpick\" | \"nit\" => \"nit\".to_string(),\n other => other.to_string(),\n }\n}\n```\n\n### HashMap merge for normalized categories, then sort by count DESC\n\n### ReviewsResult struct:\n```rust\npub struct ReviewsResult {\n pub username: String,\n pub total_diffnotes: u32,\n pub categorized_count: u32,\n pub mrs_reviewed: u32,\n pub categories: Vec,\n}\npub struct ReviewCategory { pub name: String, pub count: u32, pub percentage: f64 }\n```\n\nNo LIMIT needed — categories are naturally bounded (few distinct prefixes).\n\n## Files\n\n- `src/cli/commands/who.rs`\n\n## TDD Loop\n\nRED:\n```\ntest_reviews_query — insert 3 DiffNotes (2 with **prefix**, 1 without); verify total=3, categorized=2, categories.len()=2\ntest_normalize_review_prefix — \"suggestion\" \"Suggestion:\" \"suggestion (non-blocking):\" \"Nitpick:\" \"nit (non-blocking):\" \"question\" \"TODO:\"\n```\n\nGREEN: Implement query_reviews + normalize_review_prefix\nVERIFY: `cargo test -- reviews`\n\n## Acceptance Criteria\n\n- [ ] test_reviews_query passes (total=3, categorized=2)\n- [ ] test_normalize_review_prefix passes (nit/nitpick merge, parenthetical strip)\n- [ ] Only counts DiffNotes on MRs user did NOT author\n- [ ] Default since window: 6m\n\n## Edge Cases\n\n- Self-authored MRs excluded (m.author_username != ?1) — user's notes on own MRs are not \"reviews\"\n- ltrim() handles leading whitespace before **prefix**\n- Empty raw_prefix after normalization filtered out (!normalized.is_empty())\n- Percentage calculated from categorized_count (not total_diffnotes)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-08T02:40:53.350210Z","created_by":"tayloreernisse","updated_at":"2026-02-08T04:10:29.599252Z","closed_at":"2026-02-08T04:10:29.599217Z","close_reason":"Implemented by agent team: migration 017, CLI skeleton, all 5 query modes, human+robot output, 20 tests. All quality gates pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2711","depends_on_id":"bd-2ldg","type":"blocks","created_at":"2026-02-08T02:43:37.763557Z","created_by":"tayloreernisse"},{"issue_id":"bd-2711","depends_on_id":"bd-34rr","type":"blocks","created_at":"2026-02-08T02:43:37.911881Z","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-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-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} {"id":"bd-2cu","title":"[CP1] Discussion ingestion module","description":"Fetch and store discussions/notes for each issue.\n\n## Module\nsrc/ingestion/discussions.rs\n\n## Key Structs\n\n### IngestDiscussionsResult\n- discussions_fetched: usize\n- discussions_upserted: usize\n- notes_upserted: usize\n- system_notes_count: usize\n\n## Main Function\npub async fn ingest_issue_discussions(\n conn, client, config,\n project_id, gitlab_project_id,\n issue_iid, local_issue_id, issue_updated_at\n) -> Result\n\n## Logic\n1. Paginate through all discussions for given issue\n2. For each discussion:\n - Begin transaction\n - Store raw payload (compressed)\n - Transform and upsert discussion record with correct issue FK\n - Get local discussion ID\n - Transform notes from discussion\n - For each note:\n - Store raw payload\n - Upsert note with discussion_id FK\n - Count system notes\n - Commit transaction\n3. After all discussions synced: mark_discussions_synced(conn, local_issue_id, issue_updated_at)\n - UPDATE issues SET discussions_synced_for_updated_at = ? WHERE id = ?\n\n## Invariant\nA rerun MUST NOT refetch discussions for issues whose updated_at has not advanced, even with cursor rewind. The discussions_synced_for_updated_at watermark ensures this.\n\n## Helper Functions\n- upsert_discussion(conn, discussion, payload_id)\n- get_local_discussion_id(conn, project_id, gitlab_id) -> i64\n- upsert_note(conn, discussion_id, note, payload_id)\n- mark_discussions_synced(conn, issue_id, issue_updated_at)\n\nFiles: src/ingestion/discussions.rs\nTests: tests/discussion_watermark_tests.rs\nDone when: Discussions and notes populated with correct FKs, watermark prevents redundant refetch","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T16:57:36.703237Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.827880Z","deleted_at":"2026-01-25T17:02:01.827876Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-2dk","title":"Implement project resolution for --project filter","description":"## Background\nThe --project filter on search (and other commands) accepts a string that must be resolved to a project_id. Users may type the full path, a partial path, or just the project name. The resolution logic provides cascading match with helpful error messages when ambiguous. This improves ergonomics for multi-project installations.\n\n## Approach\nImplement project resolution function (location TBD — likely `src/core/project.rs` or inline in search filters):\n\n```rust\n/// Resolve a project string to a project_id using cascading match:\n/// 1. Exact match on path_with_namespace\n/// 2. Case-insensitive exact match\n/// 3. Suffix match (only if unambiguous)\n/// 4. Error with available projects list\npub fn resolve_project(conn: &Connection, project_str: &str) -> Result\n```\n\n**SQL queries:**\n```sql\n-- Step 1: exact match\nSELECT id FROM projects WHERE path_with_namespace = ?\n\n-- Step 2: case-insensitive\nSELECT id FROM projects WHERE LOWER(path_with_namespace) = LOWER(?)\n\n-- Step 3: suffix match\nSELECT id, path_with_namespace FROM projects\nWHERE path_with_namespace LIKE '%/' || ?\n OR path_with_namespace = ?\n\n-- Step 4: list all for error message\nSELECT path_with_namespace FROM projects ORDER BY path_with_namespace\n```\n\n**Error format:**\n```\nError: Project 'auth-service' not found.\n\nAvailable projects:\n backend/auth-service\n frontend/auth-service-ui\n infra/auth-proxy\n\nHint: Use the full path, e.g., --project=backend/auth-service\n```\n\nUses `LoreError::Ambiguous` variant for multiple suffix matches.\n\n## Acceptance Criteria\n- [ ] Exact match: \"group/project\" resolves correctly\n- [ ] Case-insensitive: \"Group/Project\" resolves to \"group/project\"\n- [ ] Suffix match: \"project-name\" resolves when only one \"*/project-name\" exists\n- [ ] Ambiguous suffix: error lists matching projects with hint\n- [ ] No match: error lists all available projects with hint\n- [ ] Empty projects table: clear error message\n- [ ] `cargo test project_resolution` passes\n\n## Files\n- `src/core/project.rs` — new file (or add to existing module)\n- `src/core/mod.rs` — add `pub mod project;`\n\n## TDD Loop\nRED: Tests:\n- `test_exact_match` — full path resolves\n- `test_case_insensitive` — mixed case resolves\n- `test_suffix_unambiguous` — short name resolves when unique\n- `test_suffix_ambiguous` — error with list when multiple match\n- `test_no_match` — error with available projects\nGREEN: Implement resolve_project\nVERIFY: `cargo test project_resolution`\n\n## Edge Cases\n- Project path containing special LIKE characters (%, _): unlikely but escape for safety\n- Single project in DB: suffix always unambiguous\n- Project path with multiple slashes: \"org/group/project\" — suffix match on \"project\" works","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-30T15:26:13.076571Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:39:17.197735Z","closed_at":"2026-01-30T17:39:17.197552Z","close_reason":"Implemented resolve_project() with cascading match (exact, CI, suffix) + 6 tests","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2dk","depends_on_id":"bd-3q2","type":"blocks","created_at":"2026-01-30T15:29:24.446650Z","created_by":"tayloreernisse"}]} +{"id":"bd-2dlt","title":"Implement GraphQL client with partial-error handling","description":"## Background\nGitLab's GraphQL endpoint (/api/graphql) uses different auth than REST (Bearer token, not PRIVATE-TOKEN). We need a minimal GraphQL client that handles the GitLab-specific error codes and partial-data responses per GraphQL spec. The client returns a GraphqlQueryResult struct that propagates partial-error metadata end-to-end.\n\n## Approach\nCreate a new file src/gitlab/graphql.rs with GraphqlClient (uses reqwest). Add httpdate crate for Retry-After HTTP-date parsing. Wire into the module tree. Factory on GitLabClient keeps token encapsulated.\n\n## Files\n- src/gitlab/graphql.rs (NEW) — GraphqlClient struct, GraphqlQueryResult, ansi256_from_rgb\n- src/gitlab/mod.rs (add pub mod graphql;)\n- src/gitlab/client.rs (add graphql_client() factory method)\n- Cargo.toml (add httpdate dependency)\n\n## Implementation\n\nGraphqlClient struct:\n Fields: http (reqwest::Client with 30s timeout), base_url (String), token (String)\n Constructor: new(base_url, token) — trims trailing slash from base_url\n \nquery() method:\n - POST to {base_url}/api/graphql\n - Headers: Authorization: Bearer {token}, Content-Type: application/json\n - Body: {\"query\": \"...\", \"variables\": {...}}\n - Returns Result\n\nGraphqlQueryResult struct (pub):\n data: serde_json::Value\n had_partial_errors: bool\n first_partial_error: Option\n\nHTTP status mapping:\n 401 | 403 -> LoreError::GitLabAuthFailed\n 404 -> LoreError::GitLabNotFound { resource: \"GraphQL endpoint\" }\n 429 -> LoreError::GitLabRateLimited { retry_after } (parse Retry-After: try u64 first, then httpdate::parse_http_date, fallback 60)\n Other non-success -> LoreError::Other\n\nGraphQL-level error handling:\n errors array present + data absent/null -> Err(LoreError::Other(\"GraphQL error: {first_msg}\"))\n errors array present + data present -> Ok(GraphqlQueryResult { data, had_partial_errors: true, first_partial_error: Some(first_msg) })\n No errors + data present -> Ok(GraphqlQueryResult { data, had_partial_errors: false, first_partial_error: None })\n No errors + no data -> Err(LoreError::Other(\"missing 'data' field\"))\n\nansi256_from_rgb(r, g, b) -> u8:\n Maps RGB to nearest ANSI 256-color index using 6x6x6 cube (indices 16-231).\n MUST be placed BEFORE #[cfg(test)] module (clippy::items_after_test_module).\n\nFactory in src/gitlab/client.rs:\n pub fn graphql_client(&self) -> crate::gitlab::graphql::GraphqlClient {\n crate::gitlab::graphql::GraphqlClient::new(&self.base_url, &self.token)\n }\n\n## Acceptance Criteria\n- [ ] query() sends POST with Bearer auth header\n- [ ] Success: returns GraphqlQueryResult { data, had_partial_errors: false }\n- [ ] Errors-only (no data): returns Err with first error message\n- [ ] Partial data + errors: returns Ok with had_partial_errors: true\n- [ ] 401 -> GitLabAuthFailed\n- [ ] 403 -> GitLabAuthFailed\n- [ ] 404 -> GitLabNotFound\n- [ ] 429 -> GitLabRateLimited (parses Retry-After delta-seconds and HTTP-date, fallback 60)\n- [ ] ansi256_from_rgb: (0,0,0)->16, (255,255,255)->231\n- [ ] cargo check --all-targets passes\n\n## TDD Loop\nRED: test_graphql_query_success, test_graphql_query_with_errors_no_data, test_graphql_auth_uses_bearer, test_graphql_401_maps_to_auth_failed, test_graphql_403_maps_to_auth_failed, test_graphql_404_maps_to_not_found, test_graphql_partial_data_with_errors_returns_data, test_retry_after_http_date_format, test_retry_after_invalid_falls_back_to_60, test_ansi256_from_rgb\n Tests use wiremock or similar mock HTTP server\nGREEN: Implement GraphqlClient, add httpdate to Cargo.toml\nVERIFY: cargo test graphql && cargo test ansi256\n\n## Edge Cases\n- Use r##\"...\"## in tests containing \"#1f75cb\" hex colors (# breaks r#\"...\"#)\n- LoreError::GitLabRateLimited uses u64 not Option — use .unwrap_or(60)\n- httpdate::parse_http_date returns SystemTime — compute duration_since(now) for delta\n- GraphqlQueryResult is NOT Clone — tests must check fields individually","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-11T06:41:52.833151Z","created_by":"tayloreernisse","updated_at":"2026-02-11T07:21:33.417835Z","closed_at":"2026-02-11T07:21:33.417793Z","close_reason":"Implemented by agent swarm — all quality gates pass (595 tests, 0 failures)","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2dlt","depends_on_id":"bd-1v8t","type":"blocks","created_at":"2026-02-11T06:42:40.451408Z","created_by":"tayloreernisse"},{"issue_id":"bd-2dlt","depends_on_id":"bd-2y79","type":"parent-child","created_at":"2026-02-11T06:41:52.840577Z","created_by":"tayloreernisse"}]} {"id":"bd-2e8","title":"Add fetchResourceEvents config flag to SyncConfig","description":"## Background\nEvent fetching should be opt-in (default true) so users who don't need temporal queries skip 3 extra API calls per entity. This follows the existing SyncConfig pattern with serde defaults and camelCase JSON aliases.\n\n## Approach\nAdd to SyncConfig in src/core/config.rs:\n```rust\n#[serde(rename = \"fetchResourceEvents\", default = \"default_true\")]\npub fetch_resource_events: bool,\n```\n\nAdd default function (if not already present):\n```rust\nfn default_true() -> bool { true }\n```\n\nUpdate Default impl for SyncConfig to include `fetch_resource_events: true`.\n\nAdd --no-events flag to sync command in src/cli/mod.rs (SyncArgs):\n```rust\n/// Skip resource event fetching (overrides config)\n#[arg(long = \"no-events\", help_heading = \"Sync Options\")]\npub no_events: bool,\n```\n\nIn the sync command handler (src/cli/commands/sync.rs), override config when flag is set:\n```rust\nif args.no_events {\n config.sync.fetch_resource_events = false;\n}\n```\n\n## Acceptance Criteria\n- [ ] SyncConfig deserializes `fetchResourceEvents: false` from JSON config\n- [ ] SyncConfig defaults to `fetch_resource_events: true` when field absent\n- [ ] `--no-events` flag parses correctly in CLI\n- [ ] `--no-events` overrides config to false\n- [ ] `cargo test` passes with no regressions\n\n## Files\n- src/core/config.rs (add field to SyncConfig + default fn + Default impl)\n- src/cli/mod.rs (add --no-events to SyncArgs)\n- src/cli/commands/sync.rs (override config when flag set)\n\n## TDD Loop\nRED: tests/config_tests.rs (or inline in config.rs):\n- `test_sync_config_fetch_resource_events_default_true` - omit field from JSON, verify default\n- `test_sync_config_fetch_resource_events_explicit_false` - set field false, verify parsed\n- `test_sync_config_no_events_flag` - verify CLI arg parsing\n\nGREEN: Add the field, default fn, Default impl update, CLI flag, and override logic\n\nVERIFY: `cargo test config -- --nocapture && cargo build`\n\n## Edge Cases\n- Ensure serde rename matches camelCase convention used by all other SyncConfig fields\n- The default_true fn may already exist for other fields — check before adding duplicate\n- The --no-events flag must NOT be confused with --no-X negation flags already in CLI (check mod.rs for conflicts)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:31:24.006037Z","created_by":"tayloreernisse","updated_at":"2026-02-03T16:10:20.311986Z","closed_at":"2026-02-03T16:10:20.311939Z","close_reason":"Completed: Added fetch_resource_events bool to SyncConfig with serde rename, default_true, --no-events CLI flag, and config override in sync handler","compaction_level":0,"original_size":0,"labels":["config","gate-1","phase-b"],"dependencies":[{"issue_id":"bd-2e8","depends_on_id":"bd-2zl","type":"parent-child","created_at":"2026-02-02T21:31:24.010608Z","created_by":"tayloreernisse"}]} {"id":"bd-2ez","title":"Add 'lore count references' command","description":"## Background\n\nThe count command currently supports issues, mrs, discussions, notes, and events. This adds 'references' as a new entity type, showing cross-reference totals and breakdowns by reference_type and source_method.\n\n## Codebase Context\n\n- entity_references table (migration 011) with:\n - reference_type CHECK: `'closes' | 'mentioned' | 'related'`\n - source_method CHECK: `'api' | 'note_parse' | 'description_parse'` (**codebase values, NOT spec values**)\n - target_entity_id: NULL for unresolved cross-project refs\n- Count command pattern in src/cli/commands/count.rs: run_count() returns CountResult, handle_count formats output\n- events count already implemented as a special case: run_count_events() in main.rs (line ~829)\n- count.rs has value_parser list for entity arg\n\n## Approach\n\n### 1. Add to CountArgs value_parser in `src/cli/mod.rs`:\n```rust\n#[arg(value_parser = [\"issues\", \"mrs\", \"discussions\", \"notes\", \"events\", \"references\"])]\npub entity: String,\n```\n\n### 2. Add types and query in `src/cli/commands/count.rs`:\n\n```rust\npub struct ReferenceCountResult {\n pub total: i64,\n pub by_type: HashMap, // closes, mentioned, related\n pub by_method: HashMap, // api, note_parse, description_parse\n pub unresolved: i64,\n}\n```\n\n### 3. SQL:\n```sql\nSELECT\n COUNT(*) as total,\n COALESCE(SUM(CASE WHEN reference_type = 'closes' THEN 1 ELSE 0 END), 0) as closes,\n COALESCE(SUM(CASE WHEN reference_type = 'mentioned' THEN 1 ELSE 0 END), 0) as mentioned,\n COALESCE(SUM(CASE WHEN reference_type = 'related' THEN 1 ELSE 0 END), 0) as related,\n COALESCE(SUM(CASE WHEN source_method = 'api' THEN 1 ELSE 0 END), 0) as api,\n COALESCE(SUM(CASE WHEN source_method = 'note_parse' THEN 1 ELSE 0 END), 0) as note_parse,\n COALESCE(SUM(CASE WHEN source_method = 'description_parse' THEN 1 ELSE 0 END), 0) as desc_parse,\n COALESCE(SUM(CASE WHEN target_entity_id IS NULL THEN 1 ELSE 0 END), 0) as unresolved\nFROM entity_references\n```\n\n### 4. Human output:\n```\nReferences: 1,234\n By type:\n closes: 456\n mentioned: 678\n related: 100\n By source:\n api: 234\n note_parse: 890\n description_parse: 110\n Unresolved: 45 (3.6%)\n```\n\n### 5. Robot JSON:\n```json\n{\n \"ok\": true,\n \"data\": {\n \"entity\": \"references\",\n \"total\": 1234,\n \"by_type\": { \"closes\": 456, \"mentioned\": 678, \"related\": 100 },\n \"by_method\": { \"api\": 234, \"note_parse\": 890, \"description_parse\": 110 },\n \"unresolved\": 45\n }\n}\n```\n\n### 6. Wire in main.rs handle_count:\nAdd \"references\" branch, similar to the existing \"events\" special case.\n\n## Acceptance Criteria\n\n- [ ] `lore count references` works with human output\n- [ ] `lore --robot count references` returns JSON\n- [ ] by_type uses codebase values: closes, mentioned, related\n- [ ] by_method uses codebase values: api, note_parse, description_parse (NOT spec values)\n- [ ] Unresolved = WHERE target_entity_id IS NULL\n- [ ] Zero references: all counts 0, not error\n- [ ] entity_references table missing (old schema): graceful error with migration suggestion\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/cli/mod.rs` (add \"references\" to value_parser)\n- `src/cli/commands/count.rs` (add count_references + ReferenceCountResult)\n- `src/main.rs` (add \"references\" branch in handle_count)\n\n## TDD Loop\n\nRED: `test_count_references_query` with in-memory DB + migration 011 data\n\nGREEN: Implement query, result type, output.\n\nVERIFY: `cargo test --lib -- count && cargo check --all-targets`\n\n## Edge Cases\n\n- entity_references table doesn't exist (pre-migration-011): catch SQL error, suggest `lore migrate`\n- All references unresolved: unresolved = total\n- New source_method values in future: consider logging unknown values","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-02T22:42:43.780303Z","created_by":"tayloreernisse","updated_at":"2026-02-05T19:42:55.459109Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2ez","depends_on_id":"bd-1se","type":"parent-child","created_at":"2026-02-02T22:43:40.652558Z","created_by":"tayloreernisse"},{"issue_id":"bd-2ez","depends_on_id":"bd-hu3","type":"blocks","created_at":"2026-02-02T22:43:33.877742Z","created_by":"tayloreernisse"}]} {"id":"bd-2f0","title":"[CP1] gi count issues/discussions/notes commands","description":"## Background\n\nThe `gi count` command provides quick counts of entities in the local database. It supports counting issues, MRs, discussions, and notes, with optional filtering by noteable type. This enables quick validation that sync is working correctly.\n\n## Approach\n\n### Module: src/cli/commands/count.rs\n\n### Clap Definition\n\n```rust\n#[derive(Args)]\npub struct CountArgs {\n /// Entity type to count\n #[arg(value_parser = [\"issues\", \"mrs\", \"discussions\", \"notes\"])]\n pub entity: String,\n\n /// Filter by noteable type (for discussions/notes)\n #[arg(long, value_parser = [\"issue\", \"mr\"])]\n pub r#type: Option,\n}\n```\n\n### Handler Function\n\n```rust\npub async fn handle_count(args: CountArgs, conn: &Connection) -> Result<()>\n```\n\n### Queries by Entity\n\n**issues:**\n```sql\nSELECT COUNT(*) FROM issues\n```\nOutput: `Issues: 3,801`\n\n**discussions:**\n```sql\n-- Without type filter\nSELECT COUNT(*) FROM discussions\n\n-- With --type=issue\nSELECT COUNT(*) FROM discussions WHERE noteable_type = 'Issue'\n```\nOutput: `Issue Discussions: 1,234`\n\n**notes:**\n```sql\n-- Total and system count\nSELECT COUNT(*), SUM(is_system) FROM notes\n\n-- With --type=issue (join through discussions)\nSELECT COUNT(*), SUM(n.is_system)\nFROM notes n\nJOIN discussions d ON n.discussion_id = d.id\nWHERE d.noteable_type = 'Issue'\n```\nOutput: `Issue Notes: 5,678 (excluding 1,234 system)`\n\n### Output Format\n\n```\nIssues: 3,801\n```\n\n```\nIssue Discussions: 1,234\n```\n\n```\nIssue Notes: 5,678 (excluding 1,234 system)\n```\n\n## Acceptance Criteria\n\n- [ ] `gi count issues` shows total issue count\n- [ ] `gi count discussions` shows total discussion count\n- [ ] `gi count discussions --type=issue` filters to issue discussions\n- [ ] `gi count notes` shows total note count with system note exclusion\n- [ ] `gi count notes --type=issue` filters to issue notes\n- [ ] Numbers formatted with thousands separators (1,234)\n\n## Files\n\n- src/cli/commands/mod.rs (add `pub mod count;`)\n- src/cli/commands/count.rs (create)\n- src/cli/mod.rs (add Count variant to Commands enum)\n\n## TDD Loop\n\nRED:\n```rust\n#[tokio::test] async fn count_issues_returns_total()\n#[tokio::test] async fn count_discussions_with_type_filter()\n#[tokio::test] async fn count_notes_excludes_system_notes()\n```\n\nGREEN: Implement handler with queries\n\nVERIFY: `cargo test count`\n\n## Edge Cases\n\n- Zero entities - show \"Issues: 0\"\n- --type flag invalid for issues/mrs - ignore or error\n- All notes are system notes - show \"Notes: 0 (excluding 1,234 system)\"","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-25T17:02:38.360495Z","created_by":"tayloreernisse","updated_at":"2026-01-25T23:01:37.084627Z","closed_at":"2026-01-25T23:01:37.084568Z","close_reason":"Implemented gi count command with issues/discussions/notes support, format_number helper, and system note exclusion","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2f0","depends_on_id":"bd-208","type":"blocks","created_at":"2026-01-25T17:04:05.677181Z","created_by":"tayloreernisse"}]} @@ -81,6 +96,7 @@ {"id":"bd-2h0","title":"[CP1] gi list issues command","description":"List issues from the database.\n\n## Module\nsrc/cli/commands/list.rs\n\n## Clap Definition\nList {\n #[arg(value_parser = [\"issues\", \"mrs\"])]\n entity: String,\n \n #[arg(long, default_value = \"20\")]\n limit: usize,\n \n #[arg(long)]\n project: Option,\n \n #[arg(long, value_parser = [\"opened\", \"closed\", \"all\"])]\n state: Option,\n}\n\n## Output Format\nIssues (showing 20 of 3,801)\n\n #1234 Authentication redesign opened @johndoe 3 days ago\n #1233 Fix memory leak in cache closed @janedoe 5 days ago\n #1232 Add dark mode support opened @bobsmith 1 week ago\n ...\n\n## Implementation\n- Query issues table with filters\n- Join with projects table for display\n- Format updated_at as relative time (\"3 days ago\")\n- Truncate title if too long\n\nFiles: src/cli/commands/list.rs\nDone when: List displays issues with proper filtering and formatting","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T16:58:23.809829Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.898106Z","deleted_at":"2026-01-25T17:02:01.898102Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-2i10","title":"OBSERV: Add log file diagnostics to lore doctor","description":"## Background\nlore doctor is the diagnostic entry point. Adding log file info lets users verify logging is working and check disk usage. The existing DoctorChecks struct (src/cli/commands/doctor.rs:43-51) has checks for config, database, gitlab, projects, ollama.\n\n## Approach\nAdd a new LoggingCheck struct and field to DoctorChecks:\n\n```rust\n#[derive(Debug, Serialize)]\npub struct LoggingCheck {\n pub result: CheckResult,\n pub log_dir: String,\n pub file_count: usize,\n pub total_bytes: u64,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub oldest_file: Option,\n}\n```\n\nAdd to DoctorChecks (src/cli/commands/doctor.rs:43-51):\n```rust\npub logging: LoggingCheck,\n```\n\nImplement check_logging() function:\n```rust\nfn check_logging() -> LoggingCheck {\n let log_dir = get_log_dir(None); // TODO: accept config override\n let mut file_count = 0;\n let mut total_bytes = 0u64;\n let mut oldest: Option = None;\n\n if let Ok(entries) = std::fs::read_dir(&log_dir) {\n for entry in entries.flatten() {\n let name = entry.file_name().to_string_lossy().to_string();\n if name.starts_with(\"lore.\") && name.ends_with(\".log\") {\n file_count += 1;\n if let Ok(meta) = entry.metadata() {\n total_bytes += meta.len();\n }\n if oldest.as_ref().map_or(true, |o| name < *o) {\n oldest = Some(name);\n }\n }\n }\n }\n\n LoggingCheck {\n result: CheckResult { status: CheckStatus::Ok, message: None },\n log_dir: log_dir.display().to_string(),\n file_count,\n total_bytes,\n oldest_file: oldest,\n }\n}\n```\n\nCall from run_doctor() (src/cli/commands/doctor.rs:91-126) and add to DoctorChecks construction.\n\nFor interactive output in print_doctor_results(), add a section:\n```\nLogging\n Log directory: ~/.local/share/lore/logs/\n Log files: 7 (2.3 MB)\n Oldest: lore.2026-01-28.log\n```\n\n## Acceptance Criteria\n- [ ] lore doctor shows log directory path, file count, total size\n- [ ] lore --robot doctor JSON includes logging field with log_dir, file_count, total_bytes, oldest_file\n- [ ] When no log files exist: file_count=0, total_bytes=0, oldest_file=null\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/cli/commands/doctor.rs (add LoggingCheck struct, check_logging fn, wire into DoctorChecks)\n\n## TDD Loop\nRED: test_check_logging_with_files, test_check_logging_empty_dir\nGREEN: Implement LoggingCheck struct and check_logging function\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- Log directory doesn't exist yet (first run before any sync): report file_count=0, status Ok\n- Permission errors on read_dir: report status Warning with message","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-04T15:53:55.682986Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:15:04.520915Z","closed_at":"2026-02-04T17:15:04.520868Z","close_reason":"Added LoggingCheck to DoctorChecks with log_dir, file_count, total_bytes; shows in both interactive and robot output","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-2i10","depends_on_id":"bd-1k4","type":"blocks","created_at":"2026-02-04T15:55:19.686771Z","created_by":"tayloreernisse"},{"issue_id":"bd-2i10","depends_on_id":"bd-2nx","type":"parent-child","created_at":"2026-02-04T15:53:55.683866Z","created_by":"tayloreernisse"}]} {"id":"bd-2iq","title":"[CP1] Database migration 002_issues.sql","description":"## Background\n\nThe 002_issues.sql migration creates tables for issues, labels, issue_labels, discussions, and notes. This is the data foundation for Checkpoint 1, enabling issue ingestion with cursor-based sync, label tracking, and discussion storage.\n\n## Approach\n\nCreate `migrations/002_issues.sql` with complete SQL statements.\n\n### Full Migration SQL\n\n```sql\n-- Migration 002: Issue Ingestion Tables\n-- Applies on top of 001_initial.sql\n\n-- Issues table\nCREATE TABLE issues (\n id INTEGER PRIMARY KEY,\n gitlab_id INTEGER UNIQUE NOT NULL,\n project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,\n iid INTEGER NOT NULL,\n title TEXT,\n description TEXT,\n state TEXT NOT NULL CHECK (state IN ('opened', 'closed')),\n author_username TEXT,\n created_at INTEGER NOT NULL, -- ms epoch UTC\n updated_at INTEGER NOT NULL, -- ms epoch UTC\n last_seen_at INTEGER NOT NULL, -- updated on every upsert\n discussions_synced_for_updated_at INTEGER, -- watermark for dependent sync\n web_url TEXT,\n raw_payload_id INTEGER REFERENCES raw_payloads(id)\n);\n\nCREATE INDEX idx_issues_project_updated ON issues(project_id, updated_at);\nCREATE INDEX idx_issues_author ON issues(author_username);\nCREATE UNIQUE INDEX uq_issues_project_iid ON issues(project_id, iid);\n\n-- Labels table (name-only for CP1)\nCREATE TABLE labels (\n id INTEGER PRIMARY KEY,\n gitlab_id INTEGER, -- optional, for future Labels API\n project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,\n name TEXT NOT NULL,\n color TEXT,\n description TEXT\n);\n\nCREATE UNIQUE INDEX uq_labels_project_name ON labels(project_id, name);\nCREATE INDEX idx_labels_name ON labels(name);\n\n-- Issue-label junction (DELETE before INSERT for stale removal)\nCREATE TABLE issue_labels (\n issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,\n label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,\n PRIMARY KEY(issue_id, label_id)\n);\n\nCREATE INDEX idx_issue_labels_label ON issue_labels(label_id);\n\n-- Discussion threads for issues (MR discussions added in CP2)\nCREATE TABLE discussions (\n id INTEGER PRIMARY KEY,\n gitlab_discussion_id TEXT NOT NULL, -- GitLab string ID (e.g., \"6a9c1750b37d...\")\n project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,\n issue_id INTEGER REFERENCES issues(id) ON DELETE CASCADE,\n merge_request_id INTEGER, -- FK added in CP2 via ALTER TABLE\n noteable_type TEXT NOT NULL CHECK (noteable_type IN ('Issue', 'MergeRequest')),\n individual_note INTEGER NOT NULL DEFAULT 0, -- 0=threaded, 1=standalone\n first_note_at INTEGER, -- min(note.created_at) for ordering\n last_note_at INTEGER, -- max(note.created_at) for \"recently active\"\n last_seen_at INTEGER NOT NULL, -- updated on every upsert\n resolvable INTEGER NOT NULL DEFAULT 0, -- MR discussions can be resolved\n resolved INTEGER NOT NULL DEFAULT 0,\n CHECK (\n (noteable_type = 'Issue' AND issue_id IS NOT NULL AND merge_request_id IS NULL) OR\n (noteable_type = 'MergeRequest' AND merge_request_id IS NOT NULL AND issue_id IS NULL)\n )\n);\n\nCREATE UNIQUE INDEX uq_discussions_project_discussion_id ON discussions(project_id, gitlab_discussion_id);\nCREATE INDEX idx_discussions_issue ON discussions(issue_id);\nCREATE INDEX idx_discussions_mr ON discussions(merge_request_id);\nCREATE INDEX idx_discussions_last_note ON discussions(last_note_at);\n\n-- Notes belong to discussions\nCREATE TABLE notes (\n id INTEGER PRIMARY KEY,\n gitlab_id INTEGER UNIQUE NOT NULL,\n discussion_id INTEGER NOT NULL REFERENCES discussions(id) ON DELETE CASCADE,\n project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,\n note_type TEXT, -- 'DiscussionNote' | 'DiffNote' | null\n is_system INTEGER NOT NULL DEFAULT 0, -- 1 for system-generated notes\n author_username TEXT,\n body TEXT,\n created_at INTEGER NOT NULL, -- ms epoch\n updated_at INTEGER NOT NULL, -- ms epoch\n last_seen_at INTEGER NOT NULL, -- updated on every upsert\n position INTEGER, -- 0-indexed array order from API\n resolvable INTEGER NOT NULL DEFAULT 0,\n resolved INTEGER NOT NULL DEFAULT 0,\n resolved_by TEXT,\n resolved_at INTEGER,\n -- DiffNote position metadata (populated for MR DiffNotes in CP2)\n position_old_path TEXT,\n position_new_path TEXT,\n position_old_line INTEGER,\n position_new_line INTEGER,\n raw_payload_id INTEGER REFERENCES raw_payloads(id)\n);\n\nCREATE INDEX idx_notes_discussion ON notes(discussion_id);\nCREATE INDEX idx_notes_author ON notes(author_username);\nCREATE INDEX idx_notes_system ON notes(is_system);\n\n-- Update schema version\nINSERT INTO schema_version (version, applied_at, description)\nVALUES (2, strftime('%s', 'now') * 1000, 'Issue ingestion tables');\n```\n\n## Acceptance Criteria\n\n- [ ] Migration file exists at `migrations/002_issues.sql`\n- [ ] All tables created: issues, labels, issue_labels, discussions, notes\n- [ ] All indexes created as specified\n- [ ] CHECK constraints on state and noteable_type work correctly\n- [ ] CASCADE deletes work (project deletion cascades)\n- [ ] Migration applies cleanly on fresh DB after 001_initial.sql\n- [ ] schema_version updated to 2 after migration\n- [ ] `gi doctor` shows schema_version = 2\n\n## Files\n\n- migrations/002_issues.sql (create)\n\n## TDD Loop\n\nRED:\n```rust\n// tests/migration_tests.rs\n#[test] fn migration_002_creates_issues_table()\n#[test] fn migration_002_creates_labels_table()\n#[test] fn migration_002_creates_discussions_table()\n#[test] fn migration_002_creates_notes_table()\n#[test] fn migration_002_enforces_state_check()\n#[test] fn migration_002_enforces_noteable_type_check()\n#[test] fn migration_002_cascades_on_project_delete()\n```\n\nGREEN: Create migration file with all SQL\n\nVERIFY:\n```bash\n# Apply migration to test DB\nsqlite3 :memory: < migrations/001_initial.sql\nsqlite3 :memory: < migrations/002_issues.sql\n\n# Verify schema_version\nsqlite3 test.db \"SELECT version FROM schema_version ORDER BY version DESC LIMIT 1\"\n# Expected: 2\n\ncargo test migration_002\n```\n\n## Edge Cases\n\n- Applying twice - should fail on UNIQUE constraint (idempotency via version check)\n- Missing 001 - foreign key to projects fails\n- Long label names - TEXT handles any length\n- NULL description - allowed by schema\n- Empty discussions_synced_for_updated_at - NULL means never synced","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-25T17:02:38.128594Z","created_by":"tayloreernisse","updated_at":"2026-01-25T22:25:10.309900Z","closed_at":"2026-01-25T22:25:10.309852Z","close_reason":"Created 002_issues.sql with issues/labels/issue_labels/discussions/notes tables, 8 passing tests verify schema, constraints, and cascades","compaction_level":0,"original_size":0} +{"id":"bd-2jzn","title":"Migration 021: Add status columns to issues table","description":"## Background\nGitLab issues have work item status (To do, In progress, Done, Won't do, Duplicate) only available via GraphQL. We need 5 nullable columns on the issues table to store this data after enrichment. The status_synced_at column tracks when enrichment last wrote/cleared each row (ms epoch UTC).\n\n## Approach\nCreate a new SQL migration file and register it in the MIGRATIONS array. SQLite ALTER TABLE ADD COLUMN is non-destructive — existing rows get NULL defaults. Add a compound index for --status filter performance.\n\n## Files\n- migrations/021_work_item_status.sql (NEW)\n- src/core/db.rs (add entry to MIGRATIONS array)\n\n## Implementation\n\nmigrations/021_work_item_status.sql:\n ALTER TABLE issues ADD COLUMN status_name TEXT;\n ALTER TABLE issues ADD COLUMN status_category TEXT;\n ALTER TABLE issues ADD COLUMN status_color TEXT;\n ALTER TABLE issues ADD COLUMN status_icon_name TEXT;\n ALTER TABLE issues ADD COLUMN status_synced_at INTEGER;\n CREATE INDEX IF NOT EXISTS idx_issues_project_status_name ON issues(project_id, status_name);\n\nIn src/core/db.rs, add as last entry in MIGRATIONS array:\n (\"021\", include_str!(\"../../migrations/021_work_item_status.sql\")),\nLATEST_SCHEMA_VERSION is computed as MIGRATIONS.len() as i32 — auto-becomes 21.\n\n## Acceptance Criteria\n- [ ] Migration file exists at migrations/021_work_item_status.sql\n- [ ] MIGRATIONS array has 21 entries ending with (\"021\", ...)\n- [ ] In-memory DB: PRAGMA table_info(issues) includes all 5 new columns\n- [ ] In-memory DB: PRAGMA index_list(issues) includes idx_issues_project_status_name\n- [ ] Existing rows have NULL for all 5 new columns\n- [ ] cargo check --all-targets passes\n\n## TDD Loop\nRED: test_migration_021_adds_columns, test_migration_021_adds_index\n Pattern: create_connection(Path::new(\":memory:\")) + run_migrations(&conn), then PRAGMA queries\nGREEN: Create SQL file + register in MIGRATIONS\nVERIFY: cargo test test_migration_021\n\n## Edge Cases\n- Migration has 5 columns (including status_synced_at INTEGER), not 4\n- Test project insert uses gitlab_project_id, path_with_namespace, web_url (no name/last_seen_at)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-11T06:41:40.806320Z","created_by":"tayloreernisse","updated_at":"2026-02-11T07:21:33.414434Z","closed_at":"2026-02-11T07:21:33.414387Z","close_reason":"Implemented by agent swarm — all quality gates pass (595 tests, 0 failures)","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2jzn","depends_on_id":"bd-2y79","type":"parent-child","created_at":"2026-02-11T06:41:40.807222Z","created_by":"tayloreernisse"}]} {"id":"bd-2ldg","title":"WHO: Mode resolution, path helpers, run_who entry point","description":"## Background\n\nCore scaffolding that all 5 query modes depend on. Defines the mode discrimination logic, path normalization, path-to-SQL translation (with project-scoped DB probes), time resolution, and the run_who() entry point that dispatches to query functions.\n\n## Approach\n\n### WhoMode enum\n```rust\nenum WhoMode<'a> {\n Expert { path: String }, // owns String (normalization produces new strings)\n Workload { username: &'a str }, // borrows from args\n Reviews { username: &'a str },\n Active,\n Overlap { path: String },\n}\n```\n\n### resolve_mode() discrimination rules:\n1. --path flag always wins -> Expert\n2. --active -> Active\n3. --overlap -> Overlap\n4. positional target with --reviews -> Reviews\n5. positional target containing '/' -> Expert (username never contains /)\n6. positional target without '/' -> Workload (strip @ prefix)\n7. No args -> error with usage examples\n\n### normalize_repo_path(): strips ./, leading /, collapses //, converts \\ to / (Windows paste, only when no / present), trims whitespace\n\n### PathQuery + build_path_query(conn, path, project_id):\n- Struct: `{ value: String, is_prefix: bool }`\n- Trailing / forces directory prefix\n- Root path (no /) without trailing / -> exact match (handles Makefile, LICENSE via --path)\n- Last segment contains . -> heuristic: file (exact)\n- **Two-way DB probe** (project-scoped): when heuristics are ambiguous, probe DB:\n - Probe 1: exact path exists? `SELECT 1 FROM notes WHERE note_type='DiffNote' AND is_system=0 AND position_new_path = ?1 AND (?2 IS NULL OR project_id = ?2) LIMIT 1`\n - Probe 2 (only if exact miss, not forced-dir): prefix exists?\n - Decision: forced_dir -> prefix; exact_exists -> exact; prefix_exists -> prefix; else heuristic\n- **CRITICAL**: escape_like() is ONLY called for prefix (LIKE) matches. For exact matches (=), use raw path — LIKE metacharacters (_, %) are not special in = comparisons.\n\n### Result types: WhoRun, WhoResolvedInput (since_mode tri-state: \"default\"/\"explicit\"/\"none\"), WhoResult enum, all 5 mode-specific result structs (see plan Step 2 \"Result Types\")\n\n### run_who() entry: resolve project -> resolve mode -> resolve since -> dispatch to query_* -> return WhoRun\n\n### since_mode semantics:\n- Expert/Reviews/Active/Overlap: default window applies if --since absent -> \"default\"\n- Workload: no default window; --since absent -> \"none\"\n- Any mode with explicit --since -> \"explicit\"\n\n## Files\n\n- `src/cli/commands/who.rs` — all code in this file\n\n## TDD Loop\n\nRED:\n```\ntest_is_file_path_discrimination — resolve_mode for paths/usernames/@/--reviews/--path\ntest_build_path_query — directory/file/root/dotted/underscore/dotless\ntest_build_path_query_exact_does_not_escape — _ in exact path stays raw\ntest_path_flag_dotless_root_file_is_exact — Makefile/Dockerfile via --path\ntest_build_path_query_dotless_subdir_file_uses_db_probe — src/Dockerfile with/without DB data\ntest_build_path_query_probe_is_project_scoped — data in proj 1, query proj 2\ntest_escape_like — normal/underscore/percent/backslash\ntest_normalize_repo_path — ./ / \\\\ // whitespace identity\ntest_lookup_project_path — basic round-trip\n```\n\nGREEN: Implement all functions. Query functions can be stubs (todo!()) for now.\nVERIFY: `cargo test -- who`\n\n## Acceptance Criteria\n\n- [ ] resolve_mode correctly discriminates all 7 cases (see tests)\n- [ ] build_path_query returns exact for files, prefix for dirs\n- [ ] build_path_query DB probe is project-scoped (cross-project isolation)\n- [ ] escape_like escapes %, _, \\ correctly\n- [ ] normalize_repo_path handles ./, /, \\\\, //, whitespace\n- [ ] WhoResolvedInput.since_mode is \"none\" for Workload without --since\n\n## Edge Cases\n\n- Dotless files in subdirectories (src/Dockerfile, infra/Makefile) — DB probe catches these, heuristic alone would misclassify as directory\n- Windows path paste (src\\foo\\bar.rs) — convert \\ to / only when no / present\n- LIKE metacharacters in filenames (README_with_underscore.md) — must NOT be escaped for exact match\n- Root files without / (README.md, LICENSE, Makefile) — must use --path flag, positional would treat as username","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-08T02:40:11.209288Z","created_by":"tayloreernisse","updated_at":"2026-02-08T04:10:29.595703Z","closed_at":"2026-02-08T04:10:29.595666Z","close_reason":"Implemented by agent team: migration 017, CLI skeleton, all 5 query modes, human+robot output, 20 tests. All quality gates pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2ldg","depends_on_id":"bd-2rk9","type":"blocks","created_at":"2026-02-08T02:43:36.665026Z","created_by":"tayloreernisse"}]} {"id":"bd-2ms","title":"[CP1] Unit tests for transformers","description":"Comprehensive unit tests for issue and discussion transformers.\n\n## Issue Transformer Tests (tests/issue_transformer_tests.rs)\n\n- transforms_gitlab_issue_to_normalized_schema\n- extracts_labels_from_issue_payload\n- handles_missing_optional_fields_gracefully\n- converts_iso_timestamps_to_ms_epoch\n- sets_last_seen_at_to_current_time\n\n## Discussion Transformer Tests (tests/discussion_transformer_tests.rs)\n\n- transforms_discussion_payload_to_normalized_schema\n- extracts_notes_array_from_discussion\n- sets_individual_note_flag_correctly\n- flags_system_notes_with_is_system_true\n- preserves_note_order_via_position_field\n- computes_first_note_at_and_last_note_at_correctly\n- computes_resolvable_and_resolved_status\n\n## Test Setup\n- Load from test fixtures\n- Use serde_json for deserialization\n- Compare against expected NormalizedX structs\n\nFiles: tests/issue_transformer_tests.rs, tests/discussion_transformer_tests.rs\nDone when: All transformer unit tests pass","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T16:59:04.165187Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:02.015847Z","deleted_at":"2026-01-25T17:02:02.015841Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-2mz","title":"Epic: Gate A - Lexical MVP","description":"## Background\nGate A delivers the lexical search MVP — the foundation that works without sqlite-vec or Ollama. It introduces the document layer (documents, document_labels, document_paths), FTS5 indexing, search filters, and the search + stats + generate-docs CLI commands. Gate A is independently shippable — users get working search with FTS5 only.\n\n## Gate A Deliverables\n1. Document generation from issues/MRs/discussions with FTS5 indexing\n2. Lexical search + filters + snippets + lore stats\n\n## Bead Dependencies (execution order)\n1. **bd-3lc** — Rename GiError to LoreError (no deps, enables all subsequent work)\n2. **bd-hrs** — Migration 007 (blocked by bd-3lc)\n3. **bd-221** — Migration 008 FTS5 (blocked by bd-hrs)\n4. **bd-36p** — Document types + extractor module (blocked by bd-3lc)\n5. **bd-18t** — Truncation logic (blocked by bd-36p)\n6. **bd-247** — Issue extraction (blocked by bd-36p, bd-hrs)\n7. **bd-1yz** — MR extraction (blocked by bd-36p, bd-hrs)\n8. **bd-2fp** — Discussion extraction (blocked by bd-36p, bd-hrs, bd-18t)\n9. **bd-1u1** — Document regenerator (blocked by bd-36p, bd-38q, bd-hrs)\n10. **bd-1k1** — FTS5 search (blocked by bd-221)\n11. **bd-3q2** — Search filters (blocked by bd-36p)\n12. **bd-3lu** — Search CLI (blocked by bd-1k1, bd-3q2, bd-36p)\n13. **bd-3qs** — Generate-docs CLI (blocked by bd-1u1, bd-3lu)\n14. **bd-pr1** — Stats CLI (blocked by bd-hrs)\n15. **bd-2dk** — Project resolution (blocked by bd-3lc)\n\n## Acceptance Criteria\n- [ ] `lore search \"query\"` returns FTS5 results with snippets\n- [ ] `lore search --type issue --label bug \"query\"` filters correctly\n- [ ] `lore generate-docs` creates documents from all entities\n- [ ] `lore generate-docs --full` regenerates everything\n- [ ] `lore stats` shows document/FTS/queue counts\n- [ ] `lore stats --check` verifies FTS consistency\n- [ ] No sqlite-vec dependency in Gate A","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-30T15:25:09.721108Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:54:44.243610Z","closed_at":"2026-01-30T17:54:44.243562Z","close_reason":"All Gate A sub-beads complete. Lexical MVP delivered: document extraction (issue/MR/discussion), FTS5 indexing, search with filters/snippets/RRF, generate-docs CLI, stats CLI with integrity check/repair.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2mz","depends_on_id":"bd-3lu","type":"blocks","created_at":"2026-01-30T15:29:35.679499Z","created_by":"tayloreernisse"},{"issue_id":"bd-2mz","depends_on_id":"bd-3qs","type":"blocks","created_at":"2026-01-30T15:29:35.713718Z","created_by":"tayloreernisse"},{"issue_id":"bd-2mz","depends_on_id":"bd-pr1","type":"blocks","created_at":"2026-01-30T15:29:35.747904Z","created_by":"tayloreernisse"}]} @@ -92,13 +108,16 @@ {"id":"bd-2px","title":"[CP1] Epic: Issue Ingestion","description":"Ingest all issues, labels, and issue discussions from configured GitLab repositories with resumable cursor-based incremental sync. This establishes the core data ingestion pattern reused for MRs in CP2.\n\n## Success Criteria\n- gi ingest --type=issues fetches all issues (count matches GitLab UI)\n- Labels extracted from issue payloads (name-only)\n- Label linkage reflects current GitLab state (removed labels unlinked on re-sync)\n- Issue discussions fetched per-issue (dependent sync)\n- Cursor-based sync is resumable (re-running fetches 0 new items)\n- Discussion sync skips unchanged issues (per-issue watermark)\n- Sync tracking records all runs\n- Single-flight lock prevents concurrent runs\n\n## Internal Gates\n- Gate A: Issues only (cursor + upsert + raw payloads + list/count/show)\n- Gate B: Labels correct (stale-link removal verified)\n- Gate C: Dependent discussion sync (watermark prevents redundant refetch)\n- Gate D: Resumability proof (kill mid-run, rerun; bounded redo)\n\nReference: docs/prd/checkpoint-1.md","status":"tombstone","priority":1,"issue_type":"epic","created_at":"2026-01-25T15:42:13.167698Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.638609Z","deleted_at":"2026-01-25T17:02:01.638606Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"epic","compaction_level":0,"original_size":0} {"id":"bd-2rk9","title":"WHO: CLI skeleton — WhoArgs, Commands::Who, dispatch arm","description":"## Background\n\nWire up the CLI plumbing so `lore who --help` works and dispatch reaches the who module. This is pure boilerplate — no query logic yet.\n\n## Approach\n\n### 1. src/cli/mod.rs — WhoArgs struct (after TimelineArgs, ~line 195)\n\n```rust\n#[derive(Parser)]\n#[command(after_help = \"\\x1b[1mExamples:\\x1b[0m\n lore who src/features/auth/ # Who knows about this area?\n lore who @asmith # What is asmith working on?\n lore who @asmith --reviews # What review patterns does asmith have?\n lore who --active # What discussions need attention?\n lore who --overlap src/features/auth/ # Who else is touching these files?\n lore who --path README.md # Expert lookup for a root file\")]\npub struct WhoArgs {\n /// Username or file path (path if contains /)\n pub target: Option,\n\n /// Force expert mode for a file/directory path (handles root files like README.md, Makefile)\n #[arg(long, help_heading = \"Mode\", conflicts_with_all = [\"active\", \"overlap\", \"reviews\"])]\n pub path: Option,\n\n /// Show active unresolved discussions\n #[arg(long, help_heading = \"Mode\", conflicts_with_all = [\"target\", \"overlap\", \"reviews\", \"path\"])]\n pub active: bool,\n\n /// Find users with MRs/notes touching this file path\n #[arg(long, help_heading = \"Mode\", conflicts_with_all = [\"target\", \"active\", \"reviews\", \"path\"])]\n pub overlap: Option,\n\n /// Show review pattern analysis (requires username target)\n #[arg(long, help_heading = \"Mode\", requires = \"target\", conflicts_with_all = [\"active\", \"overlap\", \"path\"])]\n pub reviews: bool,\n\n /// Time window (7d, 2w, 6m, YYYY-MM-DD). Default varies by mode.\n #[arg(long, help_heading = \"Filters\")]\n pub since: Option,\n\n /// Scope to a project (supports fuzzy matching)\n #[arg(short = 'p', long, help_heading = \"Filters\")]\n pub project: Option,\n\n /// Maximum results per section (1..=500)\n #[arg(short = 'n', long = \"limit\", default_value = \"20\",\n value_parser = clap::value_parser!(u16).range(1..=500),\n help_heading = \"Output\")]\n pub limit: u16,\n}\n```\n\n### 2. Commands enum — add Who(WhoArgs) after Timeline, before hidden List\n\n### 3. src/cli/commands/mod.rs — add `pub mod who;` and re-exports:\n```rust\npub use who::{run_who, print_who_human, print_who_json, WhoRun};\n```\n\n### 4. src/main.rs — dispatch arm + handler:\n```rust\nSome(Commands::Who(args)) => handle_who(cli.config.as_deref(), args, robot_mode),\n```\n\n### 5. src/cli/commands/who.rs — stub file with signatures that compile\n\n## Files\n\n- `src/cli/mod.rs` — WhoArgs struct + Commands::Who variant\n- `src/cli/commands/mod.rs` — pub mod who + re-exports\n- `src/main.rs` — dispatch arm + handle_who function + imports\n- `src/cli/commands/who.rs` — CREATE stub file\n\n## TDD Loop\n\nRED: `cargo check --all-targets` fails (missing who module)\nGREEN: Create stub who.rs with empty/todo!() implementations, wire up all 4 files\nVERIFY: `cargo check --all-targets && cargo run -- who --help`\n\n## Acceptance Criteria\n\n- [ ] `cargo check --all-targets` passes\n- [ ] `lore who --help` displays all flags with correct grouping (Mode, Filters, Output)\n- [ ] `lore who --active --overlap foo` rejected by clap (conflicts_with)\n- [ ] `lore who --reviews` rejected by clap (requires target)\n- [ ] WhoArgs is pub and importable from lore::cli\n\n## Edge Cases\n\n- conflicts_with_all on --path must NOT include \"target\" (--path is used alongside positional target in some cases... actually no, --path replaces target — check the plan: it conflicts with active/overlap/reviews but NOT target. Wait, looking at the plan: --path does NOT conflict with target. But if both target and --path are provided, --path takes priority in resolve_mode. The clap struct allows both.)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-08T02:39:58.436660Z","created_by":"tayloreernisse","updated_at":"2026-02-08T04:10:29.594923Z","closed_at":"2026-02-08T04:10:29.594882Z","close_reason":"Implemented by agent team: migration 017, CLI skeleton, all 5 query modes, human+robot output, 20 tests. All quality gates pass.","compaction_level":0,"original_size":0} {"id":"bd-2rr","title":"OBSERV: Replace subscriber init with dual-layer setup","description":"## Background\nThis is the core infrastructure bead for Phase 1. It replaces the single-layer subscriber (src/main.rs:44-58) with a dual-layer registry that separates stderr and file concerns. The file layer provides always-on post-mortem data; the stderr layer respects -v flags.\n\n## Approach\nReplace src/main.rs lines 44-58 with a function (e.g., init_tracing()) that:\n\n1. Build stderr filter from -v count (or RUST_LOG override):\n```rust\nfn build_stderr_filter(verbose: u8, quiet: bool) -> EnvFilter {\n if let Ok(rust_log) = std::env::var(\"RUST_LOG\") {\n return EnvFilter::new(rust_log);\n }\n if quiet {\n return EnvFilter::new(\"lore=warn,error\");\n }\n match verbose {\n 0 => EnvFilter::new(\"lore=info,warn\"),\n 1 => EnvFilter::new(\"lore=debug,warn\"),\n 2 => EnvFilter::new(\"lore=debug,info\"),\n _ => EnvFilter::new(\"trace,debug\"),\n }\n}\n```\n\n2. Build file filter (always lore=debug,warn unless RUST_LOG set):\n```rust\nfn build_file_filter() -> EnvFilter {\n if let Ok(rust_log) = std::env::var(\"RUST_LOG\") {\n return EnvFilter::new(rust_log);\n }\n EnvFilter::new(\"lore=debug,warn\")\n}\n```\n\n3. Assemble the registry:\n```rust\nlet stderr_layer = fmt::layer()\n .with_target(false)\n .with_writer(SuspendingWriter);\n// Conditionally add .json() based on log_format\n\nlet file_appender = tracing_appender::rolling::daily(log_dir, \"lore\");\nlet (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);\nlet file_layer = fmt::layer()\n .json()\n .with_writer(non_blocking);\n\ntracing_subscriber::registry()\n .with(stderr_layer.with_filter(build_stderr_filter(cli.verbose, cli.quiet)))\n .with(file_layer.with_filter(build_file_filter()))\n .init();\n```\n\nCRITICAL: The non_blocking _guard must be held for the program's lifetime. Store it in main() scope, NOT in the init function. If the guard drops, the file writer thread stops and buffered logs are lost.\n\nCRITICAL: Per-layer filtering requires each .with_filter() to produce a Filtered type. The two layers will have different concrete types (one with json, one without). This is fine -- the registry accepts heterogeneous layers via .with().\n\nWhen --log-format json: wrap stderr_layer with .json() too. This requires conditional construction. Two approaches:\n A) Use Box> for dynamic dispatch (simpler, tiny perf hit)\n B) Use an enum wrapper (zero cost but more code)\nRecommend approach A for simplicity. The overhead is one vtable indirection per log event, dwarfed by I/O.\n\nWhen file_logging is false (LoggingConfig.file_logging == false): skip adding the file layer entirely.\n\n## Acceptance Criteria\n- [ ] lore sync writes JSON log lines to ~/.local/share/lore/logs/lore.YYYY-MM-DD.log\n- [ ] lore -v sync shows DEBUG lore::* on stderr, deps at WARN\n- [ ] lore -vv sync shows DEBUG lore::* + INFO deps on stderr\n- [ ] lore -vvv sync shows TRACE everything on stderr\n- [ ] RUST_LOG=lore::gitlab=trace overrides -v for both layers\n- [ ] lore --log-format json sync emits JSON on stderr\n- [ ] -q + -v: -q wins (stderr at WARN+)\n- [ ] -q does NOT affect file layer (still DEBUG+)\n- [ ] File layer does NOT use SuspendingWriter\n- [ ] Non-blocking guard kept alive for program duration\n- [ ] Existing behavior unchanged when no new flags passed\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/main.rs (replace lines 44-58, add init_tracing function or inline)\n\n## TDD Loop\nRED:\n - test_verbosity_filter_construction: assert filter directives for verbose=0,1,2,3\n - test_rust_log_overrides_verbose: set env, assert TRACE not DEBUG\n - test_quiet_overrides_verbose: -q + -v => WARN+\n - test_json_log_output_format: capture file output, parse as JSON\n - test_suspending_writer_dual_layer: no garbled stderr with progress bars\nGREEN: Implement build_stderr_filter, build_file_filter, assemble registry\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- _guard lifetime: if guard is dropped early, buffered log lines are lost. MUST hold in main() scope.\n- Type erasure: stderr layer with/without .json() produces different types. Use Box> or separate init paths.\n- Empty RUST_LOG string: env::var returns Ok(\"\"), which EnvFilter::new(\"\") defaults to TRACE. May want to check is_empty().\n- File I/O error on log dir: tracing-appender handles this gracefully (no panic), but logs will be silently lost. The doctor command (bd-2i10) can diagnose this.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-04T15:53:55.577025Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:15:04.384114Z","closed_at":"2026-02-04T17:15:04.384062Z","close_reason":"Replaced single-layer subscriber with dual-layer setup: stderr (human/json, -v controlled) + file (always-on JSON, daily rotation via tracing-appender)","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-2rr","depends_on_id":"bd-17n","type":"blocks","created_at":"2026-02-04T15:55:19.397949Z","created_by":"tayloreernisse"},{"issue_id":"bd-2rr","depends_on_id":"bd-1k4","type":"blocks","created_at":"2026-02-04T15:55:19.461728Z","created_by":"tayloreernisse"},{"issue_id":"bd-2rr","depends_on_id":"bd-1o1","type":"blocks","created_at":"2026-02-04T15:55:19.327157Z","created_by":"tayloreernisse"},{"issue_id":"bd-2rr","depends_on_id":"bd-2nx","type":"parent-child","created_at":"2026-02-04T15:53:55.577882Z","created_by":"tayloreernisse"},{"issue_id":"bd-2rr","depends_on_id":"bd-gba","type":"blocks","created_at":"2026-02-04T15:55:19.262870Z","created_by":"tayloreernisse"}]} +{"id":"bd-2sr2","title":"Robot sync envelope: status enrichment metadata","description":"## Background\nAgents need machine-readable status enrichment metadata in the robot sync output to detect issues like unsupported GraphQL, partial errors, or enrichment failures. Without this, enrichment problems are invisible to automation.\n\n## Approach\nWire IngestProjectResult status fields into the per-project robot sync JSON. Add aggregate error count to top-level summary.\n\n## Files\n- Wherever robot sync output JSON is constructed (likely src/cli/commands/ingest.rs or the sync output serialization path — search for IngestProjectResult -> JSON conversion)\n\n## Implementation\n\nPer-project status_enrichment object in robot sync JSON:\n{\n \"mode\": \"fetched\" | \"unsupported\" | \"skipped\",\n \"reason\": null | \"graphql_endpoint_missing\" | \"auth_forbidden\",\n \"seen\": N,\n \"enriched\": N,\n \"cleared\": N,\n \"without_widget\": N,\n \"partial_errors\": N,\n \"first_partial_error\": null | \"message\",\n \"error\": null | \"message\"\n}\n\nSource fields from IngestProjectResult:\n mode <- status_enrichment_mode\n reason <- status_unsupported_reason\n seen <- statuses_seen\n enriched <- statuses_enriched\n cleared <- statuses_cleared\n without_widget <- statuses_without_widget\n partial_errors <- partial_error_count\n first_partial_error <- first_partial_error\n error <- status_enrichment_error\n\nTop-level sync summary: add status_enrichment_errors: N (count of projects where error is Some)\n\nField semantics:\n mode \"fetched\": enrichment ran (even if 0 statuses or error occurred)\n mode \"unsupported\": 404/403 from GraphQL\n mode \"skipped\": config toggle off\n seen > 0 + enriched == 0: project has issues but none with status\n partial_errors > 0: some pages returned incomplete data\n\n## Acceptance Criteria\n- [ ] Robot sync JSON includes per-project status_enrichment object\n- [ ] All 9 fields present with correct types\n- [ ] mode reflects actual enrichment outcome (fetched/unsupported/skipped)\n- [ ] Top-level status_enrichment_errors count present\n- [ ] Test: full robot sync output validates structure\n\n## TDD Loop\nRED: test_robot_sync_includes_status_enrichment\nGREEN: Wire fields into JSON serialization\nVERIFY: cargo test robot_sync\n\n## Edge Cases\n- Find the exact location where IngestProjectResult is serialized to JSON — may be in a Serialize impl or manual json! macro\n- All numeric fields default to 0, all Option fields default to null in JSON\n- mode is always present (never null)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-11T06:42:29.127412Z","created_by":"tayloreernisse","updated_at":"2026-02-11T07:21:33.422233Z","closed_at":"2026-02-11T07:21:33.422193Z","close_reason":"Implemented by agent swarm — all quality gates pass (595 tests, 0 failures)","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2sr2","depends_on_id":"bd-2y79","type":"parent-child","created_at":"2026-02-11T06:42:29.130750Z","created_by":"tayloreernisse"},{"issue_id":"bd-2sr2","depends_on_id":"bd-3dum","type":"blocks","created_at":"2026-02-11T06:42:45.995816Z","created_by":"tayloreernisse"}]} {"id":"bd-2sx","title":"Implement lore embed CLI command","description":"## Background\nThe embed CLI command is the user-facing wrapper for the embedding pipeline. It runs Ollama health checks, selects documents to embed (pending or failed), shows progress, and reports results. This is the standalone command for building embeddings outside of the sync orchestrator.\n\n## Approach\nCreate `src/cli/commands/embed.rs` per PRD Section 4.4.\n\n**IMPORTANT: The embed command is async.** The underlying `embed_documents()` function is `async fn` (uses `FuturesUnordered` for concurrent HTTP to Ollama). The CLI runner must use tokio runtime.\n\n**Core function (async):**\n```rust\npub async fn run_embed(\n config: &Config,\n retry_failed: bool,\n) -> Result\n```\n\n**Pipeline:**\n1. Create OllamaClient from config.embedding (base_url, model, timeout_secs)\n2. Run `client.health_check().await` — fail early with clear error if Ollama unavailable or model missing\n3. Determine selection: `EmbedSelection::RetryFailed` if --retry-failed, else `EmbedSelection::Pending`\n4. Call `embed_documents(conn, &client, selection, concurrency, progress_callback).await`\n - `concurrency` param controls max in-flight HTTP requests to Ollama\n - `progress_callback` drives indicatif progress bar\n5. Show progress bar (indicatif) during embedding\n6. Return EmbedResult with counts\n\n**CLI args:**\n```rust\n#[derive(Args)]\npub struct EmbedArgs {\n #[arg(long)]\n retry_failed: bool,\n}\n```\n\n**Output:**\n- Human: \"Embedded 42 documents (15 chunks), 2 errors, 5 skipped (unchanged)\"\n- JSON: `{\"ok\": true, \"data\": {\"embedded\": 42, \"chunks\": 15, \"errors\": 2, \"skipped\": 5}}`\n\n**Tokio integration note:**\nThe embed command runs async code. Either:\n- Use `#[tokio::main]` on main and propagate async through CLI dispatch\n- Or use `tokio::runtime::Runtime::new()` in the embed command handler\n\n## Acceptance Criteria\n- [ ] Command is async (embed_documents is async, health_check is async)\n- [ ] OllamaClient created from config.embedding settings\n- [ ] Health check runs first — clear error if Ollama down (exit code 14)\n- [ ] Clear error if model not found: \"Pull the model: ollama pull nomic-embed-text\" (exit code 15)\n- [ ] Embeds pending documents (no existing embeddings or stale content_hash)\n- [ ] --retry-failed re-attempts documents with last_error\n- [ ] Progress bar shows during embedding (indicatif)\n- [ ] embed_documents called with concurrency parameter\n- [ ] embed_documents called with progress_callback for progress bar\n- [ ] Human + JSON output\n- [ ] `cargo build` succeeds\n\n## Files\n- `src/cli/commands/embed.rs` — new file\n- `src/cli/commands/mod.rs` — add `pub mod embed;`\n- `src/cli/mod.rs` — add EmbedArgs, wire up embed subcommand\n- `src/main.rs` — add embed command handler (async dispatch)\n\n## TDD Loop\nRED: Integration test needing Ollama\nGREEN: Implement run_embed (async)\nVERIFY: `cargo build && cargo test embed`\n\n## Edge Cases\n- No documents in DB: \"No documents to embed\" (not error)\n- All documents already embedded and unchanged: \"0 documents to embed (all up to date)\"\n- Ollama goes down mid-embedding: pipeline records errors for remaining docs, returns partial result\n- --retry-failed with no failed docs: \"No failed documents to retry\"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:26:34.126482Z","created_by":"tayloreernisse","updated_at":"2026-01-30T18:02:38.633115Z","closed_at":"2026-01-30T18:02:38.633055Z","close_reason":"Embed CLI command fully wired: EmbedArgs, Commands::Embed variant, handle_embed handler, clean build, all tests pass","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2sx","depends_on_id":"bd-am7","type":"blocks","created_at":"2026-01-30T15:29:24.766104Z","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-2y79","title":"Add work item status via GraphQL enrichment","description":"## Background\n\nGitLab 18.2+ added native work item status (To do, In progress, Done, Won't do, Duplicate) but it is only available via GraphQL, not the REST API. This enriches synced issues with status information by making a supplementary GraphQL call after the REST ingestion.\n\n**Spec reference:** Not in Phase B spec. This is an enhancement discovered during Phase B planning.\n\n## Codebase Context — Migration Numbering\n\n- LATEST_SCHEMA_VERSION = 14 (MIGRATIONS array in db.rs includes 001-014)\n- Migration 015 exists on disk (commit SHAs) but is NOT registered in db.rs yet — bd-1oo fixes this\n- bd-1oo creates migration 016 (mr_file_changes)\n- **This bead uses migration 017** for issue status columns\n- bd-343o (linked issues) would use migration 018 — coordinate numbering\n- No GraphQL client exists yet — this would be the first GraphQL usage in the codebase\n\n## Other Codebase Context\n\n- src/core/config.rs SyncConfig has: fetch_resource_events, and will get fetch_mr_file_changes (bd-jec)\n- src/gitlab/client.rs: all API methods use reqwest + fetch_all_pages() + coalesce_not_found()\n- src/cli/commands/show.rs: print_show_issue() displays issue detail with colored sections\n- src/ingestion/orchestrator.rs: ingest_project_issues() returns IngestProjectResult with progress callbacks\n\n## Approach\n\n### Phase 1: GraphQL Client (src/gitlab/graphql.rs NEW)\n\nMinimal GraphQL client — single function, not a full framework:\n```rust\npub async fn graphql_query(\n base_url: &str,\n token: &str,\n query: &str,\n variables: serde_json::Value,\n) -> Result {\n // POST to {base_url}/api/graphql\n // Content-Type: application/json\n // Headers: PRIVATE-TOKEN: {token}\n // Body: {\"query\": \"...\", \"variables\": {...}}\n // Parse response, check for errors array\n}\n```\n\nAdd `pub mod graphql;` to `src/gitlab/mod.rs`.\n\n### Phase 2: Status Types (src/gitlab/types.rs)\n\n```rust\n#[derive(Debug, Clone, Serialize, Deserialize)]\npub struct WorkItemStatus {\n pub name: String, // \"To do\", \"In progress\", \"Done\", etc.\n pub category: String, // \"todo\", \"in_progress\", \"done\"\n pub color: Option,\n pub icon_name: Option,\n}\n```\n\n### Phase 3: Batch Fetch Query\n\n```graphql\nquery IssueStatuses($projectPath: ID!, $iids: [String!]) {\n project(fullPath: $projectPath) {\n issues(iids: $iids) {\n nodes {\n iid\n state\n workItemType { name }\n widgets {\n ... on WorkItemWidgetStatus {\n status { name category color iconName }\n }\n }\n }\n }\n }\n}\n```\n\nBatch in groups of 50 IIDs to avoid query complexity limits.\n\n### Phase 4: Migration 017 (migrations/017_issue_status.sql)\n\n```sql\nALTER TABLE issues ADD COLUMN status_name TEXT;\nALTER TABLE issues ADD COLUMN status_category TEXT;\n\nINSERT INTO schema_version (version, applied_at, description)\nVALUES (17, strftime('%s', 'now') * 1000, 'Issue work item status columns');\n```\n\nRegister in db.rs MIGRATIONS array.\n\n### Phase 5: Enrichment Step (src/ingestion/orchestrator.rs)\n\nAfter REST issue ingestion, call GraphQL to fetch statuses for all synced issues.\n\n### Phase 6: Display (src/cli/commands/show.rs)\n\nAdd status line to print_show_issue():\n```\nStatus: In progress\n```\n\n### Phase 7: Graceful Degradation\n\n- If GraphQL endpoint returns 404 or 403: skip silently (older GitLab)\n- If work item status widget not present: skip\n- Never fail the sync pipeline due to GraphQL errors\n\n## Acceptance Criteria\n\n- [ ] GraphQL client can POST queries and handle errors\n- [ ] Status fetched in batches of 50 IIDs\n- [ ] Migration 017 adds status_name and status_category columns to issues table\n- [ ] `lore show issue 123` shows status in human output (when available)\n- [ ] `lore --robot show issue 123` includes status_name, status_category in JSON\n- [ ] Graceful degradation: older GitLab versions do not cause errors\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- src/gitlab/graphql.rs (NEW — minimal GraphQL client)\n- src/gitlab/mod.rs (add pub mod graphql)\n- src/gitlab/types.rs (add WorkItemStatus struct)\n- migrations/017_issue_status.sql (NEW)\n- src/core/db.rs (add migration 017 to MIGRATIONS array)\n- src/ingestion/orchestrator.rs (call enrich_issue_statuses after issue sync)\n- src/cli/commands/show.rs (display status in issue output)\n\n## TDD Loop\n\nRED:\n- test_graphql_query_success — mock server returns valid GraphQL response\n- test_graphql_query_error — mock server returns errors array -> Result::Err\n- test_work_item_status_deserialize — parse GraphQL response into WorkItemStatus\n- test_enrichment_graceful_degradation — 403 response -> Ok(0) not Err\n\nGREEN: Implement GraphQL client, enrichment step, migration.\n\nVERIFY: cargo test --lib -- graphql\n\n## Edge Cases\n\n- GitLab < 18.2: GraphQL endpoint exists but work item status widget missing -> skip\n- GraphQL rate limiting: respect Retry-After header\n- Issue with no status widget: status_name = NULL in DB\n- Token with only read_api scope: GraphQL may require different scopes — test and document\n- Must run after bd-1oo (migration numbering: 015 registered, 016 created, then 017)\n","status":"open","priority":2,"issue_type":"feature","created_at":"2026-02-05T18:32:39.287957Z","created_by":"tayloreernisse","updated_at":"2026-02-05T20:55:27.192737Z","compaction_level":0,"original_size":0,"labels":["api","phase-b"]} +{"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-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-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-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} @@ -120,12 +139,15 @@ {"id":"bd-38e","title":"[CP0] gi init command - interactive setup wizard","description":"## Background\n\nThe init command is the user's first interaction with gi. It must guide them through setup, validate everything works before writing config, and leave the system in a ready-to-use state. Poor UX here will frustrate new users.\n\nReference: docs/prd/checkpoint-0.md section \"gi init\"\n\n## Approach\n\n**src/cli/commands/init.ts:**\n\nInteractive flow (using inquirer):\n1. Check if config exists at target path\n - If exists and no --force: prompt \"Config exists. Overwrite? [y/N]\"\n - If --non-interactive and config exists: exit 2\n2. Prompt for GitLab base URL (validate URL format)\n3. Prompt for token env var name (default: GITLAB_TOKEN)\n4. Check token is set in environment\n - If not set: exit 1 with \"Export GITLAB_TOKEN first\"\n5. Test auth with GET /api/v4/user\n - If 401: exit 1 with \"Authentication failed\"\n - Show \"Authenticated as @username (Display Name)\"\n6. Prompt for project paths (comma-separated or add one at a time)\n7. Validate each project with GET /api/v4/projects/:encoded_path\n - If 404: exit 1 with \"Project not found: group/project\"\n - Show \"✓ group/project (Project Name)\"\n8. Write config.json to target path\n9. Initialize database with migrations\n10. Insert validated projects into projects table\n11. Show \"Setup complete! Run 'gi doctor' to verify.\"\n\n**Flags:**\n- `--config `: Write config to specific path\n- `--force`: Skip overwrite confirmation\n- `--non-interactive`: Fail if prompts would be shown (for CI/scripting)\n\n## Acceptance Criteria\n\n- [ ] Creates config.json with valid structure\n- [ ] Validates GitLab URL is reachable before writing config\n- [ ] Validates token with GET /api/v4/user before writing config\n- [ ] Validates each project path exists in GitLab before writing config\n- [ ] Fails with exit 1 if token not set in environment\n- [ ] Fails with exit 1 if GitLab auth fails\n- [ ] Fails with exit 1 if any project not found\n- [ ] Prompts before overwriting existing config (unless --force)\n- [ ] --force skips overwrite confirmation\n- [ ] --non-interactive fails if prompts would be shown\n- [ ] Creates data directory and applies DB migrations\n- [ ] Inserts validated projects into projects table\n- [ ] tests/integration/init.test.ts passes (11 tests)\n\n## Files\n\nCREATE:\n- src/cli/commands/init.ts\n- tests/integration/init.test.ts\n\n## TDD Loop\n\nRED:\n```typescript\n// tests/integration/init.test.ts\ndescribe('gi init', () => {\n it('creates config file with valid structure')\n it('validates GitLab URL format')\n it('validates GitLab connection before writing config')\n it('validates each project path exists in GitLab')\n it('fails if token not set')\n it('fails if GitLab auth fails')\n it('fails if any project path not found')\n it('prompts before overwriting existing config')\n it('respects --force to skip confirmation')\n it('generates config with sensible defaults')\n it('creates data directory if missing')\n})\n```\n\nGREEN: Implement init.ts\n\nVERIFY: `npm run test -- tests/integration/init.test.ts`\n\n## Edge Cases\n\n- User cancels at any prompt: exit 2 (user cancelled)\n- Network error during validation: show specific error, exit 1\n- Token has wrong scopes (no read_api): auth succeeds but project fetch fails\n- Project path with special characters must be URL-encoded\n- Config directory might not exist - create with mkdirSync recursive\n- --non-interactive with missing env var should fail immediately","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-24T16:09:50.810720Z","created_by":"tayloreernisse","updated_at":"2026-01-25T03:27:07.775170Z","closed_at":"2026-01-25T03:27:07.774984Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-38e","depends_on_id":"bd-13b","type":"blocks","created_at":"2026-01-24T16:13:09.682253Z","created_by":"tayloreernisse"},{"issue_id":"bd-38e","depends_on_id":"bd-1l1","type":"blocks","created_at":"2026-01-24T16:13:09.733568Z","created_by":"tayloreernisse"},{"issue_id":"bd-38e","depends_on_id":"bd-3ng","type":"blocks","created_at":"2026-01-24T16:13:09.715644Z","created_by":"tayloreernisse"},{"issue_id":"bd-38e","depends_on_id":"bd-epj","type":"blocks","created_at":"2026-01-24T16:13:09.699092Z","created_by":"tayloreernisse"}]} {"id":"bd-38q","title":"Implement dirty source tracking module","description":"## Background\nDirty source tracking drives incremental document regeneration. When entities are upserted during ingestion, they're marked dirty. The regenerator drains this queue. The key constraint: mark_dirty_tx() takes &Transaction to enforce atomic marking inside the entity upsert transaction. Uses ON CONFLICT DO UPDATE (not INSERT OR IGNORE) to reset backoff on re-queue.\n\n## Approach\nCreate \\`src/ingestion/dirty_tracker.rs\\` per PRD Section 6.1.\n\n```rust\nconst DIRTY_SOURCES_BATCH_SIZE: usize = 500;\n\n/// Mark dirty INSIDE existing transaction. Takes &Transaction, NOT &Connection.\n/// ON CONFLICT resets ALL backoff/error state (not INSERT OR IGNORE).\n/// This ensures fresh updates are immediately eligible, not stuck behind stale backoff.\npub fn mark_dirty_tx(\n tx: &rusqlite::Transaction<'_>,\n source_type: SourceType,\n source_id: i64,\n) -> Result<()>;\n\n/// Convenience wrapper for non-transactional contexts.\npub fn mark_dirty(conn: &Connection, source_type: SourceType, source_id: i64) -> Result<()>;\n\n/// Get dirty sources ready for processing.\n/// WHERE next_attempt_at IS NULL OR next_attempt_at <= now\n/// ORDER BY attempt_count ASC, queued_at ASC (failed items deprioritized)\n/// LIMIT 500\npub fn get_dirty_sources(conn: &Connection) -> Result>;\n\n/// Clear dirty entry after successful processing.\npub fn clear_dirty(conn: &Connection, source_type: SourceType, source_id: i64) -> Result<()>;\n```\n\n**PRD-specific details:**\n- get_dirty_sources ORDER BY: \\`attempt_count ASC, queued_at ASC\\` (failed items processed AFTER fresh items)\n- mark_dirty_tx ON CONFLICT resets: queued_at, attempt_count=0, last_attempt_at=NULL, last_error=NULL, next_attempt_at=NULL\n- SourceType parsed from string in query results via match on \\\"issue\\\"/\\\"merge_request\\\"/\\\"discussion\\\"\n- Invalid source_type in DB -> rusqlite::Error::FromSqlConversionFailure\n\n**Error recording is in regenerator.rs (bd-1u1)**, not dirty_tracker. The dirty_tracker only marks, gets, and clears.\n\n## Acceptance Criteria\n- [ ] mark_dirty_tx takes &Transaction<'_>, NOT &Connection\n- [ ] ON CONFLICT DO UPDATE resets: attempt_count=0, next_attempt_at=NULL, last_error=NULL, last_attempt_at=NULL\n- [ ] Uses ON CONFLICT DO UPDATE, NOT INSERT OR IGNORE (PRD explains why)\n- [ ] get_dirty_sources WHERE next_attempt_at IS NULL OR <= now\n- [ ] get_dirty_sources ORDER BY attempt_count ASC, queued_at ASC\n- [ ] get_dirty_sources LIMIT 500\n- [ ] get_dirty_sources returns Vec<(SourceType, i64)>\n- [ ] clear_dirty DELETEs entry\n- [ ] Queue drains completely when called in loop\n- [ ] \\`cargo test dirty_tracker\\` passes\n\n## Files\n- \\`src/ingestion/dirty_tracker.rs\\` — new file\n- \\`src/ingestion/mod.rs\\` — add \\`pub mod dirty_tracker;\\`\n\n## TDD Loop\nRED: Tests:\n- \\`test_mark_dirty_tx_inserts\\` — entry appears in dirty_sources\n- \\`test_requeue_resets_backoff\\` — mark, simulate error state, re-mark -> attempt_count=0, next_attempt_at=NULL\n- \\`test_get_respects_backoff\\` — entry with future next_attempt_at not returned\n- \\`test_get_orders_by_attempt_count\\` — fresh items before failed items\n- \\`test_batch_size_500\\` — insert 600, get returns 500\n- \\`test_clear_removes\\` — entry gone after clear\n- \\`test_drain_loop\\` — insert 1200, loop 3 times = empty\nGREEN: Implement all functions\nVERIFY: \\`cargo test dirty_tracker\\`\n\n## Edge Cases\n- Empty queue: get returns empty Vec\n- Invalid source_type string in DB: FromSqlConversionFailure error\n- Concurrent mark + get: ON CONFLICT handles race condition","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:27:09.434845Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:31:35.455315Z","closed_at":"2026-01-30T17:31:35.455127Z","close_reason":"Implemented dirty_tracker with mark_dirty_tx, get_dirty_sources, clear_dirty, record_dirty_error + 8 tests","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-38q","depends_on_id":"bd-36p","type":"blocks","created_at":"2026-01-30T15:29:34.914038Z","created_by":"tayloreernisse"},{"issue_id":"bd-38q","depends_on_id":"bd-hrs","type":"blocks","created_at":"2026-01-30T15:29:34.961390Z","created_by":"tayloreernisse"},{"issue_id":"bd-38q","depends_on_id":"bd-mem","type":"blocks","created_at":"2026-01-30T15:29:34.995197Z","created_by":"tayloreernisse"}]} {"id":"bd-39w","title":"[CP1] Test fixtures for mocked GitLab responses","description":"## Background\n\nTest fixtures provide mocked GitLab API responses for unit and integration tests. They enable testing without a live GitLab instance and ensure consistent test data across runs.\n\n## Approach\n\n### Fixture Files\n\nCreate JSON fixtures that match GitLab API response shapes:\n\n```\ntests/fixtures/\n├── gitlab_issue.json # Single issue\n├── gitlab_issues_page.json # Array of issues (pagination test)\n├── gitlab_discussion.json # Single discussion with notes\n└── gitlab_discussions_page.json # Array of discussions\n```\n\n### gitlab_issue.json\n\n```json\n{\n \"id\": 12345,\n \"iid\": 42,\n \"project_id\": 100,\n \"title\": \"Test issue title\",\n \"description\": \"Test issue description\",\n \"state\": \"opened\",\n \"created_at\": \"2024-01-15T10:00:00.000Z\",\n \"updated_at\": \"2024-01-20T15:30:00.000Z\",\n \"closed_at\": null,\n \"author\": {\n \"id\": 1,\n \"username\": \"testuser\",\n \"name\": \"Test User\"\n },\n \"labels\": [\"bug\", \"priority::high\"],\n \"web_url\": \"https://gitlab.example.com/group/project/-/issues/42\"\n}\n```\n\n### gitlab_discussion.json\n\n```json\n{\n \"id\": \"6a9c1750b37d513a43987b574953fceb50b03ce7\",\n \"individual_note\": false,\n \"notes\": [\n {\n \"id\": 1001,\n \"type\": \"DiscussionNote\",\n \"body\": \"First comment in thread\",\n \"author\": { \"id\": 1, \"username\": \"testuser\", \"name\": \"Test User\" },\n \"created_at\": \"2024-01-16T09:00:00.000Z\",\n \"updated_at\": \"2024-01-16T09:00:00.000Z\",\n \"system\": false,\n \"resolvable\": true,\n \"resolved\": false,\n \"resolved_by\": null,\n \"resolved_at\": null,\n \"position\": null\n },\n {\n \"id\": 1002,\n \"type\": \"DiscussionNote\",\n \"body\": \"Reply to first comment\",\n \"author\": { \"id\": 2, \"username\": \"reviewer\", \"name\": \"Reviewer\" },\n \"created_at\": \"2024-01-16T10:00:00.000Z\",\n \"updated_at\": \"2024-01-16T10:00:00.000Z\",\n \"system\": false,\n \"resolvable\": true,\n \"resolved\": false,\n \"resolved_by\": null,\n \"resolved_at\": null,\n \"position\": null\n }\n ]\n}\n```\n\n### Helper Module\n\n```rust\n// tests/fixtures/mod.rs\n\npub fn load_fixture(name: &str) -> T {\n let path = PathBuf::from(env!(\"CARGO_MANIFEST_DIR\"))\n .join(\"tests/fixtures\")\n .join(name);\n let content = std::fs::read_to_string(&path)\n .expect(&format!(\"Failed to read fixture: {}\", name));\n serde_json::from_str(&content)\n .expect(&format!(\"Failed to parse fixture: {}\", name))\n}\n\npub fn gitlab_issue() -> GitLabIssue {\n load_fixture(\"gitlab_issue.json\")\n}\n\npub fn gitlab_issues_page() -> Vec {\n load_fixture(\"gitlab_issues_page.json\")\n}\n\npub fn gitlab_discussion() -> GitLabDiscussion {\n load_fixture(\"gitlab_discussion.json\")\n}\n```\n\n## Acceptance Criteria\n\n- [ ] gitlab_issue.json deserializes to GitLabIssue correctly\n- [ ] gitlab_issues_page.json contains 3+ issues for pagination tests\n- [ ] gitlab_discussion.json contains multi-note thread\n- [ ] gitlab_discussions_page.json contains mix of individual_note true/false\n- [ ] At least one fixture includes system: true note\n- [ ] Helper functions load fixtures without panic\n\n## Files\n\n- tests/fixtures/gitlab_issue.json (create)\n- tests/fixtures/gitlab_issues_page.json (create)\n- tests/fixtures/gitlab_discussion.json (create)\n- tests/fixtures/gitlab_discussions_page.json (create)\n- tests/fixtures/mod.rs (create)\n\n## TDD Loop\n\nRED:\n```rust\n#[test] fn fixture_gitlab_issue_deserializes()\n#[test] fn fixture_gitlab_discussion_deserializes()\n#[test] fn fixture_has_system_note()\n```\n\nGREEN: Create JSON fixtures and helper module\n\nVERIFY: `cargo test fixture`\n\n## Edge Cases\n\n- Include issue with empty labels array\n- Include issue with null description\n- Include system note (system: true)\n- Include individual_note: true discussion (standalone comment)\n- Timestamps must be valid ISO 8601","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-25T17:02:38.433752Z","created_by":"tayloreernisse","updated_at":"2026-01-25T22:48:08.415195Z","closed_at":"2026-01-25T22:48:08.415132Z","close_reason":"Created 4 JSON fixture files (issue, issues_page, discussion, discussions_page) with helper tests - 6 tests passing","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-39w","depends_on_id":"bd-1np","type":"blocks","created_at":"2026-01-25T17:04:05.770848Z","created_by":"tayloreernisse"}]} +{"id":"bd-3a4k","title":"CLI: list issues status column, filter, and robot fields","description":"## Background\nList issues needs a Status column in the table, status fields in robot JSON, and a --status filter for querying by work item status name. The filter supports multiple values (OR semantics) and case-insensitive matching.\n\n## Approach\nExtend list.rs row types, SQL, table rendering. Add --status Vec to clap args. Build dynamic WHERE clause with COLLATE NOCASE. Wire into both ListFilters constructions in main.rs. Register in autocorrect.\n\n## Files\n- src/cli/commands/list.rs (row types, SQL, table, filter, color helper)\n- src/cli/mod.rs (--status flag on IssuesArgs)\n- src/main.rs (wire statuses into both ListFilters)\n- src/cli/autocorrect.rs (add --status to COMMAND_FLAGS)\n\n## Implementation\n\nIssueListRow + IssueListRowJson: add 5 status fields (all Option)\nFrom<&IssueListRow> for IssueListRowJson: clone all 5 fields\n\nquery_issues SELECT: add i.status_name, i.status_category, i.status_color, i.status_icon_name, i.status_synced_at after existing columns\n Existing SELECT has 12 columns (indices 0-11). New columns: indices 12-16.\n Row mapping: status_name: row.get(12)?, ..., status_synced_at: row.get(16)?\n\nListFilters: add pub statuses: &'a [String]\n\nWHERE clause builder (after has_due_date block):\n if statuses.len() == 1: \"i.status_name = ? COLLATE NOCASE\" + push param\n if statuses.len() > 1: \"i.status_name IN (?, ?, ...) COLLATE NOCASE\" + push all params\n\nTable: add \"Status\" column header (bold) between State and Assignee\n Row: match &issue.status_name -> Some: colored_cell_hex(status, color), None: Cell::new(\"\")\n\nNew helper:\n fn colored_cell_hex(content, hex: Option<&str>) -> Cell\n If no hex or colors disabled: Cell::new(content)\n Parse 6-char hex, use Cell::new(content).fg(Color::Rgb { r, g, b })\n\nIn src/cli/mod.rs IssuesArgs:\n #[arg(long, help_heading = \"Filters\")]\n pub status: Vec,\n\nIn src/main.rs handle_issues (~line 695):\n ListFilters { ..., statuses: &args.status }\nIn legacy List handler (~line 2421):\n ListFilters { ..., statuses: &[] }\n\nIn src/cli/autocorrect.rs COMMAND_FLAGS \"issues\" entry:\n Add \"--status\" between existing flags\n\n## Acceptance Criteria\n- [ ] Status column appears in table between State and Assignee\n- [ ] NULL status -> empty cell\n- [ ] Status colored by hex in human mode\n- [ ] --status \"In progress\" filters correctly\n- [ ] --status \"in progress\" matches \"In progress\" (COLLATE NOCASE)\n- [ ] --status \"To do\" --status \"In progress\" -> OR semantics (both returned)\n- [ ] Robot: status_name, status_category in each issue JSON\n- [ ] --fields supports status_name, status_category, status_color, status_icon_name, status_synced_at\n- [ ] --fields minimal does NOT include status fields\n- [ ] Autocorrect registry test passes (--status registered)\n- [ ] cargo check --all-targets passes\n\n## TDD Loop\nRED: test_list_filter_by_status, test_list_filter_by_status_case_insensitive, test_list_filter_by_multiple_statuses\nGREEN: Implement all changes across 4 files\nVERIFY: cargo test list_filter && cargo test registry_covers\n\n## Edge Cases\n- COLLATE NOCASE is ASCII-only but sufficient (all system statuses are ASCII)\n- Single-value uses = for simplicity; multi-value uses IN with dynamic placeholders\n- --status combined with other filters (--state, --label) -> AND logic\n- autocorrect registry_covers_command_flags test will FAIL if --status not registered\n- Legacy List command path also constructs ListFilters — needs statuses: &[]\n- Column index offset: new columns start at 12 (0-indexed)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-11T06:42:26.438Z","created_by":"tayloreernisse","updated_at":"2026-02-11T07:21:33.421297Z","closed_at":"2026-02-11T07:21:33.421247Z","close_reason":"Implemented by agent swarm — all quality gates pass (595 tests, 0 failures)","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3a4k","depends_on_id":"bd-2y79","type":"parent-child","created_at":"2026-02-11T06:42:26.440928Z","created_by":"tayloreernisse"},{"issue_id":"bd-3a4k","depends_on_id":"bd-3dum","type":"blocks","created_at":"2026-02-11T06:42:45.236067Z","created_by":"tayloreernisse"}]} {"id":"bd-3ae","title":"Epic: CP2 Gate A - MRs Only","description":"## Background\nGate A validates core MR ingestion works before adding complexity. Proves the cursor-based sync, pagination, and basic CLI work. This is the foundation - if Gate A fails, nothing else matters.\n\n## Acceptance Criteria (Pass/Fail)\n- [ ] `gi ingest --type=merge_requests` completes without error\n- [ ] `SELECT COUNT(*) FROM merge_requests` > 0\n- [ ] `gi list mrs --limit=5` shows 5 MRs with iid, title, state, author\n- [ ] `gi count mrs` shows total count matching DB query\n- [ ] MR with `state=locked` can be stored (if exists in test data)\n- [ ] Draft MR shows `draft=1` in DB and `[DRAFT]` in list output\n- [ ] `work_in_progress=true` MR shows `draft=1` (fallback works)\n- [ ] `head_sha` populated for MRs with commits\n- [ ] `references_short` and `references_full` populated\n- [ ] Re-run ingest shows \"0 new MRs\" or minimal refetch (cursor working)\n- [ ] Cursor saved at page boundary, not item boundary\n\n## Validation Script\n```bash\n#!/bin/bash\nset -e\n\nDB_PATH=\"${XDG_DATA_HOME:-$HOME/.local/share}/gitlab-inbox/db.sqlite3\"\n\necho \"=== Gate A: MRs Only ===\"\n\n# 1. Clear any existing MR data for clean test\necho \"Step 1: Reset MR cursor for clean test...\"\nsqlite3 \"$DB_PATH\" \"DELETE FROM sync_cursors WHERE resource_type = 'merge_requests';\"\n\n# 2. Run MR ingestion\necho \"Step 2: Ingest MRs...\"\ngi ingest --type=merge_requests\n\n# 3. Verify MRs exist\necho \"Step 3: Verify MR count...\"\nMR_COUNT=$(sqlite3 \"$DB_PATH\" \"SELECT COUNT(*) FROM merge_requests;\")\necho \" MR count: $MR_COUNT\"\n[ \"$MR_COUNT\" -gt 0 ] || { echo \"FAIL: No MRs ingested\"; exit 1; }\n\n# 4. Verify list command\necho \"Step 4: Test list command...\"\ngi list mrs --limit=5\n\n# 5. Verify count command\necho \"Step 5: Test count command...\"\ngi count mrs\n\n# 6. Verify draft handling\necho \"Step 6: Check draft MRs...\"\nDRAFT_COUNT=$(sqlite3 \"$DB_PATH\" \"SELECT COUNT(*) FROM merge_requests WHERE draft = 1;\")\necho \" Draft MR count: $DRAFT_COUNT\"\n\n# 7. Verify head_sha population\necho \"Step 7: Check head_sha...\"\nSHA_COUNT=$(sqlite3 \"$DB_PATH\" \"SELECT COUNT(*) FROM merge_requests WHERE head_sha IS NOT NULL;\")\necho \" MRs with head_sha: $SHA_COUNT\"\n\n# 8. Verify references\necho \"Step 8: Check references...\"\nREF_COUNT=$(sqlite3 \"$DB_PATH\" \"SELECT COUNT(*) FROM merge_requests WHERE references_short IS NOT NULL;\")\necho \" MRs with references: $REF_COUNT\"\n\n# 9. Verify cursor saved\necho \"Step 9: Check cursor...\"\nCURSOR=$(sqlite3 \"$DB_PATH\" \"SELECT updated_at, gitlab_id FROM sync_cursors WHERE resource_type = 'merge_requests';\")\necho \" Cursor: $CURSOR\"\n[ -n \"$CURSOR\" ] || { echo \"FAIL: Cursor not saved\"; exit 1; }\n\n# 10. Re-run and verify minimal refetch\necho \"Step 10: Re-run ingest (should be minimal)...\"\ngi ingest --type=merge_requests\n# Output should show minimal or zero new MRs\n\necho \"\"\necho \"=== Gate A: PASSED ===\"\n```\n\n## Test Commands (Quick Verification)\n```bash\n# Run these in order:\ngi ingest --type=merge_requests\ngi list mrs --limit=10\ngi count mrs\n\n# Verify in DB:\nsqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 \"\n SELECT \n COUNT(*) as total,\n SUM(CASE WHEN draft = 1 THEN 1 ELSE 0 END) as drafts,\n SUM(CASE WHEN head_sha IS NOT NULL THEN 1 ELSE 0 END) as with_sha,\n SUM(CASE WHEN references_short IS NOT NULL THEN 1 ELSE 0 END) as with_refs\n FROM merge_requests;\n\"\n\n# Re-run (should be no-op):\ngi ingest --type=merge_requests\n```\n\n## Dependencies\nThis gate requires these beads to be complete:\n- bd-3ir (Database migration)\n- bd-5ta (GitLab MR types)\n- bd-34o (MR transformer)\n- bd-iba (GitLab client pagination)\n- bd-ser (MR ingestion module)\n\n## Edge Cases\n- `locked` state is transitional (merge in progress); may not exist in test data\n- Some older GitLab instances may not return `head_sha` for all MRs\n- `work_in_progress` is deprecated but should still work as fallback\n- Very large projects (10k+ MRs) may take significant time on first sync","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-26T22:06:00.966522Z","created_by":"tayloreernisse","updated_at":"2026-01-27T00:48:21.057298Z","closed_at":"2026-01-27T00:48:21.057225Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3ae","depends_on_id":"bd-iba","type":"blocks","created_at":"2026-01-26T22:08:55.576626Z","created_by":"tayloreernisse"},{"issue_id":"bd-3ae","depends_on_id":"bd-ser","type":"blocks","created_at":"2026-01-26T22:08:55.446814Z","created_by":"tayloreernisse"}]} {"id":"bd-3as","title":"Implement timeline event collection and chronological interleaving","description":"## Background\n\nThe event collection phase is steps 4-5 of the timeline pipeline (spec Section 3.2). It takes seed + expanded entity sets and collects all their events from resource event tables, then interleaves chronologically.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.2 steps 4-5, Section 3.3 (Event Model).\n\n## Codebase Context\n\n- resource_state_events: columns include state, actor_username (not actor_gitlab_id for display), created_at, issue_id, merge_request_id, source_merge_request_iid, source_commit\n- resource_label_events: columns include action ('add'|'remove'), label_name (NULLABLE since migration 012), actor_username, created_at\n- resource_milestone_events: columns include action ('add'|'remove'), milestone_title (NULLABLE since migration 012), actor_username, created_at\n- issues table: created_at, author_username, title, web_url\n- merge_requests table: created_at, author_username, title, web_url, merged_at, updated_at\n- All timestamps are ms epoch UTC (stored as INTEGER)\n\n## Approach\n\nCreate `src/core/timeline_collect.rs`:\n\n```rust\nuse rusqlite::Connection;\nuse crate::core::timeline::{TimelineEvent, TimelineEventType, EntityRef, ExpandedEntityRef};\n\npub fn collect_events(\n conn: &Connection,\n seed_entities: &[EntityRef],\n expanded_entities: &[ExpandedEntityRef],\n evidence_notes: &[TimelineEvent], // from seed phase\n since_ms: Option, // --since filter\n limit: usize, // -n flag (default 100)\n) -> Result> { ... }\n```\n\n### Event Collection Per Entity\n\nFor each entity (seed + expanded), collect:\n\n1. **Creation event** (`Created`):\n ```sql\n -- Issues:\n SELECT created_at, author_username, title, web_url FROM issues WHERE id = ?1\n -- MRs:\n SELECT created_at, author_username, title, web_url FROM merge_requests WHERE id = ?1\n ```\n\n2. **State changes** (`StateChanged { state }`):\n ```sql\n SELECT state, actor_username, created_at FROM resource_state_events\n WHERE (issue_id = ?1 OR merge_request_id = ?1)\n AND (?2 IS NULL OR created_at >= ?2) -- since filter\n ORDER BY created_at ASC\n ```\n NOTE: For MRs, a state='merged' event also produces a separate Merged variant.\n\n3. **Label changes** (`LabelAdded`/`LabelRemoved`):\n ```sql\n SELECT action, label_name, actor_username, created_at FROM resource_label_events\n WHERE (issue_id = ?1 OR merge_request_id = ?1)\n AND (?2 IS NULL OR created_at >= ?2)\n ORDER BY created_at ASC\n ```\n Handle NULL label_name (deleted label): use \"[deleted label]\" as fallback.\n\n4. **Milestone changes** (`MilestoneSet`/`MilestoneRemoved`):\n ```sql\n SELECT action, milestone_title, actor_username, created_at FROM resource_milestone_events\n WHERE (issue_id = ?1 OR merge_request_id = ?1)\n AND (?2 IS NULL OR created_at >= ?2)\n ORDER BY created_at ASC\n ```\n Handle NULL milestone_title: use \"[deleted milestone]\" as fallback.\n\n5. **Merge event** (Merged, MR only):\n Derive from merge_requests.merged_at (preferred) OR resource_state_events WHERE state='merged'. Skip StateChanged when state='merged' — emit only the Merged variant.\n\n### Chronological Interleave\n\n```rust\nevents.sort(); // Uses Ord impl from bd-20e\nif let Some(since) = since_ms {\n events.retain(|e| e.timestamp >= since);\n}\nevents.truncate(limit);\n```\n\nRegister in `src/core/mod.rs`: `pub mod timeline_collect;`\n\n## Acceptance Criteria\n\n- [ ] Collects Created, StateChanged, LabelAdded/Removed, MilestoneSet/Removed, Merged, NoteEvidence events\n- [ ] Merged events deduplicated from StateChanged{merged} — emit only Merged variant\n- [ ] NULL label_name/milestone_title handled with fallback text\n- [ ] --since filter applied to all event types\n- [ ] Events sorted chronologically with stable tiebreak\n- [ ] Limit applied AFTER sorting\n- [ ] Evidence notes from seed phase included\n- [ ] is_seed correctly set based on entity source\n- [ ] Module registered in src/core/mod.rs\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/core/timeline_collect.rs` (NEW)\n- `src/core/mod.rs` (add `pub mod timeline_collect;`)\n\n## TDD Loop\n\nRED:\n- `test_collect_creation_event` - entity produces Created event\n- `test_collect_state_events` - state changes produce StateChanged events\n- `test_collect_merged_dedup` - state='merged' produces Merged not StateChanged\n- `test_collect_null_label_fallback` - NULL label_name uses fallback text\n- `test_collect_since_filter` - old events excluded\n- `test_collect_chronological_sort` - mixed entity events interleave correctly\n- `test_collect_respects_limit`\n\nTests need in-memory DB with migrations 001-014 applied.\n\nGREEN: Implement SQL queries and event assembly.\n\nVERIFY: `cargo test --lib -- timeline_collect`\n\n## Edge Cases\n\n- MR with merged_at=NULL and no state='merged' event: no Merged event emitted\n- Entity with 0 events in resource tables: only Created event returned\n- NULL actor_username: actor field is None\n- Timestamps at exact --since boundary: use >= (inclusive)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:33:08.703942Z","created_by":"tayloreernisse","updated_at":"2026-02-05T21:53:01.160429Z","closed_at":"2026-02-05T21:53:01.160380Z","close_reason":"Completed: Created src/core/timeline_collect.rs with event collection for Created, StateChanged, LabelAdded/Removed, MilestoneSet/Removed, Merged, NoteEvidence. Merged dedup (state=merged skipped in favor of Merged variant). NULL label/milestone fallbacks. Since filter, chronological sort, limit. 10 tests pass.","compaction_level":0,"original_size":0,"labels":["gate-3","phase-b","query"],"dependencies":[{"issue_id":"bd-3as","depends_on_id":"bd-1ep","type":"blocks","created_at":"2026-02-02T21:33:37.618171Z","created_by":"tayloreernisse"},{"issue_id":"bd-3as","depends_on_id":"bd-ike","type":"parent-child","created_at":"2026-02-02T21:33:08.705605Z","created_by":"tayloreernisse"},{"issue_id":"bd-3as","depends_on_id":"bd-ypa","type":"blocks","created_at":"2026-02-02T21:33:37.575585Z","created_by":"tayloreernisse"}]} {"id":"bd-3bo","title":"[CP1] gi count issues/discussions/notes commands","description":"Count entities in the database.\n\nCommands:\n- gi count issues → 'Issues: N'\n- gi count discussions --type=issue → 'Issue Discussions: N'\n- gi count notes --type=issue → 'Issue Notes: N (excluding M system)'\n\nFiles: src/cli/commands/count.ts\nDone when: Counts match expected values from GitLab","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T15:20:16.190875Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.156293Z","deleted_at":"2026-01-25T15:21:35.156290Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0} +{"id":"bd-3dum","title":"Orchestrator: status enrichment phase with transactional writes","description":"## Background\nThe orchestrator controls the sync pipeline. Status enrichment is a new Phase 1.5 that runs after issue ingestion but before discussion sync. It must be non-fatal — errors skip enrichment but don't crash the sync.\n\n## Approach\nAdd enrichment phase to ingest_project_issues_with_progress. Use client.graphql_client() factory. Look up project path from DB via .optional()? for non-fatal failure. Transactional writes via enrich_issue_statuses_txn() with two phases: clear stale, then apply new.\n\n## Files\n- src/ingestion/orchestrator.rs (enrichment phase + txn helper + IngestProjectResult fields + ProgressEvent variants)\n- src/cli/commands/ingest.rs (add match arms for new ProgressEvent variants)\n\n## Implementation\n\nIngestProjectResult new fields:\n statuses_enriched: usize, statuses_cleared: usize, statuses_seen: usize,\n statuses_without_widget: usize, partial_error_count: usize,\n first_partial_error: Option, status_enrichment_error: Option,\n status_enrichment_mode: String, status_unsupported_reason: Option\n Default: all 0/None/\"\" as appropriate\n\nProgressEvent new variants:\n StatusEnrichmentComplete { enriched: usize, cleared: usize }\n StatusEnrichmentSkipped\n\nPhase 1.5 logic (after ingest_issues, before discussion sync):\n 1. Check config.sync.fetch_work_item_status && !signal.is_cancelled()\n 2. If false: set mode=\"skipped\", emit StatusEnrichmentSkipped\n 3. Look up project path: conn.query_row(\"SELECT path_with_namespace FROM projects WHERE id = ?1\", [project_id], |r| r.get(0)).optional()?\n 4. If None: warn, set status_enrichment_error=\"project_path_missing\", emit StatusEnrichmentComplete{0,0}\n 5. Create graphql_client via client.graphql_client()\n 6. Call fetch_issue_statuses(&graphql_client, &project_path).await\n 7. On Ok: map unsupported_reason to mode/reason, call enrich_issue_statuses_txn(), set counters\n 8. On Err: warn, set status_enrichment_error, mode=\"fetched\"\n 9. Emit StatusEnrichmentComplete\n\nenrich_issue_statuses_txn(conn, project_id, statuses, all_fetched_iids, now_ms) -> Result<(usize, usize)>:\n Uses conn.unchecked_transaction() (conn is &Connection not &mut)\n Phase 1 (clear): UPDATE issues SET status_*=NULL, status_synced_at=now_ms WHERE project_id=? AND iid=? AND status_name IS NOT NULL — for IIDs in all_fetched_iids but NOT in statuses\n Phase 2 (apply): UPDATE issues SET status_name=?, status_category=?, status_color=?, status_icon_name=?, status_synced_at=now_ms WHERE project_id=? AND iid=?\n tx.commit(), return (enriched, cleared)\n\nIn src/cli/commands/ingest.rs progress callback, add arms:\n ProgressEvent::StatusEnrichmentComplete { enriched, cleared } => { ... }\n ProgressEvent::StatusEnrichmentSkipped => { ... }\n\n## Acceptance Criteria\n- [ ] Enrichment runs after ingest_issues, before discussion sync\n- [ ] Gated by config.sync.fetch_work_item_status\n- [ ] Project path missing -> skipped with error=\"project_path_missing\", sync continues\n- [ ] enrich_issue_statuses_txn correctly UPDATEs status columns + status_synced_at\n- [ ] Stale status cleared: issue in all_fetched_iids but not statuses -> NULL + synced_at set\n- [ ] Transaction rollback on failure: no partial updates\n- [ ] Idempotent: running twice with same data produces same result\n- [ ] GraphQL error: logged, enrichment_error captured, sync continues\n- [ ] ingest.rs compiles with new ProgressEvent arms\n- [ ] cargo check --all-targets passes\n\n## TDD Loop\nRED: test_enrich_issue_statuses_txn, test_enrich_skips_unknown_iids, test_enrich_clears_removed_status, test_enrich_transaction_rolls_back_on_failure, test_enrich_idempotent_across_two_runs, test_enrich_sets_synced_at_on_clear, test_enrichment_error_captured_in_result, test_project_path_missing_skips_enrichment\n Tests use in-memory DB with migration 021 applied\nGREEN: Implement enrichment phase + txn helper + result fields + progress arms\nVERIFY: cargo test enrich && cargo test orchestrator\n\n## Edge Cases\n- unchecked_transaction() needed because conn is &Connection not &mut Connection\n- .optional()? requires use rusqlite::OptionalExtension\n- status_synced_at is set on BOTH clear and apply operations (not NULL on clear)\n- Clear SQL has WHERE status_name IS NOT NULL to avoid counting already-cleared rows\n- Progress callback match must be updated in SAME batch as enum change (compile error otherwise)\n- status_enrichment_mode must be set in ALL code paths (fetched/unsupported/skipped)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-11T06:42:11.254917Z","created_by":"tayloreernisse","updated_at":"2026-02-11T07:21:33.419310Z","closed_at":"2026-02-11T07:21:33.419268Z","close_reason":"Implemented by agent swarm — all quality gates pass (595 tests, 0 failures)","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3dum","depends_on_id":"bd-1gvg","type":"blocks","created_at":"2026-02-11T06:42:43.501683Z","created_by":"tayloreernisse"},{"issue_id":"bd-3dum","depends_on_id":"bd-2jzn","type":"blocks","created_at":"2026-02-11T06:42:43.553793Z","created_by":"tayloreernisse"},{"issue_id":"bd-3dum","depends_on_id":"bd-2y79","type":"parent-child","created_at":"2026-02-11T06:42:11.257123Z","created_by":"tayloreernisse"}]} {"id":"bd-3er","title":"OBSERV Epic: Phase 3 - Performance Metrics Collection","description":"StageTiming struct, custom MetricsLayer tracing subscriber layer, span-to-metrics extraction, robot JSON enrichment with meta.stages, human-readable timing summary.\n\nDepends on: Phase 2 (spans must exist to extract timing from)\nUnblocks: Phase 4 (sync history needs Vec to store)\n\nFiles: src/core/metrics.rs (new), src/cli/commands/sync.rs, src/cli/commands/ingest.rs, src/main.rs\n\nAcceptance criteria (PRD Section 6.3):\n- lore --robot sync includes meta.run_id and meta.stages array\n- Each stage has name, elapsed_ms, items_processed\n- Top-level stages have sub_stages arrays\n- Interactive sync prints timing summary table\n- Zero-value fields omitted from JSON","status":"closed","priority":2,"issue_type":"epic","created_at":"2026-02-04T15:53:27.415566Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:32:56.743477Z","closed_at":"2026-02-04T17:32:56.743430Z","close_reason":"All Phase 3 tasks complete: StageTiming struct, MetricsLayer, span field recording, robot JSON enrichment with stages, and human-readable timing summary","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-3er","depends_on_id":"bd-2ni","type":"blocks","created_at":"2026-02-04T15:55:19.101775Z","created_by":"tayloreernisse"}]} {"id":"bd-3eu","title":"Implement hybrid search with adaptive recall","description":"## Background\nHybrid search is the top-level search orchestrator that combines FTS5 lexical results with sqlite-vec semantic results via RRF ranking. It supports three modes (Lexical, Semantic, Hybrid) and implements adaptive recall (wider initial fetch when filters are applied) and graceful degradation (falls back to FTS when Ollama is unavailable). All modes use RRF for consistent --explain output.\n\n## Approach\nCreate `src/search/hybrid.rs` per PRD Section 5.3.\n\n**Key types:**\n```rust\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum SearchMode {\n Hybrid, // Vector + FTS with RRF\n Lexical, // FTS only\n Semantic, // Vector only\n}\n\nimpl SearchMode {\n pub fn from_str(s: &str) -> Option {\n match s.to_lowercase().as_str() {\n \"hybrid\" => Some(Self::Hybrid),\n \"lexical\" | \"fts\" => Some(Self::Lexical),\n \"semantic\" | \"vector\" => Some(Self::Semantic),\n _ => None,\n }\n }\n\n pub fn as_str(&self) -> &'static str {\n match self {\n Self::Hybrid => \"hybrid\",\n Self::Lexical => \"lexical\",\n Self::Semantic => \"semantic\",\n }\n }\n}\n\npub struct HybridResult {\n pub document_id: i64,\n pub score: f64, // Normalized RRF score (0-1)\n pub vector_rank: Option,\n pub fts_rank: Option,\n pub rrf_score: f64, // Raw RRF score\n}\n```\n\n**Core function (ASYNC, PRD-exact signature):**\n```rust\npub async fn search_hybrid(\n conn: &Connection,\n client: Option<&OllamaClient>, // None if Ollama unavailable\n ollama_base_url: Option<&str>, // For actionable error messages\n query: &str,\n mode: SearchMode,\n filters: &SearchFilters,\n fts_mode: FtsQueryMode,\n) -> Result<(Vec, Vec)>\n```\n\n**IMPORTANT — client is `Option<&OllamaClient>`:** This enables graceful degradation. When Ollama is unavailable, the caller passes `None` and hybrid mode falls back to FTS-only with a warning. The `ollama_base_url` is separate so error messages can include it even when client is None.\n\n**Adaptive recall constants (PRD Section 5.3):**\n```rust\nconst BASE_RECALL_MIN: usize = 50;\nconst FILTERED_RECALL_MIN: usize = 200;\nconst RECALL_CAP: usize = 1500;\n```\n\n**Recall formula:**\n```rust\nlet requested = filters.clamp_limit();\nlet top_k = if filters.has_any_filter() {\n (requested * 50).max(FILTERED_RECALL_MIN).min(RECALL_CAP)\n} else {\n (requested * 10).max(BASE_RECALL_MIN).min(RECALL_CAP)\n};\n```\n\n**Mode behavior:**\n- **Lexical:** FTS only -> rank_rrf with empty vector list (single-list RRF)\n- **Semantic:** Vector only -> requires client (error if None) -> rank_rrf with empty FTS list\n- **Hybrid:** Both FTS + vector -> rank_rrf with both lists\n- **Hybrid with client=None:** Graceful degradation to Lexical with warning, NOT error\n\n**Graceful degradation logic:**\n```rust\nSearchMode::Hybrid => {\n let fts_results = search_fts(conn, query, top_k, fts_mode)?;\n let fts_tuples: Vec<_> = fts_results.iter().map(|r| (r.document_id, r.rank)).collect();\n\n match client {\n Some(client) => {\n let query_embedding = client.embed_batch(vec\\![query.to_string()]).await?;\n let embedding = query_embedding.into_iter().next().unwrap();\n let vec_results = search_vector(conn, &embedding, top_k)?;\n let vec_tuples: Vec<_> = vec_results.iter().map(|r| (r.document_id, r.distance)).collect();\n let ranked = rank_rrf(&vec_tuples, &fts_tuples);\n // ... map to HybridResult\n Ok((results, warnings))\n }\n None => {\n warnings.push(\"Ollama unavailable, falling back to lexical search\".into());\n let ranked = rank_rrf(&[], &fts_tuples);\n // ... map to HybridResult\n Ok((results, warnings))\n }\n }\n}\n```\n\n## Acceptance Criteria\n- [ ] Function is `async` (per PRD — Ollama client methods are async)\n- [ ] Signature takes `client: Option<&OllamaClient>` (not required)\n- [ ] Signature takes `ollama_base_url: Option<&str>` for actionable error messages\n- [ ] Returns `(Vec, Vec)` — results + warnings\n- [ ] Lexical mode: FTS-only results ranked via RRF (single list)\n- [ ] Semantic mode: vector-only results ranked via RRF; error if client is None\n- [ ] Hybrid mode: both FTS + vector results merged via RRF\n- [ ] Graceful degradation: client=None in Hybrid falls back to FTS with warning (not error)\n- [ ] Adaptive recall: unfiltered max(50, limit*10), filtered max(200, limit*50), capped 1500\n- [ ] All modes produce consistent --explain output (vector_rank, fts_rank, rrf_score)\n- [ ] SearchMode::from_str accepts aliases: \"fts\" for Lexical, \"vector\" for Semantic\n- [ ] `cargo build` succeeds\n\n## Files\n- `src/search/hybrid.rs` — new file\n- `src/search/mod.rs` — add `pub use hybrid::{search_hybrid, HybridResult, SearchMode};`\n\n## TDD Loop\nRED: Tests (some integration, some unit):\n- `test_lexical_mode` — FTS results only\n- `test_semantic_mode` — vector results only\n- `test_hybrid_mode` — both lists merged\n- `test_graceful_degradation` — None client falls back to FTS with warning in warnings vec\n- `test_adaptive_recall_unfiltered` — recall = max(50, limit*10)\n- `test_adaptive_recall_filtered` — recall = max(200, limit*50)\n- `test_recall_cap` — never exceeds 1500\n- `test_search_mode_from_str` — \"hybrid\", \"lexical\", \"fts\", \"semantic\", \"vector\", invalid\nGREEN: Implement search_hybrid\nVERIFY: `cargo test hybrid`\n\n## Edge Cases\n- Both FTS and vector return zero results: empty output (not error)\n- FTS returns results but vector returns empty: RRF still works (single-list)\n- Very high limit (100) with filters: recall = min(5000, 1500) = 1500\n- Semantic mode with client=None: error (OllamaUnavailable), not degradation\n- Semantic mode with 0% coverage: return LoreError::EmbeddingsNotBuilt","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:26:50.343002Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:56:16.631748Z","closed_at":"2026-01-30T17:56:16.631682Z","close_reason":"Implemented hybrid search with 3 modes (lexical/semantic/hybrid), graceful degradation when Ollama unavailable, adaptive recall (50-1500), RRF fusion. 6 tests pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3eu","depends_on_id":"bd-1k1","type":"blocks","created_at":"2026-01-30T15:29:24.913458Z","created_by":"tayloreernisse"},{"issue_id":"bd-3eu","depends_on_id":"bd-335","type":"blocks","created_at":"2026-01-30T15:29:25.025502Z","created_by":"tayloreernisse"},{"issue_id":"bd-3eu","depends_on_id":"bd-3ez","type":"blocks","created_at":"2026-01-30T15:29:24.987809Z","created_by":"tayloreernisse"},{"issue_id":"bd-3eu","depends_on_id":"bd-bjo","type":"blocks","created_at":"2026-01-30T15:29:24.950761Z","created_by":"tayloreernisse"}]} {"id":"bd-3ez","title":"Implement RRF ranking","description":"## Background\nReciprocal Rank Fusion (RRF) combines results from multiple retrieval systems (FTS5 lexical + sqlite-vec semantic) into a single ranked list without requiring score normalization. Documents appearing in both lists rank higher than single-list documents. This is the core ranking algorithm for hybrid search in Gate B.\n\n## Approach\nCreate \\`src/search/rrf.rs\\` per PRD Section 5.2.\n\n```rust\nuse std::collections::HashMap;\n\nconst RRF_K: f64 = 60.0;\n\npub struct RrfResult {\n pub document_id: i64,\n pub rrf_score: f64, // Raw RRF score\n pub normalized_score: f64, // Normalized to 0-1 (rrf_score / max)\n pub vector_rank: Option, // 1-indexed rank in vector list\n pub fts_rank: Option, // 1-indexed rank in FTS list\n}\n\n/// Input: tuples of (document_id, score/distance) — already sorted by retriever.\n/// Ranks are 1-indexed (first result = rank 1).\n/// Score = sum of 1/(k + rank) for each list containing the document.\npub fn rank_rrf(\n vector_results: &[(i64, f64)], // (doc_id, distance)\n fts_results: &[(i64, f64)], // (doc_id, bm25_score)\n) -> Vec\n```\n\n**Algorithm (per PRD):**\n1. Build HashMap\n2. For each vector result at position i: score += 1/(K + (i+1)), record vector_rank = i+1 (**1-indexed**)\n3. For each FTS result at position i: score += 1/(K + (i+1)), record fts_rank = i+1 (**1-indexed**)\n4. Sort descending by rrf_score\n5. Normalize: each result.normalized_score = result.rrf_score / max_score (best = 1.0)\n\n**Key PRD details:**\n- Ranks are **1-indexed** (rank 1 = best, not rank 0)\n- Input is \\`&[(i64, f64)]\\` tuples, NOT custom structs\n- Output has both \\`rrf_score\\` (raw) and \\`normalized_score\\` (0-1)\n\n## Acceptance Criteria\n- [ ] Documents in both lists score higher than single-list documents\n- [ ] Single-list documents are included (not dropped)\n- [ ] Ranks are 1-indexed (first element = rank 1)\n- [ ] Raw RRF score available in rrf_score field\n- [ ] Normalized score: best = 1.0, all in [0, 1]\n- [ ] Results sorted descending by rrf_score\n- [ ] vector_rank and fts_rank tracked per result for --explain\n- [ ] Empty input lists handled (return empty)\n- [ ] One empty list + one non-empty returns results from non-empty list\n\n## Files\n- \\`src/search/rrf.rs\\` — new file\n- \\`src/search/mod.rs\\` — add \\`mod rrf; pub use rrf::{rank_rrf, RrfResult};\\`\n\n## TDD Loop\nRED: Tests in \\`#[cfg(test)] mod tests\\`:\n- \\`test_dual_list_ranks_higher\\` — doc in both lists scores > doc in one list\n- \\`test_single_list_included\\` — FTS-only and vector-only docs appear\n- \\`test_normalization\\` — best score is 1.0, all in [0, 1]\n- \\`test_empty_inputs\\` — empty returns empty\n- \\`test_ranks_are_1_indexed\\` — verify vector_rank/fts_rank start at 1\n- \\`test_raw_and_normalized_scores\\` — both fields populated correctly\nGREEN: Implement rank_rrf()\nVERIFY: \\`cargo test rrf\\`\n\n## Edge Cases\n- Duplicate document_id within same list: shouldn't happen, use first occurrence\n- Single result in one list, zero in other: normalized_score = 1.0\n- Very large input lists: HashMap handles efficiently","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:26:50.309012Z","created_by":"tayloreernisse","updated_at":"2026-01-30T16:53:04.128560Z","closed_at":"2026-01-30T16:53:04.128498Z","close_reason":"Completed: RRF ranking with 1-indexed ranks, raw+normalized scores, vector_rank/fts_rank provenance, 7 tests pass","compaction_level":0,"original_size":0} +{"id":"bd-3hjh","title":"Quality gates: cargo check, clippy, fmt, test","description":"## Background\nFinal verification that all implementation beads integrate cleanly. Must pass all quality gates before the feature is considered complete.\n\n## Approach\nRun all 4 quality gate commands. Fix any issues discovered.\n\n## Commands (in order)\n1. cargo check --all-targets (zero errors)\n2. cargo clippy --all-targets -- -D warnings (pedantic + nursery clean)\n3. cargo fmt --check (formatted)\n4. cargo test (all green, including all 42 new tests)\n\n## Acceptance Criteria\n- [ ] cargo check --all-targets: exit 0\n- [ ] cargo clippy --all-targets -- -D warnings: exit 0\n- [ ] cargo fmt --check: exit 0\n- [ ] cargo test: all pass (0 failures)\n- [ ] All 42 new tests from the plan are present and green\n\n## Known Gotchas from Plan's Trial Run\n- clippy::items_after_test_module: ansi256_from_rgb must be BEFORE #[cfg(test)]\n- clippy::collapsible_if: use let-chain syntax (if x && let ...)\n- clippy::manual_range_contains: use (16..=231).contains(&blue)\n- r##\"...\"## needed for test JSON with hex colors","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-11T06:42:34.364266Z","created_by":"tayloreernisse","updated_at":"2026-02-11T07:21:33.423111Z","closed_at":"2026-02-11T07:21:33.423074Z","close_reason":"Implemented by agent swarm — all quality gates pass (595 tests, 0 failures)","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3hjh","depends_on_id":"bd-1b91","type":"blocks","created_at":"2026-02-11T06:42:47.002789Z","created_by":"tayloreernisse"},{"issue_id":"bd-3hjh","depends_on_id":"bd-2sr2","type":"blocks","created_at":"2026-02-11T06:42:47.117015Z","created_by":"tayloreernisse"},{"issue_id":"bd-3hjh","depends_on_id":"bd-2y79","type":"parent-child","created_at":"2026-02-11T06:42:34.365883Z","created_by":"tayloreernisse"},{"issue_id":"bd-3hjh","depends_on_id":"bd-3a4k","type":"blocks","created_at":"2026-02-11T06:42:47.065086Z","created_by":"tayloreernisse"}]} {"id":"bd-3hy","title":"[CP1] Test fixtures for mocked GitLab responses","description":"Create mock response files for integration tests using wiremock.\n\n## Fixtures to Create\n\n### tests/fixtures/gitlab_issue.json\nSingle issue with labels:\n- id, iid, project_id, title, description, state\n- author object\n- labels array (string names)\n- timestamps\n- web_url\n\n### tests/fixtures/gitlab_issues_page.json\nArray of issues simulating paginated response:\n- 3-5 issues with varying states\n- Mix of labels\n\n### tests/fixtures/gitlab_discussion.json\nSingle discussion:\n- id (string)\n- individual_note: false\n- notes array with 2+ notes\n- Include one system note\n\n### tests/fixtures/gitlab_discussions_page.json\nArray of discussions:\n- Mix of individual_note true/false\n- Include resolvable/resolved examples\n\n## Edge Cases to Cover\n- Issue with no labels (empty array)\n- Issue with labels_details (ignored in CP1)\n- Discussion with individual_note=true (single note)\n- System notes with system=true\n- Resolvable notes\n\nFiles: tests/fixtures/gitlab_issue.json, gitlab_issues_page.json, gitlab_discussion.json, gitlab_discussions_page.json\nDone when: wiremock handlers can use fixtures for deterministic tests","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T16:59:01.206436Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.991367Z","deleted_at":"2026-01-25T17:02:01.991362Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-3ia","title":"Fetch closes_issues API and populate entity_references","description":"## Background\nGET /projects/:id/merge_requests/:iid/closes_issues returns issues that will close when MR merges. This is the most reliable source for MR→issue relationships. Uses the generic dependent fetch queue (job_type = 'mr_closes_issues').\n\n## Approach\n\n**1. Add API endpoint to GitLab client (src/gitlab/client.rs):**\n```rust\n/// Fetch issues that will be closed when this MR merges.\npub async fn fetch_mr_closes_issues(\n &self, \n project_id: i64, \n iid: i64\n) -> Result>\n```\n\nNew type in src/gitlab/types.rs:\n```rust\n#[derive(Debug, Clone, Deserialize)]\npub struct GitLabIssueRef {\n pub id: i64,\n pub iid: i64,\n pub project_id: i64,\n pub title: String,\n pub state: String,\n pub web_url: String,\n}\n```\n\nURL: `GET /api/v4/projects/{project_id}/merge_requests/{iid}/closes_issues?per_page=100`\n\n**2. Enqueue jobs during MR ingestion:**\nIn orchestrator.rs, after MR upsert:\n```rust\nenqueue_job(conn, project_id, \"merge_request\", iid, local_id, \"mr_closes_issues\", None)?;\n```\n\nThis is always enqueued (not gated by a config flag) because cross-reference data is fundamental to all temporal queries.\n\n**3. Process jobs in drain step:**\nIn the drain dispatcher (from bd-1ep), handle \"mr_closes_issues\" job_type:\n```rust\nlet closes_issues = client.fetch_mr_closes_issues(gitlab_project_id, job.entity_iid).await?;\nfor issue_ref in &closes_issues {\n let target_id = resolve_issue_local_id(conn, project_id, issue_ref.iid);\n insert_entity_reference(conn, EntityReference {\n source_entity_type: \"merge_request\",\n source_entity_id: job.entity_local_id,\n target_entity_type: \"issue\",\n target_entity_id: target_id, // Some(id) or None for cross-project\n target_project_path: if target_id.is_none() { Some(resolve_project_path(issue_ref.project_id)) } else { None },\n target_entity_iid: if target_id.is_none() { Some(issue_ref.iid) } else { None },\n reference_type: \"closes\",\n source_method: \"api_closes_issues\",\n created_at: None,\n })?;\n}\n```\n\n**4. Insert helper for entity_references:**\nAdd to src/core/references.rs:\n```rust\npub fn insert_entity_reference(conn: &Connection, ref_: &EntityReference) -> Result\n// INSERT OR IGNORE, returns true if inserted\n```\n\n## Acceptance Criteria\n- [ ] closes_issues API called for all MRs during sync\n- [ ] Entity references created with reference_type='closes', source_method='api_closes_issues'\n- [ ] Source = MR, target = issue (correct directionality)\n- [ ] Cross-project issues stored as unresolved (target_entity_id=NULL, target_project_path set)\n- [ ] Idempotent: re-sync doesn't create duplicate references\n- [ ] 404 on deleted MR handled gracefully (fail_job)\n\n## Files\n- src/gitlab/client.rs (add fetch_mr_closes_issues)\n- src/gitlab/types.rs (add GitLabIssueRef)\n- src/core/references.rs (add insert_entity_reference helper)\n- src/ingestion/orchestrator.rs (enqueue mr_closes_issues jobs)\n- src/core/drain.rs or sync.rs (handle mr_closes_issues in drain dispatcher)\n\n## TDD Loop\nRED: tests/references_tests.rs:\n- `test_closes_issues_creates_references` - mock closes_issues response, verify entity_references rows\n- `test_closes_issues_cross_project_unresolved` - issue from different project stored as unresolved\n- `test_closes_issues_idempotent` - process same job twice, verify no duplicates\n\ntests/gitlab_types_tests.rs:\n- `test_deserialize_issue_ref` - verify GitLabIssueRef deserialization\n\nGREEN: Implement API endpoint, enqueue hook, drain handler, insert helper\n\nVERIFY: `cargo test references -- --nocapture && cargo test gitlab_types -- --nocapture`\n\n## Edge Cases\n- closes_issues API returns issues from OTHER projects (cross-project closing) — must check if issue is in local DB\n- Empty response (MR doesn't close any issues) — no refs created, job still completed\n- MR may close the same issue via description (\"Closes #123\") and via commits — API deduplicates, but our INSERT OR IGNORE handles it too\n- The closes_issues API may return stale data for draft MRs (issues that *would* close but haven't yet)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:32:33.561956Z","created_by":"tayloreernisse","updated_at":"2026-02-04T20:15:54.763773Z","closed_at":"2026-02-04T20:15:54.763643Z","compaction_level":0,"original_size":0,"labels":["api","gate-2","phase-b"],"dependencies":[{"issue_id":"bd-3ia","depends_on_id":"bd-1se","type":"parent-child","created_at":"2026-02-02T21:32:33.563366Z","created_by":"tayloreernisse"},{"issue_id":"bd-3ia","depends_on_id":"bd-hu3","type":"blocks","created_at":"2026-02-02T22:41:50.613776Z","created_by":"tayloreernisse"},{"issue_id":"bd-3ia","depends_on_id":"bd-tir","type":"blocks","created_at":"2026-02-02T21:32:42.860463Z","created_by":"tayloreernisse"}]} {"id":"bd-3ir","title":"Add database migration 006_merge_requests.sql","description":"## Background\nFoundation for all CP2 MR features. This migration defines the schema that all other MR components depend on. Must complete BEFORE any other CP2 work can proceed.\n\n## Approach\nCreate migration file that adds:\n1. `merge_requests` table with all CP2 fields\n2. `mr_labels`, `mr_assignees`, `mr_reviewers` junction tables\n3. Indexes on discussions for MR queries\n4. DiffNote position columns on notes table\n\n## Files\n- `migrations/006_merge_requests.sql` - New migration file\n- `src/core/db.rs` - Update MIGRATIONS const to include version 6\n\n## Acceptance Criteria\n- [ ] Migration file exists at `migrations/006_merge_requests.sql`\n- [ ] `merge_requests` table has columns: id, gitlab_id, project_id, iid, title, description, state, draft, author_username, source_branch, target_branch, head_sha, references_short, references_full, detailed_merge_status, merge_user_username, created_at, updated_at, merged_at, closed_at, last_seen_at, discussions_synced_for_updated_at, discussions_sync_last_attempt_at, discussions_sync_attempts, discussions_sync_last_error, web_url, raw_payload_id\n- [ ] `mr_labels` junction table exists with (merge_request_id, label_id) PK\n- [ ] `mr_assignees` junction table exists with (merge_request_id, username) PK\n- [ ] `mr_reviewers` junction table exists with (merge_request_id, username) PK\n- [ ] `idx_discussions_mr_id` and `idx_discussions_mr_resolved` indexes exist\n- [ ] `notes` table has new columns: position_type, position_line_range_start, position_line_range_end, position_base_sha, position_start_sha, position_head_sha\n- [ ] `gi doctor` runs without migration errors\n- [ ] `cargo test` passes\n\n## TDD Loop\nRED: Cannot open DB with version 6 schema\nGREEN: Add migration file with full SQL\nVERIFY: `cargo run -- doctor` shows healthy DB\n\n## SQL Reference (from PRD)\n```sql\n-- Merge requests table\nCREATE TABLE merge_requests (\n id INTEGER PRIMARY KEY,\n gitlab_id INTEGER UNIQUE NOT NULL,\n project_id INTEGER NOT NULL REFERENCES projects(id),\n iid INTEGER NOT NULL,\n title TEXT,\n description TEXT,\n state TEXT, -- opened | merged | closed | locked\n draft INTEGER NOT NULL DEFAULT 0, -- SQLite boolean\n author_username TEXT,\n source_branch TEXT,\n target_branch TEXT,\n head_sha TEXT,\n references_short TEXT,\n references_full TEXT,\n detailed_merge_status TEXT,\n merge_user_username TEXT,\n created_at INTEGER, -- ms epoch UTC\n updated_at INTEGER,\n merged_at INTEGER,\n closed_at INTEGER,\n last_seen_at INTEGER NOT NULL,\n discussions_synced_for_updated_at INTEGER,\n discussions_sync_last_attempt_at INTEGER,\n discussions_sync_attempts INTEGER DEFAULT 0,\n discussions_sync_last_error TEXT,\n web_url TEXT,\n raw_payload_id INTEGER REFERENCES raw_payloads(id)\n);\nCREATE INDEX idx_mrs_project_updated ON merge_requests(project_id, updated_at);\nCREATE UNIQUE INDEX uq_mrs_project_iid ON merge_requests(project_id, iid);\n-- ... (see PRD for full index list)\n\n-- Junction tables\nCREATE TABLE mr_labels (\n merge_request_id INTEGER REFERENCES merge_requests(id) ON DELETE CASCADE,\n label_id INTEGER REFERENCES labels(id) ON DELETE CASCADE,\n PRIMARY KEY(merge_request_id, label_id)\n);\n\nCREATE TABLE mr_assignees (\n merge_request_id INTEGER REFERENCES merge_requests(id) ON DELETE CASCADE,\n username TEXT NOT NULL,\n PRIMARY KEY(merge_request_id, username)\n);\n\nCREATE TABLE mr_reviewers (\n merge_request_id INTEGER REFERENCES merge_requests(id) ON DELETE CASCADE,\n username TEXT NOT NULL,\n PRIMARY KEY(merge_request_id, username)\n);\n\n-- DiffNote position columns (ALTER TABLE)\nALTER TABLE notes ADD COLUMN position_type TEXT;\nALTER TABLE notes ADD COLUMN position_line_range_start INTEGER;\nALTER TABLE notes ADD COLUMN position_line_range_end INTEGER;\nALTER TABLE notes ADD COLUMN position_base_sha TEXT;\nALTER TABLE notes ADD COLUMN position_start_sha TEXT;\nALTER TABLE notes ADD COLUMN position_head_sha TEXT;\n\nINSERT INTO schema_version (version, applied_at, description)\nVALUES (6, strftime('%s', 'now') * 1000, 'Merge requests, MR labels, assignees, reviewers');\n```\n\n## Edge Cases\n- SQLite does not support ADD CONSTRAINT - FK defined as nullable in CP1\n- `locked` state is transitional (merge-in-progress) - store as first-class\n- discussions_synced_for_updated_at prevents redundant discussion refetch","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-26T22:06:40.101470Z","created_by":"tayloreernisse","updated_at":"2026-01-27T00:06:43.899079Z","closed_at":"2026-01-27T00:06:43.898875Z","close_reason":"Migration 006_merge_requests.sql created and verified. Schema v6 applied successfully with all tables, indexes, and position columns.","compaction_level":0,"original_size":0} diff --git a/.beads/last-touched b/.beads/last-touched index a7a6ff8..a87f638 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -bd-3qn6 +bd-3hjh diff --git a/.claude/hooks/on-file-write.sh b/.claude/hooks/on-file-write.sh index f98b260..871cf94 100755 --- a/.claude/hooks/on-file-write.sh +++ b/.claude/hooks/on-file-write.sh @@ -1,6 +1,11 @@ #!/bin/bash # Ultimate Bug Scanner - Claude Code Hook # Runs on every file save for UBS-supported languages (JS/TS, Python, C/C++, Rust, Go, Java, Ruby) +# Claude Code hooks receive context as JSON on stdin. + +INPUT=$(cat) +FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty') +CWD=$(echo "$INPUT" | jq -r '.cwd // empty') if [[ "$FILE_PATH" =~ \.(js|jsx|ts|tsx|mjs|cjs|py|pyw|pyi|c|cc|cpp|cxx|h|hh|hpp|hxx|rs|go|java|rb)$ ]]; then echo "🔬 Running bug scanner..." @@ -8,5 +13,5 @@ if [[ "$FILE_PATH" =~ \.(js|jsx|ts|tsx|mjs|cjs|py|pyw|pyi|c|cc|cpp|cxx|h|hh|hpp| echo "⚠️ 'ubs' not found in PATH; install it before using this hook." >&2 exit 0 fi - ubs "${PROJECT_DIR}" --ci 2>&1 | head -50 + ubs "$FILE_PATH" --ci 2>&1 | head -50 fi