Implement Ollama API client

## Background
The Ollama API client provides the HTTP interface to the local Ollama embedding server. It handles health checks (is Ollama running? does the model exist?), batch embedding requests (up to 32 texts per call), and error translation to LoreError variants. This is the lowest-level embedding component — the pipeline (bd-am7) builds on top of it.

## Approach
Create \`src/embedding/ollama.rs\` per PRD Section 4.2. **Uses async reqwest (not blocking).**

```rust
use reqwest::Client;  // NOTE: async Client, not reqwest::blocking
use serde::{Deserialize, Serialize};
use crate::core::error::{LoreError, Result};

pub struct OllamaConfig {
    pub base_url: String,      // default \"http://localhost:11434\"
    pub model: String,         // default \"nomic-embed-text\"
    pub timeout_secs: u64,     // default 60
}

impl Default for OllamaConfig { /* PRD defaults */ }

pub struct OllamaClient {
    client: Client,      // async reqwest::Client
    config: OllamaConfig,
}

#[derive(Serialize)]
struct EmbedRequest { model: String, input: Vec<String> }

#[derive(Deserialize)]
struct EmbedResponse { model: String, embeddings: Vec<Vec<f32>> }

#[derive(Deserialize)]
struct TagsResponse { models: Vec<ModelInfo> }

#[derive(Deserialize)]
struct ModelInfo { name: String }

impl OllamaClient {
    pub fn new(config: OllamaConfig) -> Self;

    /// Async health check: GET /api/tags
    /// Model matched via starts_with (\"nomic-embed-text\" matches \"nomic-embed-text:latest\")
    pub async fn health_check(&self) -> Result<()>;

    /// Async batch embedding: POST /api/embed
    /// Input: Vec<String> of texts, Response: Vec<Vec<f32>> of 768-dim embeddings
    pub async fn embed_batch(&self, texts: Vec<String>) -> Result<Vec<Vec<f32>>>;
}

/// Quick health check without full client (async).
pub async fn check_ollama_health(base_url: &str) -> bool;
```

**Error mapping (per PRD):**
- Connection refused/timeout -> LoreError::OllamaUnavailable { base_url, source: Some(e) }
- Model not in /api/tags -> LoreError::OllamaModelNotFound { model }
- Non-200 from /api/embed -> LoreError::EmbeddingFailed { document_id: 0, reason: format!(\"HTTP {}: {}\", status, body) }

**Key PRD detail:** Model matching uses \`starts_with\` (not exact match) so \"nomic-embed-text\" matches \"nomic-embed-text:latest\".

## Acceptance Criteria
- [ ] Uses async reqwest::Client (not blocking)
- [ ] health_check() is async, detects server availability and model presence
- [ ] Model matched via starts_with (handles \":latest\" suffix)
- [ ] embed_batch() is async, sends POST /api/embed
- [ ] Batch size up to 32 texts
- [ ] Returns Vec<Vec<f32>> with 768 dimensions each
- [ ] OllamaUnavailable error includes base_url and source error
- [ ] OllamaModelNotFound error includes model name
- [ ] Non-200 response mapped to EmbeddingFailed with status + body
- [ ] Timeout: 60 seconds default (configurable via OllamaConfig)
- [ ] \`cargo build\` succeeds

## Files
- \`src/embedding/ollama.rs\` — new file
- \`src/embedding/mod.rs\` — add \`pub mod ollama;\` and re-exports

## TDD Loop
RED: Tests (unit tests with mock, integration needs Ollama):
- \`test_config_defaults\` — verify default base_url, model, timeout
- \`test_health_check_model_starts_with\` — \"nomic-embed-text\" matches \"nomic-embed-text:latest\"
- \`test_embed_batch_parse\` — mock response parsed correctly
- \`test_connection_error_maps_to_ollama_unavailable\`
GREEN: Implement OllamaClient
VERIFY: \`cargo test ollama\`

## Edge Cases
- Ollama returns model name with version tag (\"nomic-embed-text:latest\"): starts_with handles this
- Empty texts array: send empty batch, Ollama returns empty embeddings
- Ollama returns wrong number of embeddings (2 texts, 1 embedding): caller (pipeline) validates
- Non-JSON response: reqwest deserialization error -> wrap appropriately

Completed: OllamaClient with async health_check (starts_with model matching), embed_batch, error mapping to LoreError variants, check_ollama_health helper, 4 tests pass


Fetch and store GitLab linked issues (Related to)

## Background
GitLab's 'Linked items' section shows related issues connected via the issue links API. This is distinct from:
- **closes** references (MR->Issue via closes_issues API) - already implemented
- **mentioned** references (parsed from notes/descriptions) - partially implemented

The 'Related to' relationship requires a separate API call and provides bidirectional issue linking.

**System fit**: Extends the existing `entity_references` table with `reference_type='related'` entries.

## Approach

### Phase 1: API Client (src/gitlab/client.rs)

Add method to fetch issue links:
```rust
pub async fn fetch_issue_links(
    &self,
    gitlab_project_id: i64,
    issue_iid: i64,
) -> Result<Vec<GitLabIssueLink>> {
    let path = format!(
        "/api/v4/projects/{}/issues/{}/links",
        gitlab_project_id, issue_iid
    );
    self.request(&path).await
}
```

### Phase 2: Types (src/gitlab/types.rs)

Add GitLab response struct:
```rust
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitLabIssueLink {
    pub id: i64,
    pub iid: i64,
    pub project_id: i64,
    pub title: String,
    pub state: String,
    pub web_url: String,
    pub link_type: String,        // 'relates_to', 'blocks', 'is_blocked_by'
    pub link_created_at: String,
}
```

### Phase 3: Ingestion (src/ingestion/issue_links.rs - NEW FILE)

Create new ingestion module:
```rust
pub async fn ingest_issue_links(
    conn: &Connection,
    client: &GitLabClient,
    project_id: i64,
    gitlab_project_id: i64,
    issue_local_id: i64,
    issue_iid: i64,
) -> Result<usize> {
    let links = client.fetch_issue_links(gitlab_project_id, issue_iid).await?;

    for link in links {
        let target_local_id = resolve_issue_local_id(conn, project_id, link.iid)?;

        let ref_ = EntityReference {
            project_id,
            source_entity_type: "issue",
            source_entity_id: issue_local_id,
            target_entity_type: "issue",
            target_entity_id: target_local_id,
            target_project_path: if target_local_id.is_none() {
                resolve_project_path(conn, link.project_id).ok().flatten()
            } else { None },
            target_entity_iid: if target_local_id.is_none() { Some(link.iid) } else { None },
            reference_type: "related",  // Could also map link_type to blocks/is_blocked_by
            source_method: "api",
        };

        insert_entity_reference(conn, &ref_)?;
    }

    Ok(links.len())
}
```

### Phase 4: Orchestrator Integration (src/ingestion/orchestrator.rs)

Add to dependent fetch queue (similar to mr_closes_issues):
1. Enqueue `issue_links` jobs for issues
2. Drain queue in MR sync phase
3. Track `issue_links_fetched` / `issue_links_failed` counters

### Phase 5: Display (src/cli/commands/show.rs)

Add `get_related_issues()` function:
```rust
fn get_related_issues(conn: &Connection, issue_id: i64) -> Result<Vec<RelatedIssueRef>> {
    let mut stmt = conn.prepare(
        "SELECT i.iid, i.title, i.state, i.web_url, p.path_with_namespace
         FROM entity_references er
         JOIN issues i ON i.id = er.target_entity_id
         JOIN projects p ON i.project_id = p.id
         WHERE er.source_entity_type = 'issue'
           AND er.source_entity_id = ?
           AND er.target_entity_type = 'issue'
           AND er.reference_type = 'related'
         ORDER BY i.iid"
    )?;
    // ... map to RelatedIssueRef ...
}
```

Update `IssueDetail` with `related_issues: Vec<RelatedIssueRef>`.

Update `print_show_issue()`:
```rust
if !issue.related_issues.is_empty() {
    println!("Linked Issues:");
    for ri in &issue.related_issues {
        println!("  #{} {} ({})", ri.iid, ri.title, ri.state);
    }
}
```

## Acceptance Criteria
- [ ] `cargo test test_fetch_issue_links` passes (mock API test)
- [ ] `cargo test test_ingest_issue_links` passes (DB integration)
- [ ] `cargo test test_get_related_issues` passes (query test)
- [ ] `lore sync` fetches and stores issue links
- [ ] `lore issues <id>` shows Linked Issues section
- [ ] `lore -J issues <id>` includes related_issues array
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files
- `src/gitlab/client.rs` - Add fetch_issue_links()
- `src/gitlab/types.rs` - Add GitLabIssueLink struct
- `src/ingestion/issue_links.rs` - NEW FILE
- `src/ingestion/mod.rs` - Export issue_links
- `src/ingestion/orchestrator.rs` - Integrate into sync flow
- `src/cli/commands/show.rs` - Display related issues

## TDD Loop

**RED** - Start with API type test:
```rust
// src/gitlab/types.rs tests
#[test]
fn test_issue_link_deserialize() {
    let json = r#"{
        "id": 123,
        "iid": 45,
        "project_id": 100,
        "title": "Related bug",
        "state": "opened",
        "web_url": "https://gitlab.com/...",
        "link_type": "relates_to",
        "link_created_at": "2024-01-15T10:00:00Z"
    }"#;
    let link: GitLabIssueLink = serde_json::from_str(json).unwrap();
    assert_eq!(link.iid, 45);
    assert_eq!(link.link_type, "relates_to");
}
```

**GREEN** - Implement incrementally: types -> client -> ingestion -> display

**VERIFY**: `cargo test issue_link && cargo clippy --all-targets -- -D warnings`

## Edge Cases
- Cross-project links -> store with target_project_path, display with project prefix
- Bidirectional links -> GitLab returns both directions, dedup via UNIQUE constraint
- Deleted linked issues -> target_entity_id NULL, show as 'unresolved'
- link_type variations -> 'blocks'/'is_blocked_by' could be stored as separate reference_types
- Rate limiting -> batch requests, respect retry-after headers


OBSERV: Implement MetricsLayer custom tracing subscriber layer

## Background
MetricsLayer is a custom tracing subscriber layer that records span timing and structured fields, then materializes them into Vec<StageTiming>. This avoids threading a mutable collector through every function signature -- spans are the single source of truth.

## Approach
Add to src/core/metrics.rs (same file as StageTiming):

```rust
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use tracing::span::{Attributes, Id, Record};
use tracing::Subscriber;
use tracing_subscriber::layer::{Context, Layer};
use tracing_subscriber::registry::LookupSpan;

#[derive(Debug)]
struct SpanData {
    name: String,
    parent_id: Option<Id>,
    start: Instant,
    fields: HashMap<String, serde_json::Value>,
}

#[derive(Debug, Clone)]
pub struct MetricsLayer {
    spans: Arc<Mutex<HashMap<u64, SpanData>>>,
    completed: Arc<Mutex<Vec<(u64, StageTiming)>>>,
}

impl MetricsLayer {
    pub fn new() -> Self {
        Self {
            spans: Arc::new(Mutex::new(HashMap::new())),
            completed: Arc::new(Mutex::new(Vec::new())),
        }
    }

    /// Extract timing tree for a completed run.
    /// Call this after the root span closes.
    pub fn extract_timings(&self) -> Vec<StageTiming> {
        let completed = self.completed.lock().unwrap();
        // Build tree: find root entries (no parent), attach children
        // ... tree construction logic
    }
}

impl<S> Layer<S> for MetricsLayer
where
    S: Subscriber + for<'a> LookupSpan<'a>,
{
    fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
        let parent_id = ctx.span(id).and_then(|s| s.parent().map(|p| p.id()));
        let mut fields = HashMap::new();
        // Visit attrs to capture initial field values
        let mut visitor = FieldVisitor(&mut fields);
        attrs.record(&mut visitor);

        self.spans.lock().unwrap().insert(id.into_u64(), SpanData {
            name: attrs.metadata().name().to_string(),
            parent_id,
            start: Instant::now(),
            fields,
        });
    }

    fn on_record(&self, id: &Id, values: &Record<'_>, _ctx: Context<'_, S>) {
        // Capture recorded fields (items_processed, items_skipped, errors)
        if let Some(data) = self.spans.lock().unwrap().get_mut(&id.into_u64()) {
            let mut visitor = FieldVisitor(&mut data.fields);
            values.record(&mut visitor);
        }
    }

    fn on_close(&self, id: Id, _ctx: Context<'_, S>) {
        if let Some(data) = self.spans.lock().unwrap().remove(&id.into_u64()) {
            let elapsed = data.start.elapsed();
            let timing = StageTiming {
                name: data.name,
                project: data.fields.get("project").and_then(|v| v.as_str()).map(String::from),
                elapsed_ms: elapsed.as_millis() as u64,
                items_processed: data.fields.get("items_processed").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
                items_skipped: data.fields.get("items_skipped").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
                errors: data.fields.get("errors").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
                sub_stages: vec![], // Will be populated during extract_timings tree construction
            };
            self.completed.lock().unwrap().push((id.into_u64(), timing));
        }
    }
}
```

Need a FieldVisitor struct implementing tracing::field::Visit to capture field values.

Register in subscriber stack (src/main.rs), alongside stderr and file layers:
```rust
let metrics_layer = MetricsLayer::new();
let metrics_handle = metrics_layer.clone(); // Clone Arc for later extraction

