lore related: semantic similarity discovery

## Background
Given 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.

## Current Infrastructure (Verified 2026-02-12)
- sqlite-vec extension loaded via sqlite3_vec_init in src/core/db.rs:84
- Embeddings stored in: embedding_metadata table (chunk info) + vec0 virtual table named `embeddings` (vectors)
- Migration 009 creates embedding infrastructure
- search_vector() at src/search/vector.rs:43 — works with sqlite-vec KNN queries
- OllamaClient::embed_batch() at src/embedding/ollama.rs:103 — batch embedding
- Model: nomic-embed-text, 768 dimensions, context_length=2048 tokens (~1500 bytes)
- 61K documents in DB, embedding coverage TBD

### sqlite-vec Distance Metric
The `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 }`.

## Approach

### Entity Mode: lore related issues N
1. Look up document for issue N:
```sql
SELECT d.id, d.content_text
FROM documents d
JOIN issues i ON d.source_type = 'issue' AND d.source_id = i.id
WHERE i.iid = ?1 AND i.project_id = (SELECT id FROM projects WHERE ...)
```
NOTE: `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`.

2. Get its embedding: Look up via embedding_metadata which maps document_id -> rowid in the vec0 table:
```sql
SELECT em.rowid
FROM embedding_metadata em
WHERE em.document_id = ?1
LIMIT 1  -- use first chunk's embedding as representative
```
Then extract the embedding vector from the vec0 table to use as the KNN query.

Alternatively, embed the document's content_text on-the-fly via OllamaClient (simpler, more robust):
```rust
let embedding = client.embed_batch(&[&doc.content_text]).await?[0].clone();
```

3. Call search_vector(conn, &embedding, limit * 2) for KNN — multiply limit to have room after filtering self
4. Exclude self (filter out source document_id from results)
5. Hydrate results: join documents -> issues/mrs/discussions for title, url, labels, author
6. Compute shared_labels: parse `documents.label_names` (JSON array string) for both source and each result, intersect
7. Return ranked list

### Query Mode: lore related 'free text'
1. Embed query via OllamaClient::embed_batch(&[query_text])
2. Call search_vector(conn, &query_embedding, limit)
3. Hydrate and return (same as entity mode minus self-exclusion)

### Key Design Decision
This is intentionally SIMPLER than hybrid search. No FTS, no RRF. Pure vector similarity. The point is conceptual relatedness, not keyword matching.

### Distance to Similarity Score Conversion
sqlite-vec returns L2 (Euclidean) distance. Convert to 0-1 similarity:
```rust
/// Convert L2 distance to a 0-1 similarity score.
/// Uses inverse relationship: closer (lower distance) = higher similarity.
/// The +1 prevents division by zero and ensures score is in (0, 1].
fn distance_to_similarity(distance: f64) -> f64 {
    1.0 / (1.0 + distance)
}
```
For normalized embeddings (which nomic-embed-text produces), L2 distance ranges roughly 0-2. This formula maps:
- distance 0.0 -> similarity 1.0 (identical)
- distance 1.0 -> similarity 0.5
- distance 2.0 -> similarity 0.33

### Label Extraction for shared_labels
```rust
fn parse_label_names(label_names_json: &Option<String>) -> HashSet<String> {
    label_names_json
        .as_deref()
        .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
        .unwrap_or_default()
        .into_iter()
        .collect()
}

let source_labels = parse_label_names(&source_doc.label_names);
let result_labels = parse_label_names(&result_doc.label_names);
let shared: Vec<String> = source_labels.intersection(&result_labels).cloned().collect();
```

## Function Signatures

```rust
// New: src/cli/commands/related.rs
pub struct RelatedArgs {
    pub entity_type: Option<String>,  // "issues" or "mrs"
    pub entity_iid: Option<i64>,
    pub query: Option<String>,        // free text mode
    pub project: Option<String>,
    pub limit: Option<usize>,
}

pub async fn run_related(
    config: &Config,
    args: RelatedArgs,
) -> Result<RelatedResponse>

// Reuse from src/search/vector.rs:43
pub fn search_vector(
    conn: &Connection,
    query_embedding: &[f32],
    limit: usize,
) -> Result<Vec<VectorResult>>
// VectorResult { document_id: i64, distance: f64 }

// Reuse from src/embedding/ollama.rs:103
pub async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>>
```

## Robot Mode Output Schema
```json
{
  "ok": true,
  "data": {
    "source": { "type": "issue", "iid": 3864, "title": "..." },
    "query": "switch throw time...",
    "results": [{
      "source_type": "issue",
      "iid": 3800,
      "title": "Rail Break Card",
      "url": "...",
      "similarity_score": 0.87,
      "shared_labels": ["customer:BNSF"],
      "shared_authors": [],
      "project_path": "vs/typescript-code"
    }]
  },
  "meta": { "elapsed_ms": 42, "mode": "entity", "embedding_dims": 768, "distance_metric": "l2" }
}
```

## Clap Registration
```rust
// In src/main.rs Commands enum, add:
Related {
    /// Entity type ("issues" or "mrs") or free text query
    query_or_type: String,
    /// Entity IID (when first arg is entity type)
    iid: Option<i64>,
    /// Maximum results
    #[arg(short = 'n', long, default_value = "10")]
    limit: usize,
    /// Scope to project (fuzzy match)
    #[arg(short, long)]
    project: Option<String>,
},
```

## TDD Loop
RED: Tests in src/cli/commands/related.rs:
- test_related_entity_excludes_self: insert doc + embedding for issue, query related, assert source doc not in results
- test_related_shared_labels: insert 2 docs with overlapping labels (JSON in label_names), assert shared_labels computed correctly
- test_related_empty_embeddings: no embeddings in DB, assert exit code 14 with helpful error
- test_related_query_mode: embed free text via mock, assert results returned
- test_related_similarity_score_range: all scores between 0.0 and 1.0
- test_distance_to_similarity: unit test the conversion function (0.0->1.0, 1.0->0.5, large->~0.0)

GREEN: Implement related command using search_vector + hydration

VERIFY:
```bash
cargo test related:: && cargo clippy --all-targets -- -D warnings
cargo run --release -- -J related issues 3864 -n 5 | jq '.data.results[0].similarity_score'
```

## Acceptance Criteria
- [ ] lore related issues N returns top-K semantically similar entities
- [ ] lore related mrs N works for merge requests
- [ ] lore related 'free text' works as concept search (requires Ollama)
- [ ] Results exclude the input entity itself
- [ ] similarity_score is 0-1 range (higher = more similar), converted from L2 distance
- [ ] Robot mode includes shared_labels (from documents.label_names JSON), shared_authors per result
- [ ] Human mode shows ranked list with titles, scores, common labels
- [ ] No embeddings in DB: exit code 14 with message "Run 'lore embed' first"
- [ ] Ollama unavailable (query mode only): exit code 14 with suggestion
- [ ] Performance: <1s for 61K documents
- [ ] Command registered in main.rs and robot-docs

## Edge Cases
- Entity has no embedding (added after last lore embed): embed its content_text on-the-fly via OllamaClient, or exit 14 if Ollama unavailable
- All results have very low similarity (<0.3): include warning "No strongly related entities found"
- Entity is a discussion (not issue/MR): should still work (documents table has discussion docs)
- Multiple documents per entity (discussion docs): use the entity-level document, not discussion subdocs
- Free text query very short (1-2 words): may produce noisy results, add warning
- Entity not found in DB: exit code 17 with suggestion to sync
- Ambiguous project: exit code 18 with suggestion to use -p flag
- documents.label_names may be NULL or invalid JSON — parse_label_names handles both gracefully

## Dependency Context
- **bd-1ksf (hybrid search)**: BLOCKER. Shares OllamaClient infrastructure. Also ensures async search.rs patterns are established. Related reuses the same vector search infrastructure.

## Files to Create/Modify
- NEW: src/cli/commands/related.rs
- src/cli/commands/mod.rs (add pub mod related; re-export)
- src/main.rs (register Related subcommand in Commands enum, add handle_related fn)
- Reuse: search_vector() from src/search/vector.rs, OllamaClient from src/embedding/ollama.rs


Extract cross-references from resource_state_events

## Background
resource_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.

## Approach
Create src/core/references.rs (new module) or add to events_db.rs:

```rust
/// Extract cross-references from stored state events and insert into entity_references.
/// Looks for state events with source_merge_request_id IS NOT NULL (meaning "closed by MR").
///
/// Directionality: source = MR (that caused the close), target = issue (that was closed)
pub fn extract_refs_from_state_events(
    conn: &Connection,
    project_id: i64,
) -> Result<usize>  // returns count of new references inserted
```

SQL logic:
```sql
INSERT OR IGNORE INTO entity_references (
    source_entity_type, source_entity_id,
    target_entity_type, target_entity_id,
    reference_type, source_method, created_at
)
SELECT
    'merge_request',
    mr.id,
    'issue',
    rse.issue_id,
    'closes',
    'api_state_event',
    rse.created_at
FROM resource_state_events rse
JOIN merge_requests mr ON mr.project_id = rse.project_id AND mr.iid = rse.source_merge_request_id
WHERE rse.source_merge_request_id IS NOT NULL
  AND rse.issue_id IS NOT NULL
  AND rse.project_id = ?1;
```

Key: source_merge_request_id stores the MR iid, so we JOIN on merge_requests.iid to get the local DB id.

Register in src/core/mod.rs: `pub mod references;`

Call this after drain_dependent_queue in the sync pipeline (after all state events are stored).

## Acceptance Criteria
- [ ] State events with source_merge_request_id produce 'closes' references
- [ ] Source = MR (resolved by iid), target = issue
- [ ] source_method = 'api_state_event'
- [ ] INSERT OR IGNORE prevents duplicates with api_closes_issues data
- [ ] Returns count of newly inserted references
- [ ] No-op when no state events have source_merge_request_id

## Files
- src/core/references.rs (new)
- src/core/mod.rs (add `pub mod references;`)
- src/cli/commands/sync.rs (call after drain step)

## TDD Loop
RED: tests/references_tests.rs:
- `test_extract_refs_from_state_events_basic` - seed a "closed" state event with source_merge_request_id, verify entity_reference created
- `test_extract_refs_dedup_with_closes_issues` - insert ref from closes_issues API first, verify state event extraction doesn't duplicate
- `test_extract_refs_no_source_mr` - state events without source_merge_request_id produce no refs

Setup: create_test_db with migrations 001-011, seed project + issue + MR + state events.

GREEN: Implement extract_refs_from_state_events

VERIFY: `cargo test references -- --nocapture`

## Edge Cases
- 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)
- Multiple state events can reference the same MR for the same issue (reopen + re-close) — INSERT OR IGNORE handles dedup
- The merge_requests table might not have the MR yet if sync is still running — call this after all dependent fetches complete


Comprehensive robot-docs as agent bootstrap

## Background
Agents 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.

## Current State (Verified 2026-02-12)
- `handle_robot_docs()` at src/main.rs:2069
- Called at no-args in robot mode (main.rs:165) and via Commands::RobotDocs { brief } (main.rs:229)
- Current output top-level keys: name, version, description, activation, commands, aliases, exit_codes, clap_error_codes, error_format, workflows
- Missing: response_schema per command, example_output per command, quick_start section, glab equivalence table
- --brief flag exists but returns shorter version of same structure
- main.rs is 2579 lines total

## Current robot-docs Output Structure
```json
{
  "name": "lore",
  "version": "0.6.1",
  "description": "...",
  "activation": { "flags": ["--robot", "-J"], "env": "LORE_ROBOT=1", "auto_detect": "non-TTY" },
  "commands": [{ "name": "...", "description": "...", "flags": [...], "example": "..." }],
  "aliases": { ... },
  "exit_codes": { ... },
  "clap_error_codes": { ... },
  "error_format": { ... },
  "workflows": { ... }
}
```

## Approach

