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


[CP1] Test fixtures for mocked GitLab responses

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


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)


[CP1] Issue transformer with label extraction

## Background

The issue transformer converts GitLab API responses into our local schema format. It extracts core fields and, critically, the array of label names from each issue. This transformer is pure logic with no I/O, making it easy to test.

## Approach

Create a transformer module with a function that:
1. Takes a `GitLabIssue` and returns an `IssueRow` struct
2. Extracts the `labels: Vec<String>` directly from the issue (GitLab returns label names as strings)

### Structs

```rust
// src/gitlab/transformers/issue.rs

pub struct IssueRow {
    pub gitlab_id: i64,
    pub iid: i64,
    pub project_id: i64,
    pub title: String,
    pub description: Option<String>,
    pub state: String,
    pub author_username: String,
    pub created_at: i64,      // ms epoch UTC
    pub updated_at: i64,      // ms epoch UTC
    pub web_url: String,
}

pub struct IssueWithLabels {
    pub issue: IssueRow,
    pub label_names: Vec<String>,
}
```

### Function

```rust
pub fn transform_issue(issue: GitLabIssue) -> Result<IssueWithLabels, TransformError> {
    // Parse ISO 8601 timestamps to ms epoch
    // Extract author.username
    // Return IssueWithLabels with label_names from issue.labels
}
```

## Acceptance Criteria

- [ ] `IssueRow` struct exists with all fields from schema
- [ ] `IssueWithLabels` struct bundles issue + label names
- [ ] `transform_issue` parses ISO 8601 to ms epoch correctly
- [ ] `transform_issue` handles missing description (None)
- [ ] Label names are preserved exactly as received from GitLab
- [ ] Unit tests cover all edge cases

## Files

- src/gitlab/transformers/mod.rs (create, add `pub mod issue;`)
- src/gitlab/transformers/issue.rs (create)

## TDD Loop

RED:
```rust
// tests/unit/issue_transformer_test.rs
#[test] fn transforms_issue_with_all_fields()
#[test] fn handles_missing_description()
#[test] fn extracts_label_names()
#[test] fn parses_timestamps_to_ms_epoch()
```

GREEN: Implement transform_issue function

VERIFY: `cargo test issue_transformer`

## Edge Cases

- GitLab timestamps are ISO 8601 with timezone - use chrono::DateTime::parse_from_rfc3339
- Description can be null in GitLab API - map to Option<String>
- Empty labels array is valid - return empty Vec
- Do NOT parse labels_details - it varies across GitLab versions

Implemented IssueRow, IssueWithLabels, transform_issue with 6 passing unit tests covering all edge cases


[CP0] Database setup with migrations and app lock

## Background

The database is the backbone of gitlab-inbox. SQLite with WAL mode for performance, foreign keys for integrity, and proper pragmas for reliability. Migrations allow schema evolution. App lock prevents concurrent sync corruption.

Reference: docs/prd/checkpoint-0.md sections "Database Schema", "SQLite Runtime Pragmas", "App Lock Mechanism"

## Approach

**src/core/db.ts:**
```typescript
import Database from 'better-sqlite3';
import { join, dirname } from 'node:path';
import { mkdirSync, readdirSync, readFileSync } from 'node:fs';
import { getDbPath } from './paths';
import { dbLogger } from './logger';

export function createConnection(dbPath: string): Database.Database {
  mkdirSync(dirname(dbPath), { recursive: true });
  const db = new Database(dbPath);

  // Production-grade pragmas
  db.pragma('journal_mode = WAL');
  db.pragma('synchronous = NORMAL');
  db.pragma('foreign_keys = ON');
  db.pragma('busy_timeout = 5000');
  db.pragma('temp_store = MEMORY');

  return db;
}

export function runMigrations(db: Database.Database, migrationsDir: string): void {
  // Create schema_version table if not exists
  // Read migration files sorted by version
  // Apply migrations not yet applied
  // Track in schema_version table
}
```