registry()
    .with(stderr_layer.with_filter(stderr_filter))
    .with(file_layer.with_filter(file_filter))
    .with(metrics_layer) // No filter -- captures all spans
    .init();
```

Pass metrics_handle to command handlers so they can call extract_timings() after the pipeline completes.

## Acceptance Criteria
- [ ] MetricsLayer captures span enter/close timing
- [ ] on_record captures items_processed, items_skipped, errors fields
- [ ] extract_timings() returns correctly nested Vec<StageTiming> tree
- [ ] Parallel spans (multiple projects) both appear as sub_stages of parent
- [ ] Thread-safe: Arc<Mutex<>> allows concurrent span operations
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/core/metrics.rs (add MetricsLayer, FieldVisitor, tree construction)
- src/main.rs (register MetricsLayer in subscriber stack)

## TDD Loop
RED:
  - test_metrics_layer_single_span: enter/exit one span, extract, assert one StageTiming
  - test_metrics_layer_nested_spans: parent + child, assert child in parent.sub_stages
  - test_metrics_layer_parallel_spans: two sibling spans, assert both in parent.sub_stages
  - test_metrics_layer_field_recording: record items_processed=42, assert captured
GREEN: Implement MetricsLayer with on_new_span, on_record, on_close, extract_timings
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- Span ID reuse: tracing may reuse span IDs after close. Using remove on close prevents stale data.
- Lock contention: Mutex per operation. For high-span-count scenarios, consider parking_lot::Mutex. But lore's span count is low (<100 per run), so std::sync::Mutex is fine.
- extract_timings tree construction: iterate completed Vec, build parent->children map, then recursively construct StageTiming tree. Root entries have parent_id matching the root span or None.
- MetricsLayer has no filter: it sees ALL spans. To avoid noise from dependency spans, check if span name starts with known stage names, or rely on the "stage" field being present.

Implemented MetricsLayer custom tracing subscriber layer with span timing capture, rate-limit/retry event detection, tree extraction, and 12 unit tests


Implement MR transformer

## Background
Transforms GitLab MR API responses into normalized schema for database storage. Handles deprecated field fallbacks and extracts metadata (labels, assignees, reviewers).

## Approach
Create new transformer module following existing issue transformer pattern:
- `NormalizedMergeRequest` - Database-ready struct
- `MergeRequestWithMetadata` - MR + extracted labels/assignees/reviewers
- `transform_merge_request()` - Main transformation function
- `extract_labels()` - Label extraction helper

## Files
- `src/gitlab/transformers/merge_request.rs` - New transformer module
- `src/gitlab/transformers/mod.rs` - Export new module
- `tests/mr_transformer_tests.rs` - Unit tests

## Acceptance Criteria
- [ ] `NormalizedMergeRequest` struct exists with all DB columns
- [ ] `MergeRequestWithMetadata` contains MR + label_names + assignee_usernames + reviewer_usernames
- [ ] `transform_merge_request()` returns `Result<MergeRequestWithMetadata, String>`
- [ ] `draft` computed as `gitlab_mr.draft || gitlab_mr.work_in_progress`
- [ ] `detailed_merge_status` prefers `detailed_merge_status` over `merge_status_legacy`
- [ ] `merge_user_username` prefers `merge_user` over `merged_by`
- [ ] `head_sha` extracted from `sha` field
- [ ] `references_short` and `references_full` extracted from `references` Option
- [ ] Timestamps parsed with `iso_to_ms()`, errors returned (not zeroed)
- [ ] `last_seen_at` set to `now_ms()`
- [ ] `cargo test mr_transformer` passes

## TDD Loop
RED: `cargo test mr_transformer` -> module not found
GREEN: Add transformer with all fields
VERIFY: `cargo test mr_transformer`

## Struct Definitions
```rust
#[derive(Debug, Clone)]
pub struct NormalizedMergeRequest {
    pub gitlab_id: i64,
    pub project_id: i64,
    pub iid: i64,
    pub title: String,
    pub description: Option<String>,
    pub state: String,
    pub draft: bool,
    pub author_username: String,
    pub source_branch: String,
    pub target_branch: String,
    pub head_sha: Option<String>,
    pub references_short: Option<String>,
    pub references_full: Option<String>,
    pub detailed_merge_status: Option<String>,
    pub merge_user_username: Option<String>,
    pub created_at: i64,
    pub updated_at: i64,
    pub merged_at: Option<i64>,
    pub closed_at: Option<i64>,
    pub last_seen_at: i64,
    pub web_url: String,
}

#[derive(Debug, Clone)]
pub struct MergeRequestWithMetadata {
    pub merge_request: NormalizedMergeRequest,
    pub label_names: Vec<String>,
    pub assignee_usernames: Vec<String>,
    pub reviewer_usernames: Vec<String>,
}
```

## Function Signature
```rust
pub fn transform_merge_request(
    gitlab_mr: &GitLabMergeRequest,
    local_project_id: i64,
) -> Result<MergeRequestWithMetadata, String>
```

## Key Logic
```rust
// Draft: prefer draft, fallback to work_in_progress
let is_draft = gitlab_mr.draft || gitlab_mr.work_in_progress;

// Merge status: prefer detailed_merge_status
let detailed_merge_status = gitlab_mr.detailed_merge_status
    .clone()
    .or_else(|| gitlab_mr.merge_status_legacy.clone());

// Merge user: prefer merge_user
let merge_user_username = gitlab_mr.merge_user
    .as_ref()
    .map(|u| u.username.clone())
    .or_else(|| gitlab_mr.merged_by.as_ref().map(|u| u.username.clone()));

// References extraction
let (references_short, references_full) = gitlab_mr.references
    .as_ref()
    .map(|r| (Some(r.short.clone()), Some(r.full.clone())))
    .unwrap_or((None, None));

// Head SHA
let head_sha = gitlab_mr.sha.clone();
```

## Edge Cases
- Invalid timestamps should return `Err`, not zero values
- Empty labels/assignees/reviewers should return empty Vecs, not None
- `state` must pass through as-is (including "locked")


Create golden query test suite

## Background
Golden query tests verify end-to-end search quality with known-good expected results. They use a seeded SQLite DB with deterministic fixture data and fixed embedding vectors (no Ollama dependency). Each test query must return at least one expected URL in the top 10 results. These tests catch search regressions (ranking changes, filter bugs, missing results).

## Approach
Create test infrastructure:

**1. tests/fixtures/golden_queries.json:**
```json
[
  {
    "query": "authentication login",
    "mode": "lexical",
    "filters": {},
    "expected_urls": ["https://gitlab.example.com/group/project/-/issues/234"],
    "min_results": 1,
    "max_rank": 10
  },
  {
    "query": "jwt token refresh",
    "mode": "hybrid",
    "filters": {"type": "merge_request"},
    "expected_urls": ["https://gitlab.example.com/group/project/-/merge_requests/456"],
    "min_results": 1,
    "max_rank": 10
  }
]
```

**2. Test harness (tests/golden_query_tests.rs):**
- Load golden_queries.json
- Create in-memory DB, apply all migrations
- Seed with deterministic fixture documents (issues, MRs, discussions)
- For hybrid/semantic queries: seed with fixed embedding vectors (768-dim, manually constructed for known similarity)
- For each query: run search, verify expected URL in top N results

**Fixture data design:**
- 10-20 documents covering different source types
- Known content that matches expected queries
- Fixed embeddings: construct vectors where similar documents have small cosine distance
- No randomness — fully deterministic

## Acceptance Criteria
- [ ] Golden queries file exists with at least 5 test queries
- [ ] Test harness loads queries and validates each
- [ ] All golden queries pass: expected URL in top 10
- [ ] No external dependencies (no Ollama, no GitLab)
- [ ] Deterministic fixture data (fixed embeddings, fixed content)
- [ ] `cargo test --test golden_query_tests` passes in CI

## Files
- `tests/fixtures/golden_queries.json` — new file
- `tests/golden_query_tests.rs` — new file (or tests/golden_queries.rs)

## TDD Loop
RED: Create golden_queries.json with expected results, harness fails (no fixture data)
GREEN: Seed fixture data that satisfies expected results
VERIFY: `cargo test --test golden_query_tests`

## Edge Cases
- Query matches multiple expected URLs: all must be present
- Lexical queries: FTS ranking determines position, not vector
- Hybrid queries: RRF combines both signals — fixed vectors must be designed to produce expected ranking
- Empty result for a golden query: test failure with clear message showing actual results

Golden query test suite: 7 golden queries in fixture, 8 seeded documents, 2 test functions (all_pass + fixture_valid), deterministic in-memory DB, no external deps. 312 total tests pass.


[CP1] Discussion and note transformers

Transform GitLab discussion/note payloads to normalized database schema.

Functions to implement:
- transformDiscussion(gitlabDiscussion, localProjectId, localIssueId) → NormalizedDiscussion
- transformNotes(gitlabDiscussion, localProjectId) → NormalizedNote[]

Transformation rules:
- Compute first_note_at/last_note_at from notes array
- Compute resolvable/resolved status from notes
- Set is_system from note.system
- Preserve note order via position (array index)
- Convert ISO timestamps to ms epoch

Files: src/gitlab/transformers/discussion.ts
Tests: tests/unit/discussion-transformer.test.ts
Done when: Unit tests pass for discussion/note transformation with system note flagging


Final validation and test coverage

## Background
Final validation gate ensuring all CP2 features work correctly. Verifies tests, lint, and manual smoke tests pass.

## Approach
Run comprehensive validation:
1. Automated tests (unit + integration)
2. Clippy and formatting
3. Critical test case verification
4. Gate A/B/C/D/E checklist
5. Manual smoke tests

## Files
None - validation only

## Acceptance Criteria
- [ ] `cargo test` passes (all tests green)
- [ ] `cargo test --release` passes
- [ ] `cargo clippy -- -D warnings` passes (zero warnings)
- [ ] `cargo fmt --check` passes
- [ ] Critical tests pass (see list below)
- [ ] Gate A/B/C/D/E verification complete
- [ ] Manual smoke tests pass

## Validation Commands
```bash
# 1. Build and test
cargo build --release
cargo test --release

# 2. Lint
cargo clippy -- -D warnings
cargo fmt --check

