Write migration 011: resource event tables, entity_references, and dependent fetch queue

## Background
Phase B needs three new event tables and a generic dependent fetch queue to power temporal queries (timeline, file-history, trace). These tables store structured event data from GitLab Resource Events APIs, replacing fragile system note parsing for state/label/milestone changes.

Migration 010_chunk_config.sql already exists, so Phase B starts at migration 011.

## Approach
Create migrations/011_resource_events.sql with the exact schema from the Phase B spec (§1.2 + §2.2):

**Event tables:**
- resource_state_events: state changes (opened/closed/reopened/merged/locked) with source_merge_request_id for "closed by MR" linking
- resource_label_events: label add/remove with label_name
- resource_milestone_events: milestone add/remove with milestone_title + milestone_id

**Cross-reference table (Gate 2):**
- entity_references: source/target entity pairs with reference_type (closes/mentioned/related), source_method provenance, and unresolved reference support (target_entity_id NULL with target_project_path + target_entity_iid)

**Dependent fetch queue:**
- pending_dependent_fetches: generic job queue with job_type IN ('resource_events', 'mr_closes_issues', 'mr_diffs'), locked_at crash recovery, exponential backoff via attempts + next_retry_at

**All tables must have:**
- CHECK constraints for entity exclusivity (issue XOR merge_request) on event tables
- UNIQUE constraints (gitlab_id + project_id for events, composite for queue, multi-column for references)
- Partial indexes (WHERE issue_id IS NOT NULL, WHERE target_entity_id IS NULL, etc.)
- CASCADE deletes on project_id and entity FKs

Register in src/core/db.rs MIGRATIONS array:
```rust
("011", include_str!("../../migrations/011_resource_events.sql")),
```

End migration with:
```sql
INSERT INTO schema_version (version, applied_at, description)
VALUES (11, strftime('%s', 'now') * 1000, 'Resource events, entity references, and dependent fetch queue');
```

## Acceptance Criteria
- [ ] migrations/011_resource_events.sql exists with all 4 tables + indexes + constraints
- [ ] src/core/db.rs MIGRATIONS array includes ("011", include_str!(...))
- [ ] `cargo build` succeeds (migration SQL compiles into binary)
- [ ] `cargo test migration` passes (migration applies cleanly on fresh DB)
- [ ] All CHECK constraints enforced (issue XOR merge_request on event tables)
- [ ] All UNIQUE constraints present (prevents duplicate events/refs/jobs)
- [ ] entity_references UNIQUE handles NULL coalescing correctly
- [ ] pending_dependent_fetches job_type CHECK includes all three types

## Files
- migrations/011_resource_events.sql (new)
- src/core/db.rs (add to MIGRATIONS array, line ~46)

## TDD Loop
RED: Add test in tests/migration_tests.rs:
- `test_migration_011_creates_event_tables` - verify all 4 tables exist after migration
- `test_migration_011_entity_exclusivity_constraint` - verify CHECK rejects both NULL and both non-NULL for issue_id/merge_request_id
- `test_migration_011_event_dedup` - verify UNIQUE(gitlab_id, project_id) rejects duplicate events
- `test_migration_011_entity_references_dedup` - verify UNIQUE constraint with NULL coalescing
- `test_migration_011_queue_dedup` - verify UNIQUE(project_id, entity_type, entity_iid, job_type)

GREEN: Write the migration SQL + register in db.rs

VERIFY: `cargo test migration_tests -- --nocapture`

## Edge Cases
- entity_references UNIQUE uses COALESCE for NULLable columns — test with both resolved and unresolved refs
- pending_dependent_fetches job_type CHECK — ensure 'mr_diffs' is included (Gate 4 needs it)
- SQLite doesn't enforce CHECK on INSERT OR REPLACE — verify constraint behavior
- The entity exclusivity CHECK must allow exactly one of issue_id/merge_request_id to be non-NULL
- Verify CASCADE deletes work (delete project → all events/refs/jobs deleted)

Already completed in prior session, re-closing after accidental reopen


Add GitLab client MR pagination methods

## Background
GitLab client pagination for merge requests and discussions. Must support robust pagination with fallback chain because some GitLab instances/proxies strip headers.

## Approach
Add to existing `src/gitlab/client.rs`:
1. `MergeRequestPage` struct - Items + pagination metadata
2. `parse_link_header_next()` - RFC 8288 Link header parsing
3. `fetch_merge_requests_page()` - Single page fetch with metadata
4. `paginate_merge_requests()` - Async stream for all MRs
5. `paginate_mr_discussions()` - Async stream for MR discussions

## Files
- `src/gitlab/client.rs` - Add pagination methods

## Acceptance Criteria
- [ ] `MergeRequestPage` struct exists with `items`, `next_page`, `is_last_page`
- [ ] `parse_link_header_next()` extracts `rel="next"` URL from Link header
- [ ] Pagination fallback chain: Link header > x-next-page > full-page heuristic
- [ ] `paginate_merge_requests()` returns `Pin<Box<dyn Stream<Item = Result<GitLabMergeRequest>>>>`
- [ ] `paginate_mr_discussions()` returns `Pin<Box<dyn Stream<Item = Result<GitLabDiscussion>>>>`
- [ ] MR endpoint uses `scope=all&state=all` to include all MRs
- [ ] `cargo test client` passes

## TDD Loop
RED: `cargo test fetch_merge_requests` -> method not found
GREEN: Add pagination methods
VERIFY: `cargo test client`

## Struct Definitions
```rust
#[derive(Debug)]
pub struct MergeRequestPage {
    pub items: Vec<GitLabMergeRequest>,
    pub next_page: Option<u32>,
    pub is_last_page: bool,
}
```

## Link Header Parsing (RFC 8288)
```rust
/// Parse Link header to extract rel="next" URL.
fn parse_link_header_next(headers: &reqwest::header::HeaderMap) -> Option<String> {
    headers
        .get("link")
        .and_then(|v| v.to_str().ok())
        .and_then(|link_str| {
            // Format: <url>; rel="next", <url>; rel="last"
            for part in link_str.split(',') {
                let part = part.trim();
                if part.contains("rel=\"next\"") || part.contains("rel=next") {
                    if let Some(start) = part.find('<') {
                        if let Some(end) = part.find('>') {
                            return Some(part[start + 1..end].to_string());
                        }
                    }
                }
            }
            None
        })
}
```

## Pagination Fallback Chain
```rust
let next_page = match (link_next, x_next_page, items.len() as u32 == per_page) {
    (Some(_), _, _) => Some(page + 1),     // Link header present: continue
    (None, Some(np), _) => Some(np),       // x-next-page present: use it
    (None, None, true) => Some(page + 1),  // Full page, no headers: try next
    (None, None, false) => None,           // Partial page: we're done
};
```

