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


[CP1] Test fixtures for mocked GitLab responses

## 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


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)


Epic: CP2 Gate A - MRs Only

## Background
Gate A validates core MR ingestion works before adding complexity. Proves the cursor-based sync, pagination, and basic CLI work. This is the foundation - if Gate A fails, nothing else matters.

## Acceptance Criteria (Pass/Fail)
- [ ] `gi ingest --type=merge_requests` completes without error
- [ ] `SELECT COUNT(*) FROM merge_requests` > 0
- [ ] `gi list mrs --limit=5` shows 5 MRs with iid, title, state, author
- [ ] `gi count mrs` shows total count matching DB query
- [ ] MR with `state=locked` can be stored (if exists in test data)
- [ ] Draft MR shows `draft=1` in DB and `[DRAFT]` in list output
- [ ] `work_in_progress=true` MR shows `draft=1` (fallback works)
- [ ] `head_sha` populated for MRs with commits
- [ ] `references_short` and `references_full` populated
- [ ] Re-run ingest shows "0 new MRs" or minimal refetch (cursor working)
- [ ] Cursor saved at page boundary, not item boundary

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

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

echo "=== Gate A: MRs Only ==="

# 1. Clear any existing MR data for clean test
echo "Step 1: Reset MR cursor for clean test..."
sqlite3 "$DB_PATH" "DELETE FROM sync_cursors WHERE resource_type = 'merge_requests';"

# 2. Run MR ingestion
echo "Step 2: Ingest MRs..."
gi ingest --type=merge_requests

# 3. Verify MRs exist
echo "Step 3: Verify MR count..."
MR_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM merge_requests;")
echo "  MR count: $MR_COUNT"
[ "$MR_COUNT" -gt 0 ] || { echo "FAIL: No MRs ingested"; exit 1; }

# 4. Verify list command
echo "Step 4: Test list command..."
gi list mrs --limit=5

# 5. Verify count command
echo "Step 5: Test count command..."
gi count mrs

# 6. Verify draft handling
echo "Step 6: Check draft MRs..."
DRAFT_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM merge_requests WHERE draft = 1;")
echo "  Draft MR count: $DRAFT_COUNT"

# 7. Verify head_sha population
echo "Step 7: Check head_sha..."
SHA_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM merge_requests WHERE head_sha IS NOT NULL;")
echo "  MRs with head_sha: $SHA_COUNT"

# 8. Verify references
echo "Step 8: Check references..."
REF_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM merge_requests WHERE references_short IS NOT NULL;")
echo "  MRs with references: $REF_COUNT"

# 9. Verify cursor saved
echo "Step 9: Check cursor..."
CURSOR=$(sqlite3 "$DB_PATH" "SELECT updated_at, gitlab_id FROM sync_cursors WHERE resource_type = 'merge_requests';")
echo "  Cursor: $CURSOR"
[ -n "$CURSOR" ] || { echo "FAIL: Cursor not saved"; exit 1; }

# 10. Re-run and verify minimal refetch
echo "Step 10: Re-run ingest (should be minimal)..."
gi ingest --type=merge_requests
# Output should show minimal or zero new MRs

echo ""
echo "=== Gate A: PASSED ==="
```

## Test Commands (Quick Verification)
```bash
# Run these in order:
gi ingest --type=merge_requests
gi list mrs --limit=10
gi count mrs

# Verify in DB:
sqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 "
  SELECT
    COUNT(*) as total,
    SUM(CASE WHEN draft = 1 THEN 1 ELSE 0 END) as drafts,
    SUM(CASE WHEN head_sha IS NOT NULL THEN 1 ELSE 0 END) as with_sha,
    SUM(CASE WHEN references_short IS NOT NULL THEN 1 ELSE 0 END) as with_refs
  FROM merge_requests;
"

# Re-run (should be no-op):
gi ingest --type=merge_requests
```

## Dependencies
This gate requires these beads to be complete:
- bd-3ir (Database migration)
- bd-5ta (GitLab MR types)
- bd-34o (MR transformer)
- bd-iba (GitLab client pagination)
- bd-ser (MR ingestion module)

## Edge Cases
- `locked` state is transitional (merge in progress); may not exist in test data
- Some older GitLab instances may not return `head_sha` for all MRs
- `work_in_progress` is deprecated but should still work as fallback
- Very large projects (10k+ MRs) may take significant time on first sync


Implement timeline event collection and chronological interleaving

## Background

The event collection phase is steps 4-5 of the timeline pipeline (spec Section 3.2). It takes seed + expanded entity sets and collects all their events from resource event tables, then interleaves chronologically.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.2 steps 4-5, Section 3.3 (Event Model).

## Codebase Context

- resource_state_events: columns include state, actor_username (not actor_gitlab_id for display), created_at, issue_id, merge_request_id, source_merge_request_iid, source_commit
- resource_label_events: columns include action ('add'|'remove'), label_name (NULLABLE since migration 012), actor_username, created_at
- resource_milestone_events: columns include action ('add'|'remove'), milestone_title (NULLABLE since migration 012), actor_username, created_at
- issues table: created_at, author_username, title, web_url
- merge_requests table: created_at, author_username, title, web_url, merged_at, updated_at
- All timestamps are ms epoch UTC (stored as INTEGER)

## Approach

Create `src/core/timeline_collect.rs`:

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

pub fn collect_events(
    conn: &Connection,
    seed_entities: &[EntityRef],
    expanded_entities: &[ExpandedEntityRef],
    evidence_notes: &[TimelineEvent],      // from seed phase
    since_ms: Option<i64>,                 // --since filter
    limit: usize,                          // -n flag (default 100)
) -> Result<Vec<TimelineEvent>> { ... }
```

### Event Collection Per Entity

For each entity (seed + expanded), collect:

1. **Creation event** (`Created`):
   ```sql
   -- Issues:
   SELECT created_at, author_username, title, web_url FROM issues WHERE id = ?1
   -- MRs:
   SELECT created_at, author_username, title, web_url FROM merge_requests WHERE id = ?1
   ```

2. **State changes** (`StateChanged { state }`):
   ```sql
   SELECT state, actor_username, created_at FROM resource_state_events
   WHERE (issue_id = ?1 OR merge_request_id = ?1)
     AND (?2 IS NULL OR created_at >= ?2)  -- since filter
   ORDER BY created_at ASC
   ```
   NOTE: For MRs, a state='merged' event also produces a separate Merged variant.

3. **Label changes** (`LabelAdded`/`LabelRemoved`):
   ```sql
   SELECT action, label_name, actor_username, created_at FROM resource_label_events
   WHERE (issue_id = ?1 OR merge_request_id = ?1)
     AND (?2 IS NULL OR created_at >= ?2)
   ORDER BY created_at ASC
   ```
   Handle NULL label_name (deleted label): use "[deleted label]" as fallback.

4. **Milestone changes** (`MilestoneSet`/`MilestoneRemoved`):
   ```sql
   SELECT action, milestone_title, actor_username, created_at FROM resource_milestone_events
   WHERE (issue_id = ?1 OR merge_request_id = ?1)
     AND (?2 IS NULL OR created_at >= ?2)
   ORDER BY created_at ASC
   ```
   Handle NULL milestone_title: use "[deleted milestone]" as fallback.

5. **Merge event** (Merged, MR only):
   Derive from merge_requests.merged_at (preferred) OR resource_state_events WHERE state='merged'. Skip StateChanged when state='merged' — emit only the Merged variant.

### Chronological Interleave

```rust
events.sort(); // Uses Ord impl from bd-20e
if let Some(since) = since_ms {
    events.retain(|e| e.timestamp >= since);
}
events.truncate(limit);
```

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

## Acceptance Criteria

- [ ] Collects Created, StateChanged, LabelAdded/Removed, MilestoneSet/Removed, Merged, NoteEvidence events
- [ ] Merged events deduplicated from StateChanged{merged} — emit only Merged variant
- [ ] NULL label_name/milestone_title handled with fallback text
- [ ] --since filter applied to all event types
- [ ] Events sorted chronologically with stable tiebreak
- [ ] Limit applied AFTER sorting
- [ ] Evidence notes from seed phase included
- [ ] is_seed correctly set based on entity source
- [ ] Module registered in src/core/mod.rs
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

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

