Audit and fill data gaps: lore detail view vs glab

## Background
For lore to be the definitive read path, its single-entity detail view must return everything glab returns PLUS lore-exclusive enrichments.

## Current Issue Detail Output (lore -J issues N)
Fields returned: assignees, author_username, closing_merge_requests, created_at, description, discussions, due_date, id, iid, labels, milestone, project_path, state, status_color, status_icon_name, status_name, status_synced_at, title, updated_at, web_url

## Gap Analysis (Verified 2026-02-12)

### Raw Payload Audit
Issue raw_payloads store exactly 15 fields: assignees, author, closed_at, created_at, description, due_date, id, iid, labels, milestone, project_id, state, title, updated_at, web_url.

Fields NOT in raw payloads (require ingestion pipeline update to capture from GitLab API):
- closed_by, confidential, upvotes, downvotes, weight, issue_type, time_stats, health_status, references

### Phase 1 — Computed fields (NO schema change, NO ingestion change)
These can be derived from existing data:
1. `references_full`: format!("{path_with_namespace}#{iid}") — project_path already in show.rs:IssueDetail
2. `user_notes_count`: SELECT COUNT(*) FROM notes n JOIN discussions d ON n.discussion_id = d.id WHERE d.noteable_type = 'Issue' AND d.noteable_id = ? AND n.is_system = 0
3. `merge_requests_count`: COUNT from closing_merge_requests vec already loaded in show.rs (just .len())

### Phase 2 — Extract from existing raw payloads (schema change, NO ingestion change)
`closed_at` IS in raw_payloads for closed issues. Can be backfilled:
1. Add `closed_at TEXT` column to issues table (migration 023)
2. Backfill: UPDATE issues SET closed_at = json_extract((SELECT payload FROM raw_payloads WHERE id = issues.raw_payload_id), '$.closed_at') WHERE state = 'closed'
3. Capture during ingestion going forward

### Phase 3 — Requires ingestion pipeline update (schema change + API capture)
These fields are in the GitLab Issues API response but NOT captured by lore's ingestion:
1. `closed_by` (object with username) — add closed_by_username TEXT to issues
2. `confidential` (boolean) — add confidential INTEGER DEFAULT 0 to issues
3. Both require updating src/ingestion/ to extract these fields during sync

### Phase 4 — Same audit for MR detail view
MR detail (src/cli/commands/show.rs MrDetail struct lines 14-33) already includes: closed_at, merged_at, draft, source/target branch, reviewers. Missing: approvers_count, pipeline_status.

## Implementation: show.rs Modifications

### IssueDetail struct (src/cli/commands/show.rs:69-91)
Add fields:
```rust
pub references_full: String,        // Phase 1: computed
pub user_notes_count: i64,          // Phase 1: computed
pub merge_requests_count: usize,    // Phase 1: computed (closing_merge_requests.len())
pub closed_at: Option<String>,      // Phase 2: from DB after migration
pub confidential: bool,             // Phase 3: from DB after ingestion update
```

### SQL for computed fields
```sql
-- user_notes_count
SELECT COUNT(*) FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE d.noteable_type = 'Issue' AND d.noteable_id = ?1 AND n.is_system = 0

-- references_full (in Rust)
format!("{}#{}", project_path, iid)

-- merge_requests_count (in Rust)
closing_merge_requests.len()
```

## Migration 023 (after bd-2l3s takes 022)
```sql
-- migrations/023_issue_detail_fields.sql
ALTER TABLE issues ADD COLUMN closed_at TEXT;
ALTER TABLE issues ADD COLUMN confidential INTEGER NOT NULL DEFAULT 0;

-- Backfill closed_at from raw_payloads
UPDATE issues SET closed_at = (
    SELECT json_extract(rp.payload, '$.closed_at')
    FROM raw_payloads rp
    WHERE rp.id = issues.raw_payload_id
) WHERE state = 'closed' AND raw_payload_id IS NOT NULL;

INSERT INTO schema_version (version, applied_at, description)
VALUES (23, strftime('%s', 'now') * 1000, 'Issue detail fields: closed_at, confidential');
```

NOTE: raw_payload_id column on issues — verify this exists. If issues don't have a direct FK to raw_payloads, the backfill SQL needs adjustment (may need to join through another path).

## TDD Loop
RED: Tests in src/cli/commands/show.rs:
- test_show_issue_has_references_full: insert issue with known project_path, assert JSON output contains "project/path#123"
- test_show_issue_has_notes_count: insert issue + 3 user notes + 1 system note, assert user_notes_count = 3
- test_show_issue_closed_has_closed_at: insert closed issue with closed_at in raw_payload, run migration, verify closed_at appears

GREEN: Add computed fields to IssueDetail, add migration 023 for closed_at + confidential columns

VERIFY:
```bash
cargo test show:: && cargo clippy --all-targets -- -D warnings
cargo run --release -- -J issues 3864 | jq '{references_full, user_notes_count, merge_requests_count}'
```

## Acceptance Criteria
- [ ] lore -J issues N includes references_full (string, e.g., "vs/typescript-code#3864")
- [ ] lore -J issues N includes user_notes_count (integer, excludes system notes)
- [ ] lore -J issues N includes merge_requests_count (integer)
- [ ] lore -J issues N includes closed_at (ISO string for closed issues, null for open)
- [ ] lore -J issues N includes confidential (boolean, after Phase 3)
- [ ] --fields minimal preset updated to include references_full
- [ ] Migration 023 adds closed_at and confidential columns to issues table
- [ ] Backfill SQL populates closed_at from existing raw_payloads
- [ ] cargo test passes with new show:: tests

## Edge Cases
- Issue with zero notes: user_notes_count = 0 (not null)
- Issue with no closing MRs: merge_requests_count = 0
- Open issue: closed_at = null (serialized as JSON null, not omitted)
- confidential before Phase 3: default false (safe default)
- MR detail: different computed fields (approvers_count, pipeline_status if available)
- Raw payload missing for very old issues (raw_payload_id = NULL): closed_at stays NULL
- raw_payload_id column: verify it exists on the issues table before writing backfill SQL

## Files to Modify
- src/cli/commands/show.rs (IssueDetail struct + query logic)
- src/core/db.rs (migration 023: wire into MIGRATIONS array)
- NEW: migrations/023_issue_detail_fields.sql
- src/ingestion/ (Phase 3: capture closed_by, confidential during sync — specify exact file after reviewing ingestion pipeline)

Data gaps filled: references_full, user_notes_count, merge_requests_count, closed_at, confidential via migration 023


[CP1] gi list issues command

List issues from the database.

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

## Clap Definition
List {
    #[arg(value_parser = ["issues", "mrs"])]
    entity: String,

    #[arg(long, default_value = "20")]
    limit: usize,

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

    #[arg(long, value_parser = ["opened", "closed", "all"])]
    state: Option<String>,
}

## Output Format
Issues (showing 20 of 3,801)

  #1234  Authentication redesign                       opened   @johndoe   3 days ago
  #1233  Fix memory leak in cache                      closed   @janedoe   5 days ago
  #1232  Add dark mode support                         opened   @bobsmith  1 week ago
  ...

## Implementation
- Query issues table with filters
- Join with projects table for display
- Format updated_at as relative time ("3 days ago")
- Truncate title if too long

Files: src/cli/commands/list.rs
Done when: List displays issues with proper filtering and formatting


OBSERV: Add log file diagnostics to lore doctor

## Background
lore doctor is the diagnostic entry point. Adding log file info lets users verify logging is working and check disk usage. The existing DoctorChecks struct (src/cli/commands/doctor.rs:43-51) has checks for config, database, gitlab, projects, ollama.

## Approach
Add a new LoggingCheck struct and field to DoctorChecks:

```rust
#[derive(Debug, Serialize)]
pub struct LoggingCheck {
    pub result: CheckResult,
    pub log_dir: String,
    pub file_count: usize,
    pub total_bytes: u64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub oldest_file: Option<String>,
}
```

Add to DoctorChecks (src/cli/commands/doctor.rs:43-51):
```rust
pub logging: LoggingCheck,
```

Implement check_logging() function:
```rust
fn check_logging() -> LoggingCheck {
    let log_dir = get_log_dir(None); // TODO: accept config override
    let mut file_count = 0;
    let mut total_bytes = 0u64;
    let mut oldest: Option<String> = None;

    if let Ok(entries) = std::fs::read_dir(&log_dir) {
        for entry in entries.flatten() {
            let name = entry.file_name().to_string_lossy().to_string();
            if name.starts_with("lore.") && name.ends_with(".log") {
                file_count += 1;
                if let Ok(meta) = entry.metadata() {
                    total_bytes += meta.len();
                }
                if oldest.as_ref().map_or(true, |o| name < *o) {
                    oldest = Some(name);
                }
            }
        }
    }

    LoggingCheck {
        result: CheckResult { status: CheckStatus::Ok, message: None },
        log_dir: log_dir.display().to_string(),
        file_count,
        total_bytes,
        oldest_file: oldest,
    }
}
```

Call from run_doctor() (src/cli/commands/doctor.rs:91-126) and add to DoctorChecks construction.

For interactive output in print_doctor_results(), add a section:
```
Logging
  Log directory: ~/.local/share/lore/logs/
  Log files: 7 (2.3 MB)
  Oldest: lore.2026-01-28.log
```

## Acceptance Criteria
- [ ] lore doctor shows log directory path, file count, total size
- [ ] lore --robot doctor JSON includes logging field with log_dir, file_count, total_bytes, oldest_file
- [ ] When no log files exist: file_count=0, total_bytes=0, oldest_file=null
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/cli/commands/doctor.rs (add LoggingCheck struct, check_logging fn, wire into DoctorChecks)

## TDD Loop
RED: test_check_logging_with_files, test_check_logging_empty_dir
GREEN: Implement LoggingCheck struct and check_logging function
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- Log directory doesn't exist yet (first run before any sync): report file_count=0, status Ok
- Permission errors on read_dir: report status Warning with message

Added LoggingCheck to DoctorChecks with log_dir, file_count, total_bytes; shows in both interactive and robot output


[CP1] Database migration 002_issues.sql

## Background

The 002_issues.sql migration creates tables for issues, labels, issue_labels, discussions, and notes. This is the data foundation for Checkpoint 1, enabling issue ingestion with cursor-based sync, label tracking, and discussion storage.

## Approach

Create `migrations/002_issues.sql` with complete SQL statements.

### Full Migration SQL