### 1. Add quick_start section
Top-level key with glab-to-lore translation and lore-exclusive feature summary:
```json
"quick_start": {
  "glab_equivalents": [
    { "glab": "glab issue list", "lore": "lore -J issues -n 50", "note": "Richer: includes labels, status, closing MRs" },
    { "glab": "glab issue view 123", "lore": "lore -J issues 123", "note": "Includes discussions, work-item status" },
    { "glab": "glab mr list", "lore": "lore -J mrs", "note": "Includes draft status, reviewers" },
    { "glab": "glab mr view 456", "lore": "lore -J mrs 456", "note": "Includes discussions, file changes" },
    { "glab": "glab api '/projects/:id/issues'", "lore": "lore -J issues -p project", "note": "Fuzzy project matching" }
  ],
  "lore_exclusive": [
    "search: FTS5 + vector hybrid search across all entities",
    "who: Expert/workload/reviews analysis per file path or person",
    "timeline: Chronological event reconstruction across entities",
    "stats: Database statistics with document/note/discussion counts",
    "count: Entity counts with state breakdowns"
  ]
}
```

### 2. Add response_schema per command
For each command in the commands array, add a `response_schema` field showing the JSON shape:
```json
{
  "name": "issues",
  "response_schema": {
    "ok": "boolean",
    "data": { "type": "array|object", "fields": ["iid", "title", "state", "..."] },
    "meta": { "elapsed_ms": "integer" }
  }
}
```
Commands with multiple output shapes (list vs detail) need both documented.

### 3. Add example_output per command
Realistic truncated JSON for each command. Keep each example under 500 bytes.

### 4. Token budget enforcement
- --brief mode: ONLY quick_start + command names + invocation syntax. Target <4000 tokens (~16000 bytes).
- Full mode: everything. Target <12000 tokens (~48000 bytes).
- Measure with: `cargo run --release -- --robot robot-docs --brief | wc -c`

## TDD Loop
RED: Tests in src/main.rs or new src/cli/commands/robot_docs.rs:
- test_robot_docs_has_quick_start: parse output JSON, assert quick_start.glab_equivalents array has >= 5 entries
- test_robot_docs_brief_size: --brief output < 16000 bytes
- test_robot_docs_full_size: full output < 48000 bytes
- test_robot_docs_has_response_schemas: every command entry has response_schema key
- 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)

GREEN: Add quick_start, response_schema, example_output to robot-docs output

VERIFY:
```bash
cargo test robot_docs && cargo clippy --all-targets -- -D warnings
cargo run --release -- --robot robot-docs | jq '.quick_start.glab_equivalents | length'
# Should return >= 5
cargo run --release -- --robot robot-docs --brief | wc -c
# Should be < 16000
```

## Acceptance Criteria
- [ ] robot-docs JSON has quick_start.glab_equivalents array with >= 5 entries
- [ ] robot-docs JSON has quick_start.lore_exclusive array
- [ ] Every command entry has response_schema showing the JSON shape
- [ ] Every command entry has example_output with realistic truncated data
- [ ] --brief output is under 16000 bytes (~4000 tokens)
- [ ] Full output is under 48000 bytes (~12000 tokens)
- [ ] An agent reading ONLY robot-docs can correctly invoke any lore command
- [ ] cargo test passes with new robot_docs tests

## Edge Cases
- Commands with multiple output shapes (e.g., issues list vs issues detail via iid) need both schemas documented
- --fields flag changes output shape -- document the effect in the response_schema
- robot-docs output must be stable across versions (agents may cache it)
- Version field should match Cargo.toml version

## Files to Modify
- src/main.rs fn handle_robot_docs() (~line 2069) — add quick_start section, response_schema, example_output
- Consider extracting to src/cli/commands/robot_docs.rs if the function exceeds 200 lines

Robot-docs enhanced with quick_start (glab equivalents, lore exclusives, read/write split) and example_output for issues/mrs/search/who


[CP1] gi sync-status enhancement

Enhance sync-status from CP0 stub to show issue cursors.

## Changes to src/cli/commands/sync_status.rs

Update the existing stub to show:
- Last run timestamp and duration
- Cursor positions per project (issues resource_type)
- Entity counts (issues, discussions, notes)

## Output Format
Last sync: 2026-01-25 10:30:00 (succeeded, 45s)

Cursors:
  group/project-one
    issues: 2026-01-25T10:25:00Z (gitlab_id: 12345678)

Counts:
  Issues: 1,234
  Discussions: 5,678
  Notes: 23,456 (4,567 system)

Files: src/cli/commands/sync_status.rs
Done when: Shows cursor positions and counts after ingestion


Implement 'lore trace' command with human and robot output

## Background

The trace command is Gate 5's capstone CLI. It answers 'Why was this code introduced?' by building file -> MR -> issue -> discussion chains.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 5.3.

## Codebase Context

- CLI pattern: same as file-history (Commands enum, handler in main.rs)
- trace.rs (bd-2n4): run_trace() returns TraceResult with chains
- Path parsing: support 'src/foo.rs:45' syntax (line number for future Tier 2)
- merge_requests.merged_at exists (migration 006) — use COALESCE(merged_at, updated_at) for ordering

## Approach

### 1. TraceArgs (`src/cli/mod.rs`):
```rust
#[derive(Parser)]
pub struct TraceArgs {
    pub path: String,                      // supports :line suffix
    #[arg(short = 'p', long)] pub project: Option<String>,
    #[arg(long)] pub discussions: bool,
    #[arg(long = "no-follow-renames")] pub no_follow_renames: bool,
    #[arg(short = 'n', long = "limit", default_value = "20")] pub limit: usize,
}
```

### 2. Path parsing:
```rust
fn parse_trace_path(input: &str) -> (String, Option<u32>) {
    if let Some((path, line)) = input.rsplit_once(':') {
        if let Ok(n) = line.parse::<u32>() { return (path.to_string(), Some(n)); }
    }
    (input.to_string(), None)
}
```
If line present: warn 'Line-level tracing requires Tier 2. Showing file-level results.'

### 3. Human output shows chains with MR -> issue -> discussion context

### 4. Robot JSON:
```json
{"ok": true, "data": {"path": "...", "resolved_paths": [...], "trace_chains": [...]}, "meta": {"tier": "api_only", "line_requested": null}}
```

## Acceptance Criteria

- [ ] `lore trace src/foo.rs` with human output
- [ ] `lore --robot trace src/foo.rs` with JSON
- [ ] :line suffix parses and emits Tier 2 warning
- [ ] -p, --discussions, --no-follow-renames, -n all work
- [ ] Rename-aware via resolve_rename_chain
- [ ] meta.tier = 'api_only'
- [ ] Added to VALID_COMMANDS and robot-docs
- [ ] `cargo check --all-targets` passes

## Files

- `src/cli/mod.rs` (TraceArgs + Commands::Trace)
- `src/cli/commands/trace.rs` (NEW)
- `src/cli/commands/mod.rs` (re-export)
- `src/main.rs` (handler + VALID_COMMANDS + robot-docs)

## TDD Loop

RED:
- `test_parse_trace_path_simple` - "src/foo.rs" -> (path, None)
- `test_parse_trace_path_with_line` - "src/foo.rs:42" -> (path, Some(42))
- `test_parse_trace_path_windows` - "C:/foo.rs" -> (path, None) — don't misparse drive letter

GREEN: Implement CLI wiring and handlers.

VERIFY: `cargo check --all-targets`

## Edge Cases

- Windows paths: don't misparse C: as line number
- No MR data: friendly message with suggestion to sync
- Very deep rename chain: bounded by resolve_rename_chain


lore explain: auto-generate issue/MR narrative

## Background
Given 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.

## Current Infrastructure (Verified 2026-02-12)
- show.rs: IssueDetail (line 69) and MrDetail (line 14) — entity detail with discussions
- timeline.rs: 5-stage pipeline SHIPPED — chronological event reconstruction
- notes table: 282K rows with body, author, created_at, is_system, discussion_id
- discussions table: links notes to parent entity (noteable_type, noteable_id), has resolved flag
- resource_state_events table: state changes with created_at, user_username (src/core/events_db.rs)
- resource_label_events table: label add/remove with created_at, user_username
- 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`

## Approach
New command: `lore explain issues N` / `lore explain mrs N`

### Data Assembly (reuse existing internals as library calls)
1. Entity detail: reuse show.rs query logic for IssueDetail/MrDetail
2. Timeline events: reuse timeline pipeline with entity-scoped seed
3. Discussion notes:
```sql
SELECT n.id, n.body, n.author_username, n.created_at
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE d.noteable_type = ? AND d.noteable_id = ?
  AND n.is_system = 0
ORDER BY n.created_at
```
4. Cross-references:
```sql
SELECT target_entity_type, target_entity_id, target_project_path,
       target_entity_iid, reference_type, source_method
FROM entity_references
WHERE (source_entity_type = ?1 AND source_entity_id = ?2)
UNION ALL
SELECT source_entity_type, source_entity_id, NULL,
       NULL, reference_type, source_method
FROM entity_references
WHERE (target_entity_type = ?1 AND target_entity_id = ?2)
```

### Key Decisions Heuristic
Notes from assignees/author that follow state or label changes within 1 hour:
```rust
struct StateOrLabelEvent {
    created_at: i64,  // ms epoch
    user: String,
    description: String,  // e.g. "state: opened -> closed" or "label: +bug"
}

