Implement resize storm + rapid keypress + event fuzz tests

## Background
Stress tests verify the TUI handles adverse input conditions without panic: rapid terminal resizes, fast keypress sequences, and randomized event traces. The event fuzz suite uses deterministic seed replay for reproducibility.

## Approach
Resize storm:
- Send 100 resize events in rapid succession (varying sizes from 20x10 to 300x80)
- Assert no panic, no layout corruption, final render is valid for final size
- FrankenTUI's BOCPD resize coalescing should handle this — verify it works

Rapid keypress:
- Send 50 key events in <100ms: mix of navigation, filter input, mode switches
- Assert no panic, no stuck input mode, final state is consistent
- Verify Ctrl+C always exits regardless of state

Event fuzz (deterministic):
- Generate 10k randomized event traces from: key events, resize events, paste events, tick events
- Use seeded RNG for reproducibility
- Replay each trace, check invariants after each event:
  - Navigation stack depth >= 1 (always has at least Dashboard)
  - InputMode transitions are valid (no impossible state combinations)
  - No panic
  - LoadState transitions are valid (no Idle->Refreshing without LoadingInitial first for initial load)
- On invariant violation: log seed + event index for reproduction

## Acceptance Criteria
- [ ] 100 rapid resizes: no panic, valid final render
- [ ] 50 rapid keys: no stuck input mode, Ctrl+C exits
- [ ] 10k fuzz traces: zero invariant violations
- [ ] Fuzz tests deterministically reproducible via seed
- [ ] Navigation invariant: stack always has at least Dashboard
- [ ] InputMode invariant: valid transitions only

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

## TDD Anchor
RED: Write test_resize_storm_no_panic that sends 100 resize events to LoreApp, asserts no panic.
GREEN: Ensure view() handles all terminal sizes gracefully.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_resize_storm

## Edge Cases
- Zero-size terminal (0x0): must not panic, skip rendering
- Very large terminal (500x200): must not allocate unbounded memory
- Paste events can contain arbitrary bytes including control chars — sanitize
- Fuzz seed must be logged at test start for reproduction

## Dependency Context
Uses LoreApp from "Implement LoreApp Model" task.
Uses NavigationStack from "Implement NavigationStack" task.
Uses FakeClock for deterministic time in fuzz tests.


Epic: TUI Phase 3 — Power Features

## Background
Phase 3 adds the power-user screens: Search (3 modes with preview), Timeline (5-stage pipeline visualization), Who (5 expert/workload modes), and Command Palette (fuzzy match). These screens leverage the foundation from Phases 1-2.

## Acceptance Criteria
- [ ] Search supports lexical, hybrid, and semantic modes with split-pane preview
- [ ] Search capability detection enables/disables modes based on available indexes
- [ ] Timeline renders chronological event stream with color-coded event types
- [ ] Who supports Expert, Workload, Reviews, Active, and Overlap modes
- [ ] Command palette provides fuzzy-match access to all commands


[CP1] gi show issue command

## Background

The `gi show issue <iid>` command displays detailed information about a single issue including metadata, description, labels, and all discussions with their notes. It provides a complete view similar to the GitLab web UI.

## Approach

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

### Clap Definition

```rust
#[derive(Args)]
pub struct ShowArgs {
    /// Entity type
    #[arg(value_parser = ["issue", "mr"])]
    pub entity: String,

    /// Entity IID
    pub iid: i64,

    /// Project path (required if ambiguous)
    #[arg(long)]
    pub project: Option<String>,
}
```

### Handler Function

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

### Logic (for entity="issue")

1. **Find issue**: Query by iid, optionally filtered by project
   - If multiple projects have same iid, require --project or error
2. **Load metadata**: title, state, author, created_at, updated_at, web_url
3. **Load labels**: JOIN through issue_labels to labels table
4. **Load discussions**: All discussions for this issue
5. **Load notes**: All notes for each discussion, ordered by position
6. **Format output**: Rich display with sections

### Output Format (matches PRD)

```
Issue #1234: Authentication redesign
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

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

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

Discussions (5):

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

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

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

### Queries

```sql
-- Find issue
SELECT i.*, p.path as project_path
FROM issues i
JOIN projects p ON i.project_id = p.id
WHERE i.iid = ? AND (p.path = ? OR ? IS NULL)

-- Get labels
SELECT l.name FROM labels l
JOIN issue_labels il ON l.id = il.label_id
WHERE il.issue_id = ?

-- Get discussions with notes
SELECT d.*, n.* FROM discussions d
JOIN notes n ON d.id = n.discussion_id
WHERE d.issue_id = ?
ORDER BY d.first_note_at, n.position
```

## Acceptance Criteria

- [ ] Shows issue metadata (title, state, author, dates, URL)
- [ ] Shows labels as comma-separated list
- [ ] Shows description (truncated if very long)
- [ ] Shows discussions grouped with notes indented
- [ ] Handles --project filter correctly
- [ ] Errors clearly if iid is ambiguous without --project

## Files

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

## TDD Loop

RED:
```rust
#[tokio::test] async fn show_issue_displays_metadata()
#[tokio::test] async fn show_issue_displays_labels()
#[tokio::test] async fn show_issue_displays_discussions()
#[tokio::test] async fn show_issue_requires_project_when_ambiguous()
```

GREEN: Implement handler with queries and formatting

VERIFY: `cargo test show_issue`

## Edge Cases

- Issue with no labels - show "Labels: (none)"
- Issue with no discussions - show "Discussions: (none)"
- Issue with very long description - truncate with "..."
- System notes in discussions - filter out or show with [system] prefix
- Individual notes (not threaded) - show without reply indentation

Implemented gi show issue command with metadata, labels, and discussions display


[CP1] Ingestion orchestrator

## Background

The ingestion orchestrator coordinates issue sync followed by dependent discussion sync. It implements the CP1 canonical pattern: fetch issues, identify which need discussion sync (updated_at advanced), then execute discussion sync with bounded concurrency.

## Approach

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

### Main Function

```rust
pub async fn ingest_project_issues(
    conn: &Connection,
    client: &GitLabClient,
    config: &Config,
    project_id: i64,           // Local DB project ID
    gitlab_project_id: i64,
) -> Result<IngestProjectResult>

#[derive(Debug, Default)]
pub struct IngestProjectResult {
    pub issues_fetched: usize,
    pub issues_upserted: usize,
    pub labels_created: usize,
    pub discussions_fetched: usize,
    pub notes_fetched: usize,
    pub system_notes_count: usize,
    pub issues_skipped_discussion_sync: usize,
}
```

### Orchestration Steps

1. **Call issue ingestion**: `ingest_issues(conn, client, config, project_id, gitlab_project_id)`
2. **Get issues needing discussion sync**: From IngestIssuesResult.issues_needing_discussion_sync
3. **Execute bounded discussion sync**:
   - Use `tokio::task::LocalSet` for single-threaded runtime
   - Respect `config.sync.dependent_concurrency` (default: 5)
   - For each IssueForDiscussionSync:
     - Call `ingest_issue_discussions(...)`
     - Aggregate results
4. **Calculate skipped count**: total_issues - issues_needing_discussion_sync.len()

### Bounded Concurrency Pattern

```rust
use futures::stream::{self, StreamExt};

let local_set = LocalSet::new();
local_set.run_until(async {
    stream::iter(issues_needing_sync)
        .map(|issue| async {
            ingest_issue_discussions(
                conn, client, config,
                project_id, gitlab_project_id,
                issue.iid, issue.local_issue_id, issue.updated_at,
            ).await
        })
        .buffer_unordered(config.sync.dependent_concurrency)
        .try_collect::<Vec<_>>()
        .await
}).await
```

Note: Single-threaded runtime means concurrency is I/O-bound, not parallel execution.

## Acceptance Criteria

- [ ] Orchestrator calls issue ingestion first
- [ ] Only issues with updated_at > discussions_synced_for_updated_at get discussion sync
- [ ] Bounded concurrency respects dependent_concurrency config
- [ ] Results aggregated from both issue and discussion ingestion
- [ ] issues_skipped_discussion_sync accurately reflects unchanged issues

## Files

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

## TDD Loop

RED:
```rust
// tests/orchestrator_tests.rs
#[tokio::test] async fn orchestrates_issue_then_discussion_sync()
#[tokio::test] async fn skips_discussion_sync_for_unchanged_issues()
#[tokio::test] async fn respects_bounded_concurrency()
#[tokio::test] async fn aggregates_results_correctly()
```

GREEN: Implement orchestrator with bounded concurrency

VERIFY: `cargo test orchestrator`

## Edge Cases

- All issues unchanged - no discussion sync calls
- All issues new - all get discussion sync
- dependent_concurrency=1 - sequential discussion fetches
- Issue ingestion fails - orchestrator returns error, no discussion sync


OBSERV: Add root tracing span with run_id to sync and ingest

## Background
A root tracing span per command invocation provides the top of the span hierarchy. All child spans (ingest_issues, fetch_pages, etc.) inherit the run_id field, making every log line within a run filterable by jq.

## Approach
In run_sync() (src/cli/commands/sync.rs:54), after generating run_id, create a root span:

```rust
pub async fn run_sync(config: &Config, options: SyncOptions) -> Result<SyncResult> {
    let run_id = &uuid::Uuid::new_v4().to_string()[..8];
    let _root = tracing::info_span!("sync", %run_id).entered();
    // ... existing sync pipeline code
}
```

In run_ingest() (src/cli/commands/ingest.rs:107), same pattern:

```rust
pub async fn run_ingest(...) -> Result<IngestResult> {
    let run_id = &uuid::Uuid::new_v4().to_string()[..8];
    let _root = tracing::info_span!("ingest", %run_id, resource_type).entered();
    // ... existing ingest code
}
```

CRITICAL: The _root guard must live for the entire function scope. If it drops early (e.g., shadowed or moved into a block), child spans lose their parent context. Use let _root (underscore prefix) to signal intentional unused binding that's kept alive for its Drop impl.

For async functions, use .entered() NOT .enter(). In async Rust, Span::enter() returns a guard that is NOT Send, which prevents the future from being sent across threads. However, .entered() on an info_span! creates an Entered<Span> which is also !Send. For async, prefer:

```rust
let root_span = tracing::info_span!("sync", %run_id);
async move {
    // ... body
}.instrument(root_span).await
```

Or use #[instrument] on the function itself with the run_id field.

## Acceptance Criteria
- [ ] Root span established for every sync and ingest invocation
- [ ] run_id appears in span context of all child log lines
- [ ] jq 'select(.spans[]? | .run_id)' can extract all lines from a run
- [ ] Span is active for entire function duration (not dropped early)
- [ ] Works correctly with async/await (span propagated across .await points)
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/cli/commands/sync.rs (add root span in run_sync, line ~54)
- src/cli/commands/ingest.rs (add root span in run_ingest, line ~107)

## TDD Loop
RED: test_root_span_propagates_run_id (capture JSON log output, verify run_id in span context)
GREEN: Add root spans to run_sync and run_ingest
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- Async span propagation: .entered() is !Send. For async functions, use .instrument() or #[instrument]. The run_sync function is async (line 54: pub async fn run_sync).
- Nested command calls: run_sync calls run_ingest internally. If both create root spans, we get a nested hierarchy: sync > ingest. This is correct behavior -- the ingest span becomes a child of sync.
- Span storage: tracing-subscriber registry handles span storage automatically. No manual setup needed beyond adding the layer.

Added root tracing spans with run_id to run_sync() and run_ingest() using .instrument() pattern for async compatibility


Implement lore stats CLI command

## Background
The stats command provides visibility into the document/search/embedding pipeline health. It reports counts (DocumentStats, EmbeddingStats, FtsStats, QueueStats), verifies consistency between tables (--check), and repairs inconsistencies (--repair). This is essential for diagnosing sync issues and validating Gate A/B/C correctness.

## Approach
Create `src/cli/commands/stats.rs` per PRD Section 4.6.

**Stats structs (PRD-exact):**
```rust
#[derive(Debug, Serialize)]
pub struct Stats {
    pub documents: DocumentStats,
    pub embeddings: EmbeddingStats,
    pub fts: FtsStats,
    pub queues: QueueStats,
}

