Implement user flow integration tests (9 PRD flows)

## Background

The PRD Section 6 defines 9 end-to-end user flows that exercise cross-screen navigation, state preservation, and data flow. The existing vertical slice test (bd-1mju) covers one flow (Dashboard -> Issue List -> Issue Detail -> Sync). These integration tests cover the remaining 8 flows plus re-test the vertical slice from a user-journey perspective. Each test simulates a realistic keystroke sequence using FrankenTUI's test harness and verifies that the correct screens are reached with the correct data visible.

## Approach

Create a test module `tests/tui_user_flows.rs` with 9 test functions, each simulating a keystroke sequence against a FrankenTUI `TestHarness` with a pre-populated test database. Tests use `FakeClock` for deterministic timestamps.

**Test database fixture**: A shared setup function creates an in-memory SQLite DB with ~20 issues, ~10 MRs, ~30 discussions, a few experts, and timeline events. This fixture is reused across all flow tests.

**Flow tests**:

1. **`test_flow_find_expert`** — Dashboard -> `w` -> type "src/auth/" -> verify Expert mode results appear -> `↓` select first person -> `Enter` -> verify navigation to Issue List filtered by that person
2. **`test_flow_timeline_query`** — Dashboard -> `t` -> type "auth timeout" -> `Enter` -> verify Timeline shows seed events -> `Enter` on first event -> verify entity detail opens -> `Esc` -> back on Timeline
3. **`test_flow_quick_search`** — Any screen -> `/` -> type query -> verify results appear -> `Tab` (switch mode) -> verify mode label changes -> `Enter` -> verify entity detail opens
4. **`test_flow_sync_and_browse`** — Dashboard -> `s` -> `Enter` (start sync) -> wait for completion -> verify Summary shows deltas -> `i` -> verify Issue List filtered to new items
5. **`test_flow_review_workload`** — Dashboard -> `w` -> `Tab` (Workload mode) -> type "@bjones" -> verify workload sections appear (assigned, authored, reviewing)
6. **`test_flow_command_palette`** — Any screen -> `Ctrl+P` -> type "mrs draft" -> verify fuzzy match -> `Enter` -> verify MR List opened with draft filter
7. **`test_flow_morning_triage`** — Dashboard -> `i` -> verify Issue List (opened, sorted by updated) -> `Enter` on first -> verify Issue Detail -> `Esc` -> verify cursor preserved on same row -> `j` -> verify cursor moved
8. **`test_flow_direct_screen_jumps`** — Issue Detail -> `gt` -> verify Timeline -> `gw` -> verify Who -> `gi` -> verify Issue List -> `H` -> verify Dashboard (clean reset)
9. **`test_flow_risk_sweep`** — Dashboard -> scroll to Insights -> `Enter` on first insight -> verify pre-filtered Issue List

Each test follows the pattern:
```rust
#[test]
fn test_flow_X() {
    let (harness, app) = setup_test_harness_with_fixture();
    // Send keystrokes
    harness.send_key(Key::Char('w'));
    // Assert screen state
    assert_eq!(app.current_screen(), Screen::Who);
    // Assert visible content
    let frame = harness.render();
    assert!(frame.contains("Expert"));
}
```

## Acceptance Criteria
- [ ] All 9 flow tests exist and compile
- [ ] Each test uses the shared DB fixture (no per-test DB setup)
- [ ] Each test verifies screen transitions via `current_screen()` assertions
- [ ] Each test verifies at least one content assertion (rendered text contains expected data)
- [ ] test_flow_morning_triage verifies cursor preservation after Enter/Esc round-trip
- [ ] test_flow_direct_screen_jumps verifies the g-prefix navigation chain
- [ ] test_flow_sync_and_browse verifies delta-filtered navigation after sync
- [ ] All tests use FakeClock for deterministic timestamps
- [ ] Tests complete in <5 seconds each (no real I/O)

## Files
- CREATE: crates/lore-tui/tests/tui_user_flows.rs
- MODIFY: (none — this is a new test file only)

## TDD Anchor
RED: Write `test_flow_morning_triage` first — it exercises the most common daily workflow (Dashboard -> Issue List -> Issue Detail -> back with cursor preservation). Start with just the Dashboard -> Issue List transition.
GREEN: Requires all Phase 2 core screens to be working; the test itself is the GREEN verification.
VERIFY: cargo test -p lore-tui test_flow_morning_triage

Additional tests: All 9 flows listed above.

## Edge Cases
- Flow tests must handle async data loading — use harness.tick() or harness.wait_for_idle() to let async tasks complete before asserting
- g-prefix timeout (500ms) — tests must send the second key within the timeout; use harness clock control
- Sync flow test needs a mock sync that completes quickly — use a pre-populated SyncDeltaLedger rather than running actual sync

## Dependency Context
- Depends on bd-1mju (vertical slice integration test) which establishes the test harness patterns and fixture setup.
- Depends on bd-2nfs (snapshot test infrastructure) which provides the FakeClock and TestHarness setup.
- Depends on all Phase 2 core screen beads (bd-35g5 Dashboard, bd-3ei1 Issue List, bd-8ab7 Issue Detail, bd-2kr0 MR List, bd-3t1b MR Detail) being implemented.
- Depends on Phase 3 power feature beads (bd-1zow Search, bd-29qw Timeline, bd-u7se Who, bd-wzqi Command Palette) being implemented.
- Depends on bd-2x2h (Sync screen) for the sync+browse flow test.


Fetch MR diffs API and populate mr_file_changes

## Background

This bead fetches MR diff metadata from the GitLab API and populates the mr_file_changes table created by migration 016. It extracts only file-level metadata (paths, change type) and discards actual diff content.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 4.3 (Ingestion).

## Codebase Context

- pending_dependent_fetches already has `job_type='mr_diffs'` in CHECK constraint (migration 011)
- dependent_queue.rs has: enqueue_job(), claim_jobs(), complete_job(), fail_job() with exponential backoff
- Orchestrator pattern: enqueue after entity ingestion, drain after primary ingestion completes
- GitLab client uses fetch_all_pages() for pagination
- Existing drain patterns in orchestrator.rs: drain_resource_events() and drain_mr_closes_issues() — follow same pattern
- config.sync.fetch_mr_file_changes flag guards enqueue (see bd-jec)
- mr_file_changes table created by migration 016 (bd-1oo) — NOT 015 (015 is commit SHAs)
- merge_commit_sha and squash_commit_sha already captured during MR ingestion (src/ingestion/merge_requests.rs lines 184, 205-206, 230-231) — no work needed for those fields

## Approach

### 1. API Client — add to `src/gitlab/client.rs`:

```rust
pub async fn fetch_mr_diffs(
    &self,
    project_id: i64,
    mr_iid: i64,
) -> Result<Vec<GitLabMrDiff>> {
    let path = format\!("/projects/{project_id}/merge_requests/{mr_iid}/diffs");
    self.fetch_all_pages(&path, &[("per_page", "100")]).await
        .or_else(|e| coalesce_not_found(e, Vec::new()))
}
```

### 2. Types — add to `src/gitlab/types.rs`:

```rust
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitLabMrDiff {
    pub old_path: String,
    pub new_path: String,
    pub new_file: bool,
    pub renamed_file: bool,
    pub deleted_file: bool,
    // Ignore: diff, a_mode, b_mode, generated_file (not stored)
}
```

Add `GitLabMrDiff` to `src/gitlab/mod.rs` re-exports.

### 3. Change Type Derivation (in new file):

```rust
fn derive_change_type(diff: &GitLabMrDiff) -> &'static str {
    if diff.new_file { "added" }
    else if diff.renamed_file { "renamed" }
    else if diff.deleted_file { "deleted" }
    else { "modified" }
}
```

### 4. DB Storage — new `src/ingestion/mr_diffs.rs`:

```rust
pub fn upsert_mr_file_changes(
    conn: &Connection,
    mr_local_id: i64,
    project_id: i64,
    diffs: &[GitLabMrDiff],
) -> Result<usize> {
    // DELETE FROM mr_file_changes WHERE merge_request_id = ?
    // INSERT each diff row with derived change_type
    // DELETE+INSERT is simpler than UPSERT for array replacement
}
```

Add `pub mod mr_diffs;` to `src/ingestion/mod.rs`.

### 5. Queue Integration — in orchestrator.rs:

```rust
// After MR upsert, if config.sync.fetch_mr_file_changes:
enqueue_job(conn, project_id, "merge_request", mr_iid, mr_local_id, "mr_diffs")?;
```

Add `drain_mr_diffs()` following the drain_mr_closes_issues() pattern. Call it after drain_mr_closes_issues() in the sync pipeline.

## Acceptance Criteria

- [ ] `fetch_mr_diffs()` calls GET /projects/:id/merge_requests/:iid/diffs with pagination
- [ ] GitLabMrDiff type added to src/gitlab/types.rs and re-exported from src/gitlab/mod.rs
- [ ] Change type derived: new_file->added, renamed_file->renamed, deleted_file->deleted, else->modified
- [ ] mr_file_changes rows have correct old_path, new_path, change_type
- [ ] Old rows deleted before insert (clean replacement per MR)
- [ ] Jobs only enqueued when config.sync.fetch_mr_file_changes is true
- [ ] 404/403 API errors handled gracefully (empty result, not failure)
- [ ] drain_mr_diffs() added to orchestrator.rs sync pipeline
- [ ] `pub mod mr_diffs;` added to src/ingestion/mod.rs
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

- `src/gitlab/client.rs` (add fetch_mr_diffs method)
- `src/gitlab/types.rs` (add GitLabMrDiff struct)
- `src/gitlab/mod.rs` (re-export GitLabMrDiff)
- `src/ingestion/mr_diffs.rs` (NEW — upsert_mr_file_changes + derive_change_type)
- `src/ingestion/mod.rs` (add pub mod mr_diffs)
- `src/ingestion/orchestrator.rs` (enqueue mr_diffs jobs + drain_mr_diffs)

## TDD Loop

RED:
- `test_derive_change_type_added` - new_file=true -> "added"
- `test_derive_change_type_renamed` - renamed_file=true -> "renamed"
- `test_derive_change_type_deleted` - deleted_file=true -> "deleted"
- `test_derive_change_type_modified` - all false -> "modified"
- `test_upsert_replaces_existing` - second upsert replaces first

GREEN: Implement API client, type derivation, DB ops, orchestrator wiring.

VERIFY: `cargo test --lib -- mr_diffs`

## Edge Cases

- MR with 500+ files: paginate properly via fetch_all_pages
- Binary files: handled as modified (renamed_file/new_file/deleted_file all false)
- File renamed AND modified: renamed_file=true takes precedence
- Draft MRs: still fetch diffs
- Deleted MR: 404 -> empty vec via coalesce_not_found()
- merge_commit_sha/squash_commit_sha: already handled in merge_requests.rs ingestion — NOT part of this bead

Implemented: GitLabMrDiff type, fetch_mr_diffs client method, upsert_mr_file_changes in new mr_diffs.rs module, enqueue_mr_diffs_jobs + drain_mr_diffs in orchestrator, migration 020 for diffs_synced_for_updated_at watermark, progress events, autocorrect registry. All 390 tests pass, clippy clean.


[CP1] Issue transformer with label extraction

Transform GitLab issue payloads to normalized database schema.

Functions to implement:
- transformIssue(gitlabIssue, localProjectId) → NormalizedIssue
- extractLabels(gitlabIssue, localProjectId) → Label[]

Transformation rules:
- Convert ISO timestamps to ms epoch using isoToMs()
- Set last_seen_at to nowMs()
- Handle labels vs labels_details (prefer details when available)
- Handle missing optional fields gracefully

Files: src/gitlab/transformers/issue.ts
Tests: tests/unit/issue-transformer.test.ts
Done when: Unit tests pass for payload transformation and label extraction


[CP1] Cargo.toml updates - async-stream and futures

## Background

The GitLab client pagination methods require async streaming capabilities. The `async-stream` crate provides the `stream!` macro for creating async iterators, and `futures` provides `StreamExt` for consuming them with `.next()` and other combinators.

## Approach

Add these dependencies to Cargo.toml:

```toml
[dependencies]
async-stream = "0.3"
futures = { version = "0.3", default-features = false, features = ["alloc"] }
```

Use minimal features on `futures` to avoid pulling unnecessary code.

## Acceptance Criteria

- [ ] `async-stream = "0.3"` is in Cargo.toml [dependencies]
- [ ] `futures` with `alloc` feature is in Cargo.toml [dependencies]
- [ ] `cargo check` succeeds after adding dependencies

## Files

- Cargo.toml (edit)

## TDD Loop