fn extract_key_decisions(
    state_events: &[ResourceStateEvent],
    label_events: &[ResourceLabelEvent],
    notes: &[Note],
) -> Vec<KeyDecision> {
    // Merge both event types into a unified chronological list
    let mut events: Vec<StateOrLabelEvent> = Vec::new();
    for e in state_events {
        events.push(StateOrLabelEvent {
            created_at: e.created_at,
            user: e.user_username.clone(),
            description: format!("state: {} -> {}", e.from_state.as_deref().unwrap_or("?"), e.to_state),
        });
    }
    for e in label_events {
        let action = if e.action == "add" { "+" } else { "-" };
        events.push(StateOrLabelEvent {
            created_at: e.created_at,
            user: e.user_username.clone(),
            description: format!("label: {}{}", action, e.label_name.as_deref().unwrap_or("?")),
        });
    }
    events.sort_by_key(|e| e.created_at);

    let mut decisions = Vec::new();
    let one_hour_ms: i64 = 60 * 60 * 1000;

    for event in &events {
        // Find notes by same actor within 60 min after the event
        for note in notes {
            if note.author_username == event.user
                && note.created_at >= event.created_at
                && note.created_at <= event.created_at + one_hour_ms
            {
                decisions.push(KeyDecision {
                    timestamp: event.created_at,
                    actor: event.user.clone(),
                    action: event.description.clone(),
                    context_note: truncate(&note.body, 500),
                });
                break;  // one note per event
            }
        }
    }
    decisions.truncate(10);  // Cap at 10 key decisions
    decisions
}
```

### Narrative Sections
1. **Header**: title, author, opened date, state, assignees, labels, status_name
2. **Description excerpt**: first 500 chars of description (or full if shorter)
3. **Key decisions**: notes correlated with state/label changes (heuristic above)
4. **Activity summary**: counts of state changes, label changes, notes, time range
5. **Open threads**: discussions WHERE resolved = false
6. **Related entities**: closing MRs (with state), related issues from entity_references
7. **Timeline excerpt**: first 20 events from timeline pipeline

## Robot Mode Output Schema
```json
{
  "ok": true,
  "data": {
    "entity": {
      "type": "issue", "iid": 3864, "title": "...", "state": "opened",
      "author": "teernisse", "assignees": ["teernisse"],
      "labels": ["customer:BNSF"], "created_at": "...", "updated_at": "...",
      "url": "...", "status_name": "In progress"
    },
    "description_excerpt": "First 500 chars of description...",
    "key_decisions": [{
      "timestamp": "2026-01-15T...",
      "actor": "teernisse",
      "action": "state: opened -> in_progress",
      "context_note": "Starting work on the BNSF throw time integration..."
    }],
    "activity": {
      "state_changes": 3, "label_changes": 5, "notes": 42,
      "first_event": "2026-01-10T...", "last_event": "2026-02-12T..."
    },
    "open_threads": [{
      "discussion_id": "abc123",
      "started_by": "cseiber",
      "started_at": "2026-02-01T...",
      "note_count": 5,
      "last_note_at": "2026-02-10T..."
    }],
    "related": {
      "closing_mrs": [{ "iid": 200, "title": "...", "state": "merged" }],
      "related_issues": [{ "iid": 3800, "title": "Rail Break Card", "relation": "related" }]
    },
    "timeline_excerpt": [{ "timestamp": "...", "event_type": "...", "actor": "...", "summary": "..." }]
  },
  "meta": { "elapsed_ms": 350 }
}
```

## Clap Registration
```rust
// In src/main.rs Commands enum, add:
Explain {
    /// Entity type: "issues" or "mrs"
    entity_type: String,
    /// Entity IID
    iid: i64,
    /// Scope to project (fuzzy match)
    #[arg(short, long)]
    project: Option<String>,
},
```

## TDD Loop
RED: Tests in src/cli/commands/explain.rs:
- 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)
- test_explain_key_decision_heuristic: insert state change event + note by same author within 30 min, assert note appears in key_decisions
- test_explain_key_decision_ignores_unrelated_notes: insert note by different author, assert it does NOT appear in key_decisions
- test_explain_open_threads: insert 2 discussions (1 resolved, 1 unresolved), assert only unresolved in open_threads
- test_explain_no_notes: issue with zero notes produces header + description + empty sections
- test_explain_mr: insert MR with merged_at, assert entity includes type="merge_request"
- test_explain_activity_counts: insert 3 state events + 2 label events + 10 notes, assert counts match

GREEN: Implement explain command with section assembly

VERIFY:
```bash
cargo test explain:: && cargo clippy --all-targets -- -D warnings
cargo run --release -- -J explain issues 3864 | jq '.data | keys'
# Should include: entity, description_excerpt, key_decisions, activity, open_threads, related, timeline_excerpt
```

## Acceptance Criteria
- [ ] lore explain issues N produces structured output for any synced issue
- [ ] lore explain mrs N produces structured output for any synced MR
- [ ] Robot mode returns all 7 sections
- [ ] Human mode renders readable narrative with headers and indentation
- [ ] Key decisions heuristic: captures notes within 60 min of state/label changes by same actor
- [ ] Works fully offline (no API calls, no LLM)
- [ ] Performance: <500ms for issue with 50 notes
- [ ] Command registered in main.rs and robot-docs
- [ ] key_decisions capped at 10, timeline_excerpt capped at 20 events

## Edge Cases
- Issue with empty description: description_excerpt = "(no description)"
- Issue with 500+ notes: timeline_excerpt capped at 20, key_decisions capped at 10
- Issue not found in local DB: exit code 17 with suggestion to sync
- Ambiguous project: exit code 18 with suggestion to use -p flag
- MR with no review activity: activity section shows zeros
- Cross-project references: show as unresolved with project path hint
- Notes that are pure code blocks: include in key_decisions if correlated with events (they may contain implementation decisions)
- ResourceStateEvent/ResourceLabelEvent field names: check src/core/events_db.rs for exact struct definitions before implementing

## Dependency Context
- **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.

## Files to Create/Modify
- NEW: src/cli/commands/explain.rs
- src/cli/commands/mod.rs (add pub mod explain; re-export)
- src/main.rs (register Explain subcommand in Commands enum, add handle_explain fn)
- Reuse: show.rs queries, timeline pipeline, notes/discussions/resource_events queries from src/core/events_db.rs


Implement embedding pipeline with chunking

## Background
The embedding pipeline takes documents, chunks them (paragraph-boundary splitting with overlap), sends chunks to Ollama for embedding via async HTTP, and stores vectors in sqlite-vec + metadata. It uses keyset pagination, concurrent HTTP requests via FuturesUnordered, per-batch transactions, and dimension validation.

## Approach
Create \`src/embedding/pipeline.rs\` per PRD Section 4.4. **The pipeline is async.**

**Constants (per PRD):**
```rust
const BATCH_SIZE: usize = 32;           // texts per Ollama API call
const DB_PAGE_SIZE: usize = 500;        // keyset pagination page size
const EXPECTED_DIMS: usize = 768;       // nomic-embed-text dimensions
const CHUNK_MAX_CHARS: usize = 32_000;  // max chars per chunk
const CHUNK_OVERLAP_CHARS: usize = 500; // overlap between chunks
```

**Core async function:**
```rust
pub async fn embed_documents(
    conn: &Connection,
    client: &OllamaClient,
    selection: EmbedSelection,
    concurrency: usize,              // max in-flight HTTP requests
    progress_callback: Option<Box<dyn Fn(usize, usize)>>,
) -> Result<EmbedResult>
```

**EmbedSelection:** Pending | RetryFailed
**EmbedResult:** { embedded, failed, skipped }

**Algorithm (per PRD):**
1. count_pending_documents(conn, selection) for progress total
2. Keyset pagination loop: find_pending_documents(conn, DB_PAGE_SIZE, last_id, selection)
3. For each page:
   a. Begin transaction
   b. For each doc: clear_document_embeddings(&tx, doc.id), split_into_chunks(&doc.content)
   c. Build ChunkWork items with doc_hash + chunk_hash
   d. Commit clearing transaction
4. Batch ChunkWork texts into Ollama calls (BATCH_SIZE=32)
5. Use **FuturesUnordered** for concurrent HTTP, cap at \`concurrency\`
6. collect_writes() in per-batch transactions: validate dims (768), store LE bytes, write metadata
7. On error: record_embedding_error per chunk (not abort)
8. Advance keyset cursor

**ChunkWork struct:**
```rust
struct ChunkWork {
    doc_id: i64,
    chunk_index: usize,
    doc_hash: String,    // SHA-256 of FULL document (staleness detection)
    chunk_hash: String,  // SHA-256 of THIS chunk (provenance)
    text: String,
}
```

**Splitting:** split_into_chunks(content) -> Vec<(usize, String)>
- Documents <= CHUNK_MAX_CHARS: single chunk (index 0)
- Longer: split at paragraph boundaries (\\n\\n), fallback to sentence/word, with CHUNK_OVERLAP_CHARS overlap

**Storage:** embeddings as raw LE bytes, rowid = encode_rowid(doc_id, chunk_idx)
**Staleness detection:** uses document_hash (not chunk_hash) because it's document-level

Also create \`src/embedding/change_detector.rs\` (referenced in PRD module structure):
```rust
pub fn detect_embedding_changes(conn: &Connection) -> Result<Vec<PendingDocument>>;
```

## Acceptance Criteria
- [ ] Pipeline is async (uses FuturesUnordered for concurrent HTTP)
- [ ] concurrency parameter caps in-flight HTTP requests
- [ ] progress_callback reports (processed, total)
- [ ] New documents embedded, changed re-embedded, unchanged skipped
- [ ] clear_document_embeddings before re-embedding (range delete vec0 + metadata)
- [ ] Chunking at paragraph boundaries with 500-char overlap
- [ ] Short documents (<32k chars) produce exactly 1 chunk
- [ ] Embeddings stored as raw LE bytes in vec0
- [ ] Rowids encoded via encode_rowid(doc_id, chunk_index)
- [ ] Dimension validation: 768 floats per embedding (mismatch -> record error, not store)
- [ ] Per-batch transactions for writes
- [ ] Errors recorded in embedding_metadata per chunk (last_error, attempt_count)
- [ ] Keyset pagination (d.id > last_id, not OFFSET)
- [ ] Pending detection uses document_hash (not chunk_hash)
- [ ] \`cargo build\` succeeds

## Files
- \`src/embedding/pipeline.rs\` — new file (async)
- \`src/embedding/change_detector.rs\` — new file
- \`src/embedding/mod.rs\` — add \`pub mod pipeline; pub mod change_detector;\` + re-exports

## TDD Loop
RED: Unit tests for chunking:
- \`test_short_document_single_chunk\` — <32k produces [(0, full_content)]
- \`test_long_document_multiple_chunks\` — >32k splits at paragraph boundaries
- \`test_chunk_overlap\` — adjacent chunks share 500-char overlap
- \`test_no_paragraph_boundary\` — falls back to char boundary
Integration tests need Ollama or mock.
GREEN: Implement split_into_chunks, embed_documents (async)
VERIFY: \`cargo test pipeline\`

## Edge Cases
- Empty document content_text: skip (don't embed)
- No paragraph boundaries: split at CHUNK_MAX_CHARS with overlap
- Ollama error for one batch: record error per chunk, continue with next batch
- Dimension mismatch (model returns 512 instead of 768): record error, don't store corrupt data
- Document deleted between pagination and embedding: skip gracefully

Implemented embedding pipeline: chunking at paragraph boundaries with 500-char overlap, change detector (keyset pagination, hash-based staleness), async embed via Ollama with batch processing, dimension validation, per-chunk error recording, LE byte vector storage. 7 chunking tests pass. 289 total tests.


OBSERV: Create migration 014 for sync_runs enrichment

## Background
The sync_runs table (created in migration 001) has columns id, started_at, heartbeat_at, finished_at, status, command, error, metrics_json but NOTHING writes to it. This migration adds columns for the observability correlation ID and aggregate counts, enabling queryable sync history.

## Approach
Create migrations/014_sync_runs_enrichment.sql:

```sql
-- Migration 014: sync_runs enrichment for observability
-- Adds correlation ID and aggregate counts for queryable sync history

ALTER TABLE sync_runs ADD COLUMN run_id TEXT;
ALTER TABLE sync_runs ADD COLUMN total_items_processed INTEGER DEFAULT 0;
ALTER TABLE sync_runs ADD COLUMN total_errors INTEGER DEFAULT 0;