#[derive(Debug, Serialize)]
pub struct DocumentStats {
    pub issues: usize,
    pub mrs: usize,
    pub discussions: usize,
    pub total: usize,
    pub truncated: usize,
}

#[derive(Debug, Serialize)]
pub struct EmbeddingStats {
    /// Documents with at least one embedding (chunk_index=0 exists in embedding_metadata)
    pub embedded: usize,
    pub pending: usize,
    pub failed: usize,
    /// embedded / total_documents * 100 (document-level, not chunk-level)
    pub coverage_pct: f64,
    /// Total chunks across all embedded documents
    pub total_chunks: usize,
}

#[derive(Debug, Serialize)]
pub struct FtsStats { pub indexed: usize }

#[derive(Debug, Serialize)]
pub struct QueueStats {
    pub dirty_sources: usize,
    pub dirty_sources_failed: usize,
    pub pending_discussion_fetches: usize,
    pub pending_discussion_fetches_failed: usize,
}
```

**IntegrityCheck struct (PRD-exact):**
```rust
#[derive(Debug, Serialize)]
pub struct IntegrityCheck {
    pub documents_count: usize,
    pub fts_count: usize,
    pub embeddings_count: usize,
    pub metadata_count: usize,
    pub orphaned_embeddings: usize,
    pub hash_mismatches: usize,
    pub ok: bool,
}
```

**RepairResult struct (PRD-exact):**
```rust
#[derive(Debug, Serialize)]
pub struct RepairResult {
    pub orphaned_embeddings_deleted: usize,
    pub stale_embeddings_cleared: usize,
    pub missing_fts_repopulated: usize,
}
```

**Core functions:**
- `run_stats(config) -> Result<Stats>` — gather all stats
- `run_integrity_check(config) -> Result<IntegrityCheck>` — verify consistency
- `run_repair(config) -> Result<RepairResult>` — fix issues

**Integrity checks (per PRD):**
1. documents count == documents_fts count
2. All `embeddings.rowid / 1000` map to valid `documents.id` (orphan detection)
3. `embedding_metadata.document_hash == documents.content_hash` for chunk_index=0 rows (staleness uses `document_hash`, NOT `chunk_hash`)

**Repair operations (PRD-exact):**
1. Delete orphaned embedding_metadata (document_id NOT IN documents)
2. Delete orphaned vec0 rows: `DELETE FROM embeddings WHERE rowid / 1000 NOT IN (SELECT id FROM documents)` — uses `rowid / 1000` for chunked scheme
3. Clear stale embeddings: find documents where `embedding_metadata.document_hash != documents.content_hash` (chunk_index=0 comparison), delete ALL chunks for those docs (range-based: `rowid >= doc_id * 1000 AND rowid < (doc_id + 1) * 1000`)
4. FTS rebuild: `INSERT INTO documents_fts(documents_fts) VALUES('rebuild')` — full rebuild, NOT optimize. PRD note: partial fix is fragile with external-content FTS; rebuild is guaranteed correct.

**CLI args (PRD-exact):**
```rust
#[derive(Args)]
pub struct StatsArgs {
    #[arg(long)]
    check: bool,
    #[arg(long, requires = "check")]
    repair: bool,  // --repair requires --check
}
```

## Acceptance Criteria
- [ ] Document counts by type: issues, mrs, discussions, total, truncated
- [ ] Embedding coverage is document-level (not chunk-level): `embedded / total * 100`
- [ ] Embedding stats include total_chunks count
- [ ] FTS indexed count reported
- [ ] Queue stats: dirty_sources + dirty_sources_failed, pending_discussion_fetches + pending_discussion_fetches_failed
- [ ] --check verifies: FTS count == documents count, orphan embeddings, hash mismatches
- [ ] Orphan detection uses `rowid / 1000` for chunked embedding scheme
- [ ] Hash mismatch uses `document_hash` (not `chunk_hash`) for document-level staleness
- [ ] --repair deletes orphaned embeddings (range-based for chunks)
- [ ] --repair clears stale metadata (document_hash != content_hash at chunk_index=0)
- [ ] --repair uses FTS `rebuild` (not `optimize`) for correct-by-construction repair
- [ ] --repair requires --check (Clap `requires` attribute)
- [ ] Human output: formatted with aligned columns
- [ ] JSON output: `{"ok": true, "data": stats}`
- [ ] `cargo build` succeeds

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

## TDD Loop
RED: Integration tests:
- `test_stats_empty_db` — all counts 0, coverage 0%
- `test_stats_with_documents` — correct counts by type
- `test_integrity_check_healthy` — ok=true when consistent
- `test_integrity_check_fts_mismatch` — detects FTS/doc count divergence
- `test_integrity_check_orphan_embeddings` — detects orphaned rowids
- `test_repair_rebuilds_fts` — FTS count matches after repair
- `test_repair_cleans_orphans` — orphaned embeddings deleted
- `test_repair_clears_stale` — stale metadata cleared (doc_hash mismatch)
GREEN: Implement stats, integrity check, repair
VERIFY: `cargo build && cargo test stats`

## Edge Cases
- Empty database: all counts 0, coverage 0%, no integrity issues
- Gate A only (no embeddings table): skip embedding stats gracefully
- --repair on healthy DB: no-op, reports "no issues found" / zero counts
- FTS rebuild on large DB: may be slow
- --repair without --check: Clap rejects (requires attribute enforces dependency)

Implemented stats CLI with document counts by type, embedding coverage, FTS index count, queue stats, --check integrity (FTS mismatch, orphan embeddings, stale metadata), --repair (rebuild FTS, delete orphans, clear stale). Human + JSON output. Builds clean.


Description

Another test


WHO: Workload mode query (query_workload)

## Background

Workload mode answers "What is person X working on?" — a four-section snapshot of a user's active work items: assigned issues, authored MRs, MRs they're reviewing, and unresolved discussions they participate in.

## Approach

Four independent SQL queries, all using the same parameter pattern: `rusqlite::params![username, project_id, since_ms, limit_plus_one]`

### Key design decisions:
- **since_ms is Option<i64>**: unlike other modes, Workload has NO default time window. Unresolved discussions and open issues are relevant regardless of age. When --since is explicitly provided, (?3 IS NULL OR ...) activates filtering.
- **Canonical refs**: SQL computes project-qualified references directly:
  - Issues: `p.path_with_namespace || '#' || i.iid` -> "group/project#42"
  - MRs: `p.path_with_namespace || '!' || m.iid` -> "group/project!100"
- **Discussions**: use EXISTS subquery to check user participation, CASE for ref separator (# vs !)

### Query 1: Open issues assigned to user
```sql
SELECT i.iid, (p.path_with_namespace || '#' || i.iid) AS ref,
       i.title, p.path_with_namespace, i.updated_at
FROM issues i
JOIN issue_assignees ia ON ia.issue_id = i.id
JOIN projects p ON i.project_id = p.id
WHERE ia.username = ?1 AND i.state = 'opened'
  AND (?2 IS NULL OR i.project_id = ?2)
  AND (?3 IS NULL OR i.updated_at >= ?3)
ORDER BY i.updated_at DESC LIMIT ?4
```

### Query 2: Open MRs authored (similar pattern, m.author_username = ?1)
### Query 3: Open MRs where user is reviewer (JOIN mr_reviewers, includes m.author_username in output)
### Query 4: Unresolved discussions where user participated (EXISTS notes subquery)

### Per-section truncation:
```rust
let assigned_issues_truncated = assigned_issues.len() > limit;
let assigned_issues = assigned_issues.into_iter().take(limit).collect();
// ... same for all 4 sections
```

### WorkloadResult struct:
```rust
pub struct WorkloadResult {
    pub username: String,
    pub assigned_issues: Vec<WorkloadIssue>,
    pub authored_mrs: Vec<WorkloadMr>,
    pub reviewing_mrs: Vec<WorkloadMr>,
    pub unresolved_discussions: Vec<WorkloadDiscussion>,
    pub assigned_issues_truncated: bool,
    pub authored_mrs_truncated: bool,
    pub reviewing_mrs_truncated: bool,
    pub unresolved_discussions_truncated: bool,
}
```

## Files

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

## TDD Loop

RED: `test_workload_query` — insert project, issue+assignee, MR; verify assigned_issues.len()=1, authored_mrs.len()=1
GREEN: Implement all 4 queries with prepare_cached()
VERIFY: `cargo test -- workload`

## Acceptance Criteria

- [ ] test_workload_query passes
- [ ] Canonical refs contain project path (group/project#iid format)
- [ ] since_ms=None means no time filtering (all open items returned)
- [ ] All 4 sections have independent truncation flags

## Edge Cases

- since_ms is Option<i64> (not i64) — Workload is the only mode with optional time window
- Discussions: --since filters on d.last_note_at (recent activity), not creation time
- Reviewing MRs: include m.author_username in output (who wrote the MR being reviewed)

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


Implement MR ingestion module

## Background
MR ingestion module with cursor-based sync. Follows the same pattern as issue ingestion from CP1. Discussion sync eligibility is determined via DB query AFTER ingestion (not in-memory collection) to avoid memory growth on large projects.

## Approach
Create `src/ingestion/merge_requests.rs` with:
1. `IngestMergeRequestsResult` - Aggregated stats
2. `ingest_merge_requests()` - Main ingestion function
3. `upsert_merge_request()` - Single MR upsert
4. Helper functions for labels, assignees, reviewers, cursor management

## Files
- `src/ingestion/merge_requests.rs` - New module
- `src/ingestion/mod.rs` - Export new module
- `tests/mr_ingestion_tests.rs` - Integration tests

## Acceptance Criteria
- [ ] `IngestMergeRequestsResult` has: fetched, upserted, labels_created, assignees_linked, reviewers_linked
- [ ] `ingest_merge_requests()` returns `Result<IngestMergeRequestsResult>`
- [ ] Page-boundary cursor updates (not item-count modulo)
- [ ] Tuple-based cursor filtering: `(updated_at, gitlab_id)`
- [ ] Transaction per MR for atomicity
- [ ] Raw payload stored for each MR
- [ ] Labels: clear-and-relink pattern (removes stale)
- [ ] Assignees: clear-and-relink pattern
- [ ] Reviewers: clear-and-relink pattern
- [ ] `reset_discussion_watermarks()` for --full sync
- [ ] `cargo test mr_ingestion` passes

## TDD Loop
RED: `cargo test ingest_mr` -> module not found
GREEN: Add ingestion module with full logic
VERIFY: `cargo test mr_ingestion`

## Main Function Signature
```rust
pub async fn ingest_merge_requests(
    conn: &Connection,
    client: &GitLabClient,
    config: &Config,
    project_id: i64,           // Local DB project ID
    gitlab_project_id: i64,    // GitLab project ID
    full_sync: bool,           // Reset cursor if true
) -> Result<IngestMergeRequestsResult>
```

## Ingestion Loop (page-based)
```rust
let mut page = 1u32;
loop {
    let page_result = client.fetch_merge_requests_page(...).await?;

    for mr in &page_result.items {
        // Tuple cursor filtering
        if let (Some(cursor_ts), Some(cursor_id)) = (cursor_updated_at, cursor_gitlab_id) {
            if mr_updated_at < cursor_ts { continue; }
            if mr_updated_at == cursor_ts && mr.id <= cursor_id { continue; }
        }

        // Begin transaction
        let tx = conn.unchecked_transaction()?;

        // Store raw payload
        let payload_id = store_payload(&tx, ...)?;

        // Transform and upsert
        let transformed = transform_merge_request(&mr, project_id)?;
        let upsert_result = upsert_merge_request(&tx, &transformed.merge_request, payload_id)?;

        // Clear-and-relink labels
        clear_mr_labels(&tx, local_mr_id)?;
        for label in &labels { ... }

        // Clear-and-relink assignees
        clear_mr_assignees(&tx, local_mr_id)?;
        for username in &transformed.assignee_usernames { ... }

        // Clear-and-relink reviewers
        clear_mr_reviewers(&tx, local_mr_id)?;
        for username in &transformed.reviewer_usernames { ... }

        tx.commit()?;

        // Track for cursor
        last_updated_at = Some(mr_updated_at);
        last_gitlab_id = Some(mr.id);
    }

    // Page-boundary cursor flush
    if let (Some(updated_at), Some(gitlab_id)) = (last_updated_at, last_gitlab_id) {
        update_cursor(conn, project_id, "merge_requests", updated_at, gitlab_id)?;
    }

    if page_result.is_last_page { break; }
    page = page_result.next_page.unwrap_or(page + 1);
}
```

## Full Sync Watermark Reset
```rust
fn reset_discussion_watermarks(conn: &Connection, project_id: i64) -> Result<()> {
    conn.execute(
        "UPDATE merge_requests
         SET discussions_synced_for_updated_at = NULL,
             discussions_sync_attempts = 0,
             discussions_sync_last_error = NULL
         WHERE project_id = ?",
        [project_id],
    )?;
    Ok(())
}
```

## DB Helper Functions
- `get_cursor(conn, project_id) -> (Option<i64>, Option<i64>)` - Get (updated_at, gitlab_id)
- `update_cursor(conn, project_id, resource_type, updated_at, gitlab_id)`
- `reset_cursor(conn, project_id, resource_type)`
- `upsert_merge_request(conn, mr, payload_id) -> Result<UpsertMergeRequestResult>`
- `clear_mr_labels(conn, mr_id)`
- `link_mr_label(conn, mr_id, label_id)`
- `clear_mr_assignees(conn, mr_id)`
- `upsert_mr_assignee(conn, mr_id, username)`
- `clear_mr_reviewers(conn, mr_id)`
- `upsert_mr_reviewer(conn, mr_id, username)`

## Edge Cases
- Cursor rewind may cause refetch of already-seen MRs (tuple filtering handles this)
- Large projects: 10k+ MRs - page-based cursor prevents massive refetch on crash
- Labels/assignees/reviewers may change - clear-and-relink ensures correctness


Add Resource Events API endpoints to GitLab client

## Background
Need paginated fetching of state/label/milestone events per entity from GitLab Resource Events APIs. The existing client uses reqwest with rate limiting and has stream_issues/stream_merge_requests patterns for paginated endpoints. However, resource events are per-entity (not project-wide), so they should return Vec rather than use streaming.

Existing pagination pattern in client.rs: follow Link headers with per_page=100.

## Approach
Add to src/gitlab/client.rs a generic helper and 6 endpoint methods:

1. Generic paginated fetch helper (if not already present):
```rust
async fn fetch_all_pages<T: DeserializeOwned>(&self, url: &str) -> Result<Vec<T>> {
    let mut results = Vec::new();
    let mut next_url = Some(url.to_string());
    while let Some(current_url) = next_url {
        self.rate_limiter.lock().unwrap().wait();
        let resp = self.client.get(&current_url)
            .header("PRIVATE-TOKEN", &self.token)
            .query(&[("per_page", "100")])
            .send().await?;
        // ... parse Link header for next page
        let page: Vec<T> = resp.json().await?;
        results.extend(page);
        next_url = parse_next_link(&resp_headers);
    }
    Ok(results)
}
```

2. Six endpoint methods:
```rust
pub async fn fetch_issue_state_events(&self, project_id: i64, iid: i64) -> Result<Vec<GitLabStateEvent>>
pub async fn fetch_issue_label_events(&self, project_id: i64, iid: i64) -> Result<Vec<GitLabLabelEvent>>
pub async fn fetch_issue_milestone_events(&self, project_id: i64, iid: i64) -> Result<Vec<GitLabMilestoneEvent>>
pub async fn fetch_mr_state_events(&self, project_id: i64, iid: i64) -> Result<Vec<GitLabStateEvent>>
pub async fn fetch_mr_label_events(&self, project_id: i64, iid: i64) -> Result<Vec<GitLabLabelEvent>>
pub async fn fetch_mr_milestone_events(&self, project_id: i64, iid: i64) -> Result<Vec<GitLabMilestoneEvent>>
```

URL patterns:
- Issues: `/api/v4/projects/{project_id}/issues/{iid}/resource_{type}_events`
- MRs: `/api/v4/projects/{project_id}/merge_requests/{iid}/resource_{type}_events`

3. Consider a convenience method that fetches all 3 event types for an entity in one call:
```rust
pub async fn fetch_all_resource_events(&self, project_id: i64, entity_type: &str, iid: i64)
    -> Result<(Vec<GitLabStateEvent>, Vec<GitLabLabelEvent>, Vec<GitLabMilestoneEvent>)>