## TDD Loop

RED:
- `test_collect_creation_event` - entity produces Created event
- `test_collect_state_events` - state changes produce StateChanged events
- `test_collect_merged_dedup` - state='merged' produces Merged not StateChanged
- `test_collect_null_label_fallback` - NULL label_name uses fallback text
- `test_collect_since_filter` - old events excluded
- `test_collect_chronological_sort` - mixed entity events interleave correctly
- `test_collect_respects_limit`

Tests need in-memory DB with migrations 001-014 applied.

GREEN: Implement SQL queries and event assembly.

VERIFY: `cargo test --lib -- timeline_collect`

## Edge Cases

- MR with merged_at=NULL and no state='merged' event: no Merged event emitted
- Entity with 0 events in resource tables: only Created event returned
- NULL actor_username: actor field is None
- Timestamps at exact --since boundary: use >= (inclusive)

Completed: Created src/core/timeline_collect.rs with event collection for Created, StateChanged, LabelAdded/Removed, MilestoneSet/Removed, Merged, NoteEvidence. Merged dedup (state=merged skipped in favor of Merged variant). NULL label/milestone fallbacks. Since filter, chronological sort, limit. 10 tests pass.


[CP1] gi count issues/discussions/notes commands

Count entities in the database.

Commands:
- gi count issues → 'Issues: N'
- gi count discussions --type=issue → 'Issue Discussions: N'
- gi count notes --type=issue → 'Issue Notes: N (excluding M system)'

Files: src/cli/commands/count.ts
Done when: Counts match expected values from GitLab


NOTE-0A: Upsert/sweep for issue discussion notes

## Background
Issue discussion note ingestion uses a delete/reinsert pattern (DELETE FROM notes WHERE discussion_id = ? at line 132-135 of src/ingestion/discussions.rs then re-insert). This makes notes.id unstable across syncs. MR discussion notes already use upsert (ON CONFLICT(gitlab_id) DO UPDATE at line 470-536 of src/ingestion/mr_discussions.rs) producing stable IDs. Phase 2 depends on stable notes.id as source_id for note documents.

## Approach
Refactor src/ingestion/discussions.rs to match the MR pattern in src/ingestion/mr_discussions.rs:

1. Create shared NoteUpsertOutcome struct (in src/ingestion/discussions.rs, also used by mr_discussions.rs):
   pub struct NoteUpsertOutcome { pub local_note_id: i64, pub changed_semantics: bool }

2. Replace insert_note() (line 201-233) with upsert_note_for_issue(). Current signature is:
   fn insert_note(conn: &Connection, discussion_id: i64, note: &NormalizedNote, payload_id: Option<i64>) -> Result<()>
   New signature:
   fn upsert_note_for_issue(conn: &Connection, discussion_id: i64, note: &NormalizedNote, last_seen_at: i64, payload_id: Option<i64>) -> Result<NoteUpsertOutcome>

   Use ON CONFLICT(gitlab_id) DO UPDATE SET body, note_type, updated_at, last_seen_at, resolvable, resolved, resolved_by, resolved_at, position_old_path, position_new_path, position_old_line, position_new_line, position_type, position_line_range_start, position_line_range_end, position_base_sha, position_start_sha, position_head_sha

   IMPORTANT: The current issue insert_note() only populates: gitlab_id, discussion_id, project_id, note_type, is_system, author_username, body, created_at, updated_at, last_seen_at, position (integer array order), resolvable, resolved, resolved_by, resolved_at, raw_payload_id. It does NOT populate the decomposed position columns (position_new_path, etc.). The MR upsert_note() at line 470 DOES populate all decomposed position columns. Your upsert must include ALL columns from the MR pattern. The NormalizedNote struct (from src/gitlab/transformers.rs) has all position fields.

3. Change detection via pre-read: SELECT existing note before upsert, compare semantic fields (body, note_type, resolved, resolved_by, positions). Exclude updated_at/last_seen_at from semantic comparison. Use IS NOT for NULL-safe comparison.

4. Add sweep_stale_issue_notes(conn, discussion_id, last_seen_at) — DELETE FROM notes WHERE discussion_id = ? AND last_seen_at < ?

5. Replace the delete-reinsert loop (lines 132-139) with:
   for note in notes { let outcome = upsert_note_for_issue(&tx, local_discussion_id, &note, last_seen_at, None)?; }
   sweep_stale_issue_notes(&tx, local_discussion_id, last_seen_at)?;

6. Update upsert_note() in mr_discussions.rs (line 470) to return NoteUpsertOutcome with same semantic change detection. Current signature returns Result<()>.

Reference files:
- src/ingestion/mr_discussions.rs: upsert_note() line 470, sweep_stale_notes() line 551
- src/ingestion/discussions.rs: insert_note() line 201, delete pattern line 132-135
- src/gitlab/transformers.rs: NormalizedNote struct definition

## Files
- MODIFY: src/ingestion/discussions.rs (refactor insert_note -> upsert + sweep, lines 132-233)
- MODIFY: src/ingestion/mr_discussions.rs (return NoteUpsertOutcome from upsert_note at line 470)

## TDD Anchor
RED: test_issue_note_upsert_stable_id — insert 2 notes, record IDs, re-sync same gitlab_ids, assert IDs unchanged.
GREEN: Implement upsert_note_for_issue with ON CONFLICT.
VERIFY: cargo test upsert_stable_id -- --nocapture
Tests: test_issue_note_upsert_detects_body_change, test_issue_note_upsert_unchanged_returns_false, test_issue_note_upsert_updated_at_only_does_not_mark_semantic_change, test_issue_note_sweep_removes_stale, test_issue_note_upsert_returns_local_id

## Acceptance Criteria
- [ ] upsert_note_for_issue() uses ON CONFLICT(gitlab_id) DO UPDATE
- [ ] Local note IDs stable across re-syncs of identical data
- [ ] changed_semantics = true only for body/note_type/resolved/position changes
- [ ] changed_semantics = false for updated_at-only changes
- [ ] sweep removes notes with stale last_seen_at
- [ ] MR upsert_note() returns NoteUpsertOutcome
- [ ] Issue upsert populates ALL position columns (matching MR pattern)
- [ ] All 6 tests pass, clippy clean

## Edge Cases
- NULL body: IS NOT comparison handles NULLs correctly
- UNIQUE(gitlab_id) already exists on notes table (migration 002)
- last_seen_at prevents stale-sweep of notes currently being ingested
- Issue notes currently don't populate position_new_path etc. — the new upsert must extract these from NormalizedNote (check that the transformer populates them for issue DiffNotes)


NOTE-2I: Batch parent metadata cache for note regeneration

## Background
extract_note_document() (from NOTE-2C) fetches parent entity metadata per note via SQL queries. During initial backfill of ~8K notes, this creates N+1 amplification — 50 notes on same MR = 50 identical parent lookups. This is a performance optimization for batch regeneration only.

## Approach
1. Add ParentMetadataCache struct in src/documents/extractor.rs:
   pub struct ParentMetadataCache {
       cache: HashMap<(String, i64), ParentMetadata>,
   }
   Key: (noteable_type: String, parent_local_id: i64)
   ParentMetadata struct: { iid: i64, title: String, web_url: String, labels: Vec<String>, project_path: String }

   Methods:
   - pub fn new() -> Self
   - pub fn get_or_fetch(&mut self, conn: &Connection, noteable_type: &str, parent_id: i64) -> Result<Option<&ParentMetadata>>
   get_or_fetch uses HashMap entry API: on miss, fetches from DB (same queries as extract_note_document), caches, returns ref.

2. Add pub fn extract_note_document_cached(conn: &Connection, note_id: i64, cache: &mut ParentMetadataCache) -> Result<Option<DocumentData>>:
   Same logic as extract_note_document but calls cache.get_or_fetch() instead of inline parent queries. The uncached version remains for single-note use.