RED: Not applicable (dependency addition)
GREEN: Add lines to Cargo.toml
VERIFY: `cargo check`

## Edge Cases

- If `futures` is already present, merge features rather than duplicate
- Use exact version pins for reproducibility

Added async-stream 0.3 and futures 0.3 (alloc feature) to Cargo.toml, cargo check passes


Add timestamp-aware test helpers

## Background
Existing test helpers (who.rs:2469-2598) use now_ms() for all timestamps. Time-decay tests need precise timestamp control to verify decay math, state-aware timestamps, and as-of filtering.

## Approach
Add three new helpers in the test module (who.rs after insert_file_change at line 2598), patterned after existing helpers:

### insert_mr_at() — variant of insert_mr (who.rs:2469-2493):
```rust
#[allow(clippy::too_many_arguments)]
fn insert_mr_at(
    conn: &Connection,
    id: i64,
    project_id: i64,
    iid: i64,
    author: &str,
    state: &str,
    updated_at_ms: i64,
    merged_at_ms: Option<i64>,
    closed_at_ms: Option<i64>,
) {
    conn.execute(
        "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, updated_at, merged_at, closed_at)
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
        rusqlite::params![id, id * 10, project_id, iid, format!("MR {iid}"), author, state, now_ms(), updated_at_ms, merged_at_ms, closed_at_ms],
    ).unwrap();
}
```

### insert_diffnote_at() — variant of insert_diffnote (who.rs:2541-2580):
```rust
#[allow(clippy::too_many_arguments)]
fn insert_diffnote_at(
    conn: &Connection,
    id: i64,
    discussion_id: i64,
    project_id: i64,
    author: &str,
    new_path: &str,
    old_path: Option<&str>,
    body: &str,
    created_at_ms: i64,
) {
    conn.execute(
        "INSERT INTO notes (id, gitlab_id, project_id, discussion_id, author_username, note_type, is_system, position_new_path, position_old_path, body, created_at, updated_at)
         VALUES (?1, ?2, ?3, ?4, ?5, 'DiffNote', 0, ?6, ?7, ?8, ?9, ?9)",
        rusqlite::params![id, id * 10, project_id, discussion_id, author, new_path, old_path, body, created_at_ms],
    ).unwrap();
}
```

### insert_file_change_with_old_path() — variant of insert_file_change (who.rs:2585-2598):
```rust
fn insert_file_change_with_old_path(
    conn: &Connection,
    mr_id: i64,
    project_id: i64,
    new_path: &str,
    old_path: Option<&str>,
    change_type: &str,
) {
    conn.execute(
        "INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, old_path, change_type)
         VALUES (?1, ?2, ?3, ?4, ?5)",
        rusqlite::params![mr_id, project_id, new_path, old_path, change_type],
    ).unwrap();
}
```

Note: mr_file_changes already has an old_path column (from migration 016). The existing insert_file_change helper simply omits it (defaults to NULL).

## Acceptance Criteria
- [ ] insert_mr_at compiles and inserts with explicit updated_at, merged_at, closed_at
- [ ] insert_diffnote_at compiles and inserts with explicit old_path and created_at
- [ ] insert_file_change_with_old_path compiles and inserts with explicit old_path
- [ ] Existing helpers (insert_mr, insert_diffnote, insert_file_change) unchanged
- [ ] cargo check --all-targets passes
- [ ] All three helpers are used by downstream beads (bd-13q8, bd-1h3f)

## Files
- MODIFY: src/cli/commands/who.rs (test module, after insert_file_change at line ~2598)

## TDD Loop
N/A — these are test utilities. Verified indirectly by tests in bd-13q8 and bd-1h3f.
VERIFY: cargo check --all-targets (compiles cleanly)

## Edge Cases
- merged_at_ms/closed_at_ms as Option<i64> — NULL when not applicable
- old_path as Option<&str> — NULL when no rename occurred
- created_at_ms used for both created_at and updated_at on notes (matching existing insert_diffnote pattern)
- #[allow(clippy::too_many_arguments)] needed on helpers with 8+ params (project uses pedantic clippy)

Dependencies:
  -> bd-2w1p (blocks) - Add half-life fields and config validation to ScoringConfig

Implemented by time-decay swarm: 3 agents, 12 tasks, 621 tests passing, all quality gates green


Epic: Gate 1 - Resource Events Ingestion

## Background
Gate 1 transforms gitlore from a snapshot engine into a temporal data store by ingesting structured event data from GitLab Resource Events APIs (state, label, milestone changes). This is the foundation — Gates 2-5 all depend on the event tables and dependent fetch queue that Gate 1 establishes.

Currently, when an issue is closed or a label changes, gitlore overwrites the current state. The transition is lost. Gate 1 captures these transitions as discrete events with timestamps, actors, and provenance, enabling temporal queries like "when did this issue become critical?" and "who closed this MR?"

## Architecture
- **Three new tables:** resource_state_events, resource_label_events, resource_milestone_events (migration 011, already shipped as bd-hu3)
- **Generic dependent fetch queue:** pending_dependent_fetches table replaces per-type queue tables. Supports job_types: resource_events, mr_closes_issues, mr_diffs. Used by Gates 1, 2, and 4.
- **Opt-in via config:** sync.fetchResourceEvents (default true). --no-events CLI flag to skip.
- **Incremental:** Only changed entities enqueued. --full re-enqueues all.
- **Crash recovery:** locked_at column with 5-minute stale lock reclaim.

## Children (Execution Order)
1. **bd-hu3** [CLOSED] — Migration 011: event tables + entity_references + dependent fetch queue
2. **bd-2e8** [CLOSED] — fetchResourceEvents config flag
3. **bd-2fm** [CLOSED] — GitLab Resource Event serde types
4. **bd-sqw** [CLOSED] — Resource Events API endpoints in GitLab client
5. **bd-1uc** [CLOSED] — DB upsert functions for resource events
6. **bd-tir** [CLOSED] — Generic dependent fetch queue (enqueue + drain)
7. **bd-1ep** [CLOSED] — Wire resource event fetching into sync pipeline
8. **bd-3sh** [CLOSED] — lore count events command
9. **bd-1m8** [CLOSED] — lore stats --check for event integrity + queue health

## Gate Completion Criteria
- [ ] All 9 children closed
- [ ] `lore sync` fetches resource events for changed entities
- [ ] `lore sync --no-events` skips event fetching
- [ ] Event fetch failures queued for retry with exponential backoff
- [ ] Stale locks auto-reclaimed on next sync run
- [ ] `lore count events` shows counts by type (state/label/milestone)
- [ ] `lore stats --check` validates referential integrity + queue health
- [ ] Robot mode JSON for all new commands
- [ ] Integration test: full sync cycle with events enabled

## Dependencies
- None (Gate 1 is the foundation)
- Downstream: Gate 2 (bd-1se) depends on event tables and dependent fetch queue

Already implemented: migration 011 exists, events_db.rs has upsert functions, client.rs has fetch_*_state_events, orchestrator.rs has drain_resource_events. Full Gate 1 functionality is live.


[CP1] GitLab client pagination methods

Add async stream methods for paginated GitLab API calls.

## Methods to Add to GitLabClient

### paginate_issues(gitlab_project_id, updated_after, cursor_rewind_seconds) -> Stream<GitLabIssue>
- Use async_stream::try_stream! macro
- Query params: scope=all, state=all, order_by=updated_at, sort=asc, per_page=100
- If updated_after provided, apply cursor_rewind_seconds (subtract from timestamp)
- Clamp to 0 to avoid underflow: (ts - rewind_ms).max(0)
- Follow x-next-page header until empty/absent
- Fall back to empty-page detection if headers missing

### paginate_issue_discussions(gitlab_project_id, issue_iid) -> Stream<GitLabDiscussion>
- Paginate through discussions for single issue
- per_page=100
- Follow x-next-page header

### request_with_headers<T>(path, params) -> Result<(T, HeaderMap)>
- Acquire rate limiter
- Make request with PRIVATE-TOKEN header
- Return both deserialized data and response headers

## Dependencies
- async-stream = "0.3" (for try_stream! macro)
- futures = "0.3" (for Stream trait and StreamExt)

Files: src/gitlab/client.rs
Tests: tests/pagination_tests.rs
Done when: Pagination handles multiple pages and respects cursors, tests pass


[CP1] Discussion ingestion module

Fetch and store discussions/notes for each issue.

Implement ingestIssueDiscussions(options) → { discussionsFetched, discussionsUpserted, notesUpserted, systemNotesCount }

Logic:
1. Paginate through all discussions for given issue
2. For each discussion:
   - Store raw payload (compressed)
   - Upsert discussion record with correct issue FK
   - Transform and upsert all notes
   - Store raw payload per note
   - Track system notes count

Files: src/ingestion/discussions.ts
Tests: tests/integration/issue-discussion-ingestion.test.ts
Done when: Discussions and notes populated with correct FKs and is_system flags


Epic: CP2 Gate B - Labels + Assignees + Reviewers

## Background
Gate B validates junction tables for labels, assignees, and reviewers. Ensures relationships are tracked correctly and stale links are removed on resync. This is critical for filtering (`--reviewer=alice`) and display.

## Acceptance Criteria (Pass/Fail)
- [ ] `mr_labels` table has rows for MRs with labels
- [ ] Label count per MR matches GitLab UI (spot check 3 MRs)
- [ ] `mr_assignees` table has rows for MRs with assignees
- [ ] Assignee usernames match GitLab UI (spot check 3 MRs)
- [ ] `mr_reviewers` table has rows for MRs with reviewers
- [ ] Reviewer usernames match GitLab UI (spot check 3 MRs)
- [ ] Remove label in GitLab -> resync -> link removed from mr_labels
- [ ] Add reviewer in GitLab -> resync -> link added to mr_reviewers
- [ ] `gi list mrs --label=bugfix` filters correctly
- [ ] `gi list mrs --reviewer=alice` filters correctly

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

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

echo "=== Gate B: Labels + Assignees + Reviewers ==="

# 1. Check label linkage exists
echo "Step 1: Check label linkage..."
LABEL_LINKS=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM mr_labels;")
echo "  Total label links: $LABEL_LINKS"

# 2. Show sample label linkage
echo "Step 2: Sample label linkage..."
sqlite3 "$DB_PATH" "
  SELECT m.iid, GROUP_CONCAT(l.name, ', ') as labels
  FROM merge_requests m
  JOIN mr_labels ml ON ml.merge_request_id = m.id
  JOIN labels l ON l.id = ml.label_id
  GROUP BY m.id
  LIMIT 5;
"

# 3. Check assignee linkage
echo "Step 3: Check assignee linkage..."
ASSIGNEE_LINKS=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM mr_assignees;")
echo "  Total assignee links: $ASSIGNEE_LINKS"

# 4. Show sample assignee linkage
echo "Step 4: Sample assignee linkage..."
sqlite3 "$DB_PATH" "
  SELECT m.iid, GROUP_CONCAT(a.username, ', ') as assignees
  FROM merge_requests m
  JOIN mr_assignees a ON a.merge_request_id = m.id
  GROUP BY m.id
  LIMIT 5;
"

# 5. Check reviewer linkage
echo "Step 5: Check reviewer linkage..."
REVIEWER_LINKS=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM mr_reviewers;")
echo "  Total reviewer links: $REVIEWER_LINKS"

# 6. Show sample reviewer linkage
echo "Step 6: Sample reviewer linkage..."
sqlite3 "$DB_PATH" "
  SELECT m.iid, GROUP_CONCAT(r.username, ', ') as reviewers
  FROM merge_requests m
  JOIN mr_reviewers r ON r.merge_request_id = m.id
  GROUP BY m.id
  LIMIT 5;
"

# 7. Test filter commands
echo "Step 7: Test filter commands..."
# Get a label that exists
LABEL=$(sqlite3 "$DB_PATH" "SELECT name FROM labels LIMIT 1;")
if [ -n "$LABEL" ]; then
  echo "  Testing --label=$LABEL"
  gi list mrs --label="$LABEL" --limit=3
fi

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

echo ""
echo "=== Gate B: PASSED ==="
```

## Stale Link Removal Test (Manual)
```bash
# 1. Pick an MR with labels in GitLab UI
MR_IID=123

# 2. Note current label count
sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM mr_labels ml
  JOIN merge_requests m ON m.id = ml.merge_request_id
  WHERE m.iid = $MR_IID;
"
# Example: 3 labels

# 3. Remove a label in GitLab UI (manually)

# 4. Resync
gi ingest --type=merge_requests

# 5. Verify label removed
sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM mr_labels ml
  JOIN merge_requests m ON m.id = ml.merge_request_id
  WHERE m.iid = $MR_IID;