-- Index for correlation queries (find run by run_id from logs)
CREATE INDEX IF NOT EXISTS idx_sync_runs_run_id ON sync_runs(run_id);
```

Migration naming convention: check migrations/ directory. Current latest is 013_resource_event_watermarks.sql. Next is 014.

Note: SQLite ALTER TABLE ADD COLUMN is always safe -- it sets NULL for existing rows. DEFAULT 0 applies to new INSERTs only.

## Acceptance Criteria
- [ ] Migration 014 applies cleanly on a fresh DB (all migrations 001-014)
- [ ] Migration 014 applies cleanly on existing DB with 001-013 already applied
- [ ] sync_runs table has run_id TEXT column
- [ ] sync_runs table has total_items_processed INTEGER DEFAULT 0 column
- [ ] sync_runs table has total_errors INTEGER DEFAULT 0 column
- [ ] idx_sync_runs_run_id index exists
- [ ] Existing sync_runs rows (if any) have NULL run_id, 0 for counts
- [ ] cargo clippy --all-targets -- -D warnings passes (no code changes, but verify migration is picked up)

## Files
- migrations/014_sync_runs_enrichment.sql (new file)

## TDD Loop
RED:
  - test_migration_014_applies: apply all migrations on fresh in-memory DB, query sync_runs schema
  - test_migration_014_idempotent: CREATE INDEX IF NOT EXISTS makes re-run safe; ALTER TABLE ADD COLUMN is NOT idempotent in SQLite (will error). Consider: skip this test or use IF NOT EXISTS workaround
GREEN: Create migration file
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- ALTER TABLE ADD COLUMN in SQLite: NOT idempotent. Running migration twice will error "duplicate column name." The migration system should prevent re-runs, but IF NOT EXISTS is not available for ALTER TABLE in SQLite. Rely on migration tracking.
- Migration numbering conflict: if another PR adds 014 first, renumber to 015. Check before merging.
- metrics_json already exists (from migration 001): we don't touch it. The new columns supplement it with queryable aggregates.

Created migration 014 adding run_id TEXT, total_items_processed INTEGER, total_errors INTEGER to sync_runs, with idx_sync_runs_run_id index


WHO: Overlap mode query (query_overlap)

## Background

Overlap mode answers "Who else has MRs/notes touching my files?" — helps identify potential reviewers, collaborators, or conflicting work at a path. Tracks author and reviewer roles separately for richer signal.

## Approach

### SQL: two static variants (prefix/exact) with reviewer + author UNION ALL

Both branches return: username, role, touch_count (COUNT DISTINCT m.id), last_seen_at, mr_refs (GROUP_CONCAT of project-qualified refs).

Key differences from Expert:
- No scoring formula — just touch_count ranking
- mr_refs collected for actionable output (group/project!iid format)
- Rust-side merge needed (can't fully aggregate in SQL due to HashSet dedup of mr_refs across branches)

### Reviewer branch includes:
- Self-review exclusion: `n.author_username != m.author_username`
- MR state filter: `m.state IN ('opened','merged')`
- Project-qualified refs: `GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid))`

### Rust accumulator pattern:
```rust
struct OverlapAcc {
    username: String,
    author_touch_count: u32,
    review_touch_count: u32,
    touch_count: u32,
    last_seen_at: i64,
    mr_refs: HashSet<String>,  // O(1) dedup from the start
}
// Build HashMap<String, OverlapAcc> from rows
// Convert to Vec, sort, bound mr_refs
```

### Bounded mr_refs:
```rust
const MAX_MR_REFS_PER_USER: usize = 50;
let mr_refs_total = mr_refs.len() as u32;
let mr_refs_truncated = mr_refs.len() > MAX_MR_REFS_PER_USER;
```

### Deterministic sort: touch_count DESC, last_seen_at DESC, username ASC

### format_overlap_role():
```rust
fn format_overlap_role(user: &OverlapUser) -> &'static str {
    match (user.author_touch_count > 0, user.review_touch_count > 0) {
        (true, true) => "A+R", (true, false) => "A",
        (false, true) => "R", (false, false) => "-",
    }
}
```

### OverlapResult/OverlapUser structs include path_match ("exact"/"prefix"), truncated bool, per-user mr_refs_total + mr_refs_truncated

## Files

- `src/cli/commands/who.rs`

## TDD Loop

RED:
```
test_overlap_dual_roles — user is author of MR 1 and reviewer of MR 2 at same path; verify A+R role, both touch counts > 0, mr_refs contain "team/backend!"
test_overlap_multi_project_mr_refs — same iid 100 in two projects; verify both "team/backend!100" and "team/frontend!100" present
test_overlap_excludes_self_review_notes — author comments on own MR; review_touch_count must be 0
```

GREEN: Implement query_overlap with both SQL variants + accumulator
VERIFY: `cargo test -- overlap`

## Acceptance Criteria

- [ ] test_overlap_dual_roles passes (A+R role detection)
- [ ] test_overlap_multi_project_mr_refs passes (project-qualified refs unique)
- [ ] test_overlap_excludes_self_review_notes passes
- [ ] Default since window: 30d
- [ ] mr_refs sorted alphabetically for deterministic output
- [ ] touch_count uses coherent units (COUNT DISTINCT m.id on BOTH branches)

## Edge Cases

- Both branches count MRs (not DiffNotes) for coherent touch_count — mixing units produces misleading totals
- mr_refs from GROUP_CONCAT may contain duplicates across branches — HashSet handles dedup
- Project scoping on n.project_id (not m.project_id) for index alignment
- mr_refs sorted before output (HashSet iteration is nondeterministic)

Implemented by agent team: migration 017, CLI skeleton, all 5 query modes, human+robot output, 20 tests. All quality gates pass.


Implement vector search function

## Background
Vector search queries the sqlite-vec virtual table for nearest-neighbor documents. Because documents may have multiple chunks, the raw KNN results need deduplication by document_id (keeping the best/lowest distance per document). The function over-fetches 3x to ensure enough unique documents after dedup.

## Approach
Create `src/search/vector.rs`:

```rust
pub struct VectorResult {
    pub document_id: i64,
    pub distance: f64,  // Lower = closer match
}

/// Search documents using sqlite-vec KNN query.
/// Over-fetches 3x limit to handle chunk dedup.
pub fn search_vector(
    conn: &Connection,
    query_embedding: &[f32],  // 768-dim embedding of search query
    limit: usize,
) -> Result<Vec<VectorResult>>
```

**SQL (KNN query):**
```sql
SELECT rowid, distance
FROM embeddings
WHERE embedding MATCH ?
  AND k = ?
ORDER BY distance
```

**Algorithm:**
1. Convert query_embedding to raw LE bytes
2. Execute KNN with k = limit * 3 (over-fetch for dedup)
3. Decode each rowid via decode_rowid() -> (document_id, chunk_index)
4. Group by document_id, keep minimum distance (best chunk)
5. Sort by distance ascending
6. Take first `limit` results

## Acceptance Criteria
- [ ] Returns deduplicated document-level results (not chunk-level)
- [ ] Best chunk distance kept per document (lowest distance wins)
- [ ] KNN with k parameter (3x limit)
- [ ] Query embedding passed as raw LE bytes
- [ ] Results sorted by distance ascending (closest first)
- [ ] Returns at most `limit` results
- [ ] Empty embeddings table returns empty Vec
- [ ] `cargo build` succeeds

## Files
- `src/search/vector.rs` — new file
- `src/search/mod.rs` — add `pub use vector::{search_vector, VectorResult};`

## TDD Loop
RED: Integration tests need sqlite-vec + seeded embeddings:
- `test_vector_search_basic` — finds nearest document
- `test_vector_search_dedup` — multi-chunk doc returns once with best distance
- `test_vector_search_empty` — empty table returns empty
- `test_vector_search_limit` — respects limit parameter
GREEN: Implement search_vector
VERIFY: `cargo test vector`

## Edge Cases
- All chunks belong to same document: returns single result
- Query embedding wrong dimension: sqlite-vec may error — handle gracefully
- Over-fetch returns fewer than limit unique docs: return what we have
- Distance = 0.0: exact match (valid result)

Implemented search_vector with KNN query, 3x over-fetch, chunk dedup. 3 tests pass.


[CP1] Cargo.toml updates - async-stream and futures

Add required dependencies for async pagination streams.

## Changes
Add to Cargo.toml:
- async-stream = "0.3"
- futures = "0.3"

## Why
The pagination methods use async generators which require async-stream crate.
futures crate provides StreamExt for consuming the streams.

## Done When
- cargo check passes with new deps
- No unused dependency warnings

Files: Cargo.toml


[CP1] Integration tests for label linkage

Integration tests verifying label linkage and stale removal.

## Tests (tests/label_linkage_tests.rs)

- clears_existing_labels_before_linking_new_set
- removes_stale_label_links_on_issue_update
- handles_issue_with_all_labels_removed
- preserves_labels_that_still_exist

## Test Scenario
1. Create issue with labels [A, B]
2. Verify issue_labels has links to A and B
3. Update issue with labels [B, C]
4. Verify A link removed, B preserved, C added

## Why This Matters
The clear-and-relink pattern ensures GitLab reality is reflected locally.
If we only INSERT, removed labels would persist incorrectly.

Files: tests/label_linkage_tests.rs
Done when: Stale label links correctly removed on resync


Add entity_references table to migration 010

## Background
The entity_references table is now part of migration 011 (combined with resource event tables and dependent fetch queue). This bead is satisfied by bd-hu3 since the entity_references table schema is included in the same migration.

## Approach
This bead's work is folded into bd-hu3 (Write migration 011). The entity_references table from Phase B spec §2.2 is included in migrations/011_resource_events.sql alongside the event tables and queue.

The entity_references schema includes:
- source/target entity type + id with reference_type and source_method
- Unresolved reference support (target_entity_id NULL with target_project_path + target_entity_iid)
- UNIQUE constraint using COALESCE for nullable columns
- Partial indexes for source, target (where not null), and unresolved refs

No separate migration file needed — this is in 011.

## Acceptance Criteria
- [ ] entity_references table exists in migration 011 (verified by bd-hu3)
- [ ] UNIQUE constraint handles NULL columns via COALESCE
- [ ] Indexes created: source composite, target composite (partial), unresolved (partial)
- [ ] reference_type CHECK includes 'closes', 'mentioned', 'related'
- [ ] source_method CHECK includes 'api_closes_issues', 'api_state_event', 'system_note_parse'

## Files
- migrations/011_resource_events.sql (part of bd-hu3)

## TDD Loop
Covered by bd-hu3's test_migration_011_entity_references_dedup test.

VERIFY: `cargo test migration_tests -- --nocapture`

## Edge Cases
- Same as bd-hu3's entity_references edge cases

Work folded into bd-hu3 (migration 011 includes entity_references table)


Implement timeline robot mode JSON output

## Background

Robot mode JSON for timeline follows the {ok, data, meta} envelope pattern. The JSON schema MUST match spec Section 3.5 exactly — this is the contract for AI agent consumers.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.5 (Robot Mode JSON).

## Codebase Context

- Robot mode pattern: all commands use {ok: true, data: {...}, meta: {...}} envelope
- Timestamps: internal ms epoch UTC -> output ISO 8601 via core::time::ms_to_iso()
- source_method values in DB: 'api', 'note_parse', 'description_parse' (NOT spec's api_closes_issues etc.)
- Serde rename: use #[serde(rename = "type")] for entity objects per spec

## Approach

Create `print_timeline_json()` in `src/cli/commands/timeline.rs`:

### Key JSON structure (spec Section 3.5):
- data.seed_entities: [{type, iid, project}] — note "type" not "entity_type", "project" not "project_path"
- data.expanded_entities: [{type, iid, project, depth, via: {from: {type,iid,project}, reference_type, source_method}}]
- data.unresolved_references: [{source: {type,iid,project}, target_project, target_type, target_iid, reference_type}]
- data.events: [{timestamp (ISO 8601), entity_type, entity_iid, project, event_type, summary, actor, url, is_seed, details}]
- meta: {search_mode: "lexical", expansion_depth, expand_mentions, total_entities, total_events, evidence_notes_included, unresolved_references, showing}

### Details object per event type:
- created: {labels: [...]}
- note_evidence: {note_id, snippet}
- state_changed: {state}
- label_added: {label}

### Rust JSON Structs

```rust
#[derive(Serialize)]
struct TimelineJson {
    ok: bool,
    data: TimelineDataJson,
    meta: TimelineMetaJson,
}

#[derive(Serialize)]
struct TimelineDataJson {
    query: String,
    event_count: usize,
    seed_entities: Vec<EntityJson>,
    expanded_entities: Vec<ExpandedEntityJson>,
    unresolved_references: Vec<UnresolvedRefJson>,
    events: Vec<EventJson>,
}

#[derive(Serialize)]
struct EntityJson {
    #[serde(rename = "type")]
    entity_type: String,
    iid: i64,
    project: String,
}

#[derive(Serialize)]
struct TimelineMetaJson {
    search_mode: String,        // always "lexical"
    expansion_depth: u32,
    expand_mentions: bool,
    total_entities: usize,
    total_events: usize,        // before limit
    evidence_notes_included: usize,
    unresolved_references: usize,
    showing: usize,             // after limit
}
```

### source_method values: use CODEBASE values (api/note_parse/description_parse), not spec values

## Acceptance Criteria

- [ ] Valid JSON to stdout
- [ ] {ok, data, meta} envelope
- [ ] ISO 8601 timestamps
- [ ] Entity objects use "type" and "project" keys per spec
- [ ] Nested "via" object on expanded entities per spec
- [ ] Events include url and details fields
- [ ] meta.total_events before limit; meta.showing after limit
- [ ] source_method uses codebase values
- [ ] `cargo check --all-targets` passes

## Files

- `src/cli/commands/timeline.rs` (add print_timeline_json + JSON structs)
- `src/cli/commands/mod.rs` (re-export)

## TDD Loop

Verify: `lore --robot timeline "test" | jq '.data.expanded_entities[0].via.from'`

## Edge Cases

- Empty results: events=[], meta.showing=0
- Null actor/url: serialize as null (not omitted)
- source_method: use actual DB values, not spec originals

Implemented print_timeline_json_with_meta() robot JSON output in src/cli/commands/timeline.rs with {ok,data,meta} envelope, ISO timestamps, entity/expanded/unresolved JSON structs, event details per type


[CP0] Config loading with Zod validation

## Background

Config loading is critical infrastructure - every CLI command needs the config. Uses Zod for schema validation with sensible defaults. Must handle missing files gracefully with typed errors.

Reference: docs/prd/checkpoint-0.md sections "Configuration Schema", "Config Resolution Order"

## Approach

**src/core/config.ts:**
```typescript
import { z } from 'zod';
import { readFileSync } from 'node:fs';
import { ConfigNotFoundError, ConfigValidationError } from './errors';
import { getConfigPath } from './paths';

