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.


Define MeArgs struct and register me subcommand

## Background
The `lore me` command needs a CLI argument struct and handler wiring. The existing pattern is `WhoArgs` in `src/cli/mod.rs` (line ~964) and `Commands::Who(WhoArgs)` variant. The handler in `src/main.rs` follows: `Some(Commands::Who(args)) => handle_who(cli.config.as_deref(), args, robot_mode)`.

## Approach
1. Define `MeArgs` in `src/cli/mod.rs` (alongside WhoArgs):
```rust
#[derive(Args, Debug)]
pub struct MeArgs {
    /// Show only issues section
    #[arg(long)]
    pub issues: bool,
    /// Show only MRs section (authored + reviewing)
    #[arg(long)]
    pub mrs: bool,
    /// Show only activity feed
    #[arg(long)]
    pub activity: bool,
    /// Activity window (e.g., "30d", "7d") — default 30d
    #[arg(long, default_value = "30d")]
    pub since: String,
    /// Scope to one project (fuzzy match)
    #[arg(short, long)]
    pub project: Option<String>,
    /// Override configured username
    #[arg(long)]
    pub user: Option<String>,
    /// Show all synced projects (overrides default_project)
    #[arg(long)]
    pub all: bool,
    /// Select output fields (preset: "minimal", or comma-separated)
    #[arg(long, value_delimiter = ',')]
    pub fields: Option<Vec<String>>,
}
```

2. Add variant to Commands enum (around line 114):
```rust
/// Personal work dashboard — my issues, MRs, and activity
Me(MeArgs),
```

3. Create `src/cli/commands/me/mod.rs` with stub:
```rust
use crate::Config;
use crate::cli::MeArgs;
use crate::core::error::Result;

pub fn handle_me(
    config_override: Option<&str>,
    args: MeArgs,
    robot_mode: bool,
) -> std::result::Result<(), Box<dyn std::error::Error>> {
    eprintln!("lore me: not yet implemented");
    std::process::exit(1);
}
```

4. Wire in `src/main.rs` — add match arm (near line ~3164 where handle_who is):
```rust
Some(Commands::Me(args)) => handle_me(cli.config.as_deref(), args, robot_mode),
```

5. Add `pub mod me;` to `src/cli/commands/mod.rs`.

## Acceptance Criteria
- [ ] `MeArgs` struct defined with all flags: --issues, --mrs, --activity, --since, --project, --user, --all, --fields
- [ ] --since has default_value "30d"
- [ ] --fields uses value_delimiter=',' for comma-separated list
- [ ] `Me(MeArgs)` variant in Commands enum
- [ ] `src/cli/commands/me/mod.rs` exists with stub handler
- [ ] Handler wired in main.rs match arm
- [ ] `lore me --help` shows all flags with descriptions
- [ ] `lore me` runs without panic (stub prints "not yet implemented" and exits)
- [ ] Standard global flags (--robot/-J, --color, --icons) inherited from Cli struct
- [ ] --project and --all are separate flags (mutual exclusivity is runtime, not clap)

## Files
- MODIFY: src/cli/mod.rs (MeArgs struct + Commands::Me variant)
- CREATE: src/cli/commands/me/mod.rs (stub handler)
- MODIFY: src/cli/commands/mod.rs (add `pub mod me;`)
- MODIFY: src/main.rs (add match arm calling handle_me)

## TDD Anchor
RED: Write `test_me_args_parse` that parses `["lore", "me", "--issues", "--since", "7d", "--user", "jdoe"]` via `Cli::try_parse_from` and asserts `issues=true, since="7d", user=Some("jdoe")`.
GREEN: Define MeArgs struct and Commands variant.
VERIFY: `cargo test me_args`

## Edge Cases
- `--since` default must be "30d" when not provided (test by parsing without --since)
- `--fields` with no value should be None, `--fields minimal` should be Some(vec!["minimal"])
- The handler signature matches the who pattern: `(config_override, args, robot_mode)`

## Dependency Context
Uses resolve_username from bd-1f1f (called within the handler when fully implemented).
Pattern reference: WhoArgs at `src/cli/mod.rs:964`, handle_who at `src/main.rs:3164`.