```sql
-- Migration 002: Issue Ingestion Tables
-- Applies on top of 001_initial.sql

-- Issues table
CREATE TABLE issues (
    id INTEGER PRIMARY KEY,
    gitlab_id INTEGER UNIQUE NOT NULL,
    project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    iid INTEGER NOT NULL,
    title TEXT,
    description TEXT,
    state TEXT NOT NULL CHECK (state IN ('opened', 'closed')),
    author_username TEXT,
    created_at INTEGER NOT NULL,           -- ms epoch UTC
    updated_at INTEGER NOT NULL,           -- ms epoch UTC
    last_seen_at INTEGER NOT NULL,         -- updated on every upsert
    discussions_synced_for_updated_at INTEGER, -- watermark for dependent sync
    web_url TEXT,
    raw_payload_id INTEGER REFERENCES raw_payloads(id)
);

CREATE INDEX idx_issues_project_updated ON issues(project_id, updated_at);
CREATE INDEX idx_issues_author ON issues(author_username);
CREATE UNIQUE INDEX uq_issues_project_iid ON issues(project_id, iid);

-- Labels table (name-only for CP1)
CREATE TABLE labels (
    id INTEGER PRIMARY KEY,
    gitlab_id INTEGER,                      -- optional, for future Labels API
    project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    name TEXT NOT NULL,
    color TEXT,
    description TEXT
);

CREATE UNIQUE INDEX uq_labels_project_name ON labels(project_id, name);
CREATE INDEX idx_labels_name ON labels(name);

-- Issue-label junction (DELETE before INSERT for stale removal)
CREATE TABLE issue_labels (
    issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
    label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
    PRIMARY KEY(issue_id, label_id)
);

CREATE INDEX idx_issue_labels_label ON issue_labels(label_id);

-- Discussion threads for issues (MR discussions added in CP2)
CREATE TABLE discussions (
    id INTEGER PRIMARY KEY,
    gitlab_discussion_id TEXT NOT NULL,     -- GitLab string ID (e.g., "6a9c1750b37d...")
    project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    issue_id INTEGER REFERENCES issues(id) ON DELETE CASCADE,
    merge_request_id INTEGER,               -- FK added in CP2 via ALTER TABLE
    noteable_type TEXT NOT NULL CHECK (noteable_type IN ('Issue', 'MergeRequest')),
    individual_note INTEGER NOT NULL DEFAULT 0,  -- 0=threaded, 1=standalone
    first_note_at INTEGER,                  -- min(note.created_at) for ordering
    last_note_at INTEGER,                   -- max(note.created_at) for "recently active"
    last_seen_at INTEGER NOT NULL,          -- updated on every upsert
    resolvable INTEGER NOT NULL DEFAULT 0,  -- MR discussions can be resolved
    resolved INTEGER NOT NULL DEFAULT 0,
    CHECK (
        (noteable_type = 'Issue' AND issue_id IS NOT NULL AND merge_request_id IS NULL) OR
        (noteable_type = 'MergeRequest' AND merge_request_id IS NOT NULL AND issue_id IS NULL)
    )
);

CREATE UNIQUE INDEX uq_discussions_project_discussion_id ON discussions(project_id, gitlab_discussion_id);
CREATE INDEX idx_discussions_issue ON discussions(issue_id);
CREATE INDEX idx_discussions_mr ON discussions(merge_request_id);
CREATE INDEX idx_discussions_last_note ON discussions(last_note_at);

-- Notes belong to discussions
CREATE TABLE notes (
    id INTEGER PRIMARY KEY,
    gitlab_id INTEGER UNIQUE NOT NULL,
    discussion_id INTEGER NOT NULL REFERENCES discussions(id) ON DELETE CASCADE,
    project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    note_type TEXT,                         -- 'DiscussionNote' | 'DiffNote' | null
    is_system INTEGER NOT NULL DEFAULT 0,   -- 1 for system-generated notes
    author_username TEXT,
    body TEXT,
    created_at INTEGER NOT NULL,            -- ms epoch
    updated_at INTEGER NOT NULL,            -- ms epoch
    last_seen_at INTEGER NOT NULL,          -- updated on every upsert
    position INTEGER,                       -- 0-indexed array order from API
    resolvable INTEGER NOT NULL DEFAULT 0,
    resolved INTEGER NOT NULL DEFAULT 0,
    resolved_by TEXT,
    resolved_at INTEGER,
    -- DiffNote position metadata (populated for MR DiffNotes in CP2)
    position_old_path TEXT,
    position_new_path TEXT,
    position_old_line INTEGER,
    position_new_line INTEGER,
    raw_payload_id INTEGER REFERENCES raw_payloads(id)
);

CREATE INDEX idx_notes_discussion ON notes(discussion_id);
CREATE INDEX idx_notes_author ON notes(author_username);
CREATE INDEX idx_notes_system ON notes(is_system);

-- Update schema version
INSERT INTO schema_version (version, applied_at, description)
VALUES (2, strftime('%s', 'now') * 1000, 'Issue ingestion tables');
```

## Acceptance Criteria

- [ ] Migration file exists at `migrations/002_issues.sql`
- [ ] All tables created: issues, labels, issue_labels, discussions, notes
- [ ] All indexes created as specified
- [ ] CHECK constraints on state and noteable_type work correctly
- [ ] CASCADE deletes work (project deletion cascades)
- [ ] Migration applies cleanly on fresh DB after 001_initial.sql
- [ ] schema_version updated to 2 after migration
- [ ] `gi doctor` shows schema_version = 2

## Files

- migrations/002_issues.sql (create)

## TDD Loop

RED:
```rust
// tests/migration_tests.rs
#[test] fn migration_002_creates_issues_table()
#[test] fn migration_002_creates_labels_table()
#[test] fn migration_002_creates_discussions_table()
#[test] fn migration_002_creates_notes_table()
#[test] fn migration_002_enforces_state_check()
#[test] fn migration_002_enforces_noteable_type_check()
#[test] fn migration_002_cascades_on_project_delete()
```

GREEN: Create migration file with all SQL

VERIFY:
```bash
# Apply migration to test DB
sqlite3 :memory: < migrations/001_initial.sql
sqlite3 :memory: < migrations/002_issues.sql

# Verify schema_version
sqlite3 test.db "SELECT version FROM schema_version ORDER BY version DESC LIMIT 1"
# Expected: 2

cargo test migration_002
```

## Edge Cases

- Applying twice - should fail on UNIQUE constraint (idempotency via version check)
- Missing 001 - foreign key to projects fails
- Long label names - TEXT handles any length
- NULL description - allowed by schema
- Empty discussions_synced_for_updated_at - NULL means never synced

Created 002_issues.sql with issues/labels/issue_labels/discussions/notes tables, 8 passing tests verify schema, constraints, and cascades


Implement Doctor + Stats screens

## Background
Doctor shows environment health checks (config, auth, DB, Ollama). Stats shows database statistics (entity counts, index sizes, FTS coverage). Both are informational screens using ftui JsonView or simple table layouts.

## Approach
State:
- DoctorState: checks (Vec<HealthCheck>), overall_status (Healthy|Warning|Error)
- StatsState: entity_stats (EntityStats), index_stats (IndexStats), fts_stats (FtsStats)

Action:
- run_doctor(config, conn) -> Vec<HealthCheck>: reuses existing lore doctor logic
- fetch_stats(conn) -> StatsData: reuses existing lore stats logic

View:
- Doctor: vertical list of health checks with pass/fail/warn indicators
- Stats: table of entity counts, index sizes, FTS document count, embedding coverage

## Acceptance Criteria
- [ ] Doctor shows config, auth, DB, and Ollama health status
- [ ] Stats shows entity counts matching lore --robot stats output
- [ ] Both screens accessible via navigation (gd for Doctor)
- [ ] Health check results color-coded: green pass, yellow warn, red fail

## Files
- CREATE: crates/lore-tui/src/state/doctor.rs
- CREATE: crates/lore-tui/src/state/stats.rs
- CREATE: crates/lore-tui/src/view/doctor.rs
- CREATE: crates/lore-tui/src/view/stats.rs
- MODIFY: crates/lore-tui/src/action.rs (add run_doctor, fetch_stats)

## TDD Anchor
RED: Write test_fetch_stats_counts that creates DB with known data, asserts fetch_stats returns correct counts.
GREEN: Implement fetch_stats with COUNT queries.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_fetch_stats

## Edge Cases
- Ollama not running: Doctor shows warning, not error (optional dependency)
- Very large databases: stats queries should be fast (use shadow tables for FTS count)

## Dependency Context
Uses existing doctor and stats logic from lore CLI commands.
Uses DbManager from "Implement DbManager" task.


Migration 021: Add status columns to issues table

