OBSERV Epic: Phase 3 - Performance Metrics Collection

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

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

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

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

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


Implement hybrid search with adaptive recall

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


Implement RRF ranking

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

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

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

const RRF_K: f64 = 60.0;

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

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

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

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

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

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

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

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

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


Implement stale response + SQLITE_BUSY + cancel race tests

## Background
These tests verify the TUI handles async race conditions correctly: stale responses from superseded tasks are dropped, SQLITE_BUSY errors trigger retry with backoff, and query cancellation doesn't bleed across tasks or leave stuck loading states.

## Approach
Stale response tests:
- Submit task A (generation 1), then submit task B (generation 2) with same key
- Deliver task A's result: assert it's dropped (generation mismatch)
- Deliver task B's result: assert it's applied

SQLITE_BUSY retry tests:
- Lock DB with a writer, attempt read query, assert retry with exponential backoff
- Verify TUI shows "Database busy" toast, not a crash

Cancel race tests:
- Submit task, cancel via CancelToken, immediately submit new task with same key
- Assert old task's CancelToken is set, new task proceeds normally
- Rapid cancel-then-resubmit: no stuck LoadingInitial state after sequence
- Cross-task bleed: interrupt handle only cancels the owning task's connection

## Acceptance Criteria
- [ ] Stale response with old generation silently dropped
- [ ] SQLITE_BUSY shows user-friendly error, retries automatically
- [ ] Cancel-then-resubmit: no stuck loading state
- [ ] InterruptHandle only cancels its owning task's query
- [ ] Rapid sequence (5 cancel+submit in 100ms): final state is correct

## Files
- CREATE: crates/lore-tui/tests/race_condition_tests.rs

## TDD Anchor
RED: Write test_stale_response_dropped that submits two tasks, delivers first result, asserts state unchanged.
GREEN: Ensure is_current() check in update() guards all *Loaded handlers.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_stale_response

## Edge Cases
- SQLITE_BUSY timeout must be configurable (default 5000ms)
- Rapid navigation can create >10 pending tasks — all but latest should be cancelled
- CancelToken check must be in hot loops, not just at task entry

## Dependency Context
Uses TaskSupervisor from "Implement TaskSupervisor" task.
Uses DbManager from "Implement DbManager" task.
Uses LoreApp update() stale-result guards from "Implement LoreApp Model" task.


Implement session persistence + instance lock + text width

## Background
Session state persistence allows the TUI to resume where the user left off (current screen, filter state, scroll position). Instance locking prevents data corruption from accidental double-launch. Text width handling ensures correct rendering of CJK, emoji, and combining marks.

## Approach
Session (session.rs):
- SessionState: versioned struct with current_screen, nav_history, per-screen filter/scroll state, global_scope
- save(path): atomic write (tmp->fsync->rename) + CRC32 checksum + max-size guard (1MB)
- load(path) -> Result<SessionState>: validate CRC32, reject corrupted (quarantine bad file), handle version migration
- corruption quarantine: move bad files to .quarantine/ subdir

Instance Lock (instance_lock.rs):
- InstanceLock: advisory lock file (~/.local/share/lore/tui.lock) with PID written
- acquire() -> Result<InstanceLock>: try lock, check for stale lock (PID no longer running), clear stale, create new
- Drop impl: remove lock file
- On collision: clear error message with running PID

Text Width (text_width.rs):
- measure_display_width(s: &str) -> usize: terminal display width using unicode-width + unicode-segmentation
- truncate_display_width(s: &str, max_width: usize) -> String: truncate at grapheme boundary, append ellipsis
- pad_display_width(s: &str, width: usize) -> String: pad with spaces to target display width
- Handles: CJK (2-cell), emoji ZWJ sequences, skin tone modifiers, flag sequences, combining marks

## Acceptance Criteria
- [ ] Session state saved on quit, restored on launch
- [ ] Atomic write prevents partial session files
- [ ] CRC32 checksum detects corruption
- [ ] Corrupted sessions quarantined (not deleted)
- [ ] Max 1MB session file size enforced
- [ ] Instance lock prevents double-launch with clear error
- [ ] Stale lock (dead PID) automatically recovered
- [ ] Lock released on normal exit and on panic (Drop impl)
- [ ] CJK characters measured as 2 cells wide
- [ ] Emoji ZWJ sequences treated as single grapheme cluster
- [ ] Truncation never splits a grapheme cluster

## Files
- CREATE: crates/lore-tui/src/session.rs
- CREATE: crates/lore-tui/src/instance_lock.rs
- CREATE: crates/lore-tui/src/text_width.rs

## TDD Anchor
RED: Write test_measure_cjk_width that asserts measure_display_width("Hello") == 5 and measure_display_width("日本語") == 6.
GREEN: Implement measure_display_width using unicode-width.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_measure

Additional tests:
- test_session_roundtrip: save and load, assert equal
- test_session_corruption_detected: modify saved file, assert load returns error
- test_instance_lock_stale_recovery: create lock with dead PID, assert acquire succeeds
- test_truncate_emoji: truncate string with emoji, assert no split grapheme

## Edge Cases
- Lock file dir doesn't exist: create it
- PID reuse: rare but possible — stale lock detection uses PID existence check only
- Session version migration: old version file should be handled gracefully (reset to defaults)
- text_width: some terminals render emoji incorrectly — we use standard wcwidth semantics


Quality gates: cargo check, clippy, fmt, test

## Background
Final verification that all implementation beads integrate cleanly. Must pass all quality gates before the feature is considered complete.

## Approach
Run all 4 quality gate commands. Fix any issues discovered.