```

## Acceptance Criteria
- [ ] All 6 endpoints construct correct URLs
- [ ] Pagination follows Link headers (handles entities with >100 events)
- [ ] Rate limiter respected for each page request
- [ ] 404 returns GitLabNotFound error (entity may have been deleted)
- [ ] Network errors wrapped in GitLabNetworkError
- [ ] Types from bd-2fm used for deserialization

## Files
- src/gitlab/client.rs (add methods + optionally generic helper)

## TDD Loop
RED: Add to tests/gitlab_client_tests.rs (or new file):
- `test_fetch_issue_state_events_url` - verify URL construction (mock or inspect)
- `test_fetch_mr_label_events_url` - verify URL construction
- Note: Full integration tests require a mock HTTP server (mockito or wiremock). If the project doesn't already have one, write URL-construction unit tests only.

GREEN: Implement the 6 methods using the generic helper

VERIFY: `cargo test gitlab_client -- --nocapture && cargo build`

## Edge Cases
- project_id here is the GitLab project ID (not local DB id) — callers must pass gitlab_project_id
- Empty results (new entity with no events) should return Ok(Vec::new()), not error
- GitLab returns 403 for projects where Resource Events API is disabled — map to appropriate error
- Very old entities may have thousands of events — pagination is essential
- Rate limiter must be called per-page, not per-entity

Added fetch_all_pages<T> generic paginator, 6 per-entity endpoint methods (state/label/milestone for issues and MRs), and fetch_all_resource_events convenience method in src/gitlab/client.rs.


WHO: Comprehensive test suite

## Background

20+ tests covering mode resolution, path query construction, SQL queries, and edge cases. All tests use in-memory SQLite with run_migrations().

## Approach

### Test helpers (shared across all tests):
```rust
fn setup_test_db() -> Connection {
    let conn = create_connection(Path::new(":memory:")).unwrap();
    run_migrations(&conn).unwrap();
    conn
}
fn insert_project(conn, id, path) // gitlab_project_id=id*100, web_url from path
fn insert_mr(conn, id, project_id, iid, author, state) // gitlab_id=id*10, timestamps=now_ms()
fn insert_issue(conn, id, project_id, iid, author) // state='opened'
fn insert_discussion(conn, id, project_id, mr_id, issue_id, resolvable, resolved)
#[allow(clippy::too_many_arguments)]
fn insert_diffnote(conn, id, discussion_id, project_id, author, file_path, body)
fn insert_assignee(conn, issue_id, username)
fn insert_reviewer(conn, mr_id, username)
```

### Test list with key assertions:

**Mode resolution:**
- test_is_file_path_discrimination: src/auth/ -> Expert, asmith -> Workload, @asmith -> Workload, asmith+--reviews -> Reviews, --path README.md -> Expert, --path Makefile -> Expert

**Path queries:**
- test_build_path_query: trailing/ -> prefix, no-dot-no-slash -> prefix, file.ext -> exact, root.md -> exact, .github/workflows/ -> prefix, v1.2/auth/ -> prefix, test_files/ -> escaped prefix
- test_build_path_query_exact_does_not_escape: README_with_underscore.md -> raw (no \\_)
- test_path_flag_dotless_root_file_is_exact: Makefile -> exact, Dockerfile -> exact
- test_build_path_query_dotless_subdir_file_uses_db_probe: src/Dockerfile with DB data -> exact; without -> prefix
- test_build_path_query_probe_is_project_scoped: data in proj 1, unscoped -> exact; scoped proj 2 -> prefix; scoped proj 1 -> exact
- test_escape_like: normal->normal, has_underscore->has\\_underscore, has%percent->has\\%percent
- test_normalize_repo_path: ./src/ -> src/, /src/ -> src/, ././src -> src, backslash conversion, // collapse, whitespace trim

**Queries:**
- test_expert_query: 3 experts ranked correctly, reviewer_b first
- test_expert_excludes_self_review_notes: author_a review_mr_count=0, author_mr_count>0
- test_expert_truncation: limit=2 truncated=true len=2; limit=10 truncated=false
- test_workload_query: assigned_issues.len()=1, authored_mrs.len()=1
- test_reviews_query: total=3, categorized=2, categories.len()=2
- test_normalize_review_prefix: suggestion/Suggestion:/nit/nitpick/non-blocking/TODO
- test_active_query: total=1, discussions.len()=1, note_count=2 (NOT 1), discussion_id>0
- test_active_participants_sorted: ["alpha_user", "zebra_user"]
- test_overlap_dual_roles: A+R role, both touch counts >0, mr_refs contain project path
- test_overlap_multi_project_mr_refs: team/backend!100 AND team/frontend!100 present
- test_overlap_excludes_self_review_notes: review_touch_count=0
- test_lookup_project_path: round-trip "team/backend"

## Files

- `src/cli/commands/who.rs` (inside #[cfg(test)] mod tests)

## TDD Loop

Tests are written alongside each query bead (RED phase). This bead tracks the full test suite as a verification gate.
VERIFY: `cargo test -- who`

## Acceptance Criteria

- [ ] All 20+ tests pass
- [ ] cargo test -- who shows 0 failures
- [ ] No clippy warnings from test code (use #[allow(clippy::too_many_arguments)] on insert_diffnote)

## Edge Cases

- In-memory DB includes migration 017 (indexes created but no real data perf benefit)
- Test timestamps use now_ms() — tests are time-independent (since_ms=0 in most queries)
- insert_mr uses gitlab_id=id*10 to avoid conflicts


Implement generic dependent fetch queue (enqueue + drain)

## Background
The pending_dependent_fetches table (migration 011) provides a generic job queue for all dependent resource fetches across Gates 1, 2, and 4. This module implements the queue operations: enqueue, claim, complete, fail, and stale lock reclamation. It generalizes the existing discussion_queue.rs pattern.

## Approach
Create src/core/dependent_queue.rs with:

```rust
use rusqlite::Connection;
use super::error::Result;

/// A pending job from the dependent fetch queue.
pub struct PendingJob {
    pub id: i64,
    pub project_id: i64,
    pub entity_type: String,      // "issue" | "merge_request"
    pub entity_iid: i64,
    pub entity_local_id: i64,
    pub job_type: String,         // "resource_events" | "mr_closes_issues" | "mr_diffs"
    pub payload_json: Option<String>,
    pub attempts: i32,
}

/// Enqueue a dependent fetch job. Idempotent via UNIQUE constraint (INSERT OR IGNORE).
pub fn enqueue_job(
    conn: &Connection,
    project_id: i64,
    entity_type: &str,
    entity_iid: i64,
    entity_local_id: i64,
    job_type: &str,
    payload_json: Option<&str>,
) -> Result<bool>  // returns true if actually inserted (not deduped)

/// Claim a batch of jobs for processing. Atomically sets locked_at.
/// Only claims jobs where locked_at IS NULL AND (next_retry_at IS NULL OR next_retry_at <= now).
pub fn claim_jobs(
    conn: &Connection,
    job_type: &str,
    batch_size: usize,
) -> Result<Vec<PendingJob>>

/// Mark a job as complete (DELETE the row).
pub fn complete_job(conn: &Connection, job_id: i64) -> Result<()>

/// Mark a job as failed. Increment attempts, set next_retry_at with exponential backoff, clear locked_at.
/// Backoff: 30s * 2^(attempts-1), capped at 480s.
pub fn fail_job(conn: &Connection, job_id: i64, error: &str) -> Result<()>

/// Reclaim stale locks (locked_at older than threshold).
/// Returns count of reclaimed jobs.
pub fn reclaim_stale_locks(conn: &Connection, stale_threshold_minutes: u32) -> Result<usize>

