Implement discussion tree + cross-reference widgets

## Background
The discussion tree renders threaded conversations from GitLab issues/MRs using FrankenTUI's Tree widget. Cross-references show linked entities (closing MRs, related issues) as navigable links. Both are used in Issue Detail and MR Detail views.

## Approach
Discussion Tree (view/common/discussion_tree.rs):
- Wraps ftui Tree widget with TreePersistState for expand/collapse persistence
- Tree structure: top-level discussions as roots, notes within discussion as children
- Each node renders: author, timestamp (relative via Clock), note body (sanitized)
- System notes rendered with muted style
- Diff notes show file path + line reference
- Keyboard: j/k navigate, Enter expand/collapse, Space toggle thread
- Expand-on-demand: thread bodies loaded only when expanded (progressive hydration phase 3)

Cross-Reference (view/common/cross_ref.rs):
- CrossRefWidget: renders list of entity references with type icon and navigable links
- CrossRef struct: kind (ClosingMR, RelatedIssue, MentionedIn), entity_key (EntityKey), label (String)
- Enter on a cross-ref navigates to that entity (pushes nav stack)
- Renders as: "Closing MR !42: Fix authentication flow" with colored kind indicator

## Acceptance Criteria
- [ ] Discussion tree renders top-level discussions as expandable nodes
- [ ] Notes within discussion shown as children with indentation
- [ ] System notes visually distinguished (muted color)
- [ ] Diff notes show file path context
- [ ] Timestamps use injected Clock for deterministic rendering
- [ ] All note text sanitized via sanitize_for_terminal()
- [ ] Cross-references render with entity type icons
- [ ] Enter on cross-ref navigates to entity detail
- [ ] Tree state persists across navigation (expand/collapse remembered)

## Files
- CREATE: crates/lore-tui/src/view/common/discussion_tree.rs
- CREATE: crates/lore-tui/src/view/common/cross_ref.rs

## TDD Anchor
RED: Write test_cross_ref_entity_key that creates a CrossRef with EntityKey::mr(1, 42), asserts kind and key are correct.
GREEN: Implement CrossRef struct.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_cross_ref

## Edge Cases
- Deeply nested discussions (rare in GitLab but possible): limit indent depth to 4 levels
- Very long note bodies: wrap text within tree node area
- Empty discussions (resolved with no notes): show "[resolved]" indicator
- Cross-references to entities not in local DB: show as non-navigable text

## Dependency Context
Uses sanitize_for_terminal() from "Implement terminal safety module" task.
Uses Clock for timestamps from "Implement Clock trait" task.
Uses EntityKey, Screen from "Implement core types" task.
Uses NavigationStack from "Implement NavigationStack" task.


Epic: TUI Phase 4 — Operations

## Background
Phase 4 adds operational screens: Sync (real-time progress + post-sync summary), Doctor/Stats (health checks), and CLI integration (lore tui command for binary delegation). The Sync screen is the most complex — it needs real-time streaming progress with backpressure handling.

## Acceptance Criteria
- [ ] Sync screen shows real-time progress during sync with per-lane indicators
- [ ] Sync summary shows exact changed entities after completion
- [ ] Doctor screen shows environment health checks
- [ ] Stats screen shows database statistics
- [ ] CLI integration: lore tui launches lore-tui binary via runtime delegation


Wire resource event fetching into sync pipeline

## Background
After issue/MR primary ingestion and discussion fetch, changed entities need resource_events jobs enqueued and drained. This is the integration point that connects the queue (bd-tir), API client (bd-sqw), DB upserts (bd-1uc), and config flag (bd-2e8).

## Approach
Modify the sync pipeline to add two new phases after discussion sync:

**Phase 1 — Enqueue during ingestion:**
In src/ingestion/orchestrator.rs, after each entity upsert (issue or MR), call:
```rust
if config.sync.fetch_resource_events {
    enqueue_job(conn, project_id, "issue", iid, local_id, "resource_events", None)?;
}
// For MRs, also enqueue mr_closes_issues (always) and mr_diffs (when fetchMrFileChanges)
```

The "changed entity" detection uses the existing dirty tracker: if an entity was inserted or updated during this sync run, it gets enqueued. On --full sync, all entities are enqueued.

**Phase 2 — Drain dependent queue:**
Add a new drain step in src/cli/commands/sync.rs (or new src/core/drain.rs), called after discussion sync:
```rust
pub async fn drain_dependent_queue(
    conn: &Connection,
    client: &GitLabClient,
    config: &Config,
    progress: Option<ProgressCallback>,
) -> Result<DrainResult>
```

Flow:
1. reclaim_stale_locks(conn, config.sync.stale_lock_minutes)
2. Loop: claim_jobs(conn, "resource_events", batch_size=10)
3. For each job:
   a. Fetch 3 event types via client (fetch_issue_state_events etc.)
   b. Store via upsert functions (upsert_state_events etc.)
   c. complete_job(conn, job.id) on success
   d. fail_job(conn, job.id, error_msg) on failure
4. Report progress: "Fetching resource events... [N/M]"
5. Repeat until no more claimable jobs

**Progress reporting:**
Add new ProgressEvent variants:
```rust
ResourceEventsFetchStart { total: usize },
ResourceEventsFetchProgress { completed: usize, total: usize },
ResourceEventsFetchComplete { fetched: usize, failed: usize },
```

## Acceptance Criteria
- [ ] Full sync enqueues resource_events jobs for all issues and MRs
- [ ] Incremental sync only enqueues for entities changed since last sync
- [ ] --no-events prevents enqueueing resource_events jobs
- [ ] Drain step fetches all 3 event types per entity
- [ ] Successful fetches stored and job completed
- [ ] Failed fetches recorded with error, job retried on next sync
- [ ] Stale locks reclaimed at drain start
- [ ] Progress displayed: "Fetching resource events... [N/M]"
- [ ] Robot mode progress suppressed (quiet mode)

## Files
- src/ingestion/orchestrator.rs (add enqueue calls during upsert)
- src/cli/commands/sync.rs (add drain step after discussions)
- src/core/drain.rs (new, optional — or inline in sync.rs)

## TDD Loop
RED: tests/sync_pipeline_tests.rs (or extend existing):
- `test_sync_enqueues_resource_events_for_changed_entities` - mock sync, verify jobs enqueued
- `test_sync_no_events_flag_skips_enqueue` - verify no jobs when flag false
- `test_drain_completes_jobs_on_success` - mock API responses, verify jobs deleted
- `test_drain_fails_jobs_on_error` - mock API failure, verify job attempts incremented

Note: Full pipeline integration tests may need mock HTTP server. Start with unit tests on enqueue/drain logic using the real DB with mock API responses.

GREEN: Implement enqueue hooks + drain step

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