## Commands (in order)
1. cargo check --all-targets (zero errors)
2. cargo clippy --all-targets -- -D warnings (pedantic + nursery clean)
3. cargo fmt --check (formatted)
4. cargo test (all green, including all 42 new tests)

## Acceptance Criteria
- [ ] cargo check --all-targets: exit 0
- [ ] cargo clippy --all-targets -- -D warnings: exit 0
- [ ] cargo fmt --check: exit 0
- [ ] cargo test: all pass (0 failures)
- [ ] All 42 new tests from the plan are present and green

## Known Gotchas from Plan's Trial Run
- clippy::items_after_test_module: ansi256_from_rgb must be BEFORE #[cfg(test)]
- clippy::collapsible_if: use let-chain syntax (if x && let ...)
- clippy::manual_range_contains: use (16..=231).contains(&blue)
- r##"..."## needed for test JSON with hex colors

Implemented by agent swarm — all quality gates pass (595 tests, 0 failures)


[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


Fetch closes_issues API and populate entity_references

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

## Approach

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

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

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

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

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

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

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

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

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

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

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

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

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

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


NOTE-1B: CLI arguments and command wiring for lore notes

## Background
Wire the notes query layer into the CLI. Add NotesArgs clap struct, Notes variant to Commands enum, and handler in main.rs.

## Approach
1. Add NotesArgs struct to src/cli/mod.rs (after MrsArgs at line 397):
   #[derive(Debug, clap::Args)]
   pub struct NotesArgs {
       --limit/-n (usize, default 50)
       --fields (Option<String>)
       --format (table/json/jsonl/csv, default table)
       --author/-a (Option<String>)
       --note-type (Option<String>)
       --contains (Option<String>)
       --note-id (Option<i64>)
       --gitlab-note-id (Option<i64>)
       --discussion-id (Option<String>)
       --include-system (bool flag)
       --for-issue (Option<i64>, conflicts_with = "for_mr")
       --for-mr (Option<i64>, conflicts_with = "for_issue")
       --project/-p (Option<String>)
       --since (Option<String>)
       --until (Option<String>)
       --path (Option<String>)
       --resolution (Option<String>, value_parser = ["any", "unresolved", "resolved"])
       --sort (String, default "created", value_parser = ["created", "updated"])
       --asc (bool flag)
       --open (bool flag)
   }
   NOTE: --for-issue/--for-mr do NOT use clap requires="project" — defaultProject handles project resolution at query time.

2. Add Notes(NotesArgs) to Commands enum (line 108-170, after Mrs variant at line 113):
   /// List notes from discussions
   Notes(NotesArgs),

3. Add "notes" minimal preset to expand_fields_preset() in src/cli/robot.rs (line 28):
   "notes" => ["id", "author_username", "body", "created_at_iso"]
   Add after the existing "search" preset (line 39).

4. Add handle_notes() in src/main.rs following the handle_issues/handle_mrs pattern. The dispatch logic should:
   - Build NoteListFilters from NotesArgs
   - Call query_notes() from NOTE-1A
   - Dispatch on format: robot/json → print_list_notes_json, jsonl → print_list_notes_jsonl, csv → print_list_notes_csv, table → print_list_notes
   (Print functions come from NOTE-1C)

5. Wire command dispatch in main.rs command match:
   Some(Commands::Notes(args)) => handle_notes(cli.config.as_deref(), args, robot_mode).await

6. Re-export in src/cli/commands/mod.rs (line 32-35 area):
   Add to pub use list:: { ..., run_list_notes, NoteListFilters, ... }

## Files
- MODIFY: src/cli/mod.rs (NotesArgs struct + Commands::Notes variant)
- MODIFY: src/cli/robot.rs (notes minimal preset at expand_fields_preset, line 28)
- MODIFY: src/main.rs (handle_notes function + command dispatch)
- MODIFY: src/cli/commands/mod.rs (add note exports to pub use list block, line 32)

## TDD Anchor
RED: test_expand_fields_preset_notes — assert expand_fields_preset(&["minimal".to_string()], "notes") returns ["id", "author_username", "body", "created_at_iso"]
GREEN: Add "notes" case to match in expand_fields_preset.
VERIFY: cargo test expand_fields_preset_notes -- --nocapture

## Acceptance Criteria
- [ ] lore notes command exists and dispatches to handle_notes
- [ ] All 19+ CLI flags parsed correctly by clap
- [ ] --for-issue and --for-mr are mutually exclusive (conflicts_with)
- [ ] --format supports table, json, jsonl, csv
- [ ] Robot mode auto-selects JSON output
- [ ] --fields minimal preset works for notes
- [ ] "notes" added to CLI autocorrect suggestions in src/cli/autocorrect.rs
- [ ] Test passes

## Dependency Context
- Depends on NOTE-1A (bd-20p9): uses NoteListFilters, query_notes, NoteListResult

## Edge Cases
- Robot mode without --format: auto-selects JSON (same pattern as issues/mrs)
- Unknown --format value: clap rejects before handler runs
- --for-issue without --project: handled at query layer (NOTE-1A), not clap


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.


Implement terminal safety module (sanitize + URL policy + redact)

## Background
GitLab content (issue descriptions, comments, MR descriptions) can contain arbitrary text including ANSI escape sequences, bidirectional text overrides, OSC hyperlinks, and C1 control codes. Displaying unsanitized content in a terminal can hijack cursor position, inject fake UI elements, or cause rendering corruption. This module provides a sanitization layer that strips dangerous sequences while preserving a safe ANSI subset for readability.

## Approach
Create `crates/lore-tui/src/safety.rs` with:
- `sanitize_for_terminal(input: &str) -> String` — the main entry point
- Strip C1 control codes (0x80-0x9F)
- Strip OSC sequences (ESC ] ... ST)
- Strip cursor movement (CSI <n> A/B/C/D/E/F/G/H/J/K)
- Strip bidi overrides (U+202A-U+202E, U+2066-U+2069)
- **PRESERVE safe ANSI subset**: SGR sequences for bold (1), italic (3), underline (4), reset (0), and standard foreground/background colors (30-37, 40-47, 90-97, 100-107). These improve readability of formatted GitLab content.
- `UrlPolicy` enum: `Strip`, `Footnote`, `Passthrough` — controls how OSC 8 hyperlinks are handled
- `RedactPattern` for optional PII/secret redaction (email, token patterns)
- All functions are pure (no I/O), fully testable