/// Count pending jobs by job_type (for stats/progress).
pub fn count_pending_jobs(conn: &Connection) -> Result<HashMap<String, usize>>
```

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

**Key implementation details:**
- claim_jobs uses a two-step approach: SELECT ids WHERE available, then UPDATE SET locked_at for those ids. Use a single transaction.
- enqueued_at = current time in ms epoch UTC
- locked_at = current time in ms epoch UTC when claimed
- Backoff formula: next_retry_at = now + min(30_000 * 2^(attempts-1), 480_000) ms

## Acceptance Criteria
- [ ] enqueue_job is idempotent (INSERT OR IGNORE on UNIQUE constraint)
- [ ] enqueue_job returns true on insert, false on dedup
- [ ] claim_jobs only claims unlocked, non-retrying jobs
- [ ] claim_jobs respects batch_size limit
- [ ] complete_job DELETEs the row
- [ ] fail_job increments attempts, sets next_retry_at, clears locked_at, records last_error
- [ ] Backoff: 30s, 60s, 120s, 240s, 480s (capped)
- [ ] reclaim_stale_locks clears locked_at for jobs older than threshold
- [ ] count_pending_jobs returns accurate counts by job_type

## Files
- src/core/dependent_queue.rs (new)
- src/core/mod.rs (add `pub mod dependent_queue;`)

## TDD Loop
RED: tests/dependent_queue_tests.rs (new):
- `test_enqueue_job_basic` - enqueue a job, verify it exists
- `test_enqueue_job_idempotent` - enqueue same job twice, verify single row
- `test_claim_jobs_batch` - enqueue 5, claim 3, verify 3 returned and locked
- `test_claim_jobs_skips_locked` - lock a job, claim again, verify it's skipped
- `test_claim_jobs_respects_retry_at` - set next_retry_at in future, verify skipped
- `test_claim_jobs_includes_retryable` - set next_retry_at in past, verify claimed
- `test_complete_job_deletes` - complete a job, verify gone
- `test_fail_job_backoff` - fail 3 times, verify exponential next_retry_at values
- `test_reclaim_stale_locks` - set old locked_at, reclaim, verify cleared

Setup: create_test_db() with migrations 001-011, seed project + issue.

GREEN: Implement all functions

VERIFY: `cargo test dependent_queue -- --nocapture`

## Edge Cases
- claim_jobs with batch_size=0 should return empty vec (not error)
- enqueue_job with invalid job_type will be rejected by CHECK constraint — map rusqlite error to LoreError
- fail_job on a non-existent job_id should be a no-op (job may have been completed by another path)
- reclaim_stale_locks with 0 threshold would reclaim everything — ensure threshold is reasonable (minimum 1 min)
- Timestamps must use consistent ms epoch UTC (not seconds)

Implemented PendingJob struct, enqueue_job, claim_jobs, complete_job, fail_job (with exponential backoff), reclaim_stale_locks, count_pending_jobs in src/core/dependent_queue.rs.


Add sync_runs migration 027 for surgical mode columns

## Background
The `sync_runs` table (created in migration 001, enriched in 014) tracks sync run lifecycle for observability and crash recovery. Surgical sync needs additional columns to track its distinct mode, phase progression, IID targeting, and per-stage counters. This is a schema-only change — no Rust code changes beyond registering the migration file.

The migration system uses a `MIGRATIONS` array in `src/core/db.rs`. Each entry is a `(version, sql_file_name)` tuple. SQL files live in `src/core/migrations/`. The current latest migration is 026 (`026_scoring_indexes.sql`), so this will be migration 027.

## Approach

### Migration SQL file: `src/core/migrations/027_surgical_sync_runs.sql`

```sql
-- Migration 027: Extend sync_runs for surgical sync observability
-- Adds mode/phase tracking and surgical-specific counters.
-- Reuses existing sync_runs row lifecycle (SyncRunRecorder).

ALTER TABLE sync_runs ADD COLUMN mode TEXT;
ALTER TABLE sync_runs ADD COLUMN phase TEXT;
ALTER TABLE sync_runs ADD COLUMN surgical_iids_json TEXT;
ALTER TABLE sync_runs ADD COLUMN issues_fetched INTEGER NOT NULL DEFAULT 0;
ALTER TABLE sync_runs ADD COLUMN mrs_fetched INTEGER NOT NULL DEFAULT 0;
ALTER TABLE sync_runs ADD COLUMN issues_ingested INTEGER NOT NULL DEFAULT 0;
ALTER TABLE sync_runs ADD COLUMN mrs_ingested INTEGER NOT NULL DEFAULT 0;
ALTER TABLE sync_runs ADD COLUMN skipped_stale INTEGER NOT NULL DEFAULT 0;
ALTER TABLE sync_runs ADD COLUMN docs_regenerated INTEGER NOT NULL DEFAULT 0;
ALTER TABLE sync_runs ADD COLUMN docs_embedded INTEGER NOT NULL DEFAULT 0;
ALTER TABLE sync_runs ADD COLUMN warnings_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE sync_runs ADD COLUMN cancelled_at INTEGER;

CREATE INDEX IF NOT EXISTS idx_sync_runs_mode_started
    ON sync_runs(mode, started_at DESC);
CREATE INDEX IF NOT EXISTS idx_sync_runs_status_phase_started
    ON sync_runs(status, phase, started_at DESC);
```

**Column semantics:**
- `mode`: 'standard' or 'surgical' (NULL for pre-migration rows)
- `phase`: preflight → ingest → dependents → docs → embed → done / failed / cancelled
- `surgical_iids_json`: JSON like `{"issues":[7,8],"mrs":[101]}`
- Counter columns: integers with DEFAULT 0 for backward compat
- `cancelled_at`: ms-epoch timestamp, NULL unless cancelled

### Register in MIGRATIONS array: `src/core/db.rs`

Add to the `MIGRATIONS` array (currently has 26 entries ending with `026_scoring_indexes.sql`):

```rust
(27, include_str\!("migrations/027_surgical_sync_runs.sql")),
```

`LATEST_SCHEMA_VERSION` is computed as `MIGRATIONS.len() as i32`, so it will automatically become 27.

## Acceptance Criteria
- [ ] File `src/core/migrations/027_surgical_sync_runs.sql` exists with all ALTER TABLE and CREATE INDEX statements
- [ ] Migration 027 is registered in MIGRATIONS array in `src/core/db.rs`
- [ ] `LATEST_SCHEMA_VERSION` evaluates to 27
- [ ] Migration runs successfully on existing databases (all ALTERs are additive, NULLable or with DEFAULT)
- [ ] Migration runs successfully on fresh databases (in-memory test)
- [ ] Pre-existing sync_runs rows are unaffected (NULL mode/phase, 0 counters)
- [ ] `cargo check --all-targets` passes
- [ ] `cargo test` passes (migration tests use in-memory DB)

## Files
- CREATE: src/core/migrations/027_surgical_sync_runs.sql
- MODIFY: src/core/db.rs (add entry to MIGRATIONS array)

## TDD Anchor
RED: Write `sync_run_surgical_columns_exist` test in `src/core/db.rs` tests (or surgical_tests.rs):
```rust
#[test]
fn sync_run_surgical_columns_exist() {
    let conn = create_connection(Path::new(":memory:")).unwrap();
    run_migrations(&conn).unwrap();
    conn.execute(
        "INSERT INTO sync_runs (started_at, heartbeat_at, status, command, mode, phase, surgical_iids_json)
         VALUES (1000, 1000, 'running', 'sync', 'surgical', 'preflight', '[7]')",
        [],
    ).unwrap();
    let phase: String = conn.query_row(
        "SELECT phase FROM sync_runs WHERE mode = 'surgical'", [], |r| r.get(0),
    ).unwrap();
    assert_eq\!(phase, "preflight");
}
```
GREEN: Create the SQL file and register the migration.
VERIFY: `cargo test sync_run_surgical`

## Edge Cases
- SQLite ALTER TABLE ADD COLUMN requires DEFAULT for NOT NULL columns. All counter columns use DEFAULT 0.
- mode/phase/surgical_iids_json are nullable TEXT (no DEFAULT needed for nullable).
- The indexes include mode and status for efficient observability queries.

## Dependency Context
None — this is a leaf dependency. Can be done in parallel with other foundation beads.


Implement Who screen (5 modes: expert/workload/reviews/active/overlap)

## Background
The Who screen is the people explorer, showing contributor expertise and workload across 5 modes. Each mode renders differently: Expert shows file-path expertise scores, Workload shows issue/MR assignment counts, Reviews shows review activity, Active shows recent contributors, Overlap shows shared file knowledge.

## Approach
State (state/who.rs):
- WhoState: mode (WhoMode), results (WhoResult), path (String), path_input (TextInput), path_focused (bool), selected_index (usize)
- WhoMode: Expert, Workload, Reviews, Active, Overlap
- WhoResult: variant per mode with different data shapes

Action (action.rs):
- fetch_who(conn, mode, path, limit) -> Result<WhoResult, LoreError>: dispatches to existing who query functions in lore CLI (query_experts, query_workload, etc.)

View (view/who.rs):
- Mode tabs at top: E(xpert) | W(orkload) | R(eviews) | A(ctive) | O(verlap)
- Expert: path input + sorted table of authors by expertise score + bar chart
- Workload: stacked bar chart of open issues/MRs per person
- Reviews: table of review counts (given/received) per person
- Active: time-sorted list of recent contributors
- Overlap: matrix or pair-wise table showing shared file knowledge
- Keyboard: 1-5 or Tab to switch modes, j/k scroll, / focus path input

## Acceptance Criteria
- [ ] 5 modes switchable via Tab or number keys
- [ ] Expert mode: path input filters by file path, shows expertise scores
- [ ] Workload mode: shows assignment counts per person
- [ ] Reviews mode: shows review activity counts
- [ ] Active mode: shows recent contributors sorted by activity
- [ ] Overlap mode: shows shared knowledge between contributors
- [ ] Each mode renders appropriate visualization
- [ ] Enter on a person navigates to their issues (scoped issue list)

## Files
- MODIFY: crates/lore-tui/src/state/who.rs (expand from stub)
- MODIFY: crates/lore-tui/src/action.rs (add fetch_who)
- CREATE: crates/lore-tui/src/view/who.rs

## TDD Anchor
RED: Write test_fetch_who_expert that creates notes with diff paths, calls fetch_who(Expert, "src/"), asserts authors sorted by expertise score.
GREEN: Implement fetch_who dispatching to existing who queries.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_fetch_who

## Edge Cases
- Empty results for a mode: show "No data for this mode" message
- Expert mode with no diff notes: explain that expert data requires diff notes to be synced
- Very long file paths in Expert mode: truncate from left (show ...path/to/file.rs)

## Dependency Context
Uses existing who query functions from src/cli/commands/who.rs (made pub).
Uses WhoState from "Implement AppState composition" task.


[CP1] gi ingest --type=issues command

## Background

The `gi ingest --type=issues` command is the main entry point for issue ingestion. It acquires a single-flight lock, calls the orchestrator for each configured project, and outputs progress/summary to the user.

## Approach

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

### Clap Definition

```rust
#[derive(Args)]
pub struct IngestArgs {
    /// Resource type to ingest
    #[arg(long, value_parser = ["issues", "merge_requests"])]
    pub r#type: String,

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

    /// Override stale sync lock
    #[arg(long)]
    pub force: bool,
}
```

### Handler Function

```rust
pub async fn handle_ingest(args: IngestArgs, config: &Config) -> Result<()>
```

### Logic

1. **Acquire single-flight lock**: `acquire_sync_lock(conn, args.force)?`
2. **Get projects to sync**:
   - If `args.project` specified, filter to that one
   - Otherwise, get all configured projects from DB
3. **For each project**:
   - Print "Ingesting issues for {project_path}..."
   - Call `ingest_project_issues(conn, client, config, project_id, gitlab_project_id)`
   - Print "{N} issues fetched, {M} new labels"
4. **Print discussion sync summary**:
   - "Fetching discussions ({N} issues with updates)..."
   - "{N} discussions, {M} notes (excluding {K} system notes)"
   - "Skipped discussion sync for {N} unchanged issues."
5. **Release lock**: Lock auto-released when handler returns

### Output Format (matches PRD)

```
Ingesting issues...

  group/project-one: 1,234 issues fetched, 45 new labels

