Epic: Gate 5 - Code Trace (lore trace)

## Background

Gate 5 implements 'lore trace' — answers 'Why was this code introduced?' by tracing from a file path through the MR that modified it, to the issue that motivated the MR, to the discussions with decision rationale. Capstone of Phase B.

Gate 5 ships Tier 1 only (API-only, no local git). Tier 2 (git blame via git2-rs) deferred to Phase C.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Gate 5 (Sections 5.1-5.7).

## Prerequisites

- Gates 1-2 COMPLETE: entity_references populated, resource events fetched
- Gate 4 (bd-14q): provides mr_file_changes table + resolve_rename_chain algorithm
- entity_references source_method: 'api' | 'note_parse' | 'description_parse'
- discussions/notes tables for DiffNote content
- merge_requests.merged_at exists (migration 006). Use COALESCE(merged_at, updated_at) for ordering.

## Architecture

- **No new tables.** Trace queries combine mr_file_changes, entity_references, discussions/notes
- **Query flow:** file -> mr_file_changes -> MRs -> entity_references (closes/related) -> issues -> discussions with DiffNote context
- **Tier 1:** File-level granularity only. Cannot trace a specific line to its introducing commit.
- **Path parsing:** Supports 'src/foo.rs:45' syntax — line number parsed but deferred with Tier 2 warning.
- **Rename aware:** Reuses file_history::resolve_rename_chain for multi-path matching.

## Children (Execution Order)

1. **bd-2n4** — Trace query logic: file -> MR -> issue -> discussion chain (src/core/trace.rs)
2. **bd-9dd** — CLI command with human + robot output (src/cli/commands/trace.rs)

## Gate Completion Criteria

- [ ] `lore trace <file>` shows MRs with linked issues + discussion context
- [ ] Output includes MR -> issue -> discussion chain
- [ ] DiffNote snippets show content on the traced file
- [ ] Cross-references from entity_references used for MR->issue linking
- [ ] :line suffix parses and emits Tier 2 warning
- [ ] Robot mode JSON with tier: 'api_only'
- [ ] Graceful handling when no MR data found (suggest sync with fetchMrFileChanges)


Integrate mark_dirty_tx into ingestion modules

## Background
This bead integrates dirty source tracking into the existing ingestion pipelines. Every entity upserted during ingestion must be marked dirty so the document regenerator knows to update the corresponding search document. The critical constraint: mark_dirty_tx() must be called INSIDE the same transaction that upserts the entity — not after commit.

**Key PRD clarification:** Mark ALL upserted entities dirty (not just changed ones). The regenerator's hash comparison handles "unchanged" detection cheaply — this avoids needing change detection in ingestion.

## Approach
Modify 4 existing ingestion files to add mark_dirty_tx() calls inside existing transaction blocks per PRD Section 6.1.

**1. src/ingestion/issues.rs:**
Inside the issue upsert loop, after each successful INSERT/UPDATE:
```rust
dirty_tracker::mark_dirty_tx(&tx, SourceType::Issue, issue_row.id)?;
```

**2. src/ingestion/merge_requests.rs:**
Inside the MR upsert loop:
```rust
dirty_tracker::mark_dirty_tx(&tx, SourceType::MergeRequest, mr_row.id)?;
```

**3. src/ingestion/discussions.rs:**
Inside discussion insert (issue discussions, full-refresh transaction):
```rust
dirty_tracker::mark_dirty_tx(&tx, SourceType::Discussion, discussion_row.id)?;
```

**4. src/ingestion/mr_discussions.rs:**
Inside discussion upsert (write phase):
```rust
dirty_tracker::mark_dirty_tx(&tx, SourceType::Discussion, discussion_row.id)?;
```

**Discussion Sweep Cleanup (PRD Section 6.1 — CRITICAL):**
When the MR discussion sweep deletes stale discussions (`last_seen_at < run_start_time`), **delete the corresponding document rows directly** — do NOT use the dirty queue for cleanup. The `ON DELETE CASCADE` on `document_labels`/`document_paths` and the `documents_embeddings_ad` trigger handle all downstream cleanup.

**PRD-exact CTE pattern:**
```sql
-- In src/ingestion/mr_discussions.rs, during sweep phase.
-- Uses a CTE to capture stale IDs atomically before cascading deletes.
-- This is more defensive than two separate statements because the CTE
-- guarantees the ID set is captured before any row is deleted.
WITH stale AS (
  SELECT id FROM discussions
  WHERE merge_request_id = ? AND last_seen_at < ?
)
-- Step 1: delete orphaned documents (must happen while source_id still resolves)
DELETE FROM documents
  WHERE source_type = 'discussion' AND source_id IN (SELECT id FROM stale);
-- Step 2: delete the stale discussions themselves
DELETE FROM discussions
  WHERE id IN (SELECT id FROM stale);
```

**NOTE:** If SQLite version doesn't support CTE-based multi-statement, execute as two sequential statements capturing IDs in Rust first:
```rust
let stale_ids: Vec<i64> = conn.prepare(
    "SELECT id FROM discussions WHERE merge_request_id = ? AND last_seen_at < ?"
)?.query_map(params![mr_id, run_start], |r| r.get(0))?
  .collect::<Result<Vec<_>, _>>()?;

if !stale_ids.is_empty() {
    // Delete documents FIRST (while source_id still resolves)
    conn.execute(
        "DELETE FROM documents WHERE source_type = 'discussion' AND source_id IN (...)",
        ...
    )?;
    // Then delete the discussions
    conn.execute(
        "DELETE FROM discussions WHERE id IN (...)",
        ...
    )?;
}
```

**IMPORTANT difference from dirty queue pattern:** The sweep deletes documents DIRECTLY (not via dirty_sources queue). This is because the source entity is being deleted — there's nothing for the regenerator to regenerate from. The cascade handles FTS, labels, paths, and embeddings cleanup.

## Acceptance Criteria
- [ ] Every upserted issue is marked dirty inside the same transaction
- [ ] Every upserted MR is marked dirty inside the same transaction
- [ ] Every upserted discussion (issue + MR) is marked dirty inside the same transaction
- [ ] ALL upserted entities marked dirty (not just changed ones) — regenerator handles skip
- [ ] mark_dirty_tx called with &Transaction (not &Connection)
- [ ] mark_dirty_tx uses upsert with ON CONFLICT to reset backoff state (not INSERT OR IGNORE)
- [ ] Discussion sweep deletes documents DIRECTLY (not via dirty queue)
- [ ] Discussion sweep uses CTE (or Rust-side ID capture) to capture stale IDs before cascading deletes
- [ ] Documents deleted BEFORE discussions (while source_id still resolves)
- [ ] ON DELETE CASCADE handles document_labels, document_paths cleanup
- [ ] documents_embeddings_ad trigger handles embedding cleanup
- [ ] `cargo build` succeeds
- [ ] Existing ingestion tests still pass

## Files
- `src/ingestion/issues.rs` — add mark_dirty_tx calls in upsert loop
- `src/ingestion/merge_requests.rs` — add mark_dirty_tx calls in upsert loop
- `src/ingestion/discussions.rs` — add mark_dirty_tx calls in insert loop
- `src/ingestion/mr_discussions.rs` — add mark_dirty_tx calls + direct document deletion in sweep

## TDD Loop
RED: Existing tests should still pass (regression); new tests:
- `test_issue_upsert_marks_dirty` — after issue ingest, dirty_sources has entry
- `test_mr_upsert_marks_dirty` — after MR ingest, dirty_sources has entry
- `test_discussion_upsert_marks_dirty` — after discussion ingest, dirty_sources has entry
- `test_discussion_sweep_deletes_documents` — stale discussion documents deleted directly
- `test_sweep_cascade_cleans_labels_paths` — ON DELETE CASCADE works
GREEN: Add mark_dirty_tx calls in all 4 files, implement sweep with CTE
VERIFY: `cargo test ingestion && cargo build`

## Edge Cases
- Upsert that doesn't change data: still marks dirty (regenerator hash check handles skip)
- Transaction rollback: dirty mark also rolled back (atomic, inside same txn)
- Discussion sweep with zero stale IDs: CTE returns empty, no DELETE executed
- Large batch of upserts: each mark_dirty_tx is O(1) INSERT with ON CONFLICT
- Sweep deletes document before discussion: order matters for source_id resolution

Added mark_dirty_tx calls in issues.rs, merge_requests.rs, discussions.rs, mr_discussions.rs (2 paths)


Integration test: full Phase B sync pipeline

## Background

This integration test proves the full Phase B sync pipeline works end-to-end. Since Gates 1 and 2 are already implemented and closed, this test validates that the complete pipeline — including Gate 4 mr_diffs draining — works together.

## Codebase Context

- **Gates 1-2 FULLY IMPLEMENTED (CLOSED):** resource events fetch, closes_issues API, system note parsing (note_parser.rs), entity_references extraction (references.rs)
- **Gate 4 in progress:** migration 015 (mr_file_changes), fetch_mr_diffs, drain_mr_diffs — this test validates the full chain
- Migrations 001-014 exist. Migration 015 (bd-1oo) adds mr_file_changes + commit SHAs.
- Orchestrator has drain_resource_events() and drain_mr_closes_issues(). Gate 4 adds drain_mr_diffs().
- wiremock crate used in existing tests (check dev-dependencies in Cargo.toml)
- src/core/dependent_queue.rs: enqueue_job(), claim_jobs(), complete_job(), fail_job() with exponential backoff
- IngestProjectResult and IngestMrProjectResult track counts for all drain phases

## Approach

Create tests/phase_b_integration.rs:

### Test Setup

1. In-memory SQLite DB with all migrations (001-015)
2. wiremock mock server with:
   - /api/v4/projects/:id/issues — 2 test issues
   - /api/v4/projects/:id/merge_requests — 1 test MR
   - /api/v4/projects/:id/issues/:iid/resource_state_events — state events
   - /api/v4/projects/:id/issues/:iid/resource_label_events — label events
   - /api/v4/projects/:id/merge_requests/:iid/resource_state_events — merge event with source_merge_request_iid
   - /api/v4/projects/:id/merge_requests/:iid/closes_issues — linked issues
   - /api/v4/projects/:id/merge_requests/:iid/diffs — file changes
   - /api/v4/projects/:id/issues/:iid/discussions — discussion with system note "mentioned in !1"
3. Config with fetch_resource_events=true and fetch_mr_file_changes=true (bd-jec)
4. Use dependent_concurrency=1 to avoid timing issues

### Test Flow

```rust
#[tokio::test]
async fn test_full_phase_b_pipeline() {
    // 1. Set up mock server + DB with migrations 001-015
    // 2. Run ingest issues + MRs (orchestrator functions)
    // 3. Verify pending_dependent_fetches enqueued: resource_events, mr_closes_issues, mr_diffs
    // 4. Drain all dependent fetch queues
    // 5. Assert: resource_state_events populated (count > 0)
    // 6. Assert: resource_label_events populated (count > 0)
    // 7. Assert: entity_references has closes ref with source_method='api'
    // 8. Assert: entity_references has mentioned ref with source_method='note_parse'
    // 9. Assert: mr_file_changes populated from diffs API
    // 10. Assert: pending_dependent_fetches fully drained (no stuck locks)
}
```

### Assertions (SQL)

```sql
SELECT COUNT(*) FROM resource_state_events  -- > 0
SELECT COUNT(*) FROM resource_label_events  -- > 0
SELECT COUNT(*) FROM entity_references WHERE reference_type = 'closes' AND source_method = 'api'  -- >= 1
SELECT COUNT(*) FROM entity_references WHERE source_method = 'note_parse'  -- >= 1
SELECT COUNT(*) FROM mr_file_changes  -- > 0
SELECT COUNT(*) FROM pending_dependent_fetches WHERE locked_at IS NOT NULL  -- = 0
```

## Acceptance Criteria

- [ ] Test creates DB with migrations 001-015, mocks, and runs full pipeline
- [ ] resource_state_events and resource_label_events populated
- [ ] entity_references has closes ref (source_method='api') and mentioned ref (source_method='note_parse')
- [ ] mr_file_changes populated from diffs mock
- [ ] pending_dependent_fetches fully drained (no stuck locks, no retryable jobs)
- [ ] Test runs in < 10 seconds
- [ ] `cargo test --test phase_b_integration` passes