## Fetch Single Page
```rust
pub async fn fetch_merge_requests_page(
    &self,
    gitlab_project_id: i64,
    updated_after: Option<i64>,
    cursor_rewind_seconds: u32,
    page: u32,
    per_page: u32,
) -> Result<MergeRequestPage> {
    let mut params = vec![
        ("scope", "all".to_string()),
        ("state", "all".to_string()),
        ("order_by", "updated_at".to_string()),
        ("sort", "asc".to_string()),
        ("per_page", per_page.to_string()),
        ("page", page.to_string()),
    ];
    // Apply cursor rewind for safety
    // ...
}
```

## Async Stream Pattern
```rust
pub fn paginate_merge_requests(
    &self,
    gitlab_project_id: i64,
    updated_after: Option<i64>,
    cursor_rewind_seconds: u32,
) -> Pin<Box<dyn Stream<Item = Result<GitLabMergeRequest>> + Send + '_>> {
    Box::pin(async_stream::try_stream! {
        let mut page = 1u32;
        let per_page = 100u32;
        loop {
            let page_result = self.fetch_merge_requests_page(...).await?;
            for mr in page_result.items {
                yield mr;
            }
            if page_result.is_last_page {
                break;
            }
            match page_result.next_page {
                Some(np) => page = np,
                None => break,
            }
        }
    })
}
```

## Edge Cases
- `scope=all` required to include all MRs (not just authored by current user)
- `state=all` required to include merged/closed (GitLab defaults may exclude)
- `locked` state cannot be filtered server-side (use local SQL filtering)
- Cursor rewind should clamp to 0 to avoid negative timestamps


Epic: Gate 3 - Decision Timeline (lore timeline)

## Background

Gate 3 is the first user-facing temporal feature: `lore timeline <query>`. It answers "What happened with X?" by finding matching entities via FTS5, expanding cross-references, collecting all temporal events, and rendering a chronological narrative.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Gate 3 (Sections 3.1-3.6).

## Prerequisites (All Complete)

- Gates 1-2 COMPLETE: resource_state_events, resource_label_events, resource_milestone_events, entity_references all populated
- FTS5 search index (CP3): working search infrastructure for keyword matching
- Migration 015 (commit SHAs, closes watermark) exists on disk (registered by bd-1oo)

## Architecture — 5-Stage Pipeline

```
1. SEED:    FTS5 keyword search -> matched document IDs (issues, MRs, notes)
2. HYDRATE: Map document IDs -> source entities + top matched notes as evidence
3. EXPAND:  BFS over entity_references (depth-limited, edge-type filtered)
4. COLLECT: Gather events from all tables for seed + expanded entities
5. RENDER:  Sort chronologically, format as human or robot output
```

No new tables required. All reads are from existing tables at query time.

## Children (Execution Order)

1. **bd-20e** — Define TimelineEvent model and TimelineEventType enum (types first)
2. **bd-32q** — Implement timeline seed phase: FTS5 keyword search to entity IDs
3. **bd-ypa** — Implement timeline expand phase: BFS cross-reference expansion
4. **bd-3as** — Implement timeline event collection and chronological interleaving
5. **bd-1nf** — Register lore timeline command with all flags (CLI wiring)
6. **bd-2f2** — Implement timeline human output renderer
7. **bd-dty** — Implement timeline robot mode JSON output

## Gate Completion Criteria

- [ ] `lore timeline <query>` returns chronologically ordered events
- [ ] Seed entities found via FTS5 keyword search (issues, MRs, and notes)
- [ ] State, label, and milestone events interleaved from resource event tables
- [ ] Entity creation and merge events included
- [ ] Evidence-bearing notes included as note_evidence events (top FTS5 matches, bounded default 10)
- [ ] Cross-reference expansion follows entity_references to configurable depth
- [ ] Default: follows closes + related edges; --expand-mentions adds mentioned
- [ ] --depth 0 disables expansion
- [ ] --since filters by event timestamp
- [ ] -p scopes to project
- [ ] Human output is colored and readable
- [ ] Robot mode returns structured JSON with expansion provenance
- [ ] Unresolved (external) references included in JSON output

Gate 3 complete: all 7 children closed. Timeline pipeline fully implemented with SEED->HYDRATE->EXPAND->COLLECT->RENDER stages, human+robot renderers, CLI wiring with 9 flags, robot-docs manifest entry


NOTE-0D: Capture immutable author_id in note upserts

## Background
Core use case is year-scale reviewer profiling. GitLab usernames are mutable — a user can change username mid-year, fragmenting author queries. GitLab note payloads include note.author.id (immutable integer). Capturing this provides a stable identity anchor for longitudinal analysis.

## Approach
1. The author_id column and index are added by migration 022 (NOTE-1E, bd-296a). This bead only handles the ingestion code changes.

2. Populate author_id during upsert: In both upsert_note_for_issue() (from NOTE-0A) and upsert_note() in mr_discussions.rs, add author_id to INSERT column list and ON CONFLICT DO UPDATE SET clause. The value comes from the GitLab API note.author.id field.

3. Check NormalizedNote in src/gitlab/transformers.rs — if it doesn't have an author_id field yet, add it there. The GitLab REST API returns notes with: { "author": { "id": 12345, "username": "jdefting", ... } }. Extract author.id in the transformer.

4. Semantic change detection: author_id changes do NOT trigger changed_semantics = true. It's an identity anchor, not content. Do not include author_id in the pre-read comparison fields.

## Files
- MODIFY: src/ingestion/discussions.rs (add author_id to upsert_note_for_issue INSERT and ON CONFLICT SET)
- MODIFY: src/ingestion/mr_discussions.rs (add author_id to upsert_note INSERT and ON CONFLICT SET, line 470)
- MODIFY: src/gitlab/transformers.rs (add author_id: Option<i64> to NormalizedNote if missing, extract from API note.author.id)

## TDD Anchor
RED: test_issue_note_upsert_captures_author_id — insert note with author_id=12345, assert stored correctly.
GREEN: Add author_id to INSERT/UPDATE clauses and transformer.
VERIFY: cargo test captures_author_id -- --nocapture
Tests: test_mr_note_upsert_captures_author_id, test_note_upsert_author_id_nullable (old API responses without author.id), test_note_author_id_survives_username_change

## Acceptance Criteria
- [ ] author_id populated in upsert_note_for_issue INSERT and ON CONFLICT SET
- [ ] author_id populated in MR upsert_note INSERT and ON CONFLICT SET
- [ ] NormalizedNote has author_id: Option<i64> field
- [ ] Transformer extracts author.id from GitLab API note payload
- [ ] author_id = None handled gracefully (older API responses)
- [ ] author_id change does NOT trigger changed_semantics
- [ ] All 4 tests pass

## Dependency Context
- Depends on NOTE-0A (bd-3bpk): modifies upsert functions created in NOTE-0A
- Depends on NOTE-1E (bd-296a): migration 022 adds the author_id column + index to the notes table. Column must exist before ingestion code can write to it.

## Edge Cases
- Old GitLab instances: author.id may be missing from API response — use None
- Self-hosted GitLab: some versions may not include author block — handle gracefully
- author_id is nullable INTEGER — no NOT NULL constraint


Add fetchMrFileChanges config flag

## Background