Fetching discussions (312 issues with updates)...

  group/project-one: 312 issues → 1,234 discussions, 5,678 notes

Total: 1,234 issues, 1,234 discussions, 5,678 notes (excluding 1,234 system notes)
Skipped discussion sync for 922 unchanged issues.
```

## Acceptance Criteria

- [ ] Clap args parse --type, --project, --force correctly
- [ ] Single-flight lock acquired before sync starts
- [ ] Lock error message is clear if concurrent run attempted
- [ ] Progress output shows per-project counts
- [ ] Summary includes unchanged issues skipped count
- [ ] --force flag allows overriding stale lock

## Files

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

## TDD Loop

RED:
```rust
// tests/cli_ingest_tests.rs
#[tokio::test] async fn ingest_issues_acquires_lock()
#[tokio::test] async fn ingest_issues_fails_on_concurrent_run()
#[tokio::test] async fn ingest_issues_respects_project_filter()
#[tokio::test] async fn ingest_issues_force_overrides_stale_lock()
```

GREEN: Implement handler with lock and orchestrator calls

VERIFY: `cargo test cli_ingest`

## Edge Cases

- No projects configured - return early with helpful message
- Project filter matches nothing - error with "project not found"
- Lock already held - clear error "Sync already in progress"
- Ctrl-C during sync - lock should be released (via Drop or SIGINT handler)


Description

This is a test


Extend SyncResult with surgical mode fields for robot output

## Background
Robot mode needs rich structured output for surgical sync: per-entity failures, per-entity outcomes, stage timings, TOCTOU skip counts, recovery actions, and completion status. These fields are added to the existing `SyncResult` struct.

## Approach

**File: `src/cli/commands/sync.rs`** (SyncResult at line ~31)

Add fields to the existing `SyncResult` struct:

```rust
#[derive(Debug, Default, Serialize)]
pub struct SyncResult {
    // ... existing fields ...

    // Surgical sync metadata
    pub entity_failures: Vec<EntityFailure>,
    pub entity_outcomes: Vec<EntityOutcome>,
    pub requested_count: usize,
    pub fetched_count: usize,
    pub processed_count: usize,
    pub skipped_stale: usize,
    pub skipped_stale_iids: Vec<(String, u64)>,
    pub stage_timings_ms: std::collections::BTreeMap<String, u64>,
    pub recovery_actions: Vec<String>,
    pub completion_status: String,
}
```

Add `EntityOutcome` struct:

```rust
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct EntityOutcome {
    pub entity_type: String,
    pub iid: u64,
    pub fetched: bool,
    pub ingested: bool,
    pub stale_skipped: bool,
    pub dependent_failures: Vec<EntityFailure>,
}
```

Import `EntityFailure` from `crate::ingestion::surgical`.

Ensure all new fields have sensible Default values (empty vecs, 0, empty string/map). The Serialize derive handles JSON output automatically.

Standard sync calls should be unaffected — the new fields default to empty/zero.

## Acceptance Criteria
- [ ] SyncResult has all new fields: entity_failures, entity_outcomes, requested_count, fetched_count, processed_count, skipped_stale, skipped_stale_iids, stage_timings_ms, recovery_actions, completion_status
- [ ] EntityOutcome struct exists with Serialize derive
- [ ] EntityFailure is imported from surgical module
- [ ] All new fields have Default implementations
- [ ] Standard sync still works (defaults for new fields)
- [ ] Robot mode JSON includes new fields when populated
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files
- MODIFY: src/cli/commands/sync.rs (extend SyncResult, add EntityOutcome)

## TDD Anchor
This is a type-only change. Verify with `cargo check --all-targets`.

## Edge Cases
- `EntityFailure` uses `&'static str` for some fields. If Serialize is needed, it must derive Serialize (add `#[derive(serde::Serialize)]` to EntityFailure in surgical.rs).
- `BTreeMap<String, u64>` serializes as a JSON object with string keys — good for stage_timings_ms.
- Existing code that constructs `SyncResult::default()` or uses `..Default::default()` must continue working.

## Dependency Context
Depends on **bd-3sez** (surgical.rs module): imports EntityFailure type.


Implement performance benchmark fixtures (S/M/L tiers)

## Background
Tiered performance fixtures validate latency at three data scales. S and M tiers are CI-enforced gates; L tier is advisory. Fixtures are synthetic SQLite databases with realistic data distributions.

## Approach
Fixture generator (benches/ or tests/fixtures/):
- S-tier: 10k issues, 5k MRs, 50k notes, 10k docs
- M-tier: 100k issues, 50k MRs, 500k notes, 50k docs
- L-tier: 250k issues, 100k MRs, 1M notes, 100k docs
- Realistic distributions: state (60% closed, 30% opened, 10% other), authors from pool of 50 names, labels from pool of 20, dates spanning 2 years

Benchmarks:
- p95 first-paint latency: Dashboard load, Issue List load, MR List load
- p95 keyset pagination: next page fetch
- p95 search latency: lexical and hybrid modes
- Memory ceiling: RSS after full dashboard + list load
- SLO assertions per tier (see Phase 0 criteria)

Required indexes must be present in fixture DBs:
- idx_issues_list_default, idx_mrs_list_default, idx_discussions_entity, idx_notes_discussion

## Acceptance Criteria
- [ ] S-tier fixture generated with correct counts
- [ ] M-tier fixture generated with correct counts
- [ ] L-tier fixture generated (on-demand, not CI)
- [ ] p95 first-paint < 50ms (S), < 75ms (M), < 150ms (L)
- [ ] p95 keyset pagination < 50ms (S), < 75ms (M), < 100ms (L)
- [ ] p95 search latency < 100ms (S), < 200ms (M), < 400ms (L)
- [ ] Memory < 150MB RSS (S), < 250MB RSS (M)
- [ ] All required indexes present in fixtures
- [ ] EXPLAIN QUERY PLAN shows index usage for top 10 queries

## Files
- CREATE: crates/lore-tui/benches/perf_benchmarks.rs
- CREATE: crates/lore-tui/tests/fixtures/generate_fixtures.rs

## TDD Anchor
RED: Write benchmark_dashboard_load_s_tier that generates S-tier fixture, measures Dashboard load time, asserts p95 < 50ms.
GREEN: Implement fetch_dashboard with efficient queries.
VERIFY: cargo bench --manifest-path crates/lore-tui/Cargo.toml

## Edge Cases
- Fixture generation must be deterministic (seeded RNG) for reproducible benchmarks
- CI machines may be slower — use generous multipliers or relative thresholds
- S-tier fits in memory; M-tier requires WAL mode for concurrent access
- Benchmark warmup: discard first 5 iterations

## Dependency Context
Uses all action.rs query functions from Phase 2/3 tasks.
Uses DbManager from "Implement DbManager" task.
Uses required index migrations from the main lore crate.


Implement CLI/TUI parity tests (counts, lists, detail, search, sanitization)

## Background
Parity tests ensure the TUI and CLI show the same data. Both interfaces query the same SQLite database, but through different code paths (TUI action functions vs CLI command handlers). Drift can occur when query functions are duplicated or modified independently. These tests catch drift by running both code paths against the same in-memory DB and comparing results.

## Approach

### Test Strategy: Library-Level (Same Process)
Tests run in the same process with a shared in-memory SQLite DB. No binary execution, no JSON parsing, no process spawning. Both TUI action functions and CLI query functions are called as library code.

Setup pattern:
```rust
use lore::core::db::{create_connection, run_migrations};
use std::path::Path;

fn setup_parity_db() -> rusqlite::Connection {
    let conn = create_connection(Path::new(":memory:")).unwrap();
    run_migrations(&conn).unwrap();
    insert_fixture_data(&conn);  // shared fixture with known counts
    conn
}
```

### Fixture Data
Create a deterministic fixture with known quantities:
- 1 project (gitlab_project_id=1, path_with_namespace="group/repo", web_url="https://gitlab.example.com/group/repo")
- 15 issues (5 opened, 5 closed, 5 with various states)
- 10 merge_requests (3 opened, 3 merged, 2 closed, 2 draft)
- 30 discussions (20 for issues, 10 for MRs)
- 60 notes (2 per discussion)
- Insert via direct SQL (same pattern as existing tests in src/core/db.rs)

### Parity Checks

**Dashboard Count Parity:**
- TUI: call the dashboard fetch function that returns entity counts
- CLI: call the same count query functions used by `lore --robot count`
- Assert: issue_count, mr_count, discussion_count, note_count all match

**Issue List Parity:**
- TUI: call issue list action with default filter (state=all, limit=50, sort=updated_at DESC)
- CLI: call the issue list query used by `lore --robot issues`
- Assert: same IIDs in same order, same state values for each

**MR List Parity:**
- TUI: call MR list action with default filter
- CLI: call the MR list query used by `lore --robot mrs`
- Assert: same IIDs in same order, same state values, same draft flags

**Issue Detail Parity:**
- TUI: call issue detail fetch for a specific IID
- CLI: call the issue detail query used by `lore --robot issues <iid>`
- Assert: same metadata fields (title, state, author, labels, created_at, updated_at), same discussion count

**Search Parity:**
- TUI: call search action with a known query term
- CLI: call the search function used by `lore --robot search`
- Assert: same document IDs returned in same rank order

**Sanitization Parity:**
- Insert an issue with ANSI escape sequences in the title: "Normal \x1b[31mRED\x1b[0m text"
- TUI: fetch and sanitize via terminal safety module
- CLI: fetch and render via robot mode (which strips ANSI)
- Assert: both produce clean output without raw escape sequences

## Acceptance Criteria
- [ ] Dashboard counts: TUI == CLI for issues, MRs, discussions, notes on shared fixture
- [ ] Issue list: TUI returns same IIDs in same order as CLI query function
- [ ] MR list: TUI returns same IIDs in same order as CLI query function
- [ ] Issue detail: TUI metadata matches CLI for title, state, author, discussion count
- [ ] Search results: same document IDs in same rank order
- [ ] Sanitization: both strip ANSI escape sequences from issue titles
- [ ] All tests use in-memory DB (no file I/O, no binary spawning)
- [ ] Tests are deterministic (fixed fixture, no wall clock dependency)

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

## TDD Anchor
RED: Write `test_dashboard_count_parity` that creates shared fixture DB, calls both TUI dashboard fetch and CLI count query functions, asserts all counts equal.
GREEN: Ensure TUI query functions exist and match CLI query logic.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml parity

Additional tests:
- test_issue_list_parity
- test_mr_list_parity
- test_issue_detail_parity
- test_search_parity
- test_sanitization_parity

## Edge Cases
- CLI and TUI may use different default sort orders — normalize to same ORDER BY in test setup
- CLI list commands default to limit=50, TUI may default to page size — test with explicit limit
- Fixture must include edge cases: NULL labels, empty descriptions, issues with work item status set
- Schema version must match between both code paths (same migration version)
- FTS index must be populated for search parity (call generate-docs equivalent on fixture)

## Dependency Context
- Uses TUI action functions from Phase 2/3 screen beads (must exist as library code)
- Uses CLI query functions from src/cli/ (already exist as `lore` library exports)
- Uses lore::core::db for shared DB setup
- Uses terminal safety module (bd-3ir1) for sanitization comparison
- Depends on bd-14hv (soak tests) being complete per phase ordering


Implement Command Palette (state + view)

## Background
The Command Palette is a modal overlay (Ctrl+P) that provides fuzzy-match access to all commands. It uses FrankenTUI's built-in CommandPalette widget and is populated from the CommandRegistry.

## Approach
State (state/command_palette.rs):
- CommandPaletteState: wraps ftui CommandPalette widget state
- input (String), filtered_commands (Vec<CommandDef>), selected_index (usize), visible (bool)

View (view/command_palette.rs):
- Modal overlay centered on screen (60% width, 50% height)
- Text input at top for fuzzy search
- Scrollable list of matching commands with keybinding hints
- Enter executes selected command, Esc closes palette
- Fuzzy matching: subsequence match on command label and help text

Integration:
- Ctrl+P from any screen opens palette (handled in interpret_key stage 2)
- execute_palette_action() in app.rs converts selected command to Msg