# 3. Run specific critical tests
cargo test does_not_advance_discussion_watermark_on_partial_failure
cargo test prefers_detailed_merge_status_when_both_fields_present
cargo test prefers_merge_user_when_both_fields_present
cargo test prefers_draft_when_both_draft_and_work_in_progress_present
cargo test atomic_note_replacement_preserves_data_on_parse_failure
cargo test full_sync_resets_discussion_watermarks
```

## Critical Test Cases
| Test | What It Verifies |
|------|------------------|
| `does_not_advance_discussion_watermark_on_partial_failure` | Pagination failure doesn't lose data |
| `prefers_detailed_merge_status_when_both_fields_present` | Non-deprecated field wins |
| `prefers_merge_user_when_both_fields_present` | Non-deprecated field wins |
| `prefers_draft_when_both_draft_and_work_in_progress_present` | OR semantics for draft |
| `atomic_note_replacement_preserves_data_on_parse_failure` | Parse before delete |
| `full_sync_resets_discussion_watermarks` | --full truly refreshes |

## Gate Checklist

### Gate A: MRs Only
- [ ] `gi ingest --type=merge_requests` fetches all MRs
- [ ] MR state supports: opened, merged, closed, locked
- [ ] draft field captured with work_in_progress fallback
- [ ] detailed_merge_status used with merge_status fallback
- [ ] head_sha and references captured
- [ ] Cursor-based sync is resumable

### Gate B: Labels + Assignees + Reviewers
- [ ] Labels linked via mr_labels junction
- [ ] Stale labels removed on resync
- [ ] Assignees linked via mr_assignees
- [ ] Reviewers linked via mr_reviewers

### Gate C: Dependent Discussion Sync
- [ ] Discussions fetched for MRs with updated_at advancement
- [ ] DiffNote position metadata captured
- [ ] DiffNote SHA triplet captured
- [ ] Upsert + sweep pattern for notes
- [ ] Watermark NOT advanced on partial failure
- [ ] Unchanged MRs skip discussion refetch

### Gate D: Resumability Proof
- [ ] Kill mid-run, rerun -> bounded redo
- [ ] `--full` resets cursor AND discussion watermarks
- [ ] Single-flight lock prevents concurrent runs

### Gate E: CLI Complete
- [ ] `gi list mrs` with all filters including --draft/--no-draft
- [ ] `gi show mr <iid>` with discussions and DiffNote context
- [ ] `gi count mrs` with state breakdown
- [ ] `gi sync-status` shows MR cursors

## Manual Smoke Tests
| Command | Expected |
|---------|----------|
| `gi ingest --type=merge_requests` | Completes, shows counts |
| `gi list mrs --limit=10` | Shows 10 MRs with correct columns |
| `gi list mrs --state=merged` | Only merged MRs |
| `gi list mrs --draft` | Only draft MRs with [DRAFT] prefix |
| `gi show mr <iid>` | Full detail with discussions |
| `gi count mrs` | Count with state breakdown |
| Re-run ingest | "0 new MRs", skipped discussion count |
| `gi ingest --type=merge_requests --full` | Full resync |

## Data Integrity Checks
```sql
-- MR count matches GitLab
SELECT COUNT(*) FROM merge_requests;

-- Every MR has raw payload
SELECT COUNT(*) FROM merge_requests WHERE raw_payload_id IS NULL;
-- Should be 0

-- Labels linked correctly
SELECT m.iid, COUNT(ml.label_id)
FROM merge_requests m
LEFT JOIN mr_labels ml ON ml.merge_request_id = m.id
GROUP BY m.id;

-- DiffNotes have position metadata
SELECT COUNT(*) FROM notes WHERE position_new_path IS NOT NULL;
```


Implement document types and extractor module

## Background
The document types module is the foundational data layer for all document operations. It defines SourceType (the enum used everywhere to identify entity types), DocumentData (the struct passed between extractors, regenerator, and storage), and hash functions (for change detection that skips unchanged documents). This bead has 7 downstream dependents — it's the most-depended-on implementation bead.

## Approach
Create `src/documents/` module with `mod.rs` and `extractor.rs`.

**src/documents/mod.rs:**
```rust
mod extractor;
mod regenerator;   // placeholder: pub mod added by later bead
mod truncation;    // placeholder: pub mod added by later bead

pub use extractor::{
    extract_discussion_document, extract_issue_document, extract_mr_document,
    DocumentData, SourceType, compute_content_hash, compute_list_hash,
};
pub use regenerator::regenerate_dirty_documents;
pub use truncation::{truncate_content, TruncationResult};
```

**src/documents/extractor.rs — types only (this bead):**
- `SourceType` enum with `Issue`, `MergeRequest`, `Discussion` variants
- `SourceType::as_str()`, `SourceType::parse()` (accepts aliases), `Display` impl
- `DocumentData` struct with all fields per PRD Section 2.2
- `compute_content_hash(content: &str) -> String` using SHA-256
- `compute_list_hash(items: &[String]) -> String` using sorted join + SHA-256

**Dependencies:** Add `sha2 = "0.10"` to Cargo.toml if not already present.

Note: The `extract_*_document()` functions are stub signatures in this bead. Their implementations are in separate beads (bd-247, bd-1yz, bd-2fp).

**src/lib.rs:** Add `pub mod documents;`

## Acceptance Criteria
- [ ] `SourceType::parse("issue")` returns `Some(Issue)`
- [ ] `SourceType::parse("mr")` returns `Some(MergeRequest)` (alias)
- [ ] `SourceType::parse("mrs")` returns `Some(MergeRequest)` (alias)
- [ ] `SourceType::parse("merge_request")` returns `Some(MergeRequest)`
- [ ] `SourceType::parse("discussion")` returns `Some(Discussion)`
- [ ] `SourceType::parse("invalid")` returns `None`
- [ ] `SourceType::as_str()` returns "issue", "merge_request", "discussion"
- [ ] `compute_content_hash("hello")` returns deterministic SHA-256 hex string
- [ ] `compute_list_hash(["b", "a"])` == `compute_list_hash(["a", "b"])` (sorted)
- [ ] `DocumentData` struct compiles with all fields per PRD
- [ ] `cargo build` succeeds
- [ ] `cargo test documents` passes

## Files
- `src/documents/mod.rs` — new file (module root with pub use)
- `src/documents/extractor.rs` — new file (types + hash functions)
- `src/documents/regenerator.rs` — new file (placeholder, empty module or todo!())
- `src/documents/truncation.rs` — new file (placeholder, empty module or todo!())
- `src/lib.rs` — add `pub mod documents;`
- `Cargo.toml` — add `sha2 = "0.10"` if not present

## TDD Loop
RED: Tests in `extractor.rs` `#[cfg(test)] mod tests`:
- `test_source_type_parse_aliases` — all parse variants
- `test_source_type_as_str` — roundtrip as_str
- `test_content_hash_deterministic` — same input = same hash
- `test_list_hash_order_independent` — sorted before hashing
- `test_list_hash_empty` — empty vec produces consistent hash
GREEN: Implement types and hash functions
VERIFY: `cargo test documents`

## Edge Cases
- Empty string content hash: must produce valid SHA-256 (not panic)
- Empty labels list: compute_list_hash returns hash of empty string
- SourceType Display shows snake_case (same as as_str)

Completed: SourceType enum with parse/as_str/Display, DocumentData struct, compute_content_hash, compute_list_hash, 8 tests pass


OBSERV: Generate run_id at command entry in main.rs

## Background
Every sync/ingest run needs a unique correlation ID so log lines, database records, and robot JSON can be linked. The uuid crate (v1, v4 feature) is already in Cargo.toml (line ~48). Using first 8 chars of UUIDv4 gives ~4 billion unique values.

## Approach
In src/main.rs, after CLI parsing (line ~60) and before command dispatch (line ~73), generate run_id:

```rust
let run_id = uuid::Uuid::new_v4().to_string();
let run_id = &run_id[..8]; // First 8 hex chars: e.g., "a1b2c3d4"
```

Pass run_id to command handlers that need it (sync, ingest). This requires adding a run_id parameter to handle_sync_cmd() and handle_ingest(). Other commands (doctor, list, show, etc.) don't need correlation IDs.

Alternative: generate run_id inside each command handler instead of main(). This avoids changing signatures of commands that don't need it. Prefer this approach -- generate inside run_sync() and run_ingest() directly.

Preferred approach (generate in command handlers):
```rust
// In src/cli/commands/sync.rs run_sync():
let run_id = &uuid::Uuid::new_v4().to_string()[..8];

// In src/cli/commands/ingest.rs run_ingest():
let run_id = &uuid::Uuid::new_v4().to_string()[..8];
```

This keeps main.rs clean and only adds run_id where it's used.

## Acceptance Criteria
- [ ] run_id is generated for every sync and ingest invocation
- [ ] run_id is exactly 8 characters, all hex (0-9, a-f)
- [ ] run_id is unique across invocations (probabilistic, ~4 billion space)
- [ ] No new dependencies needed (uuid already present)
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/cli/commands/sync.rs (generate run_id in run_sync)
- src/cli/commands/ingest.rs (generate run_id in run_ingest)

## TDD Loop
RED:
  - test_run_id_format: generate 100 run_ids, assert each is 8 chars, all hex
  - test_run_id_uniqueness: generate 1000 run_ids, assert no duplicates
GREEN: Add run_id generation to run_sync and run_ingest
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- UUID string format: Uuid::new_v4().to_string() produces "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx". First 8 chars are always hex (no hyphens). Safe to slice.
- &run_id[..8] on a String: this is a byte slice on ASCII chars, always valid UTF-8. No panic risk.

Generated run_id via uuid::Uuid::new_v4().simple() at entry of run_sync and run_ingest, truncated to 8 hex chars


[CP0] gi init command - interactive setup wizard

## Background

The init command is the user's first interaction with gi. It must guide them through setup, validate everything works before writing config, and leave the system in a ready-to-use state. Poor UX here will frustrate new users.

Reference: docs/prd/checkpoint-0.md section "gi init"

## Approach

**src/cli/commands/init.ts:**

Interactive flow (using inquirer):
1. Check if config exists at target path
   - If exists and no --force: prompt "Config exists. Overwrite? [y/N]"
   - If --non-interactive and config exists: exit 2
2. Prompt for GitLab base URL (validate URL format)
3. Prompt for token env var name (default: GITLAB_TOKEN)
4. Check token is set in environment
   - If not set: exit 1 with "Export GITLAB_TOKEN first"
5. Test auth with GET /api/v4/user
   - If 401: exit 1 with "Authentication failed"
   - Show "Authenticated as @username (Display Name)"
6. Prompt for project paths (comma-separated or add one at a time)
7. Validate each project with GET /api/v4/projects/:encoded_path
   - If 404: exit 1 with "Project not found: group/project"
   - Show "✓ group/project (Project Name)"
8. Write config.json to target path
9. Initialize database with migrations
10. Insert validated projects into projects table
11. Show "Setup complete! Run 'gi doctor' to verify."

**Flags:**
- `--config <path>`: Write config to specific path
- `--force`: Skip overwrite confirmation
- `--non-interactive`: Fail if prompts would be shown (for CI/scripting)

## Acceptance Criteria

- [ ] Creates config.json with valid structure
- [ ] Validates GitLab URL is reachable before writing config
- [ ] Validates token with GET /api/v4/user before writing config
- [ ] Validates each project path exists in GitLab before writing config
- [ ] Fails with exit 1 if token not set in environment
- [ ] Fails with exit 1 if GitLab auth fails
- [ ] Fails with exit 1 if any project not found
- [ ] Prompts before overwriting existing config (unless --force)
- [ ] --force skips overwrite confirmation
- [ ] --non-interactive fails if prompts would be shown
- [ ] Creates data directory and applies DB migrations
- [ ] Inserts validated projects into projects table
- [ ] tests/integration/init.test.ts passes (11 tests)

## Files

CREATE:
- src/cli/commands/init.ts
- tests/integration/init.test.ts

## TDD Loop

RED:
```typescript
// tests/integration/init.test.ts
describe('gi init', () => {
  it('creates config file with valid structure')
  it('validates GitLab URL format')
  it('validates GitLab connection before writing config')
  it('validates each project path exists in GitLab')
  it('fails if token not set')
  it('fails if GitLab auth fails')
  it('fails if any project path not found')
  it('prompts before overwriting existing config')
  it('respects --force to skip confirmation')
  it('generates config with sensible defaults')
  it('creates data directory if missing')
})
```

GREEN: Implement init.ts

VERIFY: `npm run test -- tests/integration/init.test.ts`

## Edge Cases

- User cancels at any prompt: exit 2 (user cancelled)
- Network error during validation: show specific error, exit 1
- Token has wrong scopes (no read_api): auth succeeds but project fetch fails
- Project path with special characters must be URL-encoded
- Config directory might not exist - create with mkdirSync recursive
- --non-interactive with missing env var should fail immediately


Implement dirty source tracking module

## Background
Dirty source tracking drives incremental document regeneration. When entities are upserted during ingestion, they're marked dirty. The regenerator drains this queue. The key constraint: mark_dirty_tx() takes &Transaction to enforce atomic marking inside the entity upsert transaction. Uses ON CONFLICT DO UPDATE (not INSERT OR IGNORE) to reset backoff on re-queue.

## Approach
Create \`src/ingestion/dirty_tracker.rs\` per PRD Section 6.1.

```rust
const DIRTY_SOURCES_BATCH_SIZE: usize = 500;

/// Mark dirty INSIDE existing transaction. Takes &Transaction, NOT &Connection.
/// ON CONFLICT resets ALL backoff/error state (not INSERT OR IGNORE).
/// This ensures fresh updates are immediately eligible, not stuck behind stale backoff.
pub fn mark_dirty_tx(
    tx: &rusqlite::Transaction<'_>,
    source_type: SourceType,
    source_id: i64,
) -> Result<()>;

/// Convenience wrapper for non-transactional contexts.
pub fn mark_dirty(conn: &Connection, source_type: SourceType, source_id: i64) -> Result<()>;