Config flag controlling whether MR diff fetching is enabled, following the fetchResourceEvents pattern.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 4.2.

## Codebase Context

- src/core/config.rs has SyncConfig with fetch_resource_events: bool (serde rename 'fetchResourceEvents', default true)
- Default impl exists for SyncConfig
- CLI sync options in src/cli/mod.rs have --no-events flag pattern
- Orchestrator checks config.sync.fetch_resource_events before enqueuing resource_events jobs

## Approach

### 1. Add to SyncConfig (`src/core/config.rs`):
```rust
#[serde(rename = "fetchMrFileChanges", default = "default_true")]
pub fetch_mr_file_changes: bool,
```

Update Default impl to include fetch_mr_file_changes: true.

### 2. CLI override (`src/cli/mod.rs`):
```rust
#[arg(long = "no-file-changes")]
pub no_file_changes: bool,
```

### 3. Apply in main.rs:
```rust
if args.no_file_changes { config.sync.fetch_mr_file_changes = false; }
```

### 4. Guard in orchestrator:
```rust
if config.sync.fetch_mr_file_changes { enqueue mr_diffs jobs }
```

## Acceptance Criteria

- [ ] fetchMrFileChanges in SyncConfig, default true
- [ ] Config without field defaults to true
- [ ] --no-file-changes disables diff fetching
- [ ] Orchestrator skips mr_diffs when false
- [ ] `cargo check --all-targets` passes

## Files

- `src/core/config.rs` (add field + Default)
- `src/cli/mod.rs` (add --no-file-changes)
- `src/main.rs` (apply override)
- `src/ingestion/orchestrator.rs` (guard enqueue)

## TDD Loop

RED:
- `test_config_default_fetch_mr_file_changes` - default is true
- `test_config_deserialize_false` - JSON with false

GREEN: Add field, default, serde attribute.

VERIFY: `cargo test --lib -- config`

## Edge Cases

- Config missing fetchMrFileChanges key entirely: serde default_true fills in true
- Config explicitly set to false: no mr_diffs jobs enqueued, mr_file_changes table empty
- --no-file-changes with --full sync: overrides config, no diffs fetched even on full resync
- sync.fetchMrFileChanges = false in config + no --no-file-changes flag: respects config (no override)

Added fetch_mr_file_changes to SyncConfig (default true, serde rename fetchMrFileChanges), --no-file-changes CLI flag in SyncArgs, override in main.rs. Orchestrator guard deferred to bd-2yo which implements the actual drain.


[CP1] Discussion and note transformers

Transform GitLab discussion/note payloads to normalized database schema.

## Module
src/gitlab/transformers/discussion.rs

## Structs

### NormalizedDiscussion
- gitlab_discussion_id: String
- project_id: i64
- issue_id: i64
- noteable_type: String ("Issue")
- individual_note: bool
- first_note_at, last_note_at: Option<i64>
- last_seen_at: i64
- resolvable, resolved: bool

### NormalizedNote
- gitlab_id: i64
- project_id: i64
- note_type: Option<String>
- is_system: bool
- author_username: String
- body: String
- created_at, updated_at, last_seen_at: i64
- position: i32 (array index in notes[])
- resolvable, resolved: bool
- resolved_by: Option<String>
- resolved_at: Option<i64>

## Functions

### transform_discussion(gitlab_discussion, local_project_id, local_issue_id) -> NormalizedDiscussion
- Compute first_note_at/last_note_at from notes array min/max created_at
- Compute resolvable (any note resolvable)
- Compute resolved (resolvable AND all resolvable notes resolved)

### transform_notes(gitlab_discussion, local_project_id) -> Vec<NormalizedNote>
- Enumerate notes to get position (array index)
- Set is_system from note.system
- Convert timestamps to ms epoch

Files: src/gitlab/transformers/discussion.rs
Tests: tests/discussion_transformer_tests.rs
Done when: Unit tests pass for discussion/note transformation with system note flagging


[CP1] gi show issue command

Show issue details with discussions.

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

## Clap Definition
Show {
    #[arg(value_parser = ["issue", "mr"])]
    entity: String,

    iid: i64,

    #[arg(long)]
    project: Option<String>,
}

## Output Format
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?

## Ambiguity Handling
If multiple projects have same iid, either:
- Prompt for --project flag
- Show error listing which projects have that iid

Files: src/cli/commands/show.rs
Done when: Issue detail view displays all fields including threaded discussions


Extract orchestrator per-entity logic and implement inline dependent helpers

## Background

The orchestrator's drain functions (`drain_resource_events` at line 932, `drain_mr_closes_issues` at line 1254, `drain_mr_diffs` at line 1514) are private and tightly coupled to the job queue system (`pending_dependent_fetches`, `claim_jobs`, `complete_job`). They batch-process all entities for a project, not individual ones. Surgical sync needs per-entity versions of these operations.

The underlying storage functions already exist and are usable:
- `store_resource_events(conn, project_id, entity_type, entity_local_id, state_events, label_events, milestone_events)` (orchestrator.rs:1100) — calls `upsert_state_events`, `upsert_label_events`, `upsert_milestone_events`
- `store_closes_issues_refs(conn, project_id, mr_local_id, closes_issues)` (orchestrator.rs:1409) — inserts entity references
- `upsert_mr_file_changes(conn, project_id, mr_local_id, diffs)` (mr_diffs.rs:26) — already pub

The GitLabClient methods for fetching are also already pub:
- `fetch_all_resource_events(gitlab_project_id, entity_type, iid)` -> (state, label, milestone) events
- `fetch_mr_closes_issues(gitlab_project_id, iid)` -> Vec<GitLabIssueRef>
- `fetch_mr_diffs(gitlab_project_id, iid)` -> Vec<GitLabMrDiff>

The gap: no standalone per-entity functions that fetch + store for a single entity without the job queue machinery.

## Approach

Create standalone helper functions in `src/ingestion/surgical.rs` (or a new `src/ingestion/surgical_dependents.rs` sub-module) that surgical.rs calls after ingesting each entity:

1. **`fetch_and_store_resource_events_for_entity`** (async): Takes `client`, `conn`, `project_id`, `gitlab_project_id`, `entity_type` ("issue"|"merge_request"), `entity_iid`, `entity_local_id`. Calls `client.fetch_all_resource_events()`, then `store_resource_events()` (needs `pub(crate)` visibility, currently private in orchestrator.rs). Updates the watermark column (`resource_events_synced_for_updated_at`).

2. **`fetch_and_store_discussions_for_entity`** (async): For issues, calls existing `ingest_issue_discussions()`. For MRs, calls `ingest_mr_discussions()`. Both are already pub. This is a thin routing wrapper.

3. **`fetch_and_store_closes_issues_for_entity`** (async, MR-only): Calls `client.fetch_mr_closes_issues()`, then `store_closes_issues_refs()` (needs `pub(crate)`). Updates watermark.

4. **`fetch_and_store_file_changes_for_entity`** (async, MR-only): Calls `client.fetch_mr_diffs()`, then `upsert_mr_file_changes()` (already pub). Updates watermark.