export const ConfigSchema = z.object({
  gitlab: z.object({
    baseUrl: z.string().url(),
    tokenEnvVar: z.string().default('GITLAB_TOKEN'),
  }),
  projects: z.array(z.object({
    path: z.string().min(1),
  })).min(1),
  sync: z.object({
    backfillDays: z.number().int().positive().default(14),
    staleLockMinutes: z.number().int().positive().default(10),
    heartbeatIntervalSeconds: z.number().int().positive().default(30),
    cursorRewindSeconds: z.number().int().nonnegative().default(2),
    primaryConcurrency: z.number().int().positive().default(4),
    dependentConcurrency: z.number().int().positive().default(2),
  }).default({}),
  storage: z.object({
    dbPath: z.string().optional(),
    backupDir: z.string().optional(),
    compressRawPayloads: z.boolean().default(true),
  }).default({}),
  embedding: z.object({
    provider: z.literal('ollama').default('ollama'),
    model: z.string().default('nomic-embed-text'),
    baseUrl: z.string().url().default('http://localhost:11434'),
    concurrency: z.number().int().positive().default(4),
  }).default({}),
});

export type Config = z.infer<typeof ConfigSchema>;

export function loadConfig(cliOverride?: string): Config {
  const path = getConfigPath(cliOverride);
  // throws ConfigNotFoundError if missing
  // throws ConfigValidationError if invalid
}
```

## Acceptance Criteria

- [ ] `loadConfig()` returns validated Config object
- [ ] `loadConfig()` throws ConfigNotFoundError if file missing
- [ ] `loadConfig()` throws ConfigValidationError with Zod errors if invalid
- [ ] Empty optional fields get default values
- [ ] projects array must have at least 1 item
- [ ] gitlab.baseUrl must be valid URL
- [ ] All number fields must be positive integers
- [ ] tests/unit/config.test.ts passes (8 tests)

## Files

CREATE:
- src/core/config.ts
- tests/unit/config.test.ts
- tests/fixtures/mock-responses/valid-config.json
- tests/fixtures/mock-responses/invalid-config.json

## TDD Loop

RED:
```typescript
// tests/unit/config.test.ts
describe('Config', () => {
  it('loads config from file path')
  it('throws ConfigNotFoundError if file missing')
  it('throws ConfigValidationError if required fields missing')
  it('validates project paths are non-empty strings')
  it('applies default values for optional fields')
  it('loads from XDG path by default')
  it('respects GI_CONFIG_PATH override')
  it('respects --config flag override')
})
```

GREEN: Implement loadConfig() function

VERIFY: `npm run test -- tests/unit/config.test.ts`

## Edge Cases

- JSON parse error should wrap in ConfigValidationError
- Zod error messages should be human-readable
- File exists but empty → ConfigValidationError
- File has extra fields → should pass (Zod strips by default)


WHO: Verification gate — check, clippy, fmt, EXPLAIN QUERY PLAN

## Background

Final verification gate before the who epic is considered complete. Confirms code quality, test coverage, and index utilization against real data.

## Approach

### Step 1: Compiler checks
```bash
cargo check --all-targets
cargo clippy --all-targets -- -D warnings
cargo fmt --check
cargo test
```

### Step 2: Manual smoke test (against real DB)
```bash
cargo run --release -- who src/features/global-search/
cargo run --release -- who @asmith
cargo run --release -- who @asmith --reviews
cargo run --release -- who --active
cargo run --release -- who --active --since 30d
cargo run --release -- who --overlap libs/shared-frontend/src/features/global-search/
cargo run --release -- who --path README.md
cargo run --release -- who --path Makefile
cargo run --release -- -J who src/features/global-search/  # robot mode
cargo run --release -- -J who @asmith                       # robot mode
cargo run --release -- who src/features/global-search/ -p typescript  # project scoped
```

### Step 3: EXPLAIN QUERY PLAN verification
```bash
# Expert: should use idx_notes_diffnote_path_created
sqlite3 ~/.local/share/lore/lore.db "
  EXPLAIN QUERY PLAN
  SELECT n.author_username, COUNT(*), MAX(n.created_at)
  FROM notes n
  WHERE n.note_type = 'DiffNote' AND n.is_system = 0
    AND n.position_new_path LIKE 'src/features/global-search/%' ESCAPE '\\'
    AND n.created_at >= 0
  GROUP BY n.author_username;"

# Active global: should use idx_discussions_unresolved_recent_global
sqlite3 ~/.local/share/lore/lore.db "
  EXPLAIN QUERY PLAN
  SELECT d.id, d.last_note_at FROM discussions d
  WHERE d.resolvable = 1 AND d.resolved = 0 AND d.last_note_at >= 0
  ORDER BY d.last_note_at DESC LIMIT 20;"

# Active scoped: should use idx_discussions_unresolved_recent
sqlite3 ~/.local/share/lore/lore.db "
  EXPLAIN QUERY PLAN
  SELECT d.id, d.last_note_at FROM discussions d
  WHERE d.resolvable = 1 AND d.resolved = 0 AND d.project_id = 1
    AND d.last_note_at >= 0
  ORDER BY d.last_note_at DESC LIMIT 20;"