## Background
GitLab issues have work item status (To do, In progress, Done, Won't do, Duplicate) only available via GraphQL. We need 5 nullable columns on the issues table to store this data after enrichment. The status_synced_at column tracks when enrichment last wrote/cleared each row (ms epoch UTC).

## Approach
Create a new SQL migration file and register it in the MIGRATIONS array. SQLite ALTER TABLE ADD COLUMN is non-destructive — existing rows get NULL defaults. Add a compound index for --status filter performance.

## Files
- migrations/021_work_item_status.sql (NEW)
- src/core/db.rs (add entry to MIGRATIONS array)

## Implementation

migrations/021_work_item_status.sql:
  ALTER TABLE issues ADD COLUMN status_name TEXT;
  ALTER TABLE issues ADD COLUMN status_category TEXT;
  ALTER TABLE issues ADD COLUMN status_color TEXT;
  ALTER TABLE issues ADD COLUMN status_icon_name TEXT;
  ALTER TABLE issues ADD COLUMN status_synced_at INTEGER;
  CREATE INDEX IF NOT EXISTS idx_issues_project_status_name ON issues(project_id, status_name);

In src/core/db.rs, add as last entry in MIGRATIONS array:
  ("021", include_str!("../../migrations/021_work_item_status.sql")),
LATEST_SCHEMA_VERSION is computed as MIGRATIONS.len() as i32 — auto-becomes 21.

## Acceptance Criteria
- [ ] Migration file exists at migrations/021_work_item_status.sql
- [ ] MIGRATIONS array has 21 entries ending with ("021", ...)
- [ ] In-memory DB: PRAGMA table_info(issues) includes all 5 new columns
- [ ] In-memory DB: PRAGMA index_list(issues) includes idx_issues_project_status_name
- [ ] Existing rows have NULL for all 5 new columns
- [ ] cargo check --all-targets passes

## TDD Loop
RED: test_migration_021_adds_columns, test_migration_021_adds_index
  Pattern: create_connection(Path::new(":memory:")) + run_migrations(&conn), then PRAGMA queries
GREEN: Create SQL file + register in MIGRATIONS
VERIFY: cargo test test_migration_021

## Edge Cases
- Migration has 5 columns (including status_synced_at INTEGER), not 4
- Test project insert uses gitlab_project_id, path_with_namespace, web_url (no name/last_seen_at)

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


Implement DbManager (read pool + dedicated writer)

## Background
The TUI needs concurrent database access: multiple read queries running in parallel (screen loads, search, filter) while a single writer handles sync updates. DbManager provides closure-based access (with_reader/with_writer) to prevent lock-poison panics and accidental long-held guards. All connections use std::sync::Mutex (not tokio). SQLite WAL mode enables concurrent readers alongside a single writer.

## Approach
Create crates/lore-tui/src/db_manager.rs with:
- DbManager struct: readers (Vec of Mutex<Connection>), writer (Mutex<Connection>), reader_index (AtomicUsize for round-robin)
- DbManager::new(db_path: &Path, reader_count: usize) -> Result<Self, LoreError>: opens reader_count read-only connections + 1 read-write connection, enables WAL mode, sets busy_timeout, enables foreign_keys
- with_reader<T>(f: impl FnOnce(&Connection) -> Result<T, LoreError>) -> Result<T, LoreError>: round-robin selects a reader, locks it, runs closure
- with_writer<T>(f: impl FnOnce(&Connection) -> Result<T, LoreError>) -> Result<T, LoreError>: locks writer, runs closure
- ReaderLease: for long-running queries that need an interrupt handle. lease_reader() -> (ReaderLease, InterruptHandle). Leased connections are NOT returned to the pool while leased.
- with_read_snapshot<T>(conn: &Connection, f: impl FnOnce(&Transaction) -> Result<T, LoreError>) -> Result<T, LoreError>: wraps queries in a deferred read transaction for snapshot consistency
- DEFAULT_READER_COUNT = 3

## Acceptance Criteria
- [ ] DbManager::new() creates 3 readers + 1 writer, all with WAL mode enabled
- [ ] with_reader() round-robins across readers
- [ ] with_writer() provides exclusive write access
- [ ] Closure-based API prevents Connection escaping the closure scope
- [ ] lease_reader() returns a ReaderLease with InterruptHandle for cancellable queries
- [ ] with_read_snapshot() wraps queries in deferred transaction
- [ ] All connections set busy_timeout(5000) and pragma foreign_keys=ON
- [ ] Test: concurrent with_reader calls from multiple threads don't panic

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

## TDD Anchor
RED: Write test_dbmanager_round_robin that creates DbManager with 3 readers on an in-memory DB, calls with_reader 6 times, and verifies each reader is used twice (by tracking via a shared atomic counter or similar).
GREEN: Implement DbManager with round-robin reader selection.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_dbmanager

Additional tests:
- test_dbmanager_writer_isolation: writer and reader connections are separate
- test_read_snapshot_consistency: with_read_snapshot provides repeatable reads
- test_lease_reader_interrupt: leased reader can be interrupted via InterruptHandle

## Edge Cases
- In-memory DBs can't share connections across threads — tests must use file-based temp DBs or configure shared cache
- Mutex poisoning on panic: use .lock().expect() with clear message, or consider parking_lot::Mutex which doesn't poison
- Reader round-robin uses AtomicUsize with Relaxed ordering — sufficient since we're just load-balancing, not synchronizing
- WAL mode must be set on every connection, not just the first one


Implement MR List (state + action + view)

## Background
The MR List mirrors the Issue List pattern with MR-specific columns (target branch, source branch, draft status, reviewer). Same keyset pagination and filter bar DSL.

## Approach
State (state/mr_list.rs):
- MrListState: same structure as IssueListState but with MrFilter and MrListRow
- MrFilter: state, author, reviewer, target_branch, source_branch, label, draft (bool), free_text, project_id
- MrListRow: project_path, iid, title, state, author, reviewer, target_branch, labels, updated_at, draft
- MrCursor: updated_at, iid for keyset pagination

Action (action.rs):
- fetch_mrs(conn, filter, cursor, page_size, clock) -> Result<MrListPage, LoreError>: keyset query against merge_requests table. Uses idx_mrs_list_default index.

View (view/mr_list.rs):
- render_mr_list(frame, state, area, theme): FilterBar + EntityTable with MR columns
- Columns: IID, Title (flex), State, Author, Target, Labels, Updated, Draft indicator
- Draft MRs shown with muted style and [WIP] tag

## Acceptance Criteria
- [ ] Keyset pagination works for MR list (same pattern as issues)
- [ ] MR-specific filter fields: draft, reviewer, target_branch, source_branch
- [ ] Draft MRs visually distinguished with [WIP] indicator
- [ ] State filter supports: opened, merged, closed, locked, all
- [ ] Columns: IID, Title, State, Author, Target Branch, Labels, Updated
- [ ] Enter navigates to MrDetail, Esc returns with state preserved

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

## TDD Anchor
RED: Write test_fetch_mrs_draft_filter in action.rs that inserts 5 MRs (3 draft, 2 not), calls fetch_mrs with draft=true filter, asserts 3 results.
GREEN: Implement fetch_mrs with draft filter.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_fetch_mrs

## Edge Cases
- MR state "locked" is rare but must be handled in filter and display
- Very long branch names: truncate with ellipsis
- MRs with no reviewer: show "-" in reviewer column

## Dependency Context
Uses EntityTable and FilterBar from "Implement entity table + filter bar widgets" task.
Uses same keyset pagination pattern from "Implement Issue List" task.
Uses MrListState from "Implement AppState composition" task.


Per-note search: search individual comments at note granularity

## Background
Most knowledge in a GitLab project is buried in discussion threads. Current lore search operates at document level (one doc per issue/MR/discussion). An agent searching for "we decided to use Redis" only finds the parent issue, not the specific comment where that decision was stated.

## Current State (Verified 2026-02-12)
- documents table (migration 007): source_type, source_id, project_id, author_username, label_names, content_text, content_hash, etc. NO source_note_id column.
- source_type values: 'issue', 'merge_request', 'discussion' — discussion docs concatenate all notes into one text blob
- notes table: 282K rows with individual note body, author, timestamps, is_system flag
- discussions table: links notes to their parent entity (noteable_type, noteable_id)
- FTS5 index (documents_fts): operates on coarse document-level text
- Document generation: src/documents/extractor.rs extracts issue/MR/discussion documents
- Document regeneration: src/documents/regenerator.rs handles dirty document refresh
- PRD exists: docs/prd-per-note-search.md with 5 feedback iterations

## Approach

### Schema (Migration 022)
This bead owns migration 022. bd-2g50 (data gaps) ships after this and uses migration 023.

```sql
-- migrations/022_note_documents.sql
ALTER TABLE documents ADD COLUMN source_note_id INTEGER REFERENCES notes(id);
CREATE INDEX idx_documents_source_note ON documents(source_note_id) WHERE source_note_id IS NOT NULL;
```
- source_note_id = NULL for existing entity-level documents (backwards compatible)
- source_note_id = notes.id for new note-level documents

Wire into src/core/db.rs MIGRATIONS array as entry ("022", include_str!("../../migrations/022_note_documents.sql")). LATEST_SCHEMA_VERSION auto-updates since it's `MIGRATIONS.len() as i32`.

### Document Generation (src/documents/extractor.rs)
Add a new extraction function alongside existing `extract_issue_document()` (line 85), `extract_mr_document()` (line 186), `extract_discussion_document()` (line 302):

```rust
pub fn extract_note_documents(
    conn: &Connection,
    project_id: i64,
) -> Result<Vec<DocumentData>> {
    // SELECT n.id, n.body, n.author_username, n.created_at, n.updated_at,
    //        d.noteable_type, d.noteable_id
    // FROM notes n
    // JOIN discussions d ON n.discussion_id = d.id
    // WHERE n.is_system = 0
    //   AND LENGTH(n.body) >= 50
    //   AND d.project_id = ?1
    //   AND n.id NOT IN (SELECT source_note_id FROM documents WHERE source_note_id IS NOT NULL)

    // For each qualifying note:
    // - source_type = 'note'
    // - source_id = note.id (the note's local DB id)
    // - source_note_id = note.id
    // - title = format!("Re: {}", parent_entity_title)
    // - author_username = note.author_username
    // - content_text = note.body
    // - content_hash = sha256(note.body) for deduplication
}
```

Minimum note length (50 chars) filters out "+1", "LGTM", emoji-only notes. is_system=0 filters automated state change notes.

NOTE: The documents table CHECK constraint for source_type needs updating — currently enforces `CHECK (source_type IN ('issue','merge_request','discussion'))`. Migration 022 must also:
```sql
-- Drop and recreate the CHECK constraint is not supported in SQLite ALTER TABLE.
-- Instead, the check is only on INSERT, so we need to handle this:
-- Option A: Don't add 'note' to CHECK — just insert with source_type='note' and let
--           SQLite ignore the CHECK on ALTER (it won't — CHECK is enforced).
-- Option B: Use source_type='discussion' for note docs (semantically wrong).
-- Option C: Recreate the table (heavy migration).
-- RECOMMENDED: Use a new migration that drops the CHECK constraint entirely.
--   SQLite doesn't support ALTER TABLE ... DROP CONSTRAINT, so:
--   CREATE TABLE documents_new (... without CHECK ...);
--   INSERT INTO documents_new SELECT * FROM documents;
--   DROP TABLE documents;
--   ALTER TABLE documents_new RENAME TO documents;
--   Recreate indexes and triggers.
-- This is the only correct approach. The CHECK constraint is in migration 007.
```

### Search Integration
Add --granularity flag to search command:

```rust
// In SearchCliFilters or SearchFilters (src/search/filters.rs:15)
pub granularity: Option<Granularity>,  // note | entity (default)

// In FTS query construction (src/search/fts.rs)
// When granularity = note:
//   AND d.source_note_id IS NOT NULL
// When granularity = entity (or default):
//   AND d.source_note_id IS NULL (existing behavior)
```

### Robot Mode Output (note granularity)
```json
{
  "source_type": "note",
  "title": "Re: Switch Health Card",
  "parent_type": "issue",
  "parent_iid": 3864,
  "parent_title": "Switch Health Card (Throw Times)",
  "note_author": "teernisse",
  "note_created_at": "2026-02-01T...",
  "discussion_id": "abc123",
  "snippet": "...decided to use once-per-day ingestion from BNSF...",
  "score": 0.87
}
```

Join path for note metadata:
```sql
SELECT d.source_note_id, n.author_username, n.created_at,
       disc.gitlab_discussion_id,
       CASE disc.noteable_type
         WHEN 'Issue' THEN 'issue'
         WHEN 'MergeRequest' THEN 'merge_request'
       END as parent_type,
       disc.noteable_id
FROM documents d
JOIN notes n ON d.source_note_id = n.id
JOIN discussions disc ON n.discussion_id = disc.id
WHERE d.source_note_id IS NOT NULL AND d.id IN (...)
```

## TDD Loop
RED: Tests in src/documents/extractor.rs (or new test file):
- test_note_document_generation: insert issue + discussion + 3 notes (one 10 chars, one 60 chars, one 200 chars), run extract_note_documents, assert 2 note-level documents created (>= 50 chars only)
- test_note_document_skips_system_notes: insert system note (is_system=1) with 100-char body, assert no document generated
- test_note_document_content_hash_dedup: insert note, generate doc, re-run, assert no duplicate created
- test_note_document_parent_title: assert generated doc title starts with "Re: "

Tests in src/cli/commands/search.rs:
- test_search_granularity_note_filter: with note docs in DB, --granularity note returns only note results
- test_search_granularity_entity_default: default behavior unchanged, does NOT return note docs

GREEN: Add migration, update extractor, add --granularity flag to search

VERIFY:
```bash
cargo test note_document && cargo test search_granularity
cargo clippy --all-targets -- -D warnings
cargo run --release -- -J search 'ingestion' --granularity note | jq '.data.results[0].parent_iid'
```

## Acceptance Criteria
- [ ] Migration 022 adds source_note_id to documents table (nullable, indexed, FK to notes)
- [ ] Migration 022 handles the source_type CHECK constraint (allows 'note' as valid value)
- [ ] extract_note_documents creates note-level docs for notes >= 50 chars, non-system
- [ ] Content hash deduplication prevents duplicate note documents
- [ ] lore search --granularity note returns note-level results with parent context
- [ ] lore search (no flag) returns entity-level results only (backwards compatible)
- [ ] Robot mode includes parent_type, parent_iid, parent_title, note_author, note_created_at
- [ ] Performance: note-level FTS search across expanded index completes in <200ms
- [ ] Embedding pipeline handles note-level documents (embed individually, same as entity docs)
- [ ] lore stats shows note document count separately from entity document count

## Edge Cases
- Note with only markdown formatting (no text after stripping): skip (LENGTH(body) >= 50 handles most)
- Note body is a quote of another note (duplicated text): deduplicate via content_hash
- Very long note (>32KB): apply same truncation as entity documents (src/documents/truncation.rs)
- Discussion with 100+ notes: each becomes its own document (correct behavior)
- Deleted notes (if tracked): should not generate documents
- Notes on confidential issues: inherit visibility (future concern, not blocking)
- source_type CHECK constraint: migration MUST handle this — SQLite enforces CHECK on INSERT, so inserting source_type='note' will fail without updating the constraint

## Files to Modify
- NEW: migrations/022_note_documents.sql (schema change + CHECK constraint update)
- src/core/db.rs (wire migration 022 into MIGRATIONS array)
- src/documents/extractor.rs (add extract_note_documents function)
- src/documents/mod.rs (export new function)
- src/search/fts.rs (add granularity filter to FTS queries)
- src/search/filters.rs (add granularity to SearchFilters at line 15)
- src/cli/commands/search.rs (--granularity flag, note metadata in SearchResultDisplay)
- src/cli/commands/stats.rs (show note document count)

Replaced by granular beads broken out from docs/prd-per-note-search.md


WHO: Mode resolution, path helpers, run_who entry point

## Background

Core scaffolding that all 5 query modes depend on. Defines the mode discrimination logic, path normalization, path-to-SQL translation (with project-scoped DB probes), time resolution, and the run_who() entry point that dispatches to query functions.

## Approach

### WhoMode enum
```rust
enum WhoMode<'a> {
    Expert { path: String },       // owns String (normalization produces new strings)
    Workload { username: &'a str },  // borrows from args
    Reviews { username: &'a str },
    Active,
    Overlap { path: String },
}
```

### resolve_mode() discrimination rules:
1. --path flag always wins -> Expert
2. --active -> Active
3. --overlap -> Overlap
4. positional target with --reviews -> Reviews
5. positional target containing '/' -> Expert (username never contains /)
6. positional target without '/' -> Workload (strip @ prefix)
7. No args -> error with usage examples

### normalize_repo_path(): strips ./, leading /, collapses //, converts \ to / (Windows paste, only when no / present), trims whitespace

### PathQuery + build_path_query(conn, path, project_id):
- Struct: `{ value: String, is_prefix: bool }`
- Trailing / forces directory prefix
- Root path (no /) without trailing / -> exact match (handles Makefile, LICENSE via --path)
- Last segment contains . -> heuristic: file (exact)
- **Two-way DB probe** (project-scoped): when heuristics are ambiguous, probe DB:
  - Probe 1: exact path exists? `SELECT 1 FROM notes WHERE note_type='DiffNote' AND is_system=0 AND position_new_path = ?1 AND (?2 IS NULL OR project_id = ?2) LIMIT 1`
  - Probe 2 (only if exact miss, not forced-dir): prefix exists?
  - Decision: forced_dir -> prefix; exact_exists -> exact; prefix_exists -> prefix; else heuristic
- **CRITICAL**: escape_like() is ONLY called for prefix (LIKE) matches. For exact matches (=), use raw path — LIKE metacharacters (_, %) are not special in = comparisons.

### Result types: WhoRun, WhoResolvedInput (since_mode tri-state: "default"/"explicit"/"none"), WhoResult enum, all 5 mode-specific result structs (see plan Step 2 "Result Types")

### run_who() entry: resolve project -> resolve mode -> resolve since -> dispatch to query_* -> return WhoRun

### since_mode semantics:
- Expert/Reviews/Active/Overlap: default window applies if --since absent -> "default"
- Workload: no default window; --since absent -> "none"
- Any mode with explicit --since -> "explicit"

## Files

- `src/cli/commands/who.rs` — all code in this file

## TDD Loop

RED:
```
test_is_file_path_discrimination — resolve_mode for paths/usernames/@/--reviews/--path
test_build_path_query — directory/file/root/dotted/underscore/dotless
test_build_path_query_exact_does_not_escape — _ in exact path stays raw
test_path_flag_dotless_root_file_is_exact — Makefile/Dockerfile via --path
test_build_path_query_dotless_subdir_file_uses_db_probe — src/Dockerfile with/without DB data
test_build_path_query_probe_is_project_scoped — data in proj 1, query proj 2
test_escape_like — normal/underscore/percent/backslash
test_normalize_repo_path — ./ / \\ // whitespace identity
test_lookup_project_path — basic round-trip
```

GREEN: Implement all functions. Query functions can be stubs (todo!()) for now.
VERIFY: `cargo test -- who`

## Acceptance Criteria

- [ ] resolve_mode correctly discriminates all 7 cases (see tests)
- [ ] build_path_query returns exact for files, prefix for dirs
- [ ] build_path_query DB probe is project-scoped (cross-project isolation)
- [ ] escape_like escapes %, _, \ correctly
- [ ] normalize_repo_path handles ./, /, \\, //, whitespace
- [ ] WhoResolvedInput.since_mode is "none" for Workload without --since

## Edge Cases

- Dotless files in subdirectories (src/Dockerfile, infra/Makefile) — DB probe catches these, heuristic alone would misclassify as directory
- Windows path paste (src\foo\bar.rs) — convert \ to / only when no / present
- LIKE metacharacters in filenames (README_with_underscore.md) — must NOT be escaped for exact match
- Root files without / (README.md, LICENSE, Makefile) — must use --path flag, positional would treat as username

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


Implement Clock trait (SystemClock + FakeClock)

## Background
All relative-time rendering (e.g., "3h ago" labels) must use an injected Clock, not wall-clock time. This ensures deterministic snapshot tests and consistent timestamps within a single frame. FakeClock lets tests control time precisely.

## Approach
Create crates/lore-tui/src/clock.rs with:
- Clock trait: fn now(&self) -> chrono::DateTime<chrono::Utc>
- SystemClock: impl Clock using chrono::Utc::now()
- FakeClock: wraps Arc<Mutex<DateTime<Utc>>>, impl Clock returning the frozen value. Methods: new(fixed_time), advance(duration), set(time)
- Both cloneable (SystemClock is Copy, FakeClock shares Arc)

## Acceptance Criteria
- [ ] Clock trait with now() method
- [ ] SystemClock returns real wall-clock time
- [ ] FakeClock returns frozen time, advance() moves it forward
- [ ] FakeClock is Clone (shared Arc)
- [ ] Tests pass: frozen clock returns same time on repeated calls
- [ ] Tests pass: advance() moves time forward by exact duration

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

## TDD Anchor
RED: Write test_fake_clock_frozen that creates FakeClock at a fixed time, calls now() twice, asserts both return the same value.
GREEN: Implement FakeClock with Arc<Mutex<DateTime>>.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_fake_clock

## Edge Cases
- FakeClock must be Send+Sync for use across Cmd::task threads
- advance() must handle chrono overflow gracefully (use checked_add)


[CP1] Unit tests for transformers

Comprehensive unit tests for issue and discussion transformers.

## Issue Transformer Tests (tests/issue_transformer_tests.rs)

- transforms_gitlab_issue_to_normalized_schema
- extracts_labels_from_issue_payload
- handles_missing_optional_fields_gracefully
- converts_iso_timestamps_to_ms_epoch
- sets_last_seen_at_to_current_time

## Discussion Transformer Tests (tests/discussion_transformer_tests.rs)

- transforms_discussion_payload_to_normalized_schema
- extracts_notes_array_from_discussion
- sets_individual_note_flag_correctly
- flags_system_notes_with_is_system_true
- preserves_note_order_via_position_field
- computes_first_note_at_and_last_note_at_correctly
- computes_resolvable_and_resolved_status

## Test Setup
- Load from test fixtures
- Use serde_json for deserialization
- Compare against expected NormalizedX structs

Files: tests/issue_transformer_tests.rs, tests/discussion_transformer_tests.rs
Done when: All transformer unit tests pass


Epic: Gate A - Lexical MVP

## Background
Gate A delivers the lexical search MVP — the foundation that works without sqlite-vec or Ollama. It introduces the document layer (documents, document_labels, document_paths), FTS5 indexing, search filters, and the search + stats + generate-docs CLI commands. Gate A is independently shippable — users get working search with FTS5 only.

## Gate A Deliverables
1. Document generation from issues/MRs/discussions with FTS5 indexing
2. Lexical search + filters + snippets + lore stats

## Bead Dependencies (execution order)
1. **bd-3lc** — Rename GiError to LoreError (no deps, enables all subsequent work)
2. **bd-hrs** — Migration 007 (blocked by bd-3lc)
3. **bd-221** — Migration 008 FTS5 (blocked by bd-hrs)
4. **bd-36p** — Document types + extractor module (blocked by bd-3lc)
5. **bd-18t** — Truncation logic (blocked by bd-36p)
6. **bd-247** — Issue extraction (blocked by bd-36p, bd-hrs)
7. **bd-1yz** — MR extraction (blocked by bd-36p, bd-hrs)
8. **bd-2fp** — Discussion extraction (blocked by bd-36p, bd-hrs, bd-18t)
9. **bd-1u1** — Document regenerator (blocked by bd-36p, bd-38q, bd-hrs)
10. **bd-1k1** — FTS5 search (blocked by bd-221)
11. **bd-3q2** — Search filters (blocked by bd-36p)
12. **bd-3lu** — Search CLI (blocked by bd-1k1, bd-3q2, bd-36p)
13. **bd-3qs** — Generate-docs CLI (blocked by bd-1u1, bd-3lu)
14. **bd-pr1** — Stats CLI (blocked by bd-hrs)
15. **bd-2dk** — Project resolution (blocked by bd-3lc)

## Acceptance Criteria
- [ ] `lore search "query"` returns FTS5 results with snippets
- [ ] `lore search --type issue --label bug "query"` filters correctly
- [ ] `lore generate-docs` creates documents from all entities
- [ ] `lore generate-docs --full` regenerates everything
- [ ] `lore stats` shows document/FTS/queue counts
- [ ] `lore stats --check` verifies FTS consistency
- [ ] No sqlite-vec dependency in Gate A

All Gate A sub-beads complete. Lexical MVP delivered: document extraction (issue/MR/discussion), FTS5 indexing, search with filters/snippets/RRF, generate-docs CLI, stats CLI with integrity check/repair.


Implement trace query: file -> MR -> issue -> discussion chain

## Background

The trace query builds a chain from file path -> MRs -> issues -> discussions, combining data from mr_file_changes (Gate 4), entity_references (Gate 2), and the existing discussions/notes tables. This is the backend for the trace CLI command.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 5.4 (Query Flow Tier 1).

## Codebase Context

- entity_references table (migration 011): source_entity_type, source_entity_id, target_entity_type, target_entity_id, reference_type, source_method
- mr_file_changes table (migration 016, bd-1oo): merge_request_id, project_id, old_path, new_path, change_type
- discussions table: issue_id, merge_request_id
- notes table: discussion_id, author_username, body, created_at, is_system, position_new_path (for DiffNotes)
- merge_requests table: iid, title, state, author_username, web_url, merged_at, updated_at
- issues table: iid, title, state, web_url
- resolve_rename_chain() from bd-1yx (src/core/file_history.rs) provides multi-path matching
- reference_type values: 'closes', 'mentioned', 'related'

## Approach

Create `src/core/trace.rs`:

```rust
use rusqlite::Connection;
use crate::core::file_history::resolve_rename_chain;
use crate::core::error::Result;

#[derive(Debug, Clone, Serialize)]
pub struct TraceChain {
    pub merge_request: TraceMr,
    pub issues: Vec<TraceIssue>,
    pub discussions: Vec<TraceDiscussion>,
}

#[derive(Debug, Clone, Serialize)]
pub struct TraceMr {
    pub iid: i64,
    pub title: String,
    pub state: String,
    pub author_username: String,
    pub web_url: Option<String>,
    pub merged_at: Option<i64>,
    pub merge_commit_sha: Option<String>,
    pub file_change_type: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct TraceIssue {
    pub iid: i64,
    pub title: String,
    pub state: String,
    pub web_url: Option<String>,
    pub reference_type: String,   // "closes", "mentioned", "related"
}

#[derive(Debug, Clone, Serialize)]
pub struct TraceDiscussion {
    pub author_username: String,
    pub body_snippet: String,     // truncated to 500 chars
    pub created_at: i64,
    pub is_diff_note: bool,       // true if position_new_path matched
}

#[derive(Debug, Clone, Serialize)]
pub struct TraceResult {
    pub path: String,
    pub resolved_paths: Vec<String>,
    pub chains: Vec<TraceChain>,
}

pub fn run_trace(
    conn: &Connection,
    project_id: i64,
    path: &str,
    follow_renames: bool,
    include_discussions: bool,
    limit: usize,
) -> Result<TraceResult> {
    // 1. Resolve rename chain (unless !follow_renames)
    let paths = if follow_renames {
        resolve_rename_chain(conn, project_id, path, 10)?
    } else {
        vec![path.to_string()]
    };

    // 2. Find MRs via mr_file_changes for all resolved paths
    //    Dynamic IN-clause for path set
    // 3. For each MR, find linked issues via entity_references
    // 4. If include_discussions, fetch DiffNote discussions on traced file
    // 5. Order chains by COALESCE(merged_at, updated_at) DESC, apply limit
}
```

### SQL for step 2 (find MRs):

Build dynamic IN-clause placeholders for the resolved path set:
```sql
SELECT DISTINCT mr.id, mr.iid, mr.title, mr.state, mr.author_username,
       mr.web_url, mr.merged_at, mr.updated_at, mr.merge_commit_sha,
       mfc.change_type
FROM mr_file_changes mfc
JOIN merge_requests mr ON mr.id = mfc.merge_request_id
WHERE mfc.project_id = ?1
  AND (mfc.new_path IN (...placeholders...) OR mfc.old_path IN (...placeholders...))
ORDER BY COALESCE(mr.merged_at, mr.updated_at) DESC
LIMIT ?N
```

### SQL for step 3 (linked issues):
```sql
SELECT i.iid, i.title, i.state, i.web_url, er.reference_type
FROM entity_references er
JOIN issues i ON i.id = er.target_entity_id
WHERE er.source_entity_type = 'merge_request'
  AND er.source_entity_id = ?1
  AND er.target_entity_type = 'issue'
```

### SQL for step 4 (DiffNote discussions):
```sql
SELECT n.author_username, n.body, n.created_at, n.position_new_path
FROM notes n
JOIN discussions d ON d.id = n.discussion_id
WHERE d.merge_request_id = ?1
  AND n.position_new_path IN (...placeholders...)
  AND n.is_system = 0
ORDER BY n.created_at ASC
```

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

## Acceptance Criteria

- [ ] run_trace() returns chains ordered by COALESCE(merged_at, updated_at) DESC
- [ ] Rename-aware: uses all paths from resolve_rename_chain
- [ ] Issues linked via entity_references (closes, mentioned, related)
- [ ] DiffNote discussions correctly filtered to traced file paths via position_new_path
- [ ] Discussion body_snippet truncated to 500 chars
- [ ] Empty result (file not in any MR) returns TraceResult with empty chains
- [ ] Limit applies to number of chains (MRs), not total discussions
- [ ] Module registered in src/core/mod.rs as `pub mod trace;`
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

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

## TDD Loop

RED:
- `test_trace_empty_file` — unknown file returns empty chains
- `test_trace_finds_mr` — file in mr_file_changes returns chain with correct MR
- `test_trace_follows_renames` — renamed file finds historical MRs
- `test_trace_links_issues` — MR with entity_references shows linked issues
- `test_trace_limits_chains` — limit=1 returns at most 1 chain
- `test_trace_no_follow_renames` — follow_renames=false only matches literal path

Tests need in-memory DB with migrations applied through 016 + test fixtures for mr_file_changes, entity_references, discussions, notes.

GREEN: Implement SQL queries and chain assembly.

VERIFY: `cargo test --lib -- trace`

## Edge Cases

- MR with no linked issues: chain has empty issues vec
- Same issue linked from multiple MRs: appears in each chain independently
- DiffNote on old_path (before rename): captured via resolved path set
- include_discussions=false: skip DiffNote query for performance
- Null merged_at: falls back to updated_at for ordering
- Dynamic IN-clause: use rusqlite::params_from_iter for parameterized queries


[CP1] Issue ingestion module

Fetch and store issues with cursor-based incremental sync.

Implement ingestIssues(options) → { fetched, upserted, labelsCreated }

Logic:
1. Get current cursor from sync_cursors
2. Paginate through issues updated after cursor
3. Apply local filtering for tuple cursor semantics
4. For each issue:
   - Store raw payload (compressed)
   - Upsert issue record
   - Extract and upsert labels
   - Link issue to labels via junction
5. Update cursor after each page commit

Files: src/ingestion/issues.ts
Tests: tests/integration/issue-ingestion.test.ts
Done when: Issues, labels, issue_labels populated correctly with resumable cursor


Implement snapshot test infrastructure + terminal compat matrix

## Background
Snapshot tests ensure deterministic rendering using FakeClock and ftui's test harness. The terminal compatibility matrix validates rendering across iTerm2, tmux, Alacritty, and kitty.

## Approach
Snapshot test infrastructure:
- Use ftui-harness (or custom TestBackend) for rendering without real terminal
- FakeClock injection for deterministic timestamps
- Synthetic DB fixtures (S-tier: 10k issues, 5k MRs)
- Snapshot format: rendered output captured as styled text, compared against golden files
- Tests for: Dashboard, Issue List (with data), Issue Detail, Search results
- Deterministic: same output regardless of wall clock, terminal size fixed at 120x40

Terminal compat matrix:
- Manual + scripted tests for: iTerm2, tmux, Alacritty, kitty
- Checks: color rendering, unicode width, resize handling, mouse events (if supported)
- Document results in test matrix table

## Acceptance Criteria
- [ ] Snapshot tests produce identical output across runs (FakeClock, fixed size)
- [ ] Dashboard snapshot matches golden file
- [ ] Issue List snapshot matches golden file (with synthetic data)
- [ ] All snapshots use injected Clock, not wall time
- [ ] Terminal compat documented: iTerm2, tmux, Alacritty, kitty
- [ ] Unicode rendering verified: CJK, emoji, combining marks
- [ ] Resize handling verified in all terminals

## Files
- CREATE: crates/lore-tui/tests/snapshots/ (golden files)
- CREATE: crates/lore-tui/tests/snapshot_tests.rs

## TDD Anchor
RED: Write test_dashboard_snapshot that creates LoreApp with FakeClock and fixture data, renders Dashboard at 120x40, asserts output matches golden file.
GREEN: Implement snapshot capture and comparison.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml snapshot

## Edge Cases
- Golden file updates needed when UI changes — provide update mode (env var to overwrite)
- FakeClock must be consistent across all relative time computations in a single frame
- Terminal compat issues may require ftui configuration flags (not code changes)

## Dependency Context
Uses FakeClock from "Implement Clock trait" task.
Uses all screen views from Phase 2 and 3 tasks.
Uses ftui test harness from FrankenTUI.


OBSERV Epic: Phase 2 - Spans + Correlation IDs

Add tracing spans to all sync stages and generate UUID-based run_id for correlation. Every log line within a sync run includes run_id in JSON span context. Nested spans produce correct parent-child chains.

Depends on: Phase 1 (subscriber must support span recording)
Unblocks: Phase 3 (metrics), Phase 5 (rate limit logging)

Files: src/cli/commands/sync.rs, src/cli/commands/ingest.rs, src/ingestion/orchestrator.rs, src/documents/regenerator.rs, src/embedding/pipeline.rs, src/main.rs

Acceptance criteria (PRD Section 6.2):
- Every log line includes run_id in JSON span context
- Nested spans produce chain: fetch_pages includes parent ingest_issues span
- run_id is 8-char hex (truncated UUIDv4)
- Spans visible in -vv stderr output

Phase 2 complete: run_id correlation IDs generated at sync/ingest entry, root spans with .instrument() for async, #[instrument] on 5 key pipeline functions


Write integration tests

## Background
Integration tests verify that modules work together with a real SQLite database. They test FTS search (stemming, empty results), embedding storage (sqlite-vec ops), hybrid search (combined retrieval), and sync orchestration (full pipeline). Each test creates a fresh in-memory DB with migrations applied.

## Approach
Create integration test files in `tests/`:

**1. tests/fts_search.rs:**
- Create DB, apply migrations 001-008
- Insert test documents via SQL
- Verify FTS5 triggers fired (documents_fts has matching count)
- Search with various queries: stemming, prefix, empty, special chars
- Verify result ranking (BM25 ordering)
- Verify snippet generation

**2. tests/embedding.rs:**
- Create DB, apply migrations 001-009 (requires sqlite-vec)
- Insert test documents + embeddings with known vectors
- Verify KNN search returns nearest neighbors
- Verify chunk deduplication
- Verify orphan cleanup trigger (delete document -> embeddings gone)

**3. tests/hybrid_search.rs:**
- Create DB, apply all migrations
- Insert documents + embeddings
- Test all three modes: lexical, semantic, hybrid
- Verify RRF ranking produces expected order
- Test graceful degradation (no embeddings -> FTS fallback)
- Test adaptive recall with filters

**4. tests/sync.rs:**
- Test sync orchestration with mock/stub GitLab responses
- Verify pipeline stages execute in order
- Verify lock acquisition/release
- Verify --no-embed and --no-docs flags

**Test fixtures:**
- Deterministic embedding vectors (no Ollama required): e.g., [1.0, 0.0, 0.0, ...] for doc1, [0.0, 1.0, 0.0, ...] for doc2
- Known documents with predictable search results
- Fixed timestamps for reproducibility

## Acceptance Criteria
- [ ] FTS search tests pass (stemming, prefix, empty, special chars)
- [ ] Embedding tests pass (KNN, dedup, orphan cleanup)
- [ ] Hybrid search tests pass (all 3 modes, graceful degradation)
- [ ] Sync tests pass (pipeline orchestration)
- [ ] All tests use in-memory DB (no file I/O)
- [ ] No external dependencies (no Ollama, no GitLab) — use fixtures/stubs
- [ ] `cargo test --test fts_search --test embedding --test hybrid_search --test sync` passes

## Files
- `tests/fts_search.rs` — new file
- `tests/embedding.rs` — new file
- `tests/hybrid_search.rs` — new file
- `tests/sync.rs` — new file
- `tests/fixtures/` — optional: test helper functions (shared DB setup)

## TDD Loop
These ARE integration tests — they verify the combined behavior of multiple beads.
VERIFY: `cargo test --test fts_search && cargo test --test embedding && cargo test --test hybrid_search && cargo test --test sync`

## Edge Cases
- sqlite-vec not available: embedding tests should skip gracefully (or require feature flag)
- In-memory DB with WAL mode: may behave differently than file DB — test both if critical
- Concurrent test execution: each test creates its own DB (no shared state)

Integration tests: 10 FTS search tests (stemming, empty, special chars, ordering, triggers, null title), 5 embedding tests (KNN, limit, dedup, orphan trigger, empty DB), 6 hybrid search tests (lexical mode, FTS-only, graceful degradation, RRF ranking, filters, mode variants). 310 total tests pass.


OBSERV Epic: Phase 1 - Verbosity Flags + Structured File Logging

Foundation layer for observability. Add -v/-vv/-vvv CLI flags, dual-layer tracing subscriber (stderr + file), daily log rotation via tracing-appender, log retention cleanup, --log-format json flag, and LoggingConfig.

Depends on: nothing (first phase)
Unblocks: Phase 2, and transitively all other phases

Files: Cargo.toml, src/cli/mod.rs, src/main.rs, src/core/config.rs, src/core/paths.rs, src/cli/commands/doctor.rs

Acceptance criteria (PRD Section 6.1):
- JSON log files written to ~/.local/share/lore/logs/ with zero config
- -v/-vv/-vvv control stderr verbosity per table in PRD 4.3
- RUST_LOG overrides -v for both layers
- --log-format json emits JSON on stderr
- Daily rotation, retention cleanup on startup
- --quiet suppresses stderr, does NOT affect file layer
- lore doctor reports log directory info

Phase 1 complete: dual-layer subscriber, -v/--verbose flags, --log-format json, LoggingConfig, get_log_dir(), log retention, doctor diagnostics


Implement entity cache + render cache

## Background
Entity cache provides near-instant detail view reopens during Enter/Esc drill workflows by caching IssueDetail/MrDetail payloads. Render cache prevents per-frame recomputation of expensive render artifacts (markdown to styled text, discussion tree shaping). Both use bounded LRU eviction with selective invalidation.

## Approach
Entity Cache (entity_cache.rs):
- EntityCache<V>: bounded LRU keyed by EntityKey
- new(capacity: usize) -> Self
- get(key: &EntityKey) -> Option<&V>: LRU access
- put(key: EntityKey, value: V): insert, evict oldest if at capacity
- invalidate(keys: &[EntityKey]): selective invalidation by changed key set (NOT blanket invalidate_all)
- prewarm(keys: &[EntityKey], fetch: impl Fn(&EntityKey) -> V): optional post-sync prewarm of top changed entities

Render Cache (render_cache.rs):
- RenderCache<K, V>: keyed by (content_hash, terminal_width, theme_id)
- get(key: &K) -> Option<&V>
- put(key: K, value: V)
- invalidate_width(old_width: u16): clear all entries cached at different width
- invalidate_theme(): clear all entries
- Used for: markdown->styled text conversion, discussion tree layout

## Acceptance Criteria
- [ ] Entity cache provides O(1) lookup for recently viewed entities
- [ ] LRU eviction keeps cache bounded
- [ ] Selective invalidation by EntityKey set (not blanket clear)
- [ ] Post-sync prewarm: top changed entities fetched into cache
- [ ] Render cache prevents per-frame recomputation of markdown rendering
- [ ] Render cache invalidated on terminal resize or theme change
- [ ] Cache miss falls through to normal DB query transparently

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

## TDD Anchor
RED: Write test_entity_cache_lru_eviction that creates cache with capacity 3, puts 4 items, asserts first item evicted.
GREEN: Implement LRU eviction.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_entity_cache

## Edge Cases
- Cache entries may become stale after sync — selective invalidation handles this
- Render cache key must include terminal width (re-render needed on resize)
- Entity cache size: ~50 entries is sufficient for typical drill-in/out workflows
- Prewarm must not block the event loop — run on background thread


[CP1] Epic: Issue Ingestion

Ingest all issues, labels, and issue discussions from configured GitLab repositories with resumable cursor-based incremental sync. This establishes the core data ingestion pattern reused for MRs in CP2.

## Success Criteria
- gi ingest --type=issues fetches all issues (count matches GitLab UI)
- Labels extracted from issue payloads (name-only)
- Label linkage reflects current GitLab state (removed labels unlinked on re-sync)
- Issue discussions fetched per-issue (dependent sync)
- Cursor-based sync is resumable (re-running fetches 0 new items)
- Discussion sync skips unchanged issues (per-issue watermark)
- Sync tracking records all runs
- Single-flight lock prevents concurrent runs

## Internal Gates
- Gate A: Issues only (cursor + upsert + raw payloads + list/count/show)
- Gate B: Labels correct (stale-link removal verified)
- Gate C: Dependent discussion sync (watermark prevents redundant refetch)
- Gate D: Resumability proof (kill mid-run, rerun; bounded redo)

Reference: docs/prd/checkpoint-1.md


WHO: CLI skeleton — WhoArgs, Commands::Who, dispatch arm

## Background

Wire up the CLI plumbing so `lore who --help` works and dispatch reaches the who module. This is pure boilerplate — no query logic yet.

## Approach

### 1. src/cli/mod.rs — WhoArgs struct (after TimelineArgs, ~line 195)

```rust
#[derive(Parser)]
#[command(after_help = "\x1b[1mExamples:\x1b[0m
  lore who src/features/auth/           # Who knows about this area?
  lore who @asmith                      # What is asmith working on?
  lore who @asmith --reviews            # What review patterns does asmith have?
  lore who --active                     # What discussions need attention?
  lore who --overlap src/features/auth/ # Who else is touching these files?
  lore who --path README.md             # Expert lookup for a root file")]
pub struct WhoArgs {
    /// Username or file path (path if contains /)
    pub target: Option<String>,

    /// Force expert mode for a file/directory path (handles root files like README.md, Makefile)
    #[arg(long, help_heading = "Mode", conflicts_with_all = ["active", "overlap", "reviews"])]
    pub path: Option<String>,

    /// Show active unresolved discussions
    #[arg(long, help_heading = "Mode", conflicts_with_all = ["target", "overlap", "reviews", "path"])]
    pub active: bool,

    /// Find users with MRs/notes touching this file path
    #[arg(long, help_heading = "Mode", conflicts_with_all = ["target", "active", "reviews", "path"])]
    pub overlap: Option<String>,

    /// Show review pattern analysis (requires username target)
    #[arg(long, help_heading = "Mode", requires = "target", conflicts_with_all = ["active", "overlap", "path"])]
    pub reviews: bool,

    /// Time window (7d, 2w, 6m, YYYY-MM-DD). Default varies by mode.
    #[arg(long, help_heading = "Filters")]
    pub since: Option<String>,

    /// Scope to a project (supports fuzzy matching)
    #[arg(short = 'p', long, help_heading = "Filters")]
    pub project: Option<String>,

    /// Maximum results per section (1..=500)
    #[arg(short = 'n', long = "limit", default_value = "20",
          value_parser = clap::value_parser!(u16).range(1..=500),
          help_heading = "Output")]
    pub limit: u16,
}
```

### 2. Commands enum — add Who(WhoArgs) after Timeline, before hidden List

### 3. src/cli/commands/mod.rs — add `pub mod who;` and re-exports:
```rust
pub use who::{run_who, print_who_human, print_who_json, WhoRun};
```

### 4. src/main.rs — dispatch arm + handler:
```rust
Some(Commands::Who(args)) => handle_who(cli.config.as_deref(), args, robot_mode),
```

### 5. src/cli/commands/who.rs — stub file with signatures that compile

## Files

- `src/cli/mod.rs` — WhoArgs struct + Commands::Who variant
- `src/cli/commands/mod.rs` — pub mod who + re-exports
- `src/main.rs` — dispatch arm + handle_who function + imports
- `src/cli/commands/who.rs` — CREATE stub file

## TDD Loop

RED: `cargo check --all-targets` fails (missing who module)
GREEN: Create stub who.rs with empty/todo!() implementations, wire up all 4 files
VERIFY: `cargo check --all-targets && cargo run -- who --help`

## Acceptance Criteria

- [ ] `cargo check --all-targets` passes
- [ ] `lore who --help` displays all flags with correct grouping (Mode, Filters, Output)
- [ ] `lore who --active --overlap foo` rejected by clap (conflicts_with)
- [ ] `lore who --reviews` rejected by clap (requires target)
- [ ] WhoArgs is pub and importable from lore::cli

## Edge Cases

- conflicts_with_all on --path must NOT include "target" (--path is used alongside positional target in some cases... actually no, --path replaces target — check the plan: it conflicts with active/overlap/reviews but NOT target. Wait, looking at the plan: --path does NOT conflict with target. But if both target and --path are provided, --path takes priority in resolve_mode. The clap struct allows both.)


OBSERV: Replace subscriber init with dual-layer setup

## Background
This is the core infrastructure bead for Phase 1. It replaces the single-layer subscriber (src/main.rs:44-58) with a dual-layer registry that separates stderr and file concerns. The file layer provides always-on post-mortem data; the stderr layer respects -v flags.

## Approach
Replace src/main.rs lines 44-58 with a function (e.g., init_tracing()) that:

1. Build stderr filter from -v count (or RUST_LOG override):
```rust
fn build_stderr_filter(verbose: u8, quiet: bool) -> EnvFilter {
    if let Ok(rust_log) = std::env::var("RUST_LOG") {
        return EnvFilter::new(rust_log);
    }
    if quiet {
        return EnvFilter::new("lore=warn,error");
    }
    match verbose {
        0 => EnvFilter::new("lore=info,warn"),
        1 => EnvFilter::new("lore=debug,warn"),
        2 => EnvFilter::new("lore=debug,info"),
        _ => EnvFilter::new("trace,debug"),
    }
}
```

2. Build file filter (always lore=debug,warn unless RUST_LOG set):
```rust
fn build_file_filter() -> EnvFilter {
    if let Ok(rust_log) = std::env::var("RUST_LOG") {
        return EnvFilter::new(rust_log);
    }
    EnvFilter::new("lore=debug,warn")
}
```

3. Assemble the registry:
```rust
let stderr_layer = fmt::layer()
    .with_target(false)
    .with_writer(SuspendingWriter);
// Conditionally add .json() based on log_format

let file_appender = tracing_appender::rolling::daily(log_dir, "lore");
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
let file_layer = fmt::layer()
    .json()
    .with_writer(non_blocking);

tracing_subscriber::registry()
    .with(stderr_layer.with_filter(build_stderr_filter(cli.verbose, cli.quiet)))
    .with(file_layer.with_filter(build_file_filter()))
    .init();
```

CRITICAL: The non_blocking _guard must be held for the program's lifetime. Store it in main() scope, NOT in the init function. If the guard drops, the file writer thread stops and buffered logs are lost.

CRITICAL: Per-layer filtering requires each .with_filter() to produce a Filtered<L, F, S> type. The two layers will have different concrete types (one with json, one without). This is fine -- the registry accepts heterogeneous layers via .with().

When --log-format json: wrap stderr_layer with .json() too. This requires conditional construction. Two approaches:
  A) Use Box<dyn Layer<S>> for dynamic dispatch (simpler, tiny perf hit)
  B) Use an enum wrapper (zero cost but more code)