3. Update batch regeneration loop in src/documents/regenerator.rs. The main regeneration loop is in regenerate_dirty_documents() (top of file, ~line 20). It processes dirty entries one at a time via regenerate_one() (line 86). For batch cache to work:
   - Create ParentMetadataCache before the loop
   - In the SourceType::Note arm of regenerate_one, pass the cache through
   - This requires either making regenerate_one() take an optional cache parameter, or restructuring to handle Note specially in the loop body.

   Cleanest approach: Add cache: &mut Option<ParentMetadataCache> parameter to regenerate_one(). Initialize as Some(ParentMetadataCache::new()) before the loop. Only SourceType::Note uses it. Other types ignore it.

   Cache is created fresh per regenerate_dirty_documents() call — no cross-invocation persistence.

## Files
- MODIFY: src/documents/extractor.rs (add ParentMetadataCache struct + extract_note_document_cached)
- MODIFY: src/documents/regenerator.rs (add cache parameter to regenerate_one, use in batch loop)
- MODIFY: src/documents/mod.rs (export ParentMetadataCache if needed externally)

## TDD Anchor
RED: test_note_regeneration_batch_uses_cache — insert project, issue, 10 notes on same issue, mark all dirty, regenerate all, assert all 10 documents created correctly.
GREEN: Implement ParentMetadataCache and extract_note_document_cached.
VERIFY: cargo test note_regeneration_batch -- --nocapture
Tests: test_note_regeneration_cache_consistent_with_direct_extraction (cached output == uncached output), test_note_regeneration_cache_invalidates_across_parents (notes from different parents get correct metadata)

## Acceptance Criteria
- [ ] ParentMetadataCache reduces DB queries during batch regeneration (10 notes on 1 parent = 1 parent fetch, not 10)
- [ ] Cached extraction produces identical DocumentData output to uncached
- [ ] Cache keyed per (noteable_type, parent_id) — no cross-parent leakage
- [ ] Cache scoped to single regenerate_dirty_documents call — no persistence or invalidation complexity
- [ ] All 3 tests pass

## Dependency Context
- Depends on NOTE-2C (bd-18yh): extract_note_document function must exist to create the cached variant

## Edge Cases
- Parent deleted between cache creation and lookup: get_or_fetch returns None, extract_note_document_cached returns None (same as uncached)
- Very large batch (10K+ notes): cache grows but is bounded by number of unique parents (typically <100 issues/MRs)
- Cache miss for orphaned discussion: cached None result prevents repeated failed lookups


Create lore-tui crate scaffold

## Background
The TUI is implemented as a separate binary crate (crates/lore-tui/) that uses nightly Rust for FrankenTUI. It is EXCLUDED from the root workspace to keep nightly-only deps isolated. The lore CLI spawns lore-tui at runtime via binary delegation (PATH lookup) — zero compile-time dependency from lore to lore-tui. lore-tui depends on lore as a library (src/lib.rs exists and exports all modules).

FrankenTUI is published on crates.io as ftui (0.1.1), ftui-core, ftui-runtime, ftui-render, ftui-style. Use crates.io versions. Local clone exists at ~/projects/FrankenTUI/ for reference.

## Approach
Create the crate directory structure:
- crates/lore-tui/Cargo.toml with dependencies:
  - ftui = "0.1.1" (crates.io) and related ftui-* crates
  - lore = { path = "../.." } (library dependency for Config, db, ingestion, etc.)
  - clap, anyhow, chrono, dirs, rusqlite (bundled), crossterm
- crates/lore-tui/rust-toolchain.toml pinning nightly-2026-02-08
- crates/lore-tui/src/main.rs — binary entry point with TuiCli struct (clap Parser) supporting --config, --sync, --fresh, --render-mode, --ascii, --no-alt-screen
- crates/lore-tui/src/lib.rs — public API: launch_tui(), launch_sync_tui(), LaunchOptions struct, module declarations
- Root Cargo.toml: verify lore-tui is NOT in [workspace] members

## Acceptance Criteria
- [ ] crates/lore-tui/Cargo.toml exists with ftui (crates.io) and lore (path dep) dependencies
- [ ] crates/lore-tui/rust-toolchain.toml pins nightly-2026-02-08
- [ ] crates/lore-tui/src/main.rs compiles with clap CLI args
- [ ] crates/lore-tui/src/lib.rs declares all module stubs and exports LaunchOptions, launch_tui, launch_sync_tui
- [ ] cargo +stable check --workspace --all-targets passes (lore-tui excluded)
- [ ] cargo +nightly check --manifest-path crates/lore-tui/Cargo.toml --all-targets passes
- [ ] Root Cargo.toml does NOT include lore-tui in workspace members

## Files
- CREATE: crates/lore-tui/Cargo.toml
- CREATE: crates/lore-tui/rust-toolchain.toml
- CREATE: crates/lore-tui/src/main.rs
- CREATE: crates/lore-tui/src/lib.rs
- VERIFY: Cargo.toml (root — confirm lore-tui NOT in members)

## TDD Anchor
RED: Write a shell test that runs cargo +nightly check --manifest-path crates/lore-tui/Cargo.toml and asserts exit 0.
GREEN: Create the full crate scaffold with all deps.
VERIFY: cargo +stable check --workspace --all-targets && cargo +nightly check --manifest-path crates/lore-tui/Cargo.toml

## Edge Cases
- ftui crates may require specific nightly features — pin exact nightly date
- Path dependency to lore means lore-tui sees lore's edition 2024 — verify compat
- rusqlite bundled feature pulls in cc build — may need nightly-compatible cc version
- If ftui 0.1.1 has breaking changes vs PRD assumptions, check ~/projects/FrankenTUI/ for latest API

## Dependency Context
Root task — no dependencies. All other Phase 0 tasks depend on this scaffold existing.


Orchestrator: status enrichment phase with transactional writes

## Background
The orchestrator controls the sync pipeline. Status enrichment is a new Phase 1.5 that runs after issue ingestion but before discussion sync. It must be non-fatal — errors skip enrichment but don't crash the sync.

## Approach
Add enrichment phase to ingest_project_issues_with_progress. Use client.graphql_client() factory. Look up project path from DB via .optional()? for non-fatal failure. Transactional writes via enrich_issue_statuses_txn() with two phases: clear stale, then apply new.

## Files
- src/ingestion/orchestrator.rs (enrichment phase + txn helper + IngestProjectResult fields + ProgressEvent variants)
- src/cli/commands/ingest.rs (add match arms for new ProgressEvent variants)

## Implementation

IngestProjectResult new fields:
  statuses_enriched: usize, statuses_cleared: usize, statuses_seen: usize,
  statuses_without_widget: usize, partial_error_count: usize,
  first_partial_error: Option<String>, status_enrichment_error: Option<String>,
  status_enrichment_mode: String, status_unsupported_reason: Option<String>
  Default: all 0/None/"" as appropriate

ProgressEvent new variants:
  StatusEnrichmentComplete { enriched: usize, cleared: usize }
  StatusEnrichmentSkipped

Phase 1.5 logic (after ingest_issues, before discussion sync):
  1. Check config.sync.fetch_work_item_status && !signal.is_cancelled()
  2. If false: set mode="skipped", emit StatusEnrichmentSkipped
  3. Look up project path: conn.query_row("SELECT path_with_namespace FROM projects WHERE id = ?1", [project_id], |r| r.get(0)).optional()?
  4. If None: warn, set status_enrichment_error="project_path_missing", emit StatusEnrichmentComplete{0,0}
  5. Create graphql_client via client.graphql_client()
  6. Call fetch_issue_statuses(&graphql_client, &project_path).await
  7. On Ok: map unsupported_reason to mode/reason, call enrich_issue_statuses_txn(), set counters
  8. On Err: warn, set status_enrichment_error, mode="fetched"
  9. Emit StatusEnrichmentComplete

enrich_issue_statuses_txn(conn, project_id, statuses, all_fetched_iids, now_ms) -> Result<(usize, usize)>:
  Uses conn.unchecked_transaction() (conn is &Connection not &mut)
  Phase 1 (clear): UPDATE issues SET status_*=NULL, status_synced_at=now_ms WHERE project_id=? AND iid=? AND status_name IS NOT NULL — for IIDs in all_fetched_iids but NOT in statuses
  Phase 2 (apply): UPDATE issues SET status_name=?, status_category=?, status_color=?, status_icon_name=?, status_synced_at=now_ms WHERE project_id=? AND iid=?
  tx.commit(), return (enriched, cleared)