"
# Should be: 2 labels (one less)
```

## Test Commands (Quick Verification)
```bash
# Check counts:
sqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 "
  SELECT
    (SELECT COUNT(*) FROM mr_labels) as label_links,
    (SELECT COUNT(*) FROM mr_assignees) as assignee_links,
    (SELECT COUNT(*) FROM mr_reviewers) as reviewer_links;
"

# Test filtering:
gi list mrs --label=enhancement --limit=5
gi list mrs --reviewer=alice --limit=5
gi list mrs --assignee=bob --limit=5
```

## Dependencies
This gate requires:
- bd-ser (MR ingestion with label/assignee/reviewer linking via clear-and-relink pattern)
- Gate A must pass first

## Edge Cases
- MRs with no labels/assignees/reviewers: junction tables should have no rows for that MR
- Labels shared across issues and MRs: labels table is shared, only junction differs
- Usernames are case-sensitive: `Alice` != `alice`


[CP1] Test fixtures for mocked GitLab responses

Create mock response files for integration tests.

Fixtures to create:
- gitlab-issue.json (single issue with labels)
- gitlab-issues-page.json (paginated list)
- gitlab-discussion.json (single discussion with notes)
- gitlab-discussions-page.json (paginated list)

Include edge cases:
- Issue with labels_details
- Issue with no labels
- Discussion with individual_note=true
- System notes with system=true

Files: tests/fixtures/mock-responses/gitlab-issue*.json, gitlab-discussion*.json
Done when: MSW handlers can use fixtures for deterministic tests


[CP0] Project scaffold

## Background

This is the foundational scaffold for the GitLab Inbox CLI tool. Every subsequent bead depends on having the correct project structure, TypeScript configuration, and tooling in place. The configuration choices here (ESM modules, strict TypeScript, Vitest for testing) set constraints for all future code.

## Approach

Create a Node.js 20+ ESM project with TypeScript strict mode. Use flat ESLint config (v9+) with TypeScript plugin. Configure Vitest with coverage. Create the directory structure matching the PRD exactly.

**package.json essentials:**
- `"type": "module"` for ESM
- `"bin": { "gi": "./dist/cli/index.js" }` for CLI entry point
- Runtime deps: better-sqlite3, sqlite-vec, commander, zod, pino, pino-pretty, ora, chalk, cli-table3, inquirer
- Dev deps: typescript, @types/better-sqlite3, @types/node, vitest, msw, eslint, @typescript-eslint/*

**tsconfig.json:**
- `target: ES2022`, `module: Node16`, `moduleResolution: Node16`
- `strict: true`, `noImplicitAny: true`, `strictNullChecks: true`
- `outDir: ./dist`, `rootDir: ./src`

**vitest.config.ts:**
- Exclude `tests/live/**` unless `GITLAB_LIVE_TESTS=1`
- Coverage with v8 provider

## Acceptance Criteria

- [ ] `npm install` completes without errors
- [ ] `npm run build` compiles TypeScript to dist/
- [ ] `npm run test` runs vitest (0 tests is fine at this stage)
- [ ] `npm run lint` runs ESLint with no config errors
- [ ] All directories exist: src/cli/commands/, src/core/, src/gitlab/, src/types/, tests/unit/, tests/integration/, tests/live/, tests/fixtures/mock-responses/, migrations/

## Files

CREATE:
- package.json
- tsconfig.json
- vitest.config.ts
- eslint.config.js
- .gitignore
- src/cli/index.ts (empty placeholder with shebang)
- src/cli/commands/.gitkeep
- src/core/.gitkeep
- src/gitlab/.gitkeep
- src/types/index.ts (empty)
- tests/unit/.gitkeep
- tests/integration/.gitkeep
- tests/live/.gitkeep
- tests/fixtures/mock-responses/.gitkeep
- migrations/.gitkeep

## TDD Loop

N/A - scaffold only. Verify with:

```bash
npm install
npm run build
npm run lint
npm run test
```

## Edge Cases

- Node.js version < 20 will fail on ESM features - add `engines` field
- better-sqlite3 requires native compilation - may need python/build-essential
- sqlite-vec installation can fail on some platforms - document fallback


Implement error handling paths for me command

## Background
`lore me` has several expected user-facing failure modes that are usage/data conditions, not internal faults. This bead ensures those paths return correct exit codes and structured error output in both human and robot modes.

This spec assumes runtime usage errors are represented by `LoreError::UsageError` mapped to `ErrorCode::UsageError` (exit code 2, robot code `USAGE_ERROR`) from `bd-1f1f`.

## Approach
Validate and harden these paths in the handler and shared error formatting:

1. **No username configured**
- source: username resolver
- error: `LoreError::UsageError`
- exit: `2`
- message includes: configure `gitlab.username` or pass `--user`

2. **No synced data**
- source: `projects` table count is zero
- error: `LoreError::NotFound("No synced data. Run 'lore sync' first.")`
- exit: `17`

3. **Invalid scope flags (`--project` + `--all`)**
- source: scope resolver
- error: `LoreError::UsageError`
- exit: `2`

4. **Invalid `--since` value**
- source: parse failure in handler
- error: `LoreError::UsageError`
- exit: `2`

5. **Empty results**
- not an error
- render zero-state dashboard normally

Robot mode must emit error envelope on stderr with deterministic code/message/suggestion, including `USAGE_ERROR` for usage failures.

## Acceptance Criteria
- [ ] No username path returns exit 2 with actionable usage message
- [ ] `--project` + `--all` returns exit 2
- [ ] Invalid `--since` returns exit 2
- [ ] No synced data returns exit 17 and sync suggestion
- [ ] Empty results render dashboard (not error)
- [ ] Robot stderr envelope includes correct `error.code` (`USAGE_ERROR` or `NOT_FOUND`)
- [ ] Human-mode stderr is readable and actionable
- [ ] No usage-path error falls through to internal error exit 1

## Files
- MODIFY: `src/cli/commands/me/mod.rs`
- MODIFY: `src/core/error.rs`
- MODIFY: `src/main.rs` (if command-level error routing needs explicit usage handling)
- MODIFY: `src/cli/robot.rs` (only if code mapping/suggestion formatting requires extension)

## TDD Anchor
RED:
- `test_no_username_exits_2`
- `test_project_all_conflict_exits_2`
- `test_invalid_since_exits_2`
- `test_no_synced_data_exits_17`
- `test_empty_results_not_error`
- `test_robot_usage_error_envelope`

GREEN:
- Wire usage/data errors to explicit `LoreError` variants.
- Ensure formatter emits consistent code and suggestion.

VERIFY:
- `cargo test me_error_paths`

## Edge Cases
- A DB with projects but no matching items is not "no synced data"; it is a valid empty dashboard.
- Error messages should not leak SQL internals to end users.

## Dependency Context
Consumes handler wiring from `bd-1vv8` and error type from `bd-1f1f`.
Finalizes AC-10 compliance for `lore me`.

Dependencies:
  -> bd-1vv8 (blocks) - Implement me command handler: wire queries to renderers


OBSERV: Implement log retention cleanup at startup

## Background
Log files accumulate at ~1-10 MB/day. Without cleanup, they grow unbounded. Retention runs BEFORE subscriber init so deleted file handles aren't held open by the appender.

## Approach
Add a cleanup function, called from main.rs before the subscriber is initialized (before current line 44):

```rust
/// Delete log files older than retention_days.
/// Matches files named lore.YYYY-MM-DD.log in the log directory.
pub fn cleanup_old_logs(log_dir: &Path, retention_days: u32) -> std::io::Result<usize> {
    if retention_days == 0 {
        return Ok(0); // 0 means file logging disabled, don't delete
    }
    let cutoff = SystemTime::now() - Duration::from_secs(u64::from(retention_days) * 86400);
    let mut deleted = 0;

    for entry in std::fs::read_dir(log_dir)? {
        let entry = entry?;
        let name = entry.file_name();
        let name_str = name.to_string_lossy();

        // Only match lore.YYYY-MM-DD.log pattern
        if !name_str.starts_with("lore.") || !name_str.ends_with(".log") {
            continue;
        }

        if let Ok(metadata) = entry.metadata() {
            if let Ok(modified) = metadata.modified() {
                if modified < cutoff {
                    std::fs::remove_file(entry.path())?;
                    deleted += 1;
                }
            }
        }
    }
    Ok(deleted)
}
```

Place this function in src/core/paths.rs (next to get_log_dir) or a new src/core/log_retention.rs. Prefer paths.rs since it's small and related.

Call from main.rs:
```rust
let log_dir = get_log_dir(config.logging.log_dir.as_deref());
let _ = cleanup_old_logs(&log_dir, config.logging.retention_days);
// THEN init subscriber
```

Note: Config must be loaded before cleanup runs. Current main.rs parses Cli at line 60, but config loading happens inside command handlers. This means we need to either:
  A) Load config early in main() before subscriber init (preferred)
  B) Defer cleanup to after config load

Since the subscriber must also know log_dir, approach A is natural: load config -> cleanup -> init subscriber -> dispatch command.

## Acceptance Criteria
- [ ] Files matching lore.*.log older than retention_days are deleted
- [ ] Files matching lore.*.log within retention_days are preserved
- [ ] Non-matching files (e.g., other.txt) are never deleted
- [ ] retention_days=0 skips cleanup entirely (no files deleted)
- [ ] Errors on individual files don't prevent cleanup of remaining files
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/core/paths.rs (add cleanup_old_logs function)
- src/main.rs (call cleanup before subscriber init)

## TDD Loop
RED:
  - test_log_retention_cleanup: create tempdir with lore.2026-01-01.log through lore.2026-02-04.log, run with retention_days=7, assert old deleted, recent preserved
  - test_log_retention_ignores_non_log_files: create other.txt alongside old log files, assert other.txt untouched
  - test_log_retention_zero_days: retention_days=0, assert nothing deleted
GREEN: Implement cleanup_old_logs
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- SystemTime::now() precision varies by OS; use file modified time, not name parsing (simpler and more reliable)
- read_dir on non-existent directory: get_log_dir creates it first, so this shouldn't happen. But handle gracefully.
- Permissions error on individual file: log a warning, continue with remaining files (don't propagate)
- Race condition: another process creates a file during cleanup. Not a concern -- we only delete old files.

Implemented cleanup_old_logs() with date-pattern matching and retention_days config, runs at startup before subscriber init


Implement timeline seed phase: FTS5 keyword search to entity IDs

## Background

The seed phase is steps 1-2 of the timeline pipeline (spec Section 3.2): SEED + HYDRATE. It converts a keyword query into entity IDs via FTS5 search and collects evidence note candidates.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.2 steps 1-2.

## Codebase Context

- FTS5 index exists: documents_fts table (migration 008)
- documents table: id, source_type ('issue'|'merge_request'|'discussion'), source_id, project_id, created_at, content
- discussions table: id, issue_id, merge_request_id
- notes table: discussion_id, author_username, body, created_at, is_system, id (note_id)
- Safe FTS query builder: src/search/fts.rs has to_fts_query(raw, FtsQueryMode::Safe) for sanitizing user input
- projects table: path_with_namespace
- issues/merge_requests: iid, project_id

## Approach

Create `src/core/timeline_seed.rs`:

```rust
use crate::core::timeline::{EntityRef, TimelineEvent, TimelineEventType};
use rusqlite::Connection;

pub struct SeedResult {
    pub seed_entities: Vec<EntityRef>,
    pub evidence_notes: Vec<TimelineEvent>,  // NoteEvidence events
}

pub fn seed_timeline(
    conn: &Connection,
    query: &str,
    project_id: Option<i64>,
    since_ms: Option<i64>,
    max_seeds: usize,           // default 50
) -> Result<SeedResult> { ... }
```

### SQL for SEED + HYDRATE (entity discovery):
```sql
SELECT DISTINCT d.source_type, d.source_id, d.project_id,
       CASE d.source_type
         WHEN 'issue' THEN (SELECT iid FROM issues WHERE id = d.source_id)
         WHEN 'merge_request' THEN (SELECT iid FROM merge_requests WHERE id = d.source_id)
         WHEN 'discussion' THEN NULL  -- discussions map to parent entity below
       END AS iid,
       CASE d.source_type
         WHEN 'issue' THEN (SELECT p.path_with_namespace FROM projects p JOIN issues i ON i.project_id = p.id WHERE i.id = d.source_id)
         WHEN 'merge_request' THEN (SELECT p.path_with_namespace FROM projects p JOIN merge_requests m ON m.project_id = p.id WHERE m.id = d.source_id)
         WHEN 'discussion' THEN NULL
       END AS project_path
FROM documents_fts fts
JOIN documents d ON d.id = fts.rowid
WHERE documents_fts MATCH ?1
  AND (?2 IS NULL OR d.project_id = ?2)
ORDER BY rank
LIMIT ?3
```

For 'discussion' source_type: resolve to parent entity via discussions.issue_id or discussions.merge_request_id.

### SQL for evidence notes (top 10 FTS5-matched notes):
```sql
SELECT n.id as note_id, n.body, n.created_at, n.author_username,
       disc.id as discussion_id,
       CASE WHEN disc.issue_id IS NOT NULL THEN 'issue' ELSE 'merge_request' END as parent_type,
       COALESCE(disc.issue_id, disc.merge_request_id) AS parent_entity_id
FROM documents_fts fts
JOIN documents d ON d.id = fts.rowid
JOIN discussions disc ON disc.id = d.source_id AND d.source_type = 'discussion'
JOIN notes n ON n.discussion_id = disc.id AND n.is_system = 0
WHERE documents_fts MATCH ?1
ORDER BY rank
LIMIT 10
```

Evidence notes become TimelineEvent with:
- event_type: NoteEvidence { note_id, snippet (first 200 chars), discussion_id }
- Use to_fts_query(query, FtsQueryMode::Safe) to sanitize user input before MATCH

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

## Acceptance Criteria

- [ ] seed_timeline() returns entities from FTS5 search
- [ ] Entities deduplicated (same entity from multiple docs appears once)
- [ ] Discussion documents resolved to parent entity (issue or MR)
- [ ] Evidence notes capped at 10
- [ ] Evidence note snippets truncated to 200 chars (safe UTF-8 boundary)
- [ ] Uses to_fts_query(query, FtsQueryMode::Safe) for input sanitization
- [ ] --since filter works
- [ ] -p filter works
- [ ] Empty result for zero-match queries (not error)
- [ ] Module registered in src/core/mod.rs
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

- `src/core/timeline_seed.rs` (NEW)
- `src/core/mod.rs` (add `pub mod timeline_seed;`)

## TDD Loop

RED:
- `test_seed_deduplicates_entities`
- `test_seed_resolves_discussion_to_parent`
- `test_seed_empty_query_returns_empty`
- `test_seed_evidence_capped_at_10`
- `test_seed_evidence_snippet_truncated`
- `test_seed_respects_since_filter`

Tests need in-memory DB with migrations 001-014 + documents/FTS test data.

GREEN: Implement FTS5 queries and deduplication.

VERIFY: `cargo test --lib -- timeline_seed`

## Edge Cases

- FTS5 MATCH invalid syntax: to_fts_query(query, FtsQueryMode::Safe) sanitizes
- Discussion orphans: LEFT JOIN handles deleted notes
- UTF-8 truncation: use char_indices() to find safe 200-char boundary
- Discussion source resolving to both issue_id and merge_request_id: prefer issue_id (shouldn't happen but be defensive)

Completed: Created src/core/timeline_seed.rs with seed_timeline() function. FTS5 search to entity IDs with discussion-to-parent resolution, entity deduplication, evidence note extraction (capped, snippet-truncated). 12 tests pass. All quality gates pass.


Implement Ollama API client

## Background
The Ollama API client provides the HTTP interface to the local Ollama embedding server. It handles health checks (is Ollama running? does the model exist?), batch embedding requests (up to 32 texts per call), and error translation to LoreError variants. This is the lowest-level embedding component — the pipeline (bd-am7) builds on top of it.

## Approach
Create \`src/embedding/ollama.rs\` per PRD Section 4.2. **Uses async reqwest (not blocking).**

```rust
use reqwest::Client;  // NOTE: async Client, not reqwest::blocking
use serde::{Deserialize, Serialize};
use crate::core::error::{LoreError, Result};

pub struct OllamaConfig {
    pub base_url: String,      // default \"http://localhost:11434\"
    pub model: String,         // default \"nomic-embed-text\"
    pub timeout_secs: u64,     // default 60
}

impl Default for OllamaConfig { /* PRD defaults */ }