Visibility changes needed in orchestrator.rs (part of bd-1sc6):
- `store_resource_events` -> `pub(crate)`
- `store_closes_issues_refs` -> `pub(crate)`
- `update_resource_event_watermark_tx` -> `pub(crate)` (or inline the SQL)
- `update_closes_issues_watermark_tx` -> `pub(crate)` (or inline)

## Acceptance Criteria

- [ ] `fetch_and_store_resource_events_for_entity` fetches all 3 event types and stores them in one transaction
- [ ] `fetch_and_store_discussions_for_entity` routes to correct discussion ingest function by entity type
- [ ] `fetch_and_store_closes_issues_for_entity` fetches and stores closes_issues refs for MRs
- [ ] `fetch_and_store_file_changes_for_entity` fetches and stores MR diffs
- [ ] Each helper updates the appropriate watermark column after successful store
- [ ] Each helper returns a result struct with counts (fetched, stored, skipped)
- [ ] All helpers are `pub(crate)` for use by the orchestration function (bd-1i4i)
- [ ] Config-gated: resource events only fetched if `config.sync.fetch_resource_events == true`, file changes only if `config.sync.fetch_mr_file_changes == true`

## Files

- `src/ingestion/surgical.rs` (add helper functions, or create `surgical_dependents.rs` sub-module)
- `src/ingestion/orchestrator.rs` (change `store_resource_events`, `store_closes_issues_refs`, watermark functions to `pub(crate)` — via bd-1sc6)

## TDD Anchor

Tests in `src/ingestion/surgical_tests.rs` (bd-x8oq):

```rust
#[tokio::test]
async fn test_fetch_and_store_resource_events_for_issue() {
    let conn = setup_db();
    let mock = MockServer::start().await;
    // Mock state/label/milestone event endpoints
    Mock::given(method("GET"))
        .and(path_regex(r"/api/v4/projects/\d+/issues/\d+/resource_state_events"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!([])))
        .mount(&mock).await;
    // ... similar for label and milestone
    let client = make_test_client(&mock);
    let result = fetch_and_store_resource_events_for_entity(
        &client, &conn, /*project_id=*/1, /*gitlab_project_id=*/100,
        "issue", /*iid=*/42, /*local_id=*/1,
    ).await.unwrap();
    assert_eq!(result.fetched, 0); // empty events
    // Verify watermark updated
    let watermark: Option<i64> = conn.query_row(
        "SELECT resource_events_synced_for_updated_at FROM issues WHERE id = 1",
        [], |r| r.get(0),
    ).unwrap();
    assert!(watermark.is_some());
}

#[tokio::test]
async fn test_fetch_and_store_closes_issues_for_mr() {
    let conn = setup_db();
    let mock = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path_regex(r"/api/v4/projects/\d+/merge_requests/\d+/closes_issues"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!([
            {"iid": 10, "project_id": 100}
        ])))
        .mount(&mock).await;
    let client = make_test_client(&mock);
    let result = fetch_and_store_closes_issues_for_entity(
        &client, &conn, 1, 100, /*mr_iid=*/5, /*mr_local_id=*/1,
    ).await.unwrap();
    assert_eq!(result.stored, 1);
}

#[tokio::test]
async fn test_fetch_and_store_file_changes_for_mr() {
    // Similar: mock /diffs endpoint, verify upsert_mr_file_changes called
}

#[tokio::test]
async fn test_resource_events_skipped_when_config_disabled() {
    // config.sync.fetch_resource_events = false -> returns Ok with 0 counts
}
```

## Edge Cases

- `fetch_all_resource_events` returns 3 separate Results (state, label, milestone). If one fails (e.g., 403 on milestone events), the others should still be stored. Partial success handling.
- `fetch_mr_closes_issues` on a deleted MR returns 404: `coalesce_not_found` already handles this in the client, returning empty vec.
- Watermark update must happen AFTER successful store, not before, to avoid marking as synced when store failed.
- Discussion ingest for MRs uses `prefetch_mr_discussions` (async) + `write_prefetched_mr_discussions` (sync) two-phase pattern. The helper must handle both phases.
- If `config.sync.fetch_resource_events` is false, skip resource event fetch entirely (return empty result).
- If `config.sync.fetch_mr_file_changes` is false, skip file changes fetch entirely.

## Dependency Context

- **Blocked by bd-3sez**: surgical.rs must exist before adding helpers to it
- **Blocked by bd-1sc6 (indirectly via bd-3sez)**: `store_resource_events` and `store_closes_issues_refs` need `pub(crate)` visibility
- **Blocks bd-1i4i**: Orchestration function calls these helpers after each entity ingest
- **Blocks bd-3jqx**: Integration tests exercise the full surgical pipeline including these helpers
- **Uses existing pub APIs**: `GitLabClient::fetch_all_resource_events`, `fetch_mr_closes_issues`, `fetch_mr_diffs`, `upsert_mr_file_changes`, `ingest_issue_discussions`, `ingest_mr_discussions`

Completed: all implementation work done, code reviewed, tests passing


Rewrite agent skills to mandate lore for all reads

## Background
Agent skills and AGENTS.md files currently allow agents to choose between glab and lore for read operations. Agents default to glab (familiar from training data) even though lore returns richer data. Need a clean, enforced boundary: lore=reads, glab=writes.

## Approach
1. Audit all config files for glab read patterns
2. Replace each with lore equivalent
3. Add explicit Read/Write Split section to AGENTS.md and CLAUDE.md

## Translation Table
| glab (remove)                      | lore (replace with)              |
|------------------------------------|----------------------------------|
| glab issue view N                  | lore -J issues N                 |
| glab issue list                    | lore -J issues -n 50             |
| glab issue list -l bug             | lore -J issues --label bug       |
| glab mr view N                     | lore -J mrs N                    |
| glab mr list                       | lore -J mrs                      |
| glab mr list -s opened             | lore -J mrs -s opened            |
| glab api '/projects/:id/issues'    | lore -J issues -p project        |

## Files to Audit

### Project-level
- /Users/tayloreernisse/projects/gitlore/AGENTS.md — primary project instructions

### Global Claude config
- ~/.claude/CLAUDE.md — global instructions (already has lore section, verify no glab reads)

### Skills directory
Scan all .md files under ~/.claude/skills/ for glab read patterns.
Likely candidates: any skill that references GitLab data retrieval.

### Rules directory
Scan all .md files under ~/.claude/rules/ for glab read patterns.