**migrations/001_initial.sql:**
Full schema with tables: schema_version, projects, sync_runs, app_locks, sync_cursors, raw_payloads

**src/core/lock.ts:**
AppLock class with:
- acquire(force?): acquires lock or throws DatabaseLockError
- release(): releases lock and stops heartbeat
- Heartbeat timer that updates heartbeat_at every N seconds
- Stale lock detection (heartbeat_at > staleLockMinutes ago)

## Acceptance Criteria

- [ ] createConnection() creates parent directories if missing
- [ ] WAL mode verified: `db.pragma('journal_mode')` returns 'wal'
- [ ] Foreign keys verified: `db.pragma('foreign_keys')` returns 1
- [ ] busy_timeout verified: `db.pragma('busy_timeout')` returns 5000
- [ ] 001_initial.sql creates all 6 tables
- [ ] schema_version shows version 1 after migration
- [ ] AppLock.acquire() succeeds for first caller
- [ ] AppLock.acquire() throws DatabaseLockError for second concurrent caller
- [ ] Stale lock (heartbeat > 10 min old) can be taken over
- [ ] tests/unit/db.test.ts passes (8 tests)
- [ ] tests/integration/app-lock.test.ts passes (6 tests)

## Files

CREATE:
- src/core/db.ts
- src/core/lock.ts
- migrations/001_initial.sql
- tests/unit/db.test.ts
- tests/integration/app-lock.test.ts

## TDD Loop

RED:
```typescript
// tests/unit/db.test.ts
describe('Database', () => {
  it('creates database file if not exists')
  it('applies migrations in order')
  it('sets WAL journal mode')
  it('enables foreign keys')
  it('sets busy_timeout=5000')
  it('sets synchronous=NORMAL')
  it('sets temp_store=MEMORY')
  it('tracks schema version')
})

// tests/integration/app-lock.test.ts
describe('App Lock', () => {
  it('acquires lock successfully')
  it('updates heartbeat during operation')
  it('detects stale lock and recovers')
  it('refuses concurrent acquisition')
  it('allows force override')
  it('releases lock on completion')
})
```

GREEN: Implement db.ts, lock.ts, 001_initial.sql

VERIFY:
```bash
npm run test -- tests/unit/db.test.ts
npm run test -- tests/integration/app-lock.test.ts
```

## Edge Cases

- Migration file with syntax error should rollback and throw MigrationError
- Lock heartbeat timer must be unref()'d to not block process exit
- Database file permissions - fail clearly if not writable
- Concurrent lock tests need separate database files


Implement search filters module

## Background
Search filters are applied post-retrieval to narrow results by source type, author, project, date, labels, and file paths. The filter module must preserve ranking order from the search pipeline (FTS/RRF scores). It uses SQLite's JSON1 extension (json_each) to pass ranked document IDs efficiently and maintain their original order.

## Approach
Create `src/search/filters.rs` per PRD Section 3.3. The full implementation is specified in the PRD including the SQL query.

**Key types:**
- `SearchFilters` struct with all filter fields + `has_any_filter()` + `clamp_limit()`
- `PathFilter` enum: `Prefix(String)` (trailing `/`) or `Exact(String)`

**Core function:**
```rust
pub fn apply_filters(
    conn: &Connection,
    document_ids: &[i64],
    filters: &SearchFilters,
) -> Result<Vec<i64>>
```

**SQL pattern (JSON1 for ordered ID passing):**
```sql
SELECT d.id
FROM json_each(?) AS j
JOIN documents d ON d.id = j.value
WHERE 1=1
  AND d.source_type = ?      -- if source_type filter set
  AND d.author_username = ?  -- if author filter set
  -- ... dynamic WHERE clauses
ORDER BY j.key               -- preserves ranking order
LIMIT ?
```

**Filter logic:**
- Labels: AND logic via `EXISTS (SELECT 1 FROM document_labels dl WHERE dl.document_id = d.id AND dl.label_name = ?)`
- Path prefix: `LIKE ? ESCAPE '\\'` with escaped wildcards
- Path exact: `= ?`
- Limit: clamped to [1, 100], default 20