pub struct OllamaClient {
    client: Client,      // async reqwest::Client
    config: OllamaConfig,
}

#[derive(Serialize)]
struct EmbedRequest { model: String, input: Vec<String> }

#[derive(Deserialize)]
struct EmbedResponse { model: String, embeddings: Vec<Vec<f32>> }

#[derive(Deserialize)]
struct TagsResponse { models: Vec<ModelInfo> }

#[derive(Deserialize)]
struct ModelInfo { name: String }

impl OllamaClient {
    pub fn new(config: OllamaConfig) -> Self;

    /// Async health check: GET /api/tags
    /// Model matched via starts_with (\"nomic-embed-text\" matches \"nomic-embed-text:latest\")
    pub async fn health_check(&self) -> Result<()>;

    /// Async batch embedding: POST /api/embed
    /// Input: Vec<String> of texts, Response: Vec<Vec<f32>> of 768-dim embeddings
    pub async fn embed_batch(&self, texts: Vec<String>) -> Result<Vec<Vec<f32>>>;
}

/// Quick health check without full client (async).
pub async fn check_ollama_health(base_url: &str) -> bool;
```

**Error mapping (per PRD):**
- Connection refused/timeout -> LoreError::OllamaUnavailable { base_url, source: Some(e) }
- Model not in /api/tags -> LoreError::OllamaModelNotFound { model }
- Non-200 from /api/embed -> LoreError::EmbeddingFailed { document_id: 0, reason: format!(\"HTTP {}: {}\", status, body) }

**Key PRD detail:** Model matching uses \`starts_with\` (not exact match) so \"nomic-embed-text\" matches \"nomic-embed-text:latest\".

## Acceptance Criteria
- [ ] Uses async reqwest::Client (not blocking)
- [ ] health_check() is async, detects server availability and model presence
- [ ] Model matched via starts_with (handles \":latest\" suffix)
- [ ] embed_batch() is async, sends POST /api/embed
- [ ] Batch size up to 32 texts
- [ ] Returns Vec<Vec<f32>> with 768 dimensions each
- [ ] OllamaUnavailable error includes base_url and source error
- [ ] OllamaModelNotFound error includes model name
- [ ] Non-200 response mapped to EmbeddingFailed with status + body
- [ ] Timeout: 60 seconds default (configurable via OllamaConfig)
- [ ] \`cargo build\` succeeds

## Files
- \`src/embedding/ollama.rs\` — new file
- \`src/embedding/mod.rs\` — add \`pub mod ollama;\` and re-exports

## TDD Loop
RED: Tests (unit tests with mock, integration needs Ollama):
- \`test_config_defaults\` — verify default base_url, model, timeout
- \`test_health_check_model_starts_with\` — \"nomic-embed-text\" matches \"nomic-embed-text:latest\"
- \`test_embed_batch_parse\` — mock response parsed correctly
- \`test_connection_error_maps_to_ollama_unavailable\`
GREEN: Implement OllamaClient
VERIFY: \`cargo test ollama\`

## Edge Cases
- Ollama returns model name with version tag (\"nomic-embed-text:latest\"): starts_with handles this
- Empty texts array: send empty batch, Ollama returns empty embeddings
- Ollama returns wrong number of embeddings (2 texts, 1 embedding): caller (pipeline) validates
- Non-JSON response: reqwest deserialization error -> wrap appropriately

Completed: OllamaClient with async health_check (starts_with model matching), embed_batch, error mapping to LoreError variants, check_ollama_health helper, 4 tests pass


Fetch and store GitLab linked issues (Related to)

## Background

GitLab's "Linked items" provides bidirectional issue linking distinct from "closes" and "mentioned" references. This data is only available via the issue links API (GET /projects/:id/issues/:iid/links). The goal is to fetch these links during sync and store them as entity_references so they appear in `lore show issue` and are queryable.

**Why:** Currently `lore show issue` displays closing MRs (via `get_closing_mrs()` in show.rs:~line 1544) but has NO related issues section. This bead adds that capability.

## Codebase Context

- **entity_references table** (migration 011): reference_type CHECK: 'closes' | 'mentioned' | 'related'; source_method CHECK: 'api' | 'note_parse' | 'description_parse'
- **pending_dependent_fetches** (migration 011): job_type CHECK: 'resource_events' | 'mr_closes_issues' | 'mr_diffs'. No later migrations modified this table.
- **CRITICAL:** Adding 'issue_links' to job_type CHECK requires recreating pending_dependent_fetches table (SQLite can't ALTER CHECK constraints). Migration **027** must copy data, drop, recreate with expanded CHECK, and reinsert.
- **Orchestrator** (src/ingestion/orchestrator.rs, 1745 lines): Three drain functions exist — drain_resource_events() (line 932), drain_mr_closes_issues() (line 1254), drain_mr_diffs() (line 1514). Follow the same claim/complete/fail pattern from dependent_queue.rs.
- **dependent_queue.rs**: enqueue_job(), claim_jobs(), complete_job(), fail_job() with exponential backoff
- **show.rs** (1544 lines): Has get_closing_mrs() for closing MR display. NO related_issues section exists yet.
- **GitLab API**: GET /projects/:id/issues/:iid/links returns link_type: "relates_to", "blocks", "is_blocked_by"
- **Migration count**: 26 migrations exist (001-026). Next migration = **027**.

## Approach

### Phase 1: API Client (src/gitlab/client.rs)
```rust
pub async fn fetch_issue_links(
    &self,
    project_id: i64,
    issue_iid: i64,
) -> Result<Vec<GitLabIssueLink>> {
    // GET /projects/:id/issues/:iid/links
    // Use fetch_all_pages() + coalesce_not_found()
}
```

### Phase 2: Types (src/gitlab/types.rs)
```rust
#[derive(Debug, Deserialize)]
pub struct GitLabIssueLink {
    pub id: i64,
    pub iid: i64,
    pub title: String,
    pub state: String,
    pub web_url: String,
    pub link_type: String,      // "relates_to", "blocks", "is_blocked_by"
    pub link_created_at: Option<String>,
}
```

### Phase 3: Migration 027 (migrations/027_issue_links_job_type.sql)
Recreate pending_dependent_fetches with expanded CHECK:
```sql
CREATE TABLE pending_dependent_fetches_new (
    id INTEGER PRIMARY KEY,
    project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    entity_type TEXT NOT NULL CHECK (entity_type IN ('issue', 'merge_request')),
    entity_iid INTEGER NOT NULL,
    entity_local_id INTEGER NOT NULL,
    job_type TEXT NOT NULL CHECK (job_type IN (
        'resource_events', 'mr_closes_issues', 'mr_diffs', 'issue_links'
    )),
    payload_json TEXT,
    enqueued_at INTEGER NOT NULL,
    attempts INTEGER NOT NULL DEFAULT 0,
    last_error TEXT,
    next_retry_at INTEGER,
    locked_at INTEGER,
    UNIQUE(project_id, entity_type, entity_iid, job_type)
);
INSERT INTO pending_dependent_fetches_new SELECT * FROM pending_dependent_fetches;
DROP TABLE pending_dependent_fetches;
ALTER TABLE pending_dependent_fetches_new RENAME TO pending_dependent_fetches;
-- Recreate indexes from migration 011 (idx_pdf_job_type, idx_pdf_next_retry)
```

Register in MIGRATIONS array in src/core/db.rs (entry 27).

### Phase 4: Ingestion (src/ingestion/issue_links.rs NEW)
```rust
pub async fn fetch_and_store_issue_links(
    conn: &Connection,
    client: &GitLabClient,
    project_id: i64,
    issue_local_id: i64,
    issue_iid: i64,
) -> Result<usize> {
    // 1. Fetch links from API
    // 2. Resolve target issue to local DB id (SELECT id FROM issues WHERE project_id=? AND iid=?)
    // 3. Insert into entity_references: reference_type='related', source_method='api'
    // 4. Create bidirectional refs: A->B and B->A
    // 5. Skip self-links
    // 6. Cross-project: store with target_entity_id=NULL (unresolved)
}
```

### Phase 5: Queue Integration (src/ingestion/orchestrator.rs)
- Enqueue 'issue_links' job after issue ingestion (near the existing resource_events enqueue)
- Add drain_issue_links() following drain_mr_closes_issues() pattern (lines 1254-1512)
- Config gate: add `sync.fetchIssueLinks` (default true) to config, like existing `sync.fetchResourceEvents`

### Phase 6: Display (src/cli/commands/show.rs)
In `lore show issue 123`, add "Related Issues" section after closing MRs.
Pattern: query entity_references WHERE source_entity_type='issue' AND source_entity_id=<local_id> AND reference_type='related'.

## Acceptance Criteria

- [ ] API client fetches issue links with pagination (fetch_all_pages + coalesce_not_found)
- [ ] Stored as entity_reference: reference_type='related', source_method='api'
- [ ] Bidirectional: A links B creates both A->B and B->A references
- [ ] link_type captured (relates_to, blocks, is_blocked_by) — stored as 'related' for now
- [ ] Cross-project links stored as unresolved (target_entity_id NULL)
- [ ] Self-links skipped
- [ ] Migration **027** recreates pending_dependent_fetches with 'issue_links' in CHECK
- [ ] Migration registered in MIGRATIONS array in src/core/db.rs
- [ ] `lore show issue 123` shows related issues section
- [ ] `lore --robot show issue 123` includes related_issues in JSON
- [ ] Config gate: sync.fetchIssueLinks (default true, camelCase serde rename)
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes
- [ ] `cargo fmt --check` passes

## Files

- MODIFY: src/gitlab/client.rs (add fetch_issue_links)
- MODIFY: src/gitlab/types.rs (add GitLabIssueLink)
- CREATE: src/ingestion/issue_links.rs
- MODIFY: src/ingestion/mod.rs (add pub mod issue_links)
- MODIFY: src/ingestion/orchestrator.rs (enqueue + drain_issue_links)
- CREATE: migrations/027_issue_links_job_type.sql
- MODIFY: src/core/db.rs (add migration 027 to MIGRATIONS array)
- MODIFY: src/core/config.rs (add sync.fetchIssueLinks)
- MODIFY: src/cli/commands/show.rs (display related issues)

## TDD Anchor

RED:
- test_issue_link_deserialization (types.rs: deserialize GitLabIssueLink from JSON)
- test_store_issue_links_creates_bidirectional_references (in-memory DB, insert 2 issues, store link, verify 2 rows in entity_references)
- test_self_link_skipped (same issue_iid both sides, verify 0 rows)
- test_cross_project_link_unresolved (target not in DB, verify target_entity_id IS NULL)

GREEN: Implement API client, ingestion, migration, display.

VERIFY: cargo test --lib -- issue_links

## Edge Cases

- Cross-project links: target not in local DB -> unresolved reference (target_entity_id NULL)
- Self-links: skip entirely
- UNIQUE constraint on entity_references prevents duplicate refs on re-sync
- "blocks"/"is_blocked_by" semantics not modeled in entity_references yet — store as 'related'
- Table recreation migration: safe because pending_dependent_fetches is transient queue data that gets re-enqueued on next sync
- Recreated table must restore indexes: idx_pdf_job_type, idx_pdf_next_retry (check migration 011 for exact definitions)

## Dependency Context

- **entity_references** (migration 011): provides the target table. reference_type='related' already in CHECK.
- **dependent_queue.rs**: provides enqueue_job/claim_jobs/complete_job/fail_job lifecycle used by drain_issue_links()
- **orchestrator drain pattern**: drain_mr_closes_issues() (line 1254) is the closest template — fetch API data, insert entity_references, complete job


OBSERV: Implement MetricsLayer custom tracing subscriber layer

## Background
MetricsLayer is a custom tracing subscriber layer that records span timing and structured fields, then materializes them into Vec<StageTiming>. This avoids threading a mutable collector through every function signature -- spans are the single source of truth.

## Approach
Add to src/core/metrics.rs (same file as StageTiming):

```rust
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use tracing::span::{Attributes, Id, Record};
use tracing::Subscriber;
use tracing_subscriber::layer::{Context, Layer};
use tracing_subscriber::registry::LookupSpan;