Recommend approach A for simplicity. The overhead is one vtable indirection per log event, dwarfed by I/O.

When file_logging is false (LoggingConfig.file_logging == false): skip adding the file layer entirely.

## Acceptance Criteria
- [ ] lore sync writes JSON log lines to ~/.local/share/lore/logs/lore.YYYY-MM-DD.log
- [ ] lore -v sync shows DEBUG lore::* on stderr, deps at WARN
- [ ] lore -vv sync shows DEBUG lore::* + INFO deps on stderr
- [ ] lore -vvv sync shows TRACE everything on stderr
- [ ] RUST_LOG=lore::gitlab=trace overrides -v for both layers
- [ ] lore --log-format json sync emits JSON on stderr
- [ ] -q + -v: -q wins (stderr at WARN+)
- [ ] -q does NOT affect file layer (still DEBUG+)
- [ ] File layer does NOT use SuspendingWriter
- [ ] Non-blocking guard kept alive for program duration
- [ ] Existing behavior unchanged when no new flags passed
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/main.rs (replace lines 44-58, add init_tracing function or inline)

## TDD Loop
RED:
  - test_verbosity_filter_construction: assert filter directives for verbose=0,1,2,3
  - test_rust_log_overrides_verbose: set env, assert TRACE not DEBUG
  - test_quiet_overrides_verbose: -q + -v => WARN+
  - test_json_log_output_format: capture file output, parse as JSON
  - test_suspending_writer_dual_layer: no garbled stderr with progress bars