```

## Files

No files modified — verification only.

## TDD Loop

This bead is the TDD VERIFY phase for the entire epic. No code written.
VERIFY: All commands in Steps 1-3 must succeed. Document results.

## Acceptance Criteria

- [ ] cargo check --all-targets: 0 errors
- [ ] cargo clippy --all-targets -- -D warnings: 0 warnings
- [ ] cargo fmt --check: no formatting changes needed
- [ ] cargo test: all tests pass (including 20+ who tests)
- [ ] Expert EXPLAIN shows idx_notes_diffnote_path_created
- [ ] Active global EXPLAIN shows idx_discussions_unresolved_recent_global
- [ ] Active scoped EXPLAIN shows idx_discussions_unresolved_recent
- [ ] All 5 modes produce reasonable output against real data
- [ ] Robot mode produces valid JSON for all modes

## Edge Cases

- DB path may differ from ~/.local/share/lore/lore.db — check config with `lore -J doctor` first to get actual db_path
- EXPLAIN QUERY PLAN output format varies by SQLite version — look for the index name in any output column, not an exact string match
- If the DB has not been synced recently, smoke tests may return empty results — run `lore sync` first if needed
- Project name "typescript" in the -p flag may not exist — use an actual project from `lore -J status` output
- The real DB may not have migration 017 yet — run `cargo run --release -- migrate` first if the who command fails with a missing index error
- clippy::pedantic + clippy::nursery are enabled — common issues: arrays vs vec![] for sorted collections, too_many_arguments on test helpers (use #[allow])


OBSERV: Add tracing-appender dependency to Cargo.toml

## Background
tracing-appender provides non-blocking, daily-rotating file writes for the tracing ecosystem. It's the canonical solution used by tokio-rs projects. We need it for the file logging layer (Phase 1) that writes JSON logs to ~/.local/share/lore/logs/.

## Approach
Add tracing-appender to [dependencies] in Cargo.toml (line ~54, after the existing tracing-subscriber entry):

```toml
tracing-appender = "0.2"
```

Also add the "json" feature to tracing-subscriber since the file layer and --log-format json both need it:

```toml
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
```

Current tracing deps (Cargo.toml lines 53-54):
  tracing = "0.1"
  tracing-subscriber = { version = "0.3", features = ["env-filter"] }

## Acceptance Criteria
- [ ] cargo check --all-targets succeeds with tracing-appender available
- [ ] tracing_appender::rolling::daily() is importable
- [ ] tracing-subscriber json feature is available (fmt::layer().json() compiles)
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- Cargo.toml (modify lines 53-54 region)

## TDD Loop
RED: Not applicable (dependency addition)
GREEN: Add deps, run cargo check
VERIFY: cargo check --all-targets && cargo clippy --all-targets -- -D warnings

## Edge Cases
- Ensure tracing-appender 0.2 is compatible with tracing-subscriber 0.3 (both from tokio-rs/tracing monorepo, always compatible)
- The "json" feature on tracing-subscriber pulls in serde_json, which is already a dependency

Added tracing-appender 0.2 and json feature to tracing-subscriber


[CP0] Core utilities - paths, time, errors, logger

## Background

Core utilities provide the foundation for all other modules. Path resolution enables XDG-compliant config/data locations. Time utilities ensure consistent timestamp handling (ms epoch for DB, ISO for API). Error classes provide typed exceptions for clean error handling. Logger provides structured logging to stderr.

Reference: docs/prd/checkpoint-0.md sections "Config + Data Locations", "Timestamp Convention", "Error Classes", "Logging Configuration"

## Approach

**src/core/paths.ts:**
- `getConfigPath(cliOverride?)`: resolution order is CLI flag → GI_CONFIG_PATH env → XDG default → local fallback
- `getDataDir()`: uses XDG_DATA_HOME or ~/.local/share/gi
- `getDbPath(configOverride?)`: returns data dir + data.db
- `getBackupDir(configOverride?)`: returns data dir + backups/

**src/core/time.ts:**
- `isoToMs(isoString)`: converts GitLab API ISO 8601 → ms epoch
- `msToIso(ms)`: converts ms epoch → ISO 8601
- `nowMs()`: returns Date.now() for DB storage

**src/core/errors.ts:**
Error hierarchy (all extend GiError base class with code and cause):
- ConfigNotFoundError, ConfigValidationError
- GitLabAuthError, GitLabNotFoundError, GitLabRateLimitError, GitLabNetworkError
- DatabaseLockError, MigrationError
- TokenNotSetError

**src/core/logger.ts:**
- pino logger to stderr (fd 2) with pino-pretty in dev
- Child loggers: dbLogger, gitlabLogger, configLogger
- LOG_LEVEL env var support (default: info)

## Acceptance Criteria

- [ ] `getConfigPath()` returns ~/.config/gi/config.json when no overrides
- [ ] `getConfigPath()` respects GI_CONFIG_PATH env var
- [ ] `getConfigPath("./custom.json")` returns "./custom.json"
- [ ] `isoToMs("2024-01-27T00:00:00.000Z")` returns 1706313600000
- [ ] `msToIso(1706313600000)` returns "2024-01-27T00:00:00.000Z"
- [ ] All error classes have correct code property
- [ ] Logger outputs to stderr (not stdout)
- [ ] tests/unit/paths.test.ts passes
- [ ] tests/unit/errors.test.ts passes

## Files

CREATE:
- src/core/paths.ts
- src/core/time.ts
- src/core/errors.ts
- src/core/logger.ts
- tests/unit/paths.test.ts
- tests/unit/errors.test.ts

## TDD Loop

RED: Write tests first
```typescript
// tests/unit/paths.test.ts
describe('getConfigPath', () => {
  it('uses XDG_CONFIG_HOME if set')
  it('falls back to ~/.config/gi if XDG not set')
  it('prefers --config flag over environment')
  it('prefers environment over XDG default')
  it('falls back to local gi.config.json in dev')
})
```

GREEN: Implement paths.ts, errors.ts, time.ts, logger.ts

VERIFY: `npm run test -- tests/unit/paths.test.ts tests/unit/errors.test.ts`

## Edge Cases

- XDG_CONFIG_HOME may not exist - don't create, just return path
- existsSync() check for local fallback - only return if file exists
- Time conversion must handle timezone edge cases - always use UTC
- Logger must work even if pino-pretty not installed (production)


[CP1] Discussion ingestion module

## Background

Discussion ingestion fetches all discussions and notes for a single issue. It is called as part of dependent sync - only for issues whose `updated_at` has advanced beyond `discussions_synced_for_updated_at`. After successful sync, it updates the watermark to prevent redundant refetches.

## Approach

### Module: src/ingestion/discussions.rs

### Key Structs

```rust
#[derive(Debug, Default)]
pub struct IngestDiscussionsResult {
    pub discussions_fetched: usize,
    pub discussions_upserted: usize,
    pub notes_upserted: usize,
    pub system_notes_count: usize,
}
```

### Main Function

```rust
pub async fn ingest_issue_discussions(
    conn: &Connection,
    client: &GitLabClient,
    config: &Config,
    project_id: i64,           // Local DB project ID
    gitlab_project_id: i64,    // GitLab project ID
    issue_iid: i64,
    local_issue_id: i64,
    issue_updated_at: i64,     // For watermark update
) -> Result<IngestDiscussionsResult>
```

### Logic

1. Stream discussions via `client.paginate_issue_discussions()`
2. For each discussion:
   - Begin transaction
   - Store raw payload (compressed based on config)
   - Transform to NormalizedDiscussion
   - Upsert discussion
   - Get local discussion ID
   - Transform notes via `transform_notes()`
   - For each note: store raw payload, upsert note
   - Track system_notes_count
   - Commit transaction
3. After all discussions processed: `mark_discussions_synced(conn, local_issue_id, issue_updated_at)`

### Helper Functions

```rust
fn upsert_discussion(conn, discussion, payload_id) -> Result<()>
fn get_local_discussion_id(conn, project_id, gitlab_id) -> Result<i64>
fn upsert_note(conn, discussion_id, note, payload_id) -> Result<()>
fn mark_discussions_synced(conn, issue_id, issue_updated_at) -> Result<()>
```

### Critical Invariant

`discussions_synced_for_updated_at` MUST be updated only AFTER all discussions are successfully synced. This watermark prevents redundant refetches on subsequent runs.

## Acceptance Criteria

- [ ] `ingest_issue_discussions` streams all discussions for an issue
- [ ] Each discussion wrapped in transaction for atomicity
- [ ] Raw payloads stored for discussions and notes
- [ ] `discussions_synced_for_updated_at` updated after successful sync
- [ ] System notes tracked in result.system_notes_count
- [ ] Notes linked to correct discussion via local discussion ID

## Files

- src/ingestion/mod.rs (add `pub mod discussions;`)
- src/ingestion/discussions.rs (create)

## TDD Loop

RED:
```rust
// tests/discussion_watermark_tests.rs
#[tokio::test] async fn fetches_discussions_when_updated_at_advanced()
#[tokio::test] async fn updates_watermark_after_successful_discussion_sync()
#[tokio::test] async fn does_not_update_watermark_on_discussion_sync_failure()
#[tokio::test] async fn stores_raw_payload_for_each_discussion()
#[tokio::test] async fn stores_raw_payload_for_each_note()
```

GREEN: Implement ingest_issue_discussions with watermark logic

VERIFY: `cargo test discussion_watermark`

## Edge Cases

- Issue with 0 discussions - mark synced anyway (empty is valid)
- Discussion with 0 notes - should not happen per GitLab API (discussions always have >= 1 note)
- Network failure mid-sync - watermark NOT updated, next run retries
- individual_note=true discussions - have exactly 1 note


Create migration 007_documents.sql

## Background
Migration 007 creates the document storage layer that Gate A's entire search pipeline depends on. It introduces 5 tables: `documents` (the searchable unit), `document_labels` and `document_paths` (for filtered search), and two queue tables (`dirty_sources`, `pending_discussion_fetches`) that drive incremental document regeneration and discussion fetching in Gate C. This is the most-depended-on bead in the project (6 downstream beads block on it).

## Approach
Create `migrations/007_documents.sql` with the exact SQL from PRD Section 1.1. The schema is fully specified in the PRD — no design decisions remain.

Key implementation details:
- `documents` table has `UNIQUE(source_type, source_id)` constraint for upsert support
- `document_labels` and `document_paths` use `WITHOUT ROWID` for compact storage
- `dirty_sources` uses composite PK `(source_type, source_id)` with `ON CONFLICT` upsert semantics
- `pending_discussion_fetches` uses composite PK `(project_id, noteable_type, noteable_iid)`
- Both queue tables have `next_attempt_at` indexed for efficient backoff queries
- `labels_hash` and `paths_hash` on documents enable write optimization (skip unchanged labels/paths)

Register the migration in `src/core/db.rs` by adding entry 7 to the `MIGRATIONS` array.

## Acceptance Criteria
- [ ] `migrations/007_documents.sql` file exists with all 5 CREATE TABLE statements
- [ ] Migration applies cleanly on fresh DB (`cargo test migration_tests`)
- [ ] Migration applies cleanly after CP2 schema (migrations 001-006 already applied)
- [ ] All foreign keys enforced: `documents.project_id -> projects(id)`, `document_labels.document_id -> documents(id) ON DELETE CASCADE`, `document_paths.document_id -> documents(id) ON DELETE CASCADE`, `pending_discussion_fetches.project_id -> projects(id)`
- [ ] All indexes created: `idx_documents_project_updated`, `idx_documents_author`, `idx_documents_source`, `idx_documents_hash`, `idx_document_labels_label`, `idx_document_paths_path`, `idx_dirty_sources_next_attempt`, `idx_pending_discussions_next_attempt`
- [ ] `labels_hash TEXT NOT NULL DEFAULT ''` and `paths_hash TEXT NOT NULL DEFAULT ''` columns present on `documents`
- [ ] Schema version 7 recorded in `schema_version` table
- [ ] `cargo build` succeeds after registering migration in db.rs

## Files
- `migrations/007_documents.sql` — new file (copy exact SQL from PRD Section 1.1)
- `src/core/db.rs` — add migration 7 to `MIGRATIONS` array

## TDD Loop
RED: Add migration to db.rs, run `cargo test migration_tests` — fails because SQL file missing
GREEN: Create `migrations/007_documents.sql` with full schema
VERIFY: `cargo test migration_tests && cargo build`

## Edge Cases
- Migration must be idempotent-safe if applied twice (INSERT into schema_version will fail on second run — this is expected and handled by the migration runner's version check)
- `WITHOUT ROWID` tables (document_labels, document_paths) require explicit PK — already defined
- `CHECK` constraint on `documents.source_type` must match exactly: `'issue','merge_request','discussion'`
- `CHECK` constraint on `documents.truncated_reason` allows NULL or one of 4 specific values

Completed: migration 007_documents.sql with 5 tables (documents, document_labels, document_paths, dirty_sources, pending_discussion_fetches), 8 indexes, registered in db.rs, cargo build + migration tests pass


Write migration 011: resource event tables, entity_references, and dependent fetch queue

## Background
Phase B needs three new event tables and a generic dependent fetch queue to power temporal queries (timeline, file-history, trace). These tables store structured event data from GitLab Resource Events APIs, replacing fragile system note parsing for state/label/milestone changes.

Migration 010_chunk_config.sql already exists, so Phase B starts at migration 011.

## Approach
Create migrations/011_resource_events.sql with the exact schema from the Phase B spec (§1.2 + §2.2):

**Event tables:**
- resource_state_events: state changes (opened/closed/reopened/merged/locked) with source_merge_request_id for "closed by MR" linking
- resource_label_events: label add/remove with label_name
- resource_milestone_events: milestone add/remove with milestone_title + milestone_id

**Cross-reference table (Gate 2):**
- entity_references: source/target entity pairs with reference_type (closes/mentioned/related), source_method provenance, and unresolved reference support (target_entity_id NULL with target_project_path + target_entity_iid)

**Dependent fetch queue:**
- pending_dependent_fetches: generic job queue with job_type IN ('resource_events', 'mr_closes_issues', 'mr_diffs'), locked_at crash recovery, exponential backoff via attempts + next_retry_at

**All tables must have:**
- CHECK constraints for entity exclusivity (issue XOR merge_request) on event tables
- UNIQUE constraints (gitlab_id + project_id for events, composite for queue, multi-column for references)
- Partial indexes (WHERE issue_id IS NOT NULL, WHERE target_entity_id IS NULL, etc.)
- CASCADE deletes on project_id and entity FKs

Register in src/core/db.rs MIGRATIONS array:
```rust
("011", include_str!("../../migrations/011_resource_events.sql")),
```

End migration with:
```sql
INSERT INTO schema_version (version, applied_at, description)
VALUES (11, strftime('%s', 'now') * 1000, 'Resource events, entity references, and dependent fetch queue');
```

## Acceptance Criteria
- [ ] migrations/011_resource_events.sql exists with all 4 tables + indexes + constraints
- [ ] src/core/db.rs MIGRATIONS array includes ("011", include_str!(...))
- [ ] `cargo build` succeeds (migration SQL compiles into binary)
- [ ] `cargo test migration` passes (migration applies cleanly on fresh DB)
- [ ] All CHECK constraints enforced (issue XOR merge_request on event tables)
- [ ] All UNIQUE constraints present (prevents duplicate events/refs/jobs)
- [ ] entity_references UNIQUE handles NULL coalescing correctly
- [ ] pending_dependent_fetches job_type CHECK includes all three types

## Files
- migrations/011_resource_events.sql (new)
- src/core/db.rs (add to MIGRATIONS array, line ~46)

## TDD Loop
RED: Add test in tests/migration_tests.rs:
- `test_migration_011_creates_event_tables` - verify all 4 tables exist after migration
- `test_migration_011_entity_exclusivity_constraint` - verify CHECK rejects both NULL and both non-NULL for issue_id/merge_request_id
- `test_migration_011_event_dedup` - verify UNIQUE(gitlab_id, project_id) rejects duplicate events
- `test_migration_011_entity_references_dedup` - verify UNIQUE constraint with NULL coalescing
- `test_migration_011_queue_dedup` - verify UNIQUE(project_id, entity_type, entity_iid, job_type)

GREEN: Write the migration SQL + register in db.rs

VERIFY: `cargo test migration_tests -- --nocapture`

## Edge Cases
- entity_references UNIQUE uses COALESCE for NULLable columns — test with both resolved and unresolved refs
- pending_dependent_fetches job_type CHECK — ensure 'mr_diffs' is included (Gate 4 needs it)
- SQLite doesn't enforce CHECK on INSERT OR REPLACE — verify constraint behavior
- The entity exclusivity CHECK must allow exactly one of issue_id/merge_request_id to be non-NULL
- Verify CASCADE deletes work (delete project → all events/refs/jobs deleted)

Already completed in prior session, re-closing after accidental reopen


Add GitLab client MR pagination methods

## Background
GitLab client pagination for merge requests and discussions. Must support robust pagination with fallback chain because some GitLab instances/proxies strip headers.

## Approach
Add to existing `src/gitlab/client.rs`:
1. `MergeRequestPage` struct - Items + pagination metadata
2. `parse_link_header_next()` - RFC 8288 Link header parsing
3. `fetch_merge_requests_page()` - Single page fetch with metadata
4. `paginate_merge_requests()` - Async stream for all MRs
5. `paginate_mr_discussions()` - Async stream for MR discussions

## Files
- `src/gitlab/client.rs` - Add pagination methods

## Acceptance Criteria
- [ ] `MergeRequestPage` struct exists with `items`, `next_page`, `is_last_page`
- [ ] `parse_link_header_next()` extracts `rel="next"` URL from Link header
- [ ] Pagination fallback chain: Link header > x-next-page > full-page heuristic
- [ ] `paginate_merge_requests()` returns `Pin<Box<dyn Stream<Item = Result<GitLabMergeRequest>>>>`
- [ ] `paginate_mr_discussions()` returns `Pin<Box<dyn Stream<Item = Result<GitLabDiscussion>>>>`
- [ ] MR endpoint uses `scope=all&state=all` to include all MRs
- [ ] `cargo test client` passes

## TDD Loop
RED: `cargo test fetch_merge_requests` -> method not found
GREEN: Add pagination methods
VERIFY: `cargo test client`

## Struct Definitions
```rust
#[derive(Debug)]
pub struct MergeRequestPage {
    pub items: Vec<GitLabMergeRequest>,
    pub next_page: Option<u32>,
    pub is_last_page: bool,
}
```

## Link Header Parsing (RFC 8288)
```rust
/// Parse Link header to extract rel="next" URL.
fn parse_link_header_next(headers: &reqwest::header::HeaderMap) -> Option<String> {
    headers
        .get("link")
        .and_then(|v| v.to_str().ok())
        .and_then(|link_str| {
            // Format: <url>; rel="next", <url>; rel="last"
            for part in link_str.split(',') {
                let part = part.trim();
                if part.contains("rel=\"next\"") || part.contains("rel=next") {
                    if let Some(start) = part.find('<') {
                        if let Some(end) = part.find('>') {
                            return Some(part[start + 1..end].to_string());
                        }
                    }
                }
            }
            None
        })
}
```

## Pagination Fallback Chain
```rust
let next_page = match (link_next, x_next_page, items.len() as u32 == per_page) {
    (Some(_), _, _) => Some(page + 1),     // Link header present: continue
    (None, Some(np), _) => Some(np),       // x-next-page present: use it
    (None, None, true) => Some(page + 1),  // Full page, no headers: try next
    (None, None, false) => None,           // Partial page: we're done
};
```

## Fetch Single Page
```rust
pub async fn fetch_merge_requests_page(
    &self,
    gitlab_project_id: i64,
    updated_after: Option<i64>,
    cursor_rewind_seconds: u32,
    page: u32,
    per_page: u32,
) -> Result<MergeRequestPage> {
    let mut params = vec![
        ("scope", "all".to_string()),
        ("state", "all".to_string()),
        ("order_by", "updated_at".to_string()),
        ("sort", "asc".to_string()),
        ("per_page", per_page.to_string()),
        ("page", page.to_string()),
    ];
    // Apply cursor rewind for safety
    // ...
}
```

## Async Stream Pattern
```rust
pub fn paginate_merge_requests(
    &self,
    gitlab_project_id: i64,
    updated_after: Option<i64>,
    cursor_rewind_seconds: u32,
) -> Pin<Box<dyn Stream<Item = Result<GitLabMergeRequest>> + Send + '_>> {
    Box::pin(async_stream::try_stream! {
        let mut page = 1u32;
        let per_page = 100u32;
        loop {
            let page_result = self.fetch_merge_requests_page(...).await?;
            for mr in page_result.items {
                yield mr;
            }
            if page_result.is_last_page {
                break;
            }
            match page_result.next_page {
                Some(np) => page = np,
                None => break,
            }
        }
    })
}
```

## Edge Cases
- `scope=all` required to include all MRs (not just authored by current user)
- `state=all` required to include merged/closed (GitLab defaults may exclude)
- `locked` state cannot be filtered server-side (use local SQL filtering)
- Cursor rewind should clamp to 0 to avoid negative timestamps


Epic: Gate 3 - Decision Timeline (lore timeline)

## Background

Gate 3 is the first user-facing temporal feature: `lore timeline <query>`. It answers "What happened with X?" by finding matching entities via FTS5, expanding cross-references, collecting all temporal events, and rendering a chronological narrative.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Gate 3 (Sections 3.1-3.6).

## Prerequisites (All Complete)

- Gates 1-2 COMPLETE: resource_state_events, resource_label_events, resource_milestone_events, entity_references all populated
- FTS5 search index (CP3): working search infrastructure for keyword matching
- Migration 015 (commit SHAs, closes watermark) exists on disk (registered by bd-1oo)

## Architecture — 5-Stage Pipeline

```
1. SEED:    FTS5 keyword search -> matched document IDs (issues, MRs, notes)
2. HYDRATE: Map document IDs -> source entities + top matched notes as evidence
3. EXPAND:  BFS over entity_references (depth-limited, edge-type filtered)
4. COLLECT: Gather events from all tables for seed + expanded entities
5. RENDER:  Sort chronologically, format as human or robot output
```

No new tables required. All reads are from existing tables at query time.

## Children (Execution Order)

1. **bd-20e** — Define TimelineEvent model and TimelineEventType enum (types first)
2. **bd-32q** — Implement timeline seed phase: FTS5 keyword search to entity IDs
3. **bd-ypa** — Implement timeline expand phase: BFS cross-reference expansion
4. **bd-3as** — Implement timeline event collection and chronological interleaving
5. **bd-1nf** — Register lore timeline command with all flags (CLI wiring)
6. **bd-2f2** — Implement timeline human output renderer
7. **bd-dty** — Implement timeline robot mode JSON output

## Gate Completion Criteria

- [ ] `lore timeline <query>` returns chronologically ordered events
- [ ] Seed entities found via FTS5 keyword search (issues, MRs, and notes)
- [ ] State, label, and milestone events interleaved from resource event tables
- [ ] Entity creation and merge events included
- [ ] Evidence-bearing notes included as note_evidence events (top FTS5 matches, bounded default 10)
- [ ] Cross-reference expansion follows entity_references to configurable depth
- [ ] Default: follows closes + related edges; --expand-mentions adds mentioned
- [ ] --depth 0 disables expansion
- [ ] --since filters by event timestamp
- [ ] -p scopes to project
- [ ] Human output is colored and readable
- [ ] Robot mode returns structured JSON with expansion provenance
- [ ] Unresolved (external) references included in JSON output

Gate 3 complete: all 7 children closed. Timeline pipeline fully implemented with SEED->HYDRATE->EXPAND->COLLECT->RENDER stages, human+robot renderers, CLI wiring with 9 flags, robot-docs manifest entry


Add fetchMrFileChanges config flag

## Background

Config flag controlling whether MR diff fetching is enabled, following the fetchResourceEvents pattern.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 4.2.

## Codebase Context

- src/core/config.rs has SyncConfig with fetch_resource_events: bool (serde rename 'fetchResourceEvents', default true)
- Default impl exists for SyncConfig
- CLI sync options in src/cli/mod.rs have --no-events flag pattern
- Orchestrator checks config.sync.fetch_resource_events before enqueuing resource_events jobs

## Approach

### 1. Add to SyncConfig (`src/core/config.rs`):
```rust
#[serde(rename = "fetchMrFileChanges", default = "default_true")]
pub fetch_mr_file_changes: bool,
```

Update Default impl to include fetch_mr_file_changes: true.

### 2. CLI override (`src/cli/mod.rs`):
```rust
#[arg(long = "no-file-changes")]
pub no_file_changes: bool,
```

### 3. Apply in main.rs:
```rust
if args.no_file_changes { config.sync.fetch_mr_file_changes = false; }
```

### 4. Guard in orchestrator:
```rust
if config.sync.fetch_mr_file_changes { enqueue mr_diffs jobs }
```

## Acceptance Criteria

- [ ] fetchMrFileChanges in SyncConfig, default true
- [ ] Config without field defaults to true
- [ ] --no-file-changes disables diff fetching
- [ ] Orchestrator skips mr_diffs when false
- [ ] `cargo check --all-targets` passes

## Files

- `src/core/config.rs` (add field + Default)
- `src/cli/mod.rs` (add --no-file-changes)
- `src/main.rs` (apply override)
- `src/ingestion/orchestrator.rs` (guard enqueue)

## TDD Loop

RED:
- `test_config_default_fetch_mr_file_changes` - default is true
- `test_config_deserialize_false` - JSON with false

GREEN: Add field, default, serde attribute.

VERIFY: `cargo test --lib -- config`

## Edge Cases

- Config missing fetchMrFileChanges key entirely: serde default_true fills in true
- Config explicitly set to false: no mr_diffs jobs enqueued, mr_file_changes table empty
- --no-file-changes with --full sync: overrides config, no diffs fetched even on full resync
- sync.fetchMrFileChanges = false in config + no --no-file-changes flag: respects config (no override)

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.


[CP1] Discussion and note transformers

Transform GitLab discussion/note payloads to normalized database schema.

## Module
src/gitlab/transformers/discussion.rs

## Structs

### NormalizedDiscussion
- gitlab_discussion_id: String
- project_id: i64
- issue_id: i64
- noteable_type: String ("Issue")
- individual_note: bool
- first_note_at, last_note_at: Option<i64>
- last_seen_at: i64
- resolvable, resolved: bool

### NormalizedNote
- gitlab_id: i64
- project_id: i64
- note_type: Option<String>
- is_system: bool
- author_username: String
- body: String
- created_at, updated_at, last_seen_at: i64
- position: i32 (array index in notes[])
- resolvable, resolved: bool
- resolved_by: Option<String>
- resolved_at: Option<i64>

## Functions

### transform_discussion(gitlab_discussion, local_project_id, local_issue_id) -> NormalizedDiscussion
- Compute first_note_at/last_note_at from notes array min/max created_at
- Compute resolvable (any note resolvable)
- Compute resolved (resolvable AND all resolvable notes resolved)

### transform_notes(gitlab_discussion, local_project_id) -> Vec<NormalizedNote>
- Enumerate notes to get position (array index)
- Set is_system from note.system
- Convert timestamps to ms epoch

Files: src/gitlab/transformers/discussion.rs
Tests: tests/discussion_transformer_tests.rs
Done when: Unit tests pass for discussion/note transformation with system note flagging


[CP1] gi show issue command

Show issue details with discussions.

## Module
src/cli/commands/show.rs

## Clap Definition
Show {
    #[arg(value_parser = ["issue", "mr"])]
    entity: String,

    iid: i64,

    #[arg(long)]
    project: Option<String>,
}

## Output Format
Issue #1234: Authentication redesign
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Project:  group/project-one
State:    opened
Author:   @johndoe
Created:  2024-01-15
Updated:  2024-03-20
Labels:   enhancement, auth
URL:      https://gitlab.example.com/group/project-one/-/issues/1234

Description:
  We need to redesign the authentication flow to support...

Discussions (5):

  @janedoe (2024-01-16):
    I agree we should move to JWT-based auth...

    @johndoe (2024-01-16):
      What about refresh token strategy?

  @bobsmith (2024-01-17):
    Have we considered OAuth2?

## Ambiguity Handling
If multiple projects have same iid, either:
- Prompt for --project flag
- Show error listing which projects have that iid

Files: src/cli/commands/show.rs
Done when: Issue detail view displays all fields including threaded discussions


Rewrite agent skills to mandate lore for all reads

## Background
Agent 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.

## Approach
1. Audit all config files for glab read patterns
2. Replace each with lore equivalent
3. Add explicit Read/Write Split section to AGENTS.md and CLAUDE.md

## Translation Table
| glab (remove)                      | lore (replace with)              |
|------------------------------------|----------------------------------|
| glab issue view N                  | lore -J issues N                 |
| glab issue list                    | lore -J issues -n 50             |
| glab issue list -l bug             | lore -J issues --label bug       |
| glab mr view N                     | lore -J mrs N                    |
| glab mr list                       | lore -J mrs                      |
| glab mr list -s opened             | lore -J mrs -s opened            |
| glab api '/projects/:id/issues'    | lore -J issues -p project        |

## Files to Audit

### Project-level
- /Users/tayloreernisse/projects/gitlore/AGENTS.md — primary project instructions

### Global Claude config
- ~/.claude/CLAUDE.md — global instructions (already has lore section, verify no glab reads)

### Skills directory
Scan all .md files under ~/.claude/skills/ for glab read patterns.
Likely candidates: any skill that references GitLab data retrieval.

### Rules directory
Scan all .md files under ~/.claude/rules/ for glab read patterns.

### Work-ghost templates
- ~/projects/work-ghost/tasks/*.md — task templates that reference glab reads

## Verification Commands
After all changes:
```bash
# Should return ZERO matches (no glab read commands remain)
rg 'glab issue view|glab issue list|glab mr view|glab mr list|glab api.*issues|glab api.*merge_requests' ~/.claude/ AGENTS.md --type md