## Acceptance Criteria
- [ ] source_type filter works (issue, merge_request, discussion)
- [ ] author filter: exact username match
- [ ] project_id filter: restricts to single project
- [ ] after filter: created_at >= value
- [ ] updated_after filter: updated_at >= value
- [ ] labels filter: AND logic (all specified labels must be present)
- [ ] path exact filter: matches exact path string
- [ ] path prefix filter: trailing `/` triggers LIKE with escaped wildcards
- [ ] Ranking order preserved (ORDER BY j.key from json_each)
- [ ] Limit clamped: 0 -> 20 (default), 200 -> 100 (max)
- [ ] Empty document_ids returns empty Vec
- [ ] Multiple filters compose correctly (all applied via AND)
- [ ] `cargo test filters` passes

## Files
- `src/search/filters.rs` — new file
- `src/search/mod.rs` — add `pub use filters::{SearchFilters, PathFilter, apply_filters};`

## TDD Loop
RED: Tests in `filters.rs` `#[cfg(test)] mod tests`:
- `test_no_filters` — all docs returned up to limit
- `test_source_type_filter` — only issues returned
- `test_author_filter` — exact match
- `test_labels_and_logic` — must have ALL specified labels
- `test_path_exact` — matches exact path
- `test_path_prefix` — trailing slash matches prefix
- `test_limit_clamping` — 0 -> 20, 200 -> 100
- `test_ranking_preserved` — output order matches input order
- `test_has_any_filter` — true when any filter set, false when default
GREEN: Implement apply_filters with dynamic SQL
VERIFY: `cargo test filters`

## Edge Cases
- Path containing SQL LIKE wildcards (`%`, `_`): must be escaped before LIKE
- Empty labels list: no label filter applied (not "must have zero labels")
- `has_any_filter()` returns false for default SearchFilters (no filters set)
- Large document_ids array (1000+): JSON1 handles efficiently

Completed: SearchFilters with has_any_filter/clamp_limit, PathFilter enum, apply_filters with dynamic SQL + json_each ordering, escape_like, 8 tests pass


[CP1] Final validation - tests, smoke tests, integrity checks

Run all tests and perform data integrity checks.

Validation steps:
1. Run all unit tests (vitest)
2. Run all integration tests
3. Run ESLint
4. Run TypeScript strict check
5. Manual smoke tests per PRD table
6. Data integrity SQL checks:
   - Issue count matches GitLab
   - Every issue has raw_payload
   - Labels in junction exist in labels table
   - sync_cursors has entry per project
   - Re-run fetches 0 new items
   - Discussion count > 0
   - Every discussion has >= 1 note
   - individual_note=true has exactly 1 note

Files: All CP1 files
Done when: All gate criteria from Definition of Done pass


Implement lore generate-docs CLI command

## Background
The generate-docs CLI command is the user-facing wrapper around the document regeneration pipeline. It has two modes: incremental (default, processes dirty_sources queue only) and full (seeds dirty_sources with ALL entities, then drains). Both modes use the same regenerator codepath to avoid logic divergence. Full mode uses keyset pagination (WHERE id > last_id) for seeding to avoid O(n^2) OFFSET degradation on large tables.

## Approach
Create `src/cli/commands/generate_docs.rs` per PRD Section 2.4.

**Core function:**
```rust
pub fn run_generate_docs(
    config: &Config,
    full: bool,
    project_filter: Option<&str>,
) -> Result<GenerateDocsResult>
```

**Full mode seeding (keyset pagination):**
```rust
const FULL_MODE_CHUNK_SIZE: usize = 2000;

// For each source type (issues, MRs, discussions):
let mut last_id: i64 = 0;
loop {
    let tx = conn.transaction()?;
    let inserted = tx.execute(
        "INSERT INTO dirty_sources (source_type, source_id, queued_at, ...)
         SELECT 'issue', id, ?, 0, NULL, NULL, NULL
         FROM issues WHERE id > ? ORDER BY id LIMIT ?
         ON CONFLICT(source_type, source_id) DO NOTHING",
        params![now_ms(), last_id, FULL_MODE_CHUNK_SIZE],
    )?;
    if inserted == 0 { tx.commit()?; break; }
    // Advance keyset cursor...
    tx.commit()?;
}
```