/// Get dirty sources ready for processing.
/// WHERE next_attempt_at IS NULL OR next_attempt_at <= now
/// ORDER BY attempt_count ASC, queued_at ASC  (failed items deprioritized)
/// LIMIT 500
pub fn get_dirty_sources(conn: &Connection) -> Result<Vec<(SourceType, i64)>>;

/// Clear dirty entry after successful processing.
pub fn clear_dirty(conn: &Connection, source_type: SourceType, source_id: i64) -> Result<()>;
```

**PRD-specific details:**
- get_dirty_sources ORDER BY: \`attempt_count ASC, queued_at ASC\` (failed items processed AFTER fresh items)
- mark_dirty_tx ON CONFLICT resets: queued_at, attempt_count=0, last_attempt_at=NULL, last_error=NULL, next_attempt_at=NULL
- SourceType parsed from string in query results via match on \"issue\"/\"merge_request\"/\"discussion\"
- Invalid source_type in DB -> rusqlite::Error::FromSqlConversionFailure

**Error recording is in regenerator.rs (bd-1u1)**, not dirty_tracker. The dirty_tracker only marks, gets, and clears.

## Acceptance Criteria
- [ ] mark_dirty_tx takes &Transaction<'_>, NOT &Connection
- [ ] ON CONFLICT DO UPDATE resets: attempt_count=0, next_attempt_at=NULL, last_error=NULL, last_attempt_at=NULL
- [ ] Uses ON CONFLICT DO UPDATE, NOT INSERT OR IGNORE (PRD explains why)
- [ ] get_dirty_sources WHERE next_attempt_at IS NULL OR <= now
- [ ] get_dirty_sources ORDER BY attempt_count ASC, queued_at ASC
- [ ] get_dirty_sources LIMIT 500
- [ ] get_dirty_sources returns Vec<(SourceType, i64)>
- [ ] clear_dirty DELETEs entry
- [ ] Queue drains completely when called in loop
- [ ] \`cargo test dirty_tracker\` passes

## Files
- \`src/ingestion/dirty_tracker.rs\` — new file
- \`src/ingestion/mod.rs\` — add \`pub mod dirty_tracker;\`

## TDD Loop
RED: Tests:
- \`test_mark_dirty_tx_inserts\` — entry appears in dirty_sources
- \`test_requeue_resets_backoff\` — mark, simulate error state, re-mark -> attempt_count=0, next_attempt_at=NULL
- \`test_get_respects_backoff\` — entry with future next_attempt_at not returned
- \`test_get_orders_by_attempt_count\` — fresh items before failed items
- \`test_batch_size_500\` — insert 600, get returns 500
- \`test_clear_removes\` — entry gone after clear
- \`test_drain_loop\` — insert 1200, loop 3 times = empty
GREEN: Implement all functions
VERIFY: \`cargo test dirty_tracker\`

## Edge Cases
- Empty queue: get returns empty Vec
- Invalid source_type string in DB: FromSqlConversionFailure error
- Concurrent mark + get: ON CONFLICT handles race condition

Implemented dirty_tracker with mark_dirty_tx, get_dirty_sources, clear_dirty, record_dirty_error + 8 tests


[CP1] Test fixtures for mocked GitLab responses

## Background

Test fixtures provide mocked GitLab API responses for unit and integration tests. They enable testing without a live GitLab instance and ensure consistent test data across runs.

## Approach

### Fixture Files

Create JSON fixtures that match GitLab API response shapes:

```
tests/fixtures/
├── gitlab_issue.json           # Single issue
├── gitlab_issues_page.json     # Array of issues (pagination test)
├── gitlab_discussion.json      # Single discussion with notes
└── gitlab_discussions_page.json # Array of discussions
```

### gitlab_issue.json

```json
{
  "id": 12345,
  "iid": 42,
  "project_id": 100,
  "title": "Test issue title",
  "description": "Test issue description",
  "state": "opened",
  "created_at": "2024-01-15T10:00:00.000Z",
  "updated_at": "2024-01-20T15:30:00.000Z",
  "closed_at": null,
  "author": {
    "id": 1,
    "username": "testuser",
    "name": "Test User"
  },
  "labels": ["bug", "priority::high"],
  "web_url": "https://gitlab.example.com/group/project/-/issues/42"
}
```

### gitlab_discussion.json

```json
{
  "id": "6a9c1750b37d513a43987b574953fceb50b03ce7",
  "individual_note": false,
  "notes": [
    {
      "id": 1001,
      "type": "DiscussionNote",
      "body": "First comment in thread",
      "author": { "id": 1, "username": "testuser", "name": "Test User" },
      "created_at": "2024-01-16T09:00:00.000Z",
      "updated_at": "2024-01-16T09:00:00.000Z",
      "system": false,
      "resolvable": true,
      "resolved": false,
      "resolved_by": null,
      "resolved_at": null,
      "position": null
    },
    {
      "id": 1002,
      "type": "DiscussionNote",
      "body": "Reply to first comment",
      "author": { "id": 2, "username": "reviewer", "name": "Reviewer" },
      "created_at": "2024-01-16T10:00:00.000Z",
      "updated_at": "2024-01-16T10:00:00.000Z",
      "system": false,
      "resolvable": true,
      "resolved": false,
      "resolved_by": null,
      "resolved_at": null,
      "position": null
    }
  ]
}
```

### Helper Module

```rust
// tests/fixtures/mod.rs

pub fn load_fixture<T: DeserializeOwned>(name: &str) -> T {
    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests/fixtures")
        .join(name);
    let content = std::fs::read_to_string(&path)
        .expect(&format!("Failed to read fixture: {}", name));
    serde_json::from_str(&content)
        .expect(&format!("Failed to parse fixture: {}", name))
}

pub fn gitlab_issue() -> GitLabIssue {
    load_fixture("gitlab_issue.json")
}

pub fn gitlab_issues_page() -> Vec<GitLabIssue> {
    load_fixture("gitlab_issues_page.json")
}

pub fn gitlab_discussion() -> GitLabDiscussion {
    load_fixture("gitlab_discussion.json")
}
```

## Acceptance Criteria

- [ ] gitlab_issue.json deserializes to GitLabIssue correctly
- [ ] gitlab_issues_page.json contains 3+ issues for pagination tests
- [ ] gitlab_discussion.json contains multi-note thread
- [ ] gitlab_discussions_page.json contains mix of individual_note true/false
- [ ] At least one fixture includes system: true note
- [ ] Helper functions load fixtures without panic

## Files

- tests/fixtures/gitlab_issue.json (create)
- tests/fixtures/gitlab_issues_page.json (create)
- tests/fixtures/gitlab_discussion.json (create)
- tests/fixtures/gitlab_discussions_page.json (create)
- tests/fixtures/mod.rs (create)

## TDD Loop

RED:
```rust
#[test] fn fixture_gitlab_issue_deserializes()
#[test] fn fixture_gitlab_discussion_deserializes()
#[test] fn fixture_has_system_note()
```

GREEN: Create JSON fixtures and helper module

VERIFY: `cargo test fixture`

## Edge Cases

- Include issue with empty labels array
- Include issue with null description
- Include system note (system: true)
- Include individual_note: true discussion (standalone comment)
- Timestamps must be valid ISO 8601

Created 4 JSON fixture files (issue, issues_page, discussion, discussions_page) with helper tests - 6 tests passing


Epic: CP2 Gate A - MRs Only

## Background
Gate A validates core MR ingestion works before adding complexity. Proves the cursor-based sync, pagination, and basic CLI work. This is the foundation - if Gate A fails, nothing else matters.

## Acceptance Criteria (Pass/Fail)
- [ ] `gi ingest --type=merge_requests` completes without error
- [ ] `SELECT COUNT(*) FROM merge_requests` > 0
- [ ] `gi list mrs --limit=5` shows 5 MRs with iid, title, state, author
- [ ] `gi count mrs` shows total count matching DB query
- [ ] MR with `state=locked` can be stored (if exists in test data)
- [ ] Draft MR shows `draft=1` in DB and `[DRAFT]` in list output
- [ ] `work_in_progress=true` MR shows `draft=1` (fallback works)
- [ ] `head_sha` populated for MRs with commits
- [ ] `references_short` and `references_full` populated
- [ ] Re-run ingest shows "0 new MRs" or minimal refetch (cursor working)
- [ ] Cursor saved at page boundary, not item boundary

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

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

echo "=== Gate A: MRs Only ==="

# 1. Clear any existing MR data for clean test
echo "Step 1: Reset MR cursor for clean test..."
sqlite3 "$DB_PATH" "DELETE FROM sync_cursors WHERE resource_type = 'merge_requests';"

# 2. Run MR ingestion
echo "Step 2: Ingest MRs..."
gi ingest --type=merge_requests

# 3. Verify MRs exist
echo "Step 3: Verify MR count..."
MR_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM merge_requests;")
echo "  MR count: $MR_COUNT"
[ "$MR_COUNT" -gt 0 ] || { echo "FAIL: No MRs ingested"; exit 1; }

# 4. Verify list command
echo "Step 4: Test list command..."
gi list mrs --limit=5

# 5. Verify count command
echo "Step 5: Test count command..."
gi count mrs

# 6. Verify draft handling
echo "Step 6: Check draft MRs..."
DRAFT_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM merge_requests WHERE draft = 1;")
echo "  Draft MR count: $DRAFT_COUNT"

# 7. Verify head_sha population
echo "Step 7: Check head_sha..."
SHA_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM merge_requests WHERE head_sha IS NOT NULL;")
echo "  MRs with head_sha: $SHA_COUNT"

# 8. Verify references
echo "Step 8: Check references..."
REF_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM merge_requests WHERE references_short IS NOT NULL;")
echo "  MRs with references: $REF_COUNT"

# 9. Verify cursor saved
echo "Step 9: Check cursor..."
CURSOR=$(sqlite3 "$DB_PATH" "SELECT updated_at, gitlab_id FROM sync_cursors WHERE resource_type = 'merge_requests';")
echo "  Cursor: $CURSOR"
[ -n "$CURSOR" ] || { echo "FAIL: Cursor not saved"; exit 1; }

# 10. Re-run and verify minimal refetch
echo "Step 10: Re-run ingest (should be minimal)..."
gi ingest --type=merge_requests
# Output should show minimal or zero new MRs

echo ""
echo "=== Gate A: PASSED ==="
```

## Test Commands (Quick Verification)
```bash
# Run these in order:
gi ingest --type=merge_requests
gi list mrs --limit=10
gi count mrs

# Verify in DB:
sqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 "
  SELECT
    COUNT(*) as total,
    SUM(CASE WHEN draft = 1 THEN 1 ELSE 0 END) as drafts,
    SUM(CASE WHEN head_sha IS NOT NULL THEN 1 ELSE 0 END) as with_sha,
    SUM(CASE WHEN references_short IS NOT NULL THEN 1 ELSE 0 END) as with_refs
  FROM merge_requests;
"

# Re-run (should be no-op):
gi ingest --type=merge_requests
```

## Dependencies
This gate requires these beads to be complete:
- bd-3ir (Database migration)
- bd-5ta (GitLab MR types)
- bd-34o (MR transformer)
- bd-iba (GitLab client pagination)
- bd-ser (MR ingestion module)

## Edge Cases
- `locked` state is transitional (merge in progress); may not exist in test data
- Some older GitLab instances may not return `head_sha` for all MRs
- `work_in_progress` is deprecated but should still work as fallback
- Very large projects (10k+ MRs) may take significant time on first sync


Implement timeline event collection and chronological interleaving

## Background
Steps 4-5 of timeline query: collect events from all sources for seed + expanded entities and sort chronologically. This produces the final event stream for rendering.

## Approach
Add to src/core/timeline.rs:

```rust
/// Collect all events for the given entities and sort chronologically.
pub fn collect_and_interleave_events(
    conn: &Connection,
    seeds: &[SeedEntity],
    expanded: &[ExpandedEntity],
    evidence_notes: &[EvidenceNote],
    limit: Option<usize>,          // -n flag
) -> Result<Vec<TimelineEvent>>
```

For each entity (seed + expanded), collect:

1. **Entity creation** — from issues/merge_requests created_at:
```sql
SELECT created_at, title, author_username, web_url FROM issues WHERE id = ?;
```
→ TimelineEvent { event_type: Created, ... }

2. **State changes** — from resource_state_events:
```sql
SELECT state, actor_username, created_at FROM resource_state_events
WHERE (issue_id = ? OR merge_request_id = ?) ORDER BY created_at;
```
→ TimelineEvent { event_type: StateChanged { state }, ... }