### Work-ghost templates
- ~/projects/work-ghost/tasks/*.md — task templates that reference glab reads

## Verification Commands
After all changes:
```bash
# Should return ZERO matches (no glab read commands remain)
rg 'glab issue view|glab issue list|glab mr view|glab mr list|glab api.*issues|glab api.*merge_requests' ~/.claude/ AGENTS.md --type md

# These should REMAIN (write operations stay with glab)
rg 'glab (issue|mr) (create|update|close|delete|approve|merge|note|rebase)' ~/.claude/ AGENTS.md --type md
```

## Read/Write Split Section to Add
Add to AGENTS.md and ~/.claude/CLAUDE.md:
```markdown
## Read/Write Split: lore vs glab

| Operation | Tool | Why |
|-----------|------|-----|
| List issues/MRs | lore | Richer: includes status, discussions, closing MRs |
| View issue/MR detail | lore | Pre-joined discussions, work-item status |
| Search across entities | lore | FTS5 + vector hybrid search |
| Expert/workload analysis | lore | who command — no glab equivalent |
| Timeline reconstruction | lore | Chronological narrative — no glab equivalent |
| Create/update/close | glab | Write operations |
| Approve/merge MR | glab | Write operations |
| CI/CD pipelines | glab | Not in lore scope |
```

## TDD Loop
This is a config-only task — no Rust code changes. Verification is via grep:

RED: Run verification commands above, expect matches (glab reads still present)
GREEN: Replace all glab read references with lore equivalents
VERIFY: Run verification commands, expect zero glab read matches

## Acceptance Criteria
- [ ] Zero glab read references in AGENTS.md
- [ ] Zero glab read references in ~/.claude/CLAUDE.md
- [ ] Zero glab read references in ~/.claude/skills/**/*.md
- [ ] Zero glab read references in ~/.claude/rules/**/*.md
- [ ] glab write references preserved (create, update, close, approve, merge, CI)
- [ ] Read/Write Split section added to AGENTS.md
- [ ] Read/Write Split section added to ~/.claude/CLAUDE.md
- [ ] Fresh agent session uses lore for reads without prompting (manual verification)

## Edge Cases
- Skills that use glab api for data NOT in lore (e.g., CI pipeline data, project settings) — these should remain
- glab MCP server references — evaluate case-by-case (keep for write operations)
- Shell aliases or env vars that invoke glab for reads — out of scope unless in config files
- Skills that use `glab issue list | jq` for ad-hoc queries — replace with `lore -J issues | jq`
- References to glab in documentation context (explaining what tools exist) vs operational context (telling agent to use glab) — only replace operational references

Agent skills rewritten: AGENTS.md and CLAUDE.md updated with read/write split mandating lore for reads, glab for writes


Epic: CP2 Gate E - CLI Complete

## Background
Gate E validates all CLI commands are functional and user-friendly. This is the final usability gate - even if all data is correct, users need good CLI UX to access it.

## Acceptance Criteria (Pass/Fail)

### List Command
- [ ] `gi list mrs` shows MR table with columns: iid, title, state, author, branches, updated
- [ ] `gi list mrs --state=opened` filters to only opened MRs
- [ ] `gi list mrs --state=merged` filters to only merged MRs
- [ ] `gi list mrs --state=closed` filters to only closed MRs
- [ ] `gi list mrs --state=locked` filters locally (not server-side filter)
- [ ] `gi list mrs --draft` shows only draft MRs
- [ ] `gi list mrs --no-draft` excludes draft MRs
- [ ] Draft MRs show `[DRAFT]` prefix in title column
- [ ] `gi list mrs --author=username` filters by author
- [ ] `gi list mrs --assignee=username` filters by assignee
- [ ] `gi list mrs --reviewer=username` filters by reviewer
- [ ] `gi list mrs --target-branch=main` filters by target branch
- [ ] `gi list mrs --source-branch=feature/x` filters by source branch
- [ ] `gi list mrs --label=bugfix` filters by label
- [ ] `gi list mrs --limit=N` limits output

### Show Command
- [ ] `gi show mr <iid>` displays full MR detail
- [ ] Show includes: title, description, state, draft status, author
- [ ] Show includes: assignees, reviewers, labels
- [ ] Show includes: source_branch, target_branch
- [ ] Show includes: detailed_merge_status (e.g., "mergeable")
- [ ] Show includes: merge_user and merged_at for merged MRs
- [ ] Show includes: discussions with author and date
- [ ] DiffNote shows file context: `[src/file.ts:45]`
- [ ] Multi-line DiffNote shows range: `[src/file.ts:45-48]`
- [ ] Resolved discussions show `[RESOLVED]` marker

### Count Command
- [ ] `gi count mrs` shows total count
- [ ] Count shows state breakdown: opened, merged, closed

### Sync Status
- [ ] `gi sync-status` shows MR cursor position
- [ ] Sync status shows last sync timestamp

## Validation Script
```bash
#!/bin/bash
set -e

DB_PATH="${XDG_DATA_HOME:-$HOME/.local/share}/gitlab-inbox/db.sqlite3"

echo "=== Gate E: CLI Complete ==="

# 1. Test list command (basic)
echo "Step 1: Basic list..."
gi list mrs --limit=5 || { echo "FAIL: list mrs failed"; exit 1; }

# 2. Test state filters
echo "Step 2: State filters..."
for state in opened merged closed; do
  echo "  Testing --state=$state"
  gi list mrs --state=$state --limit=3 || echo "  Warning: No $state MRs"
done

# 3. Test draft filters
echo "Step 3: Draft filters..."
gi list mrs --draft --limit=3 || echo "  Note: No draft MRs found"
gi list mrs --no-draft --limit=3 || echo "  Note: All MRs are drafts?"

# 4. Check [DRAFT] prefix
echo "Step 4: Check [DRAFT] prefix..."
DRAFT_IID=$(sqlite3 "$DB_PATH" "SELECT iid FROM merge_requests WHERE draft = 1 LIMIT 1;")
if [ -n "$DRAFT_IID" ]; then
  if gi list mrs --limit=100 | grep -q "\[DRAFT\]"; then
    echo "  PASS: [DRAFT] prefix found"
  else
    echo "  FAIL: Draft MR exists but no [DRAFT] prefix in output"
  fi
else
  echo "  Skip: No draft MRs to test"
fi

# 5. Test author/assignee/reviewer filters
echo "Step 5: User filters..."
AUTHOR=$(sqlite3 "$DB_PATH" "SELECT author_username FROM merge_requests LIMIT 1;")
if [ -n "$AUTHOR" ]; then
  echo "  Testing --author=$AUTHOR"
  gi list mrs --author="$AUTHOR" --limit=3
fi

REVIEWER=$(sqlite3 "$DB_PATH" "SELECT username FROM mr_reviewers LIMIT 1;")
if [ -n "$REVIEWER" ]; then
  echo "  Testing --reviewer=$REVIEWER"
  gi list mrs --reviewer="$REVIEWER" --limit=3
fi

# 6. Test branch filters
echo "Step 6: Branch filters..."
TARGET=$(sqlite3 "$DB_PATH" "SELECT target_branch FROM merge_requests LIMIT 1;")
if [ -n "$TARGET" ]; then
  echo "  Testing --target-branch=$TARGET"
  gi list mrs --target-branch="$TARGET" --limit=3
fi

# 7. Test show command
echo "Step 7: Show command..."
MR_IID=$(sqlite3 "$DB_PATH" "SELECT iid FROM merge_requests LIMIT 1;")
gi show mr "$MR_IID" || { echo "FAIL: show mr failed"; exit 1; }

# 8. Test show with DiffNote context
echo "Step 8: Show with DiffNote..."
DIFFNOTE_MR=$(sqlite3 "$DB_PATH" "
  SELECT DISTINCT m.iid
  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.position_new_path IS NOT NULL
  LIMIT 1;
")
if [ -n "$DIFFNOTE_MR" ]; then
  echo "  Testing MR with DiffNotes: !$DIFFNOTE_MR"
  OUTPUT=$(gi show mr "$DIFFNOTE_MR")
  if echo "$OUTPUT" | grep -qE '\[[^]]+:[0-9]+\]'; then
    echo "  PASS: File context [path:line] found"
  else
    echo "  FAIL: DiffNote should show [path:line] context"
  fi
else
  echo "  Skip: No MRs with DiffNotes"
fi

# 9. Test count command
echo "Step 9: Count command..."
gi count mrs || { echo "FAIL: count mrs failed"; exit 1; }

# 10. Test sync-status
echo "Step 10: Sync status..."
gi sync-status || echo "  Note: sync-status may need implementation"

echo ""
echo "=== Gate E: PASSED ==="
```