Reference existing terminal safety patterns in ftui-core if available.

## Acceptance Criteria
- [ ] sanitize_for_terminal strips C1, OSC, cursor movement, bidi overrides
- [ ] sanitize_for_terminal preserves bold, italic, underline, reset, and standard color SGR sequences
- [ ] UrlPolicy::Strip removes OSC 8 hyperlinks entirely
- [ ] UrlPolicy::Footnote converts OSC 8 hyperlinks to numbered footnotes [1] with URL list at end
- [ ] RedactPattern matches common secret patterns (tokens, emails) and replaces with [REDACTED]
- [ ] No unsafe code
- [ ] Unit tests cover each dangerous sequence type AND verify safe sequences are preserved
- [ ] Fuzz test with 1000 random byte sequences: no panic

## Files
- CREATE: crates/lore-tui/src/safety.rs
- MODIFY: crates/lore-tui/src/lib.rs (add pub mod safety)

## TDD Anchor
RED: Write `test_strips_cursor_movement` that asserts CSI sequences for cursor up/down/left/right are removed from input while bold SGR is preserved.
GREEN: Implement the sanitizer state machine that categorizes and filters escape sequences.
VERIFY: cargo test -p lore-tui safety -- --nocapture

Additional tests:
- test_strips_c1_control_codes
- test_strips_bidi_overrides
- test_strips_osc_sequences
- test_preserves_bold_italic_underline_reset
- test_preserves_standard_colors
- test_url_policy_strip
- test_url_policy_footnote
- test_redact_patterns
- test_fuzz_no_panic

## Edge Cases
- Malformed/truncated escape sequences (ESC without closing) — must not consume following text
- Nested SGR sequences (e.g., bold+color combined in single CSI) — preserve entire sequence if all parameters are safe
- UTF-8 multibyte chars adjacent to escape sequences — must not corrupt char boundaries
- Empty input returns empty string
- Input with only safe content passes through unchanged

## Dependency Context
Depends on bd-3ddw (scaffold) for the crate structure to exist. No other dependencies — this is a pure utility module.


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 --fields minimal preset for me robot mode

## Background
`--fields` already exists in lore robot mode and `minimal` presets are entity-specific in `src/cli/robot.rs`. `lore me` needs equivalent behavior without breaking existing commands.

This bead defines how one requested field set is projected across four me arrays (`open_issues`, `open_mrs_authored`, `reviewing_mrs`, `activity`) while preserving non-array data (`username`, `since_iso`, `summary`, `meta`).

## Approach
### 1. Extend preset expansion in shared robot utility
In `src/cli/robot.rs`, add entity keys to `expand_fields_preset`:
- `me_work_items` => `["iid", "title", "attention_state", "updated_at_iso"]`
- `me_activity` => `["timestamp_iso", "event_type", "entity_iid", "actor"]`

### 2. Apply filtering in me robot renderer path
After full envelope is built (from `bd-2ilv`), apply filtering only to arrays:
- `open_issues` uses expanded fields from `me_work_items`.
- `open_mrs_authored` uses expanded fields from `me_work_items`.
- `reviewing_mrs` uses expanded fields from `me_work_items`.
- `activity` uses expanded fields from `me_activity`.

For non-`minimal` custom lists, pass through requested fields unchanged for all four arrays.

### 3. Keep top-level and summary stable
Never filter:
- `data.username`
- `data.since_iso`
- `data.summary`
- `meta`

## Acceptance Criteria
- [ ] `--fields minimal` projects work-item arrays to exactly: `iid`, `title`, `attention_state`, `updated_at_iso`
- [ ] `--fields minimal` projects activity array to exactly: `timestamp_iso`, `event_type`, `entity_iid`, `actor`
- [ ] `summary`, `username`, and `since_iso` remain present under `--fields minimal`
- [ ] No `--fields` flag returns full unfiltered payload
- [ ] Custom lists (e.g. `--fields iid,title,labels`) are applied to all 4 arrays
- [ ] Unknown fields do not error; they yield empty objects where no keys match (existing behavior)
- [ ] Existing non-me presets in `expand_fields_preset` remain unchanged

## Files
- MODIFY: `src/cli/robot.rs`
- MODIFY: `src/cli/commands/me/render_robot.rs`
- MODIFY: `src/cli/commands/me/mod.rs` (pass through `args.fields`)

## TDD Anchor
RED:
- `test_me_fields_minimal_projects_work_item_arrays`
- `test_me_fields_minimal_projects_activity_array`
- `test_me_fields_minimal_preserves_summary_and_identity`
- `test_me_fields_custom_list_passthrough`
- `test_me_fields_unknown_names_do_not_error`

GREEN:
- Add me presets and wire array-level filtering in me renderer.