In src/cli/commands/ingest.rs progress callback, add arms:
  ProgressEvent::StatusEnrichmentComplete { enriched, cleared } => { ... }
  ProgressEvent::StatusEnrichmentSkipped => { ... }

## Acceptance Criteria
- [ ] Enrichment runs after ingest_issues, before discussion sync
- [ ] Gated by config.sync.fetch_work_item_status
- [ ] Project path missing -> skipped with error="project_path_missing", sync continues
- [ ] enrich_issue_statuses_txn correctly UPDATEs status columns + status_synced_at
- [ ] Stale status cleared: issue in all_fetched_iids but not statuses -> NULL + synced_at set
- [ ] Transaction rollback on failure: no partial updates
- [ ] Idempotent: running twice with same data produces same result
- [ ] GraphQL error: logged, enrichment_error captured, sync continues
- [ ] ingest.rs compiles with new ProgressEvent arms
- [ ] cargo check --all-targets passes

## TDD Loop
RED: test_enrich_issue_statuses_txn, test_enrich_skips_unknown_iids, test_enrich_clears_removed_status, test_enrich_transaction_rolls_back_on_failure, test_enrich_idempotent_across_two_runs, test_enrich_sets_synced_at_on_clear, test_enrichment_error_captured_in_result, test_project_path_missing_skips_enrichment
  Tests use in-memory DB with migration 021 applied
GREEN: Implement enrichment phase + txn helper + result fields + progress arms
VERIFY: cargo test enrich && cargo test orchestrator

## Edge Cases
- unchecked_transaction() needed because conn is &Connection not &mut Connection
- .optional()? requires use rusqlite::OptionalExtension
- status_synced_at is set on BOTH clear and apply operations (not NULL on clear)
- Clear SQL has WHERE status_name IS NOT NULL to avoid counting already-cleared rows
- Progress callback match must be updated in SAME batch as enum change (compile error otherwise)
- status_enrichment_mode must be set in ALL code paths (fetched/unsupported/skipped)


Implement Issue List (state + action + view)

## Background
The Issue List is the primary browse interface for issues. It uses keyset pagination (not OFFSET) for deterministic cross-page traversal under concurrent sync writes. A browse snapshot fence preserves stable ordering until explicit refresh.

## Approach
State (state/issue_list.rs):
- IssueListState: window (Vec<IssueListRow>), total_count, selected_index, scroll_offset, next_cursor (Option<IssueCursor>), prev_cursor (Option<IssueCursor>), prefetch_in_flight (bool), filter (IssueFilter), filter_input (TextInput), filter_focused (bool), sort_field (SortField), sort_order (SortOrder), snapshot_upper_updated_at (Option<i64>), filter_hash (u64), peek_visible (bool), peek_content (Option<IssueDetail>)
- IssueCursor: updated_at (i64), iid (i64) — boundary values for keyset pagination
- IssueFilter: state (Option<String>), author (Option<String>), assignee (Option<String>), label (Option<String>), milestone (Option<String>), status (Option<String>), free_text (Option<String>), project_id (Option<i64>)
- IssueListRow: project_path, iid, title, state, author, assignee, labels, updated_at, status_name, status_icon
- handle_key(): j/k scroll, J/K page, Enter select, / focus filter, Tab sort, g+g top, G bottom, r refresh, Space toggle Quick Peek
- scroll_to_top(), apply_filter(), set_sort(), toggle_peek()

**Snapshot fence:** On first load and on explicit refresh (r), store snapshot_upper_updated_at = MAX(updated_at) from result set. Subsequent page fetches add WHERE updated_at <= snapshot_upper_updated_at to prevent rows from shifting as sync inserts new data. Explicit refresh (r) resets the fence.

**filter_hash:** Compute a hash of the current filter state. When filter changes (new hash != old hash), reset cursor to page 1 and clear snapshot fence. This prevents stale pagination after filter changes.

**Prefetch:** When scroll position reaches 80% of current window, trigger background prefetch of next page via TaskSupervisor. Prefetched data appended to window when user scrolls past current page boundary.

**Quick Peek (Space key):**
- Space toggles a right-side preview pane (40% width) showing the currently selected issue's detail
- Preview content loads asynchronously via TaskSupervisor
- Cursor movement (j/k) updates the preview for the newly selected row
- Esc or Space again closes the peek pane
- On narrow terminals (<100 cols), peek replaces the list instead of side-by-side

Action (action.rs):
- fetch_issues(conn, filter, cursor, page_size, clock, snapshot_fence) -> Result<IssueListPage, LoreError>: keyset pagination query with WHERE (updated_at, iid) < (cursor.updated_at, cursor.iid) AND updated_at <= snapshot_fence ORDER BY updated_at DESC, iid DESC LIMIT page_size+1 (extra row detects has_next). Uses idx_issues_list_default index.
- fetch_issue_peek(conn, entity_key) -> Result<IssueDetail, LoreError>: loads issue detail for Quick Peek preview
- IssueListPage: rows, next_cursor, prev_cursor, total_count

View (view/issue_list.rs):
- render_issue_list(frame, state, area, theme): FilterBar at top, EntityTable below, status bar at bottom
- When peek_visible: split area horizontally — list (60%) | peek preview (40%)
- Columns: IID, Title (flex), State, Author, Labels, Updated, Status

## Acceptance Criteria
- [ ] Keyset pagination fetches pages without OFFSET
- [ ] Next/prev page navigation preserves deterministic ordering
- [ ] Browse snapshot fence (snapshot_upper_updated_at) prevents rows from shifting during concurrent sync
- [ ] Explicit refresh (r) resets snapshot fence and re-queries from first page
- [ ] filter_hash tracks filter state; filter change resets cursor to page 1
- [ ] Prefetch triggers at 80% scroll position via TaskSupervisor
- [ ] Filter bar accepts DSL tokens and triggers re-query via ScreenIntent::RequeryNeeded
- [ ] j/k scrolls within current page, J/K loads next/prev page
- [ ] Enter navigates to IssueDetail(EntityKey), Esc returns to list with cursor preserved
- [ ] Tab cycles sort column, sort indicator shown
- [ ] Total count displayed in status area
- [ ] Space toggles Quick Peek right-side preview pane
- [ ] Quick Peek loads issue detail asynchronously
- [ ] j/k in peek mode updates preview for newly selected row
- [ ] Narrow terminal (<100 cols): peek replaces list instead of split view

## Files
- MODIFY: crates/lore-tui/src/state/issue_list.rs (expand from stub)
- MODIFY: crates/lore-tui/src/action.rs (add fetch_issues, fetch_issue_peek)
- CREATE: crates/lore-tui/src/view/issue_list.rs

## TDD Anchor
RED: Write test_keyset_pagination in action.rs that inserts 30 issues, fetches page 1 (size 10), then fetches page 2 using returned cursor, asserts no overlap between pages.
GREEN: Implement keyset pagination query.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_keyset_pagination

Additional tests:
- test_snapshot_fence_excludes_newer_rows: insert row with updated_at > fence, assert not in results
- test_filter_change_resets_cursor: change filter, verify cursor reset to None
- test_prefetch_triggered_at_80pct: scroll to 80% of window, verify prefetch_in_flight set