## Test Commands (Quick Verification)
```bash
# List with all column types visible:
gi list mrs --limit=10

# Show a specific MR:
gi show mr 42

# Count with breakdown:
gi count mrs

# Complex filter:
gi list mrs --state=opened --reviewer=alice --target-branch=main --limit=5
```

## Expected Output Formats

### gi list mrs
```
Merge Requests (showing 5 of 1,234)

  !847  Refactor auth to use JWT tokens           merged   @johndoe   main <- feature/jwt   3d ago
  !846  Fix memory leak in websocket handler      opened   @janedoe   main <- fix/websocket 5d ago
  !845  [DRAFT] Add dark mode CSS variables       opened   @bobsmith  main <- ui/dark-mode  1w ago
  !844  Update dependencies to latest versions    closed   @alice     main <- chore/deps    2w ago
```

### gi show mr 847
```
Merge Request !847: Refactor auth to use JWT tokens
================================================================================

Project:        group/project-one
State:          merged
Draft:          No
Author:         @johndoe
Assignees:      @janedoe, @bobsmith
Reviewers:      @alice, @charlie
Labels:         enhancement, auth, reviewed
Source:         feature/jwt
Target:         main
Merge Status:   merged
Merged By:      @alice
Merged At:      2024-03-20 14:30:00

Description:
  Moving away from session cookies to JWT-based authentication...

Discussions (3):

  @janedoe (2024-03-16) [src/auth/jwt.ts:45]:
    Should we use a separate signing key for refresh tokens?

    @johndoe (2024-03-16):
      Good point. I'll add a separate key with rotation support.

  @alice (2024-03-18) [RESOLVED]:
    Looks good! Just one nit about the token expiry constant.
```

### gi count mrs
```
Merge Requests: 1,234
  opened: 89
  merged: 1,045
  closed: 100
```

## Dependencies
This gate requires:
- bd-3js (CLI commands implementation)
- All previous gates must pass first

## Edge Cases
- Ambiguous MR iid across projects: should prompt for `--project` or show error
- Very long titles: should truncate with `...` in list view
- Empty description: should show "No description" or empty section
- No discussions: should show "No discussions" message
- Unicode in titles/descriptions: should render correctly


Add embedding error variants to LoreError

## Background
Gate B introduces Ollama-dependent operations that need distinct error variants for clear diagnostics. Each error has a unique exit code, a descriptive message, and an actionable suggestion. These errors must integrate with the existing LoreError enum pattern (renamed from GiError in bd-3lc).

## Approach
Extend `src/core/error.rs` with 4 new variants per PRD Section 4.3.

**ErrorCode additions:**
```rust
pub enum ErrorCode {
    // ... existing (InternalError=1 through TransformError=13)
    OllamaUnavailable,      // exit code 14
    OllamaModelNotFound,    // exit code 15
    EmbeddingFailed,        // exit code 16
}
```

**LoreError additions:**
```rust
/// Ollama-specific connection failure. Use instead of Http for Ollama errors
/// because it includes base_url for actionable error messages.
#[error("Cannot connect to Ollama at {base_url}. Is it running?")]
OllamaUnavailable {
    base_url: String,
    #[source]
    source: Option<reqwest::Error>,
},

#[error("Ollama model '{model}' not found. Run: ollama pull {model}")]
OllamaModelNotFound { model: String },

#[error("Embedding failed for document {document_id}: {reason}")]
EmbeddingFailed { document_id: i64, reason: String },

#[error("No embeddings found. Run: lore embed")]
EmbeddingsNotBuilt,
```

**code() mapping:**
- OllamaUnavailable => ErrorCode::OllamaUnavailable
- OllamaModelNotFound => ErrorCode::OllamaModelNotFound
- EmbeddingFailed => ErrorCode::EmbeddingFailed
- EmbeddingsNotBuilt => ErrorCode::EmbeddingFailed (shares exit code 16)

**suggestion() mapping:**
- OllamaUnavailable => "Start Ollama: ollama serve"
- OllamaModelNotFound => "Pull the model: ollama pull nomic-embed-text"
- EmbeddingFailed => "Check Ollama logs or retry with 'lore embed --retry-failed'"
- EmbeddingsNotBuilt => "Generate embeddings first: lore embed"

## Acceptance Criteria
- [ ] All 4 error variants compile
- [ ] Exit codes: OllamaUnavailable=14, OllamaModelNotFound=15, EmbeddingFailed=16
- [ ] EmbeddingsNotBuilt shares exit code 16 (mapped to ErrorCode::EmbeddingFailed)
- [ ] OllamaUnavailable has `base_url: String` and `source: Option<reqwest::Error>`
- [ ] EmbeddingFailed has `document_id: i64` and `reason: String`
- [ ] Each variant has actionable .suggestion() text per PRD
- [ ] ErrorCode Display: OLLAMA_UNAVAILABLE, OLLAMA_MODEL_NOT_FOUND, EMBEDDING_FAILED
- [ ] Robot mode JSON includes code + suggestion for each variant
- [ ] `cargo build` succeeds

## Files
- `src/core/error.rs` — extend LoreError enum + ErrorCode enum + impl blocks

## TDD Loop
RED: Add variants, `cargo build` fails on missing match arms
GREEN: Add match arms in code(), exit_code(), suggestion(), to_robot_error(), Display
VERIFY: `cargo build && cargo test error`

## Edge Cases
- OllamaUnavailable with source=None: still valid (used when no HTTP error available)
- EmbeddingFailed with document_id=0: used for batch-level failures (not per-doc)
- EmbeddingsNotBuilt vs OllamaUnavailable: former means "never ran embed", latter means "Ollama down right now"

Completed: Added 4 LoreError variants (OllamaUnavailable, OllamaModelNotFound, EmbeddingFailed, EmbeddingsNotBuilt) and 3 ErrorCode variants with exit codes 14-16. cargo build succeeds.


Epic: Gate B - Hybrid MVP