VERIFY:
- `cargo test fields_minimal`
- `cargo test me_robot_fields`

## Edge Cases
- Applying custom fields that only exist on some arrays (e.g. `draft`) should keep matching keys where present and produce empty objects elsewhere.
- Empty arrays should remain `[]` after filtering.

## Dependency Context
Builds on `bd-2ilv` full me envelope and shared filtering helpers in `src/cli/robot.rs`.
Provides final robot token-shaping behavior consumed by `bd-1vv8` command handler.

Dependencies:
  -> bd-2ilv (blocks) - Implement robot JSON output for me command

Dependents:
  <- bd-1vv8 (blocks) - Implement me command handler: wire queries to renderers


Implement async integration tests: cancellation, timeout, embed isolation, payload integrity

## Background

The surgical sync pipeline involves async operations, cancellation signals, timeouts, scoped embedding, and multi-entity coordination. Unit tests in individual beads cover their own logic, but integration tests are needed to verify the full pipeline under realistic conditions: cancellation at different stages, timeout behavior with continuation, embedding scope isolation (only affected documents get embedded), and payload integrity (project_id mismatches rejected). These tests use wiremock for HTTP mocking and tokio for async runtime.

## Approach

Create `tests/surgical_integration.rs` as an integration test file (Rust convention: `tests/` directory for integration tests). Six test functions covering the critical behavioral properties of the surgical pipeline:

1. **Cancellation before preflight**: Signal cancelled before any HTTP call. Verify: recorder marked failed, no GitLab requests made, result has zero updates.
2. **Cancellation during dependent stage**: Signal cancelled after preflight succeeds but during discussion fetch. Verify: partial results recorded, recorder marked failed, entities processed before cancellation have outcomes.
3. **Per-entity timeout with continuation**: One entity's GitLab endpoint is slow (wiremock delay). Verify: that entity gets `failed` outcome with timeout error, remaining entities continue and succeed.
4. **Embed scope isolation**: Sync two issues. Verify: only documents generated from those two issues are embedded, not the entire corpus. Assert by checking document IDs passed to embed function.
5. **Payload project_id mismatch rejection**: Preflight returns an issue with `project_id` different from the resolved project. Verify: that entity gets `failed` outcome with clear error, other entities unaffected.
6. **Successful full pipeline**: Sync one issue end-to-end through all stages. Verify: SyncResult has correct counts, entity_results has `synced` outcome, documents regenerated, embeddings created.

All tests use in-memory SQLite (`create_connection(Path::new(":memory:"))` + `run_migrations`) and wiremock `MockServer`.

## Acceptance Criteria

1. All 6 tests compile and pass
2. Tests are isolated (each creates its own DB and mock server)
3. Cancellation tests verify recorder state (failed status in sync_runs table)
4. Timeout test uses wiremock delay, not `tokio::time::sleep` on the test side
5. Embed isolation test verifies document-level scoping, not just function call
6. Tests run in CI without flakiness (no real network, no real Ollama)

## Files

- `tests/surgical_integration.rs` — all 6 integration tests

## TDD Anchor