## Edge Cases
- Multi-project datasets: cursor must include project_id scope from global ScopeContext
- Issues with identical updated_at: keyset tiebreaker on iid ensures deterministic ordering
- Empty result set: show "No issues match your filter" message, not empty table
- Filter changes must reset cursor to first page (not continue from mid-pagination)
- Quick Peek on empty list: no-op (don't show empty pane)
- Rapid j/k with peek open: debounce peek loads to avoid flooding TaskSupervisor

## Dependency Context
Uses EntityTable and FilterBar from "Implement entity table + filter bar widgets" (bd-18qs).
Uses AppState, IssueListState, ScreenIntent from "Implement AppState composition" (bd-1v9m).
Uses TaskSupervisor for load management and prefetch from "Implement TaskSupervisor" (bd-3le2).
Uses DbManager from "Implement DbManager" (bd-2kop).
Requires idx_issues_list_default index from "Add required TUI indexes" (bd-3pm2).


Implement property tests for navigation invariants

## Background
Property-based tests verify navigation invariants hold for all possible sequences of push/pop/forward/jump/reset operations. Uses proptest or quickcheck for automated input generation.

## Approach
- Property: stack depth always >= 1 (Dashboard is always reachable)
- Property: after push(X), current() == X
- Property: after push(X) then pop(), current() returns to previous
- Property: forward_stack cleared after any push (browser semantics)
- Property: jump_list only contains detail/entity screens
- Property: reset_to(X) clears all history, current() == X
- Property: breadcrumbs length == back_stack.len() + 1
- Arbitrary sequence of operations should never panic

## Acceptance Criteria
- [ ] All 7 navigation properties hold for 10000 generated test cases
- [ ] No panic for any sequence of operations
- [ ] Proptest shrinking finds minimal counterexamples on failure

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

## TDD Anchor
RED: Write proptest that generates random sequences of push/pop/forward/reset, asserts stack depth >= 1 after every operation.
GREEN: Ensure NavigationStack maintains invariant (pop returns None at root).
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml nav_property

## Dependency Context
Uses NavigationStack from "Implement NavigationStack" task.
Uses Screen enum from "Implement core types" task.


OBSERV Epic: Phase 3 - Performance Metrics Collection

StageTiming struct, custom MetricsLayer tracing subscriber layer, span-to-metrics extraction, robot JSON enrichment with meta.stages, human-readable timing summary.

Depends on: Phase 2 (spans must exist to extract timing from)
Unblocks: Phase 4 (sync history needs Vec<StageTiming> to store)

Files: src/core/metrics.rs (new), src/cli/commands/sync.rs, src/cli/commands/ingest.rs, src/main.rs

Acceptance criteria (PRD Section 6.3):
- lore --robot sync includes meta.run_id and meta.stages array
- Each stage has name, elapsed_ms, items_processed
- Top-level stages have sub_stages arrays
- Interactive sync prints timing summary table
- Zero-value fields omitted from JSON

All Phase 3 tasks complete: StageTiming struct, MetricsLayer, span field recording, robot JSON enrichment with stages, and human-readable timing summary


Implement hybrid search with adaptive recall

## Background
Hybrid search is the top-level search orchestrator that combines FTS5 lexical results with sqlite-vec semantic results via RRF ranking. It supports three modes (Lexical, Semantic, Hybrid) and implements adaptive recall (wider initial fetch when filters are applied) and graceful degradation (falls back to FTS when Ollama is unavailable). All modes use RRF for consistent --explain output.

## Approach
Create `src/search/hybrid.rs` per PRD Section 5.3.

**Key types:**
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchMode {
    Hybrid,    // Vector + FTS with RRF
    Lexical,   // FTS only
    Semantic,  // Vector only
}

impl SearchMode {
    pub fn from_str(s: &str) -> Option<Self> {
        match s.to_lowercase().as_str() {
            "hybrid" => Some(Self::Hybrid),
            "lexical" | "fts" => Some(Self::Lexical),
            "semantic" | "vector" => Some(Self::Semantic),
            _ => None,
        }
    }

    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Hybrid => "hybrid",
            Self::Lexical => "lexical",
            Self::Semantic => "semantic",
        }
    }
}

pub struct HybridResult {
    pub document_id: i64,
    pub score: f64,          // Normalized RRF score (0-1)
    pub vector_rank: Option<usize>,
    pub fts_rank: Option<usize>,
    pub rrf_score: f64,      // Raw RRF score
}
```

**Core function (ASYNC, PRD-exact signature):**
```rust
pub async fn search_hybrid(
    conn: &Connection,
    client: Option<&OllamaClient>,      // None if Ollama unavailable
    ollama_base_url: Option<&str>,       // For actionable error messages
    query: &str,
    mode: SearchMode,
    filters: &SearchFilters,
    fts_mode: FtsQueryMode,
) -> Result<(Vec<HybridResult>, Vec<String>)>
```

**IMPORTANT — client is `Option<&OllamaClient>`:** This enables graceful degradation. When Ollama is unavailable, the caller passes `None` and hybrid mode falls back to FTS-only with a warning. The `ollama_base_url` is separate so error messages can include it even when client is None.

**Adaptive recall constants (PRD Section 5.3):**
```rust
const BASE_RECALL_MIN: usize = 50;
const FILTERED_RECALL_MIN: usize = 200;
const RECALL_CAP: usize = 1500;
```

**Recall formula:**
```rust
let requested = filters.clamp_limit();
let top_k = if filters.has_any_filter() {
    (requested * 50).max(FILTERED_RECALL_MIN).min(RECALL_CAP)
} else {
    (requested * 10).max(BASE_RECALL_MIN).min(RECALL_CAP)
};
```

**Mode behavior:**
- **Lexical:** FTS only -> rank_rrf with empty vector list (single-list RRF)
- **Semantic:** Vector only -> requires client (error if None) -> rank_rrf with empty FTS list
- **Hybrid:** Both FTS + vector -> rank_rrf with both lists
- **Hybrid with client=None:** Graceful degradation to Lexical with warning, NOT error

**Graceful degradation logic:**
```rust
SearchMode::Hybrid => {
    let fts_results = search_fts(conn, query, top_k, fts_mode)?;
    let fts_tuples: Vec<_> = fts_results.iter().map(|r| (r.document_id, r.rank)).collect();

    match client {
        Some(client) => {
            let query_embedding = client.embed_batch(vec\![query.to_string()]).await?;
            let embedding = query_embedding.into_iter().next().unwrap();
            let vec_results = search_vector(conn, &embedding, top_k)?;
            let vec_tuples: Vec<_> = vec_results.iter().map(|r| (r.document_id, r.distance)).collect();
            let ranked = rank_rrf(&vec_tuples, &fts_tuples);
            // ... map to HybridResult
            Ok((results, warnings))
        }
        None => {
            warnings.push("Ollama unavailable, falling back to lexical search".into());
            let ranked = rank_rrf(&[], &fts_tuples);
            // ... map to HybridResult
            Ok((results, warnings))
        }
    }
}
```

## Acceptance Criteria
- [ ] Function is `async` (per PRD — Ollama client methods are async)
- [ ] Signature takes `client: Option<&OllamaClient>` (not required)
- [ ] Signature takes `ollama_base_url: Option<&str>` for actionable error messages
- [ ] Returns `(Vec<HybridResult>, Vec<String>)` — results + warnings
- [ ] Lexical mode: FTS-only results ranked via RRF (single list)
- [ ] Semantic mode: vector-only results ranked via RRF; error if client is None
- [ ] Hybrid mode: both FTS + vector results merged via RRF
- [ ] Graceful degradation: client=None in Hybrid falls back to FTS with warning (not error)
- [ ] Adaptive recall: unfiltered max(50, limit*10), filtered max(200, limit*50), capped 1500
- [ ] All modes produce consistent --explain output (vector_rank, fts_rank, rrf_score)
- [ ] SearchMode::from_str accepts aliases: "fts" for Lexical, "vector" for Semantic
- [ ] `cargo build` succeeds

## Files
- `src/search/hybrid.rs` — new file
- `src/search/mod.rs` — add `pub use hybrid::{search_hybrid, HybridResult, SearchMode};`

## TDD Loop
RED: Tests (some integration, some unit):
- `test_lexical_mode` — FTS results only
- `test_semantic_mode` — vector results only
- `test_hybrid_mode` — both lists merged
- `test_graceful_degradation` — None client falls back to FTS with warning in warnings vec
- `test_adaptive_recall_unfiltered` — recall = max(50, limit*10)
- `test_adaptive_recall_filtered` — recall = max(200, limit*50)
- `test_recall_cap` — never exceeds 1500
- `test_search_mode_from_str` — "hybrid", "lexical", "fts", "semantic", "vector", invalid
GREEN: Implement search_hybrid
VERIFY: `cargo test hybrid`

## Edge Cases
- Both FTS and vector return zero results: empty output (not error)
- FTS returns results but vector returns empty: RRF still works (single-list)
- Very high limit (100) with filters: recall = min(5000, 1500) = 1500
- Semantic mode with client=None: error (OllamaUnavailable), not degradation
- Semantic mode with 0% coverage: return LoreError::EmbeddingsNotBuilt

Implemented hybrid search with 3 modes (lexical/semantic/hybrid), graceful degradation when Ollama unavailable, adaptive recall (50-1500), RRF fusion. 6 tests pass.


Implement RRF ranking

## Background
Reciprocal Rank Fusion (RRF) combines results from multiple retrieval systems (FTS5 lexical + sqlite-vec semantic) into a single ranked list without requiring score normalization. Documents appearing in both lists rank higher than single-list documents. This is the core ranking algorithm for hybrid search in Gate B.

## Approach
Create \`src/search/rrf.rs\` per PRD Section 5.2.

```rust
use std::collections::HashMap;