## Acceptance Criteria
- [ ] Ctrl+P opens palette from any screen in Normal mode
- [ ] Fuzzy matching filters commands as user types
- [ ] Commands show label + keybinding + help text
- [ ] Enter executes selected command
- [ ] Esc closes palette without action
- [ ] Palette populated from CommandRegistry (single source of truth)
- [ ] Modal renders on top of current screen content

## Files
- MODIFY: crates/lore-tui/src/state/command_palette.rs (expand from stub)
- CREATE: crates/lore-tui/src/view/command_palette.rs

## TDD Anchor
RED: Write test_palette_fuzzy_match that creates registry with 5 commands, filters with "iss", asserts Issue-related commands match.
GREEN: Implement fuzzy matching on command labels.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_palette_fuzzy

## Edge Cases
- Empty search shows all commands
- Very long command labels: truncate with ellipsis
- Command not available on current screen: show but gray out
- Palette should not steal focus from text inputs — only opens in Normal mode

## Dependency Context
Uses CommandRegistry from "Implement CommandRegistry" task.
Uses ftui CommandPalette widget from FrankenTUI.
Uses InputMode::Palette from "Implement core types" task.


Write surgical_tests.rs with TDD test suite

## Background
The surgical sync test suite validates the core safety properties: dirty queue scoping, TOCTOU protection, transactional rollback, preflight aggregation, and idempotency. These tests use in-memory SQLite and wiremock — no real GitLab access needed.

This bead creates the test file referenced by `surgical.rs` via `#[cfg(test)] #[path = "surgical_tests.rs"] mod tests;`.

## Approach

**Create: `src/ingestion/surgical_tests.rs`**

### Test Helpers

```rust
fn test_config() -> crate::Config {
    serde_json::from_value(serde_json::json!({
        "gitlab": { "baseUrl": "https://gitlab.example.com", "projects": ["group/project"] },
        "storage": {}
    })).unwrap()
}

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

fn make_test_issue(iid: i64) -> GitLabIssue { ... }
fn make_test_issue_with_updated_at(iid: i64, updated_at: i64) -> GitLabIssue { ... }
fn make_test_mr(iid: i64) -> GitLabMergeRequest { ... }
```

### Sync Tests (using #[test])

1. **ingest_issue_by_iid_inserts_and_marks_dirty** — ingest, verify row exists, verify dirty marker, verify dirty_source_keys non-empty
2. **ingest_issue_by_iid_resets_discussion_watermark** — ingest, set watermark, re-ingest, verify watermark NULL
3. **ingest_issue_by_iid_resets_event_watermark** — same pattern for resource_events watermark
4. **ingest_mr_by_iid_inserts_and_marks_dirty** — MR variant
5. **ingest_mr_by_iid_resets_discussion_watermark**
6. **ingest_mr_by_iid_resets_event_watermark**
7. **duplicate_iids_are_idempotent** — ingest same issue twice, verify count == 1
8. **surgical_docs_scope_ignores_preexisting_dirty_rows** — pre-existing dirty row for different entity, surgical ingest for new entity, verify pre-existing row untouched
9. **surgical_docs_scope_ignores_preexisting_dirty_rows_for_same_entity** — UPSERT re-ingest of same entity still collects dirty key
10. **stale_payload_is_skipped_when_local_updated_at_is_newer** — insert fresh local row, ingest stale payload, verify skipped_stale == 1, local unchanged
11. **equal_updated_at_but_newer_last_seen_is_skipped** — TOCTOU edge case with equal updated_at but newer last_seen_at
12. **preflight_success_then_ingest_failure_rolls_back_all_content_writes** — 2 issues, second has bad data, verify transaction rollback (zero rows)
13. **surgical_no_docs_requires_no_embed_validation** — verify is_surgical() works for validation tests

### Async Tests (using #[tokio::test])

14. **preflight_aggregates_multiple_missing_iids** — wiremock: 1 success + 2 404s, verify 1 fetched + 2 failures

### Stub Tests (todo!() for later beads)

15. **cancellation_marks_sync_run_cancelled** — `todo!("implement after SyncRunRecorder extensions")`
16. **dependent_timeout_records_entity_failure_and_continues** — `todo!("implement after inline dependent helpers")`
17. **scoped_embed_does_not_embed_unrelated_docs_created_after_docs_stage** — `todo!("implement after run_embed_for_document_ids")`
18. **payload_project_id_mismatch_is_rejected_in_preflight** — can be implemented now if preflight_fetch has integrity check

## Acceptance Criteria
- [ ] All 13 sync tests pass (tests 1-13)
- [ ] Async preflight test passes (test 14)
- [ ] Stub tests exist with `todo!()` and compile (tests 15-18)
- [ ] Test helpers correctly build Config, setup in-memory DB, create test fixtures
- [ ] `make_test_issue_with_updated_at` helper exists for TOCTOU tests
- [ ] `cargo test surgical -- --nocapture` runs all tests
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files
- CREATE: src/ingestion/surgical_tests.rs (~400 lines)

## TDD Anchor
Start with `ingest_issue_by_iid_inserts_and_marks_dirty`:
RED: Test compiles but fails because functions are not yet implemented.
GREEN: Tests pass after surgical.rs functions are implemented.
VERIFY: `cargo test ingest_issue_by_iid_inserts`

## Edge Cases
- `make_test_issue` must produce valid JSON for GitLabIssue deserialization. Check required fields in `src/gitlab/types.rs`.
- The project insert uses `gitlab_project_id = 100` — all test issues must have `project_id: 100` in their JSON.
- `project_id` in the test functions refers to the local auto-increment `id` (likely 1), not the gitlab_project_id (100).
- For `make_test_issue_bad_data`, create JSON missing a required field (e.g., no "state") to trigger a deserialization or constraint error.
- `rusqlite::OptionalExtension` may be needed for queries that use `.optional()`.

## Dependency Context
Depends on **bd-3sez** (surgical.rs module): tests import types and functions from `crate::ingestion::surgical`.


[CP1] GitLab client pagination methods

## Background

GitLab pagination methods enable fetching large result sets (issues, discussions) as async streams. The client uses `x-next-page` headers to determine continuation and applies cursor rewind for tuple-based incremental sync.

## Approach

Add pagination methods to GitLabClient using `async-stream` crate:

### Methods to Add

```rust
impl GitLabClient {
    /// Paginate through issues for a project.
    pub fn paginate_issues(
        &self,
        gitlab_project_id: i64,
        updated_after: Option<i64>,      // ms epoch cursor
        cursor_rewind_seconds: u32,
    ) -> Pin<Box<dyn Stream<Item = Result<GitLabIssue>> + Send + '_>>

    /// Paginate through discussions for an issue.
    pub fn paginate_issue_discussions(
        &self,
        gitlab_project_id: i64,
        issue_iid: i64,
    ) -> Pin<Box<dyn Stream<Item = Result<GitLabDiscussion>> + Send + '_>>

    /// Make request and return response with headers for pagination.
    async fn request_with_headers<T: DeserializeOwned>(
        &self,
        path: &str,
        params: &[(&str, String)],
    ) -> Result<(T, HeaderMap)>
}
```

### Pagination Logic

1. Start at page 1, per_page=100
2. For issues: add scope=all, state=all, order_by=updated_at, sort=asc
3. Apply cursor rewind: `updated_after = cursor - rewind_seconds` (clamped to 0)
4. Yield each item from response
5. Check `x-next-page` header for continuation
6. Stop when header is empty/absent OR response is empty

### Cursor Rewind

```rust
if let Some(ts) = updated_after {
    let rewind_ms = (cursor_rewind_seconds as i64) * 1000;
    let rewound = (ts - rewind_ms).max(0);  // Clamp to avoid underflow
    // Convert to ISO 8601 for updated_after param
}
```

## Acceptance Criteria

- [ ] `paginate_issues` returns Stream of GitLabIssue
- [ ] `paginate_issues` adds scope=all, state=all, order_by=updated_at, sort=asc
- [ ] `paginate_issues` applies cursor rewind with max(0) clamping
- [ ] `paginate_issue_discussions` returns Stream of GitLabDiscussion
- [ ] Both methods follow x-next-page header until empty
- [ ] Both methods stop on empty response (fallback)
- [ ] `request_with_headers` returns (T, HeaderMap) tuple

## Files

- src/gitlab/client.rs (edit - add methods)

## TDD Loop

RED:
```rust
// tests/pagination_tests.rs
#[tokio::test] async fn fetches_all_pages_when_multiple_exist()
#[tokio::test] async fn respects_per_page_parameter()
#[tokio::test] async fn follows_x_next_page_header_until_empty()
#[tokio::test] async fn falls_back_to_empty_page_stop_if_headers_missing()
#[tokio::test] async fn applies_cursor_rewind_for_tuple_semantics()
#[tokio::test] async fn clamps_negative_rewind_to_zero()
```

GREEN: Implement pagination methods with async-stream

VERIFY: `cargo test pagination`

## Edge Cases

- cursor_updated_at near zero - rewind must not underflow (use max(0))
- GitLab returns empty x-next-page - treat as end of pages
- GitLab omits pagination headers entirely - use empty response as stop condition
- DateTime conversion fails - omit updated_after and fetch all (safe fallback)

Implemented paginate_issues and paginate_issue_discussions with async-stream, cursor rewind with max(0) clamping, x-next-page header following, 4 unit tests passing


NOTE-TEST2: Another test bead

type: task


Implement SyncDeltaLedger for post-sync filtered navigation

## Background

After a sync completes, the Sync Summary screen shows delta counts (+12 new issues, +3 new MRs). Pressing `i` or `m` should navigate to Issue/MR List filtered to show ONLY the entities that changed in this sync run. The SyncDeltaLedger is an in-memory data structure (not persisted to DB) that records the exact IIDs of new/updated entities during a sync run. It lives for the duration of one TUI session and is cleared when a new sync starts. If the ledger is unavailable (e.g., after app restart), the Sync Summary falls back to a timestamp-based filter using `sync_status.last_completed_at`.

## Approach

Create a `sync_delta.rs` module with:

1. **`SyncDeltaLedger` struct**:
   ```rust
   pub struct SyncDeltaLedger {
       issues_new: Vec<i64>,        // IIDs of newly created issues
       issues_updated: Vec<i64>,    // IIDs of updated (not new) issues
       mrs_new: Vec<i64>,           // IIDs of newly created MRs
       mrs_updated: Vec<i64>,       // IIDs of updated MRs
       discussions_new: usize,      // count only (too many to track individually)
       events_new: usize,           // count only
       completed_at: Option<i64>,   // timestamp when sync finished (fallback anchor)
   }
   ```
2. **Builder pattern** — `SyncDeltaLedger::new()` starts empty, populated during sync via:
   - `record_issue(iid: i64, is_new: bool)`
   - `record_mr(iid: i64, is_new: bool)`
   - `record_discussions(count: usize)`
   - `record_events(count: usize)`
   - `finalize(completed_at: i64)` — marks ledger as complete
3. **Query methods**:
   - `new_issue_iids() -> &[i64]` — for `i` key navigation in Summary mode
   - `new_mr_iids() -> &[i64]` — for `m` key navigation
   - `all_changed_issue_iids() -> Vec<i64>` — new + updated combined
   - `all_changed_mr_iids() -> Vec<i64>` — new + updated combined
   - `is_available() -> bool` — true if finalize() was called
   - `fallback_timestamp() -> Option<i64>` — completed_at for timestamp-based fallback
4. **`clear()`** — resets all fields when a new sync starts

The ledger is owned by `SyncState` (part of `AppState`) and populated by the sync action handler when processing `SyncResult` from `run_sync()`. The existing `SyncResult` struct (src/cli/commands/sync.rs:30) already tracks `issues_updated` and `mrs_updated` counts but not individual IIDs — the TUI sync action will need to collect IIDs from the ingest callbacks.

## Acceptance Criteria
- [ ] `SyncDeltaLedger::new()` creates an empty ledger with `is_available() == false`
- [ ] `record_issue(42, true)` adds 42 to `issues_new`; `record_issue(43, false)` adds to `issues_updated`
- [ ] `new_issue_iids()` returns only new IIDs, `all_changed_issue_iids()` returns new + updated
- [ ] `finalize(ts)` sets `is_available() == true` and stores the timestamp
- [ ] `clear()` resets everything back to empty with `is_available() == false`
- [ ] `fallback_timestamp()` returns None before finalize, Some(ts) after
- [ ] Ledger handles >10,000 IIDs without issues (just Vec growth)

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