## Files

- tests/phase_b_integration.rs (NEW)

## TDD Loop

RED: Write test with all assertions — may fail if Gate 4 draining not yet wired.

GREEN: Fix pipeline wiring (drain_mr_diffs in orchestrator).

VERIFY: cargo test --test phase_b_integration -- --nocapture

## Edge Cases

- Paginated mock responses: include Link header for multi-page responses
- Empty pages: verify graceful handling
- Use dependent_concurrency=1 to avoid timing issues in test environment
- Stale lock reclaim: test that locks older than stale_lock_minutes are reclaimed


Verification: quality gates, query plan check, real-world validation

## Background

Post-implementation verification checkpoint. Runs after all code beads complete to validate the full scoring model works correctly against real data, not just test fixtures.

## Approach

Execute 8 verification steps in order. Each step has a binary pass/fail outcome.

### Step 1: Compiler check
```bash
cargo check --all-targets
```
Pass: exit 0

### Step 2: Clippy
```bash
cargo clippy --all-targets -- -D warnings
```
Pass: exit 0

### Step 3: Formatting
```bash
cargo fmt --check
```
Pass: exit 0

### Step 4: Test suite
```bash
cargo test -p lore
```
Pass: all tests green, including 27+ new decay/scoring tests

### Step 5: UBS scan
```bash
ubs src/cli/commands/who.rs src/core/config.rs src/core/db.rs
```
Pass: exit 0

### Step 6: Query plan verification (manual)
Run against real database:
```bash
cargo run --release -- who --path MeasurementQualityDialog.tsx -vvv 2>&1 | grep -i "query plan"
```
Or use sqlite3 CLI with EXPLAIN QUERY PLAN on the expert SQL (both exact and prefix modes).

Pass criteria:
- matched_notes_raw branch 1 uses new_path index
- matched_notes_raw branch 2 uses idx_notes_old_path_author
- matched_file_changes_raw uses idx_mfc_new_path_project_mr and idx_mfc_old_path_project_mr
- reviewer_participation uses idx_notes_diffnote_discussion_author
- Document observed plan as SQL comment near the CTE

### Step 7: Performance baseline (manual)
```bash
time cargo run --release -- who --path MeasurementQualityDialog.tsx
time cargo run --release -- who --path src/
time cargo run --release -- who --path Dialog.tsx
```
Pass criteria (soft SLOs):
- Exact path: p95 < 200ms
- Prefix: p95 < 300ms
- Suffix: p95 < 500ms
Record timings as SQL comment for future regression reference.

### Step 8: Real-world validation
```bash
cargo run --release -- who --path MeasurementQualityDialog.tsx
cargo run --release -- who --path MeasurementQualityDialog.tsx --explain-score
cargo run --release -- who --path MeasurementQualityDialog.tsx --as-of 2025-06-01
cargo run --release -- who --path MeasurementQualityDialog.tsx --all-history
```
Pass criteria:
- [ ] Recency discounting visible (recent authors rank above old reviewers)
- [ ] --explain-score components sum to total (within f64 tolerance)
- [ ] --as-of produces identical results on repeated runs
- [ ] Assigned-only reviewers rank below participated reviewers on same MR
- [ ] Known renamed file path resolves and credits old expertise
- [ ] LGTM-only reviewers classified as assigned-only
- [ ] Closed MRs at ~50% contribution visible via --explain-score

## Acceptance Criteria
- [ ] Steps 1-5 pass (exit 0)
- [ ] Step 6: query plan documented with index usage confirmed
- [ ] Step 7: timing baselines recorded
- [ ] Step 8: all 7 real-world checks pass

## Files
- All files modified by child beads (read-only verification)
- Add SQL comments near CTE with observed EXPLAIN QUERY PLAN output

## Edge Cases
- SQLite planner may choose different plans across versions — document version
- Timing varies by hardware — record machine specs alongside baselines
- Real DB may have NULL merged_at on old MRs — state-aware fallback handles this


Implement pending discussion queue

## Background
The pending discussion queue tracks discussions that need to be fetched from GitLab. When an issue or MR is updated, its discussions may need re-fetching. This queue is separate from dirty_sources (which tracks entities needing document regeneration) — it tracks entities needing API calls to GitLab. The queue uses the same backoff pattern as dirty_sources for consistency.

## Approach
Create `src/ingestion/discussion_queue.rs`:

```rust
use crate::core::backoff::compute_next_attempt_at;

/// Noteable type for discussion queue.
#[derive(Debug, Clone, Copy)]
pub enum NoteableType {
    Issue,
    MergeRequest,
}

impl NoteableType {
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Issue => "Issue",
            Self::MergeRequest => "MergeRequest",
        }
    }
}

pub struct PendingFetch {
    pub project_id: i64,
    pub noteable_type: NoteableType,
    pub noteable_iid: i64,
    pub attempt_count: i32,
}

/// Queue a discussion fetch. ON CONFLICT DO UPDATE resets backoff (consistent with dirty_sources).
pub fn queue_discussion_fetch(
    conn: &Connection,
    project_id: i64,
    noteable_type: NoteableType,
    noteable_iid: i64,
) -> Result<()>;

/// Get next batch of pending fetches (WHERE next_attempt_at IS NULL OR <= now).
pub fn get_pending_fetches(conn: &Connection, limit: usize) -> Result<Vec<PendingFetch>>;

/// Mark fetch complete (remove from queue).
pub fn complete_fetch(
    conn: &Connection,
    project_id: i64,
    noteable_type: NoteableType,
    noteable_iid: i64,
) -> Result<()>;

/// Record fetch error with backoff.
pub fn record_fetch_error(
    conn: &Connection,
    project_id: i64,
    noteable_type: NoteableType,
    noteable_iid: i64,
    error: &str,
) -> Result<()>;
```

## Acceptance Criteria
- [ ] queue_discussion_fetch uses ON CONFLICT DO UPDATE (consistent with dirty_sources pattern)
- [ ] Re-queuing resets: attempt_count=0, next_attempt_at=NULL, last_error=NULL
- [ ] get_pending_fetches respects next_attempt_at backoff
- [ ] get_pending_fetches returns entries ordered by queued_at ASC
- [ ] complete_fetch removes entry from queue
- [ ] record_fetch_error increments attempt_count, computes next_attempt_at via shared backoff
- [ ] NoteableType.as_str() returns "Issue" or "MergeRequest" (matches DB CHECK constraint)
- [ ] `cargo test discussion_queue` passes

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

## TDD Loop
RED: Tests in `#[cfg(test)] mod tests`:
- `test_queue_and_get` — queue entry, get returns it
- `test_requeue_resets_backoff` — queue, error, re-queue -> attempt_count=0
- `test_backoff_respected` — entry with future next_attempt_at not returned
- `test_complete_removes` — complete_fetch removes entry
- `test_error_increments_attempts` — error -> attempt_count=1, next_attempt_at set
GREEN: Implement all functions
VERIFY: `cargo test discussion_queue`

## Edge Cases
- Queue same (project_id, noteable_type, noteable_iid) twice: ON CONFLICT resets state
- NoteableType must match DB CHECK constraint exactly ("Issue", "MergeRequest" — capitalized)
- Empty queue: get_pending_fetches returns empty Vec

Implemented discussion_queue with queue/get/complete/record_error + 6 tests


Parse system notes for cross-reference patterns

## Background
System notes contain cross-reference patterns like 'mentioned in !{iid}', 'closed by !{iid}', etc. This is best-effort, English-only extraction that supplements the structured API data from bd-3ia and bd-8t4. Runs as a local post-processing step (no API calls).

## Approach
Create src/core/note_parser.rs:

```rust
use regex::Regex;
use lazy_static::lazy_static;

/// A parsed cross-reference from a system note.
pub struct ParsedCrossRef {
    pub reference_type: String,    // "mentioned" | "closes"
    pub target_entity_type: String, // "issue" | "merge_request"
    pub target_iid: i64,
    pub target_project_path: Option<String>,  // None = same project
}

lazy_static! {
    static ref MENTIONED_RE: Regex = Regex::new(
        r"mentioned in (?:(?P<project>[\w\-]+/[\w\-]+))?(?P<sigil>[#!])(?P<iid>\d+)"
    ).unwrap();
    static ref CLOSED_BY_RE: Regex = Regex::new(
        r"closed by (?:(?P<project>[\w\-]+/[\w\-]+))?(?P<sigil>[#!])(?P<iid>\d+)"
    ).unwrap();
}

/// Parse a system note body for cross-references.
pub fn parse_cross_refs(body: &str) -> Vec<ParsedCrossRef>

/// Extract cross-references from all system notes and insert into entity_references.
/// Queries notes WHERE is_system = 1, parses body text, resolves to entity_references.
pub fn extract_refs_from_system_notes(
    conn: &Connection,
    project_id: i64,
) -> Result<ExtractResult>

pub struct ExtractResult {
    pub inserted: usize,
    pub skipped_unresolvable: usize,
    pub parse_failures: usize,   // logged at debug level
}
```

Sigil mapping: `#` = issue, `!` = merge_request

Resolution logic:
1. If target_project_path is None (same project): look up entity by iid in local DB → set target_entity_id
2. If target_project_path is Some: check if project is synced locally
   - If yes: resolve to local entity id
   - If no: store as unresolved (target_entity_id=NULL, target_project_path=path, target_entity_iid=iid)

Insert with source_method='system_note_parse', INSERT OR IGNORE for dedup.

Call after drain_dependent_queue and extract_refs_from_state_events in the sync pipeline.

## Acceptance Criteria
- [ ] 'mentioned in !123' → mentioned ref, target=MR iid 123
- [ ] 'mentioned in #456' → mentioned ref, target=issue iid 456
- [ ] 'mentioned in group/project!789' → cross-project mentioned ref
- [ ] 'closed by !123' → closes ref
- [ ] Cross-project refs stored as unresolved when target project not synced
- [ ] source_method = 'system_note_parse'
- [ ] Parse failures logged at debug level (not errors)
- [ ] Idempotent (INSERT OR IGNORE)
- [ ] Only processes is_system=1 notes

## Files
- src/core/note_parser.rs (new)
- src/core/mod.rs (add `pub mod note_parser;`)
- src/cli/commands/sync.rs (call after other ref extraction steps)

## TDD Loop
RED: tests/note_parser_tests.rs:
- `test_parse_mentioned_in_mr` - "mentioned in !567" → ParsedCrossRef { mentioned, merge_request, 567 }
- `test_parse_mentioned_in_issue` - "mentioned in #234" → ParsedCrossRef { mentioned, issue, 234 }
- `test_parse_mentioned_cross_project` - "mentioned in group/repo!789" → with project path
- `test_parse_closed_by_mr` - "closed by !567" → ParsedCrossRef { closes, merge_request, 567 }
- `test_parse_multiple_refs` - note with two mentions → two refs
- `test_parse_no_refs` - "Updated the description" → empty vec
- `test_extract_refs_from_system_notes_integration` - seed DB with system notes, verify entity_references created

GREEN: Implement regex patterns and extraction logic

VERIFY: `cargo test note_parser -- --nocapture`