const RRF_K: f64 = 60.0;

pub struct RrfResult {
    pub document_id: i64,
    pub rrf_score: f64,           // Raw RRF score
    pub normalized_score: f64,    // Normalized to 0-1 (rrf_score / max)
    pub vector_rank: Option<usize>,  // 1-indexed rank in vector list
    pub fts_rank: Option<usize>,     // 1-indexed rank in FTS list
}

/// Input: tuples of (document_id, score/distance) — already sorted by retriever.
/// Ranks are 1-indexed (first result = rank 1).
/// Score = sum of 1/(k + rank) for each list containing the document.
pub fn rank_rrf(
    vector_results: &[(i64, f64)],  // (doc_id, distance)
    fts_results: &[(i64, f64)],     // (doc_id, bm25_score)
) -> Vec<RrfResult>
```

**Algorithm (per PRD):**
1. Build HashMap<doc_id, (rrf_score, vector_rank, fts_rank)>
2. For each vector result at position i: score += 1/(K + (i+1)), record vector_rank = i+1 (**1-indexed**)
3. For each FTS result at position i: score += 1/(K + (i+1)), record fts_rank = i+1 (**1-indexed**)
4. Sort descending by rrf_score
5. Normalize: each result.normalized_score = result.rrf_score / max_score (best = 1.0)

**Key PRD details:**
- Ranks are **1-indexed** (rank 1 = best, not rank 0)
- Input is \`&[(i64, f64)]\` tuples, NOT custom structs
- Output has both \`rrf_score\` (raw) and \`normalized_score\` (0-1)

## Acceptance Criteria
- [ ] Documents in both lists score higher than single-list documents
- [ ] Single-list documents are included (not dropped)
- [ ] Ranks are 1-indexed (first element = rank 1)
- [ ] Raw RRF score available in rrf_score field
- [ ] Normalized score: best = 1.0, all in [0, 1]
- [ ] Results sorted descending by rrf_score
- [ ] vector_rank and fts_rank tracked per result for --explain
- [ ] Empty input lists handled (return empty)
- [ ] One empty list + one non-empty returns results from non-empty list

## Files
- \`src/search/rrf.rs\` — new file
- \`src/search/mod.rs\` — add \`mod rrf; pub use rrf::{rank_rrf, RrfResult};\`

## TDD Loop
RED: Tests in \`#[cfg(test)] mod tests\`:
- \`test_dual_list_ranks_higher\` — doc in both lists scores > doc in one list
- \`test_single_list_included\` — FTS-only and vector-only docs appear
- \`test_normalization\` — best score is 1.0, all in [0, 1]
- \`test_empty_inputs\` — empty returns empty
- \`test_ranks_are_1_indexed\` — verify vector_rank/fts_rank start at 1
- \`test_raw_and_normalized_scores\` — both fields populated correctly
GREEN: Implement rank_rrf()
VERIFY: \`cargo test rrf\`

## Edge Cases
- Duplicate document_id within same list: shouldn't happen, use first occurrence
- Single result in one list, zero in other: normalized_score = 1.0
- Very large input lists: HashMap handles efficiently

Completed: RRF ranking with 1-indexed ranks, raw+normalized scores, vector_rank/fts_rank provenance, 7 tests pass


Implement stale response + SQLITE_BUSY + cancel race tests

## Background
These tests verify the TUI handles async race conditions correctly: stale responses from superseded tasks are dropped, SQLITE_BUSY errors trigger retry with backoff, and query cancellation doesn't bleed across tasks or leave stuck loading states.

## Approach
Stale response tests:
- Submit task A (generation 1), then submit task B (generation 2) with same key
- Deliver task A's result: assert it's dropped (generation mismatch)
- Deliver task B's result: assert it's applied

SQLITE_BUSY retry tests:
- Lock DB with a writer, attempt read query, assert retry with exponential backoff
- Verify TUI shows "Database busy" toast, not a crash

Cancel race tests:
- Submit task, cancel via CancelToken, immediately submit new task with same key
- Assert old task's CancelToken is set, new task proceeds normally
- Rapid cancel-then-resubmit: no stuck LoadingInitial state after sequence
- Cross-task bleed: interrupt handle only cancels the owning task's connection

## Acceptance Criteria
- [ ] Stale response with old generation silently dropped
- [ ] SQLITE_BUSY shows user-friendly error, retries automatically
- [ ] Cancel-then-resubmit: no stuck loading state
- [ ] InterruptHandle only cancels its owning task's query
- [ ] Rapid sequence (5 cancel+submit in 100ms): final state is correct

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

## TDD Anchor
RED: Write test_stale_response_dropped that submits two tasks, delivers first result, asserts state unchanged.
GREEN: Ensure is_current() check in update() guards all *Loaded handlers.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_stale_response

## Edge Cases
- SQLITE_BUSY timeout must be configurable (default 5000ms)
- Rapid navigation can create >10 pending tasks — all but latest should be cancelled
- CancelToken check must be in hot loops, not just at task entry

## Dependency Context
Uses TaskSupervisor from "Implement TaskSupervisor" task.
Uses DbManager from "Implement DbManager" task.
Uses LoreApp update() stale-result guards from "Implement LoreApp Model" task.


Implement session persistence + instance lock + text width

## Background
Session state persistence allows the TUI to resume where the user left off (current screen, filter state, scroll position). Instance locking prevents data corruption from accidental double-launch. Text width handling ensures correct rendering of CJK, emoji, and combining marks.

## Approach
Session (session.rs):
- SessionState: versioned struct with current_screen, nav_history, per-screen filter/scroll state, global_scope
- save(path): atomic write (tmp->fsync->rename) + CRC32 checksum + max-size guard (1MB)
- load(path) -> Result<SessionState>: validate CRC32, reject corrupted (quarantine bad file), handle version migration
- corruption quarantine: move bad files to .quarantine/ subdir

Instance Lock (instance_lock.rs):
- InstanceLock: advisory lock file (~/.local/share/lore/tui.lock) with PID written
- acquire() -> Result<InstanceLock>: try lock, check for stale lock (PID no longer running), clear stale, create new
- Drop impl: remove lock file
- On collision: clear error message with running PID

Text Width (text_width.rs):
- measure_display_width(s: &str) -> usize: terminal display width using unicode-width + unicode-segmentation
- truncate_display_width(s: &str, max_width: usize) -> String: truncate at grapheme boundary, append ellipsis
- pad_display_width(s: &str, width: usize) -> String: pad with spaces to target display width
- Handles: CJK (2-cell), emoji ZWJ sequences, skin tone modifiers, flag sequences, combining marks

## Acceptance Criteria
- [ ] Session state saved on quit, restored on launch
- [ ] Atomic write prevents partial session files
- [ ] CRC32 checksum detects corruption
- [ ] Corrupted sessions quarantined (not deleted)
- [ ] Max 1MB session file size enforced
- [ ] Instance lock prevents double-launch with clear error
- [ ] Stale lock (dead PID) automatically recovered
- [ ] Lock released on normal exit and on panic (Drop impl)
- [ ] CJK characters measured as 2 cells wide
- [ ] Emoji ZWJ sequences treated as single grapheme cluster
- [ ] Truncation never splits a grapheme cluster

## Files
- CREATE: crates/lore-tui/src/session.rs
- CREATE: crates/lore-tui/src/instance_lock.rs
- CREATE: crates/lore-tui/src/text_width.rs

## TDD Anchor
RED: Write test_measure_cjk_width that asserts measure_display_width("Hello") == 5 and measure_display_width("日本語") == 6.
GREEN: Implement measure_display_width using unicode-width.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_measure

Additional tests:
- test_session_roundtrip: save and load, assert equal
- test_session_corruption_detected: modify saved file, assert load returns error
- test_instance_lock_stale_recovery: create lock with dead PID, assert acquire succeeds
- test_truncate_emoji: truncate string with emoji, assert no split grapheme

## Edge Cases
- Lock file dir doesn't exist: create it
- PID reuse: rare but possible — stale lock detection uses PID existence check only
- Session version migration: old version file should be handled gracefully (reset to defaults)
- text_width: some terminals render emoji incorrectly — we use standard wcwidth semantics


Quality gates: cargo check, clippy, fmt, test

## Background
Final verification that all implementation beads integrate cleanly. Must pass all quality gates before the feature is considered complete.

## Approach
Run all 4 quality gate commands. Fix any issues discovered.

## Commands (in order)
1. cargo check --all-targets (zero errors)
2. cargo clippy --all-targets -- -D warnings (pedantic + nursery clean)
3. cargo fmt --check (formatted)
4. cargo test (all green, including all 42 new tests)

## Acceptance Criteria
- [ ] cargo check --all-targets: exit 0
- [ ] cargo clippy --all-targets -- -D warnings: exit 0
- [ ] cargo fmt --check: exit 0
- [ ] cargo test: all pass (0 failures)
- [ ] All 42 new tests from the plan are present and green

## Known Gotchas from Plan's Trial Run
- clippy::items_after_test_module: ansi256_from_rgb must be BEFORE #[cfg(test)]
- clippy::collapsible_if: use let-chain syntax (if x && let ...)
- clippy::manual_range_contains: use (16..=231).contains(&blue)
- r##"..."## needed for test JSON with hex colors


Create mock response files for integration tests using wiremock.

## Fixtures to Create

### tests/fixtures/gitlab_issue.json
Single issue with labels:
- id, iid, project_id, title, description, state
- author object
- labels array (string names)
- timestamps
- web_url

### tests/fixtures/gitlab_issues_page.json
Array of issues simulating paginated response:
- 3-5 issues with varying states
- Mix of labels

### tests/fixtures/gitlab_discussion.json
Single discussion:
- id (string)
- individual_note: false
- notes array with 2+ notes
- Include one system note

### tests/fixtures/gitlab_discussions_page.json
Array of discussions:
- Mix of individual_note true/false
- Include resolvable/resolved examples

## Edge Cases to Cover
- Issue with no labels (empty array)
- Issue with labels_details (ignored in CP1)
- Discussion with individual_note=true (single note)
- System notes with system=true
- Resolvable notes

Files: tests/fixtures/gitlab_issue.json, gitlab_issues_page.json, gitlab_discussion.json, gitlab_discussions_page.json
Done when: wiremock handlers can use fixtures for deterministic tests


Fetch closes_issues API and populate entity_references

## Background
GET /projects/:id/merge_requests/:iid/closes_issues returns issues that will close when MR merges. This is the most reliable source for MR→issue relationships. Uses the generic dependent fetch queue (job_type = 'mr_closes_issues').

## Approach

**1. Add API endpoint to GitLab client (src/gitlab/client.rs):**
```rust
/// Fetch issues that will be closed when this MR merges.
pub async fn fetch_mr_closes_issues(
    &self,
    project_id: i64,
    iid: i64
) -> Result<Vec<GitLabIssueRef>>
```

New type in src/gitlab/types.rs:
```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabIssueRef {
    pub id: i64,
    pub iid: i64,
    pub project_id: i64,
    pub title: String,
    pub state: String,
    pub web_url: String,
}
```

URL: `GET /api/v4/projects/{project_id}/merge_requests/{iid}/closes_issues?per_page=100`

**2. Enqueue jobs during MR ingestion:**
In orchestrator.rs, after MR upsert:
```rust
enqueue_job(conn, project_id, "merge_request", iid, local_id, "mr_closes_issues", None)?;
```

This is always enqueued (not gated by a config flag) because cross-reference data is fundamental to all temporal queries.

**3. Process jobs in drain step:**
In the drain dispatcher (from bd-1ep), handle "mr_closes_issues" job_type:
```rust
let closes_issues = client.fetch_mr_closes_issues(gitlab_project_id, job.entity_iid).await?;
for issue_ref in &closes_issues {
    let target_id = resolve_issue_local_id(conn, project_id, issue_ref.iid);
    insert_entity_reference(conn, EntityReference {
        source_entity_type: "merge_request",
        source_entity_id: job.entity_local_id,
        target_entity_type: "issue",
        target_entity_id: target_id,            // Some(id) or None for cross-project
        target_project_path: if target_id.is_none() { Some(resolve_project_path(issue_ref.project_id)) } else { None },
        target_entity_iid: if target_id.is_none() { Some(issue_ref.iid) } else { None },
        reference_type: "closes",
        source_method: "api_closes_issues",
        created_at: None,
    })?;
}
```

**4. Insert helper for entity_references:**
Add to src/core/references.rs:
```rust
pub fn insert_entity_reference(conn: &Connection, ref_: &EntityReference) -> Result<bool>
// INSERT OR IGNORE, returns true if inserted
```

## Acceptance Criteria
- [ ] closes_issues API called for all MRs during sync
- [ ] Entity references created with reference_type='closes', source_method='api_closes_issues'
- [ ] Source = MR, target = issue (correct directionality)
- [ ] Cross-project issues stored as unresolved (target_entity_id=NULL, target_project_path set)
- [ ] Idempotent: re-sync doesn't create duplicate references
- [ ] 404 on deleted MR handled gracefully (fail_job)

## Files
- src/gitlab/client.rs (add fetch_mr_closes_issues)
- src/gitlab/types.rs (add GitLabIssueRef)
- src/core/references.rs (add insert_entity_reference helper)
- src/ingestion/orchestrator.rs (enqueue mr_closes_issues jobs)
- src/core/drain.rs or sync.rs (handle mr_closes_issues in drain dispatcher)

## TDD Loop
RED: tests/references_tests.rs:
- `test_closes_issues_creates_references` - mock closes_issues response, verify entity_references rows
- `test_closes_issues_cross_project_unresolved` - issue from different project stored as unresolved
- `test_closes_issues_idempotent` - process same job twice, verify no duplicates

tests/gitlab_types_tests.rs:
- `test_deserialize_issue_ref` - verify GitLabIssueRef deserialization

GREEN: Implement API endpoint, enqueue hook, drain handler, insert helper

VERIFY: `cargo test references -- --nocapture && cargo test gitlab_types -- --nocapture`

## Edge Cases
- closes_issues API returns issues from OTHER projects (cross-project closing) — must check if issue is in local DB
- Empty response (MR doesn't close any issues) — no refs created, job still completed
- MR may close the same issue via description ("Closes #123") and via commits — API deduplicates, but our INSERT OR IGNORE handles it too
- The closes_issues API may return stale data for draft MRs (issues that *would* close but haven't yet)


NOTE-1B: CLI arguments and command wiring for lore notes

## Background
Wire the notes query layer into the CLI. Add NotesArgs clap struct, Notes variant to Commands enum, and handler in main.rs.

## Approach
1. Add NotesArgs struct to src/cli/mod.rs (after MrsArgs at line 397):
   #[derive(Debug, clap::Args)]
   pub struct NotesArgs {
       --limit/-n (usize, default 50)
       --fields (Option<String>)
       --format (table/json/jsonl/csv, default table)
       --author/-a (Option<String>)
       --note-type (Option<String>)
       --contains (Option<String>)
       --note-id (Option<i64>)
       --gitlab-note-id (Option<i64>)
       --discussion-id (Option<String>)
       --include-system (bool flag)
       --for-issue (Option<i64>, conflicts_with = "for_mr")
       --for-mr (Option<i64>, conflicts_with = "for_issue")
       --project/-p (Option<String>)
       --since (Option<String>)
       --until (Option<String>)
       --path (Option<String>)
       --resolution (Option<String>, value_parser = ["any", "unresolved", "resolved"])
       --sort (String, default "created", value_parser = ["created", "updated"])
       --asc (bool flag)
       --open (bool flag)
   }
   NOTE: --for-issue/--for-mr do NOT use clap requires="project" — defaultProject handles project resolution at query time.

2. Add Notes(NotesArgs) to Commands enum (line 108-170, after Mrs variant at line 113):
   /// List notes from discussions
   Notes(NotesArgs),

3. Add "notes" minimal preset to expand_fields_preset() in src/cli/robot.rs (line 28):
   "notes" => ["id", "author_username", "body", "created_at_iso"]
   Add after the existing "search" preset (line 39).

4. Add handle_notes() in src/main.rs following the handle_issues/handle_mrs pattern. The dispatch logic should:
   - Build NoteListFilters from NotesArgs
   - Call query_notes() from NOTE-1A
   - Dispatch on format: robot/json → print_list_notes_json, jsonl → print_list_notes_jsonl, csv → print_list_notes_csv, table → print_list_notes
   (Print functions come from NOTE-1C)

5. Wire command dispatch in main.rs command match:
   Some(Commands::Notes(args)) => handle_notes(cli.config.as_deref(), args, robot_mode).await

6. Re-export in src/cli/commands/mod.rs (line 32-35 area):
   Add to pub use list:: { ..., run_list_notes, NoteListFilters, ... }

## Files
- MODIFY: src/cli/mod.rs (NotesArgs struct + Commands::Notes variant)
- MODIFY: src/cli/robot.rs (notes minimal preset at expand_fields_preset, line 28)
- MODIFY: src/main.rs (handle_notes function + command dispatch)
- MODIFY: src/cli/commands/mod.rs (add note exports to pub use list block, line 32)

## TDD Anchor
RED: test_expand_fields_preset_notes — assert expand_fields_preset(&["minimal".to_string()], "notes") returns ["id", "author_username", "body", "created_at_iso"]
GREEN: Add "notes" case to match in expand_fields_preset.
VERIFY: cargo test expand_fields_preset_notes -- --nocapture

## Acceptance Criteria
- [ ] lore notes command exists and dispatches to handle_notes
- [ ] All 19+ CLI flags parsed correctly by clap
- [ ] --for-issue and --for-mr are mutually exclusive (conflicts_with)
- [ ] --format supports table, json, jsonl, csv
- [ ] Robot mode auto-selects JSON output
- [ ] --fields minimal preset works for notes
- [ ] "notes" added to CLI autocorrect suggestions in src/cli/autocorrect.rs
- [ ] Test passes

## Dependency Context
- Depends on NOTE-1A (bd-20p9): uses NoteListFilters, query_notes, NoteListResult

## Edge Cases
- Robot mode without --format: auto-selects JSON (same pattern as issues/mrs)
- Unknown --format value: clap rejects before handler runs
- --for-issue without --project: handled at query layer (NOTE-1A), not clap


Add database migration 006_merge_requests.sql

## Background
Foundation for all CP2 MR features. This migration defines the schema that all other MR components depend on. Must complete BEFORE any other CP2 work can proceed.

## Approach
Create migration file that adds:
1. `merge_requests` table with all CP2 fields
2. `mr_labels`, `mr_assignees`, `mr_reviewers` junction tables
3. Indexes on discussions for MR queries
4. DiffNote position columns on notes table

## Files
- `migrations/006_merge_requests.sql` - New migration file
- `src/core/db.rs` - Update MIGRATIONS const to include version 6

## Acceptance Criteria
- [ ] Migration file exists at `migrations/006_merge_requests.sql`
- [ ] `merge_requests` table has columns: id, gitlab_id, project_id, iid, title, description, state, draft, author_username, source_branch, target_branch, head_sha, references_short, references_full, detailed_merge_status, merge_user_username, created_at, updated_at, merged_at, closed_at, last_seen_at, discussions_synced_for_updated_at, discussions_sync_last_attempt_at, discussions_sync_attempts, discussions_sync_last_error, web_url, raw_payload_id
- [ ] `mr_labels` junction table exists with (merge_request_id, label_id) PK
- [ ] `mr_assignees` junction table exists with (merge_request_id, username) PK
- [ ] `mr_reviewers` junction table exists with (merge_request_id, username) PK
- [ ] `idx_discussions_mr_id` and `idx_discussions_mr_resolved` indexes exist
- [ ] `notes` table has new columns: position_type, position_line_range_start, position_line_range_end, position_base_sha, position_start_sha, position_head_sha
- [ ] `gi doctor` runs without migration errors
- [ ] `cargo test` passes

## TDD Loop
RED: Cannot open DB with version 6 schema
GREEN: Add migration file with full SQL
VERIFY: `cargo run -- doctor` shows healthy DB

## SQL Reference (from PRD)
```sql
-- Merge requests table
CREATE TABLE merge_requests (
  id INTEGER PRIMARY KEY,
  gitlab_id INTEGER UNIQUE NOT NULL,
  project_id INTEGER NOT NULL REFERENCES projects(id),
  iid INTEGER NOT NULL,
  title TEXT,
  description TEXT,
  state TEXT,                         -- opened | merged | closed | locked
  draft INTEGER NOT NULL DEFAULT 0,   -- SQLite boolean
  author_username TEXT,
  source_branch TEXT,
  target_branch TEXT,
  head_sha TEXT,
  references_short TEXT,
  references_full TEXT,
  detailed_merge_status TEXT,
  merge_user_username TEXT,
  created_at INTEGER,                 -- ms epoch UTC
  updated_at INTEGER,
  merged_at INTEGER,
  closed_at INTEGER,
  last_seen_at INTEGER NOT NULL,
  discussions_synced_for_updated_at INTEGER,
  discussions_sync_last_attempt_at INTEGER,
  discussions_sync_attempts INTEGER DEFAULT 0,
  discussions_sync_last_error TEXT,
  web_url TEXT,
  raw_payload_id INTEGER REFERENCES raw_payloads(id)
);
CREATE INDEX idx_mrs_project_updated ON merge_requests(project_id, updated_at);
CREATE UNIQUE INDEX uq_mrs_project_iid ON merge_requests(project_id, iid);
-- ... (see PRD for full index list)