## TDD Anchor
RED: Write `test_empty_ledger_not_available` that asserts `SyncDeltaLedger::new().is_available() == false` and `new_issue_iids().is_empty()`.
GREEN: Implement the struct with new() and is_available().
VERIFY: cargo test -p lore-tui sync_delta

Additional tests:
- test_record_and_query_issues
- test_record_and_query_mrs
- test_finalize_makes_available
- test_clear_resets_everything
- test_all_changed_combines_new_and_updated
- test_fallback_timestamp

## Edge Cases
- Recording the same IID twice (e.g., issue updated twice during sync) — should deduplicate or allow duplicates? Allow duplicates (Vec, not HashSet) for simplicity; consumers can deduplicate if needed.
- Very large syncs with >50,000 entities — Vec is fine, no cap needed.
- Calling query methods before finalize — returns data so far (is_available=false signals incompleteness).

## Dependency Context
- Depends on bd-2x2h (Sync screen) which owns SyncState and drives the sync lifecycle. The ledger is a field of SyncState.
- Consumed by Sync Summary mode's `i`/`m` key handlers to produce filtered Issue/MR List navigation with exact IID sets.


[CP1] Final validation - Gate A through D

Run all tests and verify all internal gates pass.

## Gate A: Issues Only (Must Pass First)
- [ ] gi ingest --type=issues fetches all issues from configured projects
- [ ] Issues stored with correct schema, including last_seen_at
- [ ] Cursor-based sync is resumable (re-run fetches only new/updated)
- [ ] Incremental cursor updates every 100 issues
- [ ] Raw payloads stored for each issue
- [ ] gi list issues and gi count issues work

## Gate B: Labels Correct (Must Pass)
- [ ] Labels extracted and stored (name-only)
- [ ] Label links created correctly
- [ ] Stale label links removed on re-sync (verified with test)
- [ ] Label count per issue matches GitLab

## Gate C: Dependent Discussion Sync (Must Pass)
- [ ] Discussions fetched for issues with updated_at advancement
- [ ] Notes stored with is_system flag correctly set
- [ ] Raw payloads stored for discussions and notes
- [ ] discussions_synced_for_updated_at watermark updated after sync
- [ ] Unchanged issues skip discussion refetch (verified with test)
- [ ] Bounded concurrency (dependent_concurrency respected)

## Gate D: Resumability Proof (Must Pass)
- [ ] Kill mid-run, rerun; bounded redo (cursor progress preserved)
- [ ] No redundant discussion refetch after crash recovery
- [ ] Single-flight lock prevents concurrent runs

## Final Gate (Must Pass)
- [ ] All unit tests pass (cargo test)
- [ ] All integration tests pass (mocked with wiremock)
- [ ] cargo clippy passes with no warnings
- [ ] cargo fmt --check passes
- [ ] Compiles with --release

## Validation Commands
cargo test
cargo clippy -- -D warnings
cargo fmt --check
cargo build --release

Files: All CP1 files
Done when: All gate criteria pass


Implement timeline expand phase: BFS cross-reference expansion

## Background

The expand phase is step 3 of the timeline pipeline (spec Section 3.2). Starting from seed entities, it performs BFS over entity_references to discover related entities not matched by keywords.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.2 step 3, Section 3.5 (expanded_entities JSON).

## Codebase Context

- entity_references table exists (migration 011) with columns: source_entity_type, source_entity_id, target_entity_type, target_entity_id, target_project_path, target_entity_iid, reference_type, source_method, created_at
- reference_type CHECK: `'closes' | 'mentioned' | 'related'`
- source_method CHECK: `'api' | 'note_parse' | 'description_parse'` — use these values in provenance, NOT the spec's original values
- Indexes: idx_entity_refs_source (source_entity_type, source_entity_id), idx_entity_refs_target (target_entity_id WHERE NOT NULL)

## Approach

Create `src/core/timeline_expand.rs`:

```rust
use std::collections::{HashSet, VecDeque};
use rusqlite::Connection;
use crate::core::timeline::{EntityRef, ExpandedEntityRef, UnresolvedRef};

pub struct ExpandResult {
    pub expanded_entities: Vec<ExpandedEntityRef>,
    pub unresolved_references: Vec<UnresolvedRef>,
}

pub fn expand_timeline(
    conn: &Connection,
    seeds: &[EntityRef],
    depth: u32,                  // 0=no expansion, 1=default, 2+=deep
    include_mentions: bool,      // --expand-mentions flag
    max_entities: usize,         // cap at 100 to prevent explosion
) -> Result<ExpandResult> { ... }
```

### BFS Algorithm

```
visited: HashSet<(String, i64)> = seeds as set (entity_type, entity_id)
queue: VecDeque<(EntityRef, u32)> for multi-hop

For each seed:
  query_neighbors(conn, seed, edge_types) -> outgoing + incoming refs
  - Outgoing: SELECT target_* FROM entity_references WHERE source_entity_type=? AND source_entity_id=? AND reference_type IN (...)
  - Incoming: SELECT source_* FROM entity_references WHERE target_entity_type=? AND target_entity_id=? AND reference_type IN (...)
  - Unresolved (target_entity_id IS NULL): collect in UnresolvedRef, don't traverse
  - New resolved: add to expanded with provenance (via_from, via_reference_type, via_source_method)
  - If current_depth < depth: enqueue for further BFS
```

### Edge Type Filtering

```rust
fn edge_types(include_mentions: bool) -> Vec<&'static str> {
    if include_mentions {
        vec!["closes", "related", "mentioned"]
    } else {
        vec!["closes", "related"]
    }
}
```

### Provenance (Critical for spec compliance)

Each expanded entity needs via object per spec Section 3.5:
- via_from: EntityRef of the entity that referenced this one
- via_reference_type: from entity_references.reference_type column
- via_source_method: from entity_references.source_method column (**codebase values: 'api', 'note_parse', 'description_parse'**)

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

## Acceptance Criteria

- [ ] BFS traverses outgoing AND incoming edges in entity_references
- [ ] Default: only "closes" and "related" edges (not "mentioned")
- [ ] --expand-mentions: also traverses "mentioned" edges
- [ ] depth=0: returns empty expanded list
- [ ] max_entities cap prevents explosion (default 100)
- [ ] Provenance: via_source_method uses codebase values (api/note_parse/description_parse), NOT spec values
- [ ] Unresolved references (target_entity_id IS NULL) collected, not traversed
- [ ] No duplicates: visited set by (entity_type, entity_id)
- [ ] Self-references skipped
- [ ] Module registered in src/core/mod.rs
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

- `src/core/timeline_expand.rs` (NEW)
- `src/core/mod.rs` (add `pub mod timeline_expand;`)

## TDD Loop

RED: Tests in `src/core/timeline_expand.rs`:
- `test_expand_depth_zero` - returns empty
- `test_expand_finds_linked_entity` - seed issue -> closes -> linked MR
- `test_expand_bidirectional` - starting from target also finds source
- `test_expand_respects_max_entities`
- `test_expand_skips_mentions_by_default`
- `test_expand_includes_mentions_when_flagged`
- `test_expand_collects_unresolved`
- `test_expand_tracks_provenance` - verify via_source_method is 'api' not 'api_closes_issues'

Tests need in-memory DB with migrations 001-014 applied + entity_references test data.

GREEN: Implement BFS.

VERIFY: `cargo test --lib -- timeline_expand`

## Edge Cases

- Circular references: visited set prevents infinite loop
- Entity referenced from multiple seeds: first-come provenance wins
- Empty entity_references: returns empty, not error
- Cross-project refs with NULL target_entity_id: add to unresolved

Completed: Created src/core/timeline_expand.rs with BFS cross-reference expansion. Bidirectional traversal, depth limiting, mention filtering, max entity cap, provenance tracking, unresolved reference collection. 10 tests pass. All quality gates pass.


Run all tests and verify all internal gates pass.

## Gate A: Issues Only (Must Pass First)
- [ ] gi ingest --type=issues fetches all issues from configured projects
- [ ] Issues stored with correct schema, including last_seen_at
- [ ] Cursor-based sync is resumable (re-run fetches only new/updated)
- [ ] Incremental cursor updates every 100 issues
- [ ] Raw payloads stored for each issue
- [ ] gi list issues and gi count issues work

## Gate B: Labels Correct (Must Pass)
- [ ] Labels extracted and stored (name-only)
- [ ] Label links created correctly
- [ ] **Stale label links removed on re-sync** (verified with test)
- [ ] Label count per issue matches GitLab

## Gate C: Dependent Discussion Sync (Must Pass)
- [ ] Discussions fetched for issues with updated_at advancement
- [ ] Notes stored with is_system flag correctly set
- [ ] Raw payloads stored for discussions and notes
- [ ] discussions_synced_for_updated_at watermark updated after sync
- [ ] **Unchanged issues skip discussion refetch** (verified with test)
- [ ] Bounded concurrency (dependent_concurrency respected)

## Gate D: Resumability Proof (Must Pass)
- [ ] Kill mid-run, rerun; bounded redo (cursor progress preserved)
- [ ] No redundant discussion refetch after crash recovery
- [ ] Single-flight lock prevents concurrent runs

## Final Gate (Must Pass)
- [ ] All unit tests pass (cargo test)
- [ ] All integration tests pass (mocked with wiremock)
- [ ] cargo clippy passes with no warnings
- [ ] cargo fmt --check passes
- [ ] Compiles with --release

## Validation Commands
cargo test
cargo clippy -- -D warnings
cargo fmt --check
cargo build --release

## Data Integrity Checks
- SELECT COUNT(*) FROM issues matches GitLab issue count
- Every issue has a raw_payloads row
- Every discussion has a raw_payloads row
- Labels in issue_labels junction all exist in labels table
- Re-running gi ingest --type=issues fetches 0 new items
- After removing a label in GitLab and re-syncing, the link is removed

Files: All CP1 files
Done when: All gate criteria pass

All gates pass: 71 tests, clippy clean, fmt clean, release build successful


Implement 'lore file-history' command with human and robot output

## Background

The file-history command is Gate 4's user-facing CLI. It answers "which MRs touched this file, and why?"

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

## Codebase Context

- CLI pattern: Commands enum in src/cli/mod.rs, handler in src/main.rs, output in src/cli/commands/
- Project resolution: resolve_project() returns project_id or exit 18 (Ambiguous)
- Robot mode: {ok, data, meta} envelope pattern
- merge_requests.merged_at exists (migration 006) — order by COALESCE(merged_at, updated_at) DESC
- discussions table: issue_id, merge_request_id
- notes table: position_new_path for DiffNotes (used for --discussions flag)
- **mr_file_changes table**: migration 016 — already exists and is populated by drain_mr_diffs() (orchestrator.rs lines 708-726, 1514+)
- resolve_rename_chain() from bd-1yx (src/core/file_history.rs) for rename handling
- VALID_COMMANDS array in src/main.rs (line ~448)
- **26 migrations** exist (001-026). LATEST_SCHEMA_VERSION derived from MIGRATIONS.len().

## Approach

### 1. FileHistoryArgs subcommand (`src/cli/mod.rs`):
```rust
/// Show MRs that touched a file, with linked issues and discussions
#[command(name = "file-history")]
FileHistory(FileHistoryArgs),
```

```rust
#[derive(Parser, Debug)]
pub struct FileHistoryArgs {
    /// File path to trace history for
    pub path: String,
    /// Scope to a specific project (fuzzy match)
    #[arg(short = 'p', long)]
    pub project: Option<String>,
    /// Include discussion snippets from DiffNotes on this file
    #[arg(long)]
    pub discussions: bool,
    /// Disable rename chain resolution
    #[arg(long = "no-follow-renames")]
    pub no_follow_renames: bool,
    /// Only show merged MRs
    #[arg(long)]
    pub merged: bool,
    /// Maximum results
    #[arg(short = 'n', long = "limit", default_value = "50")]
    pub limit: usize,
}
```