#[derive(Debug)]
struct SpanData {
    name: String,
    parent_id: Option<Id>,
    start: Instant,
    fields: HashMap<String, serde_json::Value>,
}

#[derive(Debug, Clone)]
pub struct MetricsLayer {
    spans: Arc<Mutex<HashMap<u64, SpanData>>>,
    completed: Arc<Mutex<Vec<(u64, StageTiming)>>>,
}

impl MetricsLayer {
    pub fn new() -> Self {
        Self {
            spans: Arc::new(Mutex::new(HashMap::new())),
            completed: Arc::new(Mutex::new(Vec::new())),
        }
    }

    /// Extract timing tree for a completed run.
    /// Call this after the root span closes.
    pub fn extract_timings(&self) -> Vec<StageTiming> {
        let completed = self.completed.lock().unwrap();
        // Build tree: find root entries (no parent), attach children
        // ... tree construction logic
    }
}

impl<S> Layer<S> for MetricsLayer
where
    S: Subscriber + for<'a> LookupSpan<'a>,
{
    fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
        let parent_id = ctx.span(id).and_then(|s| s.parent().map(|p| p.id()));
        let mut fields = HashMap::new();
        // Visit attrs to capture initial field values
        let mut visitor = FieldVisitor(&mut fields);
        attrs.record(&mut visitor);

        self.spans.lock().unwrap().insert(id.into_u64(), SpanData {
            name: attrs.metadata().name().to_string(),
            parent_id,
            start: Instant::now(),
            fields,
        });
    }

    fn on_record(&self, id: &Id, values: &Record<'_>, _ctx: Context<'_, S>) {
        // Capture recorded fields (items_processed, items_skipped, errors)
        if let Some(data) = self.spans.lock().unwrap().get_mut(&id.into_u64()) {
            let mut visitor = FieldVisitor(&mut data.fields);
            values.record(&mut visitor);
        }
    }

    fn on_close(&self, id: Id, _ctx: Context<'_, S>) {
        if let Some(data) = self.spans.lock().unwrap().remove(&id.into_u64()) {
            let elapsed = data.start.elapsed();
            let timing = StageTiming {
                name: data.name,
                project: data.fields.get("project").and_then(|v| v.as_str()).map(String::from),
                elapsed_ms: elapsed.as_millis() as u64,
                items_processed: data.fields.get("items_processed").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
                items_skipped: data.fields.get("items_skipped").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
                errors: data.fields.get("errors").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
                sub_stages: vec![], // Will be populated during extract_timings tree construction
            };
            self.completed.lock().unwrap().push((id.into_u64(), timing));
        }
    }
}
```

Need a FieldVisitor struct implementing tracing::field::Visit to capture field values.

Register in subscriber stack (src/main.rs), alongside stderr and file layers:
```rust
let metrics_layer = MetricsLayer::new();
let metrics_handle = metrics_layer.clone(); // Clone Arc for later extraction

registry()
    .with(stderr_layer.with_filter(stderr_filter))
    .with(file_layer.with_filter(file_filter))
    .with(metrics_layer) // No filter -- captures all spans
    .init();
```

Pass metrics_handle to command handlers so they can call extract_timings() after the pipeline completes.

## Acceptance Criteria
- [ ] MetricsLayer captures span enter/close timing
- [ ] on_record captures items_processed, items_skipped, errors fields
- [ ] extract_timings() returns correctly nested Vec<StageTiming> tree
- [ ] Parallel spans (multiple projects) both appear as sub_stages of parent
- [ ] Thread-safe: Arc<Mutex<>> allows concurrent span operations
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/core/metrics.rs (add MetricsLayer, FieldVisitor, tree construction)
- src/main.rs (register MetricsLayer in subscriber stack)

## TDD Loop
RED:
  - test_metrics_layer_single_span: enter/exit one span, extract, assert one StageTiming
  - test_metrics_layer_nested_spans: parent + child, assert child in parent.sub_stages
  - test_metrics_layer_parallel_spans: two sibling spans, assert both in parent.sub_stages
  - test_metrics_layer_field_recording: record items_processed=42, assert captured
GREEN: Implement MetricsLayer with on_new_span, on_record, on_close, extract_timings
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- Span ID reuse: tracing may reuse span IDs after close. Using remove on close prevents stale data.
- Lock contention: Mutex per operation. For high-span-count scenarios, consider parking_lot::Mutex. But lore's span count is low (<100 per run), so std::sync::Mutex is fine.
- extract_timings tree construction: iterate completed Vec, build parent->children map, then recursively construct StageTiming tree. Root entries have parent_id matching the root span or None.
- MetricsLayer has no filter: it sees ALL spans. To avoid noise from dependency spans, check if span name starts with known stage names, or rely on the "stage" field being present.

Implemented MetricsLayer custom tracing subscriber layer with span timing capture, rate-limit/retry event detection, tree extraction, and 12 unit tests


Implement MR transformer

## Background
Transforms GitLab MR API responses into normalized schema for database storage. Handles deprecated field fallbacks and extracts metadata (labels, assignees, reviewers).

## Approach
Create new transformer module following existing issue transformer pattern:
- `NormalizedMergeRequest` - Database-ready struct
- `MergeRequestWithMetadata` - MR + extracted labels/assignees/reviewers
- `transform_merge_request()` - Main transformation function
- `extract_labels()` - Label extraction helper

## Files
- `src/gitlab/transformers/merge_request.rs` - New transformer module
- `src/gitlab/transformers/mod.rs` - Export new module
- `tests/mr_transformer_tests.rs` - Unit tests

## Acceptance Criteria
- [ ] `NormalizedMergeRequest` struct exists with all DB columns
- [ ] `MergeRequestWithMetadata` contains MR + label_names + assignee_usernames + reviewer_usernames
- [ ] `transform_merge_request()` returns `Result<MergeRequestWithMetadata, String>`
- [ ] `draft` computed as `gitlab_mr.draft || gitlab_mr.work_in_progress`
- [ ] `detailed_merge_status` prefers `detailed_merge_status` over `merge_status_legacy`
- [ ] `merge_user_username` prefers `merge_user` over `merged_by`
- [ ] `head_sha` extracted from `sha` field
- [ ] `references_short` and `references_full` extracted from `references` Option
- [ ] Timestamps parsed with `iso_to_ms()`, errors returned (not zeroed)
- [ ] `last_seen_at` set to `now_ms()`
- [ ] `cargo test mr_transformer` passes

## TDD Loop
RED: `cargo test mr_transformer` -> module not found
GREEN: Add transformer with all fields
VERIFY: `cargo test mr_transformer`

## Struct Definitions
```rust
#[derive(Debug, Clone)]
pub struct NormalizedMergeRequest {
    pub gitlab_id: i64,
    pub project_id: i64,
    pub iid: i64,
    pub title: String,
    pub description: Option<String>,
    pub state: String,
    pub draft: bool,
    pub author_username: String,
    pub source_branch: String,
    pub target_branch: String,
    pub head_sha: Option<String>,
    pub references_short: Option<String>,
    pub references_full: Option<String>,
    pub detailed_merge_status: Option<String>,
    pub merge_user_username: Option<String>,
    pub created_at: i64,
    pub updated_at: i64,
    pub merged_at: Option<i64>,
    pub closed_at: Option<i64>,
    pub last_seen_at: i64,
    pub web_url: String,
}

#[derive(Debug, Clone)]
pub struct MergeRequestWithMetadata {
    pub merge_request: NormalizedMergeRequest,
    pub label_names: Vec<String>,
    pub assignee_usernames: Vec<String>,
    pub reviewer_usernames: Vec<String>,
}
```

## Function Signature
```rust
pub fn transform_merge_request(
    gitlab_mr: &GitLabMergeRequest,
    local_project_id: i64,
) -> Result<MergeRequestWithMetadata, String>
```

## Key Logic
```rust
// Draft: prefer draft, fallback to work_in_progress
let is_draft = gitlab_mr.draft || gitlab_mr.work_in_progress;

// Merge status: prefer detailed_merge_status
let detailed_merge_status = gitlab_mr.detailed_merge_status
    .clone()
    .or_else(|| gitlab_mr.merge_status_legacy.clone());

// Merge user: prefer merge_user
let merge_user_username = gitlab_mr.merge_user
    .as_ref()
    .map(|u| u.username.clone())
    .or_else(|| gitlab_mr.merged_by.as_ref().map(|u| u.username.clone()));

// References extraction
let (references_short, references_full) = gitlab_mr.references
    .as_ref()
    .map(|r| (Some(r.short.clone()), Some(r.full.clone())))
    .unwrap_or((None, None));

// Head SHA
let head_sha = gitlab_mr.sha.clone();
```

## Edge Cases
- Invalid timestamps should return `Err`, not zero values
- Empty labels/assignees/reviewers should return empty Vecs, not None
- `state` must pass through as-is (including "locked")


WHO: Migration 017 — composite indexes for query paths

## Background

With 280K notes, the path/timestamp queries for lore who will degrade without composite indexes. Existing indexes cover note_type and position_new_path separately (migration 006) but not as composites aligned to the who query patterns. This is a non-breaking, additive-only migration.

## Approach

Add as entry 17 (index 16) in the MIGRATIONS array in src/core/db.rs. LATEST_SCHEMA_VERSION auto-updates via MIGRATIONS.len() as i32.

### Exact SQL for the migration entry:

```sql
-- Migration 017: Composite indexes for who query paths

