diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 7da2cc9..c91d381 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -17,7 +17,7 @@ {"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-1cjx","title":"lore drift: detect discussion divergence from original intent","description":"## Background\nDetect when a discussion thread has evolved away from the original issue description. Surfaces hidden scope creep. No existing tool does this — not GitLab, Jira, Linear, or any CLI.\n\n## Current Infrastructure (Verified 2026-02-12)\n- Embeddings: nomic-embed-text model, 768 dimensions, stored in embedding_metadata + vec0 tables\n- OllamaClient::embed_batch() at src/embedding/ollama.rs:103 — batch embedding\n- notes table: 282K rows with body, author, created_at, is_system, discussion_id\n- issues table: description column contains original intent text\n- CHUNK_MAX_BYTES = 1500 bytes for embedding input\n- No `strip_markdown()` utility exists in the codebase — must be written (see Edge Cases)\n\n## Dependencies\nThis command is standalone. It only requires:\n- OllamaClient (already shipped at src/embedding/ollama.rs) for embedding computation\n- notes + discussions tables (already in DB since migration 001/004)\n- issues table (already in DB since migration 002)\n\nNo dependency on hybrid search (bd-1ksf) or per-note search (bd-2l3s). Drift embeds on-the-fly.\n\n## Algorithm\n\n### Step 1: Embed issue description\n```rust\nlet desc_text = issue.description.unwrap_or_default();\nif desc_text.len() < 20 {\n // Too short for meaningful drift analysis\n return Ok(DriftResponse::no_drift(\"Description too short for analysis\"));\n}\nlet desc_embedding = client.embed_batch(&[&desc_text]).await?[0].clone();\n```\n\n### Step 2: Get non-system notes chronologically\n```sql\nSELECT n.id, n.body, n.author_username, n.created_at\nFROM notes n\nJOIN discussions d ON n.discussion_id = d.id\nWHERE d.noteable_type = 'Issue' AND d.noteable_id = ?\n AND n.is_system = 0\n AND LENGTH(n.body) >= 20\nORDER BY n.created_at ASC\nLIMIT 200 -- cap for performance\n```\n\n### Step 3: Embed each note\n```rust\nlet note_texts: Vec<&str> = notes.iter().map(|n| n.body.as_str()).collect();\n// Batch in groups of 32 (BATCH_SIZE from embedding pipeline)\nlet note_embeddings = client.embed_batch(¬e_texts).await?;\n```\n\n### Step 4: Compute cosine similarity curve\n```rust\n/// Cosine similarity between two embedding vectors.\n/// Returns value in [-1, 1] range; higher = more similar.\n/// Place in src/embedding/similarity.rs for reuse by related (bd-8con) and drift.\npub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {\n debug_assert_eq!(a.len(), b.len(), \"embedding dimensions must match\");\n let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();\n let norm_a: f32 = a.iter().map(|x| x * x).sum::().sqrt();\n let norm_b: f32 = b.iter().map(|x| x * x).sum::().sqrt();\n if norm_a == 0.0 || norm_b == 0.0 {\n return 0.0; // zero vector = no similarity\n }\n dot / (norm_a * norm_b)\n}\n\nlet similarity_curve: Vec = notes.iter().zip(¬e_embeddings)\n .enumerate()\n .map(|(i, (note, emb))| SimilarityPoint {\n note_index: i,\n note_id: note.id,\n similarity: cosine_similarity(&desc_embedding, emb),\n author: note.author.clone(),\n created_at: note.created_at.clone(),\n })\n .collect();\n```\n\n### Step 5: Detect drift via sliding window\n```rust\nconst DEFAULT_THRESHOLD: f32 = 0.4;\nconst WINDOW_SIZE: usize = 3;\n\nfn detect_drift(curve: &[SimilarityPoint], threshold: f32) -> Option<&SimilarityPoint> {\n if curve.len() < WINDOW_SIZE {\n return None; // need minimum 3 notes for window\n }\n for window in curve.windows(WINDOW_SIZE) {\n let avg: f32 = window.iter().map(|p| p.similarity).sum::() / WINDOW_SIZE as f32;\n if avg < threshold {\n return Some(&window[0]); // first note in drifting window\n }\n }\n None\n}\n```\n\n### Step 6: Extract drift topics (simple term frequency v1)\n```rust\n/// Simple markdown stripping for embedding quality.\n/// Remove code blocks (```...```), inline code (`...`), links [text](url),\n/// block quotes (> ...), and HTML tags (<...>).\n/// This function must be written — no existing utility in the codebase.\nfn strip_markdown(text: &str) -> String {\n // Phase 1: Remove fenced code blocks (```...```)\n let re_code_block = regex::Regex::new(r\"(?s)```.*?```\").unwrap();\n let text = re_code_block.replace_all(text, \"\");\n // Phase 2: Remove inline code (`...`)\n let re_inline = regex::Regex::new(r\"`[^`]+`\").unwrap();\n let text = re_inline.replace_all(&text, \"\");\n // Phase 3: Remove markdown links, keep text: [text](url) -> text\n let re_link = regex::Regex::new(r\"\\[([^\\]]+)\\]\\([^)]+\\)\").unwrap();\n let text = re_link.replace_all(&text, \"$1\");\n // Phase 4: Remove block quotes\n let text = text.lines()\n .filter(|l| !l.trim_start().starts_with('>'))\n .collect::>()\n .join(\"\\n\");\n // Phase 5: Remove HTML tags\n let re_html = regex::Regex::new(r\"<[^>]+>\").unwrap();\n re_html.replace_all(&text, \"\").to_string()\n}\n\nfn extract_drift_topics(\n notes_after_drift: &[Note],\n description_words: &HashSet,\n) -> Vec {\n let stopwords: HashSet<&str> = [\n \"the\", \"a\", \"an\", \"is\", \"are\", \"was\", \"were\", \"be\", \"been\", \"being\",\n \"have\", \"has\", \"had\", \"do\", \"does\", \"did\", \"will\", \"would\", \"could\",\n \"should\", \"may\", \"might\", \"shall\", \"can\", \"need\", \"dare\", \"ought\",\n \"used\", \"to\", \"of\", \"in\", \"for\", \"on\", \"with\", \"at\", \"by\", \"from\",\n \"as\", \"into\", \"through\", \"during\", \"before\", \"after\", \"above\", \"below\",\n \"between\", \"out\", \"off\", \"over\", \"under\", \"again\", \"further\", \"then\",\n \"once\", \"here\", \"there\", \"when\", \"where\", \"why\", \"how\", \"all\", \"each\",\n \"every\", \"both\", \"few\", \"more\", \"most\", \"other\", \"some\", \"such\", \"no\",\n \"nor\", \"not\", \"only\", \"own\", \"same\", \"so\", \"than\", \"too\", \"very\",\n \"just\", \"because\", \"but\", \"and\", \"or\", \"if\", \"while\", \"that\", \"this\",\n \"these\", \"those\", \"it\", \"its\", \"they\", \"them\", \"their\", \"we\", \"our\",\n \"you\", \"your\", \"he\", \"she\", \"his\", \"her\", \"what\", \"which\", \"who\",\n ].into_iter().collect();\n\n let mut term_freq: HashMap = HashMap::new();\n for note in notes_after_drift {\n let body = strip_markdown(¬e.body);\n for word in body.split_whitespace() {\n let word = word.to_lowercase()\n .trim_matches(|c: char| !c.is_alphanumeric())\n .to_string();\n if word.len() >= 3\n && !stopwords.contains(word.as_str())\n && !description_words.contains(&word)\n {\n *term_freq.entry(word).or_default() += 1;\n }\n }\n }\n\n let mut ranked: Vec<_> = term_freq.into_iter().collect();\n ranked.sort_by(|a, b| b.1.cmp(&a.1));\n ranked.into_iter().take(3).map(|(word, _)| word).collect()\n}\n```\n\nNOTE: The `regex` crate is likely already a dependency (check Cargo.toml). If not, add it. Consider compiling regexes once with `lazy_static!` or `std::sync::LazyLock` instead of in-function `Regex::new()`.\n\n## Robot Mode Output Schema\n```json\n{\n \"ok\": true,\n \"data\": {\n \"entity\": { \"type\": \"issue\", \"iid\": 3864, \"title\": \"...\" },\n \"drift_detected\": true,\n \"threshold\": 0.4,\n \"drift_point\": {\n \"note_index\": 12,\n \"note_id\": 456,\n \"author\": \"devname\",\n \"created_at\": \"2026-01-20T...\",\n \"similarity\": 0.32\n },\n \"drift_topics\": [\"ingestion\", \"maintenance\", \"lubrication\"],\n \"similarity_curve\": [\n { \"note_index\": 0, \"similarity\": 0.91, \"author\": \"...\", \"created_at\": \"...\" },\n { \"note_index\": 1, \"similarity\": 0.85, \"author\": \"...\", \"created_at\": \"...\" }\n ],\n \"recommendation\": \"Consider splitting: notes after #12 discuss ingestion, maintenance, lubrication -- topics not in original description\"\n },\n \"meta\": { \"elapsed_ms\": 1500, \"notes_analyzed\": 25, \"description_tokens\": 150 }\n}\n```\n\n## Clap Registration\n```rust\n// In src/main.rs Commands enum, add:\nDrift {\n /// Entity type: \"issues\" (MRs not supported in v1)\n entity_type: String,\n /// Entity IID\n iid: i64,\n /// Similarity threshold for drift detection (0.0-1.0, default 0.4)\n #[arg(long, default_value = \"0.4\")]\n threshold: f32,\n /// Scope to project (fuzzy match)\n #[arg(short, long)]\n project: Option,\n},\n```\n\n## TDD Loop\nRED: Tests in src/cli/commands/drift.rs:\n- test_cosine_similarity_identical: same vector -> 1.0\n- test_cosine_similarity_orthogonal: orthogonal vectors -> 0.0\n- test_cosine_similarity_zero_vector: zero vector -> 0.0 (not NaN)\n- test_drift_detected_when_notes_diverge: mock embeddings where first 5 notes are similar (>0.8) to desc, last 5 are dissimilar (<0.3), assert drift_detected=true\n- test_no_drift_on_consistent_discussion: all notes similar to desc (>0.6), assert drift_detected=false\n- test_drift_point_is_first_divergent: assert drift_point.note_index is the first note in the first sub-threshold window\n- test_drift_topics_exclude_original_terms: terms from description body should NOT appear in drift_topics\n- test_single_note: assert drift_detected=false (need min 3 notes)\n- test_empty_description: assert response with \"Description too short for analysis\" message\n- test_strip_markdown_code_blocks: verify fenced code blocks removed\n- test_strip_markdown_preserves_text: verify plain text preserved\n\nGREEN: Implement drift command with cosine_similarity + sliding window + topic extraction\n\nVERIFY:\n```bash\ncargo test drift:: && cargo clippy --all-targets -- -D warnings\ncargo run --release -- -J drift issues 3864 | jq '.data.drift_detected'\n```\n\n## Acceptance Criteria\n- [ ] lore drift issues N computes similarity curve between description and notes\n- [ ] Drift detected when sliding window of 3 notes averages below threshold\n- [ ] Drift topics extracted from divergent notes (top 3 terms not in description)\n- [ ] --threshold flag to adjust sensitivity (default 0.4)\n- [ ] Robot mode returns structured analysis with similarity_curve array\n- [ ] Human mode shows visual indication (similarity bar or sparkline per note)\n- [ ] Suggests splitting when drift detected\n- [ ] Performance: <2s for issue with 100 notes (mostly embedding time)\n- [ ] Command registered in main.rs and robot-docs\n- [ ] cosine_similarity function has its own unit tests\n- [ ] strip_markdown function has its own unit tests\n\n## Edge Cases\n- Empty description: return early with message \"Description too short for analysis\"\n- Single note: drift_detected = false, similarity_curve has 1 entry\n- Very short notes (<20 chars): filtered out in SQL query\n- All notes by same author: still valid analysis (self-drift is real)\n- Notes that are mostly quotes/code blocks: strip_markdown before embedding (remove ``` blocks, > quotes)\n- Issue with 500+ notes: SQL LIMIT 200 on notes, note in meta that analysis is partial\n- Ollama unavailable: exit code 14 with message (drift requires embedding computation)\n- No stored note embeddings: always embed on-the-fly (drift needs to compare against description, not stored embeddings)\n- Embedding dimension mismatch: assert desc and note embeddings have same length (768 for nomic-embed-text)\n- Regex compilation: use LazyLock or lazy_static to avoid recompiling regexes on every call\n\n## Files to Create/Modify\n- NEW: src/cli/commands/drift.rs (main command implementation)\n- NEW: src/embedding/similarity.rs (cosine_similarity utility, reusable by bd-8con)\n- src/embedding/mod.rs (export similarity module)\n- src/cli/commands/mod.rs (add pub mod drift; re-export)\n- src/main.rs (register Drift subcommand in Commands enum, add handle_drift fn)","status":"in_progress","priority":3,"issue_type":"feature","created_at":"2026-02-12T15:47:40.232427Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:35:09.641861Z","compaction_level":0,"original_size":0,"labels":["cli-imp","intelligence"],"dependencies":[{"issue_id":"bd-1cjx","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-02-12T15:47:40.235450Z","created_by":"tayloreernisse"}]} +{"id":"bd-1cjx","title":"lore drift: detect discussion divergence from original intent","description":"## Background\nDetect when a discussion thread has evolved away from the original issue description. Surfaces hidden scope creep. No existing tool does this — not GitLab, Jira, Linear, or any CLI.\n\n## Current Infrastructure (Verified 2026-02-12)\n- Embeddings: nomic-embed-text model, 768 dimensions, stored in embedding_metadata + vec0 tables\n- OllamaClient::embed_batch() at src/embedding/ollama.rs:103 — batch embedding\n- notes table: 282K rows with body, author, created_at, is_system, discussion_id\n- issues table: description column contains original intent text\n- CHUNK_MAX_BYTES = 1500 bytes for embedding input\n- No `strip_markdown()` utility exists in the codebase — must be written (see Edge Cases)\n\n## Dependencies\nThis command is standalone. It only requires:\n- OllamaClient (already shipped at src/embedding/ollama.rs) for embedding computation\n- notes + discussions tables (already in DB since migration 001/004)\n- issues table (already in DB since migration 002)\n\nNo dependency on hybrid search (bd-1ksf) or per-note search (bd-2l3s). Drift embeds on-the-fly.\n\n## Algorithm\n\n### Step 1: Embed issue description\n```rust\nlet desc_text = issue.description.unwrap_or_default();\nif desc_text.len() < 20 {\n // Too short for meaningful drift analysis\n return Ok(DriftResponse::no_drift(\"Description too short for analysis\"));\n}\nlet desc_embedding = client.embed_batch(&[&desc_text]).await?[0].clone();\n```\n\n### Step 2: Get non-system notes chronologically\n```sql\nSELECT n.id, n.body, n.author_username, n.created_at\nFROM notes n\nJOIN discussions d ON n.discussion_id = d.id\nWHERE d.noteable_type = 'Issue' AND d.noteable_id = ?\n AND n.is_system = 0\n AND LENGTH(n.body) >= 20\nORDER BY n.created_at ASC\nLIMIT 200 -- cap for performance\n```\n\n### Step 3: Embed each note\n```rust\nlet note_texts: Vec<&str> = notes.iter().map(|n| n.body.as_str()).collect();\n// Batch in groups of 32 (BATCH_SIZE from embedding pipeline)\nlet note_embeddings = client.embed_batch(¬e_texts).await?;\n```\n\n### Step 4: Compute cosine similarity curve\n```rust\n/// Cosine similarity between two embedding vectors.\n/// Returns value in [-1, 1] range; higher = more similar.\n/// Place in src/embedding/similarity.rs for reuse by related (bd-8con) and drift.\npub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {\n debug_assert_eq!(a.len(), b.len(), \"embedding dimensions must match\");\n let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();\n let norm_a: f32 = a.iter().map(|x| x * x).sum::().sqrt();\n let norm_b: f32 = b.iter().map(|x| x * x).sum::().sqrt();\n if norm_a == 0.0 || norm_b == 0.0 {\n return 0.0; // zero vector = no similarity\n }\n dot / (norm_a * norm_b)\n}\n\nlet similarity_curve: Vec = notes.iter().zip(¬e_embeddings)\n .enumerate()\n .map(|(i, (note, emb))| SimilarityPoint {\n note_index: i,\n note_id: note.id,\n similarity: cosine_similarity(&desc_embedding, emb),\n author: note.author.clone(),\n created_at: note.created_at.clone(),\n })\n .collect();\n```\n\n### Step 5: Detect drift via sliding window\n```rust\nconst DEFAULT_THRESHOLD: f32 = 0.4;\nconst WINDOW_SIZE: usize = 3;\n\nfn detect_drift(curve: &[SimilarityPoint], threshold: f32) -> Option<&SimilarityPoint> {\n if curve.len() < WINDOW_SIZE {\n return None; // need minimum 3 notes for window\n }\n for window in curve.windows(WINDOW_SIZE) {\n let avg: f32 = window.iter().map(|p| p.similarity).sum::() / WINDOW_SIZE as f32;\n if avg < threshold {\n return Some(&window[0]); // first note in drifting window\n }\n }\n None\n}\n```\n\n### Step 6: Extract drift topics (simple term frequency v1)\n```rust\n/// Simple markdown stripping for embedding quality.\n/// Remove code blocks (```...```), inline code (`...`), links [text](url),\n/// block quotes (> ...), and HTML tags (<...>).\n/// This function must be written — no existing utility in the codebase.\nfn strip_markdown(text: &str) -> String {\n // Phase 1: Remove fenced code blocks (```...```)\n let re_code_block = regex::Regex::new(r\"(?s)```.*?```\").unwrap();\n let text = re_code_block.replace_all(text, \"\");\n // Phase 2: Remove inline code (`...`)\n let re_inline = regex::Regex::new(r\"`[^`]+`\").unwrap();\n let text = re_inline.replace_all(&text, \"\");\n // Phase 3: Remove markdown links, keep text: [text](url) -> text\n let re_link = regex::Regex::new(r\"\\[([^\\]]+)\\]\\([^)]+\\)\").unwrap();\n let text = re_link.replace_all(&text, \"$1\");\n // Phase 4: Remove block quotes\n let text = text.lines()\n .filter(|l| !l.trim_start().starts_with('>'))\n .collect::>()\n .join(\"\\n\");\n // Phase 5: Remove HTML tags\n let re_html = regex::Regex::new(r\"<[^>]+>\").unwrap();\n re_html.replace_all(&text, \"\").to_string()\n}\n\nfn extract_drift_topics(\n notes_after_drift: &[Note],\n description_words: &HashSet,\n) -> Vec {\n let stopwords: HashSet<&str> = [\n \"the\", \"a\", \"an\", \"is\", \"are\", \"was\", \"were\", \"be\", \"been\", \"being\",\n \"have\", \"has\", \"had\", \"do\", \"does\", \"did\", \"will\", \"would\", \"could\",\n \"should\", \"may\", \"might\", \"shall\", \"can\", \"need\", \"dare\", \"ought\",\n \"used\", \"to\", \"of\", \"in\", \"for\", \"on\", \"with\", \"at\", \"by\", \"from\",\n \"as\", \"into\", \"through\", \"during\", \"before\", \"after\", \"above\", \"below\",\n \"between\", \"out\", \"off\", \"over\", \"under\", \"again\", \"further\", \"then\",\n \"once\", \"here\", \"there\", \"when\", \"where\", \"why\", \"how\", \"all\", \"each\",\n \"every\", \"both\", \"few\", \"more\", \"most\", \"other\", \"some\", \"such\", \"no\",\n \"nor\", \"not\", \"only\", \"own\", \"same\", \"so\", \"than\", \"too\", \"very\",\n \"just\", \"because\", \"but\", \"and\", \"or\", \"if\", \"while\", \"that\", \"this\",\n \"these\", \"those\", \"it\", \"its\", \"they\", \"them\", \"their\", \"we\", \"our\",\n \"you\", \"your\", \"he\", \"she\", \"his\", \"her\", \"what\", \"which\", \"who\",\n ].into_iter().collect();\n\n let mut term_freq: HashMap = HashMap::new();\n for note in notes_after_drift {\n let body = strip_markdown(¬e.body);\n for word in body.split_whitespace() {\n let word = word.to_lowercase()\n .trim_matches(|c: char| !c.is_alphanumeric())\n .to_string();\n if word.len() >= 3\n && !stopwords.contains(word.as_str())\n && !description_words.contains(&word)\n {\n *term_freq.entry(word).or_default() += 1;\n }\n }\n }\n\n let mut ranked: Vec<_> = term_freq.into_iter().collect();\n ranked.sort_by(|a, b| b.1.cmp(&a.1));\n ranked.into_iter().take(3).map(|(word, _)| word).collect()\n}\n```\n\nNOTE: The `regex` crate is likely already a dependency (check Cargo.toml). If not, add it. Consider compiling regexes once with `lazy_static!` or `std::sync::LazyLock` instead of in-function `Regex::new()`.\n\n## Robot Mode Output Schema\n```json\n{\n \"ok\": true,\n \"data\": {\n \"entity\": { \"type\": \"issue\", \"iid\": 3864, \"title\": \"...\" },\n \"drift_detected\": true,\n \"threshold\": 0.4,\n \"drift_point\": {\n \"note_index\": 12,\n \"note_id\": 456,\n \"author\": \"devname\",\n \"created_at\": \"2026-01-20T...\",\n \"similarity\": 0.32\n },\n \"drift_topics\": [\"ingestion\", \"maintenance\", \"lubrication\"],\n \"similarity_curve\": [\n { \"note_index\": 0, \"similarity\": 0.91, \"author\": \"...\", \"created_at\": \"...\" },\n { \"note_index\": 1, \"similarity\": 0.85, \"author\": \"...\", \"created_at\": \"...\" }\n ],\n \"recommendation\": \"Consider splitting: notes after #12 discuss ingestion, maintenance, lubrication -- topics not in original description\"\n },\n \"meta\": { \"elapsed_ms\": 1500, \"notes_analyzed\": 25, \"description_tokens\": 150 }\n}\n```\n\n## Clap Registration\n```rust\n// In src/main.rs Commands enum, add:\nDrift {\n /// Entity type: \"issues\" (MRs not supported in v1)\n entity_type: String,\n /// Entity IID\n iid: i64,\n /// Similarity threshold for drift detection (0.0-1.0, default 0.4)\n #[arg(long, default_value = \"0.4\")]\n threshold: f32,\n /// Scope to project (fuzzy match)\n #[arg(short, long)]\n project: Option,\n},\n```\n\n## TDD Loop\nRED: Tests in src/cli/commands/drift.rs:\n- test_cosine_similarity_identical: same vector -> 1.0\n- test_cosine_similarity_orthogonal: orthogonal vectors -> 0.0\n- test_cosine_similarity_zero_vector: zero vector -> 0.0 (not NaN)\n- test_drift_detected_when_notes_diverge: mock embeddings where first 5 notes are similar (>0.8) to desc, last 5 are dissimilar (<0.3), assert drift_detected=true\n- test_no_drift_on_consistent_discussion: all notes similar to desc (>0.6), assert drift_detected=false\n- test_drift_point_is_first_divergent: assert drift_point.note_index is the first note in the first sub-threshold window\n- test_drift_topics_exclude_original_terms: terms from description body should NOT appear in drift_topics\n- test_single_note: assert drift_detected=false (need min 3 notes)\n- test_empty_description: assert response with \"Description too short for analysis\" message\n- test_strip_markdown_code_blocks: verify fenced code blocks removed\n- test_strip_markdown_preserves_text: verify plain text preserved\n\nGREEN: Implement drift command with cosine_similarity + sliding window + topic extraction\n\nVERIFY:\n```bash\ncargo test drift:: && cargo clippy --all-targets -- -D warnings\ncargo run --release -- -J drift issues 3864 | jq '.data.drift_detected'\n```\n\n## Acceptance Criteria\n- [ ] lore drift issues N computes similarity curve between description and notes\n- [ ] Drift detected when sliding window of 3 notes averages below threshold\n- [ ] Drift topics extracted from divergent notes (top 3 terms not in description)\n- [ ] --threshold flag to adjust sensitivity (default 0.4)\n- [ ] Robot mode returns structured analysis with similarity_curve array\n- [ ] Human mode shows visual indication (similarity bar or sparkline per note)\n- [ ] Suggests splitting when drift detected\n- [ ] Performance: <2s for issue with 100 notes (mostly embedding time)\n- [ ] Command registered in main.rs and robot-docs\n- [ ] cosine_similarity function has its own unit tests\n- [ ] strip_markdown function has its own unit tests\n\n## Edge Cases\n- Empty description: return early with message \"Description too short for analysis\"\n- Single note: drift_detected = false, similarity_curve has 1 entry\n- Very short notes (<20 chars): filtered out in SQL query\n- All notes by same author: still valid analysis (self-drift is real)\n- Notes that are mostly quotes/code blocks: strip_markdown before embedding (remove ``` blocks, > quotes)\n- Issue with 500+ notes: SQL LIMIT 200 on notes, note in meta that analysis is partial\n- Ollama unavailable: exit code 14 with message (drift requires embedding computation)\n- No stored note embeddings: always embed on-the-fly (drift needs to compare against description, not stored embeddings)\n- Embedding dimension mismatch: assert desc and note embeddings have same length (768 for nomic-embed-text)\n- Regex compilation: use LazyLock or lazy_static to avoid recompiling regexes on every call\n\n## Files to Create/Modify\n- NEW: src/cli/commands/drift.rs (main command implementation)\n- NEW: src/embedding/similarity.rs (cosine_similarity utility, reusable by bd-8con)\n- src/embedding/mod.rs (export similarity module)\n- src/cli/commands/mod.rs (add pub mod drift; re-export)\n- src/main.rs (register Drift subcommand in Commands enum, add handle_drift fn)","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-02-12T15:47:40.232427Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:49:02.922951Z","closed_at":"2026-02-12T16:49:02.922901Z","close_reason":"Drift detection command implemented: cosine similarity curve, sliding window, topic extraction, human+robot output","compaction_level":0,"original_size":0,"labels":["cli-imp","intelligence"],"dependencies":[{"issue_id":"bd-1cjx","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-02-12T15:47:40.235450Z","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} @@ -35,7 +35,7 @@ {"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"}]} {"id":"bd-1k4","title":"OBSERV: Add get_log_dir() helper to paths module","description":"## Background\nA centralized helper for the log directory path ensures consistent XDG compliance and directory creation. The existing get_data_dir() (src/core/paths.rs:40-43) returns ~/.local/share/lore/. We add a sibling that appends /logs/.\n\n## Approach\nAdd to src/core/paths.rs, after get_db_path() (around line 53):\n\n```rust\n/// Get the log directory path. Creates the directory if it doesn't exist.\npub fn get_log_dir(config_override: Option<&str>) -> PathBuf {\n let dir = if let Some(path) = config_override {\n PathBuf::from(path)\n } else {\n get_data_dir().join(\"logs\")\n };\n std::fs::create_dir_all(&dir).ok();\n dir\n}\n```\n\nThe config_override comes from LoggingConfig.log_dir (bd-17n). When None, uses XDG default.\n\nExisting pattern to follow (src/core/paths.rs:40-53):\n- get_data_dir() -> PathBuf (returns ~/.local/share/lore/)\n- get_db_path(config_override: Option<&str>) -> PathBuf\n\n## Acceptance Criteria\n- [ ] get_log_dir(None) returns ~/.local/share/lore/logs/\n- [ ] get_log_dir(Some(\"/tmp/custom\")) returns /tmp/custom\n- [ ] Directory is created if it doesn't exist\n- [ ] Function is pub and accessible from other modules\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/core/paths.rs (add get_log_dir function after line ~53)\n\n## TDD Loop\nRED: test_get_log_dir_default, test_get_log_dir_override (use tempdir)\nGREEN: Add get_log_dir() function\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- create_dir_all failure (e.g., permissions): .ok() swallows error silently. This matches get_db_path() which also doesn't create dirs. Consider: should we propagate the error? The subscriber init will fail anyway if the dir doesn't exist, providing a clear error.\n- Trailing slash: PathBuf handles this correctly","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-04T15:53:55.525165Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:10:22.907812Z","closed_at":"2026-02-04T17:10:22.907763Z","close_reason":"Added get_log_dir() helper mirroring get_db_path/get_backup_dir pattern","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-1k4","depends_on_id":"bd-2nx","type":"parent-child","created_at":"2026-02-04T15:53:55.526345Z","created_by":"tayloreernisse"}]} {"id":"bd-1kh","title":"[CP0] Raw payload handling - compression and deduplication","description":"## Background\n\nRaw payload storage allows replaying API responses for debugging and audit. Compression reduces storage for large payloads. SHA-256 deduplication prevents storing identical payloads multiple times (important for frequently polled resources that haven't changed).\n\nReference: docs/prd/checkpoint-0.md section \"Raw Payload Handling\"\n\n## Approach\n\n**src/core/payloads.ts:**\n```typescript\nimport { createHash } from 'node:crypto';\nimport { gzipSync, gunzipSync } from 'node:zlib';\nimport Database from 'better-sqlite3';\nimport { nowMs } from './time';\n\ninterface StorePayloadOptions {\n projectId: number | null;\n resourceType: string; // 'project' | 'issue' | 'mr' | 'note' | 'discussion'\n gitlabId: string; // TEXT because discussion IDs are strings\n payload: unknown; // JSON-serializable object\n compress: boolean; // from config.storage.compressRawPayloads\n}\n\nexport function storePayload(db: Database.Database, options: StorePayloadOptions): number | null {\n // 1. JSON.stringify the payload\n // 2. SHA-256 hash the JSON bytes\n // 3. Check for duplicate by (project_id, resource_type, gitlab_id, payload_hash)\n // 4. If duplicate, return existing ID\n // 5. If compress=true, gzip the JSON bytes\n // 6. INSERT with content_encoding='gzip' or 'identity'\n // 7. Return lastInsertRowid\n}\n\nexport function readPayload(db: Database.Database, id: number): unknown {\n // 1. SELECT content_encoding, payload FROM raw_payloads WHERE id = ?\n // 2. If gzip, decompress\n // 3. JSON.parse and return\n}\n```\n\n## Acceptance Criteria\n\n- [ ] storePayload() with compress=true stores gzip-encoded payload\n- [ ] storePayload() with compress=false stores identity-encoded payload\n- [ ] Duplicate payload (same hash) returns existing row ID, not new row\n- [ ] readPayload() correctly decompresses gzip payloads\n- [ ] readPayload() returns null for non-existent ID\n- [ ] SHA-256 hash computed from pre-compression JSON bytes\n- [ ] Large payloads (100KB+) compress to ~10-20% of original size\n\n## Files\n\nCREATE:\n- src/core/payloads.ts\n- tests/unit/payloads.test.ts\n\n## TDD Loop\n\nRED:\n```typescript\n// tests/unit/payloads.test.ts\ndescribe('Payload Storage', () => {\n describe('storePayload', () => {\n it('stores uncompressed payload with identity encoding')\n it('stores compressed payload with gzip encoding')\n it('deduplicates identical payloads by hash')\n it('stores different payloads for same gitlab_id')\n })\n\n describe('readPayload', () => {\n it('reads uncompressed payload')\n it('reads and decompresses gzip payload')\n it('returns null for non-existent id')\n })\n})\n```\n\nGREEN: Implement storePayload() and readPayload()\n\nVERIFY: `npm run test -- tests/unit/payloads.test.ts`\n\n## Edge Cases\n\n- gitlabId is TEXT not INTEGER - discussion IDs are UUIDs\n- Compression ratio varies - some JSON compresses better than others\n- null projectId valid for global resources (like user profile)\n- Hash collision extremely unlikely with SHA-256 but unique index enforces","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-24T16:09:50.189494Z","created_by":"tayloreernisse","updated_at":"2026-01-25T03:19:12.854771Z","closed_at":"2026-01-25T03:19:12.854372Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1kh","depends_on_id":"bd-3ng","type":"blocks","created_at":"2026-01-24T16:13:09.055338Z","created_by":"tayloreernisse"}]} -{"id":"bd-1ksf","title":"Wire up hybrid search: FTS5 + vector + RRF ranking","description":"## Problem\nlore search hardcodes lexical-only mode. The full hybrid/vector/RRF backend is ALREADY IMPLEMENTED and tested -- it just needs to be called from the CLI.\n\n## Current State (Verified 2026-02-12)\n\n### Backend: COMPLETE\n- `search_hybrid()` in src/search/hybrid.rs:47 — async fn, handles Lexical/Semantic/Hybrid modes with graceful degradation\n- `search_vector()` in src/search/vector.rs:43 — sqlite-vec KNN with chunk deduplication and adaptive k multiplier\n- `rank_rrf()` in src/search/rrf.rs:13 — reciprocal rank fusion with normalization (7 passing tests)\n- `SearchMode::parse()` — parses hybrid, lexical/fts, semantic/vector\n- `OllamaClient::embed_batch()` in src/embedding/ollama.rs:103 — batch embedding via Ollama /api/embed endpoint\n- All exported from src/search/mod.rs:7-14\n\n### CLI: BROKEN\n- src/cli/commands/search.rs:61 `run_search()` is SYNCHRONOUS (not async)\n- Line 76: `let actual_mode = \"lexical\";` — hardcoded\n- Lines 77-82: warns if user requests vector/hybrid, falls back to lexical\n- Line 161: calls `search_fts()` directly instead of `search_hybrid()`\n- Line 172: calls `rank_rrf(&[], &fts_tuples)` — empty vector list, FTS-only ranking\n- Lines 143-152: manually constructs `SearchFilters` (this code is reusable)\n- Lines 187-223: hydrates + maps to `SearchResultDisplay` (this can be adapted)\n\n### Entry Point\n- src/main.rs:1731 `async fn handle_search()` — IS async, but calls `run_search()` synchronously at line 1758\n- main.rs is 2579 lines total\n\n## Actual Work Required\n\n### Step 1: Make run_search async\nChange `pub fn run_search(...)` to `pub async fn run_search(...)` in search.rs:61.\nUpdate handle_search call site (main.rs:1758) to `.await`.\n\n### Step 2: Create OllamaClient when mode != lexical\nPattern from src/cli/commands/embed.rs — reuse `OllamaConfig` from config:\n```rust\nlet client = if actual_mode != SearchMode::Lexical {\n let ollama_cfg = &config.embedding;\n Some(OllamaClient::new(&ollama_cfg.ollama_url, &ollama_cfg.model))\n} else {\n None\n};\n```\n\n### Step 3: Replace manual FTS+filter+rank with search_hybrid call\nReplace lines 161-172 (search_fts + rank_rrf) with:\n```rust\nlet (hybrid_results, mut hybrid_warnings) = search_hybrid(\n &conn,\n client.as_ref(),\n query,\n actual_mode,\n &filters,\n fts_mode,\n).await?;\nwarnings.append(&mut hybrid_warnings);\n```\n\n### Step 4: Map HybridResult to SearchResultDisplay\nHybridResult (src/search/hybrid.rs:39-45) has these fields:\n```rust\npub struct HybridResult {\n pub document_id: i64,\n pub score: f64, // combined score\n pub vector_rank: Option,\n pub fts_rank: Option,\n pub rrf_score: f64,\n}\n```\nNOTE: HybridResult has NO `snippet` field and NO `normalized_score` field. `score` is the combined score. The `snippet` must still be obtained from the FTS results or from `get_result_snippet()`.\n\nSearchResultDisplay needs: document_id, source_type, title, url, author, etc. (from hydration).\nKeep the existing hydrate_results() call (line 187) and rrf_map construction (lines 189-190), but adapt to use HybridResult instead of RrfResult:\n```rust\n// Map hybrid results for lookup\nlet hybrid_map: HashMap =\n hybrid_results.iter().map(|r| (r.document_id, r)).collect();\n\n// For each hydrated row:\nlet hr = hybrid_map.get(&row.document_id);\nlet explain_data = if explain {\n hr.map(|r| ExplainData {\n vector_rank: r.vector_rank,\n fts_rank: r.fts_rank,\n rrf_score: r.rrf_score,\n })\n} else { None };\n// score: hr.map(|r| r.score).unwrap_or(0.0)\n```\n\nFor snippets: search_hybrid calls search_fts internally, but does NOT return snippets. You need to either:\n(a) Call search_fts separately just for snippets, or\n(b) Modify search_hybrid to also return a snippet_map — preferred if touching hybrid.rs is in scope.\nSimpler approach: keep the existing `search_fts()` call for snippets, use hybrid for ranking. The FTS call is fast (<50ms) and avoids modifying the already-complete hybrid.rs.\n\n### Step 5: Determine actual_mode from config + CLI flag\n```rust\nlet actual_mode = SearchMode::parse(requested_mode).unwrap_or(SearchMode::Hybrid);\n// search_hybrid handles graceful degradation internally\n```\n\n## Signatures for Reference\n\n```rust\n// src/search/hybrid.rs:47\npub async fn search_hybrid(\n conn: &Connection,\n client: Option<&OllamaClient>,\n query: &str,\n mode: SearchMode,\n filters: &SearchFilters,\n fts_mode: FtsQueryMode,\n) -> Result<(Vec, Vec)>\n\n// src/search/hybrid.rs:39\npub struct HybridResult {\n pub document_id: i64,\n pub score: f64,\n pub vector_rank: Option,\n pub fts_rank: Option,\n pub rrf_score: f64,\n}\n\n// src/search/mod.rs exports\npub use hybrid::{HybridResult, SearchMode, search_hybrid};\npub use rrf::{RrfResult, rank_rrf};\npub use vector::{VectorResult, search_vector};\n\n// src/embedding/ollama.rs:103\npub async fn embed_batch(&self, texts: &[&str]) -> Result>>\n```\n\n## TDD Loop\nRED: Add test in src/search/hybrid.rs:\n- test_hybrid_lexical_fallback_no_ollama: search_hybrid with mode=Hybrid, client=None returns FTS results + warning\n- test_hybrid_mode_detection: verify default mode is Hybrid when embeddings exist\n\nGREEN: Wire search.rs to call search_hybrid() as described above\n\nVERIFY:\n```bash\ncargo test search:: && cargo clippy --all-targets -- -D warnings\ncargo run --release -- -J search 'throw time' --mode hybrid --explain | jq '.data.mode'\n# Should return \"hybrid\" (or \"lexical\" with warning if Ollama is down)\n```\n\n## Edge Cases\n- Ollama running but model not found: clear error with suggestion to run `ollama pull nomic-embed-text`\n- No embeddings in DB (never ran lore embed): search_vector returns empty, RRF uses FTS only — search_hybrid handles this gracefully\n- Query embedding returns all zeros: should still return FTS results\n- Very long query string (>1500 bytes): chunk or truncate before embedding (CHUNK_MAX_BYTES=1500)\n- sqlite-vec table missing (old DB without migration 009): graceful error from search_vector\n- OllamaConfig missing from config: check `config.embedding` exists before constructing client\n- Snippet handling: HybridResult has no snippet field — must obtain snippets from a separate search_fts call or from get_result_snippet() with content_text fallback\n\n## Files to Modify\n- src/cli/commands/search.rs — make run_search async, replace manual FTS+RRF with search_hybrid call (~80 lines replaced with ~20)\n- src/main.rs:1758 — add .await to run_search call (already in async context)\n\n## Files NOT to Modify (already complete)\n- src/search/hybrid.rs\n- src/search/vector.rs\n- src/search/rrf.rs\n- src/embedding/ollama.rs","status":"in_progress","priority":1,"issue_type":"feature","created_at":"2026-02-12T15:45:56.305343Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:35:09.414982Z","compaction_level":0,"original_size":0,"labels":["cli-imp","search"],"dependencies":[{"issue_id":"bd-1ksf","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-02-12T15:45:56.307149Z","created_by":"tayloreernisse"},{"issue_id":"bd-1ksf","depends_on_id":"bd-2l3s","type":"blocks","created_at":"2026-02-12T15:47:51.370479Z","created_by":"tayloreernisse"}]} +{"id":"bd-1ksf","title":"Wire up hybrid search: FTS5 + vector + RRF ranking","description":"## Problem\nlore search hardcodes lexical-only mode. The full hybrid/vector/RRF backend is ALREADY IMPLEMENTED and tested -- it just needs to be called from the CLI.\n\n## Current State (Verified 2026-02-12)\n\n### Backend: COMPLETE\n- `search_hybrid()` in src/search/hybrid.rs:47 — async fn, handles Lexical/Semantic/Hybrid modes with graceful degradation\n- `search_vector()` in src/search/vector.rs:43 — sqlite-vec KNN with chunk deduplication and adaptive k multiplier\n- `rank_rrf()` in src/search/rrf.rs:13 — reciprocal rank fusion with normalization (7 passing tests)\n- `SearchMode::parse()` — parses hybrid, lexical/fts, semantic/vector\n- `OllamaClient::embed_batch()` in src/embedding/ollama.rs:103 — batch embedding via Ollama /api/embed endpoint\n- All exported from src/search/mod.rs:7-14\n\n### CLI: BROKEN\n- src/cli/commands/search.rs:61 `run_search()` is SYNCHRONOUS (not async)\n- Line 76: `let actual_mode = \"lexical\";` — hardcoded\n- Lines 77-82: warns if user requests vector/hybrid, falls back to lexical\n- Line 161: calls `search_fts()` directly instead of `search_hybrid()`\n- Line 172: calls `rank_rrf(&[], &fts_tuples)` — empty vector list, FTS-only ranking\n- Lines 143-152: manually constructs `SearchFilters` (this code is reusable)\n- Lines 187-223: hydrates + maps to `SearchResultDisplay` (this can be adapted)\n\n### Entry Point\n- src/main.rs:1731 `async fn handle_search()` — IS async, but calls `run_search()` synchronously at line 1758\n- main.rs is 2579 lines total\n\n## Actual Work Required\n\n### Step 1: Make run_search async\nChange `pub fn run_search(...)` to `pub async fn run_search(...)` in search.rs:61.\nUpdate handle_search call site (main.rs:1758) to `.await`.\n\n### Step 2: Create OllamaClient when mode != lexical\nPattern from src/cli/commands/embed.rs — reuse `OllamaConfig` from config:\n```rust\nlet client = if actual_mode != SearchMode::Lexical {\n let ollama_cfg = &config.embedding;\n Some(OllamaClient::new(&ollama_cfg.ollama_url, &ollama_cfg.model))\n} else {\n None\n};\n```\n\n### Step 3: Replace manual FTS+filter+rank with search_hybrid call\nReplace lines 161-172 (search_fts + rank_rrf) with:\n```rust\nlet (hybrid_results, mut hybrid_warnings) = search_hybrid(\n &conn,\n client.as_ref(),\n query,\n actual_mode,\n &filters,\n fts_mode,\n).await?;\nwarnings.append(&mut hybrid_warnings);\n```\n\n### Step 4: Map HybridResult to SearchResultDisplay\nHybridResult (src/search/hybrid.rs:39-45) has these fields:\n```rust\npub struct HybridResult {\n pub document_id: i64,\n pub score: f64, // combined score\n pub vector_rank: Option,\n pub fts_rank: Option,\n pub rrf_score: f64,\n}\n```\nNOTE: HybridResult has NO `snippet` field and NO `normalized_score` field. `score` is the combined score. The `snippet` must still be obtained from the FTS results or from `get_result_snippet()`.\n\nSearchResultDisplay needs: document_id, source_type, title, url, author, etc. (from hydration).\nKeep the existing hydrate_results() call (line 187) and rrf_map construction (lines 189-190), but adapt to use HybridResult instead of RrfResult:\n```rust\n// Map hybrid results for lookup\nlet hybrid_map: HashMap =\n hybrid_results.iter().map(|r| (r.document_id, r)).collect();\n\n// For each hydrated row:\nlet hr = hybrid_map.get(&row.document_id);\nlet explain_data = if explain {\n hr.map(|r| ExplainData {\n vector_rank: r.vector_rank,\n fts_rank: r.fts_rank,\n rrf_score: r.rrf_score,\n })\n} else { None };\n// score: hr.map(|r| r.score).unwrap_or(0.0)\n```\n\nFor snippets: search_hybrid calls search_fts internally, but does NOT return snippets. You need to either:\n(a) Call search_fts separately just for snippets, or\n(b) Modify search_hybrid to also return a snippet_map — preferred if touching hybrid.rs is in scope.\nSimpler approach: keep the existing `search_fts()` call for snippets, use hybrid for ranking. The FTS call is fast (<50ms) and avoids modifying the already-complete hybrid.rs.\n\n### Step 5: Determine actual_mode from config + CLI flag\n```rust\nlet actual_mode = SearchMode::parse(requested_mode).unwrap_or(SearchMode::Hybrid);\n// search_hybrid handles graceful degradation internally\n```\n\n## Signatures for Reference\n\n```rust\n// src/search/hybrid.rs:47\npub async fn search_hybrid(\n conn: &Connection,\n client: Option<&OllamaClient>,\n query: &str,\n mode: SearchMode,\n filters: &SearchFilters,\n fts_mode: FtsQueryMode,\n) -> Result<(Vec, Vec)>\n\n// src/search/hybrid.rs:39\npub struct HybridResult {\n pub document_id: i64,\n pub score: f64,\n pub vector_rank: Option,\n pub fts_rank: Option,\n pub rrf_score: f64,\n}\n\n// src/search/mod.rs exports\npub use hybrid::{HybridResult, SearchMode, search_hybrid};\npub use rrf::{RrfResult, rank_rrf};\npub use vector::{VectorResult, search_vector};\n\n// src/embedding/ollama.rs:103\npub async fn embed_batch(&self, texts: &[&str]) -> Result>>\n```\n\n## TDD Loop\nRED: Add test in src/search/hybrid.rs:\n- test_hybrid_lexical_fallback_no_ollama: search_hybrid with mode=Hybrid, client=None returns FTS results + warning\n- test_hybrid_mode_detection: verify default mode is Hybrid when embeddings exist\n\nGREEN: Wire search.rs to call search_hybrid() as described above\n\nVERIFY:\n```bash\ncargo test search:: && cargo clippy --all-targets -- -D warnings\ncargo run --release -- -J search 'throw time' --mode hybrid --explain | jq '.data.mode'\n# Should return \"hybrid\" (or \"lexical\" with warning if Ollama is down)\n```\n\n## Edge Cases\n- Ollama running but model not found: clear error with suggestion to run `ollama pull nomic-embed-text`\n- No embeddings in DB (never ran lore embed): search_vector returns empty, RRF uses FTS only — search_hybrid handles this gracefully\n- Query embedding returns all zeros: should still return FTS results\n- Very long query string (>1500 bytes): chunk or truncate before embedding (CHUNK_MAX_BYTES=1500)\n- sqlite-vec table missing (old DB without migration 009): graceful error from search_vector\n- OllamaConfig missing from config: check `config.embedding` exists before constructing client\n- Snippet handling: HybridResult has no snippet field — must obtain snippets from a separate search_fts call or from get_result_snippet() with content_text fallback\n\n## Files to Modify\n- src/cli/commands/search.rs — make run_search async, replace manual FTS+RRF with search_hybrid call (~80 lines replaced with ~20)\n- src/main.rs:1758 — add .await to run_search call (already in async context)\n\n## Files NOT to Modify (already complete)\n- src/search/hybrid.rs\n- src/search/vector.rs\n- src/search/rrf.rs\n- src/embedding/ollama.rs","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-02-12T15:45:56.305343Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:49:25.720332Z","closed_at":"2026-02-12T16:49:25.720209Z","compaction_level":0,"original_size":0,"labels":["cli-imp","search"],"dependencies":[{"issue_id":"bd-1ksf","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-02-12T15:45:56.307149Z","created_by":"tayloreernisse"},{"issue_id":"bd-1ksf","depends_on_id":"bd-2l3s","type":"blocks","created_at":"2026-02-12T15:47:51.370479Z","created_by":"tayloreernisse"}]} {"id":"bd-1l1","title":"[CP0] GitLab API client with rate limiting","description":"## Background\n\nThe GitLab client handles all API communication with rate limiting to avoid 429 errors. Uses native fetch (Node 18+). Rate limiter adds jitter to prevent thundering herd. All errors are typed for clean error handling in CLI commands.\n\nReference: docs/prd/checkpoint-0.md section \"GitLab Client\"\n\n## Approach\n\n**src/gitlab/client.ts:**\n```typescript\nexport class GitLabClient {\n private baseUrl: string;\n private token: string;\n private rateLimiter: RateLimiter;\n\n constructor(options: { baseUrl: string; token: string; requestsPerSecond?: number }) {\n this.baseUrl = options.baseUrl.replace(/\\/$/, '');\n this.token = options.token;\n this.rateLimiter = new RateLimiter(options.requestsPerSecond ?? 10);\n }\n\n async getCurrentUser(): Promise\n async getProject(pathWithNamespace: string): Promise\n private async request(path: string, options?: RequestInit): Promise\n}\n\nclass RateLimiter {\n private lastRequest = 0;\n private minInterval: number;\n\n constructor(requestsPerSecond: number) {\n this.minInterval = 1000 / requestsPerSecond;\n }\n\n async acquire(): Promise {\n // Wait if too soon since last request\n // Add 0-50ms jitter\n }\n}\n```\n\n**src/gitlab/types.ts:**\n```typescript\nexport interface GitLabUser {\n id: number;\n username: string;\n name: string;\n}\n\nexport interface GitLabProject {\n id: number;\n path_with_namespace: string;\n default_branch: string;\n web_url: string;\n created_at: string;\n updated_at: string;\n}\n```\n\n**Integration tests with MSW (Mock Service Worker):**\nSet up MSW handlers that mock GitLab API responses for /api/v4/user and /api/v4/projects/:path\n\n## Acceptance Criteria\n\n- [ ] getCurrentUser() returns GitLabUser with id, username, name\n- [ ] getProject(\"group/project\") URL-encodes path correctly\n- [ ] 401 response throws GitLabAuthError\n- [ ] 404 response throws GitLabNotFoundError\n- [ ] 429 response throws GitLabRateLimitError with retryAfter from header\n- [ ] Network failure throws GitLabNetworkError\n- [ ] Rate limiter enforces minimum interval between requests\n- [ ] Rate limiter adds random jitter (0-50ms)\n- [ ] tests/integration/gitlab-client.test.ts passes (6 tests)\n\n## Files\n\nCREATE:\n- src/gitlab/client.ts\n- src/gitlab/types.ts\n- tests/integration/gitlab-client.test.ts\n- tests/fixtures/mock-responses/gitlab-user.json\n- tests/fixtures/mock-responses/gitlab-project.json\n\n## TDD Loop\n\nRED:\n```typescript\n// tests/integration/gitlab-client.test.ts\ndescribe('GitLab Client', () => {\n it('authenticates with valid PAT')\n it('returns 401 for invalid PAT')\n it('fetches project by path')\n it('handles rate limiting (429) with Retry-After')\n it('respects rate limit (requests per second)')\n it('adds jitter to rate limiting')\n})\n```\n\nGREEN: Implement client.ts and types.ts\n\nVERIFY: `npm run test -- tests/integration/gitlab-client.test.ts`\n\n## Edge Cases\n\n- Path with special characters (spaces, slashes) must be URL-encoded\n- Retry-After header may be missing - default to 60s\n- Network timeout should be handled (use AbortController)\n- Rate limiter jitter prevents multiple clients syncing in lockstep\n- baseUrl trailing slash should be stripped","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-24T16:09:49.842981Z","created_by":"tayloreernisse","updated_at":"2026-01-25T03:06:39.520300Z","closed_at":"2026-01-25T03:06:39.520131Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-1l1","depends_on_id":"bd-gg1","type":"blocks","created_at":"2026-01-24T16:13:08.713272Z","created_by":"tayloreernisse"}]} {"id":"bd-1m8","title":"Extend 'lore stats --check' for event table integrity and queue health","description":"## Background\nThe existing stats --check command validates data integrity. Need to extend it for event tables (referential integrity) and dependent job queue health (stuck locks, retryable jobs). This provides operators and agents a way to detect data quality issues after sync.\n\n## Approach\nExtend src/cli/commands/stats.rs check mode:\n\n**New checks:**\n\n1. Event FK integrity:\n```sql\n-- Orphaned state events (issue_id points to non-existent issue)\nSELECT COUNT(*) FROM resource_state_events rse\nWHERE rse.issue_id IS NOT NULL\n AND NOT EXISTS (SELECT 1 FROM issues i WHERE i.id = rse.issue_id);\n-- (repeat for merge_request_id, and for label + milestone event tables)\n```\n\n2. Queue health:\n```sql\n-- Pending jobs by type\nSELECT job_type, COUNT(*) FROM pending_dependent_fetches GROUP BY job_type;\n-- Stuck locks (locked_at older than 5 minutes)\nSELECT COUNT(*) FROM pending_dependent_fetches WHERE locked_at IS NOT NULL AND locked_at < ?;\n-- Retryable jobs (attempts > 0, not locked)\nSELECT COUNT(*) FROM pending_dependent_fetches WHERE attempts > 0 AND locked_at IS NULL;\n-- Max attempts (jobs that may be permanently failing)\nSELECT job_type, MAX(attempts) FROM pending_dependent_fetches GROUP BY job_type;\n```\n\n3. Human output per check: PASS / WARN / FAIL with counts\n```\nEvent FK integrity: PASS (0 orphaned events)\nQueue health: WARN (3 stuck locks, 12 retryable jobs)\n```\n\n4. Robot JSON: structured health report\n```json\n{\n \"event_integrity\": {\n \"status\": \"pass\",\n \"orphaned_state_events\": 0,\n \"orphaned_label_events\": 0,\n \"orphaned_milestone_events\": 0\n },\n \"queue_health\": {\n \"status\": \"warn\",\n \"pending_by_type\": {\"resource_events\": 5, \"mr_closes_issues\": 2},\n \"stuck_locks\": 3,\n \"retryable_jobs\": 12,\n \"max_attempts_by_type\": {\"resource_events\": 5}\n }\n}\n```\n\n## Acceptance Criteria\n- [ ] Detects orphaned events (FK target missing)\n- [ ] Detects stuck locks (locked_at older than threshold)\n- [ ] Reports retryable job count and max attempts\n- [ ] Human output shows PASS/WARN/FAIL per check\n- [ ] Robot JSON matches structured schema\n- [ ] Graceful when event/queue tables don't exist\n\n## Files\n- src/cli/commands/stats.rs (extend check mode)\n\n## TDD Loop\nRED: tests/stats_check_tests.rs:\n- `test_stats_check_events_pass` - clean data, verify PASS\n- `test_stats_check_events_orphaned` - delete an issue with events remaining, verify FAIL count\n- `test_stats_check_queue_stuck_locks` - set old locked_at, verify WARN\n- `test_stats_check_queue_retryable` - fail some jobs, verify retryable count\n\nGREEN: Add the check queries and formatting\n\nVERIFY: `cargo test stats_check -- --nocapture`\n\n## Edge Cases\n- FK with CASCADE should prevent orphaned events in normal operation — but manual DB edits or bugs could cause them\n- Tables may not exist if migration 011 not applied — check table existence before querying\n- Empty queue is PASS (not WARN for \"no jobs found\")\n- Distinguish between \"0 stuck locks\" (good) and \"queue table doesn't exist\" (skip check)","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-02T21:31:57.422916Z","created_by":"tayloreernisse","updated_at":"2026-02-03T16:23:13.409909Z","closed_at":"2026-02-03T16:23:13.409717Z","close_reason":"Extended IntegrityResult with orphan_state/label/milestone_events and queue_stuck_locks/queue_max_attempts. Added FK integrity queries for all 3 event tables and queue health checks. Updated human output with PASS/WARN/FAIL indicators and robot JSON.","compaction_level":0,"original_size":0,"labels":["cli","gate-1","phase-b"],"dependencies":[{"issue_id":"bd-1m8","depends_on_id":"bd-2zl","type":"parent-child","created_at":"2026-02-02T21:31:57.424103Z","created_by":"tayloreernisse"},{"issue_id":"bd-1m8","depends_on_id":"bd-hu3","type":"blocks","created_at":"2026-02-02T21:32:06.350605Z","created_by":"tayloreernisse"},{"issue_id":"bd-1m8","depends_on_id":"bd-tir","type":"blocks","created_at":"2026-02-02T21:32:06.391042Z","created_by":"tayloreernisse"}]} {"id":"bd-1mf","title":"[CP1] gi sync-status enhancement","description":"Enhance sync-status from CP0 stub to show issue cursors.\n\nOutput:\n- Last run timestamp and duration\n- Cursor positions per project (issues resource_type)\n- Entity counts (issues, discussions, notes)\n\nFiles: src/cli/commands/sync-status.ts (update existing)\nDone when: Shows cursor positions and counts after ingestion","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T15:20:36.449088Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.157235Z","deleted_at":"2026-01-25T15:21:35.157232Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0} @@ -97,7 +97,7 @@ {"id":"bd-2fc","title":"Update AGENTS.md and CLAUDE.md with Phase B commands","description":"## Background\n\nAfter Phase B implementation, update AGENTS.md and CLAUDE.md with temporal intelligence command documentation so agents can discover and use the new commands.\n\n## Codebase Context\n\n- AGENTS.md section \"Gitlore Robot Mode\" (line ~592) has Robot Mode Commands table\n- ~/.claude/CLAUDE.md has matching \"Gitlore (lore)\" section with command reference\n- New Phase B commands: timeline, file-history, trace\n- New count entity: references\n- sync gains --no-file-changes flag (bd-jec)\n- Config gains fetchMrFileChanges (bd-jec) and fetchResourceEvents (already exists)\n\n## Approach\n\nAdd \"Temporal Intelligence Commands\" section after existing Robot Mode Commands in both files:\n\n```bash\n# Timeline - chronological event history\nlore --robot timeline \"authentication\" --since 30d\nlore --robot timeline \"deployment\" --depth 2 --expand-mentions\n\n# File History - which MRs touched a file\nlore --robot file-history src/auth/oauth.rs --discussions\n\n# Trace - file -> MR -> issue -> discussion chain\nlore --robot trace src/auth/oauth.rs --discussions\n\n# Count references - cross-reference statistics\nlore --robot count references\n\n# Sync with file changes\nlore --robot sync --no-file-changes # skip MR diff fetching\n```\n\nAlso document config flags:\n```json\n{\n \"sync\": {\n \"fetchResourceEvents\": true,\n \"fetchMrFileChanges\": true\n }\n}\n```\n\n## Acceptance Criteria\n\n- [ ] AGENTS.md has Temporal Intelligence Commands section\n- [ ] ~/.claude/CLAUDE.md has matching section\n- [ ] All examples are valid, runnable commands\n- [ ] Config flags documented (fetchResourceEvents, fetchMrFileChanges)\n- [ ] --no-events and --no-file-changes CLI flags documented\n- [ ] sync-related changes documented\n- [ ] Mentions resource events requirement for timeline queries\n\n## Files\n\n- AGENTS.md (add temporal intelligence section)\n- ~/.claude/CLAUDE.md (add matching section)\n\n## Edge Cases\n\n- Both files must stay in sync\n- Examples must use --robot flag consistently\n- Config flag names use camelCase in JSON, snake_case in Rust","status":"open","priority":4,"issue_type":"task","created_at":"2026-02-02T22:43:22.090741Z","created_by":"tayloreernisse","updated_at":"2026-02-05T20:17:52.683565Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2fc","depends_on_id":"bd-1ht","type":"parent-child","created_at":"2026-02-02T22:43:40.829848Z","created_by":"tayloreernisse"},{"issue_id":"bd-2fc","depends_on_id":"bd-1v8","type":"blocks","created_at":"2026-02-02T22:43:34.047898Z","created_by":"tayloreernisse"}]} {"id":"bd-2fm","title":"Add GitLab Resource Event serde types","description":"## Background\nNeed Rust types for deserializing GitLab Resource Events API responses. These map directly to the API JSON shape from three endpoints: resource_state_events, resource_label_events, resource_milestone_events.\n\nExisting pattern: types.rs uses #[derive(Debug, Clone, Deserialize)] with Option for nullable fields. GitLabAuthor is already defined (id, username, name). Tests in tests/gitlab_types_tests.rs use serde_json::from_str with sample payloads.\n\n## Approach\nAdd to src/gitlab/types.rs (after existing types):\n\n```rust\n/// Reference to an MR in state event's source_merge_request field\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct GitLabMergeRequestRef {\n pub iid: i64,\n pub title: Option,\n pub web_url: Option,\n}\n\n/// Reference to a label in label event's label field\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct GitLabLabelRef {\n pub id: i64,\n pub name: String,\n pub color: Option,\n pub description: Option,\n}\n\n/// Reference to a milestone in milestone event's milestone field\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct GitLabMilestoneRef {\n pub id: i64,\n pub iid: i64,\n pub title: String,\n}\n\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct GitLabStateEvent {\n pub id: i64,\n pub user: Option,\n pub created_at: String,\n pub resource_type: String, // \"Issue\" | \"MergeRequest\"\n pub resource_id: i64,\n pub state: String, // \"opened\" | \"closed\" | \"reopened\" | \"merged\" | \"locked\"\n pub source_commit: Option,\n pub source_merge_request: Option,\n}\n\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct GitLabLabelEvent {\n pub id: i64,\n pub user: Option,\n pub created_at: String,\n pub resource_type: String,\n pub resource_id: i64,\n pub label: GitLabLabelRef,\n pub action: String, // \"add\" | \"remove\"\n}\n\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct GitLabMilestoneEvent {\n pub id: i64,\n pub user: Option,\n pub created_at: String,\n pub resource_type: String,\n pub resource_id: i64,\n pub milestone: GitLabMilestoneRef,\n pub action: String, // \"add\" | \"remove\"\n}\n```\n\nAlso export from src/gitlab/mod.rs if needed.\n\n## Acceptance Criteria\n- [ ] All 6 types (3 events + 3 refs) compile\n- [ ] GitLabStateEvent deserializes from real GitLab API JSON (with and without source_merge_request)\n- [ ] GitLabLabelEvent deserializes with nested label object\n- [ ] GitLabMilestoneEvent deserializes with nested milestone object\n- [ ] All Optional fields handle null/missing correctly\n- [ ] Types exported from lore::gitlab::types\n\n## Files\n- src/gitlab/types.rs (add 6 new types)\n- tests/gitlab_types_tests.rs (add deserialization tests)\n\n## TDD Loop\nRED: Add to tests/gitlab_types_tests.rs:\n- `test_deserialize_state_event_closed_by_mr` - JSON with source_merge_request present\n- `test_deserialize_state_event_simple` - JSON with source_merge_request null, user null\n- `test_deserialize_label_event_add` - label add with full label object\n- `test_deserialize_label_event_remove` - label remove\n- `test_deserialize_milestone_event` - milestone add with nested milestone\nImport new types: `use lore::gitlab::types::{GitLabStateEvent, GitLabLabelEvent, GitLabMilestoneEvent, GitLabMergeRequestRef, GitLabLabelRef, GitLabMilestoneRef};`\n\nGREEN: Add the type definitions to types.rs\n\nVERIFY: `cargo test gitlab_types_tests -- --nocapture`\n\n## Edge Cases\n- GitLab sometimes returns user: null for system-generated events (e.g., auto-close on merge) — user must be Option\n- source_merge_request can be null even when state is \"closed\" (manually closed, not by MR)\n- label.color may be null for labels created via API without color\n- The resource_type field uses PascalCase (\"MergeRequest\" not \"merge_request\") — don't confuse with DB entity_type","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:31:24.081234Z","created_by":"tayloreernisse","updated_at":"2026-02-03T16:10:20.253407Z","closed_at":"2026-02-03T16:10:20.253344Z","close_reason":"Completed: Added 6 new types (GitLabMergeRequestRef, GitLabLabelRef, GitLabMilestoneRef, GitLabStateEvent, GitLabLabelEvent, GitLabMilestoneEvent) to types.rs with exports and 8 passing tests","compaction_level":0,"original_size":0,"labels":["gate-1","phase-b","types"],"dependencies":[{"issue_id":"bd-2fm","depends_on_id":"bd-2zl","type":"parent-child","created_at":"2026-02-02T21:31:24.085809Z","created_by":"tayloreernisse"}]} {"id":"bd-2fp","title":"Implement discussion document extraction","description":"## Background\nDiscussion documents are the most complex extraction — they involve querying discussions + notes + parent entity (issue or MR) + parent labels + DiffNote file paths. The output includes a threaded conversation format with author/date prefixes per note. System notes (bot-generated) are excluded. DiffNote paths are extracted for the --path search filter.\n\n## Approach\nImplement `extract_discussion_document()` in `src/documents/extractor.rs`:\n\n```rust\n/// Extract a searchable document from a discussion thread.\n/// Returns None if the discussion or its parent has been deleted.\npub fn extract_discussion_document(conn: &Connection, discussion_id: i64) -> Result>\n```\n\n**SQL queries (from PRD Section 2.2):**\n```sql\n-- Discussion metadata\nSELECT d.id, d.noteable_type, d.issue_id, d.merge_request_id,\n p.path_with_namespace, p.id AS project_id\nFROM discussions d\nJOIN projects p ON p.id = d.project_id\nWHERE d.id = ?\n\n-- Parent entity (conditional on noteable_type)\n-- If Issue: SELECT i.iid, i.title, i.web_url FROM issues i WHERE i.id = ?\n-- If MR: SELECT m.iid, m.title, m.web_url FROM merge_requests m WHERE m.id = ?\n\n-- Parent labels (via issue_labels or mr_labels junction)\n\n-- Non-system notes in thread order\nSELECT n.author_username, n.body, n.created_at, n.gitlab_id,\n n.note_type, n.position_old_path, n.position_new_path\nFROM notes n\nWHERE n.discussion_id = ? AND n.is_system = 0\nORDER BY n.created_at ASC, n.id ASC\n```\n\n**Document format:**\n```\n[[Discussion]] Issue #234: Authentication redesign\nProject: group/project-one\nURL: https://gitlab.example.com/group/project-one/-/issues/234#note_12345\nLabels: [\"bug\", \"auth\"]\nFiles: [\"src/auth/login.ts\"]\n\n--- Thread ---\n\n@johndoe (2024-03-15):\nI think we should move to JWT-based auth...\n\n@janedoe (2024-03-15):\nAgreed. What about refresh token strategy?\n```\n\n**Implementation steps:**\n1. Query discussion row — if not found, return Ok(None)\n2. Determine parent type (Issue or MR) from noteable_type\n3. Query parent entity for iid, title, web_url — if not found, return Ok(None)\n4. Query parent labels via appropriate junction table\n5. Query non-system notes ordered by created_at ASC, id ASC\n6. Extract DiffNote paths: collect position_old_path and position_new_path, dedup\n7. Construct URL: `{parent_web_url}#note_{first_note_gitlab_id}`\n8. Format header with [[Discussion]] prefix\n9. Format thread body: `@author (YYYY-MM-DD):\\nbody\\n\\n` per note\n10. Apply discussion truncation via `truncate_discussion()` if needed\n11. Author = first non-system note's author_username\n12. Compute hashes, return DocumentData\n\n## Acceptance Criteria\n- [ ] System notes (is_system=1) excluded from content\n- [ ] DiffNote paths extracted from position_old_path and position_new_path\n- [ ] Paths deduplicated and sorted\n- [ ] URL constructed as `parent_web_url#note_GITLAB_ID`\n- [ ] Header uses parent entity type: \"Issue #N\" or \"MR !N\"\n- [ ] Parent title included in header\n- [ ] Labels come from PARENT entity (not the discussion itself)\n- [ ] First non-system note author used as document author\n- [ ] Thread formatted with `@author (date):` per note\n- [ ] Truncation applied for long threads via truncate_discussion()\n- [ ] `cargo test extract_discussion` passes\n\n## Files\n- `src/documents/extractor.rs` — implement `extract_discussion_document()`\n\n## TDD Loop\nRED: Tests in `#[cfg(test)] mod tests`:\n- `test_discussion_document_format` — verify header + thread format\n- `test_discussion_not_found` — returns Ok(None)\n- `test_discussion_parent_deleted` — returns Ok(None) when parent issue/MR missing\n- `test_discussion_system_notes_excluded` — system notes not in content\n- `test_discussion_diffnote_paths` — old_path + new_path extracted and deduped\n- `test_discussion_url_construction` — URL has #note_GITLAB_ID anchor\n- `test_discussion_uses_parent_labels` — labels from parent entity, not discussion\nGREEN: Implement extract_discussion_document\nVERIFY: `cargo test extract_discussion`\n\n## Edge Cases\n- Discussion with all system notes: no non-system notes -> return empty thread (or skip document entirely?)\n- Discussion with NULL parent (orphaned): return Ok(None)\n- DiffNote with same old_path and new_path: dedup produces single entry\n- Notes with NULL body: skip or use empty string\n- Discussion on MR: header shows \"MR !N\" (not \"MergeRequest !N\")","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-30T15:25:45.549099Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:34:43.597398Z","closed_at":"2026-01-30T17:34:43.597339Z","close_reason":"Implemented extract_discussion_document() with parent entity lookup, DiffNote paths, system note exclusion, URL construction + 9 tests","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2fp","depends_on_id":"bd-18t","type":"blocks","created_at":"2026-01-30T15:29:15.914098Z","created_by":"tayloreernisse"},{"issue_id":"bd-2fp","depends_on_id":"bd-36p","type":"blocks","created_at":"2026-01-30T15:29:15.847680Z","created_by":"tayloreernisse"},{"issue_id":"bd-2fp","depends_on_id":"bd-hrs","type":"blocks","created_at":"2026-01-30T15:29:15.880008Z","created_by":"tayloreernisse"}]} -{"id":"bd-2g50","title":"Audit and fill data gaps: lore detail view vs glab","description":"## Background\nFor lore to be the definitive read path, its single-entity detail view must return everything glab returns PLUS lore-exclusive enrichments.\n\n## Current Issue Detail Output (lore -J issues N)\nFields returned: assignees, author_username, closing_merge_requests, created_at, description, discussions, due_date, id, iid, labels, milestone, project_path, state, status_color, status_icon_name, status_name, status_synced_at, title, updated_at, web_url\n\n## Gap Analysis (Verified 2026-02-12)\n\n### Raw Payload Audit\nIssue raw_payloads store exactly 15 fields: assignees, author, closed_at, created_at, description, due_date, id, iid, labels, milestone, project_id, state, title, updated_at, web_url.\n\nFields NOT in raw payloads (require ingestion pipeline update to capture from GitLab API):\n- closed_by, confidential, upvotes, downvotes, weight, issue_type, time_stats, health_status, references\n\n### Phase 1 — Computed fields (NO schema change, NO ingestion change)\nThese can be derived from existing data:\n1. `references_full`: format!(\"{path_with_namespace}#{iid}\") — project_path already in show.rs:IssueDetail\n2. `user_notes_count`: SELECT COUNT(*) FROM notes n JOIN discussions d ON n.discussion_id = d.id WHERE d.noteable_type = 'Issue' AND d.noteable_id = ? AND n.is_system = 0\n3. `merge_requests_count`: COUNT from closing_merge_requests vec already loaded in show.rs (just .len())\n\n### Phase 2 — Extract from existing raw payloads (schema change, NO ingestion change)\n`closed_at` IS in raw_payloads for closed issues. Can be backfilled:\n1. Add `closed_at TEXT` column to issues table (migration 023)\n2. Backfill: UPDATE issues SET closed_at = json_extract((SELECT payload FROM raw_payloads WHERE id = issues.raw_payload_id), '$.closed_at') WHERE state = 'closed'\n3. Capture during ingestion going forward\n\n### Phase 3 — Requires ingestion pipeline update (schema change + API capture)\nThese fields are in the GitLab Issues API response but NOT captured by lore's ingestion:\n1. `closed_by` (object with username) — add closed_by_username TEXT to issues\n2. `confidential` (boolean) — add confidential INTEGER DEFAULT 0 to issues\n3. Both require updating src/ingestion/ to extract these fields during sync\n\n### Phase 4 — Same audit for MR detail view\nMR detail (src/cli/commands/show.rs MrDetail struct lines 14-33) already includes: closed_at, merged_at, draft, source/target branch, reviewers. Missing: approvers_count, pipeline_status.\n\n## Implementation: show.rs Modifications\n\n### IssueDetail struct (src/cli/commands/show.rs:69-91)\nAdd fields:\n```rust\npub references_full: String, // Phase 1: computed\npub user_notes_count: i64, // Phase 1: computed\npub merge_requests_count: usize, // Phase 1: computed (closing_merge_requests.len())\npub closed_at: Option, // Phase 2: from DB after migration\npub confidential: bool, // Phase 3: from DB after ingestion update\n```\n\n### SQL for computed fields\n```sql\n-- user_notes_count\nSELECT COUNT(*) FROM notes n\nJOIN discussions d ON n.discussion_id = d.id\nWHERE d.noteable_type = 'Issue' AND d.noteable_id = ?1 AND n.is_system = 0\n\n-- references_full (in Rust)\nformat!(\"{}#{}\", project_path, iid)\n\n-- merge_requests_count (in Rust)\nclosing_merge_requests.len()\n```\n\n## Migration 023 (after bd-2l3s takes 022)\n```sql\n-- migrations/023_issue_detail_fields.sql\nALTER TABLE issues ADD COLUMN closed_at TEXT;\nALTER TABLE issues ADD COLUMN confidential INTEGER NOT NULL DEFAULT 0;\n\n-- Backfill closed_at from raw_payloads\nUPDATE issues SET closed_at = (\n SELECT json_extract(rp.payload, '$.closed_at')\n FROM raw_payloads rp\n WHERE rp.id = issues.raw_payload_id\n) WHERE state = 'closed' AND raw_payload_id IS NOT NULL;\n\nINSERT INTO schema_version (version, applied_at, description)\nVALUES (23, strftime('%s', 'now') * 1000, 'Issue detail fields: closed_at, confidential');\n```\n\nNOTE: raw_payload_id column on issues — verify this exists. If issues don't have a direct FK to raw_payloads, the backfill SQL needs adjustment (may need to join through another path).\n\n## TDD Loop\nRED: Tests in src/cli/commands/show.rs:\n- test_show_issue_has_references_full: insert issue with known project_path, assert JSON output contains \"project/path#123\"\n- test_show_issue_has_notes_count: insert issue + 3 user notes + 1 system note, assert user_notes_count = 3\n- test_show_issue_closed_has_closed_at: insert closed issue with closed_at in raw_payload, run migration, verify closed_at appears\n\nGREEN: Add computed fields to IssueDetail, add migration 023 for closed_at + confidential columns\n\nVERIFY:\n```bash\ncargo test show:: && cargo clippy --all-targets -- -D warnings\ncargo run --release -- -J issues 3864 | jq '{references_full, user_notes_count, merge_requests_count}'\n```\n\n## Acceptance Criteria\n- [ ] lore -J issues N includes references_full (string, e.g., \"vs/typescript-code#3864\")\n- [ ] lore -J issues N includes user_notes_count (integer, excludes system notes)\n- [ ] lore -J issues N includes merge_requests_count (integer)\n- [ ] lore -J issues N includes closed_at (ISO string for closed issues, null for open)\n- [ ] lore -J issues N includes confidential (boolean, after Phase 3)\n- [ ] --fields minimal preset updated to include references_full\n- [ ] Migration 023 adds closed_at and confidential columns to issues table\n- [ ] Backfill SQL populates closed_at from existing raw_payloads\n- [ ] cargo test passes with new show:: tests\n\n## Edge Cases\n- Issue with zero notes: user_notes_count = 0 (not null)\n- Issue with no closing MRs: merge_requests_count = 0\n- Open issue: closed_at = null (serialized as JSON null, not omitted)\n- confidential before Phase 3: default false (safe default)\n- MR detail: different computed fields (approvers_count, pipeline_status if available)\n- Raw payload missing for very old issues (raw_payload_id = NULL): closed_at stays NULL\n- raw_payload_id column: verify it exists on the issues table before writing backfill SQL\n\n## Files to Modify\n- src/cli/commands/show.rs (IssueDetail struct + query logic)\n- src/core/db.rs (migration 023: wire into MIGRATIONS array)\n- NEW: migrations/023_issue_detail_fields.sql\n- src/ingestion/ (Phase 3: capture closed_by, confidential during sync — specify exact file after reviewing ingestion pipeline)","status":"in_progress","priority":2,"issue_type":"task","created_at":"2026-02-12T15:45:16.512418Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:35:09.578769Z","compaction_level":0,"original_size":0,"labels":["cli","cli-imp","robot-mode"],"dependencies":[{"issue_id":"bd-2g50","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-02-12T15:45:16.514148Z","created_by":"tayloreernisse"}]} +{"id":"bd-2g50","title":"Audit and fill data gaps: lore detail view vs glab","description":"## Background\nFor lore to be the definitive read path, its single-entity detail view must return everything glab returns PLUS lore-exclusive enrichments.\n\n## Current Issue Detail Output (lore -J issues N)\nFields returned: assignees, author_username, closing_merge_requests, created_at, description, discussions, due_date, id, iid, labels, milestone, project_path, state, status_color, status_icon_name, status_name, status_synced_at, title, updated_at, web_url\n\n## Gap Analysis (Verified 2026-02-12)\n\n### Raw Payload Audit\nIssue raw_payloads store exactly 15 fields: assignees, author, closed_at, created_at, description, due_date, id, iid, labels, milestone, project_id, state, title, updated_at, web_url.\n\nFields NOT in raw payloads (require ingestion pipeline update to capture from GitLab API):\n- closed_by, confidential, upvotes, downvotes, weight, issue_type, time_stats, health_status, references\n\n### Phase 1 — Computed fields (NO schema change, NO ingestion change)\nThese can be derived from existing data:\n1. `references_full`: format!(\"{path_with_namespace}#{iid}\") — project_path already in show.rs:IssueDetail\n2. `user_notes_count`: SELECT COUNT(*) FROM notes n JOIN discussions d ON n.discussion_id = d.id WHERE d.noteable_type = 'Issue' AND d.noteable_id = ? AND n.is_system = 0\n3. `merge_requests_count`: COUNT from closing_merge_requests vec already loaded in show.rs (just .len())\n\n### Phase 2 — Extract from existing raw payloads (schema change, NO ingestion change)\n`closed_at` IS in raw_payloads for closed issues. Can be backfilled:\n1. Add `closed_at TEXT` column to issues table (migration 023)\n2. Backfill: UPDATE issues SET closed_at = json_extract((SELECT payload FROM raw_payloads WHERE id = issues.raw_payload_id), '$.closed_at') WHERE state = 'closed'\n3. Capture during ingestion going forward\n\n### Phase 3 — Requires ingestion pipeline update (schema change + API capture)\nThese fields are in the GitLab Issues API response but NOT captured by lore's ingestion:\n1. `closed_by` (object with username) — add closed_by_username TEXT to issues\n2. `confidential` (boolean) — add confidential INTEGER DEFAULT 0 to issues\n3. Both require updating src/ingestion/ to extract these fields during sync\n\n### Phase 4 — Same audit for MR detail view\nMR detail (src/cli/commands/show.rs MrDetail struct lines 14-33) already includes: closed_at, merged_at, draft, source/target branch, reviewers. Missing: approvers_count, pipeline_status.\n\n## Implementation: show.rs Modifications\n\n### IssueDetail struct (src/cli/commands/show.rs:69-91)\nAdd fields:\n```rust\npub references_full: String, // Phase 1: computed\npub user_notes_count: i64, // Phase 1: computed\npub merge_requests_count: usize, // Phase 1: computed (closing_merge_requests.len())\npub closed_at: Option, // Phase 2: from DB after migration\npub confidential: bool, // Phase 3: from DB after ingestion update\n```\n\n### SQL for computed fields\n```sql\n-- user_notes_count\nSELECT COUNT(*) FROM notes n\nJOIN discussions d ON n.discussion_id = d.id\nWHERE d.noteable_type = 'Issue' AND d.noteable_id = ?1 AND n.is_system = 0\n\n-- references_full (in Rust)\nformat!(\"{}#{}\", project_path, iid)\n\n-- merge_requests_count (in Rust)\nclosing_merge_requests.len()\n```\n\n## Migration 023 (after bd-2l3s takes 022)\n```sql\n-- migrations/023_issue_detail_fields.sql\nALTER TABLE issues ADD COLUMN closed_at TEXT;\nALTER TABLE issues ADD COLUMN confidential INTEGER NOT NULL DEFAULT 0;\n\n-- Backfill closed_at from raw_payloads\nUPDATE issues SET closed_at = (\n SELECT json_extract(rp.payload, '$.closed_at')\n FROM raw_payloads rp\n WHERE rp.id = issues.raw_payload_id\n) WHERE state = 'closed' AND raw_payload_id IS NOT NULL;\n\nINSERT INTO schema_version (version, applied_at, description)\nVALUES (23, strftime('%s', 'now') * 1000, 'Issue detail fields: closed_at, confidential');\n```\n\nNOTE: raw_payload_id column on issues — verify this exists. If issues don't have a direct FK to raw_payloads, the backfill SQL needs adjustment (may need to join through another path).\n\n## TDD Loop\nRED: Tests in src/cli/commands/show.rs:\n- test_show_issue_has_references_full: insert issue with known project_path, assert JSON output contains \"project/path#123\"\n- test_show_issue_has_notes_count: insert issue + 3 user notes + 1 system note, assert user_notes_count = 3\n- test_show_issue_closed_has_closed_at: insert closed issue with closed_at in raw_payload, run migration, verify closed_at appears\n\nGREEN: Add computed fields to IssueDetail, add migration 023 for closed_at + confidential columns\n\nVERIFY:\n```bash\ncargo test show:: && cargo clippy --all-targets -- -D warnings\ncargo run --release -- -J issues 3864 | jq '{references_full, user_notes_count, merge_requests_count}'\n```\n\n## Acceptance Criteria\n- [ ] lore -J issues N includes references_full (string, e.g., \"vs/typescript-code#3864\")\n- [ ] lore -J issues N includes user_notes_count (integer, excludes system notes)\n- [ ] lore -J issues N includes merge_requests_count (integer)\n- [ ] lore -J issues N includes closed_at (ISO string for closed issues, null for open)\n- [ ] lore -J issues N includes confidential (boolean, after Phase 3)\n- [ ] --fields minimal preset updated to include references_full\n- [ ] Migration 023 adds closed_at and confidential columns to issues table\n- [ ] Backfill SQL populates closed_at from existing raw_payloads\n- [ ] cargo test passes with new show:: tests\n\n## Edge Cases\n- Issue with zero notes: user_notes_count = 0 (not null)\n- Issue with no closing MRs: merge_requests_count = 0\n- Open issue: closed_at = null (serialized as JSON null, not omitted)\n- confidential before Phase 3: default false (safe default)\n- MR detail: different computed fields (approvers_count, pipeline_status if available)\n- Raw payload missing for very old issues (raw_payload_id = NULL): closed_at stays NULL\n- raw_payload_id column: verify it exists on the issues table before writing backfill SQL\n\n## Files to Modify\n- src/cli/commands/show.rs (IssueDetail struct + query logic)\n- src/core/db.rs (migration 023: wire into MIGRATIONS array)\n- NEW: migrations/023_issue_detail_fields.sql\n- src/ingestion/ (Phase 3: capture closed_by, confidential during sync — specify exact file after reviewing ingestion pipeline)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T15:45:16.512418Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:49:01.580183Z","closed_at":"2026-02-12T16:49:01.580133Z","close_reason":"Data gaps filled: references_full, user_notes_count, merge_requests_count, closed_at, confidential via migration 023","compaction_level":0,"original_size":0,"labels":["cli","cli-imp","robot-mode"],"dependencies":[{"issue_id":"bd-2g50","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-02-12T15:45:16.514148Z","created_by":"tayloreernisse"}]} {"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} @@ -182,7 +182,7 @@ {"id":"bd-88m","title":"[CP1] Issue ingestion module","description":"Fetch and store issues with cursor-based incremental sync.\n\n## Module\nsrc/ingestion/issues.rs\n\n## Key Structs\n\n### IngestIssuesResult\n- fetched: usize\n- upserted: usize\n- labels_created: usize\n- issues_needing_discussion_sync: Vec\n\n### IssueForDiscussionSync\n- local_issue_id: i64\n- iid: i64\n- updated_at: i64\n\n## Main Function\npub async fn ingest_issues(conn, client, config, project_id, gitlab_project_id) -> Result\n\n## Logic\n1. Get current cursor from sync_cursors (updated_at_cursor, tie_breaker_id)\n2. Paginate through issues updated after cursor with cursor_rewind_seconds\n3. Apply local filtering for tuple cursor semantics:\n - Skip if issue.updated_at < cursor_updated_at\n - Skip if issue.updated_at == cursor_updated_at AND issue.id <= cursor_gitlab_id\n4. For each issue passing filter:\n - Begin transaction\n - Store raw payload (compressed)\n - Transform and upsert issue\n - Clear existing label links (DELETE FROM issue_labels)\n - Extract and upsert labels\n - Link issue to labels via junction\n - Commit transaction\n - Track for discussion sync eligibility\n5. Incremental cursor update every 100 issues\n6. Final cursor update\n7. Determine issues needing discussion sync: where updated_at > discussions_synced_for_updated_at\n\n## Helper Functions\n- get_cursor(conn, project_id) -> (Option, Option)\n- get_discussions_synced_at(conn, issue_id) -> Option\n- upsert_issue(conn, issue, payload_id) -> usize\n- get_local_issue_id(conn, gitlab_id) -> i64\n- clear_issue_labels(conn, issue_id)\n- upsert_label(conn, label) -> bool\n- get_label_id(conn, project_id, name) -> i64\n- link_issue_label(conn, issue_id, label_id)\n- update_cursor(conn, project_id, resource_type, updated_at, gitlab_id)\n\nFiles: src/ingestion/mod.rs, src/ingestion/issues.rs\nTests: tests/issue_ingestion_tests.rs\nDone when: Issues, labels, issue_labels populated correctly with resumable cursor","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T16:57:35.655708Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.806982Z","deleted_at":"2026-01-25T17:02:01.806977Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-8con","title":"lore related: semantic similarity discovery","description":"## Background\nGiven any entity or free text, find semantically related entities using vector embeddings. No other GitLab tool does this — glab, GitLab Advanced Search, and even paid tiers are keyword-only. This finds conceptual connections humans miss.\n\n## Current Infrastructure (Verified 2026-02-12)\n- sqlite-vec extension loaded via sqlite3_vec_init in src/core/db.rs:84\n- Embeddings stored in: embedding_metadata table (chunk info) + vec0 virtual table named `embeddings` (vectors)\n- Migration 009 creates embedding infrastructure\n- search_vector() at src/search/vector.rs:43 — works with sqlite-vec KNN queries\n- OllamaClient::embed_batch() at src/embedding/ollama.rs:103 — batch embedding\n- Model: nomic-embed-text, 768 dimensions, context_length=2048 tokens (~1500 bytes)\n- 61K documents in DB, embedding coverage TBD\n\n### sqlite-vec Distance Metric\nThe `embeddings` virtual table is `vec0(embedding float[768])`. sqlite-vec's MATCH query returns L2 (Euclidean) distance by default. Lower distance = more similar. The `search_vector()` function returns `VectorResult { document_id: i64, distance: f64 }`.\n\n## Approach\n\n### Entity Mode: lore related issues N\n1. Look up document for issue N:\n```sql\nSELECT d.id, d.content_text\nFROM documents d\nJOIN issues i ON d.source_type = 'issue' AND d.source_id = i.id\nWHERE i.iid = ?1 AND i.project_id = (SELECT id FROM projects WHERE ...)\n```\nNOTE: `documents.source_id` is the internal DB id from the source table (issues.id), NOT the GitLab IID. See migration 007 comment: `source_id INTEGER NOT NULL -- local DB id in the source table`.\n\n2. Get its embedding: Look up via embedding_metadata which maps document_id -> rowid in the vec0 table:\n```sql\nSELECT em.rowid\nFROM embedding_metadata em\nWHERE em.document_id = ?1\nLIMIT 1 -- use first chunk's embedding as representative\n```\nThen extract the embedding vector from the vec0 table to use as the KNN query.\n\nAlternatively, embed the document's content_text on-the-fly via OllamaClient (simpler, more robust):\n```rust\nlet embedding = client.embed_batch(&[&doc.content_text]).await?[0].clone();\n```\n\n3. Call search_vector(conn, &embedding, limit * 2) for KNN — multiply limit to have room after filtering self\n4. Exclude self (filter out source document_id from results)\n5. Hydrate results: join documents -> issues/mrs/discussions for title, url, labels, author\n6. Compute shared_labels: parse `documents.label_names` (JSON array string) for both source and each result, intersect\n7. Return ranked list\n\n### Query Mode: lore related 'free text'\n1. Embed query via OllamaClient::embed_batch(&[query_text])\n2. Call search_vector(conn, &query_embedding, limit)\n3. Hydrate and return (same as entity mode minus self-exclusion)\n\n### Key Design Decision\nThis is intentionally SIMPLER than hybrid search. No FTS, no RRF. Pure vector similarity. The point is conceptual relatedness, not keyword matching.\n\n### Distance to Similarity Score Conversion\nsqlite-vec returns L2 (Euclidean) distance. Convert to 0-1 similarity:\n```rust\n/// Convert L2 distance to a 0-1 similarity score.\n/// Uses inverse relationship: closer (lower distance) = higher similarity.\n/// The +1 prevents division by zero and ensures score is in (0, 1].\nfn distance_to_similarity(distance: f64) -> f64 {\n 1.0 / (1.0 + distance)\n}\n```\nFor normalized embeddings (which nomic-embed-text produces), L2 distance ranges roughly 0-2. This formula maps:\n- distance 0.0 -> similarity 1.0 (identical)\n- distance 1.0 -> similarity 0.5\n- distance 2.0 -> similarity 0.33\n\n### Label Extraction for shared_labels\n```rust\nfn parse_label_names(label_names_json: &Option) -> HashSet {\n label_names_json\n .as_deref()\n .and_then(|s| serde_json::from_str::>(s).ok())\n .unwrap_or_default()\n .into_iter()\n .collect()\n}\n\nlet source_labels = parse_label_names(&source_doc.label_names);\nlet result_labels = parse_label_names(&result_doc.label_names);\nlet shared: Vec = source_labels.intersection(&result_labels).cloned().collect();\n```\n\n## Function Signatures\n\n```rust\n// New: src/cli/commands/related.rs\npub struct RelatedArgs {\n pub entity_type: Option, // \"issues\" or \"mrs\"\n pub entity_iid: Option,\n pub query: Option, // free text mode\n pub project: Option,\n pub limit: Option,\n}\n\npub async fn run_related(\n config: &Config,\n args: RelatedArgs,\n) -> Result\n\n// Reuse from src/search/vector.rs:43\npub fn search_vector(\n conn: &Connection,\n query_embedding: &[f32],\n limit: usize,\n) -> Result>\n// VectorResult { document_id: i64, distance: f64 }\n\n// Reuse from src/embedding/ollama.rs:103\npub async fn embed_batch(&self, texts: &[&str]) -> Result>>\n```\n\n## Robot Mode Output Schema\n```json\n{\n \"ok\": true,\n \"data\": {\n \"source\": { \"type\": \"issue\", \"iid\": 3864, \"title\": \"...\" },\n \"query\": \"switch throw time...\",\n \"results\": [{\n \"source_type\": \"issue\",\n \"iid\": 3800,\n \"title\": \"Rail Break Card\",\n \"url\": \"...\",\n \"similarity_score\": 0.87,\n \"shared_labels\": [\"customer:BNSF\"],\n \"shared_authors\": [],\n \"project_path\": \"vs/typescript-code\"\n }]\n },\n \"meta\": { \"elapsed_ms\": 42, \"mode\": \"entity\", \"embedding_dims\": 768, \"distance_metric\": \"l2\" }\n}\n```\n\n## Clap Registration\n```rust\n// In src/main.rs Commands enum, add:\nRelated {\n /// Entity type (\"issues\" or \"mrs\") or free text query\n query_or_type: String,\n /// Entity IID (when first arg is entity type)\n iid: Option,\n /// Maximum results\n #[arg(short = 'n', long, default_value = \"10\")]\n limit: usize,\n /// Scope to project (fuzzy match)\n #[arg(short, long)]\n project: Option,\n},\n```\n\n## TDD Loop\nRED: Tests in src/cli/commands/related.rs:\n- test_related_entity_excludes_self: insert doc + embedding for issue, query related, assert source doc not in results\n- test_related_shared_labels: insert 2 docs with overlapping labels (JSON in label_names), assert shared_labels computed correctly\n- test_related_empty_embeddings: no embeddings in DB, assert exit code 14 with helpful error\n- test_related_query_mode: embed free text via mock, assert results returned\n- test_related_similarity_score_range: all scores between 0.0 and 1.0\n- test_distance_to_similarity: unit test the conversion function (0.0->1.0, 1.0->0.5, large->~0.0)\n\nGREEN: Implement related command using search_vector + hydration\n\nVERIFY:\n```bash\ncargo test related:: && cargo clippy --all-targets -- -D warnings\ncargo run --release -- -J related issues 3864 -n 5 | jq '.data.results[0].similarity_score'\n```\n\n## Acceptance Criteria\n- [ ] lore related issues N returns top-K semantically similar entities\n- [ ] lore related mrs N works for merge requests\n- [ ] lore related 'free text' works as concept search (requires Ollama)\n- [ ] Results exclude the input entity itself\n- [ ] similarity_score is 0-1 range (higher = more similar), converted from L2 distance\n- [ ] Robot mode includes shared_labels (from documents.label_names JSON), shared_authors per result\n- [ ] Human mode shows ranked list with titles, scores, common labels\n- [ ] No embeddings in DB: exit code 14 with message \"Run 'lore embed' first\"\n- [ ] Ollama unavailable (query mode only): exit code 14 with suggestion\n- [ ] Performance: <1s for 61K documents\n- [ ] Command registered in main.rs and robot-docs\n\n## Edge Cases\n- Entity has no embedding (added after last lore embed): embed its content_text on-the-fly via OllamaClient, or exit 14 if Ollama unavailable\n- All results have very low similarity (<0.3): include warning \"No strongly related entities found\"\n- Entity is a discussion (not issue/MR): should still work (documents table has discussion docs)\n- Multiple documents per entity (discussion docs): use the entity-level document, not discussion subdocs\n- Free text query very short (1-2 words): may produce noisy results, add warning\n- Entity not found in DB: exit code 17 with suggestion to sync\n- Ambiguous project: exit code 18 with suggestion to use -p flag\n- documents.label_names may be NULL or invalid JSON — parse_label_names handles both gracefully\n\n## Dependency Context\n- **bd-1ksf (hybrid search)**: BLOCKER. Shares OllamaClient infrastructure. Also ensures async search.rs patterns are established. Related reuses the same vector search infrastructure.\n\n## Files to Create/Modify\n- NEW: src/cli/commands/related.rs\n- src/cli/commands/mod.rs (add pub mod related; re-export)\n- src/main.rs (register Related subcommand in Commands enum, add handle_related fn)\n- Reuse: search_vector() from src/search/vector.rs, OllamaClient from src/embedding/ollama.rs","status":"open","priority":2,"issue_type":"feature","created_at":"2026-02-12T15:46:58.665923Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:31:35.489138Z","compaction_level":0,"original_size":0,"labels":["cli-imp","intelligence","search"],"dependencies":[{"issue_id":"bd-8con","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-02-12T15:46:58.668835Z","created_by":"tayloreernisse"},{"issue_id":"bd-8con","depends_on_id":"bd-1ksf","type":"blocks","created_at":"2026-02-12T15:47:51.795631Z","created_by":"tayloreernisse"}]} {"id":"bd-8t4","title":"Extract cross-references from resource_state_events","description":"## Background\nresource_state_events includes source_merge_request (with iid) for 'closed by MR' events. After state events are stored (Gate 1), post-processing extracts these into entity_references for the cross-reference graph.\n\n## Approach\nCreate src/core/references.rs (new module) or add to events_db.rs:\n\n```rust\n/// Extract cross-references from stored state events and insert into entity_references.\n/// Looks for state events with source_merge_request_id IS NOT NULL (meaning \"closed by MR\").\n/// \n/// Directionality: source = MR (that caused the close), target = issue (that was closed)\npub fn extract_refs_from_state_events(\n conn: &Connection,\n project_id: i64,\n) -> Result // returns count of new references inserted\n```\n\nSQL logic:\n```sql\nINSERT OR IGNORE INTO entity_references (\n source_entity_type, source_entity_id,\n target_entity_type, target_entity_id,\n reference_type, source_method, created_at\n)\nSELECT\n 'merge_request',\n mr.id,\n 'issue',\n rse.issue_id,\n 'closes',\n 'api_state_event',\n rse.created_at\nFROM resource_state_events rse\nJOIN merge_requests mr ON mr.project_id = rse.project_id AND mr.iid = rse.source_merge_request_id\nWHERE rse.source_merge_request_id IS NOT NULL\n AND rse.issue_id IS NOT NULL\n AND rse.project_id = ?1;\n```\n\nKey: source_merge_request_id stores the MR iid, so we JOIN on merge_requests.iid to get the local DB id.\n\nRegister in src/core/mod.rs: `pub mod references;`\n\nCall this after drain_dependent_queue in the sync pipeline (after all state events are stored).\n\n## Acceptance Criteria\n- [ ] State events with source_merge_request_id produce 'closes' references\n- [ ] Source = MR (resolved by iid), target = issue\n- [ ] source_method = 'api_state_event'\n- [ ] INSERT OR IGNORE prevents duplicates with api_closes_issues data\n- [ ] Returns count of newly inserted references\n- [ ] No-op when no state events have source_merge_request_id\n\n## Files\n- src/core/references.rs (new)\n- src/core/mod.rs (add `pub mod references;`)\n- src/cli/commands/sync.rs (call after drain step)\n\n## TDD Loop\nRED: tests/references_tests.rs:\n- `test_extract_refs_from_state_events_basic` - seed a \"closed\" state event with source_merge_request_id, verify entity_reference created\n- `test_extract_refs_dedup_with_closes_issues` - insert ref from closes_issues API first, verify state event extraction doesn't duplicate\n- `test_extract_refs_no_source_mr` - state events without source_merge_request_id produce no refs\n\nSetup: create_test_db with migrations 001-011, seed project + issue + MR + state events.\n\nGREEN: Implement extract_refs_from_state_events\n\nVERIFY: `cargo test references -- --nocapture`\n\n## Edge Cases\n- source_merge_request_id may reference an MR not synced locally (cross-project close) — the JOIN will produce no match, which is correct behavior (ref simply not created)\n- Multiple state events can reference the same MR for the same issue (reopen + re-close) — INSERT OR IGNORE handles dedup\n- The merge_requests table might not have the MR yet if sync is still running — call this after all dependent fetches complete","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:32:33.619606Z","created_by":"tayloreernisse","updated_at":"2026-02-04T20:13:28.219791Z","closed_at":"2026-02-04T20:13:28.219633Z","compaction_level":0,"original_size":0,"labels":["extraction","gate-2","phase-b"],"dependencies":[{"issue_id":"bd-8t4","depends_on_id":"bd-1ep","type":"blocks","created_at":"2026-02-02T21:32:42.945176Z","created_by":"tayloreernisse"},{"issue_id":"bd-8t4","depends_on_id":"bd-1se","type":"parent-child","created_at":"2026-02-02T21:32:33.621025Z","created_by":"tayloreernisse"},{"issue_id":"bd-8t4","depends_on_id":"bd-hu3","type":"blocks","created_at":"2026-02-02T22:41:50.562935Z","created_by":"tayloreernisse"}]} -{"id":"bd-91j1","title":"Comprehensive robot-docs as agent bootstrap","description":"## Background\nAgents reach for glab because they already know it from training data. lore robot-docs exists but is not comprehensive enough to serve as a zero-training bootstrap. An agent encountering lore for the first time should be able to use any command correctly after reading robot-docs output alone.\n\n## Current State (Verified 2026-02-12)\n- `handle_robot_docs()` at src/main.rs:2069\n- Called at no-args in robot mode (main.rs:165) and via Commands::RobotDocs { brief } (main.rs:229)\n- Current output top-level keys: name, version, description, activation, commands, aliases, exit_codes, clap_error_codes, error_format, workflows\n- Missing: response_schema per command, example_output per command, quick_start section, glab equivalence table\n- --brief flag exists but returns shorter version of same structure\n- main.rs is 2579 lines total\n\n## Current robot-docs Output Structure\n```json\n{\n \"name\": \"lore\",\n \"version\": \"0.6.1\",\n \"description\": \"...\",\n \"activation\": { \"flags\": [\"--robot\", \"-J\"], \"env\": \"LORE_ROBOT=1\", \"auto_detect\": \"non-TTY\" },\n \"commands\": [{ \"name\": \"...\", \"description\": \"...\", \"flags\": [...], \"example\": \"...\" }],\n \"aliases\": { ... },\n \"exit_codes\": { ... },\n \"clap_error_codes\": { ... },\n \"error_format\": { ... },\n \"workflows\": { ... }\n}\n```\n\n## Approach\n\n### 1. Add quick_start section\nTop-level key with glab-to-lore translation and lore-exclusive feature summary:\n```json\n\"quick_start\": {\n \"glab_equivalents\": [\n { \"glab\": \"glab issue list\", \"lore\": \"lore -J issues -n 50\", \"note\": \"Richer: includes labels, status, closing MRs\" },\n { \"glab\": \"glab issue view 123\", \"lore\": \"lore -J issues 123\", \"note\": \"Includes discussions, work-item status\" },\n { \"glab\": \"glab mr list\", \"lore\": \"lore -J mrs\", \"note\": \"Includes draft status, reviewers\" },\n { \"glab\": \"glab mr view 456\", \"lore\": \"lore -J mrs 456\", \"note\": \"Includes discussions, file changes\" },\n { \"glab\": \"glab api '/projects/:id/issues'\", \"lore\": \"lore -J issues -p project\", \"note\": \"Fuzzy project matching\" }\n ],\n \"lore_exclusive\": [\n \"search: FTS5 + vector hybrid search across all entities\",\n \"who: Expert/workload/reviews analysis per file path or person\",\n \"timeline: Chronological event reconstruction across entities\",\n \"stats: Database statistics with document/note/discussion counts\",\n \"count: Entity counts with state breakdowns\"\n ]\n}\n```\n\n### 2. Add response_schema per command\nFor each command in the commands array, add a `response_schema` field showing the JSON shape:\n```json\n{\n \"name\": \"issues\",\n \"response_schema\": {\n \"ok\": \"boolean\",\n \"data\": { \"type\": \"array|object\", \"fields\": [\"iid\", \"title\", \"state\", \"...\"] },\n \"meta\": { \"elapsed_ms\": \"integer\" }\n }\n}\n```\nCommands with multiple output shapes (list vs detail) need both documented.\n\n### 3. Add example_output per command\nRealistic truncated JSON for each command. Keep each example under 500 bytes.\n\n### 4. Token budget enforcement\n- --brief mode: ONLY quick_start + command names + invocation syntax. Target <4000 tokens (~16000 bytes).\n- Full mode: everything. Target <12000 tokens (~48000 bytes).\n- Measure with: `cargo run --release -- --robot robot-docs --brief | wc -c`\n\n## TDD Loop\nRED: Tests in src/main.rs or new src/cli/commands/robot_docs.rs:\n- test_robot_docs_has_quick_start: parse output JSON, assert quick_start.glab_equivalents array has >= 5 entries\n- test_robot_docs_brief_size: --brief output < 16000 bytes\n- test_robot_docs_full_size: full output < 48000 bytes\n- test_robot_docs_has_response_schemas: every command entry has response_schema key\n- test_robot_docs_commands_complete: assert all registered commands appear (issues, mrs, search, who, timeline, count, stats, sync, embed, doctor, health, ingest, generate-docs, show)\n\nGREEN: Add quick_start, response_schema, example_output to robot-docs output\n\nVERIFY:\n```bash\ncargo test robot_docs && cargo clippy --all-targets -- -D warnings\ncargo run --release -- --robot robot-docs | jq '.quick_start.glab_equivalents | length'\n# Should return >= 5\ncargo run --release -- --robot robot-docs --brief | wc -c\n# Should be < 16000\n```\n\n## Acceptance Criteria\n- [ ] robot-docs JSON has quick_start.glab_equivalents array with >= 5 entries\n- [ ] robot-docs JSON has quick_start.lore_exclusive array\n- [ ] Every command entry has response_schema showing the JSON shape\n- [ ] Every command entry has example_output with realistic truncated data\n- [ ] --brief output is under 16000 bytes (~4000 tokens)\n- [ ] Full output is under 48000 bytes (~12000 tokens)\n- [ ] An agent reading ONLY robot-docs can correctly invoke any lore command\n- [ ] cargo test passes with new robot_docs tests\n\n## Edge Cases\n- Commands with multiple output shapes (e.g., issues list vs issues detail via iid) need both schemas documented\n- --fields flag changes output shape -- document the effect in the response_schema\n- robot-docs output must be stable across versions (agents may cache it)\n- Version field should match Cargo.toml version\n\n## Files to Modify\n- src/main.rs fn handle_robot_docs() (~line 2069) — add quick_start section, response_schema, example_output\n- Consider extracting to src/cli/commands/robot_docs.rs if the function exceeds 200 lines","status":"in_progress","priority":1,"issue_type":"task","created_at":"2026-02-12T15:44:40.495479Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:35:09.486079Z","compaction_level":0,"original_size":0,"labels":["cli","cli-imp","robot-mode"],"dependencies":[{"issue_id":"bd-91j1","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-02-12T15:44:40.497236Z","created_by":"tayloreernisse"}]} +{"id":"bd-91j1","title":"Comprehensive robot-docs as agent bootstrap","description":"## Background\nAgents reach for glab because they already know it from training data. lore robot-docs exists but is not comprehensive enough to serve as a zero-training bootstrap. An agent encountering lore for the first time should be able to use any command correctly after reading robot-docs output alone.\n\n## Current State (Verified 2026-02-12)\n- `handle_robot_docs()` at src/main.rs:2069\n- Called at no-args in robot mode (main.rs:165) and via Commands::RobotDocs { brief } (main.rs:229)\n- Current output top-level keys: name, version, description, activation, commands, aliases, exit_codes, clap_error_codes, error_format, workflows\n- Missing: response_schema per command, example_output per command, quick_start section, glab equivalence table\n- --brief flag exists but returns shorter version of same structure\n- main.rs is 2579 lines total\n\n## Current robot-docs Output Structure\n```json\n{\n \"name\": \"lore\",\n \"version\": \"0.6.1\",\n \"description\": \"...\",\n \"activation\": { \"flags\": [\"--robot\", \"-J\"], \"env\": \"LORE_ROBOT=1\", \"auto_detect\": \"non-TTY\" },\n \"commands\": [{ \"name\": \"...\", \"description\": \"...\", \"flags\": [...], \"example\": \"...\" }],\n \"aliases\": { ... },\n \"exit_codes\": { ... },\n \"clap_error_codes\": { ... },\n \"error_format\": { ... },\n \"workflows\": { ... }\n}\n```\n\n## Approach\n\n### 1. Add quick_start section\nTop-level key with glab-to-lore translation and lore-exclusive feature summary:\n```json\n\"quick_start\": {\n \"glab_equivalents\": [\n { \"glab\": \"glab issue list\", \"lore\": \"lore -J issues -n 50\", \"note\": \"Richer: includes labels, status, closing MRs\" },\n { \"glab\": \"glab issue view 123\", \"lore\": \"lore -J issues 123\", \"note\": \"Includes discussions, work-item status\" },\n { \"glab\": \"glab mr list\", \"lore\": \"lore -J mrs\", \"note\": \"Includes draft status, reviewers\" },\n { \"glab\": \"glab mr view 456\", \"lore\": \"lore -J mrs 456\", \"note\": \"Includes discussions, file changes\" },\n { \"glab\": \"glab api '/projects/:id/issues'\", \"lore\": \"lore -J issues -p project\", \"note\": \"Fuzzy project matching\" }\n ],\n \"lore_exclusive\": [\n \"search: FTS5 + vector hybrid search across all entities\",\n \"who: Expert/workload/reviews analysis per file path or person\",\n \"timeline: Chronological event reconstruction across entities\",\n \"stats: Database statistics with document/note/discussion counts\",\n \"count: Entity counts with state breakdowns\"\n ]\n}\n```\n\n### 2. Add response_schema per command\nFor each command in the commands array, add a `response_schema` field showing the JSON shape:\n```json\n{\n \"name\": \"issues\",\n \"response_schema\": {\n \"ok\": \"boolean\",\n \"data\": { \"type\": \"array|object\", \"fields\": [\"iid\", \"title\", \"state\", \"...\"] },\n \"meta\": { \"elapsed_ms\": \"integer\" }\n }\n}\n```\nCommands with multiple output shapes (list vs detail) need both documented.\n\n### 3. Add example_output per command\nRealistic truncated JSON for each command. Keep each example under 500 bytes.\n\n### 4. Token budget enforcement\n- --brief mode: ONLY quick_start + command names + invocation syntax. Target <4000 tokens (~16000 bytes).\n- Full mode: everything. Target <12000 tokens (~48000 bytes).\n- Measure with: `cargo run --release -- --robot robot-docs --brief | wc -c`\n\n## TDD Loop\nRED: Tests in src/main.rs or new src/cli/commands/robot_docs.rs:\n- test_robot_docs_has_quick_start: parse output JSON, assert quick_start.glab_equivalents array has >= 5 entries\n- test_robot_docs_brief_size: --brief output < 16000 bytes\n- test_robot_docs_full_size: full output < 48000 bytes\n- test_robot_docs_has_response_schemas: every command entry has response_schema key\n- test_robot_docs_commands_complete: assert all registered commands appear (issues, mrs, search, who, timeline, count, stats, sync, embed, doctor, health, ingest, generate-docs, show)\n\nGREEN: Add quick_start, response_schema, example_output to robot-docs output\n\nVERIFY:\n```bash\ncargo test robot_docs && cargo clippy --all-targets -- -D warnings\ncargo run --release -- --robot robot-docs | jq '.quick_start.glab_equivalents | length'\n# Should return >= 5\ncargo run --release -- --robot robot-docs --brief | wc -c\n# Should be < 16000\n```\n\n## Acceptance Criteria\n- [ ] robot-docs JSON has quick_start.glab_equivalents array with >= 5 entries\n- [ ] robot-docs JSON has quick_start.lore_exclusive array\n- [ ] Every command entry has response_schema showing the JSON shape\n- [ ] Every command entry has example_output with realistic truncated data\n- [ ] --brief output is under 16000 bytes (~4000 tokens)\n- [ ] Full output is under 48000 bytes (~12000 tokens)\n- [ ] An agent reading ONLY robot-docs can correctly invoke any lore command\n- [ ] cargo test passes with new robot_docs tests\n\n## Edge Cases\n- Commands with multiple output shapes (e.g., issues list vs issues detail via iid) need both schemas documented\n- --fields flag changes output shape -- document the effect in the response_schema\n- robot-docs output must be stable across versions (agents may cache it)\n- Version field should match Cargo.toml version\n\n## Files to Modify\n- src/main.rs fn handle_robot_docs() (~line 2069) — add quick_start section, response_schema, example_output\n- Consider extracting to src/cli/commands/robot_docs.rs if the function exceeds 200 lines","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-12T15:44:40.495479Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:49:01.043915Z","closed_at":"2026-02-12T16:49:01.043832Z","close_reason":"Robot-docs enhanced with quick_start (glab equivalents, lore exclusives, read/write split) and example_output for issues/mrs/search/who","compaction_level":0,"original_size":0,"labels":["cli","cli-imp","robot-mode"],"dependencies":[{"issue_id":"bd-91j1","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-02-12T15:44:40.497236Z","created_by":"tayloreernisse"}]} {"id":"bd-9av","title":"[CP1] gi sync-status enhancement","description":"Enhance sync-status from CP0 stub to show issue cursors.\n\n## Changes to src/cli/commands/sync_status.rs\n\nUpdate the existing stub to show:\n- Last run timestamp and duration\n- Cursor positions per project (issues resource_type)\n- Entity counts (issues, discussions, notes)\n\n## Output Format\nLast sync: 2026-01-25 10:30:00 (succeeded, 45s)\n\nCursors:\n group/project-one\n issues: 2026-01-25T10:25:00Z (gitlab_id: 12345678)\n\nCounts:\n Issues: 1,234\n Discussions: 5,678\n Notes: 23,456 (4,567 system)\n\nFiles: src/cli/commands/sync_status.rs\nDone when: Shows cursor positions and counts after ingestion","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T16:58:27.246825Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.968507Z","deleted_at":"2026-01-25T17:02:01.968503Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-9dd","title":"Implement 'lore trace' command with human and robot output","description":"## Background\n\nThe trace command is Gate 5's capstone CLI. It answers 'Why was this code introduced?' by building file -> MR -> issue -> discussion chains.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 5.3.\n\n## Codebase Context\n\n- CLI pattern: same as file-history (Commands enum, handler in main.rs)\n- trace.rs (bd-2n4): run_trace() returns TraceResult with chains\n- Path parsing: support 'src/foo.rs:45' syntax (line number for future Tier 2)\n- merge_requests.merged_at exists (migration 006) — use COALESCE(merged_at, updated_at) for ordering\n\n## Approach\n\n### 1. TraceArgs (`src/cli/mod.rs`):\n```rust\n#[derive(Parser)]\npub struct TraceArgs {\n pub path: String, // supports :line suffix\n #[arg(short = 'p', long)] pub project: Option,\n #[arg(long)] pub discussions: bool,\n #[arg(long = \"no-follow-renames\")] pub no_follow_renames: bool,\n #[arg(short = 'n', long = \"limit\", default_value = \"20\")] pub limit: usize,\n}\n```\n\n### 2. Path parsing:\n```rust\nfn parse_trace_path(input: &str) -> (String, Option) {\n if let Some((path, line)) = input.rsplit_once(':') {\n if let Ok(n) = line.parse::() { return (path.to_string(), Some(n)); }\n }\n (input.to_string(), None)\n}\n```\nIf line present: warn 'Line-level tracing requires Tier 2. Showing file-level results.'\n\n### 3. Human output shows chains with MR -> issue -> discussion context\n\n### 4. Robot JSON:\n```json\n{\"ok\": true, \"data\": {\"path\": \"...\", \"resolved_paths\": [...], \"trace_chains\": [...]}, \"meta\": {\"tier\": \"api_only\", \"line_requested\": null}}\n```\n\n## Acceptance Criteria\n\n- [ ] `lore trace src/foo.rs` with human output\n- [ ] `lore --robot trace src/foo.rs` with JSON\n- [ ] :line suffix parses and emits Tier 2 warning\n- [ ] -p, --discussions, --no-follow-renames, -n all work\n- [ ] Rename-aware via resolve_rename_chain\n- [ ] meta.tier = 'api_only'\n- [ ] Added to VALID_COMMANDS and robot-docs\n- [ ] `cargo check --all-targets` passes\n\n## Files\n\n- `src/cli/mod.rs` (TraceArgs + Commands::Trace)\n- `src/cli/commands/trace.rs` (NEW)\n- `src/cli/commands/mod.rs` (re-export)\n- `src/main.rs` (handler + VALID_COMMANDS + robot-docs)\n\n## TDD Loop\n\nRED:\n- `test_parse_trace_path_simple` - \"src/foo.rs\" -> (path, None)\n- `test_parse_trace_path_with_line` - \"src/foo.rs:42\" -> (path, Some(42))\n- `test_parse_trace_path_windows` - \"C:/foo.rs\" -> (path, None) — don't misparse drive letter\n\nGREEN: Implement CLI wiring and handlers.\n\nVERIFY: `cargo check --all-targets`\n\n## Edge Cases\n\n- Windows paths: don't misparse C: as line number\n- No MR data: friendly message with suggestion to sync\n- Very deep rename chain: bounded by resolve_rename_chain","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-02T21:34:32.788530Z","created_by":"tayloreernisse","updated_at":"2026-02-05T19:57:11.527220Z","compaction_level":0,"original_size":0,"labels":["cli","gate-5","phase-b"],"dependencies":[{"issue_id":"bd-9dd","depends_on_id":"bd-1ht","type":"parent-child","created_at":"2026-02-02T21:34:32.789920Z","created_by":"tayloreernisse"},{"issue_id":"bd-9dd","depends_on_id":"bd-2n4","type":"blocks","created_at":"2026-02-02T21:34:37.941327Z","created_by":"tayloreernisse"}]} {"id":"bd-9lbr","title":"lore explain: auto-generate issue/MR narrative","description":"## Background\nGiven an issue or MR, auto-generate a structured narrative of what happened: who was involved, what decisions were made, what changed, and what is unresolved. Template-based v1 (no LLM dependency), deterministic and reproducible.\n\n## Current Infrastructure (Verified 2026-02-12)\n- show.rs: IssueDetail (line 69) and MrDetail (line 14) — entity detail with discussions\n- timeline.rs: 5-stage pipeline SHIPPED — chronological event reconstruction\n- notes table: 282K rows with body, author, created_at, is_system, discussion_id\n- discussions table: links notes to parent entity (noteable_type, noteable_id), has resolved flag\n- resource_state_events table: state changes with created_at, user_username (src/core/events_db.rs)\n- resource_label_events table: label add/remove with created_at, user_username\n- entity_references table (src/core/references.rs): cross-references between entities (closing MRs, related issues). Column names: `source_entity_type`, `source_entity_id`, `target_entity_type`, `target_entity_id`, `target_project_path`, `target_entity_iid`, `reference_type`, `source_method`\n\n## Approach\nNew command: `lore explain issues N` / `lore explain mrs N`\n\n### Data Assembly (reuse existing internals as library calls)\n1. Entity detail: reuse show.rs query logic for IssueDetail/MrDetail\n2. Timeline events: reuse timeline pipeline with entity-scoped seed\n3. Discussion notes:\n```sql\nSELECT n.id, n.body, n.author_username, n.created_at\nFROM notes n\nJOIN discussions d ON n.discussion_id = d.id\nWHERE d.noteable_type = ? AND d.noteable_id = ?\n AND n.is_system = 0\nORDER BY n.created_at\n```\n4. Cross-references:\n```sql\nSELECT target_entity_type, target_entity_id, target_project_path,\n target_entity_iid, reference_type, source_method\nFROM entity_references\nWHERE (source_entity_type = ?1 AND source_entity_id = ?2)\nUNION ALL\nSELECT source_entity_type, source_entity_id, NULL,\n NULL, reference_type, source_method\nFROM entity_references\nWHERE (target_entity_type = ?1 AND target_entity_id = ?2)\n```\n\n### Key Decisions Heuristic\nNotes from assignees/author that follow state or label changes within 1 hour:\n```rust\nstruct StateOrLabelEvent {\n created_at: i64, // ms epoch\n user: String,\n description: String, // e.g. \"state: opened -> closed\" or \"label: +bug\"\n}\n\nfn extract_key_decisions(\n state_events: &[ResourceStateEvent],\n label_events: &[ResourceLabelEvent],\n notes: &[Note],\n) -> Vec {\n // Merge both event types into a unified chronological list\n let mut events: Vec = Vec::new();\n for e in state_events {\n events.push(StateOrLabelEvent {\n created_at: e.created_at,\n user: e.user_username.clone(),\n description: format!(\"state: {} -> {}\", e.from_state.as_deref().unwrap_or(\"?\"), e.to_state),\n });\n }\n for e in label_events {\n let action = if e.action == \"add\" { \"+\" } else { \"-\" };\n events.push(StateOrLabelEvent {\n created_at: e.created_at,\n user: e.user_username.clone(),\n description: format!(\"label: {}{}\", action, e.label_name.as_deref().unwrap_or(\"?\")),\n });\n }\n events.sort_by_key(|e| e.created_at);\n\n let mut decisions = Vec::new();\n let one_hour_ms: i64 = 60 * 60 * 1000;\n\n for event in &events {\n // Find notes by same actor within 60 min after the event\n for note in notes {\n if note.author_username == event.user\n && note.created_at >= event.created_at\n && note.created_at <= event.created_at + one_hour_ms\n {\n decisions.push(KeyDecision {\n timestamp: event.created_at,\n actor: event.user.clone(),\n action: event.description.clone(),\n context_note: truncate(¬e.body, 500),\n });\n break; // one note per event\n }\n }\n }\n decisions.truncate(10); // Cap at 10 key decisions\n decisions\n}\n```\n\n### Narrative Sections\n1. **Header**: title, author, opened date, state, assignees, labels, status_name\n2. **Description excerpt**: first 500 chars of description (or full if shorter)\n3. **Key decisions**: notes correlated with state/label changes (heuristic above)\n4. **Activity summary**: counts of state changes, label changes, notes, time range\n5. **Open threads**: discussions WHERE resolved = false\n6. **Related entities**: closing MRs (with state), related issues from entity_references\n7. **Timeline excerpt**: first 20 events from timeline pipeline\n\n## Robot Mode Output Schema\n```json\n{\n \"ok\": true,\n \"data\": {\n \"entity\": {\n \"type\": \"issue\", \"iid\": 3864, \"title\": \"...\", \"state\": \"opened\",\n \"author\": \"teernisse\", \"assignees\": [\"teernisse\"],\n \"labels\": [\"customer:BNSF\"], \"created_at\": \"...\", \"updated_at\": \"...\",\n \"url\": \"...\", \"status_name\": \"In progress\"\n },\n \"description_excerpt\": \"First 500 chars of description...\",\n \"key_decisions\": [{\n \"timestamp\": \"2026-01-15T...\",\n \"actor\": \"teernisse\",\n \"action\": \"state: opened -> in_progress\",\n \"context_note\": \"Starting work on the BNSF throw time integration...\"\n }],\n \"activity\": {\n \"state_changes\": 3, \"label_changes\": 5, \"notes\": 42,\n \"first_event\": \"2026-01-10T...\", \"last_event\": \"2026-02-12T...\"\n },\n \"open_threads\": [{\n \"discussion_id\": \"abc123\",\n \"started_by\": \"cseiber\",\n \"started_at\": \"2026-02-01T...\",\n \"note_count\": 5,\n \"last_note_at\": \"2026-02-10T...\"\n }],\n \"related\": {\n \"closing_mrs\": [{ \"iid\": 200, \"title\": \"...\", \"state\": \"merged\" }],\n \"related_issues\": [{ \"iid\": 3800, \"title\": \"Rail Break Card\", \"relation\": \"related\" }]\n },\n \"timeline_excerpt\": [{ \"timestamp\": \"...\", \"event_type\": \"...\", \"actor\": \"...\", \"summary\": \"...\" }]\n },\n \"meta\": { \"elapsed_ms\": 350 }\n}\n```\n\n## Clap Registration\n```rust\n// In src/main.rs Commands enum, add:\nExplain {\n /// Entity type: \"issues\" or \"mrs\"\n entity_type: String,\n /// Entity IID\n iid: i64,\n /// Scope to project (fuzzy match)\n #[arg(short, long)]\n project: Option,\n},\n```\n\n## TDD Loop\nRED: Tests in src/cli/commands/explain.rs:\n- test_explain_issue_basic: insert issue + notes + state events, run explain, assert all sections present (entity, description_excerpt, key_decisions, activity, open_threads, related, timeline_excerpt)\n- test_explain_key_decision_heuristic: insert state change event + note by same author within 30 min, assert note appears in key_decisions\n- test_explain_key_decision_ignores_unrelated_notes: insert note by different author, assert it does NOT appear in key_decisions\n- test_explain_open_threads: insert 2 discussions (1 resolved, 1 unresolved), assert only unresolved in open_threads\n- test_explain_no_notes: issue with zero notes produces header + description + empty sections\n- test_explain_mr: insert MR with merged_at, assert entity includes type=\"merge_request\"\n- test_explain_activity_counts: insert 3 state events + 2 label events + 10 notes, assert counts match\n\nGREEN: Implement explain command with section assembly\n\nVERIFY:\n```bash\ncargo test explain:: && cargo clippy --all-targets -- -D warnings\ncargo run --release -- -J explain issues 3864 | jq '.data | keys'\n# Should include: entity, description_excerpt, key_decisions, activity, open_threads, related, timeline_excerpt\n```\n\n## Acceptance Criteria\n- [ ] lore explain issues N produces structured output for any synced issue\n- [ ] lore explain mrs N produces structured output for any synced MR\n- [ ] Robot mode returns all 7 sections\n- [ ] Human mode renders readable narrative with headers and indentation\n- [ ] Key decisions heuristic: captures notes within 60 min of state/label changes by same actor\n- [ ] Works fully offline (no API calls, no LLM)\n- [ ] Performance: <500ms for issue with 50 notes\n- [ ] Command registered in main.rs and robot-docs\n- [ ] key_decisions capped at 10, timeline_excerpt capped at 20 events\n\n## Edge Cases\n- Issue with empty description: description_excerpt = \"(no description)\"\n- Issue with 500+ notes: timeline_excerpt capped at 20, key_decisions capped at 10\n- Issue not found in local DB: exit code 17 with suggestion to sync\n- Ambiguous project: exit code 18 with suggestion to use -p flag\n- MR with no review activity: activity section shows zeros\n- Cross-project references: show as unresolved with project path hint\n- Notes that are pure code blocks: include in key_decisions if correlated with events (they may contain implementation decisions)\n- ResourceStateEvent/ResourceLabelEvent field names: check src/core/events_db.rs for exact struct definitions before implementing\n\n## Dependency Context\n- **bd-2g50 (data gaps)**: BLOCKER. Provides `closed_at` field on IssueDetail for the header section. Without it, explain can still show state=\"closed\" but won't have the exact close timestamp.\n\n## Files to Create/Modify\n- NEW: src/cli/commands/explain.rs\n- src/cli/commands/mod.rs (add pub mod explain; re-export)\n- src/main.rs (register Explain subcommand in Commands enum, add handle_explain fn)\n- Reuse: show.rs queries, timeline pipeline, notes/discussions/resource_events queries from src/core/events_db.rs","status":"open","priority":2,"issue_type":"feature","created_at":"2026-02-12T15:46:41.386454Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:31:34.538422Z","compaction_level":0,"original_size":0,"labels":["cli-imp","intelligence"],"dependencies":[{"issue_id":"bd-9lbr","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-02-12T15:46:41.389472Z","created_by":"tayloreernisse"},{"issue_id":"bd-9lbr","depends_on_id":"bd-2g50","type":"blocks","created_at":"2026-02-12T15:55:49.910748Z","created_by":"tayloreernisse"}]} @@ -206,7 +206,7 @@ {"id":"bd-jec","title":"Add fetchMrFileChanges config flag","description":"## Background\n\nConfig flag controlling whether MR diff fetching is enabled, following the fetchResourceEvents pattern.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 4.2.\n\n## Codebase Context\n\n- src/core/config.rs has SyncConfig with fetch_resource_events: bool (serde rename 'fetchResourceEvents', default true)\n- Default impl exists for SyncConfig\n- CLI sync options in src/cli/mod.rs have --no-events flag pattern\n- Orchestrator checks config.sync.fetch_resource_events before enqueuing resource_events jobs\n\n## Approach\n\n### 1. Add to SyncConfig (`src/core/config.rs`):\n```rust\n#[serde(rename = \"fetchMrFileChanges\", default = \"default_true\")]\npub fetch_mr_file_changes: bool,\n```\n\nUpdate Default impl to include fetch_mr_file_changes: true.\n\n### 2. CLI override (`src/cli/mod.rs`):\n```rust\n#[arg(long = \"no-file-changes\")]\npub no_file_changes: bool,\n```\n\n### 3. Apply in main.rs:\n```rust\nif args.no_file_changes { config.sync.fetch_mr_file_changes = false; }\n```\n\n### 4. Guard in orchestrator:\n```rust\nif config.sync.fetch_mr_file_changes { enqueue mr_diffs jobs }\n```\n\n## Acceptance Criteria\n\n- [ ] fetchMrFileChanges in SyncConfig, default true\n- [ ] Config without field defaults to true\n- [ ] --no-file-changes disables diff fetching\n- [ ] Orchestrator skips mr_diffs when false\n- [ ] `cargo check --all-targets` passes\n\n## Files\n\n- `src/core/config.rs` (add field + Default)\n- `src/cli/mod.rs` (add --no-file-changes)\n- `src/main.rs` (apply override)\n- `src/ingestion/orchestrator.rs` (guard enqueue)\n\n## TDD Loop\n\nRED:\n- `test_config_default_fetch_mr_file_changes` - default is true\n- `test_config_deserialize_false` - JSON with false\n\nGREEN: Add field, default, serde attribute.\n\nVERIFY: `cargo test --lib -- config`\n\n## Edge Cases\n\n- Config missing fetchMrFileChanges key entirely: serde default_true fills in true\n- Config explicitly set to false: no mr_diffs jobs enqueued, mr_file_changes table empty\n- --no-file-changes with --full sync: overrides config, no diffs fetched even on full resync\n- sync.fetchMrFileChanges = false in config + no --no-file-changes flag: respects config (no override)","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-02T21:34:08.892666Z","created_by":"tayloreernisse","updated_at":"2026-02-08T18:18:36.409511Z","closed_at":"2026-02-08T18:18:36.409467Z","close_reason":"Added fetch_mr_file_changes to SyncConfig (default true, serde rename fetchMrFileChanges), --no-file-changes CLI flag in SyncArgs, override in main.rs. Orchestrator guard deferred to bd-2yo which implements the actual drain.","compaction_level":0,"original_size":0,"labels":["config","gate-4","phase-b"],"dependencies":[{"issue_id":"bd-jec","depends_on_id":"bd-14q","type":"parent-child","created_at":"2026-02-02T21:34:08.895167Z","created_by":"tayloreernisse"}]} {"id":"bd-jov","title":"[CP1] Discussion and note transformers","description":"Transform GitLab discussion/note payloads to normalized database schema.\n\n## Module\nsrc/gitlab/transformers/discussion.rs\n\n## Structs\n\n### NormalizedDiscussion\n- gitlab_discussion_id: String\n- project_id: i64\n- issue_id: i64\n- noteable_type: String (\"Issue\")\n- individual_note: bool\n- first_note_at, last_note_at: Option\n- last_seen_at: i64\n- resolvable, resolved: bool\n\n### NormalizedNote\n- gitlab_id: i64\n- project_id: i64\n- note_type: Option\n- is_system: bool\n- author_username: String\n- body: String\n- created_at, updated_at, last_seen_at: i64\n- position: i32 (array index in notes[])\n- resolvable, resolved: bool\n- resolved_by: Option\n- resolved_at: Option\n\n## Functions\n\n### transform_discussion(gitlab_discussion, local_project_id, local_issue_id) -> NormalizedDiscussion\n- Compute first_note_at/last_note_at from notes array min/max created_at\n- Compute resolvable (any note resolvable)\n- Compute resolved (resolvable AND all resolvable notes resolved)\n\n### transform_notes(gitlab_discussion, local_project_id) -> Vec\n- Enumerate notes to get position (array index)\n- Set is_system from note.system\n- Convert timestamps to ms epoch\n\nFiles: src/gitlab/transformers/discussion.rs\nTests: tests/discussion_transformer_tests.rs\nDone when: Unit tests pass for discussion/note transformation with system note flagging","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:43:04.481361Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.759691Z","deleted_at":"2026-01-25T17:02:01.759684Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-k7b","title":"[CP1] gi show issue command","description":"Show issue details with discussions.\n\n## Module\nsrc/cli/commands/show.rs\n\n## Clap Definition\nShow {\n #[arg(value_parser = [\"issue\", \"mr\"])]\n entity: String,\n \n iid: i64,\n \n #[arg(long)]\n project: Option,\n}\n\n## Output Format\nIssue #1234: Authentication redesign\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\nProject: group/project-one\nState: opened\nAuthor: @johndoe\nCreated: 2024-01-15\nUpdated: 2024-03-20\nLabels: enhancement, auth\nURL: https://gitlab.example.com/group/project-one/-/issues/1234\n\nDescription:\n We need to redesign the authentication flow to support...\n\nDiscussions (5):\n\n @janedoe (2024-01-16):\n I agree we should move to JWT-based auth...\n\n @johndoe (2024-01-16):\n What about refresh token strategy?\n\n @bobsmith (2024-01-17):\n Have we considered OAuth2?\n\n## Ambiguity Handling\nIf multiple projects have same iid, either:\n- Prompt for --project flag\n- Show error listing which projects have that iid\n\nFiles: src/cli/commands/show.rs\nDone when: Issue detail view displays all fields including threaded discussions","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T16:58:26.904813Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.944183Z","deleted_at":"2026-01-25T17:02:01.944179Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0} -{"id":"bd-kvij","title":"Rewrite agent skills to mandate lore for all reads","description":"## Background\nAgent skills and AGENTS.md files currently allow agents to choose between glab and lore for read operations. Agents default to glab (familiar from training data) even though lore returns richer data. Need a clean, enforced boundary: lore=reads, glab=writes.\n\n## Approach\n1. Audit all config files for glab read patterns\n2. Replace each with lore equivalent\n3. Add explicit Read/Write Split section to AGENTS.md and CLAUDE.md\n\n## Translation Table\n| glab (remove) | lore (replace with) |\n|------------------------------------|----------------------------------|\n| glab issue view N | lore -J issues N |\n| glab issue list | lore -J issues -n 50 |\n| glab issue list -l bug | lore -J issues --label bug |\n| glab mr view N | lore -J mrs N |\n| glab mr list | lore -J mrs |\n| glab mr list -s opened | lore -J mrs -s opened |\n| glab api '/projects/:id/issues' | lore -J issues -p project |\n\n## Files to Audit\n\n### Project-level\n- /Users/tayloreernisse/projects/gitlore/AGENTS.md — primary project instructions\n\n### Global Claude config\n- ~/.claude/CLAUDE.md — global instructions (already has lore section, verify no glab reads)\n\n### Skills directory\nScan all .md files under ~/.claude/skills/ for glab read patterns.\nLikely candidates: any skill that references GitLab data retrieval.\n\n### Rules directory\nScan all .md files under ~/.claude/rules/ for glab read patterns.\n\n### Work-ghost templates\n- ~/projects/work-ghost/tasks/*.md — task templates that reference glab reads\n\n## Verification Commands\nAfter all changes:\n```bash\n# Should return ZERO matches (no glab read commands remain)\nrg 'glab issue view|glab issue list|glab mr view|glab mr list|glab api.*issues|glab api.*merge_requests' ~/.claude/ AGENTS.md --type md\n\n# These should REMAIN (write operations stay with glab)\nrg 'glab (issue|mr) (create|update|close|delete|approve|merge|note|rebase)' ~/.claude/ AGENTS.md --type md\n```\n\n## Read/Write Split Section to Add\nAdd to AGENTS.md and ~/.claude/CLAUDE.md:\n```markdown\n## Read/Write Split: lore vs glab\n\n| Operation | Tool | Why |\n|-----------|------|-----|\n| List issues/MRs | lore | Richer: includes status, discussions, closing MRs |\n| View issue/MR detail | lore | Pre-joined discussions, work-item status |\n| Search across entities | lore | FTS5 + vector hybrid search |\n| Expert/workload analysis | lore | who command — no glab equivalent |\n| Timeline reconstruction | lore | Chronological narrative — no glab equivalent |\n| Create/update/close | glab | Write operations |\n| Approve/merge MR | glab | Write operations |\n| CI/CD pipelines | glab | Not in lore scope |\n```\n\n## TDD Loop\nThis is a config-only task — no Rust code changes. Verification is via grep:\n\nRED: Run verification commands above, expect matches (glab reads still present)\nGREEN: Replace all glab read references with lore equivalents\nVERIFY: Run verification commands, expect zero glab read matches\n\n## Acceptance Criteria\n- [ ] Zero glab read references in AGENTS.md\n- [ ] Zero glab read references in ~/.claude/CLAUDE.md\n- [ ] Zero glab read references in ~/.claude/skills/**/*.md\n- [ ] Zero glab read references in ~/.claude/rules/**/*.md\n- [ ] glab write references preserved (create, update, close, approve, merge, CI)\n- [ ] Read/Write Split section added to AGENTS.md\n- [ ] Read/Write Split section added to ~/.claude/CLAUDE.md\n- [ ] Fresh agent session uses lore for reads without prompting (manual verification)\n\n## Edge Cases\n- Skills that use glab api for data NOT in lore (e.g., CI pipeline data, project settings) — these should remain\n- glab MCP server references — evaluate case-by-case (keep for write operations)\n- Shell aliases or env vars that invoke glab for reads — out of scope unless in config files\n- Skills that use `glab issue list | jq` for ad-hoc queries — replace with `lore -J issues | jq`\n- References to glab in documentation context (explaining what tools exist) vs operational context (telling agent to use glab) — only replace operational references","status":"in_progress","priority":1,"issue_type":"task","created_at":"2026-02-12T15:44:56.530081Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:35:09.704539Z","compaction_level":0,"original_size":0,"labels":["cli","cli-imp"],"dependencies":[{"issue_id":"bd-kvij","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-02-12T15:44:56.532741Z","created_by":"tayloreernisse"}]} +{"id":"bd-kvij","title":"Rewrite agent skills to mandate lore for all reads","description":"## Background\nAgent skills and AGENTS.md files currently allow agents to choose between glab and lore for read operations. Agents default to glab (familiar from training data) even though lore returns richer data. Need a clean, enforced boundary: lore=reads, glab=writes.\n\n## Approach\n1. Audit all config files for glab read patterns\n2. Replace each with lore equivalent\n3. Add explicit Read/Write Split section to AGENTS.md and CLAUDE.md\n\n## Translation Table\n| glab (remove) | lore (replace with) |\n|------------------------------------|----------------------------------|\n| glab issue view N | lore -J issues N |\n| glab issue list | lore -J issues -n 50 |\n| glab issue list -l bug | lore -J issues --label bug |\n| glab mr view N | lore -J mrs N |\n| glab mr list | lore -J mrs |\n| glab mr list -s opened | lore -J mrs -s opened |\n| glab api '/projects/:id/issues' | lore -J issues -p project |\n\n## Files to Audit\n\n### Project-level\n- /Users/tayloreernisse/projects/gitlore/AGENTS.md — primary project instructions\n\n### Global Claude config\n- ~/.claude/CLAUDE.md — global instructions (already has lore section, verify no glab reads)\n\n### Skills directory\nScan all .md files under ~/.claude/skills/ for glab read patterns.\nLikely candidates: any skill that references GitLab data retrieval.\n\n### Rules directory\nScan all .md files under ~/.claude/rules/ for glab read patterns.\n\n### Work-ghost templates\n- ~/projects/work-ghost/tasks/*.md — task templates that reference glab reads\n\n## Verification Commands\nAfter all changes:\n```bash\n# Should return ZERO matches (no glab read commands remain)\nrg 'glab issue view|glab issue list|glab mr view|glab mr list|glab api.*issues|glab api.*merge_requests' ~/.claude/ AGENTS.md --type md\n\n# These should REMAIN (write operations stay with glab)\nrg 'glab (issue|mr) (create|update|close|delete|approve|merge|note|rebase)' ~/.claude/ AGENTS.md --type md\n```\n\n## Read/Write Split Section to Add\nAdd to AGENTS.md and ~/.claude/CLAUDE.md:\n```markdown\n## Read/Write Split: lore vs glab\n\n| Operation | Tool | Why |\n|-----------|------|-----|\n| List issues/MRs | lore | Richer: includes status, discussions, closing MRs |\n| View issue/MR detail | lore | Pre-joined discussions, work-item status |\n| Search across entities | lore | FTS5 + vector hybrid search |\n| Expert/workload analysis | lore | who command — no glab equivalent |\n| Timeline reconstruction | lore | Chronological narrative — no glab equivalent |\n| Create/update/close | glab | Write operations |\n| Approve/merge MR | glab | Write operations |\n| CI/CD pipelines | glab | Not in lore scope |\n```\n\n## TDD Loop\nThis is a config-only task — no Rust code changes. Verification is via grep:\n\nRED: Run verification commands above, expect matches (glab reads still present)\nGREEN: Replace all glab read references with lore equivalents\nVERIFY: Run verification commands, expect zero glab read matches\n\n## Acceptance Criteria\n- [ ] Zero glab read references in AGENTS.md\n- [ ] Zero glab read references in ~/.claude/CLAUDE.md\n- [ ] Zero glab read references in ~/.claude/skills/**/*.md\n- [ ] Zero glab read references in ~/.claude/rules/**/*.md\n- [ ] glab write references preserved (create, update, close, approve, merge, CI)\n- [ ] Read/Write Split section added to AGENTS.md\n- [ ] Read/Write Split section added to ~/.claude/CLAUDE.md\n- [ ] Fresh agent session uses lore for reads without prompting (manual verification)\n\n## Edge Cases\n- Skills that use glab api for data NOT in lore (e.g., CI pipeline data, project settings) — these should remain\n- glab MCP server references — evaluate case-by-case (keep for write operations)\n- Shell aliases or env vars that invoke glab for reads — out of scope unless in config files\n- Skills that use `glab issue list | jq` for ad-hoc queries — replace with `lore -J issues | jq`\n- References to glab in documentation context (explaining what tools exist) vs operational context (telling agent to use glab) — only replace operational references","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-12T15:44:56.530081Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:49:04.598735Z","closed_at":"2026-02-12T16:49:04.598679Z","close_reason":"Agent skills rewritten: AGENTS.md and CLAUDE.md updated with read/write split mandating lore for reads, glab for writes","compaction_level":0,"original_size":0,"labels":["cli","cli-imp"],"dependencies":[{"issue_id":"bd-kvij","depends_on_id":"bd-13lp","type":"parent-child","created_at":"2026-02-12T15:44:56.532741Z","created_by":"tayloreernisse"}]} {"id":"bd-lcb","title":"Epic: CP2 Gate E - CLI Complete","description":"## Background\nGate E validates all CLI commands are functional and user-friendly. This is the final usability gate - even if all data is correct, users need good CLI UX to access it.\n\n## Acceptance Criteria (Pass/Fail)\n\n### List Command\n- [ ] `gi list mrs` shows MR table with columns: iid, title, state, author, branches, updated\n- [ ] `gi list mrs --state=opened` filters to only opened MRs\n- [ ] `gi list mrs --state=merged` filters to only merged MRs\n- [ ] `gi list mrs --state=closed` filters to only closed MRs\n- [ ] `gi list mrs --state=locked` filters locally (not server-side filter)\n- [ ] `gi list mrs --draft` shows only draft MRs\n- [ ] `gi list mrs --no-draft` excludes draft MRs\n- [ ] Draft MRs show `[DRAFT]` prefix in title column\n- [ ] `gi list mrs --author=username` filters by author\n- [ ] `gi list mrs --assignee=username` filters by assignee\n- [ ] `gi list mrs --reviewer=username` filters by reviewer\n- [ ] `gi list mrs --target-branch=main` filters by target branch\n- [ ] `gi list mrs --source-branch=feature/x` filters by source branch\n- [ ] `gi list mrs --label=bugfix` filters by label\n- [ ] `gi list mrs --limit=N` limits output\n\n### Show Command\n- [ ] `gi show mr ` displays full MR detail\n- [ ] Show includes: title, description, state, draft status, author\n- [ ] Show includes: assignees, reviewers, labels\n- [ ] Show includes: source_branch, target_branch\n- [ ] Show includes: detailed_merge_status (e.g., \"mergeable\")\n- [ ] Show includes: merge_user and merged_at for merged MRs\n- [ ] Show includes: discussions with author and date\n- [ ] DiffNote shows file context: `[src/file.ts:45]`\n- [ ] Multi-line DiffNote shows range: `[src/file.ts:45-48]`\n- [ ] Resolved discussions show `[RESOLVED]` marker\n\n### Count Command\n- [ ] `gi count mrs` shows total count\n- [ ] Count shows state breakdown: opened, merged, closed\n\n### Sync Status\n- [ ] `gi sync-status` shows MR cursor position\n- [ ] Sync status shows last sync timestamp\n\n## Validation Script\n```bash\n#!/bin/bash\nset -e\n\nDB_PATH=\"${XDG_DATA_HOME:-$HOME/.local/share}/gitlab-inbox/db.sqlite3\"\n\necho \"=== Gate E: CLI Complete ===\"\n\n# 1. Test list command (basic)\necho \"Step 1: Basic list...\"\ngi list mrs --limit=5 || { echo \"FAIL: list mrs failed\"; exit 1; }\n\n# 2. Test state filters\necho \"Step 2: State filters...\"\nfor state in opened merged closed; do\n echo \" Testing --state=$state\"\n gi list mrs --state=$state --limit=3 || echo \" Warning: No $state MRs\"\ndone\n\n# 3. Test draft filters\necho \"Step 3: Draft filters...\"\ngi list mrs --draft --limit=3 || echo \" Note: No draft MRs found\"\ngi list mrs --no-draft --limit=3 || echo \" Note: All MRs are drafts?\"\n\n# 4. Check [DRAFT] prefix\necho \"Step 4: Check [DRAFT] prefix...\"\nDRAFT_IID=$(sqlite3 \"$DB_PATH\" \"SELECT iid FROM merge_requests WHERE draft = 1 LIMIT 1;\")\nif [ -n \"$DRAFT_IID\" ]; then\n if gi list mrs --limit=100 | grep -q \"\\[DRAFT\\]\"; then\n echo \" PASS: [DRAFT] prefix found\"\n else\n echo \" FAIL: Draft MR exists but no [DRAFT] prefix in output\"\n fi\nelse\n echo \" Skip: No draft MRs to test\"\nfi\n\n# 5. Test author/assignee/reviewer filters\necho \"Step 5: User filters...\"\nAUTHOR=$(sqlite3 \"$DB_PATH\" \"SELECT author_username FROM merge_requests LIMIT 1;\")\nif [ -n \"$AUTHOR\" ]; then\n echo \" Testing --author=$AUTHOR\"\n gi list mrs --author=\"$AUTHOR\" --limit=3\nfi\n\nREVIEWER=$(sqlite3 \"$DB_PATH\" \"SELECT username FROM mr_reviewers LIMIT 1;\")\nif [ -n \"$REVIEWER\" ]; then\n echo \" Testing --reviewer=$REVIEWER\"\n gi list mrs --reviewer=\"$REVIEWER\" --limit=3\nfi\n\n# 6. Test branch filters\necho \"Step 6: Branch filters...\"\nTARGET=$(sqlite3 \"$DB_PATH\" \"SELECT target_branch FROM merge_requests LIMIT 1;\")\nif [ -n \"$TARGET\" ]; then\n echo \" Testing --target-branch=$TARGET\"\n gi list mrs --target-branch=\"$TARGET\" --limit=3\nfi\n\n# 7. Test show command\necho \"Step 7: Show command...\"\nMR_IID=$(sqlite3 \"$DB_PATH\" \"SELECT iid FROM merge_requests LIMIT 1;\")\ngi show mr \"$MR_IID\" || { echo \"FAIL: show mr failed\"; exit 1; }\n\n# 8. Test show with DiffNote context\necho \"Step 8: Show with DiffNote...\"\nDIFFNOTE_MR=$(sqlite3 \"$DB_PATH\" \"\n SELECT DISTINCT m.iid\n FROM merge_requests m\n JOIN discussions d ON d.merge_request_id = m.id\n JOIN notes n ON n.discussion_id = d.id\n WHERE n.position_new_path IS NOT NULL\n LIMIT 1;\n\")\nif [ -n \"$DIFFNOTE_MR\" ]; then\n echo \" Testing MR with DiffNotes: !$DIFFNOTE_MR\"\n OUTPUT=$(gi show mr \"$DIFFNOTE_MR\")\n if echo \"$OUTPUT\" | grep -qE '\\[[^]]+:[0-9]+\\]'; then\n echo \" PASS: File context [path:line] found\"\n else\n echo \" FAIL: DiffNote should show [path:line] context\"\n fi\nelse\n echo \" Skip: No MRs with DiffNotes\"\nfi\n\n# 9. Test count command\necho \"Step 9: Count command...\"\ngi count mrs || { echo \"FAIL: count mrs failed\"; exit 1; }\n\n# 10. Test sync-status\necho \"Step 10: Sync status...\"\ngi sync-status || echo \" Note: sync-status may need implementation\"\n\necho \"\"\necho \"=== Gate E: PASSED ===\"\n```\n\n## Test Commands (Quick Verification)\n```bash\n# List with all column types visible:\ngi list mrs --limit=10\n\n# Show a specific MR:\ngi show mr 42\n\n# Count with breakdown:\ngi count mrs\n\n# Complex filter:\ngi list mrs --state=opened --reviewer=alice --target-branch=main --limit=5\n```\n\n## Expected Output Formats\n\n### gi list mrs\n```\nMerge Requests (showing 5 of 1,234)\n\n !847 Refactor auth to use JWT tokens merged @johndoe main <- feature/jwt 3d ago\n !846 Fix memory leak in websocket handler opened @janedoe main <- fix/websocket 5d ago\n !845 [DRAFT] Add dark mode CSS variables opened @bobsmith main <- ui/dark-mode 1w ago\n !844 Update dependencies to latest versions closed @alice main <- chore/deps 2w ago\n```\n\n### gi show mr 847\n```\nMerge Request !847: Refactor auth to use JWT tokens\n================================================================================\n\nProject: group/project-one\nState: merged\nDraft: No\nAuthor: @johndoe\nAssignees: @janedoe, @bobsmith\nReviewers: @alice, @charlie\nLabels: enhancement, auth, reviewed\nSource: feature/jwt\nTarget: main\nMerge Status: merged\nMerged By: @alice\nMerged At: 2024-03-20 14:30:00\n\nDescription:\n Moving away from session cookies to JWT-based authentication...\n\nDiscussions (3):\n\n @janedoe (2024-03-16) [src/auth/jwt.ts:45]:\n Should we use a separate signing key for refresh tokens?\n\n @johndoe (2024-03-16):\n Good point. I'll add a separate key with rotation support.\n\n @alice (2024-03-18) [RESOLVED]:\n Looks good! Just one nit about the token expiry constant.\n```\n\n### gi count mrs\n```\nMerge Requests: 1,234\n opened: 89\n merged: 1,045\n closed: 100\n```\n\n## Dependencies\nThis gate requires:\n- bd-3js (CLI commands implementation)\n- All previous gates must pass first\n\n## Edge Cases\n- Ambiguous MR iid across projects: should prompt for `--project` or show error\n- Very long titles: should truncate with `...` in list view\n- Empty description: should show \"No description\" or empty section\n- No discussions: should show \"No discussions\" message\n- Unicode in titles/descriptions: should render correctly","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-26T22:06:02.411132Z","created_by":"tayloreernisse","updated_at":"2026-01-27T00:48:21.061166Z","closed_at":"2026-01-27T00:48:21.061125Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-lcb","depends_on_id":"bd-3js","type":"blocks","created_at":"2026-01-26T22:08:55.957747Z","created_by":"tayloreernisse"}]} {"id":"bd-ljf","title":"Add embedding error variants to LoreError","description":"## Background\nGate B introduces Ollama-dependent operations that need distinct error variants for clear diagnostics. Each error has a unique exit code, a descriptive message, and an actionable suggestion. These errors must integrate with the existing LoreError enum pattern (renamed from GiError in bd-3lc).\n\n## Approach\nExtend `src/core/error.rs` with 4 new variants per PRD Section 4.3.\n\n**ErrorCode additions:**\n```rust\npub enum ErrorCode {\n // ... existing (InternalError=1 through TransformError=13)\n OllamaUnavailable, // exit code 14\n OllamaModelNotFound, // exit code 15\n EmbeddingFailed, // exit code 16\n}\n```\n\n**LoreError additions:**\n```rust\n/// Ollama-specific connection failure. Use instead of Http for Ollama errors\n/// because it includes base_url for actionable error messages.\n#[error(\"Cannot connect to Ollama at {base_url}. Is it running?\")]\nOllamaUnavailable {\n base_url: String,\n #[source]\n source: Option,\n},\n\n#[error(\"Ollama model '{model}' not found. Run: ollama pull {model}\")]\nOllamaModelNotFound { model: String },\n\n#[error(\"Embedding failed for document {document_id}: {reason}\")]\nEmbeddingFailed { document_id: i64, reason: String },\n\n#[error(\"No embeddings found. Run: lore embed\")]\nEmbeddingsNotBuilt,\n```\n\n**code() mapping:**\n- OllamaUnavailable => ErrorCode::OllamaUnavailable\n- OllamaModelNotFound => ErrorCode::OllamaModelNotFound\n- EmbeddingFailed => ErrorCode::EmbeddingFailed\n- EmbeddingsNotBuilt => ErrorCode::EmbeddingFailed (shares exit code 16)\n\n**suggestion() mapping:**\n- OllamaUnavailable => \"Start Ollama: ollama serve\"\n- OllamaModelNotFound => \"Pull the model: ollama pull nomic-embed-text\"\n- EmbeddingFailed => \"Check Ollama logs or retry with 'lore embed --retry-failed'\"\n- EmbeddingsNotBuilt => \"Generate embeddings first: lore embed\"\n\n## Acceptance Criteria\n- [ ] All 4 error variants compile\n- [ ] Exit codes: OllamaUnavailable=14, OllamaModelNotFound=15, EmbeddingFailed=16\n- [ ] EmbeddingsNotBuilt shares exit code 16 (mapped to ErrorCode::EmbeddingFailed)\n- [ ] OllamaUnavailable has `base_url: String` and `source: Option`\n- [ ] EmbeddingFailed has `document_id: i64` and `reason: String`\n- [ ] Each variant has actionable .suggestion() text per PRD\n- [ ] ErrorCode Display: OLLAMA_UNAVAILABLE, OLLAMA_MODEL_NOT_FOUND, EMBEDDING_FAILED\n- [ ] Robot mode JSON includes code + suggestion for each variant\n- [ ] `cargo build` succeeds\n\n## Files\n- `src/core/error.rs` — extend LoreError enum + ErrorCode enum + impl blocks\n\n## TDD Loop\nRED: Add variants, `cargo build` fails on missing match arms\nGREEN: Add match arms in code(), exit_code(), suggestion(), to_robot_error(), Display\nVERIFY: `cargo build && cargo test error`\n\n## Edge Cases\n- OllamaUnavailable with source=None: still valid (used when no HTTP error available)\n- EmbeddingFailed with document_id=0: used for batch-level failures (not per-doc)\n- EmbeddingsNotBuilt vs OllamaUnavailable: former means \"never ran embed\", latter means \"Ollama down right now\"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:26:33.994316Z","created_by":"tayloreernisse","updated_at":"2026-01-30T16:51:20.385574Z","closed_at":"2026-01-30T16:51:20.385369Z","close_reason":"Completed: Added 4 LoreError variants (OllamaUnavailable, OllamaModelNotFound, EmbeddingFailed, EmbeddingsNotBuilt) and 3 ErrorCode variants with exit codes 14-16. cargo build succeeds.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-ljf","depends_on_id":"bd-3lc","type":"blocks","created_at":"2026-01-30T15:29:15.640924Z","created_by":"tayloreernisse"}]} {"id":"bd-lsz","title":"Epic: Gate B - Hybrid MVP","description":"## Background\nGate B adds semantic search capabilities via Ollama embeddings and sqlite-vec vector storage. It builds on Gate A's document layer, adding the embedding pipeline, vector search, RRF-based hybrid ranking, and graceful degradation when Ollama is unavailable. Gate B is independently shippable on top of Gate A.\n\n## Gate B Deliverables\n1. Ollama-powered embedding pipeline with sqlite-vec storage\n2. Hybrid search (RRF-ranked vector + lexical) with rich filtering + graceful degradation\n\n## Bead Dependencies (execution order, after Gate A)\n1. **bd-mem** — Shared backoff utility (no deps)\n2. **bd-1y8** — Chunk ID encoding (no deps)\n3. **bd-3ez** — RRF ranking (no deps)\n4. **bd-ljf** — Embedding error variants (blocked by bd-3lc)\n5. **bd-2ac** — Migration 009 embeddings (blocked by bd-hrs)\n6. **bd-335** — Ollama API client (blocked by bd-ljf)\n7. **bd-am7** — Embedding pipeline (blocked by bd-335, bd-2ac, bd-1y8)\n8. **bd-bjo** — Vector search (blocked by bd-2ac, bd-1y8)\n9. **bd-2sx** — Embed CLI (blocked by bd-am7)\n10. **bd-3eu** — Hybrid search (blocked by bd-3ez, bd-bjo, bd-1k1, bd-3q2)\n\n## Acceptance Criteria\n- [ ] `lore embed` builds embeddings for all documents via Ollama\n- [ ] `lore embed --retry-failed` re-attempts failed embeddings\n- [ ] `lore search --mode=hybrid \"query\"` uses both FTS + vector\n- [ ] `lore search --mode=semantic \"query\"` uses vector only\n- [ ] Graceful degradation: Ollama down -> FTS fallback with warning\n- [ ] `lore search --explain` shows vector_rank, fts_rank, rrf_score\n- [ ] sqlite-vec loaded before migration 009","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-30T15:25:13.462602Z","created_by":"tayloreernisse","updated_at":"2026-01-30T18:02:57.669194Z","closed_at":"2026-01-30T18:02:57.669142Z","close_reason":"All Gate B sub-beads complete: backoff, chunk IDs, RRF, error variants, migration 009, Ollama client, embedding pipeline, vector search, embed CLI, hybrid search","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-lsz","depends_on_id":"bd-2sx","type":"blocks","created_at":"2026-01-30T15:29:35.818914Z","created_by":"tayloreernisse"},{"issue_id":"bd-lsz","depends_on_id":"bd-3eu","type":"blocks","created_at":"2026-01-30T15:29:35.783218Z","created_by":"tayloreernisse"}]} diff --git a/.beads/last-touched b/.beads/last-touched index b943210..7bf42e0 100644 --- a/.beads/last-touched +++ b/.beads/last-touched @@ -1 +1 @@ -bd-kvij +bd-1ksf diff --git a/src/cli/autocorrect.rs b/src/cli/autocorrect.rs index c2c27c3..f9b08c8 100644 --- a/src/cli/autocorrect.rs +++ b/src/cli/autocorrect.rs @@ -185,6 +185,7 @@ const COMMAND_FLAGS: &[(&str, &[&str])] = &[ "--no-detail", ], ), + ("drift", &["--threshold", "--project"]), ( "init", &[