### 2. Query logic (`src/cli/commands/file_history.rs`):

1. Resolve project (exit 18 on ambiguous)
2. Call resolve_rename_chain() unless --no-follow-renames
3. Query mr_file_changes for all resolved paths
4. JOIN merge_requests for MR details
5. Optionally fetch DiffNote discussions on the file (notes.position_new_path)
6. Order by COALESCE(merged_at, updated_at) DESC
7. Apply --merged filter and --limit

### 3. Human output:
```
File History: src/auth/oauth.rs (via 3 paths, 5 MRs)
Rename chain: src/authentication/oauth.rs -> src/auth/oauth.rs

  !456  "Implement OAuth2 flow"     merged  @alice  2024-01-22  modified
  !489  "Fix OAuth token expiry"    merged  @bob    2024-02-15  modified
  !512  "Refactor auth module"      merged  @carol  2024-03-01  renamed
```

### 4. Robot JSON:
```json
{
  "ok": true,
  "data": {
    "path": "src/auth/oauth.rs",
    "rename_chain": ["src/authentication/oauth.rs", "src/auth/oauth.rs"],
    "merge_requests": [
      {
        "iid": 456,
        "title": "Implement OAuth2 flow",
        "state": "merged",
        "author": "alice",
        "merged_at": "2024-01-22T...",
        "change_type": "modified",
        "discussion_count": 12,
        "file_discussion_count": 4,
        "merge_commit_sha": "abc123"
      }
    ]
  },
  "meta": {
    "total_mrs": 5,
    "renames_followed": true,
    "paths_searched": 2
  }
}
```

## Acceptance Criteria

- [ ] `lore file-history src/foo.rs` works with human output
- [ ] `lore --robot file-history src/foo.rs` works with JSON envelope
- [ ] Rename chain displayed in human output when renames detected
- [ ] Robot JSON includes rename_chain array
- [ ] --no-follow-renames disables resolution (queries literal path only)
- [ ] --merged filters to merged MRs only
- [ ] --discussions includes DiffNote snippets from notes.position_new_path matching
- [ ] -p for project scoping (exit 18 on ambiguous)
- [ ] -n limits results
- [ ] No MR history: friendly message (exit 0, not error)
- [ ] "file-history" added to VALID_COMMANDS array
- [ ] robot-docs manifest includes file-history command
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes
- [ ] `cargo fmt --check` passes

## Files

- MODIFY: src/cli/mod.rs (FileHistoryArgs struct + Commands::FileHistory variant)
- CREATE: src/cli/commands/file_history.rs (query + human + robot output)
- MODIFY: src/cli/commands/mod.rs (add pub mod file_history + re-exports)
- MODIFY: src/main.rs (handler dispatch + VALID_COMMANDS + robot-docs entry)

## TDD Anchor

RED: No unit tests for CLI wiring — verify with cargo check + manual run.

GREEN: Implement query, human renderer, robot renderer.

VERIFY:
```bash
cargo check --all-targets
cargo run --release -- file-history --help
cargo run --release -- file-history src/main.rs
cargo run --release -- --robot file-history src/main.rs
```

## Edge Cases

- File path with spaces: clap handles quoting
- Path not in any MR: empty result, friendly message, exit 0 (not error)
- MRs ordered by COALESCE(merged_at, updated_at) DESC (unmerged MRs use updated_at)
- --discussions with no DiffNotes: empty discussion section, not error
- rename_chain omitted from robot JSON when --no-follow-renames is set
- mr_file_changes table empty (sync hasn't fetched diffs yet): friendly message suggesting `lore sync`

## Dependency Context

- **bd-1yx (resolve_rename_chain)**: provides resolve_rename_chain() in src/core/file_history.rs — takes a path and returns Vec<String> of all historical paths. MUST be implemented before this bead.
- **bd-2yo / migration 016 (mr_file_changes)**: provides the mr_file_changes table with new_path, old_path, change_type columns. Already exists and is populated by drain_mr_diffs() in orchestrator.rs (lines 708-726, 1514+).
- **bd-3ia (closes_issues)**: provides entity_references with reference_type='closes' linking MRs to issues. Used for "linked issues" column if extended later.

Implemented file-history command with human/robot output, rename chain resolution, DiffNote discussions, --merged/--no-follow-renames filters, autocorrect registry, robot-docs manifest


WHO: VALID_COMMANDS + robot-docs manifest

## Background

Register the who command in main.rs so that typo suggestions work and robot-docs manifest includes the command for agent self-discovery.

## Approach

### 1. VALID_COMMANDS array (~line 471 in suggest_similar_command):
Add "who" after "timeline":
```rust
const VALID_COMMANDS: &[&str] = &[
    "issues", "mrs", /* ... existing ... */ "timeline", "who",
];
```

### 2. robot-docs manifest (handle_robot_docs, after "timeline" entry):
```json
"who": {
    "description": "People intelligence: experts, workload, active discussions, overlap, review patterns",
    "flags": ["<target>", "--path <path>", "--active", "--overlap <path>", "--reviews", "--since <duration>", "-p/--project", "-n/--limit"],
    "modes": {
        "expert": "lore who <file-path> — Who knows about this area? (also: --path for root files)",
        "workload": "lore who <username> — What is someone working on?",
        "reviews": "lore who <username> --reviews — Review pattern analysis",
        "active": "lore who --active — Active unresolved discussions",
        "overlap": "lore who --overlap <path> — Who else is touching these files?"
    },
    "example": "lore --robot who src/features/auth/",
    "response_schema": {
        "ok": "bool",
        "data": {
            "mode": "string",
            "input": {"target": "string|null", "path": "string|null", "project": "string|null", "since": "string|null", "limit": "int"},
            "resolved_input": {"mode": "string", "project_id": "int|null", "project_path": "string|null", "since_ms": "int", "since_iso": "string", "since_mode": "string (default|explicit|none)", "limit": "int"},
            "...": "mode-specific fields"
        },
        "meta": {"elapsed_ms": "int"}
    }
}
```

### 3. workflows JSON — add people_intelligence:
```json
"people_intelligence": [
    "lore --robot who src/path/to/feature/",
    "lore --robot who @username",
    "lore --robot who @username --reviews",
    "lore --robot who --active --since 7d",
    "lore --robot who --overlap src/path/",
    "lore --robot who --path README.md"
]
```

## Files

- `src/main.rs`

## TDD Loop

VERIFY: `cargo check && cargo run --release -- robot-docs | python3 -c "import json,sys; d=json.load(sys.stdin); assert 'who' in d['commands']"`

## Acceptance Criteria

- [ ] "who" in VALID_COMMANDS
- [ ] `lore robot-docs` JSON contains who command with all 5 modes
- [ ] workflows contains people_intelligence array
- [ ] cargo check passes

## Edge Cases

- The VALID_COMMANDS array is used for typo suggestion via Levenshtein distance — ensure "who" does not collide with other short commands (it does not; closest is "show" at distance 2)
- robot-docs JSON is constructed via serde_json::json!() macro inside a raw string — ensure no trailing commas or JSON syntax errors in the manually-written JSON block
- The response_schema in robot-docs is documentation-only (not validated at runtime) — ensure it matches actual output structure from bd-3mj2
- If handle_robot_docs location has changed since plan was written, search for "robot-docs" or "robot_docs" in main.rs to find current location


WHO: Expert mode query (query_expert)

## Background

Expert mode answers "Who should I talk to about this feature/file?" by analyzing DiffNote activity at a given path. It scores users by a combination of review breadth (distinct MRs reviewed), authorship breadth (distinct MRs authored), and review intensity (DiffNote count). This is the primary use case for lore who.

## Approach

Single CTE with two UNION ALL branches (reviewer + author), then SQL-level aggregation, scoring, sorting, and LIMIT.

### Key SQL pattern (prefix variant — exact variant replaces LIKE with =):

```sql
WITH activity AS (
    -- Reviewer branch: DiffNotes on other people's MRs
    SELECT n.author_username AS username, 'reviewer' AS role,
           COUNT(DISTINCT m.id) AS mr_cnt, COUNT(*) AS note_cnt,
           MAX(n.created_at) AS last_seen_at
    FROM notes n
    JOIN discussions d ON n.discussion_id = d.id
    JOIN merge_requests m ON d.merge_request_id = m.id
    WHERE n.note_type = 'DiffNote' AND n.is_system = 0
      AND n.author_username IS NOT NULL
      AND (m.author_username IS NULL OR n.author_username != m.author_username)  -- self-review exclusion
      AND m.state IN ('opened','merged')
      AND n.position_new_path LIKE ?1 ESCAPE '\'
      AND n.created_at >= ?2
      AND (?3 IS NULL OR n.project_id = ?3)
    GROUP BY n.author_username

    UNION ALL

    -- Author branch: MR authors with DiffNote activity at this path
    SELECT m.author_username AS username, 'author' AS role,
           COUNT(DISTINCT m.id) AS mr_cnt, 0 AS note_cnt,
           MAX(n.created_at) AS last_seen_at
    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.note_type = 'DiffNote' AND n.is_system = 0
      AND m.author_username IS NOT NULL
      AND n.position_new_path LIKE ?1 ESCAPE '\'
      AND n.created_at >= ?2
      AND (?3 IS NULL OR n.project_id = ?3)
    GROUP BY m.author_username
)
SELECT username,
       SUM(CASE WHEN role='reviewer' THEN mr_cnt ELSE 0 END) AS review_mr_count,
       SUM(CASE WHEN role='reviewer' THEN note_cnt ELSE 0 END) AS review_note_count,
       SUM(CASE WHEN role='author' THEN mr_cnt ELSE 0 END) AS author_mr_count,
       MAX(last_seen_at) AS last_seen_at,
       (SUM(CASE WHEN role='reviewer' THEN mr_cnt ELSE 0 END) * 20 +
        SUM(CASE WHEN role='author' THEN mr_cnt ELSE 0 END) * 12 +
        SUM(CASE WHEN role='reviewer' THEN note_cnt ELSE 0 END) * 1) AS score
FROM activity
GROUP BY username
ORDER BY score DESC, last_seen_at DESC, username ASC
LIMIT ?4
```

### Two static SQL strings selected via `if pq.is_prefix { sql_prefix } else { sql_exact }` — the only difference is LIKE vs = on position_new_path. Both use prepare_cached().

### Scoring formula: review_mr * 20 + author_mr * 12 + review_notes * 1
- MR breadth dominates (prevents "comment storm" gaming)
- Integer arithmetic (no f64 display issues)

### LIMIT+1 truncation pattern:
```rust
let limit_plus_one = (limit + 1) as i64;
// ... query with limit_plus_one ...
let truncated = experts.len() > limit;
let experts = experts.into_iter().take(limit).collect();
```

### ExpertResult struct:
```rust
pub struct ExpertResult {
    pub path_query: String,
    pub path_match: String,  // "exact" or "prefix"
    pub experts: Vec<Expert>,
    pub truncated: bool,
}
pub struct Expert {
    pub username: String, pub score: i64,
    pub review_mr_count: u32, pub review_note_count: u32,
    pub author_mr_count: u32, pub last_seen_ms: i64,
}
```

## Files

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

## TDD Loop

RED:
```
test_expert_query — insert project, MR, discussion, 3 DiffNotes; verify expert ranking
test_expert_excludes_self_review_notes — author_a comments on own MR; review_mr_count must be 0
test_expert_truncation — 3 experts, limit=2 -> truncated=true, len=2; limit=10 -> false
```

GREEN: Implement query_expert with both SQL variants
VERIFY: `cargo test -- expert`

## Acceptance Criteria

- [ ] test_expert_query passes (reviewer_b ranked first by score)
- [ ] test_expert_excludes_self_review_notes passes (author_a has review_mr_count=0)
- [ ] test_expert_truncation passes (truncated flag correct at both limits)
- [ ] Default since window: 6m

## Edge Cases

- Self-review: MR author commenting on own diff must NOT count as reviewer (filter n.author_username != m.author_username with IS NULL guard on m.author_username)
- MR state: only 'opened' and 'merged' — closed/unmerged MRs are noise
- Project scoping is on n.project_id (not m.project_id) to maximize index usage
- Author branch also filters n.is_system = 0 for consistency