-- Expert/Overlap: DiffNote path prefix + timestamp filter.
-- Leading with position_new_path (not note_type) because the partial index
-- predicate already handles the constant filter.
CREATE INDEX IF NOT EXISTS idx_notes_diffnote_path_created
    ON notes(position_new_path, created_at, project_id)
    WHERE note_type = 'DiffNote' AND is_system = 0;

-- Active/Workload: discussion participation lookups.
CREATE INDEX IF NOT EXISTS idx_notes_discussion_author
    ON notes(discussion_id, author_username)
    WHERE is_system = 0;

-- Active (project-scoped): unresolved discussions by recency.
CREATE INDEX IF NOT EXISTS idx_discussions_unresolved_recent
    ON discussions(project_id, last_note_at)
    WHERE resolvable = 1 AND resolved = 0;

-- Active (global): unresolved discussions by recency (no project scope).
-- Without this, (project_id, last_note_at) can't satisfy ORDER BY last_note_at DESC
-- efficiently when project_id is unconstrained.
CREATE INDEX IF NOT EXISTS idx_discussions_unresolved_recent_global
    ON discussions(last_note_at)
    WHERE resolvable = 1 AND resolved = 0;

-- Workload: issue assignees by username.
CREATE INDEX IF NOT EXISTS idx_issue_assignees_username
    ON issue_assignees(username, issue_id);
```

### Not added (already adequate):
- merge_requests(author_username) — idx_mrs_author (migration 006)
- mr_reviewers(username) — idx_mr_reviewers_username (migration 006)
- notes(discussion_id) — idx_notes_discussion (migration 002)

## Files

- `src/core/db.rs` — append to MIGRATIONS array as entry index 16

## TDD Loop

RED: `cargo test -- test_migration` (existing migration tests should still pass)
GREEN: Add the migration SQL string to the array
VERIFY: `cargo test && cargo check --all-targets`

## Acceptance Criteria

- [ ] MIGRATIONS array has 17 entries (index 0-16)
- [ ] LATEST_SCHEMA_VERSION is 17
- [ ] cargo test passes (in-memory DB runs all migrations including 017)
- [ ] No existing index names conflict

## Edge Cases

- The SQL uses CREATE INDEX IF NOT EXISTS — safe for idempotent reruns
- Partial indexes (WHERE clause) keep index size small: ~33K of 280K notes for DiffNote index

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


Implement Dashboard state + action + view

## Background
The Dashboard is the home screen — first thing users see. It shows entity counts, per-project sync status, recent activity, and a last-sync summary. Data comes from aggregation queries against the local SQLite database.

## Approach
State (state/dashboard.rs):
- DashboardState: counts (EntityCounts), projects (Vec<ProjectSyncInfo>), recent (Vec<RecentActivityItem>), last_sync (LastSyncInfo)
- EntityCounts: issues_open, issues_total, mrs_open, mrs_total, discussions, notes_total, notes_system_pct, documents, embeddings
- ProjectSyncInfo: path (String), minutes_since_sync (u64)
- RecentActivityItem: entity_type, iid, title, state, minutes_ago
- update(data: DashboardData) method

Action (action.rs):
- fetch_dashboard(conn: &Connection, clock: &dyn Clock) -> Result<DashboardData, LoreError>: runs aggregation queries for counts, recent activity, project sync status. Uses clock.now() for relative time calculations.

View (view/dashboard.rs):
- render_dashboard(frame, state: &DashboardState, area: Rect, theme: &Theme): responsive layout with breakpoints
  - Wide (>=120 cols): 3-column: [Stats | Projects | Recent]
  - Medium (80-119): 2-column: [Stats+Projects | Recent]
  - Narrow (<80): single column stacked
- render_stat_panel(): entity counts with colored numbers
- render_project_list(): project names with sync staleness indicators
- render_recent_activity(): scrollable list of recent changes
- render_sync_summary(): last sync stats (if available)

## Acceptance Criteria
- [ ] DashboardState stores counts, projects, recent activity, last sync info
- [ ] fetch_dashboard returns correct counts from DB
- [ ] Dashboard renders with responsive breakpoints (3/2/1 column layouts)
- [ ] Entity counts show open/total for issues and MRs
- [ ] Project list shows sync staleness with color coding (green <1h, yellow <6h, red >6h)
- [ ] Recent activity list is scrollable with j/k
- [ ] Relative timestamps use injected Clock (not wall-clock)

## Files
- MODIFY: crates/lore-tui/src/state/dashboard.rs (expand from stub)
- MODIFY: crates/lore-tui/src/action.rs (add fetch_dashboard)
- CREATE: crates/lore-tui/src/view/dashboard.rs

## TDD Anchor
RED: Write test_fetch_dashboard_counts in action.rs that creates in-memory DB with 5 issues (3 open, 2 closed), calls fetch_dashboard, asserts issues_open=3, issues_total=5.
GREEN: Implement fetch_dashboard with COUNT queries.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_fetch_dashboard

## Edge Cases
- Empty database (first launch before sync): all counts should be 0, no crash
- Very long project paths: truncate to fit column width
- notes_system_pct: compute as (system_notes * 100 / total_notes), handle division by zero
- Clock injection ensures snapshot tests are deterministic (no "3 minutes ago" changing between runs)

## Dependency Context
Uses AppState, DashboardState, LoadState from "Implement AppState composition" task.
Uses DbManager from "Implement DbManager" task.
Uses Clock from "Implement Clock trait" task.
Uses theme from "Implement theme configuration" task.
Uses render_screen routing from "Implement common widgets" task.


Create golden query test suite

## Background
Golden query tests verify end-to-end search quality with known-good expected results. They use a seeded SQLite DB with deterministic fixture data and fixed embedding vectors (no Ollama dependency). Each test query must return at least one expected URL in the top 10 results. These tests catch search regressions (ranking changes, filter bugs, missing results).

## Approach
Create test infrastructure:

**1. tests/fixtures/golden_queries.json:**
```json
[
  {
    "query": "authentication login",
    "mode": "lexical",
    "filters": {},
    "expected_urls": ["https://gitlab.example.com/group/project/-/issues/234"],
    "min_results": 1,
    "max_rank": 10
  },
  {
    "query": "jwt token refresh",
    "mode": "hybrid",
    "filters": {"type": "merge_request"},
    "expected_urls": ["https://gitlab.example.com/group/project/-/merge_requests/456"],
    "min_results": 1,
    "max_rank": 10
  }
]
```

**2. Test harness (tests/golden_query_tests.rs):**
- Load golden_queries.json
- Create in-memory DB, apply all migrations
- Seed with deterministic fixture documents (issues, MRs, discussions)
- For hybrid/semantic queries: seed with fixed embedding vectors (768-dim, manually constructed for known similarity)
- For each query: run search, verify expected URL in top N results

**Fixture data design:**
- 10-20 documents covering different source types
- Known content that matches expected queries
- Fixed embeddings: construct vectors where similar documents have small cosine distance
- No randomness — fully deterministic

## Acceptance Criteria
- [ ] Golden queries file exists with at least 5 test queries
- [ ] Test harness loads queries and validates each
- [ ] All golden queries pass: expected URL in top 10
- [ ] No external dependencies (no Ollama, no GitLab)
- [ ] Deterministic fixture data (fixed embeddings, fixed content)
- [ ] `cargo test --test golden_query_tests` passes in CI

## Files
- `tests/fixtures/golden_queries.json` — new file
- `tests/golden_query_tests.rs` — new file (or tests/golden_queries.rs)

## TDD Loop
RED: Create golden_queries.json with expected results, harness fails (no fixture data)
GREEN: Seed fixture data that satisfies expected results
VERIFY: `cargo test --test golden_query_tests`

## Edge Cases
- Query matches multiple expected URLs: all must be present
- Lexical queries: FTS ranking determines position, not vector
- Hybrid queries: RRF combines both signals — fixed vectors must be designed to produce expected ranking
- Empty result for a golden query: test failure with clear message showing actual results

Golden query test suite: 7 golden queries in fixture, 8 seeded documents, 2 test functions (all_pass + fixture_valid), deterministic in-memory DB, no external deps. 312 total tests pass.


[CP1] Discussion and note transformers

Transform GitLab discussion/note payloads to normalized database schema.

Functions to implement:
- transformDiscussion(gitlabDiscussion, localProjectId, localIssueId) → NormalizedDiscussion
- transformNotes(gitlabDiscussion, localProjectId) → NormalizedNote[]

Transformation rules:
- Compute first_note_at/last_note_at from notes array
- Compute resolvable/resolved status from notes
- Set is_system from note.system
- Preserve note order via position (array index)
- Convert ISO timestamps to ms epoch

Files: src/gitlab/transformers/discussion.ts
Tests: tests/unit/discussion-transformer.test.ts
Done when: Unit tests pass for discussion/note transformation with system note flagging


Final validation and test coverage

## Background
Final validation gate ensuring all CP2 features work correctly. Verifies tests, lint, and manual smoke tests pass.

## Approach
Run comprehensive validation:
1. Automated tests (unit + integration)
2. Clippy and formatting
3. Critical test case verification
4. Gate A/B/C/D/E checklist
5. Manual smoke tests

## Files
None - validation only

## Acceptance Criteria
- [ ] `cargo test` passes (all tests green)
- [ ] `cargo test --release` passes
- [ ] `cargo clippy -- -D warnings` passes (zero warnings)
- [ ] `cargo fmt --check` passes
- [ ] Critical tests pass (see list below)
- [ ] Gate A/B/C/D/E verification complete
- [ ] Manual smoke tests pass

## Validation Commands
```bash
# 1. Build and test
cargo build --release
cargo test --release

# 2. Lint
cargo clippy -- -D warnings
cargo fmt --check

# 3. Run specific critical tests
cargo test does_not_advance_discussion_watermark_on_partial_failure
cargo test prefers_detailed_merge_status_when_both_fields_present
cargo test prefers_merge_user_when_both_fields_present
cargo test prefers_draft_when_both_draft_and_work_in_progress_present
cargo test atomic_note_replacement_preserves_data_on_parse_failure
cargo test full_sync_resets_discussion_watermarks
```

## Critical Test Cases
| Test | What It Verifies |
|------|------------------|
| `does_not_advance_discussion_watermark_on_partial_failure` | Pagination failure doesn't lose data |
| `prefers_detailed_merge_status_when_both_fields_present` | Non-deprecated field wins |
| `prefers_merge_user_when_both_fields_present` | Non-deprecated field wins |
| `prefers_draft_when_both_draft_and_work_in_progress_present` | OR semantics for draft |
| `atomic_note_replacement_preserves_data_on_parse_failure` | Parse before delete |
| `full_sync_resets_discussion_watermarks` | --full truly refreshes |

## Gate Checklist

### Gate A: MRs Only
- [ ] `gi ingest --type=merge_requests` fetches all MRs
- [ ] MR state supports: opened, merged, closed, locked
- [ ] draft field captured with work_in_progress fallback
- [ ] detailed_merge_status used with merge_status fallback
- [ ] head_sha and references captured
- [ ] Cursor-based sync is resumable

### Gate B: Labels + Assignees + Reviewers
- [ ] Labels linked via mr_labels junction
- [ ] Stale labels removed on resync
- [ ] Assignees linked via mr_assignees
- [ ] Reviewers linked via mr_reviewers

### Gate C: Dependent Discussion Sync
- [ ] Discussions fetched for MRs with updated_at advancement
- [ ] DiffNote position metadata captured
- [ ] DiffNote SHA triplet captured
- [ ] Upsert + sweep pattern for notes
- [ ] Watermark NOT advanced on partial failure
- [ ] Unchanged MRs skip discussion refetch

### Gate D: Resumability Proof
- [ ] Kill mid-run, rerun -> bounded redo
- [ ] `--full` resets cursor AND discussion watermarks
- [ ] Single-flight lock prevents concurrent runs

### Gate E: CLI Complete
- [ ] `gi list mrs` with all filters including --draft/--no-draft
- [ ] `gi show mr <iid>` with discussions and DiffNote context
- [ ] `gi count mrs` with state breakdown
- [ ] `gi sync-status` shows MR cursors

## Manual Smoke Tests
| Command | Expected |
|---------|----------|
| `gi ingest --type=merge_requests` | Completes, shows counts |
| `gi list mrs --limit=10` | Shows 10 MRs with correct columns |
| `gi list mrs --state=merged` | Only merged MRs |
| `gi list mrs --draft` | Only draft MRs with [DRAFT] prefix |
| `gi show mr <iid>` | Full detail with discussions |
| `gi count mrs` | Count with state breakdown |
| Re-run ingest | "0 new MRs", skipped discussion count |
| `gi ingest --type=merge_requests --full` | Full resync |

## Data Integrity Checks
```sql
-- MR count matches GitLab
SELECT COUNT(*) FROM merge_requests;