-- Junction tables
CREATE TABLE mr_labels (
  merge_request_id INTEGER REFERENCES merge_requests(id) ON DELETE CASCADE,
  label_id INTEGER REFERENCES labels(id) ON DELETE CASCADE,
  PRIMARY KEY(merge_request_id, label_id)
);

CREATE TABLE mr_assignees (
  merge_request_id INTEGER REFERENCES merge_requests(id) ON DELETE CASCADE,
  username TEXT NOT NULL,
  PRIMARY KEY(merge_request_id, username)
);

CREATE TABLE mr_reviewers (
  merge_request_id INTEGER REFERENCES merge_requests(id) ON DELETE CASCADE,
  username TEXT NOT NULL,
  PRIMARY KEY(merge_request_id, username)
);

-- DiffNote position columns (ALTER TABLE)
ALTER TABLE notes ADD COLUMN position_type TEXT;
ALTER TABLE notes ADD COLUMN position_line_range_start INTEGER;
ALTER TABLE notes ADD COLUMN position_line_range_end INTEGER;
ALTER TABLE notes ADD COLUMN position_base_sha TEXT;
ALTER TABLE notes ADD COLUMN position_start_sha TEXT;
ALTER TABLE notes ADD COLUMN position_head_sha TEXT;

INSERT INTO schema_version (version, applied_at, description)
VALUES (6, strftime('%s', 'now') * 1000, 'Merge requests, MR labels, assignees, reviewers');
```

## Edge Cases
- SQLite does not support ADD CONSTRAINT - FK defined as nullable in CP1
- `locked` state is transitional (merge-in-progress) - store as first-class
- discussions_synced_for_updated_at prevents redundant discussion refetch

Migration 006_merge_requests.sql created and verified. Schema v6 applied successfully with all tables, indexes, and position columns.