```rust
// tests/surgical_integration.rs

use lore::cli::commands::sync::{SyncOptions, SyncResult};
use lore::core::db::{create_connection, run_migrations};
use lore::core::shutdown::ShutdownSignal;
use lore::Config;
use std::path::Path;
use std::time::Duration;
use wiremock::{Mock, MockServer, ResponseTemplate};
use wiremock::matchers::{method, path_regex};

fn test_config(mock_url: &str) -> Config {
    let mut config = Config::default();
    config.gitlab.url = mock_url.to_string();
    config.gitlab.token = "test-token".to_string();
    config
}

fn setup_db() -> rusqlite::Connection {
    let conn = create_connection(Path::new(":memory:")).unwrap();
    run_migrations(&conn).unwrap();
    conn.execute(
        "INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url)
         VALUES (1, 'group/project', 'https://gitlab.example.com/group/project')",
        [],
    ).unwrap();
    conn
}

fn mock_issue_json(iid: u64) -> serde_json::Value {
    serde_json::json!({
        "id": 100 + iid, "iid": iid, "project_id": 1, "title": format!("Issue {}", iid),
        "state": "opened", "created_at": "2026-01-01T00:00:00Z",
        "updated_at": "2026-02-17T00:00:00Z",
        "author": {"id": 1, "username": "dev", "name": "Dev"},
        "web_url": format!("https://gitlab.example.com/group/project/-/issues/{}", iid)
    })
}

#[tokio::test]
async fn cancellation_before_preflight() {
    let server = MockServer::start().await;
    // No mocks mounted — if any request is made, wiremock will return 404
    let config = test_config(&server.uri());
    let options = SyncOptions {
        issue_iids: vec![7],
        project: Some("group/project".to_string()),
        robot_mode: true,
        ..SyncOptions::default()
    };
    let signal = ShutdownSignal::new();
    signal.cancel(); // Cancel before anything starts

    let result = lore::cli::commands::sync_surgical::run_sync_surgical(
        &config, options, Some("cancel-pre"), &signal,
    ).await.unwrap();

    assert_eq!(result.issues_updated, 0);
    assert_eq!(result.mrs_updated, 0);
    // Verify no HTTP requests were made
    assert_eq!(server.received_requests().await.unwrap().len(), 0);
}

#[tokio::test]
async fn cancellation_during_dependent_stage() {
    let server = MockServer::start().await;
    // Mock issue fetch (preflight succeeds)
    Mock::given(method("GET"))
        .and(path_regex(r"/api/v4/projects/1/issues"))
        .respond_with(ResponseTemplate::new(200)
            .set_body_json(serde_json::json!([mock_issue_json(7)])))
        .mount(&server).await;
    // Mock discussion fetch with delay (gives time to cancel)
    Mock::given(method("GET"))
        .and(path_regex(r"/api/v4/projects/1/issues/7/discussions"))
        .respond_with(ResponseTemplate::new(200)
            .set_body_json(serde_json::json!([]))
            .set_body_delay(Duration::from_secs(2)))
        .mount(&server).await;

    let config = test_config(&server.uri());
    let options = SyncOptions {
        issue_iids: vec![7],
        project: Some("group/project".to_string()),
        robot_mode: true,
        ..SyncOptions::default()
    };
    let signal = ShutdownSignal::new();

    // Cancel after a short delay (after preflight, during dependents)
    let signal_clone = signal.clone();
    tokio::spawn(async move {
        tokio::time::sleep(Duration::from_millis(200)).await;
        signal_clone.cancel();
    });

    let result = lore::cli::commands::sync_surgical::run_sync_surgical(
        &config, options, Some("cancel-dep"), &signal,
    ).await.unwrap();

    // Preflight should have run, but ingest may be partial
    assert!(result.surgical_mode == Some(true));
}

#[tokio::test]
async fn per_entity_timeout_with_continuation() {
    let server = MockServer::start().await;
    // Issue 7: slow response (simulates timeout)
    Mock::given(method("GET"))
        .and(path_regex(r"/api/v4/projects/1/issues\?.*iids\[\]=7"))
        .respond_with(ResponseTemplate::new(200)
            .set_body_json(serde_json::json!([mock_issue_json(7)]))
            .set_body_delay(Duration::from_secs(30)))
        .mount(&server).await;
    // Issue 42: fast response
    Mock::given(method("GET"))
        .and(path_regex(r"/api/v4/projects/1/issues\?.*iids\[\]=42"))
        .respond_with(ResponseTemplate::new(200)
            .set_body_json(serde_json::json!([mock_issue_json(42)])))
        .mount(&server).await;

    let config = test_config(&server.uri());
    let options = SyncOptions {
        issue_iids: vec![7, 42],
        project: Some("group/project".to_string()),
        robot_mode: true,
        ..SyncOptions::default()
    };
    let signal = ShutdownSignal::new();

    // With a per-entity timeout, issue 7 should fail, issue 42 should succeed
    let result = lore::cli::commands::sync_surgical::run_sync_surgical(
        &config, options, Some("timeout-test"), &signal,
    ).await.unwrap();

    let entities = result.entity_results.as_ref().unwrap();
    // One should be failed (timeout), one should be synced
    let failed = entities.iter().filter(|e| e.outcome == "failed").count();
    let synced = entities.iter().filter(|e| e.outcome == "synced").count();
    assert!(failed >= 1 || synced >= 1, "Expected mixed outcomes");
}

#[tokio::test]
async fn embed_scope_isolation() {
    let server = MockServer::start().await;
    // Mock two issues
    Mock::given(method("GET"))
        .and(path_regex(r"/api/v4/projects/1/issues"))
        .respond_with(ResponseTemplate::new(200)
            .set_body_json(serde_json::json!([
                mock_issue_json(7), mock_issue_json(42)
            ])))
        .mount(&server).await;
    // Mock empty discussions for both
    Mock::given(method("GET"))
        .and(path_regex(r"/api/v4/projects/1/issues/\d+/discussions"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
        .mount(&server).await;

    let config = test_config(&server.uri());
    let options = SyncOptions {
        issue_iids: vec![7, 42],
        project: Some("group/project".to_string()),
        robot_mode: true,
        no_embed: false,
        ..SyncOptions::default()
    };
    let signal = ShutdownSignal::new();

    let result = lore::cli::commands::sync_surgical::run_sync_surgical(
        &config, options, Some("embed-iso"), &signal,
    ).await.unwrap();

    // Embedding should only have processed documents from issues 7 and 42
    // Not the full corpus. Verify via document counts.
    assert!(result.documents_embedded <= 2,
        "Expected at most 2 documents embedded (one per issue), got {}",
        result.documents_embedded);
}

#[tokio::test]
async fn payload_project_id_mismatch_rejection() {
    let server = MockServer::start().await;
    // Return issue with project_id=999 (doesn't match resolved project_id=1)
    Mock::given(method("GET"))
        .and(path_regex(r"/api/v4/projects/1/issues"))
        .respond_with(ResponseTemplate::new(200)
            .set_body_json(serde_json::json!([{
                "id": 200, "iid": 7, "project_id": 999, "title": "Wrong Project",
                "state": "opened", "created_at": "2026-01-01T00:00:00Z",
                "updated_at": "2026-02-17T00:00:00Z",
                "author": {"id": 1, "username": "dev", "name": "Dev"},
                "web_url": "https://gitlab.example.com/other/project/-/issues/7"
            }])))
        .mount(&server).await;

    let config = test_config(&server.uri());
    let options = SyncOptions {
        issue_iids: vec![7],
        project: Some("group/project".to_string()),
        robot_mode: true,
        ..SyncOptions::default()
    };
    let signal = ShutdownSignal::new();

    let result = lore::cli::commands::sync_surgical::run_sync_surgical(
        &config, options, Some("mismatch"), &signal,
    ).await.unwrap();

    let entities = result.entity_results.as_ref().unwrap();
    assert_eq!(entities.len(), 1);
    assert_eq!(entities[0].outcome, "failed");
    assert!(entities[0].error.as_ref().unwrap().contains("project_id"));
}

#[tokio::test]
async fn successful_full_pipeline() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path_regex(r"/api/v4/projects/1/issues"))
        .respond_with(ResponseTemplate::new(200)
            .set_body_json(serde_json::json!([mock_issue_json(7)])))
        .mount(&server).await;
    Mock::given(method("GET"))
        .and(path_regex(r"/api/v4/projects/1/issues/7/discussions"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
        .mount(&server).await;
    // Mock any resource event endpoints
    Mock::given(method("GET"))
        .and(path_regex(r"/api/v4/projects/1/issues/7/resource_"))
        .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!([])))
        .mount(&server).await;

    let config = test_config(&server.uri());
    let options = SyncOptions {
        issue_iids: vec![7],
        project: Some("group/project".to_string()),
        robot_mode: true,
        no_embed: true, // Skip embed to avoid Ollama dependency
        ..SyncOptions::default()
    };
    let signal = ShutdownSignal::new();

    let result = lore::cli::commands::sync_surgical::run_sync_surgical(
        &config, options, Some("full-pipe"), &signal,
    ).await.unwrap();

    assert_eq!(result.surgical_mode, Some(true));
    assert_eq!(result.surgical_iids.as_ref().unwrap().issues, vec![7]);
    assert_eq!(result.preflight_only, Some(false));

    let entities = result.entity_results.as_ref().unwrap();
    assert_eq!(entities.len(), 1);
    assert_eq!(entities[0].entity_type, "issue");
    assert_eq!(entities[0].iid, 7);
    assert_eq!(entities[0].outcome, "synced");
    assert!(entities[0].error.is_none());

    assert!(result.issues_updated >= 1);
    assert!(result.documents_regenerated >= 1);
}
```