**After draining (full mode only):**
```sql
INSERT INTO documents_fts(documents_fts) VALUES('optimize')
```

**CLI args:**
```rust
#[derive(Args)]
pub struct GenerateDocsArgs {
    #[arg(long)]
    full: bool,
    #[arg(long)]
    project: Option<String>,
}
```

**Output:** Human-readable table + JSON robot mode.

## Acceptance Criteria
- [ ] Default mode (no --full): processes only existing dirty_sources entries
- [ ] --full mode: seeds dirty_sources with ALL issues, MRs, and discussions
- [ ] Full mode uses keyset pagination (WHERE id > last_id, not OFFSET)
- [ ] Full mode chunk size is 2000
- [ ] Full mode does FTS optimize after completion
- [ ] Both modes use regenerate_dirty_documents() (same codepath)
- [ ] Progress bar shown in human mode (via indicatif)
- [ ] JSON output in robot mode with GenerateDocsResult
- [ ] GenerateDocsResult has issues/mrs/discussions/total/truncated/skipped counts
- [ ] `cargo build` succeeds

## Files
- `src/cli/commands/generate_docs.rs` — new file
- `src/cli/commands/mod.rs` — add `pub mod generate_docs;`
- `src/cli/mod.rs` — add GenerateDocsArgs, wire up generate-docs subcommand
- `src/main.rs` — add generate-docs command handler

## TDD Loop
RED: Integration test with seeded DB
GREEN: Implement run_generate_docs with seeding + drain
VERIFY: `cargo build && cargo test generate_docs`

## Edge Cases
- Empty database (no issues/MRs/discussions): full mode seeds nothing, returns all-zero counts
- --project filter in full mode: only seed dirty_sources for entities in that project
- Interrupted full mode: dirty_sources entries persist (ON CONFLICT DO NOTHING), resume by re-running
- FTS optimize on empty FTS table: no-op (safe)

Implemented generate-docs command with incremental + full mode, keyset pagination seeding, FTS optimize, project filter, human + JSON output. Builds clean.


Epic: Gate C - Sync MVP

## Background
Gate C adds the sync orchestrator and queue infrastructure that makes the search pipeline incremental and self-maintaining. It introduces dirty source tracking (change detection during ingestion), the discussion fetch queue, and the unified lore sync command that orchestrates the full pipeline. Gate C also adds integrity checks and repair paths.

## Gate C Deliverables
1. Orchestrated lore sync command with incremental doc regen + re-embedding
2. Integrity checks + repair paths for FTS/embeddings consistency

## Bead Dependencies (execution order, after Gate A)
1. **bd-mem** — Shared backoff utility (no deps, shared with Gate B)
2. **bd-38q** — Dirty source tracking (blocked by bd-36p, bd-hrs, bd-mem)
3. **bd-1je** — Discussion queue (blocked by bd-hrs, bd-mem)
4. **bd-1i2** — Integrate dirty tracking into ingestion (blocked by bd-38q)
5. **bd-1x6** — Sync CLI (blocked by bd-38q, bd-1je, bd-1i2, bd-3qs, bd-2sx)

## Acceptance Criteria
- [ ] `lore sync` runs full pipeline: ingest -> generate-docs -> embed
- [ ] `lore sync --full` does full re-sync + regeneration
- [ ] `lore sync --no-embed` skips embedding stage
- [ ] Dirty tracking: upserted entities automatically marked for regeneration
- [ ] Queue draining: dirty_sources fully drained in bounded batch loop
- [ ] Backoff: failed items use exponential backoff with jitter
- [ ] `lore stats --check` detects inconsistencies
- [ ] `lore stats --repair` fixes FTS/embedding inconsistencies