GREEN: Implement build_stderr_filter, build_file_filter, assemble registry
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- _guard lifetime: if guard is dropped early, buffered log lines are lost. MUST hold in main() scope.
- Type erasure: stderr layer with/without .json() produces different types. Use Box<dyn Layer<_>> or separate init paths.
- Empty RUST_LOG string: env::var returns Ok(""), which EnvFilter::new("") defaults to TRACE. May want to check is_empty().
- File I/O error on log dir: tracing-appender handles this gracefully (no panic), but logs will be silently lost. The doctor command (bd-2i10) can diagnose this.

Replaced single-layer subscriber with dual-layer setup: stderr (human/json, -v controlled) + file (always-on JSON, daily rotation via tracing-appender)


Robot sync envelope: status enrichment metadata

## Background
Agents need machine-readable status enrichment metadata in the robot sync output to detect issues like unsupported GraphQL, partial errors, or enrichment failures. Without this, enrichment problems are invisible to automation.

## Approach
Wire IngestProjectResult status fields into the per-project robot sync JSON. Add aggregate error count to top-level summary.

## Files
- Wherever robot sync output JSON is constructed (likely src/cli/commands/ingest.rs or the sync output serialization path — search for IngestProjectResult -> JSON conversion)

## Implementation

Per-project status_enrichment object in robot sync JSON:
{
  "mode": "fetched" | "unsupported" | "skipped",
  "reason": null | "graphql_endpoint_missing" | "auth_forbidden",
  "seen": N,
  "enriched": N,
  "cleared": N,
  "without_widget": N,
  "partial_errors": N,
  "first_partial_error": null | "message",
  "error": null | "message"
}