3. **Label changes** — from resource_label_events:
```sql
SELECT label_name, action, actor_username, created_at FROM resource_label_events
WHERE (issue_id = ? OR merge_request_id = ?) ORDER BY created_at;
```
→ TimelineEvent { event_type: LabelAdded/LabelRemoved { label }, ... }

4. **Milestone changes** — from resource_milestone_events:
```sql
SELECT milestone_title, action, actor_username, created_at FROM resource_milestone_events
WHERE (issue_id = ? OR merge_request_id = ?) ORDER BY created_at;
```
→ TimelineEvent { event_type: MilestoneSet/MilestoneRemoved { milestone }, ... }

5. **Merge events** — from merge_requests.merged_at (if not NULL):
```sql
SELECT merged_at, author_username, web_url FROM merge_requests WHERE id = ? AND merged_at IS NOT NULL;
```
→ TimelineEvent { event_type: Merged, ... }

6. **Evidence notes** — from the seed phase's evidence_notes list:
→ TimelineEvent { event_type: NoteEvidence { note_id, snippet, discussion_id }, ... }

Mark each event with is_seed based on whether the entity is in seeds vs expanded.

Sort all events using TimelineEvent's Ord implementation (chronological + stable tiebreak).

Apply -n limit after sorting.

## Acceptance Criteria
- [ ] All 6 event types collected correctly
- [ ] Entity creation events have correct timestamps and metadata
- [ ] State/label/milestone events from resource event tables
- [ ] Merge events only for merged MRs
- [ ] Evidence notes converted to NoteEvidence events with truncated snippets (~200 chars)
- [ ] is_seed flag correct (seed entities = true, expanded = false)
- [ ] Chronological ordering with stable tiebreak
- [ ] -n limit applied after sorting

## Files
- src/core/timeline.rs (add collect_and_interleave_events)

## TDD Loop
RED: tests/timeline_collect_tests.rs:
- `test_collect_entity_creation` - verify Created event for an issue
- `test_collect_state_changes` - seed state events, verify StateChanged events
- `test_collect_label_changes` - seed label events, verify LabelAdded/Removed
- `test_collect_merge_events` - merged MR produces Merged event
- `test_collect_evidence_notes` - evidence notes become NoteEvidence events
- `test_collect_chronological_order` - events sorted by timestamp
- `test_collect_is_seed_flag` - seed entity events = true, expanded = false
- `test_collect_limit` - 20 events, limit 5, only 5 returned

Setup: create_test_db with full migrations, seed entities + events.

GREEN: Implement collection queries and sorting

VERIFY: `cargo test timeline_collect -- --nocapture`

## Edge Cases
- Entity with no events (newly created, no state changes yet) — only Created event produced
- Evidence note snippet truncation: handle UTF-8 boundary correctly (use char_indices)
- Merge event may have NULL actor (auto-merge) — actor is Optional
- resource event tables may be empty (user hasn't synced events) — graceful, just no event entries
- Very large entity sets (100+ entities × 50+ events each) — ensure efficient batching of queries


[CP1] gi count issues/discussions/notes commands

Count entities in the database.

Commands:
- gi count issues → 'Issues: N'
- gi count discussions --type=issue → 'Issue Discussions: N'
- gi count notes --type=issue → 'Issue Notes: N (excluding M system)'

Files: src/cli/commands/count.ts
Done when: Counts match expected values from GitLab


OBSERV Epic: Phase 3 - Performance Metrics Collection

StageTiming struct, custom MetricsLayer tracing subscriber layer, span-to-metrics extraction, robot JSON enrichment with meta.stages, human-readable timing summary.

Depends on: Phase 2 (spans must exist to extract timing from)
Unblocks: Phase 4 (sync history needs Vec<StageTiming> to store)

Files: src/core/metrics.rs (new), src/cli/commands/sync.rs, src/cli/commands/ingest.rs, src/main.rs

Acceptance criteria (PRD Section 6.3):
- lore --robot sync includes meta.run_id and meta.stages array
- Each stage has name, elapsed_ms, items_processed
- Top-level stages have sub_stages arrays
- Interactive sync prints timing summary table
- Zero-value fields omitted from JSON

All Phase 3 tasks complete: StageTiming struct, MetricsLayer, span field recording, robot JSON enrichment with stages, and human-readable timing summary


Implement hybrid search with adaptive recall

## Background
Hybrid search is the top-level search orchestrator that combines FTS5 lexical results with sqlite-vec semantic results via RRF ranking. It supports three modes (Lexical, Semantic, Hybrid) and implements adaptive recall (wider initial fetch when filters are applied) and graceful degradation (falls back to FTS when Ollama is unavailable). All modes use RRF for consistent --explain output.

## Approach
Create `src/search/hybrid.rs` per PRD Section 5.3.

**Key types:**
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchMode {
    Hybrid,    // Vector + FTS with RRF
    Lexical,   // FTS only
    Semantic,  // Vector only
}

impl SearchMode {
    pub fn from_str(s: &str) -> Option<Self> {
        match s.to_lowercase().as_str() {
            "hybrid" => Some(Self::Hybrid),
            "lexical" | "fts" => Some(Self::Lexical),
            "semantic" | "vector" => Some(Self::Semantic),
            _ => None,
        }
    }

    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Hybrid => "hybrid",
            Self::Lexical => "lexical",
            Self::Semantic => "semantic",
        }
    }
}

pub struct HybridResult {
    pub document_id: i64,
    pub score: f64,          // Normalized RRF score (0-1)
    pub vector_rank: Option<usize>,
    pub fts_rank: Option<usize>,
    pub rrf_score: f64,      // Raw RRF score
}
```

**Core function (ASYNC, PRD-exact signature):**
```rust
pub async fn search_hybrid(
    conn: &Connection,
    client: Option<&OllamaClient>,      // None if Ollama unavailable
    ollama_base_url: Option<&str>,       // For actionable error messages
    query: &str,
    mode: SearchMode,
    filters: &SearchFilters,
    fts_mode: FtsQueryMode,
) -> Result<(Vec<HybridResult>, Vec<String>)>
```

**IMPORTANT — client is `Option<&OllamaClient>`:** This enables graceful degradation. When Ollama is unavailable, the caller passes `None` and hybrid mode falls back to FTS-only with a warning. The `ollama_base_url` is separate so error messages can include it even when client is None.

**Adaptive recall constants (PRD Section 5.3):**
```rust
const BASE_RECALL_MIN: usize = 50;
const FILTERED_RECALL_MIN: usize = 200;
const RECALL_CAP: usize = 1500;
```

**Recall formula:**
```rust
let requested = filters.clamp_limit();
let top_k = if filters.has_any_filter() {
    (requested * 50).max(FILTERED_RECALL_MIN).min(RECALL_CAP)
} else {
    (requested * 10).max(BASE_RECALL_MIN).min(RECALL_CAP)
};
```

**Mode behavior:**
- **Lexical:** FTS only -> rank_rrf with empty vector list (single-list RRF)
- **Semantic:** Vector only -> requires client (error if None) -> rank_rrf with empty FTS list
- **Hybrid:** Both FTS + vector -> rank_rrf with both lists
- **Hybrid with client=None:** Graceful degradation to Lexical with warning, NOT error

**Graceful degradation logic:**
```rust
SearchMode::Hybrid => {
    let fts_results = search_fts(conn, query, top_k, fts_mode)?;
    let fts_tuples: Vec<_> = fts_results.iter().map(|r| (r.document_id, r.rank)).collect();

    match client {
        Some(client) => {
            let query_embedding = client.embed_batch(vec\![query.to_string()]).await?;
            let embedding = query_embedding.into_iter().next().unwrap();
            let vec_results = search_vector(conn, &embedding, top_k)?;
            let vec_tuples: Vec<_> = vec_results.iter().map(|r| (r.document_id, r.distance)).collect();
            let ranked = rank_rrf(&vec_tuples, &fts_tuples);
            // ... map to HybridResult
            Ok((results, warnings))
        }
        None => {
            warnings.push("Ollama unavailable, falling back to lexical search".into());
            let ranked = rank_rrf(&[], &fts_tuples);
            // ... map to HybridResult
            Ok((results, warnings))
        }
    }
}
```

## Acceptance Criteria
- [ ] Function is `async` (per PRD — Ollama client methods are async)
- [ ] Signature takes `client: Option<&OllamaClient>` (not required)
- [ ] Signature takes `ollama_base_url: Option<&str>` for actionable error messages
- [ ] Returns `(Vec<HybridResult>, Vec<String>)` — results + warnings
- [ ] Lexical mode: FTS-only results ranked via RRF (single list)
- [ ] Semantic mode: vector-only results ranked via RRF; error if client is None
- [ ] Hybrid mode: both FTS + vector results merged via RRF
- [ ] Graceful degradation: client=None in Hybrid falls back to FTS with warning (not error)
- [ ] Adaptive recall: unfiltered max(50, limit*10), filtered max(200, limit*50), capped 1500
- [ ] All modes produce consistent --explain output (vector_rank, fts_rank, rrf_score)
- [ ] SearchMode::from_str accepts aliases: "fts" for Lexical, "vector" for Semantic
- [ ] `cargo build` succeeds

## Files
- `src/search/hybrid.rs` — new file
- `src/search/mod.rs` — add `pub use hybrid::{search_hybrid, HybridResult, SearchMode};`

## TDD Loop
RED: Tests (some integration, some unit):
- `test_lexical_mode` — FTS results only
- `test_semantic_mode` — vector results only
- `test_hybrid_mode` — both lists merged
- `test_graceful_degradation` — None client falls back to FTS with warning in warnings vec
- `test_adaptive_recall_unfiltered` — recall = max(50, limit*10)
- `test_adaptive_recall_filtered` — recall = max(200, limit*50)
- `test_recall_cap` — never exceeds 1500
- `test_search_mode_from_str` — "hybrid", "lexical", "fts", "semantic", "vector", invalid
GREEN: Implement search_hybrid
VERIFY: `cargo test hybrid`

## Edge Cases
- Both FTS and vector return zero results: empty output (not error)
- FTS returns results but vector returns empty: RRF still works (single-list)
- Very high limit (100) with filters: recall = min(5000, 1500) = 1500
- Semantic mode with client=None: error (OllamaUnavailable), not degradation
- Semantic mode with 0% coverage: return LoreError::EmbeddingsNotBuilt

Implemented hybrid search with 3 modes (lexical/semantic/hybrid), graceful degradation when Ollama unavailable, adaptive recall (50-1500), RRF fusion. 6 tests pass.


Implement RRF ranking

## Background
Reciprocal Rank Fusion (RRF) combines results from multiple retrieval systems (FTS5 lexical + sqlite-vec semantic) into a single ranked list without requiring score normalization. Documents appearing in both lists rank higher than single-list documents. This is the core ranking algorithm for hybrid search in Gate B.

## Approach
Create \`src/search/rrf.rs\` per PRD Section 5.2.

```rust
use std::collections::HashMap;

const RRF_K: f64 = 60.0;

pub struct RrfResult {
    pub document_id: i64,
    pub rrf_score: f64,           // Raw RRF score
    pub normalized_score: f64,    // Normalized to 0-1 (rrf_score / max)
    pub vector_rank: Option<usize>,  // 1-indexed rank in vector list
    pub fts_rank: Option<usize>,     // 1-indexed rank in FTS list
}