All Gate C sub-beads complete: backoff utility, dirty tracking, discussion queue, ingestion integration, sync CLI, stats CLI


Write unit tests for core algorithms

## Background
Unit tests verify the core algorithms in isolation: document extraction formatting, FTS query sanitization, RRF scoring, content hashing, backoff curves, and filter helpers. These tests don't require a database or external services — they test pure functions and logic.

## Approach
Add #[cfg(test)] mod tests blocks to each module:

**1. src/documents/extractor.rs:**
- test_source_type_parse_all_aliases — every alias resolves correctly
- test_source_type_parse_unknown — returns None
- test_source_type_as_str_roundtrip — as_str matches parse input
- 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

**2. src/documents/truncation.rs:**
- test_truncation_edge_cases (per bd-18t TDD Loop)

**3. src/search/fts.rs:**
- test_to_fts_query_basic — "auth error" -> quoted tokens
- test_to_fts_query_prefix — "auth*" preserves prefix
- test_to_fts_query_special_chars — "C++" quoted correctly
- test_to_fts_query_dash — "-DWITH_SSL" quoted (not NOT operator)
- test_to_fts_query_internal_quotes — escaped by doubling
- test_to_fts_query_empty — empty string returns empty

**4. src/search/rrf.rs:**
- test_rrf_dual_list — docs in both lists score higher
- test_rrf_normalization — best score = 1.0
- test_rrf_empty — empty returns empty

**5. src/core/backoff.rs:**
- test_exponential_curve — delays double each attempt
- test_cap_at_one_hour — high attempt_count capped
- test_jitter_range — within [0.9, 1.1) factor

**6. src/search/filters.rs:**
- test_has_any_filter — true/false for various filter combos
- test_clamp_limit — 0->20, 200->100, 50->50
- test_path_filter_from_str — trailing slash = Prefix

**7. src/search/hybrid.rs (hydration round-trip):**
- test_single_round_trip_query — verify hydration SQL produces correct structure

## Acceptance Criteria
- [ ] All edge cases covered per PRD acceptance criteria
- [ ] Tests are unit tests (no DB, no network, no Ollama)
- [ ] `cargo test` passes with all new tests
- [ ] No test depends on execution order
- [ ] Tests cover: document extractor formats, truncation, RRF, hashing, FTS sanitization, backoff, filters

## Files
- In-module tests in: extractor.rs, truncation.rs, fts.rs, rrf.rs, backoff.rs, filters.rs, hybrid.rs

## TDD Loop
These tests ARE the TDD loop for their respective beads. Each implementation bead should write its tests first (RED), then implement (GREEN).
VERIFY: `cargo test`

## Edge Cases
- Tests with Unicode: include emoji, CJK characters in truncation tests
- Tests with empty strings: empty queries, empty content, empty labels
- Tests with boundary values: limit=0, limit=100, limit=101

All acceptance criteria tests already exist across modules. 276 tests passing (189 unit + 87 integration).


Add GitLab MR types to types.rs

## Background
GitLab API types for merge requests. These structs define how we deserialize GitLab API responses. Must handle deprecated field aliases for backward compatibility with older GitLab instances.

## Approach
Add new structs to `src/gitlab/types.rs`:
- `GitLabMergeRequest` - Main MR struct with all fields
- `GitLabReviewer` - Reviewer with optional approval state
- `GitLabReferences` - Short and full reference strings

Use serde `#[serde(alias = "...")]` for deprecated field fallbacks.

## Files
- `src/gitlab/types.rs` - Add new structs after existing GitLabIssue
- `tests/fixtures/gitlab_merge_request.json` - Test fixture

## Acceptance Criteria
- [ ] `GitLabMergeRequest` struct exists with all fields from PRD
- [ ] `detailed_merge_status` field exists (non-deprecated)
- [ ] `#[serde(alias = "merge_status")]` on `merge_status_legacy` for fallback
- [ ] `merge_user` field exists (non-deprecated)
- [ ] `merged_by` field exists for fallback
- [ ] `draft` and `work_in_progress` both exist (draft preferred, WIP fallback)
- [ ] `sha` field maps to `head_sha` in transformer
- [ ] `references: Option<GitLabReferences>` for short/full refs
- [ ] `state: String` supports "opened", "merged", "closed", "locked"
- [ ] Fixture deserializes without error
- [ ] `cargo test` passes