[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


Task 3: Implement open threads, activity summary, and cross-references

## Background

This task implements three data-fetching functions for the explain command: open threads (unresolved discussions), activity summary (event/note counts), and cross-references (related entities).

**Parent bead:** bd-9lbr (lore explain feature)
**Spec:** specs/SPEC_explain.md — Task 3
**Phase:** 2 — Core Logic
**Depends on:** Task 1 (bd-2i3z) — needs ExplainParams, ExplainResult types, run_explain skeleton, and test helpers

## Function Signatures

\`\`\`rust
pub fn fetch_open_threads(conn: &Connection, entity_type: &str, entity_id: i64) -> Result<Vec<OpenThread>>

pub fn build_activity_summary(
    conn: &Connection,
    entity_type: &str,
    entity_id: i64,
    since: Option<i64>,
) -> Result<ActivitySummary>

pub fn fetch_related_entities(conn: &Connection, entity_type: &str, entity_id: i64) -> Result<RelatedEntities>

pub fn extract_description_excerpt(description: Option<&str>) -> String
\`\`\`

Wire into run_explain():
\`\`\`rust
let description_excerpt = if should_include(&params.sections, "description") {
    Some(extract_description_excerpt(description.as_deref()))
} else { None };

let open_threads = if should_include(&params.sections, "open_threads") {
    Some(fetch_open_threads(conn, &params.entity_type, entity_local_id)?)
} else { None };

let activity = if should_include(&params.sections, "activity") {
    Some(build_activity_summary(conn, &params.entity_type, entity_local_id, params.since)?)
} else { None };

let related = if should_include(&params.sections, "related") {
    Some(fetch_related_entities(conn, &params.entity_type, entity_local_id)?)
} else { None };
\`\`\`

## Data Assembly

### Open Threads (fetch_open_threads)

\`\`\`rust
let id_col = if entity_type == "issues" { "issue_id" } else { "merge_request_id" };
\`\`\`

Query unresolved discussions:
\`\`\`sql
SELECT d.id, d.gitlab_discussion_id, d.first_note_at, d.last_note_at
FROM discussions d
WHERE d.{id_col} = ?1 AND d.resolvable = 1 AND d.resolved = 0
ORDER BY d.last_note_at DESC
\`\`\`

For each discussion row, two sub-queries:

First note author:
\`\`\`sql
SELECT author_username FROM notes WHERE discussion_id = ?1 ORDER BY created_at ASC LIMIT 1
\`\`\`

Non-system note count:
\`\`\`sql
SELECT COUNT(*) FROM notes WHERE discussion_id = ?1 AND is_system = 0
\`\`\`

IMPORTANT: discussions.id is the LOCAL SQLite row id. notes.discussion_id references this local id. discussions.gitlab_discussion_id is the GitLab string id for display output.

Result: OpenThread { discussion_id: gitlab_discussion_id, started_by: first note author, started_at: ms_to_iso(first_note_at), note_count, last_note_at: ms_to_iso(last_note_at) }

### Activity Summary (build_activity_summary)

Three aggregate queries:

\`\`\`sql
-- State events
SELECT COUNT(*), MIN(created_at), MAX(created_at)
FROM resource_state_events WHERE {id_col} = ?1 AND (?2 IS NULL OR created_at >= ?2)

-- Label events
SELECT COUNT(*), MIN(created_at), MAX(created_at)
FROM resource_label_events WHERE {id_col} = ?1 AND (?2 IS NULL OR created_at >= ?2)

-- Non-system notes
SELECT COUNT(*), MIN(n.created_at), MAX(n.created_at)
FROM notes n JOIN discussions d ON n.discussion_id = d.id
WHERE d.{id_col} = ?1 AND n.is_system = 0 AND (?2 IS NULL OR n.created_at >= ?2)
\`\`\`

Combine min/max across all three for first_event/last_event:
\`\`\`rust
let first_event = [state_min, label_min, note_min].iter().flatten().copied().min();
let last_event = [state_max, label_max, note_max].iter().flatten().copied().max();
\`\`\`

Result: ActivitySummary { state_changes, label_changes, notes, first_event: first_event.map(ms_to_iso), last_event: last_event.map(ms_to_iso) }

### Cross-References (fetch_related_entities)

**Closing MRs** (only for issues) — copy this exact query from show/issue.rs get_closing_mrs():
\`\`\`sql
SELECT mr.iid, mr.title, mr.state, mr.web_url
FROM entity_references er
JOIN merge_requests mr ON mr.id = er.source_entity_id
WHERE er.target_entity_type = 'issue'
  AND er.target_entity_id = ?
  AND er.source_entity_type = 'merge_request'
  AND er.reference_type = 'closes'
ORDER BY mr.iid
\`\`\`

NOTE: entity_references stores source=MR, target=issue for closing relationships.

**Related entities** — JOIN to get iid+title:
\`\`\`sql
-- Outgoing (this entity references others, excluding closes)
SELECT er.target_entity_type, er.target_entity_iid, er.reference_type,
       COALESCE(i.title, mr.title) as title
FROM entity_references er
LEFT JOIN issues i ON er.target_entity_type = 'issue' AND i.id = er.target_entity_id
LEFT JOIN merge_requests mr ON er.target_entity_type = 'merge_request' AND mr.id = er.target_entity_id
WHERE er.source_entity_type = ?1 AND er.source_entity_id = ?2
  AND er.reference_type != 'closes'

-- Incoming (others reference this entity, excluding closes)
SELECT er.source_entity_type, COALESCE(i.iid, mr.iid) as iid, er.reference_type,
       COALESCE(i.title, mr.title) as title
FROM entity_references er
LEFT JOIN issues i ON er.source_entity_type = 'issue' AND i.id = er.source_entity_id
LEFT JOIN merge_requests mr ON er.source_entity_type = 'merge_request' AND mr.id = er.source_entity_id
WHERE er.target_entity_type = ?1 AND er.target_entity_id = ?2
  AND er.reference_type != 'closes'
\`\`\`

Skip closing MRs section when entity_type is "mrs" (closing MRs only apply to issues).

### Description Excerpt

\`\`\`rust
pub fn extract_description_excerpt(description: Option<&str>) -> String {
    match description {
        Some(d) if !d.trim().is_empty() => truncate(d, 500),
        _ => "(no description)".to_owned(),
    }
}
\`\`\`

## Files to Modify

- src/cli/commands/explain.rs — add fetch_open_threads(), build_activity_summary(), fetch_related_entities(), extract_description_excerpt(), wire into run_explain()

## DB Schema Reference

- discussions: id (local PK), gitlab_discussion_id (TEXT), project_id, issue_id (FK), merge_request_id (FK), noteable_type, individual_note (0/1), first_note_at (ms), last_note_at (ms), last_seen_at, resolvable (0/1), resolved (0/1). CHECK: exactly one of issue_id/merge_request_id NOT NULL.
- notes: id (local PK), gitlab_id, discussion_id (FK->discussions.id), project_id, note_type, is_system (0/1), author_username, body, created_at (ms), updated_at, last_seen_at
- entity_references: source_entity_type (issue|merge_request), source_entity_id (FK), target_entity_type, target_entity_id (nullable), target_project_path, target_entity_iid, reference_type (closes|mentioned|related), source_method

## TDD Workflow (Red-Green)

### RED — Write these tests first, confirm they FAIL:

Test helpers (reuse from Task 1; add new insert helpers as needed):

\`\`\`rust
fn insert_discussion_full(conn: &Connection, issue_id: i64, gitlab_id: &str, resolvable: bool, resolved: bool) -> i64 {
    conn.execute(
        "INSERT INTO discussions (gitlab_discussion_id, project_id, issue_id, noteable_type, individual_note, first_note_at, last_note_at, last_seen_at, resolvable, resolved) VALUES (?1, 1, ?2, 'Issue', 0, 1000000, 2000000, 2000000, ?3, ?4)",
        rusqlite::params![gitlab_id, issue_id, resolvable as i32, resolved as i32],
    ).unwrap();
    conn.last_insert_rowid()
}
\`\`\`

Tests:
1. test_explain_open_threads: Insert 2 discussions (1 unresolved+resolvable, 1 resolved+resolvable), each with 2 notes. Assert only unresolved appears in result, with correct started_by (first note author) and note_count (non-system notes).
2. test_explain_activity_counts: Insert 3 state events + 2 label events + 10 non-system notes (via discussion), assert activity.state_changes=3, label_changes=2, notes=10. Verify first_event and last_event bracket the correct ms range.
3. test_explain_no_notes: Insert issue with zero notes, zero events, NULL description. Assert open_threads is empty, activity is all zeros with None timestamps, description_excerpt = "(no description)".

### GREEN — Implement the four functions and wire into run_explain()

### Verify:
cargo test explain::tests::test_explain_open_threads && cargo test explain::tests::test_explain_activity_counts && cargo test explain::tests::test_explain_no_notes && cargo clippy --all-targets -- -D warnings

## Acceptance Criteria

- [ ] test_explain_open_threads passes
- [ ] test_explain_activity_counts passes
- [ ] test_explain_no_notes passes
- [ ] Open threads correctly filtered (only unresolved+resolvable)
- [ ] Activity counts accurate with correct first/last event timestamps
- [ ] Cross-references include closing MRs with iid/title/state (for issues)
- [ ] Empty entity handled gracefully (no panics, sensible defaults)
- [ ] cargo clippy and cargo fmt clean

Implemented: all explain command sections (activity, open_threads, related, timeline_excerpt, human/robot renderers) merged into main explain.rs, robot_docs updated, autocorrect registered, 1046 tests passing


Extend SyncResult with surgical mode fields for robot output

## Background

Robot mode (`--robot`) serializes `SyncResult` as JSON for machine consumers. Currently `SyncResult` (lines 31-52 of `src/cli/commands/sync.rs`) only has fields for normal full sync. Surgical sync needs additional metadata in the JSON response: whether surgical mode was active, which IIDs were requested, per-entity outcomes, and whether it was a preflight-only run. These must be `Option` fields so normal sync serialization is unchanged (serde `skip_serializing_if = "Option::is_none"`).

## Approach

Add four `Option` fields to the existing `SyncResult` struct:

```rust
#[serde(skip_serializing_if = "Option::is_none")]
pub surgical_mode: Option<bool>,

#[serde(skip_serializing_if = "Option::is_none")]
pub surgical_iids: Option<SurgicalIids>,

#[serde(skip_serializing_if = "Option::is_none")]
pub entity_results: Option<Vec<EntitySyncResult>>,

#[serde(skip_serializing_if = "Option::is_none")]
pub preflight_only: Option<bool>,
```

Define two new supporting structs in the same file:

```rust
#[derive(Debug, Default, Serialize)]
pub struct SurgicalIids {
    pub issues: Vec<u64>,
    pub merge_requests: Vec<u64>,
}

#[derive(Debug, Serialize)]
pub struct EntitySyncResult {
    pub entity_type: String,  // "issue" or "merge_request"
    pub iid: u64,
    pub outcome: String,      // "synced", "skipped_toctou", "failed", "not_found"
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub toctou_reason: Option<String>,
}
```

Because `SyncResult` derives `Default`, the new `Option` fields default to `None` automatically. Non-surgical callers need zero changes.

## Acceptance Criteria

1. `SyncResult` compiles with all four new `Option` fields
2. `SurgicalIids` and `EntitySyncResult` are defined with `Serialize` derive
3. Serializing a `SyncResult` with surgical fields set produces JSON with `surgical_mode`, `surgical_iids`, `entity_results`, `preflight_only` keys
4. Serializing a default `SyncResult` (all `None`) produces JSON identical to current output (no surgical keys)
5. `SyncResult::default()` still works without specifying new fields
6. All existing tests pass unchanged

## Files

- `src/cli/commands/sync.rs` — add fields to `SyncResult`, define `SurgicalIids` and `EntitySyncResult`

## TDD Anchor

Add a test module or extend the existing one in `src/cli/commands/sync.rs` (or a new `sync_tests.rs` file):

```rust
#[cfg(test)]
mod surgical_result_tests {
    use super::*;

    #[test]
    fn sync_result_default_omits_surgical_fields() {
        let result = SyncResult::default();
        let json = serde_json::to_value(&result).unwrap();
        assert!(json.get("surgical_mode").is_none());
        assert!(json.get("surgical_iids").is_none());
        assert!(json.get("entity_results").is_none());
        assert!(json.get("preflight_only").is_none());
    }

    #[test]
    fn sync_result_with_surgical_fields_serializes_correctly() {
        let result = SyncResult {
            surgical_mode: Some(true),
            surgical_iids: Some(SurgicalIids {
                issues: vec![7, 42],
                merge_requests: vec![10],
            }),
            entity_results: Some(vec![
                EntitySyncResult {
                    entity_type: "issue".to_string(),
                    iid: 7,
                    outcome: "synced".to_string(),
                    error: None,
                    toctou_reason: None,
                },
                EntitySyncResult {
                    entity_type: "issue".to_string(),
                    iid: 42,
                    outcome: "skipped_toctou".to_string(),
                    error: None,
                    toctou_reason: Some("updated_at changed".to_string()),
                },
            ]),
            preflight_only: Some(false),
            ..SyncResult::default()
        };
        let json = serde_json::to_value(&result).unwrap();
        assert_eq!(json["surgical_mode"], true);
        assert_eq!(json["surgical_iids"]["issues"], serde_json::json!([7, 42]));
        assert_eq!(json["entity_results"].as_array().unwrap().len(), 2);
        assert_eq!(json["entity_results"][1]["outcome"], "skipped_toctou");
        assert_eq!(json["preflight_only"], false);
    }

    #[test]
    fn entity_sync_result_omits_none_fields() {
        let entity = EntitySyncResult {
            entity_type: "merge_request".to_string(),
            iid: 10,
            outcome: "synced".to_string(),
            error: None,
            toctou_reason: None,
        };
        let json = serde_json::to_value(&entity).unwrap();
        assert!(json.get("error").is_none());
        assert!(json.get("toctou_reason").is_none());
        assert!(json.get("entity_type").is_some());
    }
}
```

## Edge Cases

- `entity_results: Some(vec![])` — empty vec serializes as `[]`, not omitted. This is correct for "surgical mode ran but had no entities to process."
- `surgical_iids` with empty vecs — valid for edge case where user passes `--issue` but all IIDs are filtered out before sync.
- Ensure `EntitySyncResult.outcome` uses a fixed set of string values. Consider a future enum, but `String` is fine for initial implementation to keep serialization simple.

## Dependency Context

- **No upstream dependencies** — this bead only adds struct fields, no behavioral changes.
- **Downstream**: bd-1i4i (orchestrator) populates these fields. bd-3bec (wiring) passes them through.
- The `#[derive(Default)]` on `SyncResult` means all `Option` fields are `None` by default, so this is a fully additive change.

Completed: SyncResult extended with surgical_mode, surgical_iids, entity_results fields


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


Task 2: Implement key-decisions heuristic

## Background

The key-decisions heuristic is the core intelligence of lore explain. It identifies notes that explain WHY state/label changes were made by correlating events with notes by the same actor within 60 minutes.

**Parent bead:** bd-9lbr (lore explain feature)
**Spec:** specs/SPEC_explain.md — Task 2
**Phase:** 2 — Core Logic
**Depends on:** Task 1 (bd-2i3z) — needs ExplainParams, ExplainResult types, run_explain skeleton, and test helpers (setup_explain_db, insert_test_issue)

## Function Signature

\`\`\`rust
pub fn extract_key_decisions(
    conn: &Connection,
    entity_type: &str,   // "issues" or "mrs"
    entity_id: i64,      // LOCAL SQLite row id (from find_explain_issue/mr)
    since: Option<i64>,  // ms epoch threshold, or None for all time
    max_decisions: usize, // cap on returned decisions
) -> Result<Vec<KeyDecision>>
\`\`\`

Wire into run_explain():
\`\`\`rust
let key_decisions = if should_include(&params.sections, "key_decisions") {
    Some(extract_key_decisions(conn, &params.entity_type, entity_local_id, params.since, params.max_decisions)?)
} else { None };
\`\`\`

## Algorithm

1. Query resource_state_events for the entity (with optional --since filter)
2. Query resource_label_events for the entity (with optional --since filter)
3. Query non-system notes for the entity (with optional --since filter)
4. Merge state + label events into unified chronological list with (timestamp, actor, description)
5. For each event, find the FIRST non-system note by the SAME actor within 60 minutes AFTER the event
6. Pair them as a KeyDecision
7. Cap at max_decisions

## Local Structs (define in explain.rs)

\`\`\`rust
struct UnifiedEvent {
    created_at: i64,  // ms epoch
    actor: String,
    description: String,  // "state: closed" or "label: +bugfix"
}

struct NoteRow {
    body: String,
    author_username: String,
    created_at: i64,  // ms epoch
}
\`\`\`

## SQL Queries

### id_col resolution
\`\`\`rust
let id_col = if entity_type == "issues" { "issue_id" } else { "merge_request_id" };
\`\`\`

### State events
\`\`\`sql
SELECT state, actor_username, created_at
FROM resource_state_events
WHERE {id_col} = ?1 AND (?2 IS NULL OR created_at >= ?2)
ORDER BY created_at ASC
\`\`\`

### Label events
\`\`\`sql
SELECT action, label_name, actor_username, created_at
FROM resource_label_events
WHERE {id_col} = ?1 AND (?2 IS NULL OR created_at >= ?2)
ORDER BY created_at ASC
\`\`\`

### Notes for correlation
\`\`\`sql
SELECT n.body, n.author_username, n.created_at
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE d.{id_col} = ?1 AND n.is_system = 0 AND (?2 IS NULL OR n.created_at >= ?2)
ORDER BY n.created_at ASC
\`\`\`

Where {id_col} is issue_id or merge_request_id based on entity_type.
Pass since (Option<i64>) as the ?2 parameter — rusqlite handles None as SQL NULL.

## CRITICAL: State column format

The resource_state_events table has a SINGLE `state` TEXT column containing only the NEW state value (e.g., "opened", "closed", "reopened", "merged"). There is NO from_state column.

Action string format for state events: "state: closed" (just the new state).
Action string format for label events: "label: +bugfix" (add) or "label: -wontfix" (remove).

NOTE: label_name is NULLABLE (migration 012). Use unwrap_or("[deleted label]").
NOTE: actor_username on both event tables is Option<String>. Skip events with None actor (can't correlate).

## Correlation Logic

\`\`\`rust
const ONE_HOUR_MS: i64 = 60 * 60 * 1000;

let mut decisions = Vec::new();
for event in &events {  // sorted by created_at
    // Find first note by same actor within 60min after event
    if let Some(note) = notes.iter().find(|n|
        n.author_username == event.actor
        && n.created_at >= event.created_at
        && n.created_at <= event.created_at + ONE_HOUR_MS
    ) {
        decisions.push(KeyDecision {
            timestamp: ms_to_iso(event.created_at),
            actor: event.actor.clone(),
            action: event.description.clone(),
            context_note: truncate(&note.body, 500),
        });
    }
}
decisions.truncate(max_decisions);
\`\`\`

Use crate::core::time::ms_to_iso() for timestamp conversion.
Use crate::cli::render::truncate() for context_note truncation.

## Files to Modify

- src/cli/commands/explain.rs — add extract_key_decisions() function with local structs, wire into run_explain()

## TDD Workflow (Red-Green)

### RED — Write these tests first, confirm they FAIL:

Test data insertion helpers (add to explain.rs test module, reuse setup_explain_db and insert_test_issue from Task 1):

\`\`\`rust
fn insert_state_event(conn: &Connection, issue_id: i64, state: &str, actor: &str, created_at: i64) {
    conn.execute(
        "INSERT INTO resource_state_events (gitlab_id, project_id, issue_id, state, actor_username, created_at) VALUES (?1, 1, ?2, ?3, ?4, ?5)",
        rusqlite::params![created_at, issue_id, state, actor, created_at],
    ).unwrap();
}

fn insert_label_event(conn: &Connection, issue_id: i64, action: &str, label: &str, actor: &str, created_at: i64) {
    conn.execute(
        "INSERT INTO resource_label_events (gitlab_id, project_id, issue_id, action, label_name, actor_username, created_at) VALUES (?1, 1, ?2, ?3, ?4, ?5, ?6)",
        rusqlite::params![created_at, issue_id, action, label, actor, created_at],
    ).unwrap();
}

fn insert_note(conn: &Connection, discussion_id: i64, author: &str, body: &str, created_at: i64) {
    conn.execute(
        "INSERT INTO notes (gitlab_id, discussion_id, project_id, is_system, author_username, body, created_at, updated_at, last_seen_at) VALUES (?1, ?2, 1, 0, ?3, ?4, ?5, ?5, ?5)",
        rusqlite::params![created_at, discussion_id, author, body, created_at],
    ).unwrap();
}

fn insert_discussion(conn: &Connection, issue_id: i64) -> i64 {
    conn.execute(
        "INSERT INTO discussions (gitlab_discussion_id, project_id, issue_id, noteable_type, individual_note, first_note_at, last_note_at, last_seen_at, resolvable, resolved) VALUES ('disc-1', 1, ?1, 'Issue', 0, 0, 0, 0, 0, 0)",
        [issue_id],
    ).unwrap();
    conn.last_insert_rowid()
}
\`\`\`

Tests:
1. test_explain_key_decision_heuristic: Insert state change event at T=1000000, insert note by SAME author at T+30min (T+1800000), call extract_key_decisions(), assert 1 decision with action containing "state:" and non-empty context_note
2. test_explain_key_decision_ignores_unrelated_notes: Insert state change by "alice", insert note by "bob" at T+30min, assert 0 decisions
3. test_explain_key_decision_label_event: Insert label add event ("add", "bugfix") + correlated note by same author, assert decision.action starts with "label: +"
4. test_explain_max_decisions: Insert 5 correlated event+note pairs, call with max_decisions: 3, assert exactly 3 decisions returned
5. test_explain_since_scopes_events: Insert event at T-60d and event at T-10d, call with since: Some(T-30d epoch), assert only recent event appears

### GREEN — Implement extract_key_decisions() as described above

### Verify:
cargo test explain::tests::test_explain_key_decision && cargo clippy --all-targets -- -D warnings

## Acceptance Criteria

- [ ] test_explain_key_decision_heuristic passes
- [ ] test_explain_key_decision_ignores_unrelated_notes passes
- [ ] test_explain_key_decision_label_event passes
- [ ] test_explain_max_decisions passes
- [ ] test_explain_since_scopes_events passes
- [ ] Heuristic correctly correlates events with explanatory notes
- [ ] --max-decisions and --since respected
- [ ] cargo clippy and cargo fmt clean

Key-decisions heuristic implemented with state/label event correlation, 5 tests passing


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 module (`src/ingestion/surgical.rs` from bd-3sez) needs a comprehensive test suite. Tests use in-memory SQLite (no real GitLab or Ollama) and wiremock for HTTP mocks. The test file lives at `src/ingestion/surgical_tests.rs` and is included via `#[cfg(test)] #[path = "surgical_tests.rs"] mod tests;` in surgical.rs.

Key testing constraints:
- In-memory DB pattern: `create_connection(Path::new(":memory:"))` + `run_migrations(&conn)`
- Test project insert: `INSERT INTO projects (gitlab_project_id, path_with_namespace, web_url)` (no `name`/`last_seen_at` columns)
- `GitLabIssue` required fields: `id`, `iid`, `project_id`, `title`, `state`, `created_at`, `updated_at`, `author`, `web_url`
- `GitLabMergeRequest` adds: `source_branch`, `target_branch`, `draft`, `merge_status`, `reviewers`
- `updated_at` is `String` (ISO 8601) in GitLab types, e.g. `"2026-02-17T12:00:00.000+00:00"`
- `SourceType` enum variants: `Issue`, `MergeRequest`, `Discussion`, `Note`
- `dirty_sources` table: `(source_type TEXT, source_id INTEGER)` primary key

## Approach

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

### Test Helpers
- `setup_db() -> Connection` — in-memory DB with migrations + test project row
- `make_test_issue(iid: i64, updated_at: &str) -> GitLabIssue` — minimal valid JSON fixture
- `make_test_mr(iid: i64, updated_at: &str) -> GitLabMergeRequest` — minimal valid JSON fixture
- `get_db_updated_at(conn, table, iid) -> Option<i64>` — helper to query DB updated_at for assertions
- `get_dirty_keys(conn) -> Vec<(String, i64)>` — query dirty_sources for assertions

### Sync Tests (13)
1. `test_ingest_issue_by_iid_upserts_and_marks_dirty` — fresh issue ingest, verify DB row + dirty_sources entry
2. `test_ingest_mr_by_iid_upserts_and_marks_dirty` — fresh MR ingest, verify DB row + dirty_sources entry
3. `test_toctou_skips_stale_issue` — insert issue at T1, call ingest with payload at T1, assert skipped_stale=true and no dirty mark
4. `test_toctou_skips_stale_mr` — same for MRs
5. `test_toctou_allows_newer_issue` — DB has T1, payload has T2 (T2 > T1), assert upserted=true
6. `test_toctou_allows_newer_mr` — same for MRs
7. `test_is_stale_parses_iso8601` — unit test: `"2026-02-17T12:00:00.000+00:00"` parses to correct ms-epoch
8. `test_is_stale_handles_none_db_value` — first ingest, no DB row, assert not stale
9. `test_is_stale_with_z_suffix` — `"2026-02-17T12:00:00Z"` also parses correctly
10. `test_ingest_issue_returns_dirty_source_keys` — verify `dirty_source_keys` contains `(SourceType::Issue, local_id)`
11. `test_ingest_mr_returns_dirty_source_keys` — verify MR dirty source keys
12. `test_ingest_issue_updates_existing` — ingest same IID twice with newer updated_at, verify update
13. `test_ingest_mr_updates_existing` — same for MRs

### Async Preflight Test (1, wiremock)
14. `test_preflight_fetch_returns_issues_and_mrs` — wiremock GET `/projects/:id/issues?iids[]=42` returns 200 with fixture, verify PreflightResult.issues has 1 entry

### Integration Stubs (4, for bd-3jqx)
15. `test_surgical_cancellation_during_preflight` — stub: signal.cancel() before preflight, verify early return
16. `test_surgical_timeout_during_fetch` — stub: wiremock delay exceeds timeout
17. `test_surgical_embed_isolation` — stub: verify only surgical docs get embedded
18. `test_surgical_payload_integrity` — stub: verify ingested data matches GitLab payload exactly

## Acceptance Criteria

- [ ] All 13 sync tests pass with in-memory SQLite
- [ ] Async preflight test passes with wiremock
- [ ] 4 integration stubs compile and are marked `#[ignore]` (implemented in bd-3jqx)
- [ ] Test helpers produce valid GitLabIssue/GitLabMergeRequest fixtures that pass `transform_issue`/`transform_merge_request`
- [ ] No flaky tests: deterministic timestamps, no real network calls
- [ ] File wired into surgical.rs via `#[cfg(test)] #[path = "surgical_tests.rs"] mod tests;`

## Files

- `src/ingestion/surgical_tests.rs` (NEW)
- `src/ingestion/surgical.rs` (add `#[cfg(test)]` module path — created in bd-3sez)

## TDD Anchor

This bead IS the test suite. Tests are written first (TDD red phase), then bd-3sez implements the production code to make them pass (green phase). Specific test signatures:

```rust
#[test]
fn test_ingest_issue_by_iid_upserts_and_marks_dirty() {
    let conn = setup_db();
    let issue = make_test_issue(42, "2026-02-17T12:00:00.000+00:00");
    let config = Config::default();
    let result = ingest_issue_by_iid(&conn, &config, /*project_id=*/1, &issue).unwrap();
    assert!(result.upserted);
    assert!(!result.skipped_stale);
    let dirty = get_dirty_keys(&conn);
    assert!(dirty.contains(&("issue".to_string(), /*local_id from DB*/)));
}

#[test]
fn test_toctou_skips_stale_issue() {
    let conn = setup_db();
    let issue = make_test_issue(42, "2026-02-17T12:00:00.000+00:00");
    ingest_issue_by_iid(&conn, &Config::default(), 1, &issue).unwrap();
    // Ingest same timestamp again
    let result = ingest_issue_by_iid(&conn, &Config::default(), 1, &issue).unwrap();
    assert!(result.skipped_stale);
}

#[tokio::test]
async fn test_preflight_fetch_returns_issues_and_mrs() {
    let mock = MockServer::start().await;
    // ... wiremock setup ...
}
```

## Edge Cases

- `make_test_issue` must produce all required fields (`id`, `iid`, `project_id`, `title`, `state`, `created_at`, `updated_at`, `author` with `username` and `id`, `web_url`) or `transform_issue` will fail
- `make_test_mr` additionally needs `source_branch`, `target_branch`, `draft`, `merge_status`, `reviewers`
- ISO 8601 fixtures must use `+00:00` suffix (GitLab format), not `Z`
- Integration stubs must be `#[ignore]` so they do not fail CI before bd-3jqx implements them
- Test DB needs `run_migrations` to create all tables including `dirty_sources`, `documents`, `issues`, `merge_requests`

## Dependency Context

- **Blocked by bd-3sez**: Cannot compile tests until surgical.rs module exists (circular co-dependency — develop together)
- **Blocks bd-3jqx**: Integration test stubs are implemented in that bead
- **No other blockers**: Uses only in-memory DB and wiremock, no external dependencies

Merged into bd-3sez: tests belong with the code they test, not in a separate bead. TDD is the default workflow, not a separate deliverable.


[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.


Phase 3e: Rate limiter sleep migration

## What
Replace tokio::time::sleep with asupersync::time::sleep in the rate limiter backoff.

## Implementation
```rust
// Before
use tokio::time::sleep;
sleep(delay).await;

// After
use asupersync::time::sleep;
sleep(delay).await;
```

## Why This Is Trivial
The sleep API is identical — both take a Duration and return a future that resolves after the delay. The only change is the import path.

## Files Changed
- src/gitlab/client.rs (~2 LOC: 1 import + 1 usage, or just the import if usage is identical)

## Testing
- cargo check --all-targets

## Depends On
- Phase 3a (asupersync must be in Cargo.toml)

asupersync migration complete — 5 commits pushed to asupersync-migration branch


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


Phase 3f-Step1: Cx threading — orchestration path

## What
Thread Cx from main() through command dispatch into the orchestrator and ingestion modules. Replace join_all batches with region-scoped tasks. This is the BIG PAYOFF of the migration.

## Why
This is where structured cancellation actually matters. If Ctrl+C fires during a prefetch batch, the region cancels all in-flight HTTP requests with bounded cleanup instead of silently dropping them.

## Rearchitecture Context (2026-03-06)
A major code reorganization restructured several files referenced by this bead:
- main.rs is now thin (419 LOC). Command dispatch + handlers live in src/app/handlers.rs (via include\!() chain from main.rs)
- cli/commands/sync.rs -> split into cli/commands/sync/ folder (mod.rs, run.rs, render.rs, surgical.rs, sync_tests.rs)
- cli/commands/ingest.rs -> split into cli/commands/ingest/ folder (mod.rs, run.rs, render.rs)
- cli/commands/sync_surgical.rs -> moved to cli/commands/sync/surgical.rs

## Functions That Need cx: &Cx Added

| Module | Functions |
|--------|-----------|
| src/app/handlers.rs | Command dispatch handler functions for sync, ingest (physically in handlers.rs, logically in main.rs scope via include\!()) |
| cli/commands/sync/run.rs | run_sync() |
| cli/commands/ingest/run.rs | run_ingest_command(), run_ingest() |
| cli/commands/sync/surgical.rs | run_sync_surgical() |
| ingestion/orchestrator.rs | ingest_issues(), ingest_merge_requests(), ingest_discussions(), etc. |
| ingestion/surgical.rs | surgical_sync() |

## Region Wrapping for join_all Batches (orchestrator.rs)

```rust
// Before
let prefetched_batch = join_all(prefetch_futures).await;

// After — cancel-correct region
// NOTE: Pattern depends on asupersync region API. If scope.spawn() returns JoinHandle<T>,
// prefer collecting handles and awaiting them (preserves ordering).
```

## CRITICAL: Semantic Differences from join_all

1. **Ordering**: join_all preserves input order. Channel-based collection does NOT. If downstream assumes positional alignment, send (index, result) tuples and sort. Or use JoinHandle if available.

2. **Error aggregation**: join_all runs all futures even if some fail. Region cancellation may lose some results. Decide per call site: partial results OK, or retry entire batch?

3. **Backpressure**: Verify asupersync region API doesn't impose implicit concurrency caps.

4. **Late result loss on cancellation**: Tasks that completed but haven't been received may be lost. Caller must treat cancelled region results as incomplete.

AUDIT EVERY join_all CALL SITE for all 4 assumptions before choosing the pattern.

## Estimated Scope
~10 functions gain cx: &Cx parameter in this step. Focus on the orchestration hot path only.

## Files Changed
- src/app/handlers.rs (dispatch handler functions — physically here, logically in main.rs via include\!())
- src/cli/commands/sync/run.rs (was cli/commands/sync.rs)
- src/cli/commands/ingest/run.rs (was cli/commands/ingest.rs)
- src/cli/commands/sync/surgical.rs (was cli/commands/sync_surgical.rs)
- src/ingestion/orchestrator.rs (~30 LOC for region wrapping)
- src/ingestion/surgical.rs

## Testing
- cargo check --all-targets
- cargo test (all existing tests must pass)
- Manual: lore sync with Ctrl+C mid-batch — verify graceful drain

## Depends On
- Phase 3c (entrypoint provides Cx)


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

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


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