Source fields from IngestProjectResult:
  mode <- status_enrichment_mode
  reason <- status_unsupported_reason
  seen <- statuses_seen
  enriched <- statuses_enriched
  cleared <- statuses_cleared
  without_widget <- statuses_without_widget
  partial_errors <- partial_error_count
  first_partial_error <- first_partial_error
  error <- status_enrichment_error

Top-level sync summary: add status_enrichment_errors: N (count of projects where error is Some)

Field semantics:
  mode "fetched": enrichment ran (even if 0 statuses or error occurred)
  mode "unsupported": 404/403 from GraphQL
  mode "skipped": config toggle off
  seen > 0 + enriched == 0: project has issues but none with status
  partial_errors > 0: some pages returned incomplete data

## Acceptance Criteria
- [ ] Robot sync JSON includes per-project status_enrichment object
- [ ] All 9 fields present with correct types
- [ ] mode reflects actual enrichment outcome (fetched/unsupported/skipped)
- [ ] Top-level status_enrichment_errors count present
- [ ] Test: full robot sync output validates structure

## TDD Loop
RED: test_robot_sync_includes_status_enrichment
GREEN: Wire fields into JSON serialization
VERIFY: cargo test robot_sync

## Edge Cases
- Find the exact location where IngestProjectResult is serialized to JSON — may be in a Serialize impl or manual json! macro
- All numeric fields default to 0, all Option fields default to null in JSON
- mode is always present (never null)