## TDD Loop
RED: Add test that deserializes fixture -> struct not found
GREEN: Add GitLabMergeRequest, GitLabReviewer, GitLabReferences structs
VERIFY: `cargo test gitlab_types`

## Struct Definitions (from PRD)
```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabMergeRequest {
    pub id: i64,
    pub iid: i64,
    pub project_id: i64,
    pub title: String,
    pub description: Option<String>,
    pub state: String,                // "opened" | "merged" | "closed" | "locked"
    #[serde(default)]
    pub draft: bool,
    #[serde(default)]
    pub work_in_progress: bool,       // Deprecated fallback
    pub source_branch: String,
    pub target_branch: String,
    pub sha: Option<String>,          // head_sha
    pub references: Option<GitLabReferences>,
    pub detailed_merge_status: Option<String>,
    #[serde(alias = "merge_status")]
    pub merge_status_legacy: Option<String>,
    pub created_at: String,
    pub updated_at: String,
    pub merged_at: Option<String>,
    pub closed_at: Option<String>,
    pub author: GitLabAuthor,
    pub merge_user: Option<GitLabAuthor>,
    pub merged_by: Option<GitLabAuthor>,
    #[serde(default)]
    pub labels: Vec<String>,
    #[serde(default)]
    pub assignees: Vec<GitLabAuthor>,
    #[serde(default)]
    pub reviewers: Vec<GitLabReviewer>,
    pub web_url: String,
}

#[derive(Debug, Clone, Deserialize)]
pub struct GitLabReferences {
    pub short: String,   // e.g. "\!123"
    pub full: String,    // e.g. "group/project\!123"
}

#[derive(Debug, Clone, Deserialize)]
pub struct GitLabReviewer {
    pub id: i64,
    pub username: String,
    pub name: String,
}
```

## Test Fixture (create tests/fixtures/gitlab_merge_request.json)
```json
{
  "id": 12345,
  "iid": 42,
  "project_id": 100,
  "title": "Add user authentication",
  "description": "Implements JWT auth flow",
  "state": "merged",
  "draft": false,
  "work_in_progress": false,
  "source_branch": "feature/auth",
  "target_branch": "main",
  "sha": "abc123def456",
  "references": { "short": "\!42", "full": "group/project\!42" },
  "detailed_merge_status": "mergeable",
  "merge_status": "can_be_merged",
  "created_at": "2024-01-15T10:00:00Z",
  "updated_at": "2024-01-20T14:30:00Z",
  "merged_at": "2024-01-20T14:30:00Z",
  "closed_at": null,
  "author": { "id": 1, "username": "johndoe", "name": "John Doe" },
  "merge_user": { "id": 2, "username": "janedoe", "name": "Jane Doe" },
  "merged_by": { "id": 2, "username": "janedoe", "name": "Jane Doe" },
  "labels": ["enhancement", "auth"],
  "assignees": [{ "id": 3, "username": "bob", "name": "Bob Smith" }],
  "reviewers": [{ "id": 4, "username": "alice", "name": "Alice Wong" }],
  "web_url": "https://gitlab.example.com/group/project/-/merge_requests/42"
}
```

## Edge Cases
- `locked` state is transitional (merge in progress) - rare but valid
- Some older instances may not return `detailed_merge_status`
- Some older instances may not return `merge_user` (use `merged_by` fallback)
- `work_in_progress` is deprecated but still returned by some instances

Added GitLabMergeRequest, GitLabReviewer, GitLabReferences structs. Updated GitLabNotePosition with position_type, line_range, and SHA triplet fields. All 23 type tests passing.


[CP1] Issue ingestion module