## Edge Cases

- **Wiremock delay vs tokio timeout**: Use `set_body_delay` on wiremock, not `tokio::time::sleep` in tests. The per-entity timeout in the orchestrator (bd-1i4i) should use `tokio::time::timeout` around the HTTP call.
- **Embed isolation without Ollama**: Tests that verify embed scoping should either mock Ollama or use `no_embed: true` and verify the document ID list passed to the embed function. The `successful_full_pipeline` test uses `no_embed: true` to avoid requiring a running Ollama server in CI.
- **Test isolation**: Each test creates its own `MockServer`, in-memory DB, and `ShutdownSignal`. No shared state between tests.
- **Flakiness prevention**: Cancellation timing tests (test 2) use deterministic delays (cancel after 200ms, response delayed 2s). If flaky, increase the gap between cancel time and response delay.
- **CI compatibility**: No real GitLab, no real Ollama, no real filesystem locks (in-memory DB means AppLock may need adaptation for tests — consider a test-only lock bypass or use a temp file DB for lock tests).

## Dependency Context

- **Depends on (upstream)**: bd-1i4i (the `run_sync_surgical` function under test), bd-wcja (SyncResult surgical fields to assert), bd-1lja (SyncOptions extensions), bd-3sez (surgical ingest for TOCTOU test), bd-arka (SyncRunRecorder for recorder state assertions), bd-1elx (scoped embed for isolation test), bd-kanh (per-entity helpers)
- **No downstream dependents** — this is a terminal test-only bead.
- These tests validate the behavioral contracts that all upstream beads promise. They are the acceptance gate for the surgical sync feature.


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


Add lore sync --tui convenience flag

## Background

The PRD defines two CLI entry paths to the TUI: `lore tui` (full TUI) and `lore sync --tui` (convenience shortcut that launches the TUI directly on the Sync screen in inline mode). The `lore tui` command is covered by bd-26lp. This bead adds the `--tui` flag to the existing `SyncArgs` struct, which delegates to the `lore-tui` binary with `--sync` flag.

## Approach

Two changes to the existing lore CLI crate (NOT the lore-tui crate):

1. **Add `--tui` flag to `SyncArgs`** in `src/cli/mod.rs`:
   ```rust
   /// Show sync progress in interactive TUI (inline mode)
   #[arg(long)]
   pub tui: bool,
   ```

2. **Handle the flag in sync command dispatch** in `src/main.rs` (or wherever Commands::Sync is matched):
   - If `args.tui` is true, call `resolve_tui_binary()` (from bd-26lp) and spawn it with `--sync` flag
   - Forward the config path if specified
   - Exit with the lore-tui process exit code
   - If lore-tui is not found, print a helpful error message

The `resolve_tui_binary()` function is implemented by bd-26lp (CLI integration). This bead simply adds the flag and the early-return delegation path in the sync command handler.

## Acceptance Criteria
- [ ] `lore sync --tui` is accepted by the CLI parser (no unknown flag error)
- [ ] When `--tui` is set, the sync command delegates to `lore-tui --sync` binary
- [ ] Config path is forwarded if `--config` was specified
- [ ] If lore-tui binary is not found, prints error with install instructions and exits non-zero
- [ ] `lore sync --tui --full` does NOT pass `--full` to lore-tui (TUI has its own sync controls)
- [ ] `--tui` flag appears in `lore sync --help` output

## Files
- MODIFY: src/cli/mod.rs (add `tui: bool` field to `SyncArgs` struct at line ~776)
- MODIFY: src/main.rs or src/cli/commands/sync.rs (add early-return delegation when `args.tui`)

## TDD Anchor
RED: Write `test_sync_tui_flag_accepted` that verifies `SyncArgs` can be parsed with `--tui` flag.
GREEN: Add the `tui: bool` field to SyncArgs.
VERIFY: cargo test sync_tui_flag