Implement lore embed CLI command

## Background
The embed CLI command is the user-facing wrapper for the embedding pipeline. It runs Ollama health checks, selects documents to embed (pending or failed), shows progress, and reports results. This is the standalone command for building embeddings outside of the sync orchestrator.

## Approach
Create `src/cli/commands/embed.rs` per PRD Section 4.4.

**IMPORTANT: The embed command is async.** The underlying `embed_documents()` function is `async fn` (uses `FuturesUnordered` for concurrent HTTP to Ollama). The CLI runner must use tokio runtime.

**Core function (async):**
```rust
pub async fn run_embed(
    config: &Config,
    retry_failed: bool,
) -> Result<EmbedResult>
```

**Pipeline:**
1. Create OllamaClient from config.embedding (base_url, model, timeout_secs)
2. Run `client.health_check().await` — fail early with clear error if Ollama unavailable or model missing
3. Determine selection: `EmbedSelection::RetryFailed` if --retry-failed, else `EmbedSelection::Pending`
4. Call `embed_documents(conn, &client, selection, concurrency, progress_callback).await`
   - `concurrency` param controls max in-flight HTTP requests to Ollama
   - `progress_callback` drives indicatif progress bar
5. Show progress bar (indicatif) during embedding
6. Return EmbedResult with counts

**CLI args:**
```rust
#[derive(Args)]
pub struct EmbedArgs {
    #[arg(long)]
    retry_failed: bool,
}
```

**Output:**
- Human: "Embedded 42 documents (15 chunks), 2 errors, 5 skipped (unchanged)"
- JSON: `{"ok": true, "data": {"embedded": 42, "chunks": 15, "errors": 2, "skipped": 5}}`

**Tokio integration note:**
The embed command runs async code. Either:
- Use `#[tokio::main]` on main and propagate async through CLI dispatch
- Or use `tokio::runtime::Runtime::new()` in the embed command handler

## Acceptance Criteria
- [ ] Command is async (embed_documents is async, health_check is async)
- [ ] OllamaClient created from config.embedding settings
- [ ] Health check runs first — clear error if Ollama down (exit code 14)
- [ ] Clear error if model not found: "Pull the model: ollama pull nomic-embed-text" (exit code 15)
- [ ] Embeds pending documents (no existing embeddings or stale content_hash)
- [ ] --retry-failed re-attempts documents with last_error
- [ ] Progress bar shows during embedding (indicatif)
- [ ] embed_documents called with concurrency parameter
- [ ] embed_documents called with progress_callback for progress bar
- [ ] Human + JSON output
- [ ] `cargo build` succeeds

## Files
- `src/cli/commands/embed.rs` — new file
- `src/cli/commands/mod.rs` — add `pub mod embed;`
- `src/cli/mod.rs` — add EmbedArgs, wire up embed subcommand
- `src/main.rs` — add embed command handler (async dispatch)

## TDD Loop
RED: Integration test needing Ollama
GREEN: Implement run_embed (async)
VERIFY: `cargo build && cargo test embed`

## Edge Cases
- No documents in DB: "No documents to embed" (not error)
- All documents already embedded and unchanged: "0 documents to embed (all up to date)"
- Ollama goes down mid-embedding: pipeline records errors for remaining docs, returns partial result
- --retry-failed with no failed docs: "No failed documents to retry"

Embed CLI command fully wired: EmbedArgs, Commands::Embed variant, handle_embed handler, clean build, all tests pass


Epic: TUI Phase 1 — Foundation

## Background
Phase 1 builds the foundational infrastructure that all screens depend on: the full LoreApp Model implementation with key dispatch, navigation stack, task supervisor for async work management, theme configuration, common widgets, and the state/action architecture. Phase 1 deliverables are the skeleton that Phase 2 screens plug into.

## Acceptance Criteria
- [ ] LoreApp update() dispatches all Msg variants through 5-stage key pipeline
- [ ] NavigationStack supports push/pop/forward/jump with state preservation
- [ ] TaskSupervisor manages background tasks with dedup, cancellation, and generation IDs
- [ ] Theme renders correctly with adaptive light/dark colors
- [ ] Status bar, breadcrumb, loading, error toast, and help overlay widgets render
- [ ] CommandRegistry is the single source of truth for keybindings/help/palette
- [ ] AppState composition with per-screen states and LoadState map

## Scope
Blocked by Phase 0 (Toolchain Gate). Blocks Phase 2 (Core Screens).


[CP1] gi ingest --type=issues command

CLI command to orchestrate issue ingestion.

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

## Clap Definition
#[derive(Subcommand)]
pub enum Commands {
    Ingest {
        #[arg(long, value_parser = ["issues", "merge_requests"])]
        r#type: String,

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

        #[arg(long)]
        force: bool,
    },
}

## Implementation
1. Acquire app lock with heartbeat (respect --force for stale lock)
2. Create sync_run record (status='running')
3. For each configured project (or filtered --project):
   - Call orchestrator to ingest issues and discussions
   - Show progress (spinner or progress bar)
4. Update sync_run (status='succeeded', metrics_json with counts)
5. Release lock

## Output Format
Ingesting issues...

  group/project-one: 1,234 issues fetched, 45 new labels

Fetching discussions (312 issues with updates)...

  group/project-one: 312 issues → 1,234 discussions, 5,678 notes

Total: 1,234 issues, 1,234 discussions, 5,678 notes (excluding 1,234 system notes)
Skipped discussion sync for 922 unchanged issues.

## Error Handling
- Lock acquisition failure: exit with DatabaseLockError message
- Network errors: show GitLabNetworkError, exit non-zero
- Rate limiting: respect backoff, show progress

Files: src/cli/commands/ingest.rs, src/cli/commands/mod.rs
Tests: tests/integration/sync_runs_tests.rs
Done when: Full issue + discussion ingestion works end-to-end


[CP1] Epic: Issue Ingestion

Ingest all issues, labels, and issue discussions from configured GitLab repositories with resumable cursor-based incremental sync. This checkpoint establishes the core data ingestion pattern that will be reused for MRs in Checkpoint 2.

## Success Criteria
- gi ingest --type=issues fetches all issues (count matches GitLab UI)
- Labels extracted from issue payloads (name-only)
- Label linkage reflects current GitLab state (removed labels unlinked on re-sync)
- Issue discussions fetched per-issue (dependent sync)
- Cursor-based sync is resumable (re-running fetches 0 new items)
- Discussion sync skips unchanged issues (per-issue watermark)
- Sync tracking records all runs (sync_runs table)
- Single-flight lock prevents concurrent runs

## Internal Gates
- **Gate A**: Issues only - cursor + upsert + raw payloads + list/count/show working
- **Gate B**: Labels correct - stale-link removal verified; label count matches GitLab
- **Gate C**: Dependent discussion sync - watermark prevents redundant refetch; concurrency bounded
- **Gate D**: Resumability proof - kill mid-run, rerun; bounded redo and no redundant discussion refetch

## Reference
docs/prd/checkpoint-1.md

CP1 Issue Ingestion complete: all sub-tasks done, 71 tests pass, CLI commands working


Add half-life fields and config validation to ScoringConfig

## Background
The flat-weight ScoringConfig (config.rs:149-173) has only 3 fields: author_weight (25), reviewer_weight (10), note_bonus (1). Time-decay scoring needs half-life parameters, a reviewer split (participated vs assigned-only), closed MR discount, substantive-note threshold, and bot filtering.

## Approach
Extend the existing ScoringConfig struct at config.rs:149. Add new fields with #[serde(default)] and camelCase rename to match existing convention (authorWeight, reviewerWeight, noteBonus). Extend the Default impl at config.rs:167 with new defaults. Extend validate_scoring() at config.rs:245-262 (currently validates 3 weights >= 0).

### New fields to add:
```rust
#[serde(rename = "reviewerAssignmentWeight")]
pub reviewer_assignment_weight: i64,     // default: 3
#[serde(rename = "authorHalfLifeDays")]
pub author_half_life_days: u32,          // default: 180
#[serde(rename = "reviewerHalfLifeDays")]
pub reviewer_half_life_days: u32,        // default: 90
#[serde(rename = "reviewerAssignmentHalfLifeDays")]
pub reviewer_assignment_half_life_days: u32, // default: 45
#[serde(rename = "noteHalfLifeDays")]
pub note_half_life_days: u32,            // default: 45
#[serde(rename = "closedMrMultiplier")]
pub closed_mr_multiplier: f64,           // default: 0.5
#[serde(rename = "reviewerMinNoteChars")]
pub reviewer_min_note_chars: u32,        // default: 20
#[serde(rename = "excludedUsernames")]
pub excluded_usernames: Vec<String>,     // default: vec![]
```

