Update ingest command for merge_requests type

## Background
CLI entry point for MR ingestion. Routes `--type=merge_requests` to the orchestrator. Must ensure `--full` resets both MR cursor AND discussion watermarks. This is the user-facing command that kicks off the entire MR sync pipeline.

## Approach
Update `src/cli/commands/ingest.rs` to handle `merge_requests` type:
1. Add `merge_requests` branch to the resource type match statement
2. Validate resource type early with helpful error message
3. Pass `full` flag through to orchestrator (it handles the watermark reset internally)

## Files
- `src/cli/commands/ingest.rs` - Add merge_requests branch to `run_ingest`

## Acceptance Criteria
- [ ] `gi ingest --type=merge_requests` runs MR ingestion successfully
- [ ] `gi ingest --type=merge_requests --full` resets cursor AND discussion watermarks
- [ ] `gi ingest --type=invalid` returns helpful error listing valid types
- [ ] Progress output shows MR counts, discussion counts, and skip counts
- [ ] Default type remains `issues` for backward compatibility
- [ ] `cargo test ingest_command` passes

## TDD Loop
RED: `gi ingest --type=merge_requests` -> "invalid type: merge_requests"
GREEN: Add merge_requests to match statement in run_ingest
VERIFY: `gi ingest --type=merge_requests --help` shows merge_requests as valid

## Function Signature
```rust
pub async fn run_ingest(
    config: &Config,
    args: &IngestArgs,
) -> Result<(), GiError>
```

## IngestArgs Reference (existing)
```rust
#[derive(Parser, Debug)]
pub struct IngestArgs {
    /// Resource type to ingest
    #[arg(long, short = 't', default_value = "issues")]
    pub r#type: String,

    /// Filter to specific project (by path or ID)
    #[arg(long, short = 'p')]
    pub project: Option<String>,

    /// Force run even if another ingest is in progress
    #[arg(long, short = 'f')]
    pub force: bool,

    /// Full sync - reset cursor and refetch all
    #[arg(long)]
    pub full: bool,
}
```

## Code Change
```rust
use crate::core::errors::GiError;
use crate::ingestion::orchestrator::Orchestrator;

pub async fn run_ingest(
    config: &Config,
    args: &IngestArgs,
) -> Result<(), GiError> {
    let resource_type = args.r#type.as_str();

    // Validate resource type early
    match resource_type {
        "issues" | "merge_requests" => {}
        _ => {
            return Err(GiError::InvalidArgument {
                name: "type".to_string(),
                value: resource_type.to_string(),
                expected: "issues or merge_requests".to_string(),
            });
        }
    }

    // Acquire single-flight lock (unless --force)
    if !args.force {
        acquire_ingest_lock(config, resource_type)?;
    }

    // Get projects to ingest (filtered if --project specified)
    let projects = get_projects_to_ingest(config, args.project.as_deref())?;

    for project in projects {
        println!("Ingesting {} for {}...", resource_type, project.path);

        let orchestrator = Orchestrator::new(
            &config,
            project.id,
            project.gitlab_id,
        )?;

        let result = orchestrator.run_ingestion(resource_type, args.full).await?;

        // Print results based on resource type
        match resource_type {
            "issues" => {
                println!("  {}: {} issues fetched, {} upserted",
                    project.path, result.issues_fetched, result.issues_upserted);
            }
            "merge_requests" => {
                println!("  {}: {} MRs fetched, {} new labels, {} assignees, {} reviewers",
                    project.path,
                    result.mrs_fetched,
                    result.labels_created,
                    result.assignees_linked,
                    result.reviewers_linked,
                );
                println!("  Discussions: {} synced, {} notes ({} DiffNotes)",
                    result.discussions_synced,
                    result.notes_synced,
                    result.diffnotes_count,
                );
                if result.mrs_skipped_discussion_sync > 0 {
                    println!("  Skipped discussion sync for {} unchanged MRs",
                        result.mrs_skipped_discussion_sync);
                }
                if result.failed_discussion_syncs > 0 {
                    eprintln!("  Warning: {} MRs failed discussion sync (will retry next run)",
                        result.failed_discussion_syncs);
                }
            }
            _ => unreachable!(),
        }
    }

    // Release lock
    if !args.force {
        release_ingest_lock(config, resource_type)?;
    }

    Ok(())
}
```

## Output Format
```
Ingesting merge_requests for group/project-one...
  group/project-one: 567 MRs fetched, 12 new labels, 89 assignees, 45 reviewers
  Discussions: 456 synced, 1,234 notes (89 DiffNotes)
  Skipped discussion sync for 444 unchanged MRs

Total: 567 MRs, 456 discussions, 1,234 notes
```

## Full Sync Behavior
When `--full` is passed:
1. MR cursor reset to NULL (handled by `ingest_merge_requests` with `full_sync: true`)
2. Discussion watermarks reset to NULL (handled by `reset_discussion_watermarks` called from ingestion)
3. All MRs re-fetched from GitLab API
4. All discussions re-fetched for every MR

## Error Types (from GiError enum)
```rust
// In src/core/errors.rs
pub enum GiError {
    InvalidArgument {
        name: String,
        value: String,
        expected: String,
    },
    LockError {
        resource: String,
        message: String,
    },
    // ... other variants
}
```

## Edge Cases
- Default type is `issues` for backward compatibility with CP1
- Project filter (`--project`) can limit to specific project by path or ID
- Force flag (`--force`) bypasses single-flight lock for debugging
- If no projects configured, return helpful error about running `gi project add` first
- Empty project (no MRs): completes successfully with "0 MRs fetched"


[CP1] gi show issue command

## Background

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

## Approach

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

### Clap Definition

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

    /// Entity IID
    pub iid: i64,

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

### Handler Function

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

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

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

### Output Format (matches PRD)

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

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

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

Discussions (5):

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

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

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

### Queries

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

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

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

## Acceptance Criteria

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

## Files

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

## TDD Loop

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

GREEN: Implement handler with queries and formatting

VERIFY: `cargo test show_issue`

## Edge Cases

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

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


[CP1] Ingestion orchestrator

## Background

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

## Approach

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

### Main Function

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

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

### Orchestration Steps

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

### Bounded Concurrency Pattern

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

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

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

## Acceptance Criteria

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

## Files

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

## TDD Loop

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

GREEN: Implement orchestrator with bounded concurrency

VERIFY: `cargo test orchestrator`

## Edge Cases

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


Implement lore stats CLI command

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


WHO: Workload mode query (query_workload)

## Background

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

## Approach

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

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

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

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

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

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

## Files

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

## TDD Loop

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

## Acceptance Criteria

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

## Edge Cases

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

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


Implement MR ingestion module

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        tx.commit()?;

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

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

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

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

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

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


Add Resource Events API endpoints to GitLab client

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

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

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

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

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

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

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

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

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

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

GREEN: Implement the 6 methods using the generic helper

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

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

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


WHO: Comprehensive test suite

## Background

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

## Approach

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

### Test list with key assertions:

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

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

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

## Files

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

## TDD Loop

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

## Acceptance Criteria

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

## Edge Cases

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


Implement generic dependent fetch queue (enqueue + drain)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

GREEN: Implement all functions

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

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

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


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


[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


[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