-- Every MR has raw payload
SELECT COUNT(*) FROM merge_requests WHERE raw_payload_id IS NULL;
-- Should be 0

-- Labels linked correctly
SELECT m.iid, COUNT(ml.label_id)
FROM merge_requests m
LEFT JOIN mr_labels ml ON ml.merge_request_id = m.id
GROUP BY m.id;

-- DiffNotes have position metadata
SELECT COUNT(*) FROM notes WHERE position_new_path IS NOT NULL;
```


Implement document types and extractor module

## Background
The document types module is the foundational data layer for all document operations. It defines SourceType (the enum used everywhere to identify entity types), DocumentData (the struct passed between extractors, regenerator, and storage), and hash functions (for change detection that skips unchanged documents). This bead has 7 downstream dependents — it's the most-depended-on implementation bead.

## Approach
Create `src/documents/` module with `mod.rs` and `extractor.rs`.

**src/documents/mod.rs:**
```rust
mod extractor;
mod regenerator;   // placeholder: pub mod added by later bead
mod truncation;    // placeholder: pub mod added by later bead

pub use extractor::{
    extract_discussion_document, extract_issue_document, extract_mr_document,
    DocumentData, SourceType, compute_content_hash, compute_list_hash,
};
pub use regenerator::regenerate_dirty_documents;
pub use truncation::{truncate_content, TruncationResult};
```

**src/documents/extractor.rs — types only (this bead):**
- `SourceType` enum with `Issue`, `MergeRequest`, `Discussion` variants
- `SourceType::as_str()`, `SourceType::parse()` (accepts aliases), `Display` impl
- `DocumentData` struct with all fields per PRD Section 2.2
- `compute_content_hash(content: &str) -> String` using SHA-256
- `compute_list_hash(items: &[String]) -> String` using sorted join + SHA-256

**Dependencies:** Add `sha2 = "0.10"` to Cargo.toml if not already present.

Note: The `extract_*_document()` functions are stub signatures in this bead. Their implementations are in separate beads (bd-247, bd-1yz, bd-2fp).

**src/lib.rs:** Add `pub mod documents;`

## Acceptance Criteria
- [ ] `SourceType::parse("issue")` returns `Some(Issue)`
- [ ] `SourceType::parse("mr")` returns `Some(MergeRequest)` (alias)
- [ ] `SourceType::parse("mrs")` returns `Some(MergeRequest)` (alias)
- [ ] `SourceType::parse("merge_request")` returns `Some(MergeRequest)`
- [ ] `SourceType::parse("discussion")` returns `Some(Discussion)`
- [ ] `SourceType::parse("invalid")` returns `None`
- [ ] `SourceType::as_str()` returns "issue", "merge_request", "discussion"
- [ ] `compute_content_hash("hello")` returns deterministic SHA-256 hex string
- [ ] `compute_list_hash(["b", "a"])` == `compute_list_hash(["a", "b"])` (sorted)
- [ ] `DocumentData` struct compiles with all fields per PRD
- [ ] `cargo build` succeeds
- [ ] `cargo test documents` passes

## Files
- `src/documents/mod.rs` — new file (module root with pub use)
- `src/documents/extractor.rs` — new file (types + hash functions)
- `src/documents/regenerator.rs` — new file (placeholder, empty module or todo!())
- `src/documents/truncation.rs` — new file (placeholder, empty module or todo!())
- `src/lib.rs` — add `pub mod documents;`
- `Cargo.toml` — add `sha2 = "0.10"` if not present

## TDD Loop
RED: Tests in `extractor.rs` `#[cfg(test)] mod tests`:
- `test_source_type_parse_aliases` — all parse variants
- `test_source_type_as_str` — roundtrip as_str
- `test_content_hash_deterministic` — same input = same hash
- `test_list_hash_order_independent` — sorted before hashing
- `test_list_hash_empty` — empty vec produces consistent hash
GREEN: Implement types and hash functions
VERIFY: `cargo test documents`

## Edge Cases
- Empty string content hash: must produce valid SHA-256 (not panic)
- Empty labels list: compute_list_hash returns hash of empty string
- SourceType Display shows snake_case (same as as_str)

Completed: SourceType enum with parse/as_str/Display, DocumentData struct, compute_content_hash, compute_list_hash, 8 tests pass


OBSERV: Generate run_id at command entry in main.rs

## Background
Every sync/ingest run needs a unique correlation ID so log lines, database records, and robot JSON can be linked. The uuid crate (v1, v4 feature) is already in Cargo.toml (line ~48). Using first 8 chars of UUIDv4 gives ~4 billion unique values.

## Approach
In src/main.rs, after CLI parsing (line ~60) and before command dispatch (line ~73), generate run_id:

```rust
let run_id = uuid::Uuid::new_v4().to_string();
let run_id = &run_id[..8]; // First 8 hex chars: e.g., "a1b2c3d4"
```

Pass run_id to command handlers that need it (sync, ingest). This requires adding a run_id parameter to handle_sync_cmd() and handle_ingest(). Other commands (doctor, list, show, etc.) don't need correlation IDs.

Alternative: generate run_id inside each command handler instead of main(). This avoids changing signatures of commands that don't need it. Prefer this approach -- generate inside run_sync() and run_ingest() directly.

Preferred approach (generate in command handlers):
```rust
// In src/cli/commands/sync.rs run_sync():
let run_id = &uuid::Uuid::new_v4().to_string()[..8];

// In src/cli/commands/ingest.rs run_ingest():
let run_id = &uuid::Uuid::new_v4().to_string()[..8];
```

This keeps main.rs clean and only adds run_id where it's used.

## Acceptance Criteria
- [ ] run_id is generated for every sync and ingest invocation
- [ ] run_id is exactly 8 characters, all hex (0-9, a-f)
- [ ] run_id is unique across invocations (probabilistic, ~4 billion space)
- [ ] No new dependencies needed (uuid already present)
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/cli/commands/sync.rs (generate run_id in run_sync)
- src/cli/commands/ingest.rs (generate run_id in run_ingest)

## TDD Loop
RED:
  - test_run_id_format: generate 100 run_ids, assert each is 8 chars, all hex
  - test_run_id_uniqueness: generate 1000 run_ids, assert no duplicates
GREEN: Add run_id generation to run_sync and run_ingest
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- UUID string format: Uuid::new_v4().to_string() produces "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx". First 8 chars are always hex (no hyphens). Safe to slice.
- &run_id[..8] on a String: this is a byte slice on ASCII chars, always valid UTF-8. No panic risk.

Generated run_id via uuid::Uuid::new_v4().simple() at entry of run_sync and run_ingest, truncated to 8 hex chars


[CP0] gi init command - interactive setup wizard

## Background

The init command is the user's first interaction with gi. It must guide them through setup, validate everything works before writing config, and leave the system in a ready-to-use state. Poor UX here will frustrate new users.

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

## Approach

**src/cli/commands/init.ts:**

Interactive flow (using inquirer):
1. Check if config exists at target path
   - If exists and no --force: prompt "Config exists. Overwrite? [y/N]"
   - If --non-interactive and config exists: exit 2
2. Prompt for GitLab base URL (validate URL format)
3. Prompt for token env var name (default: GITLAB_TOKEN)
4. Check token is set in environment
   - If not set: exit 1 with "Export GITLAB_TOKEN first"
5. Test auth with GET /api/v4/user
   - If 401: exit 1 with "Authentication failed"
   - Show "Authenticated as @username (Display Name)"
6. Prompt for project paths (comma-separated or add one at a time)
7. Validate each project with GET /api/v4/projects/:encoded_path
   - If 404: exit 1 with "Project not found: group/project"
   - Show "✓ group/project (Project Name)"
8. Write config.json to target path
9. Initialize database with migrations
10. Insert validated projects into projects table
11. Show "Setup complete! Run 'gi doctor' to verify."

**Flags:**
- `--config <path>`: Write config to specific path
- `--force`: Skip overwrite confirmation
- `--non-interactive`: Fail if prompts would be shown (for CI/scripting)

## Acceptance Criteria

- [ ] Creates config.json with valid structure
- [ ] Validates GitLab URL is reachable before writing config
- [ ] Validates token with GET /api/v4/user before writing config
- [ ] Validates each project path exists in GitLab before writing config
- [ ] Fails with exit 1 if token not set in environment
- [ ] Fails with exit 1 if GitLab auth fails
- [ ] Fails with exit 1 if any project not found
- [ ] Prompts before overwriting existing config (unless --force)
- [ ] --force skips overwrite confirmation
- [ ] --non-interactive fails if prompts would be shown
- [ ] Creates data directory and applies DB migrations
- [ ] Inserts validated projects into projects table
- [ ] tests/integration/init.test.ts passes (11 tests)

## Files

CREATE:
- src/cli/commands/init.ts
- tests/integration/init.test.ts

## TDD Loop

RED:
```typescript
// tests/integration/init.test.ts
describe('gi init', () => {
  it('creates config file with valid structure')
  it('validates GitLab URL format')
  it('validates GitLab connection before writing config')
  it('validates each project path exists in GitLab')
  it('fails if token not set')
  it('fails if GitLab auth fails')
  it('fails if any project path not found')
  it('prompts before overwriting existing config')
  it('respects --force to skip confirmation')
  it('generates config with sensible defaults')
  it('creates data directory if missing')
})
```

GREEN: Implement init.ts

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

## Edge Cases

- User cancels at any prompt: exit 2 (user cancelled)
- Network error during validation: show specific error, exit 1
- Token has wrong scopes (no read_api): auth succeeds but project fetch fails
- Project path with special characters must be URL-encoded
- Config directory might not exist - create with mkdirSync recursive
- --non-interactive with missing env var should fail immediately


Implement CommandRegistry (keybindings, help, palette)

## Background
CommandRegistry is the single source of truth for all actions, keybindings, CLI equivalents, palette entries, help text, and status hints. All keybinding/help/status/palette definitions are generated from this registry — no hardcoded duplicate maps in view/state modules.

## Approach
Create crates/lore-tui/src/commands.rs:
- CommandDef struct: id (String), label (String), keybinding (Option<KeyCombo>), cli_equivalent (Option<String>), help_text (String), status_hint (String), available_in (Vec<Screen> or ScreenFilter)
- CommandRegistry struct: commands (Vec<CommandDef>), by_key (HashMap<KeyCombo, Vec<CommandId>>), by_screen (HashMap<Screen, Vec<CommandId>>)
- build_registry() -> CommandRegistry: registers all commands with their keybindings
- lookup_key(key: &KeyEvent, screen: &Screen, mode: &InputMode) -> Option<&CommandDef>
- palette_entries(screen: &Screen) -> Vec<&CommandDef>: returns commands available for palette
- help_entries(screen: &Screen) -> Vec<&CommandDef>: returns commands for help overlay
- status_hints(screen: &Screen) -> Vec<&str>: returns hints for status bar

## Acceptance Criteria
- [ ] CommandRegistry is the sole source of keybinding definitions
- [ ] lookup_key respects InputMode (no keybinding leaks through Text mode)
- [ ] palette_entries returns commands sorted by label
- [ ] help_entries returns all commands available on a given screen
- [ ] status_hints returns context-appropriate hints
- [ ] cli_equivalent populated for commands that have a lore CLI counterpart

## Files
- CREATE: crates/lore-tui/src/commands.rs

## TDD Anchor
RED: Write test_registry_lookup_quit that builds registry, looks up 'q' in Normal mode on Dashboard, asserts it maps to Quit command.
GREEN: Implement build_registry with quit command registered.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_registry

## Edge Cases
- g-prefix keybindings (gi, gm, g/, gt, gw, gs) require two-key sequences — registry must support this
- Command availability varies by screen — lookup must check available_in filter
- InputMode::Text should block all normal keybindings except Esc and Ctrl+P


Implement dirty source tracking module

## Background
Dirty source tracking drives incremental document regeneration. When entities are upserted during ingestion, they're marked dirty. The regenerator drains this queue. The key constraint: mark_dirty_tx() takes &Transaction to enforce atomic marking inside the entity upsert transaction. Uses ON CONFLICT DO UPDATE (not INSERT OR IGNORE) to reset backoff on re-queue.

## Approach
Create \`src/ingestion/dirty_tracker.rs\` per PRD Section 6.1.

```rust
const DIRTY_SOURCES_BATCH_SIZE: usize = 500;