/// Input: tuples of (document_id, score/distance) — already sorted by retriever.
/// Ranks are 1-indexed (first result = rank 1).
/// Score = sum of 1/(k + rank) for each list containing the document.
pub fn rank_rrf(
    vector_results: &[(i64, f64)],  // (doc_id, distance)
    fts_results: &[(i64, f64)],     // (doc_id, bm25_score)
) -> Vec<RrfResult>
```

**Algorithm (per PRD):**
1. Build HashMap<doc_id, (rrf_score, vector_rank, fts_rank)>
2. For each vector result at position i: score += 1/(K + (i+1)), record vector_rank = i+1 (**1-indexed**)
3. For each FTS result at position i: score += 1/(K + (i+1)), record fts_rank = i+1 (**1-indexed**)
4. Sort descending by rrf_score
5. Normalize: each result.normalized_score = result.rrf_score / max_score (best = 1.0)

**Key PRD details:**
- Ranks are **1-indexed** (rank 1 = best, not rank 0)
- Input is \`&[(i64, f64)]\` tuples, NOT custom structs
- Output has both \`rrf_score\` (raw) and \`normalized_score\` (0-1)

## Acceptance Criteria
- [ ] Documents in both lists score higher than single-list documents
- [ ] Single-list documents are included (not dropped)
- [ ] Ranks are 1-indexed (first element = rank 1)
- [ ] Raw RRF score available in rrf_score field
- [ ] Normalized score: best = 1.0, all in [0, 1]
- [ ] Results sorted descending by rrf_score
- [ ] vector_rank and fts_rank tracked per result for --explain
- [ ] Empty input lists handled (return empty)
- [ ] One empty list + one non-empty returns results from non-empty list

## Files
- \`src/search/rrf.rs\` — new file
- \`src/search/mod.rs\` — add \`mod rrf; pub use rrf::{rank_rrf, RrfResult};\`

## TDD Loop
RED: Tests in \`#[cfg(test)] mod tests\`:
- \`test_dual_list_ranks_higher\` — doc in both lists scores > doc in one list
- \`test_single_list_included\` — FTS-only and vector-only docs appear
- \`test_normalization\` — best score is 1.0, all in [0, 1]
- \`test_empty_inputs\` — empty returns empty
- \`test_ranks_are_1_indexed\` — verify vector_rank/fts_rank start at 1
- \`test_raw_and_normalized_scores\` — both fields populated correctly
GREEN: Implement rank_rrf()
VERIFY: \`cargo test rrf\`

## Edge Cases
- Duplicate document_id within same list: shouldn't happen, use first occurrence
- Single result in one list, zero in other: normalized_score = 1.0
- Very large input lists: HashMap handles efficiently

Completed: RRF ranking with 1-indexed ranks, raw+normalized scores, vector_rank/fts_rank provenance, 7 tests pass


Create mock response files for integration tests using wiremock.

## Fixtures to Create

### tests/fixtures/gitlab_issue.json
Single issue with labels:
- id, iid, project_id, title, description, state
- author object
- labels array (string names)
- timestamps
- web_url

### tests/fixtures/gitlab_issues_page.json
Array of issues simulating paginated response:
- 3-5 issues with varying states
- Mix of labels

### tests/fixtures/gitlab_discussion.json
Single discussion:
- id (string)
- individual_note: false
- notes array with 2+ notes
- Include one system note

### tests/fixtures/gitlab_discussions_page.json
Array of discussions:
- Mix of individual_note true/false
- Include resolvable/resolved examples

## Edge Cases to Cover
- Issue with no labels (empty array)
- Issue with labels_details (ignored in CP1)
- Discussion with individual_note=true (single note)
- System notes with system=true
- Resolvable notes

Files: tests/fixtures/gitlab_issue.json, gitlab_issues_page.json, gitlab_discussion.json, gitlab_discussions_page.json
Done when: wiremock handlers can use fixtures for deterministic tests


Fetch closes_issues API and populate entity_references

## Background
GET /projects/:id/merge_requests/:iid/closes_issues returns issues that will close when MR merges. This is the most reliable source for MR→issue relationships. Uses the generic dependent fetch queue (job_type = 'mr_closes_issues').

## Approach

**1. Add API endpoint to GitLab client (src/gitlab/client.rs):**
```rust
/// Fetch issues that will be closed when this MR merges.
pub async fn fetch_mr_closes_issues(
    &self,
    project_id: i64,
    iid: i64
) -> Result<Vec<GitLabIssueRef>>
```

New type in src/gitlab/types.rs:
```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabIssueRef {
    pub id: i64,
    pub iid: i64,
    pub project_id: i64,
    pub title: String,
    pub state: String,
    pub web_url: String,
}
```

URL: `GET /api/v4/projects/{project_id}/merge_requests/{iid}/closes_issues?per_page=100`

**2. Enqueue jobs during MR ingestion:**
In orchestrator.rs, after MR upsert:
```rust
enqueue_job(conn, project_id, "merge_request", iid, local_id, "mr_closes_issues", None)?;
```

This is always enqueued (not gated by a config flag) because cross-reference data is fundamental to all temporal queries.

**3. Process jobs in drain step:**
In the drain dispatcher (from bd-1ep), handle "mr_closes_issues" job_type:
```rust
let closes_issues = client.fetch_mr_closes_issues(gitlab_project_id, job.entity_iid).await?;
for issue_ref in &closes_issues {
    let target_id = resolve_issue_local_id(conn, project_id, issue_ref.iid);
    insert_entity_reference(conn, EntityReference {
        source_entity_type: "merge_request",
        source_entity_id: job.entity_local_id,
        target_entity_type: "issue",
        target_entity_id: target_id,            // Some(id) or None for cross-project
        target_project_path: if target_id.is_none() { Some(resolve_project_path(issue_ref.project_id)) } else { None },
        target_entity_iid: if target_id.is_none() { Some(issue_ref.iid) } else { None },
        reference_type: "closes",
        source_method: "api_closes_issues",
        created_at: None,
    })?;
}
```

**4. Insert helper for entity_references:**
Add to src/core/references.rs:
```rust
pub fn insert_entity_reference(conn: &Connection, ref_: &EntityReference) -> Result<bool>
// INSERT OR IGNORE, returns true if inserted
```

## Acceptance Criteria
- [ ] closes_issues API called for all MRs during sync
- [ ] Entity references created with reference_type='closes', source_method='api_closes_issues'
- [ ] Source = MR, target = issue (correct directionality)
- [ ] Cross-project issues stored as unresolved (target_entity_id=NULL, target_project_path set)
- [ ] Idempotent: re-sync doesn't create duplicate references
- [ ] 404 on deleted MR handled gracefully (fail_job)

## Files
- src/gitlab/client.rs (add fetch_mr_closes_issues)
- src/gitlab/types.rs (add GitLabIssueRef)
- src/core/references.rs (add insert_entity_reference helper)
- src/ingestion/orchestrator.rs (enqueue mr_closes_issues jobs)
- src/core/drain.rs or sync.rs (handle mr_closes_issues in drain dispatcher)

## TDD Loop
RED: tests/references_tests.rs:
- `test_closes_issues_creates_references` - mock closes_issues response, verify entity_references rows
- `test_closes_issues_cross_project_unresolved` - issue from different project stored as unresolved
- `test_closes_issues_idempotent` - process same job twice, verify no duplicates

tests/gitlab_types_tests.rs:
- `test_deserialize_issue_ref` - verify GitLabIssueRef deserialization

GREEN: Implement API endpoint, enqueue hook, drain handler, insert helper

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

## Edge Cases
- closes_issues API returns issues from OTHER projects (cross-project closing) — must check if issue is in local DB
- Empty response (MR doesn't close any issues) — no refs created, job still completed
- MR may close the same issue via description ("Closes #123") and via commits — API deduplicates, but our INSERT OR IGNORE handles it too
- The closes_issues API may return stale data for draft MRs (issues that *would* close but haven't yet)


Add database migration 006_merge_requests.sql

## Background
Foundation for all CP2 MR features. This migration defines the schema that all other MR components depend on. Must complete BEFORE any other CP2 work can proceed.

## Approach
Create migration file that adds:
1. `merge_requests` table with all CP2 fields
2. `mr_labels`, `mr_assignees`, `mr_reviewers` junction tables
3. Indexes on discussions for MR queries
4. DiffNote position columns on notes table

## Files
- `migrations/006_merge_requests.sql` - New migration file
- `src/core/db.rs` - Update MIGRATIONS const to include version 6

## Acceptance Criteria
- [ ] Migration file exists at `migrations/006_merge_requests.sql`
- [ ] `merge_requests` table has columns: id, gitlab_id, project_id, iid, title, description, state, draft, author_username, source_branch, target_branch, head_sha, references_short, references_full, detailed_merge_status, merge_user_username, created_at, updated_at, merged_at, closed_at, last_seen_at, discussions_synced_for_updated_at, discussions_sync_last_attempt_at, discussions_sync_attempts, discussions_sync_last_error, web_url, raw_payload_id
- [ ] `mr_labels` junction table exists with (merge_request_id, label_id) PK
- [ ] `mr_assignees` junction table exists with (merge_request_id, username) PK
- [ ] `mr_reviewers` junction table exists with (merge_request_id, username) PK
- [ ] `idx_discussions_mr_id` and `idx_discussions_mr_resolved` indexes exist
- [ ] `notes` table has new columns: position_type, position_line_range_start, position_line_range_end, position_base_sha, position_start_sha, position_head_sha
- [ ] `gi doctor` runs without migration errors
- [ ] `cargo test` passes

## TDD Loop
RED: Cannot open DB with version 6 schema
GREEN: Add migration file with full SQL
VERIFY: `cargo run -- doctor` shows healthy DB

## SQL Reference (from PRD)
```sql
-- Merge requests table
CREATE TABLE merge_requests (
  id INTEGER PRIMARY KEY,
  gitlab_id INTEGER UNIQUE NOT NULL,
  project_id INTEGER NOT NULL REFERENCES projects(id),
  iid INTEGER NOT NULL,
  title TEXT,
  description TEXT,
  state TEXT,                         -- opened | merged | closed | locked
  draft INTEGER NOT NULL DEFAULT 0,   -- SQLite boolean
  author_username TEXT,
  source_branch TEXT,
  target_branch TEXT,
  head_sha TEXT,
  references_short TEXT,
  references_full TEXT,
  detailed_merge_status TEXT,
  merge_user_username TEXT,
  created_at INTEGER,                 -- ms epoch UTC
  updated_at INTEGER,
  merged_at INTEGER,
  closed_at INTEGER,
  last_seen_at INTEGER NOT NULL,
  discussions_synced_for_updated_at INTEGER,
  discussions_sync_last_attempt_at INTEGER,
  discussions_sync_attempts INTEGER DEFAULT 0,
  discussions_sync_last_error TEXT,
  web_url TEXT,
  raw_payload_id INTEGER REFERENCES raw_payloads(id)
);
CREATE INDEX idx_mrs_project_updated ON merge_requests(project_id, updated_at);
CREATE UNIQUE INDEX uq_mrs_project_iid ON merge_requests(project_id, iid);
-- ... (see PRD for full index list)

-- Junction tables
CREATE TABLE mr_labels (
  merge_request_id INTEGER REFERENCES merge_requests(id) ON DELETE CASCADE,
  label_id INTEGER REFERENCES labels(id) ON DELETE CASCADE,
  PRIMARY KEY(merge_request_id, label_id)
);

CREATE TABLE mr_assignees (
  merge_request_id INTEGER REFERENCES merge_requests(id) ON DELETE CASCADE,
  username TEXT NOT NULL,
  PRIMARY KEY(merge_request_id, username)
);

CREATE TABLE mr_reviewers (
  merge_request_id INTEGER REFERENCES merge_requests(id) ON DELETE CASCADE,
  username TEXT NOT NULL,
  PRIMARY KEY(merge_request_id, username)
);

-- DiffNote position columns (ALTER TABLE)
ALTER TABLE notes ADD COLUMN position_type TEXT;
ALTER TABLE notes ADD COLUMN position_line_range_start INTEGER;
ALTER TABLE notes ADD COLUMN position_line_range_end INTEGER;
ALTER TABLE notes ADD COLUMN position_base_sha TEXT;
ALTER TABLE notes ADD COLUMN position_start_sha TEXT;
ALTER TABLE notes ADD COLUMN position_head_sha TEXT;

INSERT INTO schema_version (version, applied_at, description)
VALUES (6, strftime('%s', 'now') * 1000, 'Merge requests, MR labels, assignees, reviewers');
```

## Edge Cases
- SQLite does not support ADD CONSTRAINT - FK defined as nullable in CP1
- `locked` state is transitional (merge-in-progress) - store as first-class
- discussions_synced_for_updated_at prevents redundant discussion refetch

Migration 006_merge_requests.sql created and verified. Schema v6 applied successfully with all tables, indexes, and position columns.


Add transform_mr_discussion and transform_notes_with_diff_position

## Background
Extends discussion transformer for MR context. MR discussions can contain DiffNotes with file position metadata. This is critical for code review context in CP3 document generation.

## Approach
Add two new functions to existing `src/gitlab/transformers/discussion.rs`:
1. `transform_mr_discussion()` - Transform discussion with MR reference
2. `transform_notes_with_diff_position()` - Extract DiffNote position metadata

CP1 already has the polymorphic `NormalizedDiscussion` with `NoteableRef` enum - reuse that pattern.

## Files
- `src/gitlab/transformers/discussion.rs` - Add new functions
- `tests/diffnote_tests.rs` - DiffNote position extraction tests
- `tests/mr_discussion_tests.rs` - MR discussion transform tests

## Acceptance Criteria
- [ ] `transform_mr_discussion()` returns `NormalizedDiscussion` with `merge_request_id: Some(local_mr_id)`
- [ ] `transform_notes_with_diff_position()` returns `Result<Vec<NormalizedNote>, String>`
- [ ] DiffNote position fields extracted: `position_old_path`, `position_new_path`, `position_old_line`, `position_new_line`
- [ ] Extended position fields extracted: `position_type`, `position_line_range_start`, `position_line_range_end`
- [ ] SHA triplet extracted: `position_base_sha`, `position_start_sha`, `position_head_sha`
- [ ] Strict timestamp parsing - returns `Err` on invalid timestamps (no `unwrap_or(0)`)
- [ ] `cargo test diffnote` passes
- [ ] `cargo test mr_discussion` passes

## TDD Loop
RED: `cargo test diffnote_position` -> test fails
GREEN: Add position extraction logic
VERIFY: `cargo test diffnote`

## Function Signatures
```rust
/// Transform GitLab discussion for MR context.
/// Reuses existing transform_discussion logic, just with MR reference.
pub fn transform_mr_discussion(
    gitlab_discussion: &GitLabDiscussion,
    local_project_id: i64,
    local_mr_id: i64,
) -> NormalizedDiscussion {
    // Use existing transform_discussion with NoteableRef::MergeRequest(local_mr_id)
    transform_discussion(
        gitlab_discussion,
        local_project_id,
        NoteableRef::MergeRequest(local_mr_id),
    )
}