## Edge Cases
- Entity deleted between enqueue and drain: API returns 404, fail_job with "entity not found" (retry won't help but backoff caps it)
- Rate limiting during drain: GitLabRateLimited error should fail_job with retry (transient)
- Network error during drain: GitLabNetworkError should fail_job with retry
- Multiple sync runs competing: locked_at prevents double-processing; stale lock reclaim handles crashes
- Drain should have a max iterations guard to prevent infinite loop if jobs keep failing and being retried within the same run

Implemented: enqueue + drain resource events in orchestrator, wired counts through ingest→sync pipeline, added progress events, 4 new tests, all 209 tests pass


Extract query functions from CLI to shared pub API

## Background
The TUI's action.rs bridges to existing CLI query functions. To avoid code duplication, the existing query_* functions in cli/commands/*.rs need to be made pub so action.rs can call them. This is the minimal refactoring approach — no new domain query layer, just visibility changes.

## Approach
Modify existing CLI command files to extract and expose query functions:
- src/cli/commands/list.rs: make query_issues(), query_mrs() pub
- src/cli/commands/show.rs: make query_issue_detail(), query_mr_detail() pub
- src/cli/commands/who.rs: make query_experts(), query_workload(), query_reviews(), query_active(), query_overlap() pub
- src/cli/commands/search.rs: make run_search_query() pub

These functions should take Connection + parameters and return Result<T, LoreError>. Any CLI-specific formatting logic stays in the CLI; only the pure query logic is extracted.

If a function mixes query + format logic, split it:
1. query_X() -> Result<Vec<T>, LoreError> (pure query, made pub)
2. format_X(data: &[T]) -> String (CLI-only formatting, stays private)

## Acceptance Criteria
- [ ] query_issues() is pub and callable from outside cli module
- [ ] query_mrs() is pub and callable
- [ ] query_issue_detail() and query_mr_detail() are pub
- [ ] query_experts() and other who functions are pub
- [ ] run_search_query() is pub
- [ ] Existing CLI behavior unchanged (no functional changes)
- [ ] All extracted functions take Connection + params, return Result
- [ ] cargo test passes (no regressions)

## Files
- MODIFY: src/cli/commands/list.rs (make query functions pub)
- MODIFY: src/cli/commands/show.rs (make query functions pub)
- MODIFY: src/cli/commands/who.rs (make query functions pub)
- MODIFY: src/cli/commands/search.rs (make search query pub)

## TDD Anchor
RED: Write test in lore-tui action.rs that calls crate::cli::commands::list::query_issues() and asserts it compiles.
GREEN: Make query_issues pub.
VERIFY: cargo test --all-targets

## Edge Cases
- Some query functions may have Config dependencies — extract only the Connection-dependent parts
- Visibility changes may expose functions that weren't designed for external use — review signatures
- This is a non-breaking change (additive pub visibility)

## Dependency Context
This modifies the main lore crate (stable Rust).
Required by all TUI action.rs query bridge functions.
Must be completed before TUI can fetch real data.


[CP1] Integration tests for discussion watermark

Integration tests verifying discussion sync watermark behavior.

## Tests (tests/discussion_watermark_tests.rs)

- skips_discussion_fetch_when_updated_at_unchanged
- fetches_discussions_when_updated_at_advanced
- updates_watermark_after_successful_discussion_sync
- does_not_update_watermark_on_discussion_sync_failure

## Test Scenario
1. Ingest issue with updated_at = T1
2. Verify discussions_synced_for_updated_at = T1
3. Re-run ingest with same issue (updated_at = T1)
4. Verify NO discussion API calls made (watermark prevents)
5. Simulate issue update (updated_at = T2)
6. Re-run ingest
7. Verify discussion API calls made for T2
8. Verify watermark updated to T2

## Why This Matters
Discussion API is expensive (1 call per issue). Watermark ensures
we only refetch when issue actually changed, even with cursor rewind.

Files: tests/discussion_watermark_tests.rs
Done when: Watermark correctly prevents redundant discussion refetch


[CP0] gi auth-test command

## Background

auth-test is a quick diagnostic command to verify GitLab connectivity. Used for troubleshooting and CI pipelines. Simpler than doctor because it only checks auth, not full system health.

Reference: docs/prd/checkpoint-0.md section "gi auth-test"

## Approach

**src/cli/commands/auth-test.ts:**
```typescript
import { Command } from 'commander';
import { loadConfig } from '../../core/config';
import { GitLabClient } from '../../gitlab/client';
import { TokenNotSetError } from '../../core/errors';

export const authTestCommand = new Command('auth-test')
  .description('Verify GitLab authentication')
  .action(async (options, command) => {
    const globalOpts = command.optsWithGlobals();

    // 1. Load config
    const config = loadConfig(globalOpts.config);

    // 2. Get token from environment
    const token = process.env[config.gitlab.tokenEnvVar];
    if (!token) {
      throw new TokenNotSetError(config.gitlab.tokenEnvVar);
    }

    // 3. Create client and test auth
    const client = new GitLabClient({
      baseUrl: config.gitlab.baseUrl,
      token,
    });

    // 4. Get current user
    const user = await client.getCurrentUser();

    // 5. Output success
    console.log(`Authenticated as @${user.username} (${user.name})`);
    console.log(`GitLab: ${config.gitlab.baseUrl}`);
  });
```

**Output format:**
```
Authenticated as @johndoe (John Doe)
GitLab: https://gitlab.example.com
```

## Acceptance Criteria

- [ ] Loads config from default or --config path
- [ ] Gets token from configured env var (default GITLAB_TOKEN)
- [ ] Throws TokenNotSetError if env var not set
- [ ] Calls GET /api/v4/user to verify auth
- [ ] Prints username and display name on success
- [ ] Exit 0 on success
- [ ] Exit 1 on auth failure (GitLabAuthError)
- [ ] Exit 1 if config not found (ConfigNotFoundError)

## Files

CREATE:
- src/cli/commands/auth-test.ts

## TDD Loop

N/A - simple command, verify manually and with integration test in init.test.ts

```bash
# Manual verification
export GITLAB_TOKEN="valid-token"
gi auth-test

# With invalid token
export GITLAB_TOKEN="invalid"
gi auth-test  # should exit 1
```

## Edge Cases

- Config exists but token env var not set - clear error message
- Token exists but wrong scopes - GitLabAuthError (401)
- Network unreachable - GitLabNetworkError
- Token with extra whitespace - should trim


Implement status fetcher with adaptive paging and pagination guard

## Background
With the GraphQL client in place, we need a status-specific fetcher that paginates through all issues in a project, extracts status widgets via __typename matching, and handles edge cases like complexity errors and cursor stalls.

## Approach
All code goes in src/gitlab/graphql.rs alongside GraphqlClient. The fetcher uses the workItems(types:[ISSUE]) resolver (NOT project.issues which returns the old Issue type without status widgets). Widget matching uses __typename == "WorkItemWidgetStatus" for deterministic identification.

## Files
- src/gitlab/graphql.rs (add to existing file created by bd-2dlt)

## Implementation

Constants:
  ISSUE_STATUS_QUERY: GraphQL query string with $projectPath, $after, $first variables
  PAGE_SIZES: &[u32] = &[100, 50, 25, 10]

Private deserialization types:
  WorkItemsResponse { project: Option<ProjectNode> }
  ProjectNode { work_items: Option<WorkItemConnection> } (serde rename workItems)
  WorkItemConnection { nodes: Vec<WorkItemNode>, page_info: PageInfo } (serde rename pageInfo)
  WorkItemNode { iid: String, widgets: Vec<serde_json::Value> }
  PageInfo { end_cursor: Option<String>, has_next_page: bool } (serde renames)
  StatusWidget { status: Option<WorkItemStatus> }

Public types:
  UnsupportedReason enum: GraphqlEndpointMissing, AuthForbidden (Debug, Clone)
  FetchStatusResult struct:
    statuses: HashMap<i64, WorkItemStatus>
    all_fetched_iids: HashSet<i64>
    unsupported_reason: Option<UnsupportedReason>
    partial_error_count: usize
    first_partial_error: Option<String>

is_complexity_or_timeout_error(msg) -> bool: lowercase contains "complexity" or "timeout"

fetch_issue_statuses(client, project_path) -> Result<FetchStatusResult>:
  Pagination loop:
    1. Build variables with current page_size from PAGE_SIZES[page_size_idx]
    2. Call client.query() — match errors:
       - GitLabNotFound -> Ok(empty + GraphqlEndpointMissing) + warn
       - GitLabAuthFailed -> Ok(empty + AuthForbidden) + warn
       - Other with complexity/timeout msg -> reduce page_size_idx, continue (retry same cursor)
       - Other with smallest page size exhausted -> return Err
       - Other -> return Err
    3. Track partial errors from GraphqlQueryResult
    4. Parse response into WorkItemsResponse
    5. For each node: parse iid to i64, add to all_fetched_iids, check widgets for __typename == "WorkItemWidgetStatus", insert status into map
    6. Reset page_size_idx to 0 after successful page
    7. Pagination guard: if has_next_page but new cursor == old cursor or is None, warn + break
    8. Update cursor, continue loop

## Acceptance Criteria
- [ ] Paginates: 2-page mock returns all statuses + all IIDs
- [ ] No status widget: IID in all_fetched_iids but not in statuses
- [ ] Status widget with null status: IID in all_fetched_iids but not in statuses
- [ ] 404 -> Ok(empty, unsupported_reason: GraphqlEndpointMissing)
- [ ] 403 -> Ok(empty, unsupported_reason: AuthForbidden)
- [ ] Success -> unsupported_reason: None
- [ ] __typename != "WorkItemWidgetStatus" -> ignored, no error
- [ ] Cursor stall (same endCursor twice) -> aborts, returns partial result
- [ ] Complexity error at first=100 -> retries at 50, succeeds
- [ ] Timeout error -> reduces page size
- [ ] All page sizes fail -> returns Err
- [ ] After successful page, next page starts at first=100 again
- [ ] Partial-data pages -> partial_error_count incremented, first_partial_error captured

## TDD Loop
RED: test_fetch_statuses_pagination, test_fetch_statuses_no_status_widget, test_fetch_statuses_404_graceful, test_fetch_statuses_403_graceful, test_typename_matching_ignores_non_status_widgets, test_fetch_statuses_cursor_stall_aborts, test_fetch_statuses_complexity_error_reduces_page_size, test_fetch_statuses_timeout_error_reduces_page_size, test_fetch_statuses_smallest_page_still_fails, test_fetch_statuses_page_size_resets_after_success, test_fetch_statuses_unsupported_reason_none_on_success, test_fetch_statuses_partial_errors_tracked
  Adaptive tests: mock must inspect $first variable in request body to return different responses per page size
GREEN: Implement all types + fetch_issue_statuses function
VERIFY: cargo test fetch_statuses && cargo test typename

## Edge Cases
- GraphQL returns iid as String — parse to i64
- widgets is Vec<serde_json::Value> — match __typename field, then deserialize matching widgets
- let-chain syntax: if is_status_widget && let Ok(sw) = serde_json::from_value::<StatusWidget>(...)
- Pagination guard: new_cursor.is_none() || new_cursor == cursor
- Page size resets to 0 (index into PAGE_SIZES) after each successful page
- FetchStatusResult is NOT Clone — test fields individually

Implemented by agent swarm — all quality gates pass (595 tests, 0 failures)


Add rename awareness to path resolution probes

## Background
The path resolution layer (build_path_query at who.rs:467 and suffix_probe at who.rs:596) only checks position_new_path and new_path. If a user queries an old filename (e.g., 'login.rs' after rename to 'auth.rs'), the probes return 'not found' and scoring never runs — even though the scoring SQL (bd-1hoq) now matches old_path.

## Approach

### build_path_query() changes (who.rs:467):

Probe 1 (exact_exists):
- Notes query: add OR position_old_path = ?1
- File changes query: add OR old_path = ?1

Probe 2 (prefix_exists):
- Notes query: add OR position_old_path LIKE ?1 ESCAPE '\\'
- File changes query: add OR old_path LIKE ?1 ESCAPE '\\'

Note: These probes use simple OR (not UNION ALL) since they only check existence (SELECT 1 ... LIMIT 1) — no risk of planner degradation on single-row probes.

### suffix_probe() changes (who.rs:596):

Add two UNION branches to the existing query:
```sql
UNION
SELECT position_old_path AS full_path FROM notes
WHERE note_type = 'DiffNote' AND is_system = 0
  AND position_old_path IS NOT NULL
  AND (position_old_path LIKE ?1 ESCAPE '\\' OR position_old_path = ?2)
  AND (?3 IS NULL OR project_id = ?3)
UNION
SELECT old_path AS full_path FROM mr_file_changes
WHERE old_path IS NOT NULL
  AND (old_path LIKE ?1 ESCAPE '\\' OR old_path = ?2)
  AND (?3 IS NULL OR project_id = ?3)
```

Use UNION (not UNION ALL) — the existing query uses UNION for dedup.

## TDD Loop

### RED (write first):
```rust
#[test]
fn test_old_path_probe_exact_and_prefix() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "alice", "merged");
    insert_file_change_with_old_path(&conn, 1, 1, "src/new/foo.rs", Some("src/old/foo.rs"), "renamed");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_diffnote_at(&conn, 1, 1, 1, "alice", "src/new/foo.rs", Some("src/old/foo.rs"), "review comment", now_ms());

    // Exact probe by OLD path should resolve
    let pq = build_path_query(&conn, "src/old/foo.rs", None).unwrap();
    assert\!(matches\!(pq, PathQuery::Exact { .. } | PathQuery::Prefix { .. }));

    // Prefix probe by OLD directory should resolve
    let pq = build_path_query(&conn, "src/old/", None).unwrap();
    assert\!(matches\!(pq, PathQuery::Prefix { .. }));

    // New path still works
    let pq = build_path_query(&conn, "src/new/foo.rs", None).unwrap();
    assert\!(matches\!(pq, PathQuery::Exact { .. }));
}

#[test]
fn test_suffix_probe_uses_old_path_sources() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "alice", "merged");
    insert_file_change_with_old_path(&conn, 1, 1, "src/utils.rs", Some("legacy/utils.rs"), "renamed");

    let result = suffix_probe(&conn, "utils.rs", None).unwrap();
    match result {
        SuffixResult::Ambiguous(paths) => {
            assert\!(paths.contains(&"src/utils.rs".to_string()));
            assert\!(paths.contains(&"legacy/utils.rs".to_string()));
        }
        SuffixResult::Unique(p) => {
            assert\!(p == "src/utils.rs" || p == "legacy/utils.rs");
        }
        other => panic\!("Expected Ambiguous or Unique, got {other:?}"),
    }
}
```

### GREEN: Add OR old_path clauses to probes + UNION branches to suffix_probe.
### VERIFY: cargo test -p lore -- test_old_path_probe test_suffix_probe_uses_old_path

## Acceptance Criteria
- [ ] test_old_path_probe_exact_and_prefix passes
- [ ] test_suffix_probe_uses_old_path_sources passes
- [ ] Existing path probe tests still pass
- [ ] No changes to PathQuery or SuffixResult enums

## Files
- MODIFY: src/cli/commands/who.rs (build_path_query at line 467, suffix_probe at line 596)

## Edge Cases
- position_old_path can be NULL — OR clause handles naturally (NULL \!= ?1)
- Old path might match multiple new paths (copy+rename) — suffix_probe Ambiguous handles this
- Requires insert_file_change_with_old_path and insert_diffnote_at helpers from bd-2yu5


[CP1] Ingestion orchestrator

Coordinate issue + dependent discussion sync with bounded concurrency.

## Module
src/ingestion/orchestrator.rs

## Canonical Pattern (CP1)

When gi ingest --type=issues runs:

1. **Ingest issues** - cursor-based with incremental cursor updates per page
2. **Collect touched issues** - record IssueForDiscussionSync for each issue passing cursor filter
3. **Filter for discussion sync** - enqueue issues where:
   issue.updated_at > issues.discussions_synced_for_updated_at
4. **Execute discussion sync** - with bounded concurrency (dependent_concurrency from config)
5. **Update watermark** - after each issue's discussions successfully ingested

## Concurrency Notes

Runtime decision: Use single-threaded Tokio runtime (flavor = "current_thread")
- rusqlite::Connection is !Send, conflicts with multi-threaded runtimes
- Single-threaded avoids Send bounds entirely
- Use tokio::task::spawn_local + LocalSet for concurrent discussion fetches
- Keeps code simple; can upgrade to channel-based DB writer in CP2 if needed

## Configuration Used
- config.sync.dependent_concurrency - limits parallel discussion requests
- config.sync.cursor_rewind_seconds - safety margin for cursor

## Progress Reporting
- Show total issues fetched
- Show issues needing discussion sync
- Show discussion/note counts per project

Files: src/ingestion/orchestrator.rs
Tests: Integration tests with mocked GitLab
Done when: Full issue + discussion ingestion orchestrated correctly


Restructure expert SQL with CTE-based dual-path matching

## Background
The current query_expert() at who.rs:641 uses a 4-signal UNION ALL that only matches position_new_path and new_path, with flat COUNT-based scoring computed entirely in SQL. The new model needs dual-path matching, 5 signal types, state-aware timestamps, and returns per-signal rows for Rust-side decay computation (bd-13q8).

## Approach
**Important**: This bead builds the new SQL as a separate function WITHOUT modifying query_expert() yet. bd-13q8 wires it into query_expert(). This keeps this bead independently testable.

Add a new function:
```rust
/// Build the CTE-based expert scoring SQL for a given path query mode.
/// Returns SQL string. Params: ?1=path, ?2=since_ms, ?3=project_id, ?4=as_of_ms, ?5=closed_mr_multiplier, ?6=reviewer_min_note_chars
fn build_expert_sql(path_op: &str) -> String {
    // ... format the SQL with {path_op} inlined, all config values as bound params
}
```

### SQL structure (8 CTEs + final SELECT):
1. **matched_notes_raw**: UNION ALL on position_new_path + position_old_path
2. **matched_notes**: DISTINCT dedup by id
3. **matched_file_changes_raw**: UNION ALL on new_path + old_path
4. **matched_file_changes**: DISTINCT dedup by (merge_request_id, project_id)
5. **mr_activity**: Centralized state-aware timestamps AND state_mult. Joins merge_requests via matched_file_changes. Computes:
   - activity_ts: CASE WHEN state='merged' THEN COALESCE(merged_at, created_at) WHEN state='closed' THEN COALESCE(closed_at, created_at) ELSE COALESCE(updated_at, created_at) END
   - state_mult: CASE WHEN state='closed' THEN ?5 ELSE 1.0 END
6. **reviewer_participation**: substantive DiffNotes WHERE LENGTH(TRIM(body)) >= ?6
7. **raw**: 5 signals (diffnote_reviewer, diffnote_author, file_author, file_reviewer_participated, file_reviewer_assigned). Signals 1-2 compute state_mult inline. Signals 3-4a-4b reference mr_activity.
8. **aggregated**: MR-level GROUP BY + note_group with COUNT

### Returns 6 columns: (username TEXT, signal TEXT, mr_id INTEGER, qty INTEGER, ts INTEGER, state_mult REAL)

See plans/time-decay-expert-scoring.md section 3 for the full SQL template.

## TDD Loop

### RED (write first):
```rust
#[test]
fn test_expert_sql_returns_expected_signal_rows() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "alice", "merged");
    insert_file_change(&conn, 1, 1, "src/app.rs", "modified");
    insert_reviewer(&conn, 1, "bob");
    insert_reviewer(&conn, 1, "carol");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_diffnote(&conn, 1, 1, 1, "carol", "src/app.rs", "This needs error handling for the edge case");

    let sql = build_expert_sql("= ?1");
    let mut stmt = conn.prepare(&sql).unwrap();
    let rows: Vec<(String, String, i64, i64, i64, f64)> = stmt
        .query_map(
            rusqlite::params!["src/app.rs", 0_i64, Option::<i64>::None, now_ms() + 1000, 0.5_f64, 20_i64],
            |row| Ok((
                row.get(0).unwrap(), row.get(1).unwrap(), row.get(2).unwrap(),
                row.get(3).unwrap(), row.get(4).unwrap(), row.get(5).unwrap(),
            ))
        ).unwrap().filter_map(|r| r.ok()).collect();

    // alice: file_author
    assert!(rows.iter().any(|(u, s, ..)| u == "alice" && s == "file_author"));
    // carol: file_reviewer_participated (left substantive DiffNote)
    assert!(rows.iter().any(|(u, s, ..)| u == "carol" && s == "file_reviewer_participated"));
    // bob: file_reviewer_assigned (no DiffNotes)
    assert!(rows.iter().any(|(u, s, ..)| u == "bob" && s == "file_reviewer_assigned"));
    // carol: note_group
    assert!(rows.iter().any(|(u, s, ..)| u == "carol" && s == "note_group"));
    // alice: diffnote_author
    assert!(rows.iter().any(|(u, s, ..)| u == "alice" && s == "diffnote_author"));
    // All merged rows have state_mult = 1.0
    assert!(rows.iter().all(|(.., sm)| (sm - 1.0).abs() < f64::EPSILON));
}
```

### GREEN: Implement build_expert_sql() with the 8 CTEs.
### VERIFY: cargo test -p lore -- test_expert_sql_returns_expected_signal_rows

## Acceptance Criteria
- [ ] test_expert_sql_returns_expected_signal_rows passes (all 5 signal types correct)
- [ ] SQL compiles against :memory: DB with indexes from bd-2ao4 (migration 026)
- [ ] 6 columns returned: username, signal, mr_id, qty, ts, state_mult (REAL, not TEXT)
- [ ] 6 SQL params: ?1=path, ?2=since_ms, ?3=project_id, ?4=as_of_ms, ?5=closed_mr_multiplier, ?6=reviewer_min_note_chars
- [ ] mr_activity CTE centralizes timestamp + state_mult (not repeated)
- [ ] reviewer_participation uses ?6 not inlined literal
- [ ] Existing query_expert() and all existing tests UNTOUCHED
- [ ] build_expert_sql() is a pure function (no Connection param)

## Files
- MODIFY: src/cli/commands/who.rs (new build_expert_sql function + test, placed near query_expert at line ~641)

## Edge Cases
- ?5 (closed_mr_multiplier) bound as f64 — rusqlite handles this
- ?6 (reviewer_min_note_chars) bound as i64 — SQLite LENGTH returns integer
- Signals 1-2 compute state_mult inline (join through discussions, not mr_activity)
- COALESCE fallback to created_at for NULL merged_at/closed_at/updated_at
- Dedup in matched_notes/matched_file_changes prevents double-counting


Epic: Gate 5 - Code Trace (lore trace)

## Background

Gate 5 implements 'lore trace' — answers 'Why was this code introduced?' by tracing from a file path through the MR that modified it, to the issue that motivated the MR, to the discussions with decision rationale. Capstone of Phase B.

Gate 5 ships Tier 1 only (API-only, no local git). Tier 2 (git blame via git2-rs) deferred to Phase C.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Gate 5 (Sections 5.1-5.7).

## Prerequisites

- Gates 1-2 COMPLETE: entity_references populated, resource events fetched
- Gate 4 (bd-14q): provides mr_file_changes table + resolve_rename_chain algorithm
- entity_references source_method: 'api' | 'note_parse' | 'description_parse'
- discussions/notes tables for DiffNote content
- merge_requests.merged_at exists (migration 006). Use COALESCE(merged_at, updated_at) for ordering.

## Architecture

- **No new tables.** Trace queries combine mr_file_changes, entity_references, discussions/notes
- **Query flow:** file -> mr_file_changes -> MRs -> entity_references (closes/related) -> issues -> discussions with DiffNote context
- **Tier 1:** File-level granularity only. Cannot trace a specific line to its introducing commit.
- **Path parsing:** Supports 'src/foo.rs:45' syntax — line number parsed but deferred with Tier 2 warning.
- **Rename aware:** Reuses file_history::resolve_rename_chain for multi-path matching.

## Children (Execution Order)

1. **bd-2n4** — Trace query logic: file -> MR -> issue -> discussion chain (src/core/trace.rs)
2. **bd-9dd** — CLI command with human + robot output (src/cli/commands/trace.rs)

## Gate Completion Criteria

- [ ] `lore trace <file>` shows MRs with linked issues + discussion context
- [ ] Output includes MR -> issue -> discussion chain
- [ ] DiffNote snippets show content on the traced file
- [ ] Cross-references from entity_references used for MR->issue linking
- [ ] :line suffix parses and emits Tier 2 warning
- [ ] Robot mode JSON with tier: 'api_only'
- [ ] Graceful handling when no MR data found (suggest sync with fetchMrFileChanges)


Integrate mark_dirty_tx into ingestion modules

## Background
This bead integrates dirty source tracking into the existing ingestion pipelines. Every entity upserted during ingestion must be marked dirty so the document regenerator knows to update the corresponding search document. The critical constraint: mark_dirty_tx() must be called INSIDE the same transaction that upserts the entity — not after commit.

**Key PRD clarification:** Mark ALL upserted entities dirty (not just changed ones). The regenerator's hash comparison handles "unchanged" detection cheaply — this avoids needing change detection in ingestion.

## Approach
Modify 4 existing ingestion files to add mark_dirty_tx() calls inside existing transaction blocks per PRD Section 6.1.

**1. src/ingestion/issues.rs:**
Inside the issue upsert loop, after each successful INSERT/UPDATE:
```rust
dirty_tracker::mark_dirty_tx(&tx, SourceType::Issue, issue_row.id)?;
```

**2. src/ingestion/merge_requests.rs:**
Inside the MR upsert loop:
```rust
dirty_tracker::mark_dirty_tx(&tx, SourceType::MergeRequest, mr_row.id)?;
```

**3. src/ingestion/discussions.rs:**
Inside discussion insert (issue discussions, full-refresh transaction):
```rust
dirty_tracker::mark_dirty_tx(&tx, SourceType::Discussion, discussion_row.id)?;
```

**4. src/ingestion/mr_discussions.rs:**
Inside discussion upsert (write phase):
```rust
dirty_tracker::mark_dirty_tx(&tx, SourceType::Discussion, discussion_row.id)?;
```

**Discussion Sweep Cleanup (PRD Section 6.1 — CRITICAL):**
When the MR discussion sweep deletes stale discussions (`last_seen_at < run_start_time`), **delete the corresponding document rows directly** — do NOT use the dirty queue for cleanup. The `ON DELETE CASCADE` on `document_labels`/`document_paths` and the `documents_embeddings_ad` trigger handle all downstream cleanup.

**PRD-exact CTE pattern:**
```sql
-- In src/ingestion/mr_discussions.rs, during sweep phase.
-- Uses a CTE to capture stale IDs atomically before cascading deletes.
-- This is more defensive than two separate statements because the CTE
-- guarantees the ID set is captured before any row is deleted.
WITH stale AS (
  SELECT id FROM discussions
  WHERE merge_request_id = ? AND last_seen_at < ?
)
-- Step 1: delete orphaned documents (must happen while source_id still resolves)
DELETE FROM documents
  WHERE source_type = 'discussion' AND source_id IN (SELECT id FROM stale);
-- Step 2: delete the stale discussions themselves
DELETE FROM discussions
  WHERE id IN (SELECT id FROM stale);
```

**NOTE:** If SQLite version doesn't support CTE-based multi-statement, execute as two sequential statements capturing IDs in Rust first:
```rust
let stale_ids: Vec<i64> = conn.prepare(
    "SELECT id FROM discussions WHERE merge_request_id = ? AND last_seen_at < ?"
)?.query_map(params![mr_id, run_start], |r| r.get(0))?
  .collect::<Result<Vec<_>, _>>()?;

if !stale_ids.is_empty() {
    // Delete documents FIRST (while source_id still resolves)
    conn.execute(
        "DELETE FROM documents WHERE source_type = 'discussion' AND source_id IN (...)",
        ...
    )?;
    // Then delete the discussions
    conn.execute(
        "DELETE FROM discussions WHERE id IN (...)",
        ...
    )?;
}
```

**IMPORTANT difference from dirty queue pattern:** The sweep deletes documents DIRECTLY (not via dirty_sources queue). This is because the source entity is being deleted — there's nothing for the regenerator to regenerate from. The cascade handles FTS, labels, paths, and embeddings cleanup.

## Acceptance Criteria
- [ ] Every upserted issue is marked dirty inside the same transaction
- [ ] Every upserted MR is marked dirty inside the same transaction
- [ ] Every upserted discussion (issue + MR) is marked dirty inside the same transaction
- [ ] ALL upserted entities marked dirty (not just changed ones) — regenerator handles skip
- [ ] mark_dirty_tx called with &Transaction (not &Connection)
- [ ] mark_dirty_tx uses upsert with ON CONFLICT to reset backoff state (not INSERT OR IGNORE)
- [ ] Discussion sweep deletes documents DIRECTLY (not via dirty queue)
- [ ] Discussion sweep uses CTE (or Rust-side ID capture) to capture stale IDs before cascading deletes
- [ ] Documents deleted BEFORE discussions (while source_id still resolves)
- [ ] ON DELETE CASCADE handles document_labels, document_paths cleanup
- [ ] documents_embeddings_ad trigger handles embedding cleanup
- [ ] `cargo build` succeeds
- [ ] Existing ingestion tests still pass

## Files
- `src/ingestion/issues.rs` — add mark_dirty_tx calls in upsert loop
- `src/ingestion/merge_requests.rs` — add mark_dirty_tx calls in upsert loop
- `src/ingestion/discussions.rs` — add mark_dirty_tx calls in insert loop
- `src/ingestion/mr_discussions.rs` — add mark_dirty_tx calls + direct document deletion in sweep

## TDD Loop
RED: Existing tests should still pass (regression); new tests:
- `test_issue_upsert_marks_dirty` — after issue ingest, dirty_sources has entry
- `test_mr_upsert_marks_dirty` — after MR ingest, dirty_sources has entry
- `test_discussion_upsert_marks_dirty` — after discussion ingest, dirty_sources has entry
- `test_discussion_sweep_deletes_documents` — stale discussion documents deleted directly
- `test_sweep_cascade_cleans_labels_paths` — ON DELETE CASCADE works
GREEN: Add mark_dirty_tx calls in all 4 files, implement sweep with CTE
VERIFY: `cargo test ingestion && cargo build`

## Edge Cases
- Upsert that doesn't change data: still marks dirty (regenerator hash check handles skip)
- Transaction rollback: dirty mark also rolled back (atomic, inside same txn)
- Discussion sweep with zero stale IDs: CTE returns empty, no DELETE executed
- Large batch of upserts: each mark_dirty_tx is O(1) INSERT with ON CONFLICT
- Sweep deletes document before discussion: order matters for source_id resolution

Added mark_dirty_tx calls in issues.rs, merge_requests.rs, discussions.rs, mr_discussions.rs (2 paths)


Integration test: full Phase B sync pipeline

## Background

This integration test proves the full Phase B sync pipeline works end-to-end. Since Gates 1 and 2 are already implemented and closed, this test validates that the complete pipeline — including Gate 4 mr_diffs draining — works together.

## Codebase Context

- **Gates 1-2 FULLY IMPLEMENTED (CLOSED):** resource events fetch, closes_issues API, system note parsing (note_parser.rs), entity_references extraction (references.rs)
- **Gate 4 in progress:** migration 015 (mr_file_changes), fetch_mr_diffs, drain_mr_diffs — this test validates the full chain
- Migrations 001-014 exist. Migration 015 (bd-1oo) adds mr_file_changes + commit SHAs.
- Orchestrator has drain_resource_events() and drain_mr_closes_issues(). Gate 4 adds drain_mr_diffs().
- wiremock crate used in existing tests (check dev-dependencies in Cargo.toml)
- src/core/dependent_queue.rs: enqueue_job(), claim_jobs(), complete_job(), fail_job() with exponential backoff
- IngestProjectResult and IngestMrProjectResult track counts for all drain phases

## Approach

Create tests/phase_b_integration.rs:

### Test Setup

1. In-memory SQLite DB with all migrations (001-015)
2. wiremock mock server with:
   - /api/v4/projects/:id/issues — 2 test issues
   - /api/v4/projects/:id/merge_requests — 1 test MR
   - /api/v4/projects/:id/issues/:iid/resource_state_events — state events
   - /api/v4/projects/:id/issues/:iid/resource_label_events — label events
   - /api/v4/projects/:id/merge_requests/:iid/resource_state_events — merge event with source_merge_request_iid
   - /api/v4/projects/:id/merge_requests/:iid/closes_issues — linked issues
   - /api/v4/projects/:id/merge_requests/:iid/diffs — file changes
   - /api/v4/projects/:id/issues/:iid/discussions — discussion with system note "mentioned in !1"
3. Config with fetch_resource_events=true and fetch_mr_file_changes=true (bd-jec)
4. Use dependent_concurrency=1 to avoid timing issues

### Test Flow

```rust
#[tokio::test]
async fn test_full_phase_b_pipeline() {
    // 1. Set up mock server + DB with migrations 001-015
    // 2. Run ingest issues + MRs (orchestrator functions)
    // 3. Verify pending_dependent_fetches enqueued: resource_events, mr_closes_issues, mr_diffs
    // 4. Drain all dependent fetch queues
    // 5. Assert: resource_state_events populated (count > 0)
    // 6. Assert: resource_label_events populated (count > 0)
    // 7. Assert: entity_references has closes ref with source_method='api'
    // 8. Assert: entity_references has mentioned ref with source_method='note_parse'
    // 9. Assert: mr_file_changes populated from diffs API
    // 10. Assert: pending_dependent_fetches fully drained (no stuck locks)
}
```

### Assertions (SQL)

```sql
SELECT COUNT(*) FROM resource_state_events  -- > 0
SELECT COUNT(*) FROM resource_label_events  -- > 0
SELECT COUNT(*) FROM entity_references WHERE reference_type = 'closes' AND source_method = 'api'  -- >= 1
SELECT COUNT(*) FROM entity_references WHERE source_method = 'note_parse'  -- >= 1
SELECT COUNT(*) FROM mr_file_changes  -- > 0
SELECT COUNT(*) FROM pending_dependent_fetches WHERE locked_at IS NOT NULL  -- = 0
```

## Acceptance Criteria

- [ ] Test creates DB with migrations 001-015, mocks, and runs full pipeline
- [ ] resource_state_events and resource_label_events populated
- [ ] entity_references has closes ref (source_method='api') and mentioned ref (source_method='note_parse')
- [ ] mr_file_changes populated from diffs mock
- [ ] pending_dependent_fetches fully drained (no stuck locks, no retryable jobs)
- [ ] Test runs in < 10 seconds
- [ ] `cargo test --test phase_b_integration` passes

## Files

- tests/phase_b_integration.rs (NEW)

## TDD Loop

RED: Write test with all assertions — may fail if Gate 4 draining not yet wired.

GREEN: Fix pipeline wiring (drain_mr_diffs in orchestrator).

VERIFY: cargo test --test phase_b_integration -- --nocapture

## Edge Cases

- Paginated mock responses: include Link header for multi-page responses
- Empty pages: verify graceful handling
- Use dependent_concurrency=1 to avoid timing issues in test environment
- Stale lock reclaim: test that locks older than stale_lock_minutes are reclaimed


Verification: quality gates, query plan check, real-world validation

## Background

Post-implementation verification checkpoint. Runs after all code beads complete to validate the full scoring model works correctly against real data, not just test fixtures.

## Approach

Execute 8 verification steps in order. Each step has a binary pass/fail outcome.

### Step 1: Compiler check
```bash
cargo check --all-targets
```
Pass: exit 0

### Step 2: Clippy
```bash
cargo clippy --all-targets -- -D warnings
```
Pass: exit 0

### Step 3: Formatting
```bash
cargo fmt --check
```
Pass: exit 0

### Step 4: Test suite
```bash
cargo test -p lore
```
Pass: all tests green, including 31 new decay/scoring tests

### Step 5: UBS scan
```bash
ubs src/cli/commands/who.rs src/core/config.rs src/core/db.rs
```
Pass: exit 0

### Step 6: Query plan verification (manual)
Run against real database:
```bash
cargo run --release -- who --path MeasurementQualityDialog.tsx -vvv 2>&1 | grep -i "query plan"
```
Or use sqlite3 CLI with EXPLAIN QUERY PLAN on the expert SQL (both exact and prefix modes).

Pass criteria (6 checks):
- matched_notes_raw branch 1 uses existing new_path index
- matched_notes_raw branch 2 uses idx_notes_old_path_author
- matched_file_changes_raw uses idx_mfc_new_path_project_mr and idx_mfc_old_path_project_mr
- reviewer_participation uses idx_notes_diffnote_discussion_author
- mr_activity CTE joins merge_requests via primary key from matched_file_changes
- Path resolution probes (old_path leg) use idx_notes_old_path_project_created
Document observed plan as SQL comment near the CTE.

### Step 7: Performance baseline (manual)
```bash
time cargo run --release -- who --path MeasurementQualityDialog.tsx
time cargo run --release -- who --path src/
time cargo run --release -- who --path Dialog.tsx
```
Pass criteria (soft SLOs):
- Exact path: p95 < 200ms
- Prefix: p95 < 300ms
- Suffix: p95 < 500ms
Record timings as SQL comment for future regression reference.

### Step 8: Real-world validation
```bash
cargo run --release -- who --path MeasurementQualityDialog.tsx
cargo run --release -- who --path MeasurementQualityDialog.tsx --explain-score
cargo run --release -- who --path MeasurementQualityDialog.tsx --as-of 2025-06-01
cargo run --release -- who --path MeasurementQualityDialog.tsx --all-history
```
Pass criteria:
- [ ] Recency discounting visible (recent authors rank above old reviewers)
- [ ] --explain-score components sum to total (within f64 tolerance)
- [ ] --as-of produces identical results on repeated runs
- [ ] Assigned-only reviewers rank below participated reviewers on same MR
- [ ] Known renamed file path resolves and credits old expertise
- [ ] LGTM-only reviewers classified as assigned-only
- [ ] Closed MRs at ~50% contribution visible via --explain-score

## Acceptance Criteria
- [ ] Steps 1-5 pass (exit 0)
- [ ] Step 6: query plan documented with all 6 index usage points confirmed
- [ ] Step 7: timing baselines recorded
- [ ] Step 8: all 7 real-world checks pass

## Files
- All files modified by child beads (read-only verification)
- Add SQL comments near CTE with observed EXPLAIN QUERY PLAN output

## Edge Cases
- SQLite planner may choose different plans across versions — document version
- Timing varies by hardware — record machine specs alongside baselines
- Real DB may have NULL merged_at on old MRs — state-aware fallback handles this


Implement pending discussion queue

## Background
The pending discussion queue tracks discussions that need to be fetched from GitLab. When an issue or MR is updated, its discussions may need re-fetching. This queue is separate from dirty_sources (which tracks entities needing document regeneration) — it tracks entities needing API calls to GitLab. The queue uses the same backoff pattern as dirty_sources for consistency.

## Approach
Create `src/ingestion/discussion_queue.rs`:

```rust
use crate::core::backoff::compute_next_attempt_at;

/// Noteable type for discussion queue.
#[derive(Debug, Clone, Copy)]
pub enum NoteableType {
    Issue,
    MergeRequest,
}

impl NoteableType {
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Issue => "Issue",
            Self::MergeRequest => "MergeRequest",
        }
    }
}

pub struct PendingFetch {
    pub project_id: i64,
    pub noteable_type: NoteableType,
    pub noteable_iid: i64,
    pub attempt_count: i32,
}

/// Queue a discussion fetch. ON CONFLICT DO UPDATE resets backoff (consistent with dirty_sources).
pub fn queue_discussion_fetch(
    conn: &Connection,
    project_id: i64,
    noteable_type: NoteableType,
    noteable_iid: i64,
) -> Result<()>;

/// Get next batch of pending fetches (WHERE next_attempt_at IS NULL OR <= now).
pub fn get_pending_fetches(conn: &Connection, limit: usize) -> Result<Vec<PendingFetch>>;

/// Mark fetch complete (remove from queue).
pub fn complete_fetch(
    conn: &Connection,
    project_id: i64,
    noteable_type: NoteableType,
    noteable_iid: i64,
) -> Result<()>;

/// Record fetch error with backoff.
pub fn record_fetch_error(
    conn: &Connection,
    project_id: i64,
    noteable_type: NoteableType,
    noteable_iid: i64,
    error: &str,
) -> Result<()>;
```

## Acceptance Criteria
- [ ] queue_discussion_fetch uses ON CONFLICT DO UPDATE (consistent with dirty_sources pattern)
- [ ] Re-queuing resets: attempt_count=0, next_attempt_at=NULL, last_error=NULL
- [ ] get_pending_fetches respects next_attempt_at backoff
- [ ] get_pending_fetches returns entries ordered by queued_at ASC
- [ ] complete_fetch removes entry from queue
- [ ] record_fetch_error increments attempt_count, computes next_attempt_at via shared backoff
- [ ] NoteableType.as_str() returns "Issue" or "MergeRequest" (matches DB CHECK constraint)
- [ ] `cargo test discussion_queue` passes

## Files
- `src/ingestion/discussion_queue.rs` — new file
- `src/ingestion/mod.rs` — add `pub mod discussion_queue;`

## TDD Loop
RED: Tests in `#[cfg(test)] mod tests`:
- `test_queue_and_get` — queue entry, get returns it
- `test_requeue_resets_backoff` — queue, error, re-queue -> attempt_count=0
- `test_backoff_respected` — entry with future next_attempt_at not returned
- `test_complete_removes` — complete_fetch removes entry
- `test_error_increments_attempts` — error -> attempt_count=1, next_attempt_at set
GREEN: Implement all functions
VERIFY: `cargo test discussion_queue`

## Edge Cases
- Queue same (project_id, noteable_type, noteable_iid) twice: ON CONFLICT resets state
- NoteableType must match DB CHECK constraint exactly ("Issue", "MergeRequest" — capitalized)
- Empty queue: get_pending_fetches returns empty Vec

Implemented discussion_queue with queue/get/complete/record_error + 6 tests


Parse system notes for cross-reference patterns

## Background
System notes contain cross-reference patterns like 'mentioned in !{iid}', 'closed by !{iid}', etc. This is best-effort, English-only extraction that supplements the structured API data from bd-3ia and bd-8t4. Runs as a local post-processing step (no API calls).

## Approach
Create src/core/note_parser.rs:

```rust
use regex::Regex;
use lazy_static::lazy_static;

/// A parsed cross-reference from a system note.
pub struct ParsedCrossRef {
    pub reference_type: String,    // "mentioned" | "closes"
    pub target_entity_type: String, // "issue" | "merge_request"
    pub target_iid: i64,
    pub target_project_path: Option<String>,  // None = same project
}

lazy_static! {
    static ref MENTIONED_RE: Regex = Regex::new(
        r"mentioned in (?:(?P<project>[\w\-]+/[\w\-]+))?(?P<sigil>[#!])(?P<iid>\d+)"
    ).unwrap();
    static ref CLOSED_BY_RE: Regex = Regex::new(
        r"closed by (?:(?P<project>[\w\-]+/[\w\-]+))?(?P<sigil>[#!])(?P<iid>\d+)"
    ).unwrap();
}

/// Parse a system note body for cross-references.
pub fn parse_cross_refs(body: &str) -> Vec<ParsedCrossRef>

/// Extract cross-references from all system notes and insert into entity_references.
/// Queries notes WHERE is_system = 1, parses body text, resolves to entity_references.
pub fn extract_refs_from_system_notes(
    conn: &Connection,
    project_id: i64,
) -> Result<ExtractResult>

pub struct ExtractResult {
    pub inserted: usize,
    pub skipped_unresolvable: usize,
    pub parse_failures: usize,   // logged at debug level
}
```

Sigil mapping: `#` = issue, `!` = merge_request

Resolution logic:
1. If target_project_path is None (same project): look up entity by iid in local DB → set target_entity_id
2. If target_project_path is Some: check if project is synced locally
   - If yes: resolve to local entity id
   - If no: store as unresolved (target_entity_id=NULL, target_project_path=path, target_entity_iid=iid)

Insert with source_method='system_note_parse', INSERT OR IGNORE for dedup.

Call after drain_dependent_queue and extract_refs_from_state_events in the sync pipeline.

## Acceptance Criteria
- [ ] 'mentioned in !123' → mentioned ref, target=MR iid 123
- [ ] 'mentioned in #456' → mentioned ref, target=issue iid 456
- [ ] 'mentioned in group/project!789' → cross-project mentioned ref
- [ ] 'closed by !123' → closes ref
- [ ] Cross-project refs stored as unresolved when target project not synced
- [ ] source_method = 'system_note_parse'
- [ ] Parse failures logged at debug level (not errors)
- [ ] Idempotent (INSERT OR IGNORE)
- [ ] Only processes is_system=1 notes

## Files
- src/core/note_parser.rs (new)
- src/core/mod.rs (add `pub mod note_parser;`)
- src/cli/commands/sync.rs (call after other ref extraction steps)

## TDD Loop
RED: tests/note_parser_tests.rs:
- `test_parse_mentioned_in_mr` - "mentioned in !567" → ParsedCrossRef { mentioned, merge_request, 567 }
- `test_parse_mentioned_in_issue` - "mentioned in #234" → ParsedCrossRef { mentioned, issue, 234 }
- `test_parse_mentioned_cross_project` - "mentioned in group/repo!789" → with project path
- `test_parse_closed_by_mr` - "closed by !567" → ParsedCrossRef { closes, merge_request, 567 }
- `test_parse_multiple_refs` - note with two mentions → two refs
- `test_parse_no_refs` - "Updated the description" → empty vec
- `test_extract_refs_from_system_notes_integration` - seed DB with system notes, verify entity_references created

GREEN: Implement regex patterns and extraction logic

VERIFY: `cargo test note_parser -- --nocapture`

## Edge Cases
- Non-English GitLab instances: "ajouté l'étiquette ~bug" won't match — this is accepted limitation, logged at debug
- Multi-level group paths: "mentioned in top/sub/project#123" — regex needs to handle arbitrary depth ([\w\-]+(?:/[\w\-]+)+)
- Note body may contain markdown links that look like refs: "[#123](url)" — the regex should handle this correctly since the prefix "mentioned in" is required
- Same ref mentioned multiple times in same note — dedup via INSERT OR IGNORE
- Note may reference itself (e.g., system note on issue #123 says "mentioned in #123") — technically valid, store it

Completed: parse_cross_refs regex parser, extract_refs_from_system_notes DB function, wired into orchestrator. 17 tests passing.


Implement FTS5 search function and query sanitization

## Background
FTS5 search is the core lexical retrieval engine. It wraps SQLite's FTS5 with safe query parsing that prevents user input from causing SQL syntax errors, while preserving useful features like prefix search for type-ahead. The search function returns ranked results with BM25 scores and contextual snippets. This module is the Gate A search backbone and also provides fallback search when Ollama is unavailable in Gate B.

## Approach
Create `src/search/` module with `mod.rs` and `fts.rs` per PRD Section 3.1-3.2.

**src/search/mod.rs:**
```rust
mod fts;
mod filters;
// Later beads add: mod vector; mod hybrid; mod rrf;
pub use fts::{search_fts, to_fts_query, FtsResult, FtsQueryMode, generate_fallback_snippet, get_result_snippet};
```

**src/search/fts.rs — key functions:**

1. `to_fts_query(raw: &str, mode: FtsQueryMode) -> String`
   - Safe mode: wrap each token in quotes, escape internal quotes, preserve trailing * on alphanumeric tokens
   - Raw mode: pass through unchanged

2. `search_fts(conn: &Connection, query: &str, limit: usize, mode: FtsQueryMode) -> Result<Vec<FtsResult>>`
   - Uses `bm25(documents_fts)` for ranking
   - Uses `snippet(documents_fts, 1, '<mark>', '</mark>', '...', 64)` for context
   - Column index 1 = content_text (0=title)

3. `generate_fallback_snippet(content_text: &str, max_chars: usize) -> String`
   - For semantic-only results without FTS snippets
   - Uses `truncate_utf8()` for safe byte boundaries

4. `truncate_utf8(s: &str, max_bytes: usize) -> &str`
   - Walks backward from max_bytes to find nearest char boundary

5. `get_result_snippet(fts_snippet: Option<&str>, content_text: &str) -> String`
   - Prefers FTS snippet, falls back to truncated content

Update `src/lib.rs`: add `pub mod search;`

## Acceptance Criteria
- [ ] Porter stemming works: search "searching" matches document containing "search"
- [ ] Prefix search works: `auth*` matches "authentication"
- [ ] Empty query returns empty Vec (no error)
- [ ] Special characters don't cause FTS5 errors: `-`, `"`, `:`, `*`
- [ ] Query `"-DWITH_SSL"` returns results (dash not treated as NOT operator)
- [ ] Query `C++` returns results (special chars preserved in quotes)
- [ ] Safe mode preserves trailing `*` on alphanumeric tokens: `auth*` -> `"auth"*`
- [ ] Raw mode passes query unchanged
- [ ] BM25 scores returned (lower = better match)
- [ ] Snippets contain `<mark>` tags around matches
- [ ] `generate_fallback_snippet` truncates at word boundary, appends "..."
- [ ] `truncate_utf8` never panics on multi-byte codepoints
- [ ] `cargo test fts` passes

## Files
- `src/search/mod.rs` — new file (module root)
- `src/search/fts.rs` — new file (FTS5 search + query sanitization)
- `src/lib.rs` — add `pub mod search;`

## TDD Loop
RED: Tests in `fts.rs` `#[cfg(test)] mod tests`:
- `test_safe_query_basic` — "auth error" -> `"auth" "error"`
- `test_safe_query_prefix` — "auth*" -> `"auth"*`
- `test_safe_query_special_chars` — "C++" -> `"C++"`
- `test_safe_query_dash` — "-DWITH_SSL" -> `"-DWITH_SSL"`
- `test_safe_query_quotes` — `he said "hello"` -> escaped
- `test_raw_mode_passthrough` — raw query unchanged
- `test_empty_query` — returns empty vec
- `test_truncate_utf8_emoji` — truncate mid-emoji walks back
- `test_fallback_snippet_word_boundary` — truncates at space
GREEN: Implement to_fts_query, search_fts, helpers
VERIFY: `cargo test fts`

## Edge Cases
- Query with only whitespace: treated as empty, returns empty
- Query with only special characters: quoted, may return no results (not an error)
- Very long query (1000+ chars): works but may be slow (no explicit limit)
- FTS5 snippet returns empty string: fallback to truncated content_text
- Non-alphanumeric prefix: `C++*` — NOT treated as prefix (special chars present)

Completed: to_fts_query (safe/raw modes), search_fts with BM25+snippets, generate_fallback_snippet, get_result_snippet, truncate_utf8 reuse, 13 tests pass


OBSERV: Add get_log_dir() helper to paths module

## Background
A centralized helper for the log directory path ensures consistent XDG compliance and directory creation. The existing get_data_dir() (src/core/paths.rs:40-43) returns ~/.local/share/lore/. We add a sibling that appends /logs/.

## Approach
Add to src/core/paths.rs, after get_db_path() (around line 53):

```rust
/// Get the log directory path. Creates the directory if it doesn't exist.
pub fn get_log_dir(config_override: Option<&str>) -> PathBuf {
    let dir = if let Some(path) = config_override {
        PathBuf::from(path)
    } else {
        get_data_dir().join("logs")
    };
    std::fs::create_dir_all(&dir).ok();
    dir
}
```

The config_override comes from LoggingConfig.log_dir (bd-17n). When None, uses XDG default.

Existing pattern to follow (src/core/paths.rs:40-53):
- get_data_dir() -> PathBuf (returns ~/.local/share/lore/)
- get_db_path(config_override: Option<&str>) -> PathBuf

## Acceptance Criteria
- [ ] get_log_dir(None) returns ~/.local/share/lore/logs/
- [ ] get_log_dir(Some("/tmp/custom")) returns /tmp/custom
- [ ] Directory is created if it doesn't exist
- [ ] Function is pub and accessible from other modules
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/core/paths.rs (add get_log_dir function after line ~53)

## TDD Loop
RED: test_get_log_dir_default, test_get_log_dir_override (use tempdir)
GREEN: Add get_log_dir() function
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- create_dir_all failure (e.g., permissions): .ok() swallows error silently. This matches get_db_path() which also doesn't create dirs. Consider: should we propagate the error? The subscriber init will fail anyway if the dir doesn't exist, providing a clear error.
- Trailing slash: PathBuf handles this correctly

Added get_log_dir() helper mirroring get_db_path/get_backup_dir pattern


[CP0] Raw payload handling - compression and deduplication

## Background

Raw payload storage allows replaying API responses for debugging and audit. Compression reduces storage for large payloads. SHA-256 deduplication prevents storing identical payloads multiple times (important for frequently polled resources that haven't changed).

Reference: docs/prd/checkpoint-0.md section "Raw Payload Handling"

## Approach

**src/core/payloads.ts:**
```typescript
import { createHash } from 'node:crypto';
import { gzipSync, gunzipSync } from 'node:zlib';
import Database from 'better-sqlite3';
import { nowMs } from './time';

interface StorePayloadOptions {
  projectId: number | null;
  resourceType: string;  // 'project' | 'issue' | 'mr' | 'note' | 'discussion'
  gitlabId: string;      // TEXT because discussion IDs are strings
  payload: unknown;      // JSON-serializable object
  compress: boolean;     // from config.storage.compressRawPayloads
}

export function storePayload(db: Database.Database, options: StorePayloadOptions): number | null {
  // 1. JSON.stringify the payload
  // 2. SHA-256 hash the JSON bytes
  // 3. Check for duplicate by (project_id, resource_type, gitlab_id, payload_hash)
  // 4. If duplicate, return existing ID
  // 5. If compress=true, gzip the JSON bytes
  // 6. INSERT with content_encoding='gzip' or 'identity'
  // 7. Return lastInsertRowid
}

export function readPayload(db: Database.Database, id: number): unknown {
  // 1. SELECT content_encoding, payload FROM raw_payloads WHERE id = ?
  // 2. If gzip, decompress
  // 3. JSON.parse and return
}
```

## Acceptance Criteria

- [ ] storePayload() with compress=true stores gzip-encoded payload
- [ ] storePayload() with compress=false stores identity-encoded payload
- [ ] Duplicate payload (same hash) returns existing row ID, not new row
- [ ] readPayload() correctly decompresses gzip payloads
- [ ] readPayload() returns null for non-existent ID
- [ ] SHA-256 hash computed from pre-compression JSON bytes
- [ ] Large payloads (100KB+) compress to ~10-20% of original size

## Files

CREATE:
- src/core/payloads.ts
- tests/unit/payloads.test.ts

## TDD Loop

RED:
```typescript
// tests/unit/payloads.test.ts
describe('Payload Storage', () => {
  describe('storePayload', () => {
    it('stores uncompressed payload with identity encoding')
    it('stores compressed payload with gzip encoding')
    it('deduplicates identical payloads by hash')
    it('stores different payloads for same gitlab_id')
  })

  describe('readPayload', () => {
    it('reads uncompressed payload')
    it('reads and decompresses gzip payload')
    it('returns null for non-existent id')
  })
})
```

GREEN: Implement storePayload() and readPayload()

VERIFY: `npm run test -- tests/unit/payloads.test.ts`

## Edge Cases

- gitlabId is TEXT not INTEGER - discussion IDs are UUIDs
- Compression ratio varies - some JSON compresses better than others
- null projectId valid for global resources (like user profile)
- Hash collision extremely unlikely with SHA-256 but unique index enforces


Wire up hybrid search: FTS5 + vector + RRF ranking

## Problem
lore search hardcodes lexical-only mode. The full hybrid/vector/RRF backend is ALREADY IMPLEMENTED and tested -- it just needs to be called from the CLI.

## Current State (Verified 2026-02-12)

### Backend: COMPLETE
- `search_hybrid()` in src/search/hybrid.rs:47 — async fn, handles Lexical/Semantic/Hybrid modes with graceful degradation
- `search_vector()` in src/search/vector.rs:43 — sqlite-vec KNN with chunk deduplication and adaptive k multiplier
- `rank_rrf()` in src/search/rrf.rs:13 — reciprocal rank fusion with normalization (7 passing tests)
- `SearchMode::parse()` — parses hybrid, lexical/fts, semantic/vector
- `OllamaClient::embed_batch()` in src/embedding/ollama.rs:103 — batch embedding via Ollama /api/embed endpoint
- All exported from src/search/mod.rs:7-14

### CLI: BROKEN
- src/cli/commands/search.rs:61 `run_search()` is SYNCHRONOUS (not async)
- Line 76: `let actual_mode = "lexical";` — hardcoded
- Lines 77-82: warns if user requests vector/hybrid, falls back to lexical
- Line 161: calls `search_fts()` directly instead of `search_hybrid()`
- Line 172: calls `rank_rrf(&[], &fts_tuples)` — empty vector list, FTS-only ranking
- Lines 143-152: manually constructs `SearchFilters` (this code is reusable)
- Lines 187-223: hydrates + maps to `SearchResultDisplay` (this can be adapted)

### Entry Point
- src/main.rs:1731 `async fn handle_search()` — IS async, but calls `run_search()` synchronously at line 1758
- main.rs is 2579 lines total

## Actual Work Required

### Step 1: Make run_search async
Change `pub fn run_search(...)` to `pub async fn run_search(...)` in search.rs:61.
Update handle_search call site (main.rs:1758) to `.await`.

### Step 2: Create OllamaClient when mode != lexical
Pattern from src/cli/commands/embed.rs — reuse `OllamaConfig` from config:
```rust
let client = if actual_mode != SearchMode::Lexical {
    let ollama_cfg = &config.embedding;
    Some(OllamaClient::new(&ollama_cfg.ollama_url, &ollama_cfg.model))
} else {
    None
};
```

### Step 3: Replace manual FTS+filter+rank with search_hybrid call
Replace lines 161-172 (search_fts + rank_rrf) with:
```rust
let (hybrid_results, mut hybrid_warnings) = search_hybrid(
    &conn,
    client.as_ref(),
    query,
    actual_mode,
    &filters,
    fts_mode,
).await?;
warnings.append(&mut hybrid_warnings);
```

### Step 4: Map HybridResult to SearchResultDisplay
HybridResult (src/search/hybrid.rs:39-45) has these fields:
```rust
pub struct HybridResult {
    pub document_id: i64,
    pub score: f64,           // combined score
    pub vector_rank: Option<usize>,
    pub fts_rank: Option<usize>,
    pub rrf_score: f64,
}
```
NOTE: HybridResult has NO `snippet` field and NO `normalized_score` field. `score` is the combined score. The `snippet` must still be obtained from the FTS results or from `get_result_snippet()`.

SearchResultDisplay needs: document_id, source_type, title, url, author, etc. (from hydration).
Keep the existing hydrate_results() call (line 187) and rrf_map construction (lines 189-190), but adapt to use HybridResult instead of RrfResult:
```rust
// Map hybrid results for lookup
let hybrid_map: HashMap<i64, &HybridResult> =
    hybrid_results.iter().map(|r| (r.document_id, r)).collect();

// For each hydrated row:
let hr = hybrid_map.get(&row.document_id);
let explain_data = if explain {
    hr.map(|r| ExplainData {
        vector_rank: r.vector_rank,
        fts_rank: r.fts_rank,
        rrf_score: r.rrf_score,
    })
} else { None };
// score: hr.map(|r| r.score).unwrap_or(0.0)
```

For snippets: search_hybrid calls search_fts internally, but does NOT return snippets. You need to either:
(a) Call search_fts separately just for snippets, or
(b) Modify search_hybrid to also return a snippet_map — preferred if touching hybrid.rs is in scope.
Simpler approach: keep the existing `search_fts()` call for snippets, use hybrid for ranking. The FTS call is fast (<50ms) and avoids modifying the already-complete hybrid.rs.

### Step 5: Determine actual_mode from config + CLI flag
```rust
let actual_mode = SearchMode::parse(requested_mode).unwrap_or(SearchMode::Hybrid);
// search_hybrid handles graceful degradation internally
```

## Signatures for Reference

```rust
// src/search/hybrid.rs:47
pub async fn search_hybrid(
    conn: &Connection,
    client: Option<&OllamaClient>,
    query: &str,
    mode: SearchMode,
    filters: &SearchFilters,
    fts_mode: FtsQueryMode,
) -> Result<(Vec<HybridResult>, Vec<String>)>

// src/search/hybrid.rs:39
pub struct HybridResult {
    pub document_id: i64,
    pub score: f64,
    pub vector_rank: Option<usize>,
    pub fts_rank: Option<usize>,
    pub rrf_score: f64,
}

// src/search/mod.rs exports
pub use hybrid::{HybridResult, SearchMode, search_hybrid};
pub use rrf::{RrfResult, rank_rrf};
pub use vector::{VectorResult, search_vector};

// src/embedding/ollama.rs:103
pub async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>>
```

## TDD Loop
RED: Add test in src/search/hybrid.rs:
- test_hybrid_lexical_fallback_no_ollama: search_hybrid with mode=Hybrid, client=None returns FTS results + warning
- test_hybrid_mode_detection: verify default mode is Hybrid when embeddings exist

GREEN: Wire search.rs to call search_hybrid() as described above

VERIFY:
```bash
cargo test search:: && cargo clippy --all-targets -- -D warnings
cargo run --release -- -J search 'throw time' --mode hybrid --explain | jq '.data.mode'
# Should return "hybrid" (or "lexical" with warning if Ollama is down)
```

## Edge Cases
- Ollama running but model not found: clear error with suggestion to run `ollama pull nomic-embed-text`
- No embeddings in DB (never ran lore embed): search_vector returns empty, RRF uses FTS only — search_hybrid handles this gracefully
- Query embedding returns all zeros: should still return FTS results
- Very long query string (>1500 bytes): chunk or truncate before embedding (CHUNK_MAX_BYTES=1500)
- sqlite-vec table missing (old DB without migration 009): graceful error from search_vector
- OllamaConfig missing from config: check `config.embedding` exists before constructing client
- Snippet handling: HybridResult has no snippet field — must obtain snippets from a separate search_fts call or from get_result_snippet() with content_text fallback

## Files to Modify
- src/cli/commands/search.rs — make run_search async, replace manual FTS+RRF with search_hybrid call (~80 lines replaced with ~20)
- src/main.rs:1758 — add .await to run_search call (already in async context)

## Files NOT to Modify (already complete)
- src/search/hybrid.rs
- src/search/vector.rs
- src/search/rrf.rs
- src/embedding/ollama.rs


[CP0] GitLab API client with rate limiting

## Background

The GitLab client handles all API communication with rate limiting to avoid 429 errors. Uses native fetch (Node 18+). Rate limiter adds jitter to prevent thundering herd. All errors are typed for clean error handling in CLI commands.

Reference: docs/prd/checkpoint-0.md section "GitLab Client"

## Approach

**src/gitlab/client.ts:**
```typescript
export class GitLabClient {
  private baseUrl: string;
  private token: string;
  private rateLimiter: RateLimiter;

  constructor(options: { baseUrl: string; token: string; requestsPerSecond?: number }) {
    this.baseUrl = options.baseUrl.replace(/\/$/, '');
    this.token = options.token;
    this.rateLimiter = new RateLimiter(options.requestsPerSecond ?? 10);
  }

  async getCurrentUser(): Promise<GitLabUser>
  async getProject(pathWithNamespace: string): Promise<GitLabProject>
  private async request<T>(path: string, options?: RequestInit): Promise<T>
}

class RateLimiter {
  private lastRequest = 0;
  private minInterval: number;

  constructor(requestsPerSecond: number) {
    this.minInterval = 1000 / requestsPerSecond;
  }

  async acquire(): Promise<void> {
    // Wait if too soon since last request
    // Add 0-50ms jitter
  }
}
```

**src/gitlab/types.ts:**
```typescript
export interface GitLabUser {
  id: number;
  username: string;
  name: string;
}

export interface GitLabProject {
  id: number;
  path_with_namespace: string;
  default_branch: string;
  web_url: string;
  created_at: string;
  updated_at: string;
}
```

**Integration tests with MSW (Mock Service Worker):**
Set up MSW handlers that mock GitLab API responses for /api/v4/user and /api/v4/projects/:path

## Acceptance Criteria

- [ ] getCurrentUser() returns GitLabUser with id, username, name
- [ ] getProject("group/project") URL-encodes path correctly
- [ ] 401 response throws GitLabAuthError
- [ ] 404 response throws GitLabNotFoundError
- [ ] 429 response throws GitLabRateLimitError with retryAfter from header
- [ ] Network failure throws GitLabNetworkError
- [ ] Rate limiter enforces minimum interval between requests
- [ ] Rate limiter adds random jitter (0-50ms)
- [ ] tests/integration/gitlab-client.test.ts passes (6 tests)

## Files

CREATE:
- src/gitlab/client.ts
- src/gitlab/types.ts
- tests/integration/gitlab-client.test.ts
- tests/fixtures/mock-responses/gitlab-user.json
- tests/fixtures/mock-responses/gitlab-project.json

## TDD Loop

RED:
```typescript
// tests/integration/gitlab-client.test.ts
describe('GitLab Client', () => {
  it('authenticates with valid PAT')
  it('returns 401 for invalid PAT')
  it('fetches project by path')
  it('handles rate limiting (429) with Retry-After')
  it('respects rate limit (requests per second)')
  it('adds jitter to rate limiting')
})
```

GREEN: Implement client.ts and types.ts

VERIFY: `npm run test -- tests/integration/gitlab-client.test.ts`

## Edge Cases

- Path with special characters (spaces, slashes) must be URL-encoded
- Retry-After header may be missing - default to 60s
- Network timeout should be handled (use AbortController)
- Rate limiter jitter prevents multiple clients syncing in lockstep
- baseUrl trailing slash should be stripped


Extend 'lore stats --check' for event table integrity and queue health

## Background
The existing stats --check command validates data integrity. Need to extend it for event tables (referential integrity) and dependent job queue health (stuck locks, retryable jobs). This provides operators and agents a way to detect data quality issues after sync.

## Approach
Extend src/cli/commands/stats.rs check mode:

**New checks:**

1. Event FK integrity:
```sql
-- Orphaned state events (issue_id points to non-existent issue)
SELECT COUNT(*) FROM resource_state_events rse
WHERE rse.issue_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM issues i WHERE i.id = rse.issue_id);
-- (repeat for merge_request_id, and for label + milestone event tables)
```

2. Queue health:
```sql
-- Pending jobs by type
SELECT job_type, COUNT(*) FROM pending_dependent_fetches GROUP BY job_type;
-- Stuck locks (locked_at older than 5 minutes)
SELECT COUNT(*) FROM pending_dependent_fetches WHERE locked_at IS NOT NULL AND locked_at < ?;
-- Retryable jobs (attempts > 0, not locked)
SELECT COUNT(*) FROM pending_dependent_fetches WHERE attempts > 0 AND locked_at IS NULL;
-- Max attempts (jobs that may be permanently failing)
SELECT job_type, MAX(attempts) FROM pending_dependent_fetches GROUP BY job_type;
```

3. Human output per check: PASS / WARN / FAIL with counts
```
Event FK integrity:     PASS (0 orphaned events)
Queue health:           WARN (3 stuck locks, 12 retryable jobs)
```

4. Robot JSON: structured health report
```json
{
  "event_integrity": {
    "status": "pass",
    "orphaned_state_events": 0,
    "orphaned_label_events": 0,
    "orphaned_milestone_events": 0
  },
  "queue_health": {
    "status": "warn",
    "pending_by_type": {"resource_events": 5, "mr_closes_issues": 2},
    "stuck_locks": 3,
    "retryable_jobs": 12,
    "max_attempts_by_type": {"resource_events": 5}
  }
}
```

## Acceptance Criteria
- [ ] Detects orphaned events (FK target missing)
- [ ] Detects stuck locks (locked_at older than threshold)
- [ ] Reports retryable job count and max attempts
- [ ] Human output shows PASS/WARN/FAIL per check
- [ ] Robot JSON matches structured schema
- [ ] Graceful when event/queue tables don't exist

## Files
- src/cli/commands/stats.rs (extend check mode)

## TDD Loop
RED: tests/stats_check_tests.rs:
- `test_stats_check_events_pass` - clean data, verify PASS
- `test_stats_check_events_orphaned` - delete an issue with events remaining, verify FAIL count
- `test_stats_check_queue_stuck_locks` - set old locked_at, verify WARN
- `test_stats_check_queue_retryable` - fail some jobs, verify retryable count

GREEN: Add the check queries and formatting

VERIFY: `cargo test stats_check -- --nocapture`

## Edge Cases
- FK with CASCADE should prevent orphaned events in normal operation — but manual DB edits or bugs could cause them
- Tables may not exist if migration 011 not applied — check table existence before querying
- Empty queue is PASS (not WARN for "no jobs found")
- Distinguish between "0 stuck locks" (good) and "queue table doesn't exist" (skip check)

Extended IntegrityResult with orphan_state/label/milestone_events and queue_stuck_locks/queue_max_attempts. Added FK integrity queries for all 3 event tables and queue health checks. Updated human output with PASS/WARN/FAIL indicators and robot JSON.


[CP1] gi sync-status enhancement

Enhance sync-status from CP0 stub to show issue cursors.

Output:
- Last run timestamp and duration
- Cursor positions per project (issues resource_type)
- Entity counts (issues, discussions, notes)

Files: src/cli/commands/sync-status.ts (update existing)
Done when: Shows cursor positions and counts after ingestion


Vertical slice integration test + SLO verification

## Background
The vertical slice gate validates that core screens work together end-to-end with real data flows and meet performance SLOs. This is a manual + automated verification pass.

## Approach
Create integration tests in crates/lore-tui/tests/:
- test_full_nav_flow: Dashboard -> press i -> IssueList loads -> press Enter -> IssueDetail loads -> press Esc -> back to IssueList with cursor preserved -> press H -> Dashboard
- test_filter_requery: IssueList -> type filter -> verify re-query triggers and results update
- test_stale_result_guard: rapidly navigate between screens, verify no stale data displayed
- Performance benchmarks: run M-tier fixture, measure p95 nav latency, assert < 75ms
- Stuck-input check: fuzz InputMode transitions, assert always recoverable via Esc or Ctrl+C
- Cancel latency: start sync, cancel, measure time to acknowledgment, assert < 2s

## Acceptance Criteria
- [ ] Full nav flow test passes without panic
- [ ] Filter re-query test shows updated results
- [ ] No stale data displayed during rapid navigation
- [ ] p95 nav latency < 75ms on M-tier fixtures
- [ ] Zero stuck-input states across 1000 random key sequences
- [ ] Sync cancel acknowledged p95 < 2s
- [ ] All state preserved correctly on back-navigation

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

## TDD Anchor
RED: Write test_dashboard_to_issue_detail_roundtrip that navigates Dashboard -> IssueList -> IssueDetail -> Esc -> IssueList, asserts cursor position preserved.
GREEN: Ensure all navigation and state preservation is wired up.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml vertical_slice

## Edge Cases
- Tests need FakeClock and synthetic DB fixtures (not real GitLab)
- ftui test harness required for rendering tests without TTY
- Performance benchmarks may vary by machine — use relative thresholds

## Dependency Context
Requires all Phase 2 screens: Dashboard, Issue List, Issue Detail, MR List, MR Detail.
Requires NavigationStack, TaskSupervisor, DbManager from Phase 1.


[CP1] gi ingest --type=issues command

CLI command to orchestrate issue ingestion.

Implementation:
1. Acquire app lock with heartbeat
2. Create sync_run record (status='running')
3. For each configured project:
   - Call ingestIssues()
   - For each ingested issue, call ingestIssueDiscussions()
   - Show progress (spinner or progress bar)
4. Update sync_run (status='succeeded', metrics_json)
5. Release lock

Flags:
- --type=issues (required)
- --project=PATH (optional, filter to single project)
- --force (override stale lock)

Output: Progress bar, then summary with counts

Files: src/cli/commands/ingest.ts
Tests: tests/integration/sync-runs.test.ts
Done when: Full issue + discussion ingestion works end-to-end


lore brief: situational awareness for topic/module/person

## Background
Composable capstone command. An agent says "I am about to work on auth" and gets everything in one call: open issues, active MRs, experts, recent activity, unresolved threads, related context. Replaces 5 separate lore calls with 1.

## Input Modes
1. Topic: `lore brief 'authentication'` — FTS search to find relevant entities, aggregate
2. Path: `lore brief --path src/auth/` — who expert internals for path expertise
3. Person: `lore brief --person teernisse` — who workload internals
4. Entity: `lore brief issues 3864` — single entity focus with cross-references

## Section Assembly Architecture

### Reuse existing run_* functions (ship faster, recommended for v1)
Each section calls existing CLI command functions and converts their output.

IMPORTANT: All existing run_* functions take `&Config`, NOT `&Connection`. The Config contains the db_path and each function opens its own connection internally.

```rust
// In src/cli/commands/brief.rs

use crate::cli::commands::list::{run_list_issues, run_list_mrs, ListFilters, MrListFilters};
use crate::cli::commands::who::{run_who, WhoArgs, WhoMode};
use crate::core::config::Config;

pub async fn run_brief(config: &Config, args: BriefArgs) -> Result<BriefResponse> {
    let mut sections_computed = Vec::new();

    // 1. open_issues: reuse list.rs
    // Signature: pub fn run_list_issues(config: &Config, filters: ListFilters) -> Result<ListResult>
    // Located at src/cli/commands/list.rs:268
    let open_issues = run_list_issues(config, ListFilters {
        state: Some("opened".into()),
        limit: Some(5),
        project: args.project.clone(),
        // ... scope by topic/path/person based on mode
        ..Default::default()
    })?;
    sections_computed.push("open_issues");

    // 2. active_mrs: reuse list.rs
    // Signature: pub fn run_list_mrs(config: &Config, filters: MrListFilters) -> Result<MrListResult>
    // Located at src/cli/commands/list.rs:476
    let active_mrs = run_list_mrs(config, MrListFilters {
        state: Some("opened".into()),
        limit: Some(5),
        project: args.project.clone(),
        ..Default::default()
    })?;
    sections_computed.push("active_mrs");

    // 3. experts: reuse who.rs
    // Signature: pub fn run_who(config: &Config, args: &WhoArgs) -> Result<WhoRun>
    // Located at src/cli/commands/who.rs:276
    let experts = run_who(config, &WhoArgs {
        mode: WhoMode::Expert,
        path: args.path.clone(),
        limit: Some(3),
        ..Default::default()
    })?;
    sections_computed.push("experts");

    // 4. recent_activity: reuse timeline internals
    // The timeline pipeline is 5-stage (SEED->HYDRATE->EXPAND->COLLECT->RENDER)
    // Types in src/core/timeline.rs, seed in src/core/timeline_seed.rs
    // ...etc
}
```

NOTE: ListFilters and MrListFilters may not implement Default. Check before using `..Default::default()`. If they don't, derive it or construct all fields explicitly.

### Concrete Function References (src/cli/commands/)
| Module | Function | Signature | Line |
|--------|----------|-----------|------|
| list.rs | run_list_issues | `(config: &Config, filters: ListFilters) -> Result<ListResult>` | 268 |
| list.rs | run_list_mrs | `(config: &Config, filters: MrListFilters) -> Result<MrListResult>` | 476 |
| who.rs | run_who | `(config: &Config, args: &WhoArgs) -> Result<WhoRun>` | 276 |
| search.rs | run_search | `(config: &Config, query: &str, cli_filters: SearchCliFilters, fts_mode: FtsQueryMode, requested_mode: &str, explain: bool) -> Result<SearchResponse>` | 61 |

NOTE: run_search is currently synchronous (pub fn, not pub async fn). If bd-1ksf ships first, it becomes async. Brief should handle both cases — call `.await` if async, direct call if sync.

### Section Details
| Section | Source | Limit | Fallback |
|---------|--------|-------|----------|
| open_issues | list.rs with state=opened | 5 | empty array |
| active_mrs | list.rs with state=opened | 5 | empty array |
| experts | who.rs Expert mode | 3 | empty array (no path data) |
| recent_activity | timeline pipeline | 10 events | empty array |
| unresolved_threads | SQL: discussions WHERE resolved=false | 5 | empty array |
| related | search_vector() via bd-8con | 5 | omit section (no embeddings) |
| warnings | computed from dates/state | all | empty array |

### Warning Generation
```rust
fn compute_warnings(issues: &[IssueRow]) -> Vec<String> {
    let now = chrono::Utc::now();
    issues.iter().filter_map(|i| {
        let updated = parse_timestamp(i.updated_at)?;
        let days_stale = (now - updated).num_days();
        if days_stale > 30 {
            Some(format!("Issue #{} has no activity for {} days", i.iid, days_stale))
        } else { None }
    }).chain(
        issues.iter().filter(|i| i.assignees.is_empty())
            .map(|i| format!("Issue #{} is unassigned", i.iid))
    ).collect()
}
```

## Robot Mode Output Schema
```json
{
  "ok": true,
  "data": {
    "mode": "topic",
    "query": "authentication",
    "summary": "3 open issues, 2 active MRs, top expert: teernisse",
    "open_issues": [{ "iid": 123, "title": "...", "state": "opened", "assignees": [...], "updated_at": "...", "labels": [...] }],
    "active_mrs": [{ "iid": 456, "title": "...", "state": "opened", "author": "...", "draft": false, "updated_at": "..." }],
    "experts": [{ "username": "teernisse", "score": 42, "last_activity": "..." }],
    "recent_activity": [{ "timestamp": "...", "event_type": "state_change", "entity_ref": "issues#123", "summary": "...", "actor": "..." }],
    "unresolved_threads": [{ "discussion_id": "abc", "entity_ref": "issues#123", "started_by": "...", "note_count": 5, "last_note_at": "..." }],
    "related": [{ "iid": 789, "title": "...", "similarity_score": 0.85 }],
    "warnings": ["Issue #3800 has no activity for 45 days"]
  },
  "meta": { "elapsed_ms": 1200, "sections_computed": ["open_issues", "active_mrs", "experts", "recent_activity"] }
}
```

## Clap Registration
```rust
// In src/main.rs Commands enum, add:
Brief {
    /// Free-text topic, entity type, or omit for project-wide brief
    query: Option<String>,
    /// Focus on a file path (who expert mode)
    #[arg(long)]
    path: Option<String>,
    /// Focus on a person (who workload mode)
    #[arg(long)]
    person: Option<String>,
    /// Scope to project (fuzzy match)
    #[arg(short, long)]
    project: Option<String>,
    /// Maximum items per section
    #[arg(long, default_value = "5")]
    section_limit: usize,
},
```

## TDD Loop
RED: Tests in src/cli/commands/brief.rs:
- test_brief_topic_returns_all_sections: insert test data, search 'auth', assert all section keys present in response
- test_brief_path_uses_who_expert: brief --path src/auth/, assert experts section populated
- test_brief_person_uses_who_workload: brief --person user, assert open_issues filtered to user's assignments
- test_brief_warnings_stale_issue: insert issue with updated_at > 30 days ago, assert warning generated
- test_brief_token_budget: robot mode output for topic query is under 12000 bytes (~3000 tokens)
- test_brief_no_embeddings_graceful: related section omitted (not errored) when no embeddings exist
- test_brief_empty_topic: zero matches returns valid JSON with empty arrays + "No data found" summary

GREEN: Implement brief with section assembly, calling existing run_* functions

VERIFY:
```bash
cargo test brief:: && cargo clippy --all-targets -- -D warnings
cargo run --release -- -J brief 'throw time' | jq '.data | keys'
cargo run --release -- -J brief 'throw time' | wc -c  # target <12000
```

## Acceptance Criteria
- [ ] lore brief TOPIC returns all sections for free-text topic
- [ ] lore brief --path PATH returns path-focused briefing with experts
- [ ] lore brief --person USERNAME returns person-focused briefing
- [ ] lore brief issues N returns entity-focused briefing
- [ ] Robot mode output under 12000 bytes (~3000 tokens)
- [ ] Each section degrades gracefully if its data source is unavailable
- [ ] summary field is auto-generated one-liner from section counts
- [ ] warnings detect: stale issues (>30d), unassigned, no due date
- [ ] Performance: <2s total (acceptable since composing multiple queries)
- [ ] Command registered in main.rs and robot-docs

## Edge Cases
- Topic with zero matches: return empty sections + "No data found for this topic" summary
- Path that nobody has touched: experts empty, related may still have results
- Person not found in DB: exit code 17 with suggestion
- All sections empty: still return valid JSON with empty arrays
- Very broad topic ("the"): may return too many results — each section respects its limit cap
- ListFilters/MrListFilters may not derive Default — construct all fields explicitly if needed

## Dependencies
- Hybrid search (bd-1ksf) for topic relevance ranking
- lore who (already shipped) for expertise
- lore related (bd-8con) for semantic connections (BLOCKER — related section is core to the feature)
- Timeline pipeline (already shipped) for recent activity

## Dependency Context
- **bd-1ksf (hybrid search)**: Provides `search_hybrid()` which brief uses for topic mode to find relevant entities. Without it, topic mode falls back to FTS-only via `search_fts()`.
- **bd-8con (related)**: Provides `run_related()` which brief calls to populate the `related` section with semantically similar entities. This is a blocking dependency — the related section is a core differentiator.

## Files to Create/Modify
- NEW: src/cli/commands/brief.rs
- src/cli/commands/mod.rs (add pub mod brief; re-export)
- src/main.rs (register Brief subcommand in Commands enum, add handle_brief fn)
- Reuse: list.rs, who.rs, timeline.rs, search.rs, show.rs internals


Register 'lore timeline' command with all flags

## Background

This bead wires the `lore timeline` command into the CLI — adding the subcommand to the Commands enum, defining all flags, registering in VALID_COMMANDS, and dispatching to the timeline handler. The actual query logic and rendering are in separate beads.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.1 (Command Design).

## Codebase Context

- Commands enum in `src/cli/mod.rs` (line ~86): uses #[derive(Subcommand)] with nested Args structs
- VALID_COMMANDS in `src/main.rs` (line ~448): &[&str] array for fuzzy command matching
- Handler dispatch in `src/main.rs` match on Commands:: variants
- robot-docs manifest in `src/main.rs`: registers commands for `lore robot-docs` output
- Existing pattern: `Sync(SyncArgs)`, `Search(SearchArgs)`, etc.
- No timeline module exists yet — this bead creates the CLI entry point only

## Approach

### 1. TimelineArgs struct (`src/cli/mod.rs`):

```rust
/// Show a chronological timeline of events matching a query
#[derive(Parser, Debug)]
pub struct TimelineArgs {
    /// Search query (keywords to find in issues, MRs, and discussions)
    pub query: String,

    /// Scope to a specific project (fuzzy match)
    #[arg(short = 'p', long)]
    pub project: Option<String>,

    /// Only show events after this date (e.g. "6m", "2w", "2024-01-01")
    #[arg(long)]
    pub since: Option<String>,

    /// Cross-reference expansion depth (0 = no expansion)
    #[arg(long, default_value = "1")]
    pub depth: usize,

    /// Also follow 'mentioned' edges during expansion (high fan-out)
    #[arg(long = "expand-mentions")]
    pub expand_mentions: bool,

    /// Maximum number of events to display
    #[arg(short = 'n', long = "limit", default_value = "100")]
    pub limit: usize,
}
```

### 2. Commands enum variant:

```rust
/// Show a chronological timeline of events matching a query
#[command(name = "timeline")]
Timeline(TimelineArgs),
```

### 3. Handler in `src/main.rs`:

```rust
Commands::Timeline(args) => {
    // Placeholder: will be filled by bd-2f2 (human) and bd-dty (robot)
    // For now: resolve project, call timeline query, dispatch to renderer
}
```

### 4. VALID_COMMANDS: add `"timeline"` to the array

### 5. robot-docs: add timeline command description to manifest

## Acceptance Criteria

- [ ] `TimelineArgs` struct with all 6 flags: query, project, since, depth, expand-mentions, limit
- [ ] Commands::Timeline variant registered in Commands enum
- [ ] Handler stub in src/main.rs dispatches to timeline logic
- [ ] `"timeline"` added to VALID_COMMANDS array
- [ ] robot-docs manifest includes timeline command description
- [ ] `lore timeline --help` shows correct help text
- [ ] `lore timeline` without query shows error (query is required positional)
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

- `src/cli/mod.rs` (TimelineArgs struct + Commands::Timeline variant)
- `src/main.rs` (handler dispatch + VALID_COMMANDS + robot-docs entry)

## TDD Loop

No unit tests for CLI wiring. Verify with:

```bash
cargo check --all-targets
cargo run -- timeline --help
```

## Edge Cases

- --since parsing: reuse existing date parsing from ListFilters (src/cli/mod.rs handles "7d", "2w", "YYYY-MM-DD")
- --depth 0: valid, means no cross-reference expansion
- --expand-mentions: off by default because mentioned edges have high fan-out

Wired lore timeline command: TimelineArgs with 9 flags, Commands::Timeline variant, handle_timeline handler, VALID_COMMANDS entry, robot-docs manifest with temporal_intelligence workflow


[CP1] GitLab types for issues, discussions, notes

## Background

GitLab types define the Rust structs for deserializing GitLab API responses. These types are the foundation for all ingestion work - issues, discussions, and notes must be correctly typed for serde to parse them.

## Approach

Add types to `src/gitlab/types.rs` with serde derives:

### GitLabIssue

```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabIssue {
    pub id: i64,                      // GitLab global ID
    pub iid: i64,                     // Project-scoped issue number
    pub project_id: i64,
    pub title: String,
    pub description: Option<String>,
    pub state: String,                // "opened" | "closed"
    pub created_at: String,           // ISO 8601
    pub updated_at: String,           // ISO 8601
    pub closed_at: Option<String>,
    pub author: GitLabAuthor,
    pub labels: Vec<String>,          // Array of label names (CP1 canonical)
    pub web_url: String,
}
```

NOTE: `labels_details` intentionally NOT modeled - varies across GitLab versions.

### GitLabAuthor

```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabAuthor {
    pub id: i64,
    pub username: String,
    pub name: String,
}
```

### GitLabDiscussion

```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabDiscussion {
    pub id: String,                   // String ID like "6a9c1750b37d..."
    pub individual_note: bool,        // true = standalone comment
    pub notes: Vec<GitLabNote>,
}
```

### GitLabNote

```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabNote {
    pub id: i64,
    #[serde(rename = "type")]
    pub note_type: Option<String>,    // "DiscussionNote" | "DiffNote" | null
    pub body: String,
    pub author: GitLabAuthor,
    pub created_at: String,           // ISO 8601
    pub updated_at: String,           // ISO 8601
    pub system: bool,                 // true for system-generated notes
    #[serde(default)]
    pub resolvable: bool,
    #[serde(default)]
    pub resolved: bool,
    pub resolved_by: Option<GitLabAuthor>,
    pub resolved_at: Option<String>,
    pub position: Option<GitLabNotePosition>,
}
```

### GitLabNotePosition

```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabNotePosition {
    pub old_path: Option<String>,
    pub new_path: Option<String>,
    pub old_line: Option<i32>,
    pub new_line: Option<i32>,
}
```

## Acceptance Criteria

- [ ] GitLabIssue deserializes from API response JSON
- [ ] GitLabAuthor embedded correctly in issue and note
- [ ] GitLabDiscussion with notes array deserializes
- [ ] GitLabNote handles null note_type (use Option<String>)
- [ ] GitLabNote uses #[serde(rename = "type")] for reserved keyword
- [ ] resolvable/resolved default to false via #[serde(default)]
- [ ] All timestamp fields are String (ISO 8601 parsed elsewhere)

## Files

- src/gitlab/types.rs (edit - add types)

## TDD Loop

RED:
```rust
// tests/gitlab_types_tests.rs
#[test] fn deserializes_gitlab_issue_from_json()
#[test] fn deserializes_gitlab_discussion_from_json()
#[test] fn handles_null_note_type()
#[test] fn handles_missing_resolvable_field()
#[test] fn deserializes_labels_as_string_array()
```

GREEN: Add type definitions with serde attributes

VERIFY: `cargo test gitlab_types`

## Edge Cases

- note_type can be null, "DiscussionNote", or "DiffNote"
- labels array can be empty
- description can be null
- resolved_by/resolved_at can be null
- position is only present for DiffNotes

Implemented GitLabAuthor, GitLabIssue, GitLabDiscussion, GitLabNote, GitLabNotePosition types with 10 passing tests


OBSERV: Add -v/--verbose and --log-format CLI flags

## Background
Users and agents need CLI-controlled verbosity without knowing RUST_LOG syntax. The -v flag convention (cargo, curl, ssh) is universally understood. --log-format json enables lore sync 2>&1 | jq workflows without reading log files.

## Approach
Add two new global flags to the Cli struct in src/cli/mod.rs (insert after the quiet field at line ~37):

```rust
/// Increase log verbosity (-v, -vv, -vvv)
#[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count, global = true)]
pub verbose: u8,

/// Log format for stderr output: text (default) or json
#[arg(long = "log-format", global = true, value_parser = ["text", "json"], default_value = "text")]
pub log_format: String,
```

The existing Cli struct (src/cli/mod.rs:13-42) has these global flags: config, robot, json, color, quiet. The new flags follow the same pattern.

Note: clap::ArgAction::Count allows -v, -vv, -vvv as a single flag with increasing count (0, 1, 2, 3).

## Acceptance Criteria
- [ ] lore -v sync parses without error (verbose=1)
- [ ] lore -vv sync parses (verbose=2)
- [ ] lore -vvv sync parses (verbose=3)
- [ ] lore --log-format json sync parses (log_format="json")
- [ ] lore --log-format text sync parses (default)
- [ ] lore --log-format xml sync errors (invalid value)
- [ ] Existing commands unaffected (verbose defaults to 0, log_format to "text")
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/cli/mod.rs (modify Cli struct, lines 13-42)

## TDD Loop
RED: Write test that parses Cli with -v flag and asserts verbose=1
GREEN: Add the two fields to Cli struct
VERIFY: cargo test -p lore && cargo clippy --all-targets -- -D warnings

## Edge Cases
- -v and -q together: both parse fine; conflict resolution happens in subscriber setup (bd-2rr), not here
- -v flag must be global=true so it works before and after subcommands: lore -v sync AND lore sync -v
- --log-format is a string, not enum, to keep Cli struct simple

Added -v/--verbose (count) and --log-format (text|json) global CLI flags


OBSERV: Define StageTiming struct in src/core/metrics.rs

## Background
StageTiming is the materialized view of span timing data. It's the data structure that flows through robot JSON output, sync_runs.metrics_json, and the human-readable timing summary. Defined in a new file because it's genuinely new functionality that doesn't fit existing modules.

## Approach
Create src/core/metrics.rs:

```rust
use serde::Serialize;

fn is_zero(v: &usize) -> bool { *v == 0 }

#[derive(Debug, Clone, Serialize)]
pub struct StageTiming {
    pub name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub project: Option<String>,
    pub elapsed_ms: u64,
    pub items_processed: usize,
    #[serde(skip_serializing_if = "is_zero")]
    pub items_skipped: usize,
    #[serde(skip_serializing_if = "is_zero")]
    pub errors: usize,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub sub_stages: Vec<StageTiming>,
}
```

Register module in src/core/mod.rs (line ~11, add):
```rust
pub mod metrics;
```

The is_zero helper is a private function used by serde's skip_serializing_if. It must take &usize (reference) and return bool.

## Acceptance Criteria
- [ ] StageTiming serializes to JSON matching PRD Section 4.6.2 example
- [ ] items_skipped omitted when 0
- [ ] errors omitted when 0
- [ ] sub_stages omitted when empty vec
- [ ] project omitted when None
- [ ] name, elapsed_ms, items_processed always present
- [ ] Struct is Debug + Clone + Serialize
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/core/metrics.rs (new file)
- src/core/mod.rs (register module, add line after existing pub mod declarations)

## TDD Loop
RED:
  - test_stage_timing_serialization: create StageTiming with sub_stages, serialize, assert JSON structure
  - test_stage_timing_zero_fields_omitted: errors=0, items_skipped=0, assert no "errors" or "items_skipped" keys
  - test_stage_timing_empty_sub_stages: sub_stages=vec![], assert no "sub_stages" key
GREEN: Create metrics.rs with StageTiming struct and is_zero helper
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- is_zero must be a function, not a closure (serde skip_serializing_if requires a function path)
- Vec::is_empty is a method on Vec, and serde accepts "Vec::is_empty" as a path for skip_serializing_if
- Recursive StageTiming (sub_stages contains StageTiming): serde handles this naturally, no special handling needed

Created src/core/metrics.rs with StageTiming struct, serde skip_serializing_if for zero/empty fields, 5 tests