## Edge Cases
- Non-English GitLab instances: "ajouté l'étiquette ~bug" won't match — this is accepted limitation, logged at debug
- Multi-level group paths: "mentioned in top/sub/project#123" — regex needs to handle arbitrary depth ([\w\-]+(?:/[\w\-]+)+)
- Note body may contain markdown links that look like refs: "[#123](url)" — the regex should handle this correctly since the prefix "mentioned in" is required
- Same ref mentioned multiple times in same note — dedup via INSERT OR IGNORE
- Note may reference itself (e.g., system note on issue #123 says "mentioned in #123") — technically valid, store it

Completed: parse_cross_refs regex parser, extract_refs_from_system_notes DB function, wired into orchestrator. 17 tests passing.


Implement FTS5 search function and query sanitization

## Background
FTS5 search is the core lexical retrieval engine. It wraps SQLite's FTS5 with safe query parsing that prevents user input from causing SQL syntax errors, while preserving useful features like prefix search for type-ahead. The search function returns ranked results with BM25 scores and contextual snippets. This module is the Gate A search backbone and also provides fallback search when Ollama is unavailable in Gate B.

## Approach
Create `src/search/` module with `mod.rs` and `fts.rs` per PRD Section 3.1-3.2.

**src/search/mod.rs:**
```rust
mod fts;
mod filters;
// Later beads add: mod vector; mod hybrid; mod rrf;
pub use fts::{search_fts, to_fts_query, FtsResult, FtsQueryMode, generate_fallback_snippet, get_result_snippet};
```

**src/search/fts.rs — key functions:**

1. `to_fts_query(raw: &str, mode: FtsQueryMode) -> String`
   - Safe mode: wrap each token in quotes, escape internal quotes, preserve trailing * on alphanumeric tokens
   - Raw mode: pass through unchanged

2. `search_fts(conn: &Connection, query: &str, limit: usize, mode: FtsQueryMode) -> Result<Vec<FtsResult>>`
   - Uses `bm25(documents_fts)` for ranking
   - Uses `snippet(documents_fts, 1, '<mark>', '</mark>', '...', 64)` for context
   - Column index 1 = content_text (0=title)

3. `generate_fallback_snippet(content_text: &str, max_chars: usize) -> String`
   - For semantic-only results without FTS snippets
   - Uses `truncate_utf8()` for safe byte boundaries

4. `truncate_utf8(s: &str, max_bytes: usize) -> &str`
   - Walks backward from max_bytes to find nearest char boundary

5. `get_result_snippet(fts_snippet: Option<&str>, content_text: &str) -> String`
   - Prefers FTS snippet, falls back to truncated content

Update `src/lib.rs`: add `pub mod search;`

## Acceptance Criteria
- [ ] Porter stemming works: search "searching" matches document containing "search"
- [ ] Prefix search works: `auth*` matches "authentication"
- [ ] Empty query returns empty Vec (no error)
- [ ] Special characters don't cause FTS5 errors: `-`, `"`, `:`, `*`
- [ ] Query `"-DWITH_SSL"` returns results (dash not treated as NOT operator)
- [ ] Query `C++` returns results (special chars preserved in quotes)
- [ ] Safe mode preserves trailing `*` on alphanumeric tokens: `auth*` -> `"auth"*`
- [ ] Raw mode passes query unchanged
- [ ] BM25 scores returned (lower = better match)
- [ ] Snippets contain `<mark>` tags around matches
- [ ] `generate_fallback_snippet` truncates at word boundary, appends "..."
- [ ] `truncate_utf8` never panics on multi-byte codepoints
- [ ] `cargo test fts` passes

## Files
- `src/search/mod.rs` — new file (module root)
- `src/search/fts.rs` — new file (FTS5 search + query sanitization)
- `src/lib.rs` — add `pub mod search;`

## TDD Loop
RED: Tests in `fts.rs` `#[cfg(test)] mod tests`:
- `test_safe_query_basic` — "auth error" -> `"auth" "error"`
- `test_safe_query_prefix` — "auth*" -> `"auth"*`
- `test_safe_query_special_chars` — "C++" -> `"C++"`
- `test_safe_query_dash` — "-DWITH_SSL" -> `"-DWITH_SSL"`
- `test_safe_query_quotes` — `he said "hello"` -> escaped
- `test_raw_mode_passthrough` — raw query unchanged
- `test_empty_query` — returns empty vec
- `test_truncate_utf8_emoji` — truncate mid-emoji walks back
- `test_fallback_snippet_word_boundary` — truncates at space
GREEN: Implement to_fts_query, search_fts, helpers
VERIFY: `cargo test fts`

## Edge Cases
- Query with only whitespace: treated as empty, returns empty
- Query with only special characters: quoted, may return no results (not an error)
- Very long query (1000+ chars): works but may be slow (no explicit limit)
- FTS5 snippet returns empty string: fallback to truncated content_text
- Non-alphanumeric prefix: `C++*` — NOT treated as prefix (special chars present)

Completed: to_fts_query (safe/raw modes), search_fts with BM25+snippets, generate_fallback_snippet, get_result_snippet, truncate_utf8 reuse, 13 tests pass


OBSERV: Add get_log_dir() helper to paths module

## Background
A centralized helper for the log directory path ensures consistent XDG compliance and directory creation. The existing get_data_dir() (src/core/paths.rs:40-43) returns ~/.local/share/lore/. We add a sibling that appends /logs/.

## Approach
Add to src/core/paths.rs, after get_db_path() (around line 53):

```rust
/// Get the log directory path. Creates the directory if it doesn't exist.
pub fn get_log_dir(config_override: Option<&str>) -> PathBuf {
    let dir = if let Some(path) = config_override {
        PathBuf::from(path)
    } else {
        get_data_dir().join("logs")
    };
    std::fs::create_dir_all(&dir).ok();
    dir
}
```

The config_override comes from LoggingConfig.log_dir (bd-17n). When None, uses XDG default.

Existing pattern to follow (src/core/paths.rs:40-53):
- get_data_dir() -> PathBuf (returns ~/.local/share/lore/)
- get_db_path(config_override: Option<&str>) -> PathBuf

## Acceptance Criteria
- [ ] get_log_dir(None) returns ~/.local/share/lore/logs/
- [ ] get_log_dir(Some("/tmp/custom")) returns /tmp/custom
- [ ] Directory is created if it doesn't exist
- [ ] Function is pub and accessible from other modules
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/core/paths.rs (add get_log_dir function after line ~53)

## TDD Loop
RED: test_get_log_dir_default, test_get_log_dir_override (use tempdir)
GREEN: Add get_log_dir() function
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- create_dir_all failure (e.g., permissions): .ok() swallows error silently. This matches get_db_path() which also doesn't create dirs. Consider: should we propagate the error? The subscriber init will fail anyway if the dir doesn't exist, providing a clear error.
- Trailing slash: PathBuf handles this correctly

Added get_log_dir() helper mirroring get_db_path/get_backup_dir pattern


[CP0] Raw payload handling - compression and deduplication

## Background

Raw payload storage allows replaying API responses for debugging and audit. Compression reduces storage for large payloads. SHA-256 deduplication prevents storing identical payloads multiple times (important for frequently polled resources that haven't changed).

Reference: docs/prd/checkpoint-0.md section "Raw Payload Handling"

## Approach

**src/core/payloads.ts:**
```typescript
import { createHash } from 'node:crypto';
import { gzipSync, gunzipSync } from 'node:zlib';
import Database from 'better-sqlite3';
import { nowMs } from './time';

interface StorePayloadOptions {
  projectId: number | null;
  resourceType: string;  // 'project' | 'issue' | 'mr' | 'note' | 'discussion'
  gitlabId: string;      // TEXT because discussion IDs are strings
  payload: unknown;      // JSON-serializable object
  compress: boolean;     // from config.storage.compressRawPayloads
}

export function storePayload(db: Database.Database, options: StorePayloadOptions): number | null {
  // 1. JSON.stringify the payload
  // 2. SHA-256 hash the JSON bytes
  // 3. Check for duplicate by (project_id, resource_type, gitlab_id, payload_hash)
  // 4. If duplicate, return existing ID
  // 5. If compress=true, gzip the JSON bytes
  // 6. INSERT with content_encoding='gzip' or 'identity'
  // 7. Return lastInsertRowid
}

export function readPayload(db: Database.Database, id: number): unknown {
  // 1. SELECT content_encoding, payload FROM raw_payloads WHERE id = ?
  // 2. If gzip, decompress
  // 3. JSON.parse and return
}
```

## Acceptance Criteria

- [ ] storePayload() with compress=true stores gzip-encoded payload
- [ ] storePayload() with compress=false stores identity-encoded payload
- [ ] Duplicate payload (same hash) returns existing row ID, not new row
- [ ] readPayload() correctly decompresses gzip payloads
- [ ] readPayload() returns null for non-existent ID
- [ ] SHA-256 hash computed from pre-compression JSON bytes
- [ ] Large payloads (100KB+) compress to ~10-20% of original size

## Files

CREATE:
- src/core/payloads.ts
- tests/unit/payloads.test.ts

## TDD Loop

RED:
```typescript
// tests/unit/payloads.test.ts
describe('Payload Storage', () => {
  describe('storePayload', () => {
    it('stores uncompressed payload with identity encoding')
    it('stores compressed payload with gzip encoding')
    it('deduplicates identical payloads by hash')
    it('stores different payloads for same gitlab_id')
  })

  describe('readPayload', () => {
    it('reads uncompressed payload')
    it('reads and decompresses gzip payload')
    it('returns null for non-existent id')
  })
})
```

GREEN: Implement storePayload() and readPayload()

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

## Edge Cases

- gitlabId is TEXT not INTEGER - discussion IDs are UUIDs
- Compression ratio varies - some JSON compresses better than others
- null projectId valid for global resources (like user profile)
- Hash collision extremely unlikely with SHA-256 but unique index enforces


Wire up hybrid search: FTS5 + vector + RRF ranking

## Problem
lore search hardcodes lexical-only mode. The full hybrid/vector/RRF backend is ALREADY IMPLEMENTED and tested -- it just needs to be called from the CLI.

## Current State (Verified 2026-02-12)

### Backend: COMPLETE
- `search_hybrid()` in src/search/hybrid.rs:47 — async fn, handles Lexical/Semantic/Hybrid modes with graceful degradation
- `search_vector()` in src/search/vector.rs:43 — sqlite-vec KNN with chunk deduplication and adaptive k multiplier
- `rank_rrf()` in src/search/rrf.rs:13 — reciprocal rank fusion with normalization (7 passing tests)
- `SearchMode::parse()` — parses hybrid, lexical/fts, semantic/vector
- `OllamaClient::embed_batch()` in src/embedding/ollama.rs:103 — batch embedding via Ollama /api/embed endpoint
- All exported from src/search/mod.rs:7-14

### CLI: BROKEN
- src/cli/commands/search.rs:61 `run_search()` is SYNCHRONOUS (not async)
- Line 76: `let actual_mode = "lexical";` — hardcoded
- Lines 77-82: warns if user requests vector/hybrid, falls back to lexical
- Line 161: calls `search_fts()` directly instead of `search_hybrid()`
- Line 172: calls `rank_rrf(&[], &fts_tuples)` — empty vector list, FTS-only ranking
- Lines 143-152: manually constructs `SearchFilters` (this code is reusable)
- Lines 187-223: hydrates + maps to `SearchResultDisplay` (this can be adapted)

### Entry Point
- src/main.rs:1731 `async fn handle_search()` — IS async, but calls `run_search()` synchronously at line 1758
- main.rs is 2579 lines total

## Actual Work Required

### Step 1: Make run_search async
Change `pub fn run_search(...)` to `pub async fn run_search(...)` in search.rs:61.
Update handle_search call site (main.rs:1758) to `.await`.

### Step 2: Create OllamaClient when mode != lexical
Pattern from src/cli/commands/embed.rs — reuse `OllamaConfig` from config:
```rust
let client = if actual_mode != SearchMode::Lexical {
    let ollama_cfg = &config.embedding;
    Some(OllamaClient::new(&ollama_cfg.ollama_url, &ollama_cfg.model))
} else {
    None
};
```

### Step 3: Replace manual FTS+filter+rank with search_hybrid call
Replace lines 161-172 (search_fts + rank_rrf) with:
```rust
let (hybrid_results, mut hybrid_warnings) = search_hybrid(
    &conn,
    client.as_ref(),
    query,
    actual_mode,
    &filters,
    fts_mode,
).await?;
warnings.append(&mut hybrid_warnings);
```

### Step 4: Map HybridResult to SearchResultDisplay
HybridResult already has: document_id, rrf_score, normalized_score, vector_rank, fts_rank, snippet.
SearchResultDisplay needs: document_id, source_type, title, url, author, etc. (from hydration).
Keep the existing hydrate_results() call (line 187) and rrf_map construction (lines 189-190), just feed it HybridResult data instead of RrfResult.

### Step 5: Determine actual_mode from config + CLI flag
```rust
let actual_mode = SearchMode::parse(requested_mode).unwrap_or(SearchMode::Hybrid);
// search_hybrid handles graceful degradation internally
```

## Signatures for Reference

```rust
// src/search/hybrid.rs:47
pub async fn search_hybrid(
    conn: &Connection,
    client: Option<&OllamaClient>,
    query: &str,
    mode: SearchMode,
    filters: &SearchFilters,
    fts_mode: FtsQueryMode,
) -> Result<(Vec<HybridResult>, Vec<String>)>

// src/search/mod.rs exports
pub use hybrid::{HybridResult, SearchMode, search_hybrid};
pub use rrf::{RrfResult, rank_rrf};
pub use vector::{VectorResult, search_vector};

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

## TDD Loop
RED: Add test in src/search/hybrid.rs:
- test_hybrid_lexical_fallback_no_ollama: search_hybrid with mode=Hybrid, client=None returns FTS results + warning
- test_hybrid_mode_detection: verify default mode is Hybrid when embeddings exist

GREEN: Wire search.rs to call search_hybrid() as described above

VERIFY:
```bash
cargo test search:: && cargo clippy --all-targets -- -D warnings
cargo run --release -- -J search 'throw time' --mode hybrid --explain | jq '.data.mode'
# Should return "hybrid" (or "lexical" with warning if Ollama is down)
```

## Edge Cases
- Ollama running but model not found: clear error with suggestion to run `ollama pull nomic-embed-text`
- No embeddings in DB (never ran lore embed): search_vector returns empty, RRF uses FTS only — search_hybrid handles this gracefully
- Query embedding returns all zeros: should still return FTS results
- Very long query string (>1500 bytes): chunk or truncate before embedding (CHUNK_MAX_BYTES=1500)
- sqlite-vec table missing (old DB without migration 009): graceful error from search_vector
- OllamaConfig missing from config: check `config.embedding` exists before constructing client

## Files to Modify
- src/cli/commands/search.rs — make run_search async, replace manual FTS+RRF with search_hybrid call (~80 lines replaced with ~20)
- src/main.rs:1758 — add .await to run_search call (already in async context)

## Files NOT to Modify (already complete)
- src/search/hybrid.rs
- src/search/vector.rs
- src/search/rrf.rs
- src/embedding/ollama.rs


[CP0] GitLab API client with rate limiting

## Background

The GitLab client handles all API communication with rate limiting to avoid 429 errors. Uses native fetch (Node 18+). Rate limiter adds jitter to prevent thundering herd. All errors are typed for clean error handling in CLI commands.

Reference: docs/prd/checkpoint-0.md section "GitLab Client"

## Approach

**src/gitlab/client.ts:**
```typescript
export class GitLabClient {
  private baseUrl: string;
  private token: string;
  private rateLimiter: RateLimiter;

  constructor(options: { baseUrl: string; token: string; requestsPerSecond?: number }) {
    this.baseUrl = options.baseUrl.replace(/\/$/, '');
    this.token = options.token;
    this.rateLimiter = new RateLimiter(options.requestsPerSecond ?? 10);
  }

  async getCurrentUser(): Promise<GitLabUser>
  async getProject(pathWithNamespace: string): Promise<GitLabProject>
  private async request<T>(path: string, options?: RequestInit): Promise<T>
}

class RateLimiter {
  private lastRequest = 0;
  private minInterval: number;

  constructor(requestsPerSecond: number) {
    this.minInterval = 1000 / requestsPerSecond;
  }

  async acquire(): Promise<void> {
    // Wait if too soon since last request
    // Add 0-50ms jitter
  }
}
```

**src/gitlab/types.ts:**
```typescript
export interface GitLabUser {
  id: number;
  username: string;
  name: string;
}

export interface GitLabProject {
  id: number;
  path_with_namespace: string;
  default_branch: string;
  web_url: string;
  created_at: string;
  updated_at: string;
}
```

**Integration tests with MSW (Mock Service Worker):**
Set up MSW handlers that mock GitLab API responses for /api/v4/user and /api/v4/projects/:path

## Acceptance Criteria

- [ ] getCurrentUser() returns GitLabUser with id, username, name
- [ ] getProject("group/project") URL-encodes path correctly
- [ ] 401 response throws GitLabAuthError
- [ ] 404 response throws GitLabNotFoundError
- [ ] 429 response throws GitLabRateLimitError with retryAfter from header
- [ ] Network failure throws GitLabNetworkError
- [ ] Rate limiter enforces minimum interval between requests
- [ ] Rate limiter adds random jitter (0-50ms)
- [ ] tests/integration/gitlab-client.test.ts passes (6 tests)

## Files

CREATE:
- src/gitlab/client.ts
- src/gitlab/types.ts
- tests/integration/gitlab-client.test.ts
- tests/fixtures/mock-responses/gitlab-user.json
- tests/fixtures/mock-responses/gitlab-project.json

## TDD Loop

RED:
```typescript
// tests/integration/gitlab-client.test.ts
describe('GitLab Client', () => {
  it('authenticates with valid PAT')
  it('returns 401 for invalid PAT')
  it('fetches project by path')
  it('handles rate limiting (429) with Retry-After')
  it('respects rate limit (requests per second)')
  it('adds jitter to rate limiting')
})
```

GREEN: Implement client.ts and types.ts

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

## Edge Cases

- Path with special characters (spaces, slashes) must be URL-encoded
- Retry-After header may be missing - default to 60s
- Network timeout should be handled (use AbortController)
- Rate limiter jitter prevents multiple clients syncing in lockstep
- baseUrl trailing slash should be stripped


Extend 'lore stats --check' for event table integrity and queue health

## Background
The existing stats --check command validates data integrity. Need to extend it for event tables (referential integrity) and dependent job queue health (stuck locks, retryable jobs). This provides operators and agents a way to detect data quality issues after sync.

## Approach
Extend src/cli/commands/stats.rs check mode:

**New checks:**

1. Event FK integrity:
```sql
-- Orphaned state events (issue_id points to non-existent issue)
SELECT COUNT(*) FROM resource_state_events rse
WHERE rse.issue_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM issues i WHERE i.id = rse.issue_id);
-- (repeat for merge_request_id, and for label + milestone event tables)
```

2. Queue health:
```sql
-- Pending jobs by type
SELECT job_type, COUNT(*) FROM pending_dependent_fetches GROUP BY job_type;
-- Stuck locks (locked_at older than 5 minutes)
SELECT COUNT(*) FROM pending_dependent_fetches WHERE locked_at IS NOT NULL AND locked_at < ?;
-- Retryable jobs (attempts > 0, not locked)
SELECT COUNT(*) FROM pending_dependent_fetches WHERE attempts > 0 AND locked_at IS NULL;
-- Max attempts (jobs that may be permanently failing)
SELECT job_type, MAX(attempts) FROM pending_dependent_fetches GROUP BY job_type;
```

3. Human output per check: PASS / WARN / FAIL with counts
```
Event FK integrity:     PASS (0 orphaned events)
Queue health:           WARN (3 stuck locks, 12 retryable jobs)
```

4. Robot JSON: structured health report
```json
{
  "event_integrity": {
    "status": "pass",
    "orphaned_state_events": 0,
    "orphaned_label_events": 0,
    "orphaned_milestone_events": 0
  },
  "queue_health": {
    "status": "warn",
    "pending_by_type": {"resource_events": 5, "mr_closes_issues": 2},
    "stuck_locks": 3,
    "retryable_jobs": 12,
    "max_attempts_by_type": {"resource_events": 5}
  }
}
```

## Acceptance Criteria
- [ ] Detects orphaned events (FK target missing)
- [ ] Detects stuck locks (locked_at older than threshold)
- [ ] Reports retryable job count and max attempts
- [ ] Human output shows PASS/WARN/FAIL per check
- [ ] Robot JSON matches structured schema
- [ ] Graceful when event/queue tables don't exist

## Files
- src/cli/commands/stats.rs (extend check mode)

## TDD Loop
RED: tests/stats_check_tests.rs:
- `test_stats_check_events_pass` - clean data, verify PASS
- `test_stats_check_events_orphaned` - delete an issue with events remaining, verify FAIL count
- `test_stats_check_queue_stuck_locks` - set old locked_at, verify WARN
- `test_stats_check_queue_retryable` - fail some jobs, verify retryable count

GREEN: Add the check queries and formatting

VERIFY: `cargo test stats_check -- --nocapture`

## Edge Cases
- FK with CASCADE should prevent orphaned events in normal operation — but manual DB edits or bugs could cause them
- Tables may not exist if migration 011 not applied — check table existence before querying
- Empty queue is PASS (not WARN for "no jobs found")
- Distinguish between "0 stuck locks" (good) and "queue table doesn't exist" (skip check)

Extended IntegrityResult with orphan_state/label/milestone_events and queue_stuck_locks/queue_max_attempts. Added FK integrity queries for all 3 event tables and queue health checks. Updated human output with PASS/WARN/FAIL indicators and robot JSON.


[CP1] gi sync-status enhancement

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

Output:
- Last run timestamp and duration
- Cursor positions per project (issues resource_type)
- Entity counts (issues, discussions, notes)

Files: src/cli/commands/sync-status.ts (update existing)
Done when: Shows cursor positions and counts after ingestion


[CP1] gi ingest --type=issues command

CLI command to orchestrate issue ingestion.

Implementation:
1. Acquire app lock with heartbeat
2. Create sync_run record (status='running')
3. For each configured project:
   - Call ingestIssues()
   - For each ingested issue, call ingestIssueDiscussions()
   - Show progress (spinner or progress bar)
4. Update sync_run (status='succeeded', metrics_json)
5. Release lock

Flags:
- --type=issues (required)
- --project=PATH (optional, filter to single project)
- --force (override stale lock)

Output: Progress bar, then summary with counts

Files: src/cli/commands/ingest.ts
Tests: tests/integration/sync-runs.test.ts
Done when: Full issue + discussion ingestion works end-to-end


lore brief: situational awareness for topic/module/person

## Background
Composable capstone command. An agent says "I am about to work on auth" and gets everything in one call: open issues, active MRs, experts, recent activity, unresolved threads, related context. Replaces 5 separate lore calls with 1.

## Input Modes
1. Topic: `lore brief 'authentication'` — FTS search to find relevant entities, aggregate
2. Path: `lore brief --path src/auth/` — who expert internals for path expertise
3. Person: `lore brief --person teernisse` — who workload internals
4. Entity: `lore brief issues 3864` — single entity focus with cross-references

## Section Assembly Architecture

### Option B: Reuse existing run_* functions (ship faster, recommended for v1)
Each section calls existing CLI command functions and converts their output:

```rust
// In src/cli/commands/brief.rs

pub async fn run_brief(config: &Config, args: BriefArgs) -> Result<BriefResponse> {
    let db_path = get_db_path(config.storage.db_path.as_deref());
    let conn = create_connection(&db_path)?;

    let mut sections_computed = Vec::new();

    // 1. open_issues: reuse list.rs internals
    let open_issues = list::run_list_issues(&conn, &ListFilters {
        state: Some("opened".into()),
        limit: Some(5),
        // ... scope by topic/path/person based on mode
    })?;
    sections_computed.push("open_issues");

    // 2. active_mrs: reuse list.rs internals
    let active_mrs = list::run_list_mrs(&conn, &ListFilters {
        state: Some("opened".into()),
        limit: Some(5),
    })?;
    sections_computed.push("active_mrs");

    // 3. experts: reuse who.rs internals
    let experts = who::run_who(&conn, &WhoArgs {
        mode: WhoMode::Expert,
        path: args.path.clone(),
        limit: Some(3),
    })?;
    sections_computed.push("experts");

    // 4. recent_activity: reuse timeline internals
    // ...etc
}
```

### Concrete Function References (src/cli/commands/)
- `list.rs`: Functions for listing issues/MRs with filters. Returns Vec<IssueRow>/Vec<MrRow>.
- `who.rs`: Expert/Workload/Reviews/Active/Overlap modes. Returns ranked contributor lists.
- `timeline.rs`: 5-stage pipeline. Returns chronological events.
- `search.rs:run_search()`: FTS search with filters. Returns SearchResponse.
- `show.rs`: Entity detail with discussions. Returns IssueDetail/MrDetail.

### Section Details
| Section | Source | Limit | Fallback |
|---------|--------|-------|----------|
| open_issues | list.rs with state=opened | 5 | empty array |
| active_mrs | list.rs with state=opened | 5 | empty array |
| experts | who.rs Expert mode | 3 | empty array (no path data) |
| recent_activity | timeline.rs | 10 events | empty array |
| unresolved_threads | SQL: discussions WHERE resolved=false | 5 | empty array |
| related | search_vector() | 5 | omit section (no embeddings) |
| warnings | computed from dates/state | all | empty array |

### Warning Generation
```rust
fn compute_warnings(issues: &[IssueRow]) -> Vec<String> {
    let now = chrono::Utc::now();
    issues.iter().filter_map(|i| {
        let updated = parse_timestamp(i.updated_at)?;
        let days_stale = (now - updated).num_days();
        if days_stale > 30 {
            Some(format!("Issue #{} has no activity for {} days", i.iid, days_stale))
        } else { None }
    }).chain(
        issues.iter().filter(|i| i.assignees.is_empty())
            .map(|i| format!("Issue #{} is unassigned", i.iid))
    ).collect()
}
```

## Robot Mode Output Schema
```json
{
  "ok": true,
  "data": {
    "mode": "topic",
    "query": "authentication",
    "summary": "3 open issues, 2 active MRs, top expert: teernisse",
    "open_issues": [{ "iid": 123, "title": "...", "state": "opened", "assignees": [...], "updated_at": "...", "labels": [...] }],
    "active_mrs": [{ "iid": 456, "title": "...", "state": "opened", "author": "...", "draft": false, "updated_at": "..." }],
    "experts": [{ "username": "teernisse", "score": 42, "last_activity": "..." }],
    "recent_activity": [{ "timestamp": "...", "event_type": "state_change", "entity_ref": "issues#123", "summary": "...", "actor": "..." }],
    "unresolved_threads": [{ "discussion_id": "abc", "entity_ref": "issues#123", "started_by": "...", "note_count": 5, "last_note_at": "..." }],
    "related": [{ "iid": 789, "title": "...", "similarity_score": 0.85 }],
    "warnings": ["Issue #3800 has no activity for 45 days"]
  },
  "meta": { "elapsed_ms": 1200, "sections_computed": ["open_issues", "active_mrs", "experts", "recent_activity"] }
}
```

## TDD Loop
RED: Tests in src/cli/commands/brief.rs:
- test_brief_topic_returns_all_sections: insert test data, search 'auth', assert all section keys present in response
- test_brief_path_uses_who_expert: brief --path src/auth/, assert experts section populated
- test_brief_person_uses_who_workload: brief --person user, assert open_issues filtered to user's assignments
- test_brief_warnings_stale_issue: insert issue with updated_at > 30 days ago, assert warning generated
- test_brief_token_budget: robot mode output for topic query is under 12000 bytes (~3000 tokens)
- test_brief_no_embeddings_graceful: related section omitted (not errored) when no embeddings exist
- test_brief_empty_topic: zero matches returns valid JSON with empty arrays + "No data found" summary

GREEN: Implement brief with section assembly, calling existing run_* functions

VERIFY:
```bash
cargo test brief:: && cargo clippy --all-targets -- -D warnings
cargo run --release -- -J brief 'throw time' | jq '.data | keys'
cargo run --release -- -J brief 'throw time' | wc -c  # target <12000
```

## Acceptance Criteria
- [ ] lore brief TOPIC returns all sections for free-text topic
- [ ] lore brief --path PATH returns path-focused briefing with experts
- [ ] lore brief --person USERNAME returns person-focused briefing
- [ ] lore brief issues N returns entity-focused briefing
- [ ] Robot mode output under 12000 bytes (~3000 tokens)
- [ ] Each section degrades gracefully if its data source is unavailable
- [ ] summary field is auto-generated one-liner from section counts
- [ ] warnings detect: stale issues (>30d), unassigned, no due date
- [ ] Performance: <2s total (acceptable since composing multiple queries)
- [ ] Command registered in main.rs and robot-docs

## Edge Cases
- Topic with zero matches: return empty sections + "No data found for this topic" summary
- Path that nobody has touched: experts empty, related may still have results
- Person not found in DB: exit code 17 with suggestion
- All sections empty: still return valid JSON with empty arrays
- Very broad topic ("the"): may return too many results — each section respects its limit cap

## Dependencies
- Hybrid search (bd-1ksf) for topic relevance ranking
- lore who (already shipped) for expertise
- lore related (bd-8con) for semantic connections (optional — degrade gracefully if not available)
- Timeline pipeline (already shipped) for recent activity

## Files to Create/Modify
- NEW: src/cli/commands/brief.rs
- src/cli/commands/mod.rs (add pub mod brief; re-export)
- src/main.rs (register Brief subcommand in Commands enum, add handle_brief fn)
- Reuse: list.rs, who.rs, timeline.rs, search.rs, show.rs internals


Register 'lore timeline' command with all flags

## Background

This bead wires the `lore timeline` command into the CLI — adding the subcommand to the Commands enum, defining all flags, registering in VALID_COMMANDS, and dispatching to the timeline handler. The actual query logic and rendering are in separate beads.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.1 (Command Design).

## Codebase Context

- Commands enum in `src/cli/mod.rs` (line ~86): uses #[derive(Subcommand)] with nested Args structs
- VALID_COMMANDS in `src/main.rs` (line ~448): &[&str] array for fuzzy command matching
- Handler dispatch in `src/main.rs` match on Commands:: variants
- robot-docs manifest in `src/main.rs`: registers commands for `lore robot-docs` output
- Existing pattern: `Sync(SyncArgs)`, `Search(SearchArgs)`, etc.
- No timeline module exists yet — this bead creates the CLI entry point only

## Approach

### 1. TimelineArgs struct (`src/cli/mod.rs`):

```rust
/// Show a chronological timeline of events matching a query
#[derive(Parser, Debug)]
pub struct TimelineArgs {
    /// Search query (keywords to find in issues, MRs, and discussions)
    pub query: String,

    /// Scope to a specific project (fuzzy match)
    #[arg(short = 'p', long)]
    pub project: Option<String>,

    /// Only show events after this date (e.g. "6m", "2w", "2024-01-01")
    #[arg(long)]
    pub since: Option<String>,

    /// Cross-reference expansion depth (0 = no expansion)
    #[arg(long, default_value = "1")]
    pub depth: usize,

    /// Also follow 'mentioned' edges during expansion (high fan-out)
    #[arg(long = "expand-mentions")]
    pub expand_mentions: bool,

    /// Maximum number of events to display
    #[arg(short = 'n', long = "limit", default_value = "100")]
    pub limit: usize,
}
```

### 2. Commands enum variant:

```rust
/// Show a chronological timeline of events matching a query
#[command(name = "timeline")]
Timeline(TimelineArgs),
```

### 3. Handler in `src/main.rs`:

```rust
Commands::Timeline(args) => {
    // Placeholder: will be filled by bd-2f2 (human) and bd-dty (robot)
    // For now: resolve project, call timeline query, dispatch to renderer
}
```

### 4. VALID_COMMANDS: add `"timeline"` to the array

### 5. robot-docs: add timeline command description to manifest

## Acceptance Criteria

- [ ] `TimelineArgs` struct with all 6 flags: query, project, since, depth, expand-mentions, limit
- [ ] Commands::Timeline variant registered in Commands enum
- [ ] Handler stub in src/main.rs dispatches to timeline logic
- [ ] `"timeline"` added to VALID_COMMANDS array
- [ ] robot-docs manifest includes timeline command description
- [ ] `lore timeline --help` shows correct help text
- [ ] `lore timeline` without query shows error (query is required positional)
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

- `src/cli/mod.rs` (TimelineArgs struct + Commands::Timeline variant)
- `src/main.rs` (handler dispatch + VALID_COMMANDS + robot-docs entry)

## TDD Loop

No unit tests for CLI wiring. Verify with:

```bash
cargo check --all-targets
cargo run -- timeline --help
```

## Edge Cases

- --since parsing: reuse existing date parsing from ListFilters (src/cli/mod.rs handles "7d", "2w", "YYYY-MM-DD")
- --depth 0: valid, means no cross-reference expansion
- --expand-mentions: off by default because mentioned edges have high fan-out

Wired lore timeline command: TimelineArgs with 9 flags, Commands::Timeline variant, handle_timeline handler, VALID_COMMANDS entry, robot-docs manifest with temporal_intelligence workflow


[CP1] GitLab types for issues, discussions, notes

## Background

GitLab types define the Rust structs for deserializing GitLab API responses. These types are the foundation for all ingestion work - issues, discussions, and notes must be correctly typed for serde to parse them.

## Approach

Add types to `src/gitlab/types.rs` with serde derives:

### GitLabIssue

```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabIssue {
    pub id: i64,                      // GitLab global ID
    pub iid: i64,                     // Project-scoped issue number
    pub project_id: i64,
    pub title: String,
    pub description: Option<String>,
    pub state: String,                // "opened" | "closed"
    pub created_at: String,           // ISO 8601
    pub updated_at: String,           // ISO 8601
    pub closed_at: Option<String>,
    pub author: GitLabAuthor,
    pub labels: Vec<String>,          // Array of label names (CP1 canonical)
    pub web_url: String,
}
```

NOTE: `labels_details` intentionally NOT modeled - varies across GitLab versions.

### GitLabAuthor

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

### GitLabDiscussion

```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabDiscussion {
    pub id: String,                   // String ID like "6a9c1750b37d..."
    pub individual_note: bool,        // true = standalone comment
    pub notes: Vec<GitLabNote>,
}
```

### GitLabNote

```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabNote {
    pub id: i64,
    #[serde(rename = "type")]
    pub note_type: Option<String>,    // "DiscussionNote" | "DiffNote" | null
    pub body: String,
    pub author: GitLabAuthor,
    pub created_at: String,           // ISO 8601
    pub updated_at: String,           // ISO 8601
    pub system: bool,                 // true for system-generated notes
    #[serde(default)]
    pub resolvable: bool,
    #[serde(default)]
    pub resolved: bool,
    pub resolved_by: Option<GitLabAuthor>,
    pub resolved_at: Option<String>,
    pub position: Option<GitLabNotePosition>,
}
```

### GitLabNotePosition

```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabNotePosition {
    pub old_path: Option<String>,
    pub new_path: Option<String>,
    pub old_line: Option<i32>,
    pub new_line: Option<i32>,
}
```

## Acceptance Criteria

- [ ] GitLabIssue deserializes from API response JSON
- [ ] GitLabAuthor embedded correctly in issue and note
- [ ] GitLabDiscussion with notes array deserializes
- [ ] GitLabNote handles null note_type (use Option<String>)
- [ ] GitLabNote uses #[serde(rename = "type")] for reserved keyword
- [ ] resolvable/resolved default to false via #[serde(default)]
- [ ] All timestamp fields are String (ISO 8601 parsed elsewhere)

## Files

- src/gitlab/types.rs (edit - add types)

## TDD Loop

RED:
```rust
// tests/gitlab_types_tests.rs
#[test] fn deserializes_gitlab_issue_from_json()
#[test] fn deserializes_gitlab_discussion_from_json()
#[test] fn handles_null_note_type()
#[test] fn handles_missing_resolvable_field()
#[test] fn deserializes_labels_as_string_array()
```

GREEN: Add type definitions with serde attributes

VERIFY: `cargo test gitlab_types`

## Edge Cases

- note_type can be null, "DiscussionNote", or "DiffNote"
- labels array can be empty
- description can be null
- resolved_by/resolved_at can be null
- position is only present for DiffNotes

Implemented GitLabAuthor, GitLabIssue, GitLabDiscussion, GitLabNote, GitLabNotePosition types with 10 passing tests


OBSERV: Add -v/--verbose and --log-format CLI flags

## Background
Users and agents need CLI-controlled verbosity without knowing RUST_LOG syntax. The -v flag convention (cargo, curl, ssh) is universally understood. --log-format json enables lore sync 2>&1 | jq workflows without reading log files.

## Approach
Add two new global flags to the Cli struct in src/cli/mod.rs (insert after the quiet field at line ~37):

```rust
/// Increase log verbosity (-v, -vv, -vvv)
#[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count, global = true)]
pub verbose: u8,

/// Log format for stderr output: text (default) or json
#[arg(long = "log-format", global = true, value_parser = ["text", "json"], default_value = "text")]
pub log_format: String,
```

The existing Cli struct (src/cli/mod.rs:13-42) has these global flags: config, robot, json, color, quiet. The new flags follow the same pattern.

Note: clap::ArgAction::Count allows -v, -vv, -vvv as a single flag with increasing count (0, 1, 2, 3).

## Acceptance Criteria
- [ ] lore -v sync parses without error (verbose=1)
- [ ] lore -vv sync parses (verbose=2)
- [ ] lore -vvv sync parses (verbose=3)
- [ ] lore --log-format json sync parses (log_format="json")
- [ ] lore --log-format text sync parses (default)
- [ ] lore --log-format xml sync errors (invalid value)
- [ ] Existing commands unaffected (verbose defaults to 0, log_format to "text")
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/cli/mod.rs (modify Cli struct, lines 13-42)

## TDD Loop
RED: Write test that parses Cli with -v flag and asserts verbose=1
GREEN: Add the two fields to Cli struct
VERIFY: cargo test -p lore && cargo clippy --all-targets -- -D warnings

## Edge Cases
- -v and -q together: both parse fine; conflict resolution happens in subscriber setup (bd-2rr), not here
- -v flag must be global=true so it works before and after subcommands: lore -v sync AND lore sync -v
- --log-format is a string, not enum, to keep Cli struct simple

Added -v/--verbose (count) and --log-format (text|json) global CLI flags


OBSERV: Define StageTiming struct in src/core/metrics.rs

## Background
StageTiming is the materialized view of span timing data. It's the data structure that flows through robot JSON output, sync_runs.metrics_json, and the human-readable timing summary. Defined in a new file because it's genuinely new functionality that doesn't fit existing modules.

## Approach
Create src/core/metrics.rs:

```rust
use serde::Serialize;

fn is_zero(v: &usize) -> bool { *v == 0 }

#[derive(Debug, Clone, Serialize)]
pub struct StageTiming {
    pub name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub project: Option<String>,
    pub elapsed_ms: u64,
    pub items_processed: usize,
    #[serde(skip_serializing_if = "is_zero")]
    pub items_skipped: usize,
    #[serde(skip_serializing_if = "is_zero")]
    pub errors: usize,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub sub_stages: Vec<StageTiming>,
}
```

Register module in src/core/mod.rs (line ~11, add):
```rust
pub mod metrics;
```

The is_zero helper is a private function used by serde's skip_serializing_if. It must take &usize (reference) and return bool.

## Acceptance Criteria
- [ ] StageTiming serializes to JSON matching PRD Section 4.6.2 example
- [ ] items_skipped omitted when 0
- [ ] errors omitted when 0
- [ ] sub_stages omitted when empty vec
- [ ] project omitted when None
- [ ] name, elapsed_ms, items_processed always present
- [ ] Struct is Debug + Clone + Serialize
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/core/metrics.rs (new file)
- src/core/mod.rs (register module, add line after existing pub mod declarations)

## TDD Loop
RED:
  - test_stage_timing_serialization: create StageTiming with sub_stages, serialize, assert JSON structure
  - test_stage_timing_zero_fields_omitted: errors=0, items_skipped=0, assert no "errors" or "items_skipped" keys
  - test_stage_timing_empty_sub_stages: sub_stages=vec![], assert no "sub_stages" key
GREEN: Create metrics.rs with StageTiming struct and is_zero helper
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- is_zero must be a function, not a closure (serde skip_serializing_if requires a function path)
- Vec::is_empty is a method on Vec, and serde accepts "Vec::is_empty" as a path for skip_serializing_if
- Recursive StageTiming (sub_stages contains StageTiming): serde handles this naturally, no special handling needed

Created src/core/metrics.rs with StageTiming struct, serde skip_serializing_if for zero/empty fields, 5 tests


Register migration 015 in db.rs and create migration 016 for mr_file_changes

## Background

This bead creates the `mr_file_changes` table that stores which files each MR touched, enabling Gate 4 (file-history) and Gate 5 (trace). It maps MRs to the file paths they modify.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 4.1 (Schema).

## Codebase Context — CRITICAL Migration Numbering

- **LATEST_SCHEMA_VERSION = 14** (MIGRATIONS array in db.rs includes 001-014)
- **Migration 015 exists on disk** (`migrations/015_commit_shas_and_closes_watermark.sql`) but is **NOT registered** in `src/core/db.rs` MIGRATIONS array
- `merge_commit_sha` and `squash_commit_sha` are already on merge_requests (added by 015 SQL) and already used in `src/ingestion/merge_requests.rs`
- `closes_issues_synced_for_updated_at` also added by 015 and used in orchestrator.rs
- **This bead must FIRST register migration 015 in db.rs**, then create migration 016 for mr_file_changes
- pending_dependent_fetches already has `job_type='mr_diffs'` in CHECK constraint (migration 011)
- Schema version auto-computes: `LATEST_SCHEMA_VERSION = MIGRATIONS.len() as i32`

## Approach

### Step 1: Register existing migration 015 in db.rs

Add to MIGRATIONS array in `src/core/db.rs` (after the "014" entry):

```rust
(
    "015",
    include_str!("../../migrations/015_commit_shas_and_closes_watermark.sql"),
),
```

This makes LATEST_SCHEMA_VERSION = 15.

### Step 2: Create migration 016 for mr_file_changes

Create `migrations/016_mr_file_changes.sql`:

```sql
-- Migration 016: MR file changes table
-- Powers file-history and trace commands (Gates 4-5)

CREATE TABLE mr_file_changes (
    id INTEGER PRIMARY KEY,
    merge_request_id INTEGER NOT NULL REFERENCES merge_requests(id) ON DELETE CASCADE,
    project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    old_path TEXT,
    new_path TEXT NOT NULL,
    change_type TEXT NOT NULL CHECK (change_type IN ('added', 'modified', 'renamed', 'deleted')),
    UNIQUE(merge_request_id, new_path)
);

CREATE INDEX idx_mfc_project_path ON mr_file_changes(project_id, new_path);
CREATE INDEX idx_mfc_project_old_path ON mr_file_changes(project_id, old_path) WHERE old_path IS NOT NULL;
CREATE INDEX idx_mfc_mr ON mr_file_changes(merge_request_id);
CREATE INDEX idx_mfc_renamed ON mr_file_changes(project_id, change_type) WHERE change_type = 'renamed';

INSERT INTO schema_version (version, applied_at, description)
VALUES (16, strftime('%s', 'now') * 1000, 'MR file changes table');
```

### Step 3: Register migration 016 in db.rs

```rust
(
    "016",
    include_str!("../../migrations/016_mr_file_changes.sql"),
),
```

LATEST_SCHEMA_VERSION will auto-compute to 16.

## Acceptance Criteria

- [ ] Migration 015 registered in MIGRATIONS array in src/core/db.rs
- [ ] Migration file exists at `migrations/016_mr_file_changes.sql`
- [ ] `mr_file_changes` table has columns: id, merge_request_id, project_id, old_path, new_path, change_type
- [ ] UNIQUE constraint on (merge_request_id, new_path)
- [ ] CHECK constraint on change_type: added, modified, renamed, deleted
- [ ] 4 indexes: project+new_path, project+old_path (partial), mr_id, project+renamed (partial)
- [ ] Migration 016 registered in MIGRATIONS array
- [ ] LATEST_SCHEMA_VERSION auto-computes to 16
- [ ] `lore migrate` applies both 015 and 016 successfully on a v14 database
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

- `src/core/db.rs` (register migrations 015 AND 016 in MIGRATIONS array)
- `migrations/016_mr_file_changes.sql` (NEW)

## TDD Loop

RED: `lore migrate` on v14 database says "already up to date" (015 not registered)

GREEN: Register 015 in db.rs, create 016 file, register 016 in db.rs. `lore migrate` applies both.

VERIFY:
```bash
cargo check --all-targets
lore --robot migrate
sqlite3 ~/.local/share/lore/lore.db '.schema mr_file_changes'
sqlite3 ~/.local/share/lore/lore.db "SELECT version FROM schema_version ORDER BY version DESC LIMIT 1"
```

## Edge Cases

- Databases already at v15 via manual migration: 015 will be skipped, only 016 applied
- old_path is NULL for added files, populated for renamed/deleted
- No lines_added/lines_removed columns (spec does not require them; removed to match spec exactly)
- Partial indexes only index relevant rows for rename chain BFS performance

Completed: registered migration 015 in db.rs MIGRATIONS array, created migration 016 (mr_file_changes table with 4 indexes, CHECK constraint, UNIQUE constraint), registered 016 in db.rs. LATEST_SCHEMA_VERSION auto-computes to 16. cargo check, clippy, and fmt all pass.


WHO: Epic — People Intelligence Commands

## Background

The current beads roadmap focuses on Gate 4/5 (file-history, code-trace) — archaeology queries requiring mr_file_changes data that does not exist yet. Meanwhile, the DB has rich people/activity data (280K notes, 210K discussions, 33K DiffNotes with file positions, 53 active participants) that can answer collaboration questions immediately with zero new tables or API calls.

## Scope

This epic builds `lore who` — a pure SQL query layer answering 5 questions:
1. **Expert**: "Who should I talk to about this feature/file?" (DiffNote path analysis)
2. **Workload**: "What is person X working on?" (open issues, authored/reviewing MRs, unresolved discussions)
3. **Reviews**: "What review patterns does person X have?" (DiffNote **prefix** category extraction)
4. **Active**: "What discussions are actively in progress?" (unresolved resolvable discussions)
5. **Overlap**: "Who else has MRs/notes touching my files?" (path-based activity overlap)

## Plan Reference

Full implementation plan with 8 iterations of review: `docs/who-command-design.md`

## Children (Execution Order)

1. **bd-34rr** — Migration 017: 5 composite indexes for query performance
2. **bd-2rk9** — CLI skeleton: WhoArgs, Commands::Who, dispatch, stub file
3. **bd-2ldg** — Mode resolution, path helpers, run_who entry point, all result types
4. **bd-zqpf** — Expert mode query (CTE + MR-breadth scoring)
5. **bd-s3rc** — Workload mode query (4 SELECT queries)
6. **bd-m7k1** — Active mode query (CTE + global/scoped SQL variants)
7. **bd-b51e** — Overlap mode query (dual role tracking + accumulator)
8. **bd-2711** — Reviews mode query (prefix extraction + normalization)
9. **bd-1rdi** — Human terminal output for all 5 modes
10. **bd-3mj2** — Robot JSON output for all 5 modes
11. **bd-tfh3** — Comprehensive test suite (20+ tests)
12. **bd-zibc** — VALID_COMMANDS + robot-docs manifest
13. **bd-g0d5** — Verification gate (check, clippy, fmt, EXPLAIN QUERY PLAN)

## Design Principles (from plan)

- All SQL fully static — no format!() for query text, LIMIT bound as ?N
- prepare_cached() everywhere for statement caching
- (?N IS NULL OR ...) nullable binding except Active mode (two SQL variants for index selection)
- Self-review exclusion on all DiffNote-based branches
- Deterministic output: sorted GROUP_CONCAT, sorted HashSet-derived vectors, stable tie-breakers
- Truncation transparency: LIMIT+1 pattern with truncated bool
- Bounded payloads: capped arrays with *_total + *_truncated metadata
- Robot-first reproducibility: input + resolved_input with since_mode tri-state

## Files

| File | Action | Description |
|---|---|---|
| `src/cli/commands/who.rs` | CREATE | All 5 query modes + human/robot output |
| `src/cli/commands/mod.rs` | MODIFY | Add `pub mod who` + re-exports |
| `src/cli/mod.rs` | MODIFY | Add `WhoArgs` struct + `Commands::Who` variant |
| `src/main.rs` | MODIFY | Add dispatch arm + `handle_who` fn + VALID_COMMANDS + robot-docs |
| `src/core/db.rs` | MODIFY | Add migration 017: composite indexes for who query paths |

## TDD Loop

Each child bead has its own RED/GREEN/VERIFY cycle. The epic TDD strategy:
- RED: Tests in bd-tfh3 (written alongside query beads)
- GREEN: Query implementations in bd-zqpf, bd-s3rc, bd-m7k1, bd-b51e, bd-2711
- VERIFY: bd-g0d5 runs `cargo test` + `cargo clippy` + EXPLAIN QUERY PLAN

## Acceptance Criteria

- [ ] `lore who src/path/` shows ranked experts with scores
- [ ] `lore who @username` shows workload across all projects
- [ ] `lore who @username --reviews` shows categorized review patterns
- [ ] `lore who --active` shows unresolved discussions
- [ ] `lore who --overlap src/path/` shows other contributors
- [ ] `lore who --path README.md` handles root files
- [ ] `lore -J who ...` produces valid JSON with input + resolved_input
- [ ] All indexes verified via EXPLAIN QUERY PLAN
- [ ] cargo check + clippy + fmt + test all pass

## Edge Cases

- This epic has zero new tables — all queries are pure SQL over existing schema + migration 017 indexes
- Gate 4/5 beads are NOT dependencies — who command works independently with current data
- If DB has <1000 notes, queries will work but results will be sparse — this is expected for fresh installations
- format_relative_time() is duplicated from list.rs intentionally (private fn, small blast radius > refactoring shared module)
- lookup_project_path() is local to who.rs — single invocation per run, does not warrant shared utility

All 13 child beads implemented: migration 017 (5 composite indexes), CLI skeleton with WhoArgs/dispatch/robot-docs, 5 query modes (expert/workload/active/overlap/reviews), human terminal + robot JSON output, 20 tests. All quality gates pass: cargo check, clippy (pedantic+nursery), fmt, test.


[CP1] Discussion and note transformers

## Background

Discussion and note transformers convert GitLab API discussion responses into our normalized schema. They compute derived fields like `first_note_at`, `last_note_at`, resolvable/resolved status, and note positions. These are pure functions with no I/O.

## Approach

Create transformer module with:

### Structs

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

pub struct NormalizedDiscussion {
    pub gitlab_discussion_id: String,
    pub project_id: i64,
    pub issue_id: i64,
    pub noteable_type: String,        // "Issue"
    pub individual_note: bool,
    pub first_note_at: Option<i64>,   // min(note.created_at) in ms epoch
    pub last_note_at: Option<i64>,    // max(note.created_at) in ms epoch
    pub last_seen_at: i64,
    pub resolvable: bool,             // any note is resolvable
    pub resolved: bool,               // all resolvable notes are resolved
}

pub struct NormalizedNote {
    pub gitlab_id: i64,
    pub project_id: i64,
    pub note_type: Option<String>,    // "DiscussionNote" | "DiffNote" | null
    pub is_system: bool,              // from note.system
    pub author_username: String,
    pub body: String,
    pub created_at: i64,              // ms epoch
    pub updated_at: i64,              // ms epoch
    pub last_seen_at: i64,
    pub position: i32,                // 0-indexed array position
    pub resolvable: bool,
    pub resolved: bool,
    pub resolved_by: Option<String>,
    pub resolved_at: Option<i64>,
}
```

### Functions

```rust
pub fn transform_discussion(
    gitlab_discussion: &GitLabDiscussion,
    local_project_id: i64,
    local_issue_id: i64,
) -> NormalizedDiscussion

pub fn transform_notes(
    gitlab_discussion: &GitLabDiscussion,
    local_project_id: i64,
) -> Vec<NormalizedNote>
```

## Acceptance Criteria

- [ ] `NormalizedDiscussion` struct with all fields
- [ ] `NormalizedNote` struct with all fields
- [ ] `transform_discussion` computes first_note_at/last_note_at from notes array
- [ ] `transform_discussion` computes resolvable (any note is resolvable)
- [ ] `transform_discussion` computes resolved (all resolvable notes resolved)
- [ ] `transform_notes` preserves array order via position field (0-indexed)
- [ ] `transform_notes` maps system flag to is_system
- [ ] Unit tests cover all computed fields

## Files

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

## TDD Loop

RED:
```rust
// tests/discussion_transformer_tests.rs
#[test] fn transforms_discussion_payload_to_normalized_schema()
#[test] fn extracts_notes_array_from_discussion()
#[test] fn sets_individual_note_flag_correctly()
#[test] fn flags_system_notes_with_is_system_true()
#[test] fn preserves_note_order_via_position_field()
#[test] fn computes_first_note_at_and_last_note_at_correctly()
#[test] fn computes_resolvable_and_resolved_status()
```

GREEN: Implement transform_discussion and transform_notes

VERIFY: `cargo test discussion_transformer`

## Edge Cases

- Discussion with single note - first_note_at == last_note_at
- All notes are system notes - still compute timestamps
- No notes resolvable - resolvable=false, resolved=false
- Mix of resolved/unresolved notes - resolved=false until all done

Implemented NormalizedDiscussion, NormalizedNote, transform_discussion, transform_notes with 9 passing unit tests


[CP1] Database migration 002_issues.sql

Create migration file with tables for issues, labels, issue_labels, discussions, and notes.

## Tables

### issues
- 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
- author_username TEXT
- created_at, updated_at, last_seen_at INTEGER (ms epoch UTC)
- discussions_synced_for_updated_at INTEGER (watermark for dependent sync)
- web_url TEXT
- raw_payload_id INTEGER REFERENCES raw_payloads(id)

### labels (name-only for CP1)
- id INTEGER PRIMARY KEY
- gitlab_id INTEGER (optional, for future Labels API)
- project_id INTEGER NOT NULL REFERENCES projects(id)
- name TEXT NOT NULL
- color TEXT, description TEXT (nullable, deferred)
- UNIQUE(project_id, name)

### issue_labels (junction)
- issue_id, label_id with CASCADE DELETE
- Clear existing links before INSERT to handle removed labels

### discussions
- gitlab_discussion_id TEXT (string ID from API)
- project_id, issue_id/merge_request_id FKs
- noteable_type TEXT ('Issue' | 'MergeRequest')
- individual_note INTEGER, first_note_at, last_note_at, last_seen_at
- resolvable, resolved flags
- CHECK constraint for Issue vs MR exclusivity

### notes
- gitlab_id INTEGER UNIQUE NOT NULL
- discussion_id, project_id FKs
- note_type, is_system, author_username, body
- timestamps, position (array order)
- resolution fields, DiffNote position fields

## Indexes
- idx_issues_project_updated, idx_issues_author, idx_issues_discussions_sync
- uq_issues_project_iid, uq_labels_project_name
- idx_issue_labels_label
- uq_discussions_project_discussion_id, idx_discussions_issue/mr/last_note
- idx_notes_discussion/author/system

Files: migrations/002_issues.sql
Done when: Migration applies cleanly on top of 001_initial.sql, schema_version = 2


WHO: Human terminal output for all 5 modes

## Background

Terminal output for humans. Each mode gets a dedicated print function with consistent styling: bold headers, cyan usernames/refs, dim metadata, table alignment.

## Approach

### Dispatch:
```rust
pub fn print_who_human(result: &WhoResult, project_path: Option<&str>) {
    match result {
        WhoResult::Expert(r) => print_expert_human(r, project_path),
        WhoResult::Workload(r) => print_workload_human(r),
        WhoResult::Reviews(r) => print_reviews_human(r),
        WhoResult::Active(r) => print_active_human(r, project_path),
        WhoResult::Overlap(r) => print_overlap_human(r, project_path),
    }
}
```

### Shared helpers:
- **print_scope_hint()**: dim "(aggregated across all projects; use -p to scope)" when project_path is None. Called by Expert, Active, Overlap.
- **format_relative_time(ms_epoch)**: "just now" / "N min ago" / "N hours ago" / "N days ago" / "N weeks ago" / "N months ago" — DUPLICATE from list.rs (private there, keep blast radius small)
- **truncate_str(s, max)**: Unicode-aware, appends "..." if truncated

### Mode formats:
- **Expert**: table with Username(16) / Score(6) / Reviewed(MRs)(12) / Notes(6) / Authored(MRs)(12) / Last Seen. Path match hint line. "-" for zero counts.
- **Workload**: 4 sections (Assigned Issues, Authored MRs, Reviewing MRs, Unresolved Discussions). Canonical refs in cyan. Draft indicator. Per-section truncation.
- **Reviews**: DiffNote summary line + category table (Category(16) / Count(6) / %(6)). Uncategorized count note.
- **Active**: Discussion list with entity ref, note count, participants (comma-joined @usernames), project path. Discussion count in header.
- **Overlap**: table with Username(16) / Role(6) / MRs(7) / Last Seen(12) / MR Refs (first 5, +N overflow). Path match hint.

### All modes: truncation dim hints, empty-state messages, console::style formatting.

## Files

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

## TDD Loop

No unit tests for print functions (they write to stdout). Verification is manual smoke test.
VERIFY: `cargo check --all-targets` then manual: `cargo run --release -- who src/features/global-search/`

## Acceptance Criteria

- [ ] cargo check passes (all print functions compile)
- [ ] Each mode produces readable, aligned terminal output
- [ ] Scope hint shown when project not specified (Expert, Active, Overlap)
- [ ] Truncation hints shown when results exceed limit
- [ ] Empty-state messages for zero results

## Edge Cases

- format_relative_time handles negative diff ("in the future")
- truncate_str is Unicode-aware (.chars().count(), not .len())
- Workload shows empty message only when ALL 4 sections are empty

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


[CP1] gi show issue command

Show issue details with discussions.

Flags:
- --project=PATH (required if iid is ambiguous across projects)

Output:
- Title, project, state, author, dates, labels, URL
- Description text
- All discussions with notes (formatted thread view)

Handle ambiguity: If multiple projects have same iid, prompt for --project or show error.

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


[CP1] Integration tests for issue ingestion

Full integration tests for issue ingestion module.

## Tests (tests/issue_ingestion_tests.rs)

- inserts_issues_into_database
- creates_labels_from_issue_payloads
- links_issues_to_labels_via_junction_table
- removes_stale_label_links_on_resync
- stores_raw_payload_for_each_issue
- stores_raw_payload_for_each_discussion
- updates_cursor_incrementally_per_page
- resumes_from_cursor_on_subsequent_runs
- handles_issues_with_no_labels
- upserts_existing_issues_on_refetch
- skips_discussion_refetch_for_unchanged_issues

## Test Setup
- tempfile::TempDir for isolated database
- wiremock::MockServer for GitLab API
- Mock handlers returning fixture data

Files: tests/issue_ingestion_tests.rs
Done when: All integration tests pass with mocked GitLab


Epic: Gate 2 - Cross-Reference Extraction

## Background
Gate 2 builds the entity relationship graph that connects issues, MRs, and discussions. Without cross-references, temporal queries can only show events for individually-matched entities. With them, "lore timeline auth migration" can discover that MR !567 closed issue #234, which spawned follow-up issue #299 — even if #299 does not contain the words "auth migration."

Three data sources feed entity_references:
1. **Structured API (reliable):** GET /projects/:id/merge_requests/:iid/closes_issues
2. **State events (reliable):** resource_state_events.source_merge_request_id
3. **System note parsing (best-effort):** "mentioned in !456", "closed by !789" patterns

## Architecture
- **entity_references table:** Already created in migration 011 (bd-hu3/bd-czk). Stores source→target relationships with reference_type (closes/mentioned/related) and source_method provenance.
- **Directionality convention:** source = entity where reference was observed, target = entity being referenced. Consistent across all source_methods.
- **Unresolved references:** Cross-project refs stored with target_entity_id=NULL, target_project_path populated. Still valuable for timeline narratives.
- **closes_issues fetch:** Uses generic dependent fetch queue (job_type = mr_closes_issues). One API call per MR.
- **System note parsing:** Local post-processing after all dependent fetches complete. No API calls. English-only, best-effort.

## Children (Execution Order)
1. **bd-czk** [CLOSED] — entity_references schema (folded into migration 011)
2. **bd-8t4** [OPEN] — Extract cross-references from resource_state_events (source_merge_request_id)
3. **bd-3ia** [OPEN] — Fetch closes_issues API and populate entity_references
4. **bd-1ji** [OPEN] — Parse system notes for cross-reference patterns

## Gate Completion Criteria
- [ ] entity_references populated from closes_issues API for all synced MRs
- [ ] entity_references populated from state events where source_merge_request_id present
- [ ] System notes parsed for cross-reference patterns (English instances)
- [ ] Cross-project references stored as unresolved (target_entity_id=NULL)
- [ ] source_method tracks provenance of each reference
- [ ] Deduplication: same relationship from multiple sources stored once (UNIQUE constraint)
- [ ] Timeline JSON includes expansion provenance (via) for expanded entities
- [ ] Integration test: sync with all three extraction methods, verify entity_references populated

## Dependencies
- Depends on: Gate 1 (bd-2zl) — event tables and dependent fetch queue
- Downstream: Gate 3 (bd-ike) depends on entity_references for BFS expansion

All child beads completed: bd-8t4 (state event extraction), bd-3ia (closes_issues API), bd-1ji (system note parsing)


Add half_life_decay() pure function

## Background
The decay function is the mathematical core of the scoring model. It must be correct, tested first (TDD RED), and verified independently of any DB or SQL changes.

## Approach
Add to who.rs as a private function near the top of the module (before query_expert):

```rust
/// Exponential half-life decay: R = 2^(-t/h)
/// Returns 1.0 at elapsed=0, 0.5 at elapsed=half_life, 0.0 if half_life=0.
fn half_life_decay(elapsed_ms: i64, half_life_days: u32) -> f64 {
    let days = (elapsed_ms as f64 / 86_400_000.0).max(0.0);
    let hl = f64::from(half_life_days);
    if hl <= 0.0 { return 0.0; }
    2.0_f64.powf(-days / hl)
}
```

## TDD Loop

### RED (write first):
```rust
#[test]
fn test_half_life_decay_math() {
    let hl_180 = 180;
    // At t=0, full retention
    assert!((half_life_decay(0, hl_180) - 1.0).abs() < f64::EPSILON);
    // At t=half_life, exactly 0.5
    let one_hl_ms = 180 * 86_400_000_i64;
    assert!((half_life_decay(one_hl_ms, hl_180) - 0.5).abs() < 1e-10);
    // At t=2*half_life, exactly 0.25
    assert!((half_life_decay(2 * one_hl_ms, hl_180) - 0.25).abs() < 1e-10);
    // Negative elapsed clamped to 0 -> 1.0
    assert!((half_life_decay(-1000, hl_180) - 1.0).abs() < f64::EPSILON);
    // Zero half-life -> 0.0 (div-by-zero guard)
    assert!((half_life_decay(86_400_000, 0)).abs() < f64::EPSILON);
}

#[test]
fn test_score_monotonicity_by_age() {
    // For any half-life, older timestamps must never produce higher decay than newer ones.
    // Use deterministic LCG PRNG (no rand dependency).
    let mut seed: u64 = 42;
    let hl = 90_u32;
    for _ in 0..50 {
        seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
        let newer_ms = (seed % 100_000_000) as i64;       // 0-100M ms (~1.15 days max)
        seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
        let older_ms = newer_ms + (seed % 500_000_000) as i64; // always >= newer
        assert!(
            half_life_decay(older_ms, hl) <= half_life_decay(newer_ms, hl),
            "Monotonicity violated: decay({older_ms}) > decay({newer_ms})"
        );
    }
}
```

### GREEN: Add the half_life_decay function (3 lines of math).
### VERIFY: `cargo test -p lore -- test_half_life_decay_math test_score_monotonicity`

## Acceptance Criteria
- [ ] test_half_life_decay_math passes (4 boundary cases + div-by-zero guard)
- [ ] test_score_monotonicity_by_age passes (50 random pairs, deterministic seed)
- [ ] Function is `fn` not `pub fn` (module-private)
- [ ] No DB dependency — pure function

## Files
- src/cli/commands/who.rs (function near top, tests in test module)

## Edge Cases
- Negative elapsed_ms: clamped to 0 via .max(0.0) -> returns 1.0
- half_life_days = 0: returns 0.0, not NaN/Inf
- Very large elapsed (10 years): returns very small positive f64, never negative


Epic: CP2 Gate C - Dependent Discussion Sync

## Background
Gate C validates the dependent discussion sync with DiffNote position capture. This is critical for code review context preservation - without DiffNote positions, we lose the file/line context for review comments.

## Acceptance Criteria (Pass/Fail)
- [ ] Discussions fetched for MRs with updated_at > discussions_synced_for_updated_at
- [ ] `SELECT COUNT(*) FROM discussions WHERE merge_request_id IS NOT NULL` > 0
- [ ] DiffNotes have `position_new_path` populated (file path)
- [ ] DiffNotes have `position_new_line` populated (line number)
- [ ] DiffNotes have `position_type` populated (text/image/file)
- [ ] DiffNotes have SHA triplet: `position_base_sha`, `position_start_sha`, `position_head_sha`
- [ ] Multi-line DiffNotes have `position_line_range_start` and `position_line_range_end`
- [ ] Unchanged MRs skip discussion refetch (watermark comparison works)
- [ ] Watermark NOT advanced on HTTP error mid-pagination
- [ ] Watermark NOT advanced on note timestamp parse failure
- [ ] `gi show mr <iid>` displays DiffNote with file context `[path:line]`

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

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

echo "=== Gate C: Dependent Discussion Sync ==="

# 1. Check discussion count for MRs
echo "Step 1: Check MR discussion count..."
MR_DISC_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM discussions WHERE merge_request_id IS NOT NULL;")
echo "  MR discussions: $MR_DISC_COUNT"
[ "$MR_DISC_COUNT" -gt 0 ] || { echo "FAIL: No MR discussions found"; exit 1; }

# 2. Check note count
echo "Step 2: Check note count..."
NOTE_COUNT=$(sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM notes n
  JOIN discussions d ON d.id = n.discussion_id
  WHERE d.merge_request_id IS NOT NULL;
")
echo "  MR notes: $NOTE_COUNT"

# 3. Check DiffNote position data
echo "Step 3: Check DiffNote positions..."
DIFFNOTE_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM notes WHERE position_new_path IS NOT NULL;")
echo "  DiffNotes with position: $DIFFNOTE_COUNT"

# 4. Sample DiffNote data
echo "Step 4: Sample DiffNote data..."
sqlite3 "$DB_PATH" "
  SELECT
    n.gitlab_id,
    n.position_new_path,
    n.position_new_line,
    n.position_type,
    SUBSTR(n.position_head_sha, 1, 7) as head_sha
  FROM notes n
  WHERE n.position_new_path IS NOT NULL
  LIMIT 5;
"

# 5. Check multi-line DiffNotes
echo "Step 5: Check multi-line DiffNotes..."
MULTILINE_COUNT=$(sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM notes
  WHERE position_line_range_start IS NOT NULL
    AND position_line_range_end IS NOT NULL
    AND position_line_range_start != position_line_range_end;
")
echo "  Multi-line DiffNotes: $MULTILINE_COUNT"

# 6. Check watermarks set
echo "Step 6: Check watermarks..."
WATERMARKED=$(sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM merge_requests
  WHERE discussions_synced_for_updated_at IS NOT NULL;
")
echo "  MRs with watermark set: $WATERMARKED"

# 7. Check last_seen_at for sweep pattern
echo "Step 7: Check last_seen_at (sweep pattern)..."
sqlite3 "$DB_PATH" "
  SELECT
    MIN(last_seen_at) as oldest,
    MAX(last_seen_at) as newest
  FROM discussions
  WHERE merge_request_id IS NOT NULL;
"

# 8. Test show command with DiffNote
echo "Step 8: Find MR with DiffNotes for show test..."
MR_IID=$(sqlite3 "$DB_PATH" "
  SELECT DISTINCT m.iid
  FROM merge_requests m
  JOIN discussions d ON d.merge_request_id = m.id
  JOIN notes n ON n.discussion_id = d.id
  WHERE n.position_new_path IS NOT NULL
  LIMIT 1;
")
if [ -n "$MR_IID" ]; then
  echo "  Testing: gi show mr $MR_IID"
  gi show mr "$MR_IID" | head -50
fi

# 9. Re-run and verify skip count
echo "Step 9: Re-run ingest (should skip unchanged MRs)..."
gi ingest --type=merge_requests
# Should report "Skipped discussion sync for N unchanged MRs"

echo ""
echo "=== Gate C: PASSED ==="
```

## Atomicity Test (Manual - Kill Test)
```bash
# This tests that partial failure preserves data

# 1. Get an MR with discussions
MR_ID=$(sqlite3 "$DB_PATH" "
  SELECT m.id FROM merge_requests m
  JOIN discussions d ON d.merge_request_id = m.id
  LIMIT 1;
")

# 2. Note current note count
BEFORE=$(sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM notes n
  JOIN discussions d ON d.id = n.discussion_id
  WHERE d.merge_request_id = $MR_ID;
")
echo "Notes before: $BEFORE"

# 3. Note watermark
WATERMARK_BEFORE=$(sqlite3 "$DB_PATH" "
  SELECT discussions_synced_for_updated_at FROM merge_requests WHERE id = $MR_ID;
")
echo "Watermark before: $WATERMARK_BEFORE"

# 4. Force full sync and kill mid-run
gi ingest --type=merge_requests --full &
PID=$!
sleep 3 && kill -9 $PID 2>/dev/null || true
wait $PID 2>/dev/null || true

# 5. Verify notes preserved (should be same or more, never less)
AFTER=$(sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM notes n
  JOIN discussions d ON d.id = n.discussion_id
  WHERE d.merge_request_id = $MR_ID;
")
echo "Notes after kill: $AFTER"
[ "$AFTER" -ge "$BEFORE" ] || echo "WARNING: Notes decreased - atomicity may be broken"

# 6. Note watermark should NOT have advanced if killed mid-pagination
WATERMARK_AFTER=$(sqlite3 "$DB_PATH" "
  SELECT discussions_synced_for_updated_at FROM merge_requests WHERE id = $MR_ID;
")
echo "Watermark after: $WATERMARK_AFTER"
```

## Test Commands (Quick Verification)
```bash
# Check DiffNote data:
sqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 "
  SELECT
    (SELECT COUNT(*) FROM discussions WHERE merge_request_id IS NOT NULL) as mr_discussions,
    (SELECT COUNT(*) FROM notes WHERE position_new_path IS NOT NULL) as diffnotes,
    (SELECT COUNT(*) FROM merge_requests WHERE discussions_synced_for_updated_at IS NOT NULL) as watermarked;
"

# Find MR with DiffNotes and show it:
gi show mr $(sqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 "
  SELECT DISTINCT m.iid FROM merge_requests m
  JOIN discussions d ON d.merge_request_id = m.id
  JOIN notes n ON n.discussion_id = d.id
  WHERE n.position_new_path IS NOT NULL LIMIT 1;
")
```

## Dependencies
This gate requires:
- bd-3j6 (Discussion transformer with DiffNote position extraction)
- bd-20h (MR discussion ingestion with atomicity guarantees)
- bd-iba (Client pagination for MR discussions)
- Gates A and B must pass first

## Edge Cases
- MRs without discussions: should sync successfully, just with 0 discussions
- Discussions without DiffNotes: regular comments have NULL position fields
- Deleted discussions in GitLab: sweep pattern should remove them locally
- Invalid note timestamps: should NOT advance watermark, should log warning


[CP1] Integration tests for pagination

Integration tests for GitLab pagination with wiremock.

## Tests (tests/pagination_tests.rs)

### Page Navigation
- fetches_all_pages_when_multiple_exist
- respects_per_page_parameter
- follows_x_next_page_header_until_empty
- falls_back_to_empty_page_stop_if_headers_missing

### Cursor Behavior
- applies_cursor_rewind_for_tuple_semantics
- clamps_negative_rewind_to_zero

## Test Setup
- Use wiremock::MockServer
- Set up handlers for /api/v4/projects/:id/issues
- Return x-next-page headers
- Verify request params (updated_after, per_page)

Files: tests/pagination_tests.rs
Done when: All pagination tests pass with mocked server