/// Transform notes with DiffNote position extraction.
/// Returns Result to enforce strict timestamp parsing.
pub fn transform_notes_with_diff_position(
    gitlab_discussion: &GitLabDiscussion,
    local_project_id: i64,
) -> Result<Vec<NormalizedNote>, String>
```

## DiffNote Position Extraction
```rust
// Extract position metadata if present
let (old_path, new_path, old_line, new_line, position_type, lr_start, lr_end, base_sha, start_sha, head_sha) = note
    .position
    .as_ref()
    .map(|pos| (
        pos.old_path.clone(),
        pos.new_path.clone(),
        pos.old_line,
        pos.new_line,
        pos.position_type.clone(),                    // "text" | "image" | "file"
        pos.line_range.as_ref().map(|r| r.start_line),
        pos.line_range.as_ref().map(|r| r.end_line),
        pos.base_sha.clone(),
        pos.start_sha.clone(),
        pos.head_sha.clone(),
    ))
    .unwrap_or((None, None, None, None, None, None, None, None, None, None));
```

## Strict Timestamp Parsing
```rust
// CRITICAL: Return error on invalid timestamps, never zero
let created_at = iso_to_ms(&note.created_at)
    .ok_or_else(|| format\!(
        "Invalid note.created_at for note {}: {}",
        note.id, note.created_at
    ))?;
```

## NormalizedNote Fields for DiffNotes
```rust
NormalizedNote {
    // ... existing fields ...
    // DiffNote position metadata
    position_old_path: old_path,
    position_new_path: new_path,
    position_old_line: old_line,
    position_new_line: new_line,
    // Extended position
    position_type,
    position_line_range_start: lr_start,
    position_line_range_end: lr_end,
    // SHA triplet
    position_base_sha: base_sha,
    position_start_sha: start_sha,
    position_head_sha: head_sha,
}
```

## Edge Cases
- Notes without position should have all position fields as None
- Invalid timestamp should fail the entire discussion (no partial results)
- File renames: `old_path \!= new_path` indicates a renamed file
- Multi-line comments: `line_range` present means comment spans lines 45-48

Implemented transform_mr_discussion() and transform_notes_with_diff_position() with full DiffNote position extraction:
- Extended NormalizedNote with 10 DiffNote position fields (path, line, type, line_range, SHA triplet)
- Added strict timestamp parsing that returns Err on invalid timestamps
- Created 13 diffnote_position_tests covering all extraction paths and error cases
- Created 6 mr_discussion_tests verifying MR reference handling
- All 161 tests passing


Implement MR CLI commands (list, show, count)

## Background
CLI commands for viewing and filtering merge requests. Includes list, show, and count commands with MR-specific filters.

## Approach
Update existing CLI command files:
1. `list.rs` - Add MR listing with filters
2. `show.rs` - Add MR detail view with discussions
3. `count.rs` - Add MR counting with state breakdown

## Files
- `src/cli/commands/list.rs` - Add MR subcommand
- `src/cli/commands/show.rs` - Add MR detail view
- `src/cli/commands/count.rs` - Add MR counting

## Acceptance Criteria
- [ ] `gi list mrs` shows MR table with iid, title, state, author, branches
- [ ] `gi list mrs --state=merged` filters by state
- [ ] `gi list mrs --state=locked` filters locally (not server-side)
- [ ] `gi list mrs --draft` shows only draft MRs
- [ ] `gi list mrs --no-draft` excludes draft MRs
- [ ] `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
- [ ] Draft MRs show `[DRAFT]` prefix in title
- [ ] `gi show mr <iid>` displays full detail including discussions
- [ ] DiffNote shows file context: `[src/file.ts:45]`
- [ ] Multi-line DiffNote shows: `[src/file.ts:45-48]`
- [ ] `gi show mr` shows `detailed_merge_status`
- [ ] `gi count mrs` shows total with state breakdown
- [ ] `gi sync-status` shows MR cursor positions
- [ ] `cargo test cli_commands` passes

## TDD Loop
RED: `cargo test list_mrs` -> command not found
GREEN: Add MR subcommand
VERIFY: `gi list mrs --help`

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

  !847  Refactor auth to use JWT tokens           merged   @johndoe   main <- feature/jwt   3 days ago
  !846  Fix memory leak in websocket handler      opened   @janedoe   main <- fix/websocket 5 days ago
  !845  [DRAFT] Add dark mode CSS variables       opened   @bobsmith  main <- ui/dark-mode  1 week ago
```

## SQL for MR Listing
```sql
SELECT
    m.iid, m.title, m.state, m.draft, m.author_username,
    m.target_branch, m.source_branch, m.updated_at
FROM merge_requests m
WHERE m.project_id = ?
  AND (? IS NULL OR m.state = ?)           -- state filter
  AND (? IS NULL OR m.draft = ?)           -- draft filter
  AND (? IS NULL OR m.author_username = ?) -- author filter
  AND (? IS NULL OR m.target_branch = ?)   -- target-branch filter
  AND (? IS NULL OR m.source_branch = ?)   -- source-branch filter
  AND (? IS NULL OR EXISTS (               -- reviewer filter
    SELECT 1 FROM mr_reviewers r
    WHERE r.merge_request_id = m.id AND r.username = ?
  ))
ORDER BY m.updated_at DESC
LIMIT ?
```

## gi show mr Output
```
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
Source:         feature/jwt
Target:         main
Merge Status:   mergeable
Merged By:      @alice
Merged At:      2024-03-20 14:30:00
Labels:         enhancement, auth, reviewed

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

Discussions (8):

  @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.
```

## DiffNote File Context Display
```rust
// Build file context string
let file_context = match (note.position_new_path, note.position_new_line, note.position_line_range_end) {
    (Some(path), Some(line), Some(end_line)) if line != end_line => {
        format!("[{}:{}-{}]", path, line, end_line)
    }
    (Some(path), Some(line), _) => {
        format!("[{}:{}]", path, line)
    }
    _ => String::new(),
};
```

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

## Filter Arguments (clap)
```rust
#[derive(Parser)]
struct ListMrsArgs {
    #[arg(long)]
    state: Option<String>,  // opened|merged|closed|locked|all
    #[arg(long)]
    draft: bool,
    #[arg(long)]
    no_draft: bool,
    #[arg(long)]
    author: Option<String>,
    #[arg(long)]
    assignee: Option<String>,
    #[arg(long)]
    reviewer: Option<String>,
    #[arg(long)]
    target_branch: Option<String>,
    #[arg(long)]
    source_branch: Option<String>,
    #[arg(long)]
    label: Vec<String>,
    #[arg(long)]
    project: Option<String>,
    #[arg(long, default_value = "20")]
    limit: u32,
}
```

## Edge Cases
- `--state=locked` must filter locally (GitLab API doesn't support it)
- Ambiguous MR iid across projects: prompt for `--project`
- Empty discussions: show "No discussions" message
- Multi-line DiffNotes: show line range in context


[CP0] gi version, backup, reset, sync-status commands

## Background

These are the remaining utility commands for CP0. version is trivial. backup creates safety copies before destructive operations. reset provides clean-slate capability. sync-status is a stub for CP0 that will be implemented in CP1.

Reference: docs/prd/checkpoint-0.md sections "gi version", "gi backup", "gi reset", "gi sync-status"

## Approach

**src/cli/commands/version.ts:**
```typescript
import { Command } from 'commander';
import { version } from '../../../package.json' with { type: 'json' };