# These should REMAIN (write operations stay with glab)
rg 'glab (issue|mr) (create|update|close|delete|approve|merge|note|rebase)' ~/.claude/ AGENTS.md --type md
```

## Read/Write Split Section to Add
Add to AGENTS.md and ~/.claude/CLAUDE.md:
```markdown
## Read/Write Split: lore vs glab

| Operation | Tool | Why |
|-----------|------|-----|
| List issues/MRs | lore | Richer: includes status, discussions, closing MRs |
| View issue/MR detail | lore | Pre-joined discussions, work-item status |
| Search across entities | lore | FTS5 + vector hybrid search |
| Expert/workload analysis | lore | who command — no glab equivalent |
| Timeline reconstruction | lore | Chronological narrative — no glab equivalent |
| Create/update/close | glab | Write operations |
| Approve/merge MR | glab | Write operations |
| CI/CD pipelines | glab | Not in lore scope |
```

## TDD Loop
This is a config-only task — no Rust code changes. Verification is via grep:

RED: Run verification commands above, expect matches (glab reads still present)
GREEN: Replace all glab read references with lore equivalents
VERIFY: Run verification commands, expect zero glab read matches

## Acceptance Criteria
- [ ] Zero glab read references in AGENTS.md
- [ ] Zero glab read references in ~/.claude/CLAUDE.md
- [ ] Zero glab read references in ~/.claude/skills/**/*.md
- [ ] Zero glab read references in ~/.claude/rules/**/*.md
- [ ] glab write references preserved (create, update, close, approve, merge, CI)
- [ ] Read/Write Split section added to AGENTS.md
- [ ] Read/Write Split section added to ~/.claude/CLAUDE.md
- [ ] Fresh agent session uses lore for reads without prompting (manual verification)

## Edge Cases
- Skills that use glab api for data NOT in lore (e.g., CI pipeline data, project settings) — these should remain
- glab MCP server references — evaluate case-by-case (keep for write operations)
- Shell aliases or env vars that invoke glab for reads — out of scope unless in config files
- Skills that use `glab issue list | jq` for ad-hoc queries — replace with `lore -J issues | jq`
- References to glab in documentation context (explaining what tools exist) vs operational context (telling agent to use glab) — only replace operational references

Agent skills rewritten: AGENTS.md and CLAUDE.md updated with read/write split mandating lore for reads, glab for writes


Epic: CP2 Gate E - CLI Complete

## Background
Gate 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.

## Acceptance Criteria (Pass/Fail)

### List Command
- [ ] `gi list mrs` shows MR table with columns: iid, title, state, author, branches, updated
- [ ] `gi list mrs --state=opened` filters to only opened MRs
- [ ] `gi list mrs --state=merged` filters to only merged MRs
- [ ] `gi list mrs --state=closed` filters to only closed MRs
- [ ] `gi list mrs --state=locked` filters locally (not server-side filter)
- [ ] `gi list mrs --draft` shows only draft MRs
- [ ] `gi list mrs --no-draft` excludes draft MRs
- [ ] Draft MRs show `[DRAFT]` prefix in title column
- [ ] `gi list mrs --author=username` filters by author
- [ ] `gi list mrs --assignee=username` filters by assignee
- [ ] `gi list mrs --reviewer=username` filters by reviewer
- [ ] `gi list mrs --target-branch=main` filters by target branch
- [ ] `gi list mrs --source-branch=feature/x` filters by source branch
- [ ] `gi list mrs --label=bugfix` filters by label
- [ ] `gi list mrs --limit=N` limits output

### Show Command
- [ ] `gi show mr <iid>` displays full MR detail
- [ ] Show includes: title, description, state, draft status, author
- [ ] Show includes: assignees, reviewers, labels
- [ ] Show includes: source_branch, target_branch
- [ ] Show includes: detailed_merge_status (e.g., "mergeable")
- [ ] Show includes: merge_user and merged_at for merged MRs
- [ ] Show includes: discussions with author and date
- [ ] DiffNote shows file context: `[src/file.ts:45]`
- [ ] Multi-line DiffNote shows range: `[src/file.ts:45-48]`
- [ ] Resolved discussions show `[RESOLVED]` marker

### Count Command
- [ ] `gi count mrs` shows total count
- [ ] Count shows state breakdown: opened, merged, closed

### Sync Status
- [ ] `gi sync-status` shows MR cursor position
- [ ] Sync status shows last sync timestamp

## Validation Script
```bash
#!/bin/bash
set -e