/// Mark dirty INSIDE existing transaction. Takes &Transaction, NOT &Connection.
/// ON CONFLICT resets ALL backoff/error state (not INSERT OR IGNORE).
/// This ensures fresh updates are immediately eligible, not stuck behind stale backoff.
pub fn mark_dirty_tx(
    tx: &rusqlite::Transaction<'_>,
    source_type: SourceType,
    source_id: i64,
) -> Result<()>;

/// Convenience wrapper for non-transactional contexts.
pub fn mark_dirty(conn: &Connection, source_type: SourceType, source_id: i64) -> Result<()>;

/// Get dirty sources ready for processing.
/// WHERE next_attempt_at IS NULL OR next_attempt_at <= now
/// ORDER BY attempt_count ASC, queued_at ASC  (failed items deprioritized)
/// LIMIT 500
pub fn get_dirty_sources(conn: &Connection) -> Result<Vec<(SourceType, i64)>>;

/// Clear dirty entry after successful processing.
pub fn clear_dirty(conn: &Connection, source_type: SourceType, source_id: i64) -> Result<()>;
```

**PRD-specific details:**
- get_dirty_sources ORDER BY: \`attempt_count ASC, queued_at ASC\` (failed items processed AFTER fresh items)
- mark_dirty_tx ON CONFLICT resets: queued_at, attempt_count=0, last_attempt_at=NULL, last_error=NULL, next_attempt_at=NULL
- SourceType parsed from string in query results via match on \"issue\"/\"merge_request\"/\"discussion\"
- Invalid source_type in DB -> rusqlite::Error::FromSqlConversionFailure

**Error recording is in regenerator.rs (bd-1u1)**, not dirty_tracker. The dirty_tracker only marks, gets, and clears.

## Acceptance Criteria
- [ ] mark_dirty_tx takes &Transaction<'_>, NOT &Connection
- [ ] ON CONFLICT DO UPDATE resets: attempt_count=0, next_attempt_at=NULL, last_error=NULL, last_attempt_at=NULL
- [ ] Uses ON CONFLICT DO UPDATE, NOT INSERT OR IGNORE (PRD explains why)
- [ ] get_dirty_sources WHERE next_attempt_at IS NULL OR <= now
- [ ] get_dirty_sources ORDER BY attempt_count ASC, queued_at ASC
- [ ] get_dirty_sources LIMIT 500
- [ ] get_dirty_sources returns Vec<(SourceType, i64)>
- [ ] clear_dirty DELETEs entry
- [ ] Queue drains completely when called in loop
- [ ] \`cargo test dirty_tracker\` passes

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

## TDD Loop
RED: Tests:
- \`test_mark_dirty_tx_inserts\` — entry appears in dirty_sources
- \`test_requeue_resets_backoff\` — mark, simulate error state, re-mark -> attempt_count=0, next_attempt_at=NULL
- \`test_get_respects_backoff\` — entry with future next_attempt_at not returned
- \`test_get_orders_by_attempt_count\` — fresh items before failed items
- \`test_batch_size_500\` — insert 600, get returns 500
- \`test_clear_removes\` — entry gone after clear
- \`test_drain_loop\` — insert 1200, loop 3 times = empty
GREEN: Implement all functions
VERIFY: \`cargo test dirty_tracker\`

## Edge Cases
- Empty queue: get returns empty Vec
- Invalid source_type string in DB: FromSqlConversionFailure error
- Concurrent mark + get: ON CONFLICT handles race condition

Implemented dirty_tracker with mark_dirty_tx, get_dirty_sources, clear_dirty, record_dirty_error + 8 tests


## Background

Test fixtures provide mocked GitLab API responses for unit and integration tests. They enable testing without a live GitLab instance and ensure consistent test data across runs.

## Approach

### Fixture Files

Create JSON fixtures that match GitLab API response shapes:

```
tests/fixtures/
├── gitlab_issue.json           # Single issue
├── gitlab_issues_page.json     # Array of issues (pagination test)
├── gitlab_discussion.json      # Single discussion with notes
└── gitlab_discussions_page.json # Array of discussions
```

### gitlab_issue.json

```json
{
  "id": 12345,
  "iid": 42,
  "project_id": 100,
  "title": "Test issue title",
  "description": "Test issue description",
  "state": "opened",
  "created_at": "2024-01-15T10:00:00.000Z",
  "updated_at": "2024-01-20T15:30:00.000Z",
  "closed_at": null,
  "author": {
    "id": 1,
    "username": "testuser",
    "name": "Test User"
  },
  "labels": ["bug", "priority::high"],
  "web_url": "https://gitlab.example.com/group/project/-/issues/42"
}
```

### gitlab_discussion.json

```json
{
  "id": "6a9c1750b37d513a43987b574953fceb50b03ce7",
  "individual_note": false,
  "notes": [
    {
      "id": 1001,
      "type": "DiscussionNote",
      "body": "First comment in thread",
      "author": { "id": 1, "username": "testuser", "name": "Test User" },
      "created_at": "2024-01-16T09:00:00.000Z",
      "updated_at": "2024-01-16T09:00:00.000Z",
      "system": false,
      "resolvable": true,
      "resolved": false,
      "resolved_by": null,
      "resolved_at": null,
      "position": null
    },
    {
      "id": 1002,
      "type": "DiscussionNote",
      "body": "Reply to first comment",
      "author": { "id": 2, "username": "reviewer", "name": "Reviewer" },
      "created_at": "2024-01-16T10:00:00.000Z",
      "updated_at": "2024-01-16T10:00:00.000Z",
      "system": false,
      "resolvable": true,
      "resolved": false,
      "resolved_by": null,
      "resolved_at": null,
      "position": null
    }
  ]
}
```

### Helper Module

```rust
// tests/fixtures/mod.rs

pub fn load_fixture<T: DeserializeOwned>(name: &str) -> T {
    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests/fixtures")
        .join(name);
    let content = std::fs::read_to_string(&path)
        .expect(&format!("Failed to read fixture: {}", name));
    serde_json::from_str(&content)
        .expect(&format!("Failed to parse fixture: {}", name))
}

pub fn gitlab_issue() -> GitLabIssue {
    load_fixture("gitlab_issue.json")
}

pub fn gitlab_issues_page() -> Vec<GitLabIssue> {
    load_fixture("gitlab_issues_page.json")
}

pub fn gitlab_discussion() -> GitLabDiscussion {
    load_fixture("gitlab_discussion.json")
}
```

## Acceptance Criteria

- [ ] gitlab_issue.json deserializes to GitLabIssue correctly
- [ ] gitlab_issues_page.json contains 3+ issues for pagination tests
- [ ] gitlab_discussion.json contains multi-note thread
- [ ] gitlab_discussions_page.json contains mix of individual_note true/false
- [ ] At least one fixture includes system: true note
- [ ] Helper functions load fixtures without panic

## Files

- tests/fixtures/gitlab_issue.json (create)
- tests/fixtures/gitlab_issues_page.json (create)
- tests/fixtures/gitlab_discussion.json (create)
- tests/fixtures/gitlab_discussions_page.json (create)
- tests/fixtures/mod.rs (create)

## TDD Loop

RED:
```rust
#[test] fn fixture_gitlab_issue_deserializes()
#[test] fn fixture_gitlab_discussion_deserializes()
#[test] fn fixture_has_system_note()
```

GREEN: Create JSON fixtures and helper module

VERIFY: `cargo test fixture`

## Edge Cases

- Include issue with empty labels array
- Include issue with null description
- Include system note (system: true)
- Include individual_note: true discussion (standalone comment)
- Timestamps must be valid ISO 8601

Created 4 JSON fixture files (issue, issues_page, discussion, discussions_page) with helper tests - 6 tests passing


Phase 3a: Cargo.toml + rust-toolchain.toml dependency swap

## What
Swap runtime and HTTP client dependencies in Cargo.toml. Finalize rust-toolchain.toml.

## Cargo.toml Changes

### Remove from [dependencies]:
```toml
reqwest = { version = "0.12", features = ["json"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "signal"] }
```

### Add to [dependencies]:
```toml
asupersync = { version = "0.2", features = ["tls", "tls-native-roots"] }
```

### Keep unchanged:
```toml
async-stream = "0.3"
futures = { version = "0.3", default-features = false, features = ["alloc"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
urlencoding = "2"
```

### Add/update [dev-dependencies]:
```toml
tokio = { version = "1", features = ["rt", "macros"] }  # Only for wiremock tests
# Keep: tempfile, wiremock
```

## rust-toolchain.toml
```toml
[toolchain]
channel = "nightly-2026-03-01"  # Pin specific date
```
Never use bare "nightly" in production. Update date as needed when newer nightlies are verified.

## Build time consideration
Measure build time before/after. asupersync may be heavier than tokio. Document in bead comments.

## Files Changed
- Cargo.toml (~10 LOC)
- rust-toolchain.toml (finalize from decision gate)
- Cargo.lock (auto-updated)

## Testing
- cargo check --all-targets (will fail until Phases 3b-3f complete — this is expected)
- This bead is part of the atomic Phases 1-3 commit

## Depends On
- Decision Gate (nightly verified)
- Phase 1 (adapter uses asupersync::http)

asupersync migration complete — 5 commits pushed to asupersync-migration branch


CLI: list issues status column, filter, and robot fields

## Background
List issues needs a Status column in the table, status fields in robot JSON, and a --status filter for querying by work item status name. The filter supports multiple values (OR semantics) and case-insensitive matching.

## Approach
Extend list.rs row types, SQL, table rendering. Add --status Vec<String> to clap args. Build dynamic WHERE clause with COLLATE NOCASE. Wire into both ListFilters constructions in main.rs. Register in autocorrect.

## Files
- src/cli/commands/list.rs (row types, SQL, table, filter, color helper)
- src/cli/mod.rs (--status flag on IssuesArgs)
- src/main.rs (wire statuses into both ListFilters)
- src/cli/autocorrect.rs (add --status to COMMAND_FLAGS)

## Implementation

IssueListRow + IssueListRowJson: add 5 status fields (all Option)
From<&IssueListRow> for IssueListRowJson: clone all 5 fields

query_issues SELECT: add i.status_name, i.status_category, i.status_color, i.status_icon_name, i.status_synced_at after existing columns
  Existing SELECT has 12 columns (indices 0-11). New columns: indices 12-16.
  Row mapping: status_name: row.get(12)?, ..., status_synced_at: row.get(16)?

ListFilters: add pub statuses: &'a [String]

WHERE clause builder (after has_due_date block):
  if statuses.len() == 1: "i.status_name = ? COLLATE NOCASE" + push param
  if statuses.len() > 1: "i.status_name IN (?, ?, ...) COLLATE NOCASE" + push all params

Table: add "Status" column header (bold) between State and Assignee
  Row: match &issue.status_name -> Some: colored_cell_hex(status, color), None: Cell::new("")

New helper:
  fn colored_cell_hex(content, hex: Option<&str>) -> Cell
  If no hex or colors disabled: Cell::new(content)
  Parse 6-char hex, use Cell::new(content).fg(Color::Rgb { r, g, b })

In src/cli/mod.rs IssuesArgs:
  #[arg(long, help_heading = "Filters")]
  pub status: Vec<String>,

In src/main.rs handle_issues (~line 695):
  ListFilters { ..., statuses: &args.status }
In legacy List handler (~line 2421):
  ListFilters { ..., statuses: &[] }

In src/cli/autocorrect.rs COMMAND_FLAGS "issues" entry:
  Add "--status" between existing flags

## Acceptance Criteria
- [ ] Status column appears in table between State and Assignee
- [ ] NULL status -> empty cell
- [ ] Status colored by hex in human mode
- [ ] --status "In progress" filters correctly
- [ ] --status "in progress" matches "In progress" (COLLATE NOCASE)
- [ ] --status "To do" --status "In progress" -> OR semantics (both returned)
- [ ] Robot: status_name, status_category in each issue JSON
- [ ] --fields supports status_name, status_category, status_color, status_icon_name, status_synced_at
- [ ] --fields minimal does NOT include status fields
- [ ] Autocorrect registry test passes (--status registered)
- [ ] cargo check --all-targets passes

## TDD Loop
RED: test_list_filter_by_status, test_list_filter_by_status_case_insensitive, test_list_filter_by_multiple_statuses
GREEN: Implement all changes across 4 files
VERIFY: cargo test list_filter && cargo test registry_covers

## Edge Cases
- COLLATE NOCASE is ASCII-only but sufficient (all system statuses are ASCII)
- Single-value uses = for simplicity; multi-value uses IN with dynamic placeholders
- --status combined with other filters (--state, --label) -> AND logic
- autocorrect registry_covers_command_flags test will FAIL if --status not registered
- Legacy List command path also constructs ListFilters — needs statuses: &[]
- Column index offset: new columns start at 12 (0-indexed)

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