Implement MR ingestion module

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        tx.commit()?;

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

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

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

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

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

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


Add Resource Events API endpoints to GitLab client

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

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

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

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

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

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

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

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

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

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

GREEN: Implement the 6 methods using the generic helper

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

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

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


WHO: Comprehensive test suite

## Background

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

## Approach

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

### Test list with key assertions:

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

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

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

## Files

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

## TDD Loop

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

## Acceptance Criteria

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

## Edge Cases

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

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


Implement generic dependent fetch queue (enqueue + drain)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

GREEN: Implement all functions

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

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

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


[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


[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


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