Fetch and store issues with cursor-based incremental sync.

## Module
src/ingestion/issues.rs

## Key Structs

### IngestIssuesResult
- fetched: usize
- upserted: usize
- labels_created: usize
- issues_needing_discussion_sync: Vec<IssueForDiscussionSync>

### IssueForDiscussionSync
- local_issue_id: i64
- iid: i64
- updated_at: i64

## Main Function
pub async fn ingest_issues(conn, client, config, project_id, gitlab_project_id) -> Result<IngestIssuesResult>

## Logic
1. Get current cursor from sync_cursors (updated_at_cursor, tie_breaker_id)
2. Paginate through issues updated after cursor with cursor_rewind_seconds
3. Apply local filtering for tuple cursor semantics:
   - Skip if issue.updated_at < cursor_updated_at
   - Skip if issue.updated_at == cursor_updated_at AND issue.id <= cursor_gitlab_id
4. For each issue passing filter:
   - Begin transaction
   - Store raw payload (compressed)
   - Transform and upsert issue
   - Clear existing label links (DELETE FROM issue_labels)
   - Extract and upsert labels
   - Link issue to labels via junction
   - Commit transaction
   - Track for discussion sync eligibility
5. Incremental cursor update every 100 issues
6. Final cursor update
7. Determine issues needing discussion sync: where updated_at > discussions_synced_for_updated_at

## Helper Functions
- get_cursor(conn, project_id) -> (Option<i64>, Option<i64>)
- get_discussions_synced_at(conn, issue_id) -> Option<i64>
- upsert_issue(conn, issue, payload_id) -> usize
- get_local_issue_id(conn, gitlab_id) -> i64
- clear_issue_labels(conn, issue_id)
- upsert_label(conn, label) -> bool
- get_label_id(conn, project_id, name) -> i64
- link_issue_label(conn, issue_id, label_id)
- update_cursor(conn, project_id, resource_type, updated_at, gitlab_id)

Files: src/ingestion/mod.rs, src/ingestion/issues.rs
Tests: tests/issue_ingestion_tests.rs
Done when: Issues, labels, issue_labels populated correctly with resumable cursor


[CP1] gi sync-status enhancement

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

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

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

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

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

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

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


Implement embedding pipeline with chunking

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


Implement vector search function

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

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

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

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

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

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

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

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

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

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

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


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

Add required dependencies for async pagination streams.

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

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

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

Files: Cargo.toml


[CP1] Integration tests for label linkage

Integration tests verifying label linkage and stale removal.

## Tests (tests/label_linkage_tests.rs)

- clears_existing_labels_before_linking_new_set
- removes_stale_label_links_on_issue_update
- handles_issue_with_all_labels_removed
- preserves_labels_that_still_exist

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

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

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


[CP0] Config loading with Zod validation

## Background

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

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

## Approach

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

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

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

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

## Acceptance Criteria

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

## Files

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

## TDD Loop

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

GREEN: Implement loadConfig() function

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

## Edge Cases

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


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

## Background

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

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

## Approach

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

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

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

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

## Acceptance Criteria

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

## Files

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

## TDD Loop

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

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

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

## Edge Cases

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


[CP1] Discussion ingestion module

## Background

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

## Approach

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

### Key Structs

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

### Main Function

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

### Logic

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

### Helper Functions

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

### Critical Invariant

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

## Acceptance Criteria

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

## Files

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

## TDD Loop

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

GREEN: Implement ingest_issue_discussions with watermark logic

VERIFY: `cargo test discussion_watermark`

## Edge Cases

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


Create migration 007_documents.sql

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

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

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

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

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

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

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

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

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


Add GitLab client MR pagination methods

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

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

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

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

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

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

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

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

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

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

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


[CP1] Discussion and note transformers

Transform GitLab discussion/note payloads to normalized database schema.

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

## Structs

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

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

## Functions

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

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

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


[CP1] gi show issue command

Show issue details with discussions.

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

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

    iid: i64,

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

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

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

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

Discussions (5):

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

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

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

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

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