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.


[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

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