### Validation additions to validate_scoring() (config.rs:245):
- All *_half_life_days must be > 0
- reviewer_assignment_weight must be >= 0
- closed_mr_multiplier must be > 0.0 and <= 1.0
- excluded_usernames entries must be non-empty strings

## TDD Loop

### RED (write first):
```rust
#[test]
fn test_config_validation_rejects_zero_half_life() {
    let mut cfg = ScoringConfig::default();
    // Default should be valid
    assert!(validate_scoring(&cfg).is_ok());

    // Zero half-life -> ConfigInvalid
    cfg.author_half_life_days = 0;
    let err = validate_scoring(&cfg).unwrap_err();
    assert!(matches!(err, LoreError::ConfigInvalid { .. }));

    // Reset, try zero reviewer half-life
    cfg.author_half_life_days = 180;
    cfg.reviewer_half_life_days = 0;
    assert!(validate_scoring(&cfg).is_err());

    // closed_mr_multiplier out of range
    cfg.reviewer_half_life_days = 90;
    cfg.closed_mr_multiplier = 0.0;
    assert!(validate_scoring(&cfg).is_err());
    cfg.closed_mr_multiplier = 1.5;
    assert!(validate_scoring(&cfg).is_err());
    cfg.closed_mr_multiplier = 1.0; // boundary: 1.0 is valid
    assert!(validate_scoring(&cfg).is_ok());
}
```

Note: validate_scoring is in config.rs (not who.rs), so this test goes in a #[cfg(test)] mod in config.rs (or use the existing test module pattern in config.rs). Check if config.rs has an existing test module; if not, add one.

### GREEN: Add fields to struct + Default impl + validation rules.
### VERIFY: `cargo test -p lore -- test_config_validation_rejects_zero_half_life`

## Acceptance Criteria
- [ ] test_config_validation_rejects_zero_half_life passes (covers all 4 new validation rules)
- [ ] ScoringConfig::default() returns correct values for all 11 fields
- [ ] cargo check --all-targets passes (downstream code compiles with ..Default::default())
- [ ] Existing config deserialization works (serde default fills new fields from old JSON)
- [ ] validate_scoring() is pub(crate) or accessible from config.rs test module

## Files
- src/core/config.rs (lines 149-262: struct, Default impl, validate_scoring)

## Edge Cases
- f64 comparison for closed_mr_multiplier: use > 0.0 and <= 1.0 (not ==)
- Vec<String> default: use Vec::new()
- Serde: #[serde(default)] on struct level already present, but new fields still need individual defaults in the Default impl


Ship timeline CLI with human and robot renderers

## Problem
The timeline pipeline (5-stage SEED->HYDRATE->EXPAND->COLLECT->RENDER) is implemented but not wired to the CLI. This is one of lore's most unique features — chronological narrative reconstruction from resource events, cross-references, and notes — and it is invisible to users and agents.

## Current State
- Types defined: src/core/timeline.rs (TimelineEvent, TimelineSeed, etc.)
- Seed stage: src/core/timeline_seed.rs (FTS search -> seed entities)
- Expand stage: src/core/timeline_expand.rs (cross-reference expansion)
- Collect stage: src/core/timeline_collect.rs (event gathering from resource events + notes)
- CLI command structure: src/cli/commands/timeline.rs (exists but incomplete)
- Remaining beads: bd-1nf (CLI wiring), bd-2f2 (human renderer), bd-dty (robot renderer)

## Acceptance Criteria
1. lore timeline 'authentication refactor' works end-to-end:
   - Searches for matching entities (SEED)
   - Fetches raw data (HYDRATE)
   - Expands via cross-references (EXPAND with --depth flag, default 1)
   - Collects events chronologically (COLLECT)
   - Renders human-readable narrative (RENDER)
2. Human renderer output:
   - Chronological event stream with timestamps
   - Color-coded by event type (state change, label change, note, reference)
   - Actor names with role context
   - Grouped by day/week for readability
   - Evidence snippets from notes (first 200 chars)
3. Robot renderer output (--robot / -J):
   - JSON array of events with: timestamp, event_type, actor, entity_ref, body/snippet, metadata
   - Seed entities listed separately (what matched the query)
   - Expansion depth metadata (how far from seed)
   - Total event count and time range
4. CLI flags:
   - --project (scope to project)
   - --since (time range)
   - --depth N (expansion depth, default 1, max 3)
   - --expand-mentions (follow mention references, not just closes/related)
   - -n LIMIT (max events)
5. Performance: timeline for a single issue with 50 events renders in <200ms

## Relationship to Existing Beads
This supersedes/unifies: bd-1nf (CLI wiring), bd-2f2 (human renderer), bd-dty (robot renderer). Those can be closed when this ships.

## Files to Modify
- src/cli/commands/timeline.rs (CLI wiring, flag parsing, output dispatch)
- src/core/timeline.rs (may need RENDER stage types)
- New: src/cli/render/timeline_human.rs or inline in timeline.rs
- New: src/cli/render/timeline_robot.rs or inline in timeline.rs

Already implemented: run_timeline(), print_timeline(), print_timeline_json_with_meta(), handle_timeline() all exist and are fully wired. Code audit 2026-02-12.


Implement Sync screen (running + summary modes + progress coalescer)

## Background
The Sync screen has two modes: Running mode (real-time progress during sync) and Summary mode (post-sync "what changed" view). A progress coalescer batches events at <=30Hz per lane to reduce render pressure. The sync delta ledger tracks exact changed entity IDs for the summary.

## Approach
State (state/sync.rs):
- SyncState: mode (SyncMode: Running|Summary), progress_lanes (HashMap<LaneKey, LaneProgress>), log_lines (Vec<String>), delta_ledger (SyncDeltaLedger), stream_stats (StreamStats), cancel_requested (bool)
- LaneKey: (project_path, resource_type)
- LaneProgress: total, completed, rate_per_sec, eta_seconds
- StreamStats: dropped_progress (u64), max_queue_depth (usize)

Progress Coalescer (progress_coalescer.rs):
- ProgressCoalescer: buffers per-lane events, emits SyncProgressBatch at <=30Hz
- Per-lane dedup: only latest progress value per lane key emitted per batch

Sync Delta Ledger (sync_delta_ledger.rs):
- SyncDeltaLedger: in-memory map of changed/new entity IDs per sync run
- Populated from SyncCompleted result
- Used by Summary mode for navigable "what changed" list

Action:
- start_sync_task: spawns sync on dedicated thread, streams progress events via channel
- Backpressure: bounded channel, drop oldest on full (SyncBackpressureDrop msg)

View (view/sync.rs):
- Running mode: per-lane progress bars, log stream, cancel button (c key), stream stats footer
- Summary mode: delta counts per project/resource, navigable changed entity list, sync duration
- Inline mode (12 rows): compact progress for `lore sync --tui`

## Acceptance Criteria
- [ ] Running mode shows per-lane progress bars with rate and ETA
- [ ] Progress coalescer limits updates to <=30Hz per lane
- [ ] Backpressure: bounded channel drops oldest events (not newest)
- [ ] Cancel (c key) signals cooperative cancellation, acknowledged p95 < 250ms
- [ ] SyncCompleted populates delta ledger with exact changed entity IDs
- [ ] Summary mode shows per-project/resource changed counts
- [ ] Changed entities navigable: Enter opens entity detail
- [ ] Stream stats (dropped events, queue depth) shown in footer
- [ ] Inline mode renders in 12 rows without alternate screen

## Files
- MODIFY: crates/lore-tui/src/state/sync.rs (expand from stub)
- CREATE: crates/lore-tui/src/progress_coalescer.rs
- CREATE: crates/lore-tui/src/sync_delta_ledger.rs
- CREATE: crates/lore-tui/src/view/sync.rs
- MODIFY: crates/lore-tui/src/action.rs (add start_sync_task)

## TDD Anchor
RED: Write test_progress_coalescer_dedup in progress_coalescer.rs that pushes 10 events for same lane in 1ms, asserts only 1 event emitted.
GREEN: Implement per-lane dedup with timer-based batching.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_progress_coalescer

Additional tests:
- test_delta_ledger_tracks_changes: SyncCompleted with 5 changed issues populates ledger
- test_backpressure_drops_oldest: fill channel, assert newest events survive
- test_cancel_sets_token: cancel request sets CancelToken, next check returns true

## Edge Cases
- Sync already running: show warning, don't start duplicate
- Network disconnect mid-sync: show error in running mode, offer retry
- Very fast sync (small dataset): running mode may flash briefly — ensure summary always shown
- Terminal resize during sync: progress bars must reflow
- Inline mode (--sync flag): no alternate screen, 12-row constraint

## Dependency Context
Uses TaskSupervisor with TaskKey::SyncStream from "Implement TaskSupervisor" task.
Uses CancelToken for cooperative cancellation.
Uses DbManager writer for sync operations.
Uses existing sync pipeline from lore core.


Add work item status via GraphQL enrichment

## Summary

GitLab 18.2+ has native work item status (To do, In progress, Done, Won't do, Duplicate) available ONLY via GraphQL, not REST. This enriches synced issues with status information by making supplementary GraphQL calls after REST ingestion.

**Plan document:** plans/work-item-status-graphql.md

## Critical Findings (from API research)

- **EE-only (Premium/Ultimate)** — Free tier won't have the widget at all
- **GraphQL auth differs from REST** — must use `Authorization: Bearer <token>`, NOT `PRIVATE-TOKEN`
- **Must use `workItems` resolver, NOT `project.issues`** — legacy issues path doesn't expose status widgets
- **5 categories:** TRIAGE, TO_DO, IN_PROGRESS, DONE, CANCELED (not 3 as originally assumed)
- **Max 100 items per GraphQL page** (standard GitLab limit)
- **Custom statuses possible on 18.5+** — can't assume only system-defined statuses

## Migration

Uses migration **021** (001-020 already exist on disk).
Adds `status_name TEXT` and `status_category TEXT` to `issues` table (both nullable).

## Files

- src/gitlab/graphql.rs (NEW — minimal GraphQL client + status fetcher)
- src/gitlab/mod.rs (add pub mod graphql)
- src/gitlab/types.rs (WorkItemStatus, WorkItemStatusCategory enum)
- src/core/db.rs (migration 021 in MIGRATIONS array)
- src/core/config.rs (fetch_work_item_status toggle in SyncConfig)
- src/ingestion/orchestrator.rs (enrichment step after issue sync)
- src/cli/commands/show.rs (display status)
- src/cli/commands/list.rs (status in list output + --status filter)

## Acceptance Criteria

- [ ] GraphQL client POSTs queries with Bearer auth and handles errors
- [ ] Status fetched via workItems resolver with pagination
- [ ] Migration 021 adds status_name and status_category to issues
- [ ] lore show issue displays status (when available)
- [ ] lore --robot show issue includes status in JSON
- [ ] lore list issues --status filter works
- [ ] Graceful degradation: Free tier, old GitLab, disabled GraphQL all handled
- [ ] Config toggle: fetch_work_item_status (default true)
- [ ] cargo check + clippy + tests pass


Fetch MR diffs API and populate mr_file_changes

## Background

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

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

## Codebase Context

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

## Approach

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

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

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

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

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

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

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

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

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

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

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

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

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

## Acceptance Criteria

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

## Files

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

## TDD Loop

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

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

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

## Edge Cases

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

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


[CP1] Issue transformer with label extraction

Transform GitLab issue payloads to normalized database schema.

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

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

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


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

## Background

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

## Approach

Add these dependencies to Cargo.toml:

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

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

## Acceptance Criteria

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

## Files

- Cargo.toml (edit)

## TDD Loop

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

## Edge Cases

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

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