Additional tests:
- test_sync_tui_flag_default_false (not set by default)

## Edge Cases
- `--tui` combined with `--dry-run` — the TUI handles dry-run internally, so `--dry-run` should be ignored when `--tui` is set (or warn)
- `--tui` when lore-tui binary does not exist — clear error, not a panic
- `--tui` in robot mode (`--robot`) — nonsensical combination, should error with "cannot use --tui with --robot"

## Dependency Context
- Depends on bd-26lp (CLI integration) which implements `resolve_tui_binary()` and `validate_tui_compat()` functions that this bead calls.
- The SyncArgs struct is at src/cli/mod.rs:739. The existing fields are: full, no_full, force, no_force, no_embed, no_docs, no_events, no_file_changes, dry_run, no_dry_run.


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 TaskSupervisor (dedup + cancellation + generation IDs)

## Background
Background tasks (DB queries, sync, search) are managed by a centralized TaskSupervisor that prevents redundant work, enables cooperative cancellation, and uses generation IDs for stale-result detection. This is the ONLY allowed path for background work — state handlers return ScreenIntent, not Cmd::task directly.

## Approach
Create crates/lore-tui/src/task_supervisor.rs:
- TaskKey enum: LoadScreen(Screen), Search, SyncStream, FilterRequery(Screen) — dedup keys, NOT generation-bearing
- TaskPriority enum: Input(0), Navigation(1), Background(2)
- CancelToken: AtomicBool wrapper with cancel(), is_cancelled()
- TaskHandle struct: key (TaskKey), generation (u64), cancel (Arc<CancelToken>), interrupt (Option<InterruptHandle>)
- TaskSupervisor struct: active (HashMap<TaskKey, TaskHandle>), generation (AtomicU64)
- submit(key: TaskKey) -> TaskHandle: cancels existing task with same key (via CancelToken), increments generation, stores new handle, returns TaskHandle
- is_current(key: &TaskKey, generation: u64) -> bool: checks if generation matches active handle
- complete(key: &TaskKey, generation: u64): removes handle if generation matches
- cancel_all(): cancels all active tasks (used on quit)

## Acceptance Criteria
- [ ] submit() with existing key cancels previous task's CancelToken
- [ ] submit() returns handle with monotonically increasing generation
- [ ] is_current() returns true only for the latest generation
- [ ] complete() removes handle only if generation matches (prevents removing newer task)
- [ ] CancelToken is Arc-wrapped and thread-safe (Send+Sync)
- [ ] TaskHandle includes optional InterruptHandle for SQLite cancellation
- [ ] Generation counter never wraps during reasonable use (AtomicU64)

## Files
- CREATE: crates/lore-tui/src/task_supervisor.rs

## TDD Anchor
RED: Write test_submit_cancels_previous that submits two tasks with same key, asserts first task's CancelToken is cancelled.
GREEN: Implement submit() with cancel-on-supersede logic.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_submit_cancels

Additional tests:
- test_is_current_after_supersede: old generation returns false, new returns true
- test_complete_removes_handle: after complete, key is absent from active map
- test_complete_ignores_stale: completing with old generation doesn't remove newer task
- test_generation_monotonic: submit() always returns increasing generation values

## Edge Cases
- CancelToken uses Relaxed ordering — sufficient for cooperative cancellation polling
- Generation u64 overflow is theoretical but worth noting (would require 2^64 submissions)
- submit() must cancel old task BEFORE storing new handle to prevent race conditions
- InterruptHandle is rusqlite-specific — only set for tasks that lease a reader connection


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.


WHO: Robot JSON output for all 5 modes

## Background

Robot-mode JSON output following the standard lore envelope: `{"ok":true,"data":{...},"meta":{"elapsed_ms":N}}`. Includes both raw CLI args (input) and computed values (resolved_input) for agent reproducibility.

## Approach

### Envelope structs:
```rust
#[derive(Serialize)]
struct WhoJsonEnvelope { ok: bool, data: WhoJsonData, meta: RobotMeta }

#[derive(Serialize)]
struct WhoJsonData {
    mode: String,
    input: serde_json::Value,
    resolved_input: serde_json::Value,
    #[serde(flatten)]
    result: serde_json::Value,
}
```

### print_who_json(run, args, elapsed_ms):
- `input`: raw CLI args `{ target, path, project, since, limit }`
- `resolved_input`: `{ mode, project_id, project_path, since_ms, since_iso, since_mode, limit }`
- `result`: mode-specific JSON via *_to_json() functions using serde_json::json\!() macro

### Mode-specific JSON fields:
- **Expert**: path_query, path_match, truncated, experts[] with ISO last_seen_at
- **Workload**: username, 4 entity arrays with ref/project_path/ISO timestamps, summary{} counts, truncation{} per-section bools
- **Reviews**: username, total_diffnotes, categorized_count, mrs_reviewed, categories[] with rounded percentages
- **Active**: total_unresolved_in_window, truncated, discussions[] with discussion_id + participants + participants_total + participants_truncated
- **Overlap**: path_query, path_match, truncated, users[] with role + touch counts + mr_refs + mr_refs_total + mr_refs_truncated

### Key implementation detail — #[serde(flatten)] on result field:
The `result` field uses `#[serde(flatten)]` so mode-specific keys are merged into the top-level data object rather than nested. This means `data.experts` (not `data.result.experts`).

### Timestamps: all use ms_to_iso() for ISO 8601 format in JSON output

### Percentage rounding: Reviews categories use `(percentage * 10.0).round() / 10.0` for single decimal precision