export const versionCommand = new Command('version')
  .description('Show version information')
  .action(() => {
    console.log(\`gi version \${version}\`);
  });
```

**src/cli/commands/backup.ts:**
```typescript
import { Command } from 'commander';
import { copyFileSync, mkdirSync } from 'node:fs';
import { loadConfig } from '../../core/config';
import { getDbPath, getBackupDir } from '../../core/paths';

export const backupCommand = new Command('backup')
  .description('Create timestamped database backup')
  .action(async (options, command) => {
    const globalOpts = command.optsWithGlobals();
    const config = loadConfig(globalOpts.config);

    const dbPath = getDbPath(config.storage?.dbPath);
    const backupDir = getBackupDir(config.storage?.backupDir);

    mkdirSync(backupDir, { recursive: true });

    // Format: data-2026-01-24T10-30-00.db (colons replaced for Windows compat)
    const timestamp = new Date().toISOString().replace(/:/g, '-').replace(/\\..*/, '');
    const backupPath = \`\${backupDir}/data-\${timestamp}.db\`;

    copyFileSync(dbPath, backupPath);
    console.log(\`Created backup: \${backupPath}\`);
  });
```

**src/cli/commands/reset.ts:**
```typescript
import { Command } from 'commander';
import { unlinkSync, existsSync } from 'node:fs';
import { createInterface } from 'node:readline';
import { loadConfig } from '../../core/config';
import { getDbPath } from '../../core/paths';

export const resetCommand = new Command('reset')
  .description('Delete database and reset all state')
  .option('--confirm', 'Skip confirmation prompt')
  .action(async (options, command) => {
    const globalOpts = command.optsWithGlobals();
    const config = loadConfig(globalOpts.config);
    const dbPath = getDbPath(config.storage?.dbPath);

    if (!existsSync(dbPath)) {
      console.log('No database to reset.');
      return;
    }

    if (!options.confirm) {
      console.log(\`This will delete:\n  - Database: \${dbPath}\n  - All sync cursors\n  - All cached data\n\`);
      // Prompt for 'yes' confirmation
      // If not 'yes', exit 2
    }

    unlinkSync(dbPath);
    // Also delete WAL and SHM files if they exist
    if (existsSync(\`\${dbPath}-wal\`)) unlinkSync(\`\${dbPath}-wal\`);
    if (existsSync(\`\${dbPath}-shm\`)) unlinkSync(\`\${dbPath}-shm\`);

    console.log("Database reset. Run 'gi sync' to repopulate.");
  });
```

**src/cli/commands/sync-status.ts:**
```typescript
// CP0 stub - full implementation in CP1
export const syncStatusCommand = new Command('sync-status')
  .description('Show sync state')
  .action(() => {
    console.log("No sync runs yet. Run 'gi sync' to start.");
  });
```

## Acceptance Criteria

- [ ] `gi version` outputs "gi version X.Y.Z"
- [ ] `gi backup` creates timestamped copy of database
- [ ] Backup filename is Windows-compatible (no colons)
- [ ] Backup directory created if missing
- [ ] `gi reset` prompts for 'yes' confirmation
- [ ] `gi reset --confirm` skips prompt
- [ ] Reset deletes .db, .db-wal, and .db-shm files
- [ ] Reset exits 2 if user doesn't type 'yes'
- [ ] `gi sync-status` outputs stub message

## Files

CREATE:
- src/cli/commands/version.ts
- src/cli/commands/backup.ts
- src/cli/commands/reset.ts
- src/cli/commands/sync-status.ts

## TDD Loop

N/A - simple commands, verify manually:

```bash
gi version
gi backup
ls ~/.local/share/gi/backups/
gi reset  # type 'no'
gi reset --confirm
ls ~/.local/share/gi/data.db  # should not exist
gi sync-status
```

## Edge Cases

- Backup when database doesn't exist - show clear error
- Reset when database doesn't exist - show "No database to reset"
- WAL/SHM files may not exist - check before unlinking
- Timestamp with milliseconds could cause very long filename
- readline prompt in non-interactive terminal - handle SIGINT


Rename GiError to LoreError across codebase

## Background
The codebase currently uses `GiError` as the primary error enum name (legacy from when the project was called "gi"). Checkpoint 3 introduces new modules (documents, search, embedding) that import error types. Renaming before Gate A work begins prevents every subsequent bead from needing to reference the old name and avoids merge conflicts across parallel work streams.

## Approach
Mechanical find-and-replace using `ast-grep` or `sed`:
1. Rename the enum declaration in `src/core/error.rs`: `pub enum GiError` -> `pub enum LoreError`
2. Update the type alias: `pub type Result<T> = std::result::Result<T, LoreError>;`
3. Update re-exports in `src/core/mod.rs` and `src/lib.rs`
4. Update all `use` statements across ~16 files that import `GiError`
5. Update any `GiError::` variant construction sites
6. Run `cargo build` to verify no references remain

**Do NOT change:**
- Error variant names (ConfigNotFound, etc.) — only the enum name
- ErrorCode enum — it's already named correctly
- RobotError — already named correctly

## Acceptance Criteria
- [ ] `cargo build` succeeds with zero warnings about GiError
- [ ] `rg GiError src/` returns zero results
- [ ] `rg LoreError src/core/error.rs` shows the enum declaration
- [ ] `src/core/mod.rs` re-exports `LoreError` (not `GiError`)
- [ ] `src/lib.rs` re-exports `LoreError`
- [ ] All `use crate::core::error::LoreError` imports compile

## Files
- `src/core/error.rs` — enum rename + type alias
- `src/core/mod.rs` — re-export update
- `src/lib.rs` — re-export update
- All files matching `rg 'GiError' src/` (~16 files: ingestion/*.rs, cli/commands/*.rs, gitlab/*.rs, main.rs)

## TDD Loop
RED: `cargo build` fails after renaming enum but before fixing imports
GREEN: Fix all imports; `cargo build` succeeds
VERIFY: `cargo build && rg GiError src/ && echo "FAIL: GiError references remain" || echo "PASS: clean"`

## Edge Cases
- Some files may use `GiError` in string literals (error messages) — do NOT rename those, only type references
- `impl From<X> for GiError` blocks must become `impl From<X> for LoreError`
- The `thiserror` derive macro on the enum does not reference the name, so no macro changes needed

Completed: renamed GiError to LoreError across all 16 files, cargo build + 164 tests pass


Implement lore search CLI command (lexical mode)

## Background
The search CLI command is the user-facing entry point for Gate A lexical search. It orchestrates the search pipeline: query parsing -> FTS5 search -> filter application -> result hydration (single round-trip) -> display. Gate B extends this same command with --mode=hybrid and --mode=semantic. The hydration query is critical for performance — it fetches all display fields + labels + paths in one SQL query using json_each() + json_group_array().

## Approach
Create `src/cli/commands/search.rs` per PRD Section 3.4.

**Key types:**
- `SearchResultDisplay` — display-ready result with all fields (dates as ISO via `ms_to_iso`)
- `ExplainData` — ranking explanation for --explain flag (vector_rank, fts_rank, rrf_score)
- `SearchResponse` — wrapper with query, mode, total_results, results, warnings

**Core function:**
```rust
pub fn run_search(
    config: &Config,
    query: &str,
    mode: SearchMode,
    filters: SearchFilters,
    explain: bool,
) -> Result<SearchResponse>
```

**Pipeline:**
1. Parse query + filters
2. Execute search based on mode -> ranked doc_ids (+ explain ranks)
3. Apply post-retrieval filters via apply_filters() preserving ranking order
4. Hydrate results in single DB round-trip using json_each + json_group_array
5. Attach snippets: prefer FTS snippet, fallback to `generate_fallback_snippet()` for semantic-only
6. Convert timestamps via `ms_to_iso()` from `crate::core::time`
7. Build SearchResponse

**Hydration query (critical — single round-trip, replaces 60 queries with 1):**
```sql
SELECT d.id, d.source_type, d.title, d.url, d.author_username,
       d.created_at, d.updated_at, d.content_text,
       p.path_with_namespace AS project_path,
       (SELECT json_group_array(dl.label_name)
        FROM document_labels dl WHERE dl.document_id = d.id) AS labels,
       (SELECT json_group_array(dp.path)
        FROM document_paths dp WHERE dp.document_id = d.id) AS paths
FROM json_each(?) AS j
JOIN documents d ON d.id = j.value
JOIN projects p ON p.id = d.project_id
ORDER BY j.key
```

**Human output uses `console::style` for terminal formatting:**
```rust
use console::style;
// Type prefix in cyan
println!("[{}] {} - {} ({})", i+1, style(type_prefix).cyan(), title, score);
// URL in dim
println!("    {}", style(url).dim());
```

**JSON robot mode includes elapsed_ms in meta (PRD Section 3.4):**
```rust
pub fn print_search_results_json(response: &SearchResponse, elapsed_ms: u64) {
    let output = serde_json::json!({
        "ok": true,
        "data": response,
        "meta": { "elapsed_ms": elapsed_ms }
    });
    println!("{}", serde_json::to_string_pretty(&output).unwrap());
}
```

**CLI args in `src/cli/mod.rs` (PRD Section 3.4):**
```rust
#[derive(Args)]
pub struct SearchArgs {
    query: String,
    #[arg(long, default_value = "hybrid")]
    mode: String,
    #[arg(long, value_name = "TYPE")]
    r#type: Option<String>,
    #[arg(long)]
    author: Option<String>,
    #[arg(long)]
    project: Option<String>,
    #[arg(long, action = clap::ArgAction::Append)]
    label: Vec<String>,
    #[arg(long)]
    path: Option<String>,
    #[arg(long)]
    after: Option<String>,
    #[arg(long)]
    updated_after: Option<String>,
    #[arg(long, default_value = "20")]
    limit: usize,
    #[arg(long)]
    explain: bool,
    #[arg(long, default_value = "safe")]
    fts_mode: String,
}
```

**IMPORTANT: default_value = "hybrid"** — When Ollama is unavailable, hybrid mode gracefully degrades to FTS-only with a warning (not an error). `lore search` works without Ollama.

## Acceptance Criteria
- [ ] Default mode is "hybrid" (not "lexical") per PRD
- [ ] Hybrid mode degrades gracefully to FTS-only when Ollama unavailable (warning, not error)
- [ ] All filters work (type, author, project, label, path, after, updated_after, limit)
- [ ] Label filter uses `clap::ArgAction::Append` for repeatable --label flags
- [ ] Hydration in single query (not N+1) — uses json_each + json_group_array
- [ ] Timestamps converted via `ms_to_iso()` for display (ISO format)
- [ ] Human output uses `console::style` for colored type prefix (cyan) and dim URLs
- [ ] JSON robot mode includes `elapsed_ms` in `meta` field
- [ ] Semantic-only results get fallback snippets via `generate_fallback_snippet()`
- [ ] Empty results show friendly message: "No results found for 'query'"
- [ ] "No data indexed" message if documents table empty
- [ ] --explain shows vector_rank, fts_rank, rrf_score per result
- [ ] --fts-mode=safe preserves prefix `*` while escaping special chars
- [ ] --fts-mode=raw passes FTS5 MATCH syntax through unchanged
- [ ] --mode=semantic with 0% embedding coverage returns LoreError::EmbeddingsNotBuilt (not OllamaUnavailable)
- [ ] SearchArgs registered in cli/mod.rs with Clap derive
- [ ] `cargo build` succeeds

## Files
- `src/cli/commands/search.rs` — new file
- `src/cli/commands/mod.rs` — add `pub mod search;`
- `src/cli/mod.rs` — add SearchArgs struct, wire up search subcommand
- `src/main.rs` — add search command handler

## TDD Loop
RED: Integration test requiring DB with documents
- `test_lexical_search_returns_results` — FTS search returns hits
- `test_hydration_single_query` — verify no N+1 (mock/inspect query count)
- `test_json_output_includes_elapsed` — robot mode JSON has meta.elapsed_ms
- `test_empty_results_message` — zero results shows friendly message
- `test_fallback_snippet` — semantic-only result uses truncated content
GREEN: Implement run_search + hydrate_results + print functions
VERIFY: `cargo build && cargo test search`

## Edge Cases
- Zero results: display friendly empty message, JSON returns empty array
- --mode=semantic with 0% embedding coverage: return LoreError::EmbeddingsNotBuilt
- json_group_array returns "[]" for documents with no labels — parse as empty array
- Very long snippets: truncated at display time
- Hybrid default works without Ollama: degrades to FTS-only with warning
- ms_to_iso with epoch 0: return valid ISO string (not crash)

Implemented search CLI with FTS5 + RRF ranking, single-query hydration (json_each + json_group_array), adaptive recall, all filters, --explain, human + JSON output. Builds clean.


[CP1] gi list issues command

List issues from the database.

Flags:
- --limit=N (default: 20)
- --project=PATH (filter by project)
- --state=opened|closed|all (default: all)

Output: Table with iid, title, state, author, relative time

Files: src/cli/commands/list.ts
Done when: List displays issues with proper filtering and formatting


[CP1] gi list issues command

## Background

The `gi list issues` command displays a paginated list of issues from the local database. It supports filtering by project and state, with configurable limit. This provides quick access to synced issues without opening GitLab.

## Approach

### Module: src/cli/commands/list.rs

### Clap Definition

```rust
#[derive(Args)]
pub struct ListArgs {
    /// Entity type to list
    #[arg(value_parser = ["issues", "mrs"])]
    pub entity: String,

    /// Maximum results
    #[arg(long, default_value = "20")]
    pub limit: usize,

    /// Filter by project path
    #[arg(long)]
    pub project: Option<String>,

    /// Filter by state
    #[arg(long, value_parser = ["opened", "closed", "all"])]
    pub state: Option<String>,
}
```

### Handler Function

```rust
pub async fn handle_list(args: ListArgs, conn: &Connection) -> Result<()>
```

### Query (for issues)

```sql
SELECT i.iid, i.title, i.state, i.author_username, i.updated_at, p.path
FROM issues i
JOIN projects p ON i.project_id = p.id
WHERE (p.path = ? OR ? IS NULL)
  AND (i.state = ? OR ? IS NULL OR ? = 'all')
ORDER BY i.updated_at DESC
LIMIT ?
```

### Output Format (matches PRD)

```
Issues (showing 20 of 3,801)

  #1234  Authentication redesign                       opened   @johndoe   3 days ago
  #1233  Fix memory leak in cache                      closed   @janedoe   5 days ago
  #1232  Add dark mode support                         opened   @bobsmith  1 week ago
  ...
```

### Column Layout

| Column | Width | Alignment |
|--------|-------|-----------|
| IID | 6 | right |
| Title | 45 | left (truncate) |
| State | 8 | left |
| Author | 12 | left |
| Updated | 12 | right (relative) |

### Relative Time Formatting

```rust
fn format_relative_time(ms_epoch: i64) -> String {
    let now = now_ms();
    let diff = now - ms_epoch;
    match diff {
        d if d < 60_000 => "just now".to_string(),
        d if d < 3_600_000 => format!("{} min ago", d / 60_000),
        d if d < 86_400_000 => format!("{} hours ago", d / 3_600_000),
        d if d < 604_800_000 => format!("{} days ago", d / 86_400_000),
        d if d < 2_592_000_000 => format!("{} weeks ago", d / 604_800_000),
        _ => format!("{} months ago", diff / 2_592_000_000),
    }
}
```

## Acceptance Criteria

- [ ] Lists issues ordered by updated_at DESC
- [ ] Shows "showing X of Y" with total count
- [ ] Respects --limit parameter
- [ ] --project filters to single project
- [ ] --state filters to opened/closed/all
- [ ] Title truncated if longer than column width
- [ ] Updated time shown as relative ("3 days ago")

## Files

- src/cli/commands/mod.rs (add `pub mod list;`)
- src/cli/commands/list.rs (create)
- src/cli/mod.rs (add List variant to Commands enum)

## TDD Loop

RED:
```rust
#[tokio::test] async fn list_issues_shows_correct_columns()
#[tokio::test] async fn list_issues_respects_limit()
#[tokio::test] async fn list_issues_filters_by_project()
#[tokio::test] async fn list_issues_filters_by_state()
```

GREEN: Implement handler with query and formatting

VERIFY: `cargo test list_issues`

## Edge Cases

- No issues match filters - show "No issues found"
- Title exactly 45 chars - no truncation
- Title 46+ chars - truncate with "..."
- --state=all shows both opened and closed
- Default state filter is all (not just opened)