[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


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. Drift can occur when query functions are duplicated or modified independently.

## Approach
Dashboard count parity:
- Run TUI fetch_dashboard, compare counts against lore --robot count issues/mrs/discussions/notes
- Assert all counts match exactly

List parity:
- Run TUI fetch_issues with default filter, compare against lore --robot issues output
- Assert same IIDs, same order, same state values
- Same for MR list

Detail parity:
- Run TUI fetch_issue_detail for a specific issue, compare against lore --robot issues <iid>
- Assert same metadata fields, same discussion count
- Same for MR detail

Search parity:
- Run TUI execute_search(lexical, "query"), compare against lore --robot search "query"
- Assert same document IDs in same order (identity parity)

Terminal safety parity:
- Create issues with ANSI escape sequences in titles
- Assert both TUI and CLI sanitize them consistently

## Acceptance Criteria
- [ ] Dashboard counts: TUI == CLI for all entity types
- [ ] Issue list: TUI returns same IIDs in same order as CLI
- [ ] MR list: TUI returns same IIDs in same order as CLI
- [ ] Issue detail: TUI metadata matches CLI for all fields
- [ ] Search results: same IDs in same order
- [ ] Sanitization: both strip ANSI from untrusted text

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

## TDD Anchor
RED: Write test_dashboard_count_parity that runs both TUI fetch_dashboard and equivalent CLI count queries on same DB, asserts all counts equal.
GREEN: Ensure TUI queries match CLI queries.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml parity

## Edge Cases
- CLI and TUI may compute relative times differently — compare raw timestamps, not formatted strings
- CLI list commands may have different default limits — normalize to same limit
- Schema must be identical for both code paths (same migration version)

## Dependency Context
Uses all TUI action.rs query functions from Phase 2/3 tasks.
Uses existing CLI command query functions from lore core.
Uses same DB fixture for both TUI and CLI queries.


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.


[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


[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 (bd-1oo)
- resolve_rename_chain() from bd-1yx (src/core/file_history.rs) for rename handling
- VALID_COMMANDS array in src/main.rs (line ~448)

## 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
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

## Files

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

## TDD Loop

No unit tests for CLI wiring. Verify with:

```bash
cargo check --all-targets
cargo run -- file-history --help
```

## Edge Cases

- File path with spaces: clap handles quoting
- Path not in any MR: empty result, friendly message, 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


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