DB_PATH="${XDG_DATA_HOME:-$HOME/.local/share}/gitlab-inbox/db.sqlite3"

echo "=== Gate E: CLI Complete ==="

# 1. Test list command (basic)
echo "Step 1: Basic list..."
gi list mrs --limit=5 || { echo "FAIL: list mrs failed"; exit 1; }

# 2. Test state filters
echo "Step 2: State filters..."
for state in opened merged closed; do
  echo "  Testing --state=$state"
  gi list mrs --state=$state --limit=3 || echo "  Warning: No $state MRs"
done

# 3. Test draft filters
echo "Step 3: Draft filters..."
gi list mrs --draft --limit=3 || echo "  Note: No draft MRs found"
gi list mrs --no-draft --limit=3 || echo "  Note: All MRs are drafts?"

# 4. Check [DRAFT] prefix
echo "Step 4: Check [DRAFT] prefix..."
DRAFT_IID=$(sqlite3 "$DB_PATH" "SELECT iid FROM merge_requests WHERE draft = 1 LIMIT 1;")
if [ -n "$DRAFT_IID" ]; then
  if gi list mrs --limit=100 | grep -q "\[DRAFT\]"; then
    echo "  PASS: [DRAFT] prefix found"
  else
    echo "  FAIL: Draft MR exists but no [DRAFT] prefix in output"
  fi
else
  echo "  Skip: No draft MRs to test"
fi

# 5. Test author/assignee/reviewer filters
echo "Step 5: User filters..."
AUTHOR=$(sqlite3 "$DB_PATH" "SELECT author_username FROM merge_requests LIMIT 1;")
if [ -n "$AUTHOR" ]; then
  echo "  Testing --author=$AUTHOR"
  gi list mrs --author="$AUTHOR" --limit=3
fi

REVIEWER=$(sqlite3 "$DB_PATH" "SELECT username FROM mr_reviewers LIMIT 1;")
if [ -n "$REVIEWER" ]; then
  echo "  Testing --reviewer=$REVIEWER"
  gi list mrs --reviewer="$REVIEWER" --limit=3
fi

# 6. Test branch filters
echo "Step 6: Branch filters..."
TARGET=$(sqlite3 "$DB_PATH" "SELECT target_branch FROM merge_requests LIMIT 1;")
if [ -n "$TARGET" ]; then
  echo "  Testing --target-branch=$TARGET"
  gi list mrs --target-branch="$TARGET" --limit=3
fi

# 7. Test show command
echo "Step 7: Show command..."
MR_IID=$(sqlite3 "$DB_PATH" "SELECT iid FROM merge_requests LIMIT 1;")
gi show mr "$MR_IID" || { echo "FAIL: show mr failed"; exit 1; }

# 8. Test show with DiffNote context
echo "Step 8: Show with DiffNote..."
DIFFNOTE_MR=$(sqlite3 "$DB_PATH" "
  SELECT DISTINCT m.iid
  FROM merge_requests m
  JOIN discussions d ON d.merge_request_id = m.id
  JOIN notes n ON n.discussion_id = d.id
  WHERE n.position_new_path IS NOT NULL
  LIMIT 1;
")
if [ -n "$DIFFNOTE_MR" ]; then
  echo "  Testing MR with DiffNotes: !$DIFFNOTE_MR"
  OUTPUT=$(gi show mr "$DIFFNOTE_MR")
  if echo "$OUTPUT" | grep -qE '\[[^]]+:[0-9]+\]'; then
    echo "  PASS: File context [path:line] found"
  else
    echo "  FAIL: DiffNote should show [path:line] context"
  fi
else
  echo "  Skip: No MRs with DiffNotes"
fi

# 9. Test count command
echo "Step 9: Count command..."
gi count mrs || { echo "FAIL: count mrs failed"; exit 1; }

# 10. Test sync-status
echo "Step 10: Sync status..."
gi sync-status || echo "  Note: sync-status may need implementation"

echo ""
echo "=== Gate E: PASSED ==="
```

## Test Commands (Quick Verification)
```bash
# List with all column types visible:
gi list mrs --limit=10

# Show a specific MR:
gi show mr 42

# Count with breakdown:
gi count mrs

# Complex filter:
gi list mrs --state=opened --reviewer=alice --target-branch=main --limit=5
```

## Expected Output Formats

### gi list mrs
```
Merge Requests (showing 5 of 1,234)

  !847  Refactor auth to use JWT tokens           merged   @johndoe   main <- feature/jwt   3d ago
  !846  Fix memory leak in websocket handler      opened   @janedoe   main <- fix/websocket 5d ago
  !845  [DRAFT] Add dark mode CSS variables       opened   @bobsmith  main <- ui/dark-mode  1w ago
  !844  Update dependencies to latest versions    closed   @alice     main <- chore/deps    2w ago
```

### gi show mr 847
```
Merge Request !847: Refactor auth to use JWT tokens
================================================================================

Project:        group/project-one
State:          merged
Draft:          No
Author:         @johndoe
Assignees:      @janedoe, @bobsmith
Reviewers:      @alice, @charlie
Labels:         enhancement, auth, reviewed
Source:         feature/jwt
Target:         main
Merge Status:   merged
Merged By:      @alice
Merged At:      2024-03-20 14:30:00

Description:
  Moving away from session cookies to JWT-based authentication...

Discussions (3):

  @janedoe (2024-03-16) [src/auth/jwt.ts:45]:
    Should we use a separate signing key for refresh tokens?

    @johndoe (2024-03-16):
      Good point. I'll add a separate key with rotation support.

  @alice (2024-03-18) [RESOLVED]:
    Looks good! Just one nit about the token expiry constant.
```

### gi count mrs
```
Merge Requests: 1,234
  opened: 89
  merged: 1,045
  closed: 100
```

## Dependencies
This gate requires:
- bd-3js (CLI commands implementation)
- All previous gates must pass first

## Edge Cases
- Ambiguous MR iid across projects: should prompt for `--project` or show error
- Very long titles: should truncate with `...` in list view
- Empty description: should show "No description" or empty section
- No discussions: should show "No discussions" message
- Unicode in titles/descriptions: should render correctly


Add embedding error variants to LoreError

## Background
Gate 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).

## Approach
Extend `src/core/error.rs` with 4 new variants per PRD Section 4.3.

**ErrorCode additions:**
```rust
pub enum ErrorCode {
    // ... existing (InternalError=1 through TransformError=13)
    OllamaUnavailable,      // exit code 14
    OllamaModelNotFound,    // exit code 15
    EmbeddingFailed,        // exit code 16
}
```

**LoreError additions:**
```rust
/// Ollama-specific connection failure. Use instead of Http for Ollama errors
/// because it includes base_url for actionable error messages.
#[error("Cannot connect to Ollama at {base_url}. Is it running?")]
OllamaUnavailable {
    base_url: String,
    #[source]
    source: Option<reqwest::Error>,
},

#[error("Ollama model '{model}' not found. Run: ollama pull {model}")]
OllamaModelNotFound { model: String },

#[error("Embedding failed for document {document_id}: {reason}")]
EmbeddingFailed { document_id: i64, reason: String },

#[error("No embeddings found. Run: lore embed")]
EmbeddingsNotBuilt,
```

**code() mapping:**
- OllamaUnavailable => ErrorCode::OllamaUnavailable
- OllamaModelNotFound => ErrorCode::OllamaModelNotFound
- EmbeddingFailed => ErrorCode::EmbeddingFailed
- EmbeddingsNotBuilt => ErrorCode::EmbeddingFailed (shares exit code 16)

**suggestion() mapping:**
- OllamaUnavailable => "Start Ollama: ollama serve"
- OllamaModelNotFound => "Pull the model: ollama pull nomic-embed-text"
- EmbeddingFailed => "Check Ollama logs or retry with 'lore embed --retry-failed'"
- EmbeddingsNotBuilt => "Generate embeddings first: lore embed"

## Acceptance Criteria
- [ ] All 4 error variants compile
- [ ] Exit codes: OllamaUnavailable=14, OllamaModelNotFound=15, EmbeddingFailed=16
- [ ] EmbeddingsNotBuilt shares exit code 16 (mapped to ErrorCode::EmbeddingFailed)
- [ ] OllamaUnavailable has `base_url: String` and `source: Option<reqwest::Error>`
- [ ] EmbeddingFailed has `document_id: i64` and `reason: String`
- [ ] Each variant has actionable .suggestion() text per PRD
- [ ] ErrorCode Display: OLLAMA_UNAVAILABLE, OLLAMA_MODEL_NOT_FOUND, EMBEDDING_FAILED
- [ ] Robot mode JSON includes code + suggestion for each variant
- [ ] `cargo build` succeeds

## Files
- `src/core/error.rs` — extend LoreError enum + ErrorCode enum + impl blocks

## TDD Loop
RED: Add variants, `cargo build` fails on missing match arms
GREEN: Add match arms in code(), exit_code(), suggestion(), to_robot_error(), Display
VERIFY: `cargo build && cargo test error`

## Edge Cases
- OllamaUnavailable with source=None: still valid (used when no HTTP error available)
- EmbeddingFailed with document_id=0: used for batch-level failures (not per-doc)
- EmbeddingsNotBuilt vs OllamaUnavailable: former means "never ran embed", latter means "Ollama down right now"

Completed: Added 4 LoreError variants (OllamaUnavailable, OllamaModelNotFound, EmbeddingFailed, EmbeddingsNotBuilt) and 3 ErrorCode variants with exit codes 14-16. cargo build succeeds.