## Background
Gate B adds semantic search capabilities via Ollama embeddings and sqlite-vec vector storage. It builds on Gate A's document layer, adding the embedding pipeline, vector search, RRF-based hybrid ranking, and graceful degradation when Ollama is unavailable. Gate B is independently shippable on top of Gate A.

## Gate B Deliverables
1. Ollama-powered embedding pipeline with sqlite-vec storage
2. Hybrid search (RRF-ranked vector + lexical) with rich filtering + graceful degradation

## Bead Dependencies (execution order, after Gate A)
1. **bd-mem** — Shared backoff utility (no deps)
2. **bd-1y8** — Chunk ID encoding (no deps)
3. **bd-3ez** — RRF ranking (no deps)
4. **bd-ljf** — Embedding error variants (blocked by bd-3lc)
5. **bd-2ac** — Migration 009 embeddings (blocked by bd-hrs)
6. **bd-335** — Ollama API client (blocked by bd-ljf)
7. **bd-am7** — Embedding pipeline (blocked by bd-335, bd-2ac, bd-1y8)
8. **bd-bjo** — Vector search (blocked by bd-2ac, bd-1y8)
9. **bd-2sx** — Embed CLI (blocked by bd-am7)
10. **bd-3eu** — Hybrid search (blocked by bd-3ez, bd-bjo, bd-1k1, bd-3q2)

## Acceptance Criteria
- [ ] `lore embed` builds embeddings for all documents via Ollama
- [ ] `lore embed --retry-failed` re-attempts failed embeddings
- [ ] `lore search --mode=hybrid "query"` uses both FTS + vector
- [ ] `lore search --mode=semantic "query"` uses vector only
- [ ] Graceful degradation: Ollama down -> FTS fallback with warning
- [ ] `lore search --explain` shows vector_rank, fts_rank, rrf_score
- [ ] sqlite-vec loaded before migration 009

All Gate B sub-beads complete: backoff, chunk IDs, RRF, error variants, migration 009, Ollama client, embedding pipeline, vector search, embed CLI, hybrid search


WHO: Active mode query (query_active)

## Background

Active mode answers "What discussions are actively in progress?" by finding unresolved resolvable discussions with recent activity. This is the most complex query due to the CTE structure and the dual SQL variant requirement.

## Approach

### Two static SQL variants (CRITICAL — not nullable-OR):
Active mode uses separate global vs project-scoped SQL strings because:
- With (?N IS NULL OR d.project_id = ?N), SQLite can't commit to either index at prepare time
- Global queries need idx_discussions_unresolved_recent_global (single-column last_note_at)
- Scoped queries need idx_discussions_unresolved_recent (project_id, last_note_at)
- Selected at runtime: `match project_id { None => sql_global, Some(pid) => sql_scoped }`

### CTE structure (4 stages):
```sql
WITH picked AS (
    -- Stage 1: Select limited discussions using the right index
    SELECT d.id, d.noteable_type, d.issue_id, d.merge_request_id,
           d.project_id, d.last_note_at
    FROM discussions d
    WHERE d.resolvable = 1 AND d.resolved = 0
      AND d.last_note_at >= ?1
    ORDER BY d.last_note_at DESC LIMIT ?2
),
note_counts AS (
    -- Stage 2: Count all non-system notes per discussion (ACTUAL note count)
    SELECT n.discussion_id, COUNT(*) AS note_count
    FROM notes n JOIN picked p ON p.id = n.discussion_id
    WHERE n.is_system = 0
    GROUP BY n.discussion_id
),
participants AS (
    -- Stage 3: Distinct usernames per discussion, then GROUP_CONCAT
    SELECT x.discussion_id, GROUP_CONCAT(x.author_username, X'1F') AS participants
    FROM (
        SELECT DISTINCT n.discussion_id, n.author_username
        FROM notes n JOIN picked p ON p.id = n.discussion_id
        WHERE n.is_system = 0 AND n.author_username IS NOT NULL
    ) x
    GROUP BY x.discussion_id
)
-- Stage 4: Join everything
SELECT p.id, p.noteable_type, COALESCE(i.iid, m.iid), COALESCE(i.title, m.title),
       proj.path_with_namespace, p.last_note_at,
       COALESCE(nc.note_count, 0), COALESCE(pa.participants, '')
FROM picked p
JOIN projects proj ON p.project_id = proj.id
LEFT JOIN issues i ON p.issue_id = i.id
LEFT JOIN merge_requests m ON p.merge_request_id = m.id
LEFT JOIN note_counts nc ON nc.discussion_id = p.id
LEFT JOIN participants pa ON pa.discussion_id = p.id
ORDER BY p.last_note_at DESC
```

### CRITICAL BUG PREVENTION: note_counts and participants MUST be separate CTEs.
A single CTE with `SELECT DISTINCT discussion_id, author_username` then `COUNT(*)` produces a PARTICIPANT count, not a NOTE count. A discussion with 5 notes from 2 people would show note_count: 2 instead of 5.

### Participants post-processing in Rust:
```rust
let mut participants: Vec<String> = csv.split('\x1F').map(String::from).collect();
participants.sort();  // deterministic — GROUP_CONCAT order is undefined
const MAX_PARTICIPANTS: usize = 50;
let participants_total = participants.len() as u32;
let participants_truncated = participants.len() > MAX_PARTICIPANTS;
```

### Total count also uses two variants (global/scoped), same match pattern.

### Unit separator X'1F' for GROUP_CONCAT (not comma — usernames could theoretically contain commas)

## Files

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

## TDD Loop

RED:
```
test_active_query — insert discussion + 2 notes by same user; verify:
  - total_unresolved_in_window = 1
  - discussions.len() = 1
  - participants = ["reviewer_b"]
  - note_count = 2 (NOT 1 — this was a real regression in iteration 4)
  - discussion_id > 0
test_active_participants_sorted — insert notes by zebra_user then alpha_user; verify sorted ["alpha_user", "zebra_user"]
```

GREEN: Implement query_active with both SQL variants and the shared map_row closure
VERIFY: `cargo test -- active`

## Acceptance Criteria

- [ ] test_active_query passes with note_count = 2 (not participant count)
- [ ] test_active_participants_sorted passes (alphabetical order)
- [ ] discussion_id included in output (stable entity ID for agents)
- [ ] Default since window: 7d
- [ ] Bounded participants: cap 50, with total + truncated metadata

## Edge Cases