## Files

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

## TDD Loop

No unit tests for JSON serialization — the serde_json::json\!() macro produces correct JSON by construction. Verification via manual robot mode invocation.
VERIFY: `cargo check && cargo run --release -- -J who src/features/global-search/ | python3 -m json.tool`

## Acceptance Criteria

- [ ] cargo check passes
- [ ] JSON output validates (valid JSON, no trailing content)
- [ ] input echoes raw CLI args
- [ ] resolved_input includes since_mode tri-state (default/explicit/none)
- [ ] All timestamps in ISO 8601 format
- [ ] Bounded metadata present (participants_total, mr_refs_total, truncation object)
- [ ] #[serde(flatten)] correctly merges result keys into data object

## Edge Cases

- `#[serde(flatten)]` on the result Value means mode-specific keys must not collide with mode/input/resolved_input — verified by convention (expert uses "experts", workload uses "username", etc.)
- serde_json::json\!() panics are impossible for valid Rust expressions, but verify that all row.get() values in *_to_json() handle None fields correctly (author_username in WorkloadMr is Option<String> — json\!() serializes None as null, which is correct)
- ms_to_iso() must handle 0 and very old timestamps gracefully — produces "1970-01-01T00:00:00Z" for epoch 0, which is valid
- Reviews percentage rounding: categories summing to >100% due to rounding is acceptable (display artifact) — agent consumers should not assert sum == 100
- println\!() for JSON output (not eprintln\!) — errors go to stderr, data to stdout, matching all other robot-mode commands
- If a mode returns empty results, the JSON should still be valid (empty arrays, zero counts) — serde handles this correctly

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


[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


NOTE-2E: Generate-docs full rebuild support for notes

## Background
lore generate-docs --full seeds all issues, MRs, and discussions into the dirty queue. Notes must be seeded too. The seeding happens in src/cli/commands/generate_docs.rs at lines 38-41.

## Approach
In src/cli/commands/generate_docs.rs, the run_generate_docs() function (line 25) calls seed_dirty() for each source type when full=true (lines 38-41):
   result.seeded += seed_dirty(&conn, SourceType::Issue, project_filter)?;
   result.seeded += seed_dirty(&conn, SourceType::MergeRequest, project_filter)?;
   result.seeded += seed_dirty(&conn, SourceType::Discussion, project_filter)?;

The seed_dirty() function (line 61) maps SourceType to table name and does:
   INSERT INTO dirty_sources SELECT source_type, id, now FROM {table} ...

For notes, the table name is 'notes' but we need to exclude system notes (is_system = 0). The current seed_dirty function doesn't support WHERE filters. Two options:

Option A (preferred): Add a separate seed_dirty_notes() function that handles the is_system filter:
   fn seed_dirty_notes(conn: &Connection, project_filter: Option<&str>) -> Result<usize> {
       INSERT INTO dirty_sources (source_type, source_id, queued_at)
       SELECT 'note', n.id, ?1 FROM notes n WHERE n.is_system = 0
       [AND n.project_id IN (SELECT id FROM projects WHERE path_with_namespace LIKE ?)]
       ON CONFLICT(source_type, source_id) DO UPDATE SET queued_at = excluded.queued_at, attempt_count = 0, last_attempt_at = NULL, last_error = NULL, next_attempt_at = NULL
   }

Option B: Extend seed_dirty() to accept optional WHERE clause.

Add the call in run_generate_docs() after the existing 3 seed_dirty calls:
   result.seeded += seed_dirty_notes(&conn, project_filter)?;

## Files
- MODIFY: src/cli/commands/generate_docs.rs (add seed_dirty_notes function, call it in run_generate_docs at line 41)

## TDD Anchor
RED: test_full_seed_includes_notes — setup project, 3 non-system + 1 system note, call run_generate_docs with full=true, assert 3 note entries in dirty_sources.
GREEN: Implement seed_dirty_notes and wire into run_generate_docs.
VERIFY: cargo test full_seed_includes_notes -- --nocapture
Tests: test_note_document_count_stable_after_second_generate_docs_full (second run is idempotent)

## Acceptance Criteria
- [ ] generate-docs --full seeds non-system notes into dirty queue
- [ ] System notes excluded from seeding (is_system = 0 filter)
- [ ] Project filter works (--project flag scopes to one project)
- [ ] Second full rebuild is idempotent (same document count, ON CONFLICT resets attempt counters)
- [ ] Both tests pass

## Dependency Context
- Depends on NOTE-2D (bd-2ezb): regenerator must handle SourceType::Note so that seeded dirty entries can actually be processed

## Edge Cases
- Empty database: no notes = no-op, no errors
- All system notes: no entries seeded
- project_filter with no matching notes: 0 seeded, no error


OBSERV Epic: Phase 5 - Rate Limit + Retry Instrumentation

Enhanced logging in GitLab HTTP client for rate limits and retries. Structured fields on retry/rate-limit events. StageTiming gets rate_limit_hits and retries counters.

Depends on: Phase 2 (spans for context)
Parallel with: Phase 3

Files: src/gitlab/client.rs, src/core/metrics.rs

Acceptance criteria (PRD Section 6.5):
- 429 events log at INFO with path, attempt, retry_after_secs, status_code
- Retry events log with path, attempt, error
- StageTiming includes rate_limit_hits and retries (omitted when zero)
- lore -v sync shows retry activity on stderr
- Rate limit counts included in metrics_json

All Phase 5 tasks complete: structured rate-limit logging (bd-12ae) and rate_limit_hits/retries counters in StageTiming (bd-3vqk)