- note_count vs participant_count: MUST be separate CTEs (see bug prevention above)
- GROUP_CONCAT order is undefined — sort participants in Rust after parsing
- SQLite doesn't support GROUP_CONCAT(DISTINCT col, separator) — use subquery with SELECT DISTINCT then GROUP_CONCAT
- Two SQL variants: prepare exactly ONE statement per invocation (don't prepare both)
- entity_type mapping: "MergeRequest" -> "MR", else "Issue"

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


Implement shared backoff utility

## Background
Both `dirty_sources` and `pending_discussion_fetches` tables use exponential backoff with `next_attempt_at` timestamps. Without a shared utility, each module would duplicate the backoff curve logic, risking drift. The shared backoff module ensures consistent retry behavior across all queue consumers in Gate C.

## Approach
Create `src/core/backoff.rs` per PRD Section 6.X.

**IMPORTANT — PRD-exact signature and implementation:**
```rust
use rand::Rng;

/// Compute next_attempt_at with exponential backoff and jitter.
///
/// Formula: now + min(3600000, 1000 * 2^attempt_count) * (0.9 to 1.1)
/// - Capped at 1 hour to prevent runaway delays
/// - ±10% jitter prevents synchronized retries after outages
///
/// Used by:
/// - `dirty_sources` retry scheduling (document regeneration failures)
/// - `pending_discussion_fetches` retry scheduling (API fetch failures)
///
/// Having one implementation prevents subtle divergence between queues
/// (e.g., different caps or jitter ranges).
pub fn compute_next_attempt_at(now: i64, attempt_count: i64) -> i64 {
    // Cap attempt_count to prevent overflow (2^30 > 1 hour anyway)
    let capped_attempts = attempt_count.min(30) as u32;
    let base_delay_ms = 1000_i64.saturating_mul(1 << capped_attempts);
    let capped_delay_ms = base_delay_ms.min(3_600_000); // 1 hour cap

    // Add ±10% jitter
    let jitter_factor = rand::thread_rng().gen_range(0.9..=1.1);
    let delay_with_jitter = (capped_delay_ms as f64 * jitter_factor) as i64;

    now + delay_with_jitter
}
```

**Key PRD details (must match exactly):**
- `attempt_count` parameter is `i64` (not `u32`) — matches SQLite integer type from DB columns
- Overflow prevention: `.min(30) as u32` caps before shift
- Base delay: `1000_i64.saturating_mul(1 << capped_attempts)` — uses `saturating_mul` for safety
- Cap: `3_600_000` (1 hour)
- Jitter: `gen_range(0.9..=1.1)` — inclusive range
- Return: `i64` (milliseconds epoch)

**Cargo.toml change:** Add `rand = "0.8"` to `[dependencies]`.

## Acceptance Criteria
- [ ] Single shared implementation used by both dirty_tracker and discussion_queue
- [ ] Signature: `pub fn compute_next_attempt_at(now: i64, attempt_count: i64) -> i64`
- [ ] attempt_count is i64 (matches SQLite column type), not u32
- [ ] Overflow prevention: `.min(30) as u32` before shift
- [ ] Base delay uses `1000_i64.saturating_mul(1 << capped_attempts)`
- [ ] Cap at 1 hour (3,600,000 ms)
- [ ] Jitter: `gen_range(0.9..=1.1)` inclusive range
- [ ] Exponential curve: 1s, 2s, 4s, 8s, ... up to 1h cap
- [ ] `cargo test backoff` passes

## Files
- `src/core/backoff.rs` — new file
- `src/core/mod.rs` — add `pub mod backoff;`
- `Cargo.toml` — add `rand = "0.8"`

## TDD Loop
RED: `src/core/backoff.rs` with `#[cfg(test)] mod tests`:
- `test_exponential_curve` — verify delays double each attempt (within jitter range)
- `test_cap_at_one_hour` — attempt 20+ still produces delay <= MAX_DELAY_MS * 1.1
- `test_jitter_range` — run 100 iterations, all delays within [0.9x, 1.1x] of base
- `test_first_retry_is_about_one_second` — attempt 1 produces ~1000ms delay
- `test_overflow_safety` — very large attempt_count doesn't panic
GREEN: Implement compute_next_attempt_at()
VERIFY: `cargo test backoff`

## Edge Cases
- `attempt_count` > 30: `.min(30)` caps, saturating_mul prevents overflow
- `attempt_count` = 0: not used in practice (callers pass `attempt_count + 1`)
- `attempt_count` = 1: delay is ~1 second (first retry)
- Negative attempt_count: `.min(30)` still works, shift of negative-as-u32 wraps but saturating_mul handles it

Completed: compute_next_attempt_at with exp backoff (1s base, 1h cap, +-10% jitter), i64 params matching SQLite, overflow-safe, 5 tests pass


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"


Implement resize storm + rapid keypress + event fuzz tests

## Background
Stress tests verify the TUI handles adverse input conditions without panic: rapid terminal resizes, fast keypress sequences, and randomized event traces. The event fuzz suite uses deterministic seed replay for reproducibility.

## Approach
Resize storm:
- Send 100 resize events in rapid succession (varying sizes from 20x10 to 300x80)
- Assert no panic, no layout corruption, final render is valid for final size
- FrankenTUI's BOCPD resize coalescing should handle this — verify it works

Rapid keypress:
- Send 50 key events in <100ms: mix of navigation, filter input, mode switches
- Assert no panic, no stuck input mode, final state is consistent
- Verify Ctrl+C always exits regardless of state

Event fuzz (deterministic):
- Generate 10k randomized event traces from: key events, resize events, paste events, tick events
- Use seeded RNG for reproducibility
- Replay each trace, check invariants after each event:
  - Navigation stack depth >= 1 (always has at least Dashboard)
  - InputMode transitions are valid (no impossible state combinations)
  - No panic
  - LoadState transitions are valid (no Idle->Refreshing without LoadingInitial first for initial load)
- On invariant violation: log seed + event index for reproduction

## Acceptance Criteria
- [ ] 100 rapid resizes: no panic, valid final render
- [ ] 50 rapid keys: no stuck input mode, Ctrl+C exits
- [ ] 10k fuzz traces: zero invariant violations
- [ ] Fuzz tests deterministically reproducible via seed
- [ ] Navigation invariant: stack always has at least Dashboard
- [ ] InputMode invariant: valid transitions only

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

## TDD Anchor
RED: Write test_resize_storm_no_panic that sends 100 resize events to LoreApp, asserts no panic.
GREEN: Ensure view() handles all terminal sizes gracefully.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_resize_storm

## Edge Cases
- Zero-size terminal (0x0): must not panic, skip rendering
- Very large terminal (500x200): must not allocate unbounded memory
- Paste events can contain arbitrary bytes including control chars — sanitize
- Fuzz seed must be logged at test start for reproduction

## Dependency Context
Uses LoreApp from "Implement LoreApp Model" task.
Uses NavigationStack from "Implement NavigationStack" task.
Uses FakeClock for deterministic time in fuzz tests.


Epic: TUI Phase 3 — Power Features

## Background
Phase 3 adds the power-user screens: Search (3 modes with preview), Timeline (5-stage pipeline visualization), Who (5 expert/workload modes), and Command Palette (fuzzy match). These screens leverage the foundation from Phases 1-2.

## Acceptance Criteria
- [ ] Search supports lexical, hybrid, and semantic modes with split-pane preview
- [ ] Search capability detection enables/disables modes based on available indexes
- [ ] Timeline renders chronological event stream with color-coded event types
- [ ] Who supports Expert, Workload, Reviews, Active, and Overlap modes
- [ ] Command palette provides fuzzy-match access to all commands


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


Description

Another test


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)


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.