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

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

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

```rust
use serde::Serialize;

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

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

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

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

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

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

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

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

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


NOTE-2A: Schema migration for note documents (migration 024)

## Background
The documents and dirty_sources tables have CHECK constraints limiting source_type to ('issue', 'merge_request', 'discussion'). Need to add 'note' as valid source_type. SQLite doesn't support ALTER CONSTRAINT, so use the table-rebuild pattern. Uses migration slot 024 (022 = query indexes, 023 = issue_detail_fields already exists).

## Approach
Create migrations/024_note_documents.sql:

1. Rebuild dirty_sources: CREATE dirty_sources_new with CHECK adding 'note', INSERT SELECT, DROP old, RENAME.
2. Rebuild documents (complex — must preserve FTS consistency):
   - Save junction table data (_doc_labels_backup, _doc_paths_backup)
   - Drop FTS triggers (documents_ai, documents_ad, documents_au — defined in migration 008_fts5.sql)
   - Drop junction tables (document_labels, document_paths — defined in migration 007_documents.sql)
   - Create documents_new with updated CHECK adding 'note'
   - INSERT INTO documents_new SELECT * FROM documents (preserves rowids for FTS)
   - Drop documents, rename new
   - Recreate all indexes (idx_documents_project_updated, idx_documents_author, idx_documents_source, idx_documents_content_hash — see migration 007_documents.sql for definitions)
   - Recreate junction tables + restore data from backups
   - Recreate FTS triggers (see migration 008_fts5.sql for trigger SQL)
   - INSERT INTO documents_fts(documents_fts) VALUES('rebuild')
3. Defense-in-depth triggers:
   - notes_ad_cleanup: AFTER DELETE ON notes WHEN old.is_system = 0 → delete doc + dirty_sources for source_type='note', source_id=old.id
   - notes_au_system_cleanup: AFTER UPDATE OF is_system ON notes WHEN NEW.is_system = 1 AND OLD.is_system = 0 → delete doc + dirty_sources
4. Drop temp backup tables

Register as ("024", include_str!("../../migrations/024_note_documents.sql")) in MIGRATIONS array in src/core/db.rs. Position AFTER the "023" entry.

## Files
- CREATE: migrations/024_note_documents.sql
- MODIFY: src/core/db.rs (add ("024", include_str!(...)) to MIGRATIONS array, after line 75)

## TDD Anchor
RED: test_migration_024_allows_note_source_type — INSERT with source_type='note' should succeed in both documents and dirty_sources.
GREEN: Implement the table rebuild migration.
VERIFY: cargo test migration_024 -- --nocapture
Tests: test_migration_024_preserves_existing_data, test_migration_024_fts_triggers_intact, test_migration_024_row_counts_preserved, test_migration_024_integrity_checks_pass, test_migration_024_fts_rebuild_consistent, test_migration_024_note_delete_trigger_cleans_document, test_migration_024_note_system_flip_trigger_cleans_document, test_migration_024_system_note_delete_trigger_does_not_fire

## Acceptance Criteria
- [ ] INSERT source_type='note' succeeds in documents and dirty_sources
- [ ] All existing data preserved through table rebuild (row counts match before/after)
- [ ] FTS triggers fire correctly after rebuild (insert a doc, verify FTS entry exists)
- [ ] documents_fts row count == documents row count after rebuild
- [ ] PRAGMA foreign_key_check returns no violations
- [ ] notes_ad_cleanup trigger fires on note deletion (deletes document + dirty_sources)
- [ ] notes_au_system_cleanup trigger fires when is_system flips 0→1
- [ ] System note deletion does NOT trigger notes_ad_cleanup (is_system = 1 guard)
- [ ] All 9 tests pass

## Edge Cases
- Rowid preservation: INSERT INTO documents_new SELECT * preserves id column = rowid for FTS consistency
- CRITICAL: Must save/restore junction table data (ON DELETE CASCADE on document_labels/document_paths would delete them when documents table is dropped)
- The FTS rebuild at end is a safety net for any rowid drift
- Empty database: migration is a no-op (all SELECTs return 0 rows, tables rebuilt with new CHECK)


Register migration 015 in db.rs and create migration 016 for mr_file_changes

## Background

This bead creates the `mr_file_changes` table that stores which files each MR touched, enabling Gate 4 (file-history) and Gate 5 (trace). It maps MRs to the file paths they modify.

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

## Codebase Context — CRITICAL Migration Numbering

- **LATEST_SCHEMA_VERSION = 14** (MIGRATIONS array in db.rs includes 001-014)
- **Migration 015 exists on disk** (`migrations/015_commit_shas_and_closes_watermark.sql`) but is **NOT registered** in `src/core/db.rs` MIGRATIONS array
- `merge_commit_sha` and `squash_commit_sha` are already on merge_requests (added by 015 SQL) and already used in `src/ingestion/merge_requests.rs`
- `closes_issues_synced_for_updated_at` also added by 015 and used in orchestrator.rs
- **This bead must FIRST register migration 015 in db.rs**, then create migration 016 for mr_file_changes
- pending_dependent_fetches already has `job_type='mr_diffs'` in CHECK constraint (migration 011)
- Schema version auto-computes: `LATEST_SCHEMA_VERSION = MIGRATIONS.len() as i32`

## Approach

### Step 1: Register existing migration 015 in db.rs

Add to MIGRATIONS array in `src/core/db.rs` (after the "014" entry):

```rust
(
    "015",
    include_str!("../../migrations/015_commit_shas_and_closes_watermark.sql"),
),
```

This makes LATEST_SCHEMA_VERSION = 15.

### Step 2: Create migration 016 for mr_file_changes

Create `migrations/016_mr_file_changes.sql`:

```sql
-- Migration 016: MR file changes table
-- Powers file-history and trace commands (Gates 4-5)

CREATE TABLE mr_file_changes (
    id INTEGER PRIMARY KEY,
    merge_request_id INTEGER NOT NULL REFERENCES merge_requests(id) ON DELETE CASCADE,
    project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    old_path TEXT,
    new_path TEXT NOT NULL,
    change_type TEXT NOT NULL CHECK (change_type IN ('added', 'modified', 'renamed', 'deleted')),
    UNIQUE(merge_request_id, new_path)
);

CREATE INDEX idx_mfc_project_path ON mr_file_changes(project_id, new_path);
CREATE INDEX idx_mfc_project_old_path ON mr_file_changes(project_id, old_path) WHERE old_path IS NOT NULL;
CREATE INDEX idx_mfc_mr ON mr_file_changes(merge_request_id);
CREATE INDEX idx_mfc_renamed ON mr_file_changes(project_id, change_type) WHERE change_type = 'renamed';

INSERT INTO schema_version (version, applied_at, description)
VALUES (16, strftime('%s', 'now') * 1000, 'MR file changes table');
```

### Step 3: Register migration 016 in db.rs

```rust
(
    "016",
    include_str!("../../migrations/016_mr_file_changes.sql"),
),
```

LATEST_SCHEMA_VERSION will auto-compute to 16.

## Acceptance Criteria

- [ ] Migration 015 registered in MIGRATIONS array in src/core/db.rs
- [ ] Migration file exists at `migrations/016_mr_file_changes.sql`
- [ ] `mr_file_changes` table has columns: id, merge_request_id, project_id, old_path, new_path, change_type
- [ ] UNIQUE constraint on (merge_request_id, new_path)
- [ ] CHECK constraint on change_type: added, modified, renamed, deleted
- [ ] 4 indexes: project+new_path, project+old_path (partial), mr_id, project+renamed (partial)
- [ ] Migration 016 registered in MIGRATIONS array
- [ ] LATEST_SCHEMA_VERSION auto-computes to 16
- [ ] `lore migrate` applies both 015 and 016 successfully on a v14 database
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

- `src/core/db.rs` (register migrations 015 AND 016 in MIGRATIONS array)
- `migrations/016_mr_file_changes.sql` (NEW)

## TDD Loop

RED: `lore migrate` on v14 database says "already up to date" (015 not registered)

GREEN: Register 015 in db.rs, create 016 file, register 016 in db.rs. `lore migrate` applies both.

VERIFY:
```bash
cargo check --all-targets
lore --robot migrate
sqlite3 ~/.local/share/lore/lore.db '.schema mr_file_changes'
sqlite3 ~/.local/share/lore/lore.db "SELECT version FROM schema_version ORDER BY version DESC LIMIT 1"
```

## Edge Cases

- Databases already at v15 via manual migration: 015 will be skipped, only 016 applied
- old_path is NULL for added files, populated for renamed/deleted
- No lines_added/lines_removed columns (spec does not require them; removed to match spec exactly)
- Partial indexes only index relevant rows for rename chain BFS performance

Completed: registered migration 015 in db.rs MIGRATIONS array, created migration 016 (mr_file_changes table with 4 indexes, CHECK constraint, UNIQUE constraint), registered 016 in db.rs. LATEST_SCHEMA_VERSION auto-computes to 16. cargo check, clippy, and fmt all pass.


NOTE-1D: robot-docs integration for notes command

## Background
Add the notes command to the robot-docs manifest so agents can discover it. Also forward-prep SearchArgs --type to accept "note"/"notes" (duplicates work in NOTE-2F but is safe to do early).

## Approach
1. Robot-docs manifest is in src/main.rs, function handle_robot_docs() starting at line 2087. The commands JSON is built at line 2090 with serde_json::json!. Add a "notes" entry following the pattern of "issues" (line 2107 area) and "mrs" entries:

   "notes": {
       "description": "List notes from discussions with rich filtering",
       "flags": ["--limit/-n <N>", "--author/-a <username>", "--note-type <type>", "--contains <text>", "--for-issue <iid>", "--for-mr <iid>", "-p/--project <path>", "--since <period>", "--until <period>", "--path <filepath>", "--resolution <any|unresolved|resolved>", "--sort <created|updated>", "--asc", "--include-system", "--note-id <id>", "--gitlab-note-id <id>", "--discussion-id <id>", "--format <table|json|jsonl|csv>", "--fields <list|minimal>", "--open"],
       "robot_flags": ["--format json", "--fields minimal"],
       "example": "lore --robot notes --author jdefting --since 1y --format json --fields minimal",
       "response_schema": {
           "ok": "bool",
           "data": {"notes": "[NoteListRowJson]", "total_count": "int", "showing": "int"},
           "meta": {"elapsed_ms": "int"}
       }
   }

2. Update SearchArgs.source_type value_parser in src/cli/mod.rs (line 560) to include "note":
   value_parser = ["issue", "mr", "discussion", "note"]
   (This is also done in NOTE-2F but is safe to do in either order — value_parser is additive)

3. Add "notes" to the command list in handle_robot_docs (line 662 area where command names are listed).

## Files
- MODIFY: src/main.rs (add notes to robot-docs commands JSON at line 2090 area, add to command list at line 662)
- MODIFY: src/cli/mod.rs (add "note" to SearchArgs source_type value_parser at line 560)

## TDD Anchor
Smoke test: cargo run -- --robot robot-docs | jq '.data.commands.notes' should return the notes command entry.
VERIFY: cargo test -- --nocapture (no dedicated test needed — robot-docs is a static JSON generator)

## Acceptance Criteria
- [ ] lore robot-docs output includes notes command with all flags
- [ ] notes command has response_schema, example, and robot_flags
- [ ] SearchArgs accepts --type note
- [ ] All existing tests still pass

## Dependency Context
- Depends on NOTE-1A (bd-20p9), NOTE-1B (bd-3iod), NOTE-1C (bd-25hb): command must be fully wired before documenting (the manifest should describe actual working behavior)

## Edge Cases
- robot-docs --brief mode: notes command should still appear in brief output
- Value parser order doesn't matter — "note" can be added at any position in the array


Implement responsive layout system (LORE_BREAKPOINTS + Responsive)

## Background

Every TUI view needs to adapt its layout to terminal width. The PRD defines a project-wide breakpoint system using FrankenTUI native `Responsive<T>` with 5 tiers: Xs (<60), Sm (60-89), Md (90-119), Lg (120-159), Xl (160+). This is cross-cutting infrastructure — Dashboard uses it for 1/2/3-column layout, Issue/MR lists use it for column visibility, Search/Who use it for split-pane toggling. Without this, every view would reinvent breakpoint logic.

## Approach

Define a single `layout.rs` module in the TUI crate with:

1. **`LORE_BREAKPOINTS` constant** — `Breakpoints::new(60, 90, 120)` (Xl defaults to Lg + 40 = 160)
2. **`classify_width(width: u16) -> Breakpoint`** — wrapper around `LORE_BREAKPOINTS.classify_width(area.width)`
3. **Helper functions** using `Responsive<T>`:
   - `dashboard_columns(bp: Breakpoint) -> u16` — 1 (Xs/Sm), 2 (Md), 3 (Lg/Xl)
   - `show_preview_pane(bp: Breakpoint) -> bool` — false (Xs/Sm), true (Md+)
   - `table_columns(bp: Breakpoint, screen: Screen) -> Vec<ColumnDef>` — returns visible column set per breakpoint per screen

Reference FrankenTUI types:
```rust
use ftui::layout::{Breakpoints, Breakpoint, Responsive, Flex, Constraint};
pub const LORE_BREAKPOINTS: Breakpoints = Breakpoints::new(60, 90, 120);
```

The `Responsive<T>` wrapper provides breakpoint-aware values with inheritance — `.new(base)` sets Xs, `.at(bp, val)` sets overrides, `.resolve_cloned(bp)` walks downward.

## Acceptance Criteria
- [ ] `LORE_BREAKPOINTS` constant defined with sm=60, md=90, lg=120
- [ ] `classify_width()` returns correct Breakpoint for widths: 40->Xs, 60->Sm, 90->Md, 120->Lg, 160->Xl
- [ ] `dashboard_columns()` returns 1 for Xs/Sm, 2 for Md, 3 for Lg/Xl
- [ ] `show_preview_pane()` returns false for Xs/Sm, true for Md+
- [ ] All helpers use `Responsive<T>` (not bare match), so inheritance is automatic
- [ ] Module is `pub` and importable by all view modules

## Files
- CREATE: crates/lore-tui/src/layout.rs
- MODIFY: crates/lore-tui/src/lib.rs (add `pub mod layout;`)

## TDD Anchor
RED: Write `test_classify_width_boundaries` that asserts classify_width(59)=Xs, classify_width(60)=Sm, classify_width(89)=Sm, classify_width(90)=Md, classify_width(119)=Md, classify_width(120)=Lg, classify_width(159)=Lg, classify_width(160)=Xl.
GREEN: Implement LORE_BREAKPOINTS and classify_width().
VERIFY: cargo test -p lore-tui classify_width

Additional tests:
- test_dashboard_columns_per_breakpoint
- test_show_preview_pane_per_breakpoint
- test_responsive_inheritance (Sm inherits from Xs when no Sm override set)

## Edge Cases
- Terminal width of 0 or 1 must not panic — classify to Xs
- Very wide terminals (>300 cols) should still work — classify to Xl
- All Responsive<T> values must have an Xs base so resolve never fails

## Dependency Context
- Depends on bd-3ddw (crate scaffold) which creates the crates/lore-tui/ workspace
- Consumed by all view beads (bd-35g5 Dashboard, bd-3ei1 Issue List, bd-2kr0 MR List, bd-1zow Search, bd-u7se Who, etc.) for layout decisions


WHO: Epic — People Intelligence Commands

## Background

The current beads roadmap focuses on Gate 4/5 (file-history, code-trace) — archaeology queries requiring mr_file_changes data that does not exist yet. Meanwhile, the DB has rich people/activity data (280K notes, 210K discussions, 33K DiffNotes with file positions, 53 active participants) that can answer collaboration questions immediately with zero new tables or API calls.

## Scope

This epic builds `lore who` — a pure SQL query layer answering 5 questions:
1. **Expert**: "Who should I talk to about this feature/file?" (DiffNote path analysis)
2. **Workload**: "What is person X working on?" (open issues, authored/reviewing MRs, unresolved discussions)
3. **Reviews**: "What review patterns does person X have?" (DiffNote **prefix** category extraction)
4. **Active**: "What discussions are actively in progress?" (unresolved resolvable discussions)
5. **Overlap**: "Who else has MRs/notes touching my files?" (path-based activity overlap)

## Plan Reference

Full implementation plan with 8 iterations of review: `docs/who-command-design.md`

## Children (Execution Order)

1. **bd-34rr** — Migration 017: 5 composite indexes for query performance
2. **bd-2rk9** — CLI skeleton: WhoArgs, Commands::Who, dispatch, stub file
3. **bd-2ldg** — Mode resolution, path helpers, run_who entry point, all result types
4. **bd-zqpf** — Expert mode query (CTE + MR-breadth scoring)
5. **bd-s3rc** — Workload mode query (4 SELECT queries)
6. **bd-m7k1** — Active mode query (CTE + global/scoped SQL variants)
7. **bd-b51e** — Overlap mode query (dual role tracking + accumulator)
8. **bd-2711** — Reviews mode query (prefix extraction + normalization)
9. **bd-1rdi** — Human terminal output for all 5 modes
10. **bd-3mj2** — Robot JSON output for all 5 modes
11. **bd-tfh3** — Comprehensive test suite (20+ tests)
12. **bd-zibc** — VALID_COMMANDS + robot-docs manifest
13. **bd-g0d5** — Verification gate (check, clippy, fmt, EXPLAIN QUERY PLAN)

## Design Principles (from plan)

- All SQL fully static — no format!() for query text, LIMIT bound as ?N
- prepare_cached() everywhere for statement caching
- (?N IS NULL OR ...) nullable binding except Active mode (two SQL variants for index selection)
- Self-review exclusion on all DiffNote-based branches
- Deterministic output: sorted GROUP_CONCAT, sorted HashSet-derived vectors, stable tie-breakers
- Truncation transparency: LIMIT+1 pattern with truncated bool
- Bounded payloads: capped arrays with *_total + *_truncated metadata
- Robot-first reproducibility: input + resolved_input with since_mode tri-state

## Files

| File | Action | Description |
|---|---|---|
| `src/cli/commands/who.rs` | CREATE | All 5 query modes + human/robot output |
| `src/cli/commands/mod.rs` | MODIFY | Add `pub mod who` + re-exports |
| `src/cli/mod.rs` | MODIFY | Add `WhoArgs` struct + `Commands::Who` variant |
| `src/main.rs` | MODIFY | Add dispatch arm + `handle_who` fn + VALID_COMMANDS + robot-docs |
| `src/core/db.rs` | MODIFY | Add migration 017: composite indexes for who query paths |

## TDD Loop

Each child bead has its own RED/GREEN/VERIFY cycle. The epic TDD strategy:
- RED: Tests in bd-tfh3 (written alongside query beads)
- GREEN: Query implementations in bd-zqpf, bd-s3rc, bd-m7k1, bd-b51e, bd-2711
- VERIFY: bd-g0d5 runs `cargo test` + `cargo clippy` + EXPLAIN QUERY PLAN

## Acceptance Criteria

- [ ] `lore who src/path/` shows ranked experts with scores
- [ ] `lore who @username` shows workload across all projects
- [ ] `lore who @username --reviews` shows categorized review patterns
- [ ] `lore who --active` shows unresolved discussions
- [ ] `lore who --overlap src/path/` shows other contributors
- [ ] `lore who --path README.md` handles root files
- [ ] `lore -J who ...` produces valid JSON with input + resolved_input
- [ ] All indexes verified via EXPLAIN QUERY PLAN
- [ ] cargo check + clippy + fmt + test all pass

## Edge Cases

- This epic has zero new tables — all queries are pure SQL over existing schema + migration 017 indexes
- Gate 4/5 beads are NOT dependencies — who command works independently with current data
- If DB has <1000 notes, queries will work but results will be sparse — this is expected for fresh installations
- format_relative_time() is duplicated from list.rs intentionally (private fn, small blast radius > refactoring shared module)
- lookup_project_path() is local to who.rs — single invocation per run, does not warrant shared utility

All 13 child beads implemented: migration 017 (5 composite indexes), CLI skeleton with WhoArgs/dispatch/robot-docs, 5 query modes (expert/workload/active/overlap/reviews), human terminal + robot JSON output, 20 tests. All quality gates pass: cargo check, clippy (pedantic+nursery), fmt, test.


[CP1] Discussion and note transformers

## Background

Discussion and note transformers convert GitLab API discussion responses into our normalized schema. They compute derived fields like `first_note_at`, `last_note_at`, resolvable/resolved status, and note positions. These are pure functions with no I/O.

## Approach

Create transformer module with:

### Structs

```rust
// src/gitlab/transformers/discussion.rs

pub struct NormalizedDiscussion {
    pub gitlab_discussion_id: String,
    pub project_id: i64,
    pub issue_id: i64,
    pub noteable_type: String,        // "Issue"
    pub individual_note: bool,
    pub first_note_at: Option<i64>,   // min(note.created_at) in ms epoch
    pub last_note_at: Option<i64>,    // max(note.created_at) in ms epoch
    pub last_seen_at: i64,
    pub resolvable: bool,             // any note is resolvable
    pub resolved: bool,               // all resolvable notes are resolved
}

pub struct NormalizedNote {
    pub gitlab_id: i64,
    pub project_id: i64,
    pub note_type: Option<String>,    // "DiscussionNote" | "DiffNote" | null
    pub is_system: bool,              // from note.system
    pub author_username: String,
    pub body: String,
    pub created_at: i64,              // ms epoch
    pub updated_at: i64,              // ms epoch
    pub last_seen_at: i64,
    pub position: i32,                // 0-indexed array position
    pub resolvable: bool,
    pub resolved: bool,
    pub resolved_by: Option<String>,
    pub resolved_at: Option<i64>,
}
```

### Functions

```rust
pub fn transform_discussion(
    gitlab_discussion: &GitLabDiscussion,
    local_project_id: i64,
    local_issue_id: i64,
) -> NormalizedDiscussion

pub fn transform_notes(
    gitlab_discussion: &GitLabDiscussion,
    local_project_id: i64,
) -> Vec<NormalizedNote>
```

## Acceptance Criteria

- [ ] `NormalizedDiscussion` struct with all fields
- [ ] `NormalizedNote` struct with all fields
- [ ] `transform_discussion` computes first_note_at/last_note_at from notes array
- [ ] `transform_discussion` computes resolvable (any note is resolvable)
- [ ] `transform_discussion` computes resolved (all resolvable notes resolved)
- [ ] `transform_notes` preserves array order via position field (0-indexed)
- [ ] `transform_notes` maps system flag to is_system
- [ ] Unit tests cover all computed fields

## Files

- src/gitlab/transformers/mod.rs (add `pub mod discussion;`)
- src/gitlab/transformers/discussion.rs (create)

## TDD Loop

RED:
```rust
// tests/discussion_transformer_tests.rs
#[test] fn transforms_discussion_payload_to_normalized_schema()
#[test] fn extracts_notes_array_from_discussion()
#[test] fn sets_individual_note_flag_correctly()
#[test] fn flags_system_notes_with_is_system_true()
#[test] fn preserves_note_order_via_position_field()
#[test] fn computes_first_note_at_and_last_note_at_correctly()
#[test] fn computes_resolvable_and_resolved_status()
```

GREEN: Implement transform_discussion and transform_notes

VERIFY: `cargo test discussion_transformer`

## Edge Cases

- Discussion with single note - first_note_at == last_note_at
- All notes are system notes - still compute timestamps
- No notes resolvable - resolvable=false, resolved=false
- Mix of resolved/unresolved notes - resolved=false until all done

Implemented NormalizedDiscussion, NormalizedNote, transform_discussion, transform_notes with 9 passing unit tests


Implement NavigationStack (back/forward/jump list)

## Background
Navigation uses a stack with global shortcuts, supporting back/forward (browser-like) and jump list (vim-like Ctrl+O/Ctrl+I). State is preserved when navigating away — screens are never cleared on pop. The jump list only records "significant" hops (detail views, cross-references).

## Approach
Create crates/lore-tui/src/navigation.rs:
- NavigationStack struct: back_stack (Vec<Screen>), current (Screen), forward_stack (Vec<Screen>), jump_list (Vec<Screen>), jump_index (usize), browse_snapshots (HashMap<ScreenKind, BrowseSnapshot>)
- new() -> Self: initializes with Dashboard as current
- current() -> &Screen
- is_at(&Screen) -> bool
- push(Screen): pushes current to back_stack, clears forward_stack, sets new current, records detail hops in jump_list
- pop() -> Option<Screen>: pops from back_stack, pushes current to forward_stack
- go_forward() -> Option<Screen>: pops from forward_stack, pushes current to back_stack
- jump_back() -> Option<&Screen>: moves backward in jump list (Ctrl+O)
- jump_forward() -> Option<&Screen>: moves forward in jump list (Ctrl+I)
- reset_to(Screen): clears all stacks, sets new current (H=Home)
- breadcrumbs() -> Vec<&str>: returns labels for breadcrumb display
- depth() -> usize: back_stack.len() + 1
- BrowseSnapshot struct: per-screen pagination cursor snapshot for stable ordering during concurrent writes

## Acceptance Criteria
- [ ] push() adds to back_stack and clears forward_stack
- [ ] pop() moves current to forward_stack and restores previous
- [ ] go_forward() restores from forward_stack
- [ ] jump_back/forward navigates only through detail views
- [ ] reset_to() clears all history
- [ ] breadcrumbs() returns ordered screen labels
- [ ] pop() returns None at root (can't pop past Dashboard)
- [ ] push() only records is_detail_or_entity() screens in jump_list

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

## TDD Anchor
RED: Write test_push_pop_preserves_order that pushes Dashboard->IssueList->IssueDetail, pops twice, verifies correct order.
GREEN: Implement push/pop with back_stack.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_push_pop

Additional tests:
- test_forward_stack_cleared_on_new_push
- test_jump_list_skips_list_screens
- test_reset_clears_all_history
- test_pop_at_root_returns_none
- test_breadcrumbs_reflect_stack

## Edge Cases
- Stack depth has no explicit limit — deeply nested cross-reference chains are supported
- Forward stack must be cleared on any new push (browser behavior)
- Jump list must truncate forward entries when recording a new jump (vim behavior)

## Dependency Context
Uses Screen enum and Screen::is_detail_or_entity() from "Implement core types" task.


[CP1] Database migration 002_issues.sql

Create migration file with tables for issues, labels, issue_labels, discussions, and notes.

## Tables

### issues
- id INTEGER PRIMARY KEY
- gitlab_id INTEGER UNIQUE NOT NULL
- project_id INTEGER NOT NULL REFERENCES projects(id)
- iid INTEGER NOT NULL
- title TEXT, description TEXT, state TEXT
- author_username TEXT
- created_at, updated_at, last_seen_at INTEGER (ms epoch UTC)
- discussions_synced_for_updated_at INTEGER (watermark for dependent sync)
- web_url TEXT
- raw_payload_id INTEGER REFERENCES raw_payloads(id)

### labels (name-only for CP1)
- id INTEGER PRIMARY KEY
- gitlab_id INTEGER (optional, for future Labels API)
- project_id INTEGER NOT NULL REFERENCES projects(id)
- name TEXT NOT NULL
- color TEXT, description TEXT (nullable, deferred)
- UNIQUE(project_id, name)

### issue_labels (junction)
- issue_id, label_id with CASCADE DELETE
- Clear existing links before INSERT to handle removed labels

### discussions
- gitlab_discussion_id TEXT (string ID from API)
- project_id, issue_id/merge_request_id FKs
- noteable_type TEXT ('Issue' | 'MergeRequest')
- individual_note INTEGER, first_note_at, last_note_at, last_seen_at
- resolvable, resolved flags
- CHECK constraint for Issue vs MR exclusivity

### notes
- gitlab_id INTEGER UNIQUE NOT NULL
- discussion_id, project_id FKs
- note_type, is_system, author_username, body
- timestamps, position (array order)
- resolution fields, DiffNote position fields

## Indexes
- idx_issues_project_updated, idx_issues_author, idx_issues_discussions_sync
- uq_issues_project_iid, uq_labels_project_name
- idx_issue_labels_label
- uq_discussions_project_discussion_id, idx_discussions_issue/mr/last_note
- idx_notes_discussion/author/system

Files: migrations/002_issues.sql
Done when: Migration applies cleanly on top of 001_initial.sql, schema_version = 2


WHO: Human terminal output for all 5 modes

## Background

Terminal output for humans. Each mode gets a dedicated print function with consistent styling: bold headers, cyan usernames/refs, dim metadata, table alignment.

## Approach

### Dispatch:
```rust
pub fn print_who_human(result: &WhoResult, project_path: Option<&str>) {
    match result {
        WhoResult::Expert(r) => print_expert_human(r, project_path),
        WhoResult::Workload(r) => print_workload_human(r),
        WhoResult::Reviews(r) => print_reviews_human(r),
        WhoResult::Active(r) => print_active_human(r, project_path),
        WhoResult::Overlap(r) => print_overlap_human(r, project_path),
    }
}
```

### Shared helpers:
- **print_scope_hint()**: dim "(aggregated across all projects; use -p to scope)" when project_path is None. Called by Expert, Active, Overlap.
- **format_relative_time(ms_epoch)**: "just now" / "N min ago" / "N hours ago" / "N days ago" / "N weeks ago" / "N months ago" — DUPLICATE from list.rs (private there, keep blast radius small)
- **truncate_str(s, max)**: Unicode-aware, appends "..." if truncated

### Mode formats:
- **Expert**: table with Username(16) / Score(6) / Reviewed(MRs)(12) / Notes(6) / Authored(MRs)(12) / Last Seen. Path match hint line. "-" for zero counts.
- **Workload**: 4 sections (Assigned Issues, Authored MRs, Reviewing MRs, Unresolved Discussions). Canonical refs in cyan. Draft indicator. Per-section truncation.
- **Reviews**: DiffNote summary line + category table (Category(16) / Count(6) / %(6)). Uncategorized count note.
- **Active**: Discussion list with entity ref, note count, participants (comma-joined @usernames), project path. Discussion count in header.
- **Overlap**: table with Username(16) / Role(6) / MRs(7) / Last Seen(12) / MR Refs (first 5, +N overflow). Path match hint.

### All modes: truncation dim hints, empty-state messages, console::style formatting.

## Files

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

## TDD Loop

No unit tests for print functions (they write to stdout). Verification is manual smoke test.
VERIFY: `cargo check --all-targets` then manual: `cargo run --release -- who src/features/global-search/`

## Acceptance Criteria

- [ ] cargo check passes (all print functions compile)
- [ ] Each mode produces readable, aligned terminal output
- [ ] Scope hint shown when project not specified (Expert, Active, Overlap)
- [ ] Truncation hints shown when results exceed limit
- [ ] Empty-state messages for zero results

## Edge Cases

- format_relative_time handles negative diff ("in the future")
- truncate_str is Unicode-aware (.chars().count(), not .len())
- Workload shows empty message only when ALL 4 sections are empty

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


[CP1] gi show issue command

Show issue details with discussions.

Flags:
- --project=PATH (required if iid is ambiguous across projects)

Output:
- Title, project, state, author, dates, labels, URL
- Description text
- All discussions with notes (formatted thread view)

Handle ambiguity: If multiple projects have same iid, prompt for --project or show error.

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


[CP1] Integration tests for issue ingestion

Full integration tests for issue ingestion module.

## Tests (tests/issue_ingestion_tests.rs)

- inserts_issues_into_database
- creates_labels_from_issue_payloads
- links_issues_to_labels_via_junction_table
- removes_stale_label_links_on_resync
- stores_raw_payload_for_each_issue
- stores_raw_payload_for_each_discussion
- updates_cursor_incrementally_per_page
- resumes_from_cursor_on_subsequent_runs
- handles_issues_with_no_labels
- upserts_existing_issues_on_refetch
- skips_discussion_refetch_for_unchanged_issues

## Test Setup
- tempfile::TempDir for isolated database
- wiremock::MockServer for GitLab API
- Mock handlers returning fixture data

Files: tests/issue_ingestion_tests.rs
Done when: All integration tests pass with mocked GitLab


Epic: Gate 2 - Cross-Reference Extraction

## Background
Gate 2 builds the entity relationship graph that connects issues, MRs, and discussions. Without cross-references, temporal queries can only show events for individually-matched entities. With them, "lore timeline auth migration" can discover that MR !567 closed issue #234, which spawned follow-up issue #299 — even if #299 does not contain the words "auth migration."

Three data sources feed entity_references:
1. **Structured API (reliable):** GET /projects/:id/merge_requests/:iid/closes_issues
2. **State events (reliable):** resource_state_events.source_merge_request_id
3. **System note parsing (best-effort):** "mentioned in !456", "closed by !789" patterns

## Architecture
- **entity_references table:** Already created in migration 011 (bd-hu3/bd-czk). Stores source→target relationships with reference_type (closes/mentioned/related) and source_method provenance.
- **Directionality convention:** source = entity where reference was observed, target = entity being referenced. Consistent across all source_methods.
- **Unresolved references:** Cross-project refs stored with target_entity_id=NULL, target_project_path populated. Still valuable for timeline narratives.
- **closes_issues fetch:** Uses generic dependent fetch queue (job_type = mr_closes_issues). One API call per MR.
- **System note parsing:** Local post-processing after all dependent fetches complete. No API calls. English-only, best-effort.

## Children (Execution Order)
1. **bd-czk** [CLOSED] — entity_references schema (folded into migration 011)
2. **bd-8t4** [OPEN] — Extract cross-references from resource_state_events (source_merge_request_id)
3. **bd-3ia** [OPEN] — Fetch closes_issues API and populate entity_references
4. **bd-1ji** [OPEN] — Parse system notes for cross-reference patterns

## Gate Completion Criteria
- [ ] entity_references populated from closes_issues API for all synced MRs
- [ ] entity_references populated from state events where source_merge_request_id present
- [ ] System notes parsed for cross-reference patterns (English instances)
- [ ] Cross-project references stored as unresolved (target_entity_id=NULL)
- [ ] source_method tracks provenance of each reference
- [ ] Deduplication: same relationship from multiple sources stored once (UNIQUE constraint)
- [ ] Timeline JSON includes expansion provenance (via) for expanded entities
- [ ] Integration test: sync with all three extraction methods, verify entity_references populated

## Dependencies
- Depends on: Gate 1 (bd-2zl) — event tables and dependent fetch queue
- Downstream: Gate 3 (bd-ike) depends on entity_references for BFS expansion

All child beads completed: bd-8t4 (state event extraction), bd-3ia (closes_issues API), bd-1ji (system note parsing)


Implement scope context (global project filter)

## Background
The scope context provides a global project filter that flows through all query bridge functions. Users can pin to a specific project set or view all projects. The P keybinding opens a scope picker overlay. Scope is persisted in session state.

## Approach
Create crates/lore-tui/src/scope.rs:
- ScopeContext enum: AllProjects, Pinned(Vec<ProjectInfo>)
- ProjectInfo: id (i64), path (String)
- scope_filter_sql(scope: &ScopeContext) -> String: generates WHERE clause fragment
- All action.rs query functions accept &ScopeContext parameter
- Scope picker overlay: list of projects with checkbox selection
- P keybinding toggles scope picker from any screen

## Acceptance Criteria
- [ ] AllProjects scope returns unfiltered results
- [ ] Pinned scope filters to specific project IDs
- [ ] All query functions respect global scope
- [ ] P keybinding opens scope picker
- [ ] Scope persisted in session state
- [ ] Scope change triggers re-query of current screen

## Files
- CREATE: crates/lore-tui/src/scope.rs
- MODIFY: crates/lore-tui/src/action.rs (add scope parameter to all queries)

## TDD Anchor
RED: Write test_scope_filter_sql that creates Pinned scope with 2 projects, asserts generated SQL contains IN (1, 2).
GREEN: Implement scope_filter_sql.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_scope_filter

## Edge Cases
- Single-project datasets: scope picker not needed, but should still work
- Very many projects (>50): scope picker should be scrollable
- Scope change mid-pagination: reset cursor to first page

## Dependency Context
Uses AppState from "Implement AppState composition" task.
Uses session persistence from "Implement session persistence" task.


Add half_life_decay() pure function

## Background
The decay function is the mathematical core of the scoring model. It must be correct, tested first (TDD RED), and verified independently of any DB or SQL changes.

## Approach
Add to who.rs as a private function near the top of the module (before query_expert):

```rust
/// Exponential half-life decay: R = 2^(-t/h)
/// Returns 1.0 at elapsed=0, 0.5 at elapsed=half_life, 0.0 if half_life=0.
fn half_life_decay(elapsed_ms: i64, half_life_days: u32) -> f64 {
    let days = (elapsed_ms as f64 / 86_400_000.0).max(0.0);
    let hl = f64::from(half_life_days);
    if hl <= 0.0 { return 0.0; }
    2.0_f64.powf(-days / hl)
}
```

## TDD Loop

### RED (write first):
```rust
#[test]
fn test_half_life_decay_math() {
    let hl_180 = 180;
    // At t=0, full retention
    assert!((half_life_decay(0, hl_180) - 1.0).abs() < f64::EPSILON);
    // At t=half_life, exactly 0.5
    let one_hl_ms = 180 * 86_400_000_i64;
    assert!((half_life_decay(one_hl_ms, hl_180) - 0.5).abs() < 1e-10);
    // At t=2*half_life, exactly 0.25
    assert!((half_life_decay(2 * one_hl_ms, hl_180) - 0.25).abs() < 1e-10);
    // Negative elapsed clamped to 0 -> 1.0
    assert!((half_life_decay(-1000, hl_180) - 1.0).abs() < f64::EPSILON);
    // Zero half-life -> 0.0 (div-by-zero guard)
    assert!((half_life_decay(86_400_000, 0)).abs() < f64::EPSILON);
}

#[test]
fn test_score_monotonicity_by_age() {
    // For any half-life, older timestamps must never produce higher decay than newer ones.
    // Use deterministic LCG PRNG (no rand dependency).
    let mut seed: u64 = 42;
    let hl = 90_u32;
    for _ in 0..50 {
        seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
        let newer_ms = (seed % 100_000_000) as i64;       // 0-100M ms (~1.15 days max)
        seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
        let older_ms = newer_ms + (seed % 500_000_000) as i64; // always >= newer
        assert!(
            half_life_decay(older_ms, hl) <= half_life_decay(newer_ms, hl),
            "Monotonicity violated: decay({older_ms}) > decay({newer_ms})"
        );
    }
}
```

### GREEN: Add the half_life_decay function (3 lines of math).
### VERIFY: `cargo test -p lore -- test_half_life_decay_math test_score_monotonicity`

## Acceptance Criteria
- [ ] test_half_life_decay_math passes (4 boundary cases + div-by-zero guard)
- [ ] test_score_monotonicity_by_age passes (50 random pairs, deterministic seed)
- [ ] Function is `fn` not `pub fn` (module-private)
- [ ] No DB dependency — pure function

## Files
- src/cli/commands/who.rs (function near top, tests in test module)

## Edge Cases
- Negative elapsed_ms: clamped to 0 via .max(0.0) -> returns 1.0
- half_life_days = 0: returns 0.0, not NaN/Inf
- Very large elapsed (10 years): returns very small positive f64, never negative

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


Epic: CP2 Gate C - Dependent Discussion Sync

## Background
Gate C validates the dependent discussion sync with DiffNote position capture. This is critical for code review context preservation - without DiffNote positions, we lose the file/line context for review comments.

## Acceptance Criteria (Pass/Fail)
- [ ] Discussions fetched for MRs with updated_at > discussions_synced_for_updated_at
- [ ] `SELECT COUNT(*) FROM discussions WHERE merge_request_id IS NOT NULL` > 0
- [ ] DiffNotes have `position_new_path` populated (file path)
- [ ] DiffNotes have `position_new_line` populated (line number)
- [ ] DiffNotes have `position_type` populated (text/image/file)
- [ ] DiffNotes have SHA triplet: `position_base_sha`, `position_start_sha`, `position_head_sha`
- [ ] Multi-line DiffNotes have `position_line_range_start` and `position_line_range_end`
- [ ] Unchanged MRs skip discussion refetch (watermark comparison works)
- [ ] Watermark NOT advanced on HTTP error mid-pagination
- [ ] Watermark NOT advanced on note timestamp parse failure
- [ ] `gi show mr <iid>` displays DiffNote with file context `[path:line]`

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

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

echo "=== Gate C: Dependent Discussion Sync ==="

# 1. Check discussion count for MRs
echo "Step 1: Check MR discussion count..."
MR_DISC_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM discussions WHERE merge_request_id IS NOT NULL;")
echo "  MR discussions: $MR_DISC_COUNT"
[ "$MR_DISC_COUNT" -gt 0 ] || { echo "FAIL: No MR discussions found"; exit 1; }

# 2. Check note count
echo "Step 2: Check note count..."
NOTE_COUNT=$(sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM notes n
  JOIN discussions d ON d.id = n.discussion_id
  WHERE d.merge_request_id IS NOT NULL;
")
echo "  MR notes: $NOTE_COUNT"

# 3. Check DiffNote position data
echo "Step 3: Check DiffNote positions..."
DIFFNOTE_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM notes WHERE position_new_path IS NOT NULL;")
echo "  DiffNotes with position: $DIFFNOTE_COUNT"

# 4. Sample DiffNote data
echo "Step 4: Sample DiffNote data..."
sqlite3 "$DB_PATH" "
  SELECT
    n.gitlab_id,
    n.position_new_path,
    n.position_new_line,
    n.position_type,
    SUBSTR(n.position_head_sha, 1, 7) as head_sha
  FROM notes n
  WHERE n.position_new_path IS NOT NULL
  LIMIT 5;
"

# 5. Check multi-line DiffNotes
echo "Step 5: Check multi-line DiffNotes..."
MULTILINE_COUNT=$(sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM notes
  WHERE position_line_range_start IS NOT NULL
    AND position_line_range_end IS NOT NULL
    AND position_line_range_start != position_line_range_end;
")
echo "  Multi-line DiffNotes: $MULTILINE_COUNT"

# 6. Check watermarks set
echo "Step 6: Check watermarks..."
WATERMARKED=$(sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM merge_requests
  WHERE discussions_synced_for_updated_at IS NOT NULL;
")
echo "  MRs with watermark set: $WATERMARKED"

# 7. Check last_seen_at for sweep pattern
echo "Step 7: Check last_seen_at (sweep pattern)..."
sqlite3 "$DB_PATH" "
  SELECT
    MIN(last_seen_at) as oldest,
    MAX(last_seen_at) as newest
  FROM discussions
  WHERE merge_request_id IS NOT NULL;
"

# 8. Test show command with DiffNote
echo "Step 8: Find MR with DiffNotes for show test..."
MR_IID=$(sqlite3 "$DB_PATH" "
  SELECT DISTINCT m.iid
  FROM merge_requests m
  JOIN discussions d ON d.merge_request_id = m.id
  JOIN notes n ON n.discussion_id = d.id
  WHERE n.position_new_path IS NOT NULL
  LIMIT 1;
")
if [ -n "$MR_IID" ]; then
  echo "  Testing: gi show mr $MR_IID"
  gi show mr "$MR_IID" | head -50
fi

# 9. Re-run and verify skip count
echo "Step 9: Re-run ingest (should skip unchanged MRs)..."
gi ingest --type=merge_requests
# Should report "Skipped discussion sync for N unchanged MRs"

echo ""
echo "=== Gate C: PASSED ==="
```

## Atomicity Test (Manual - Kill Test)
```bash
# This tests that partial failure preserves data

# 1. Get an MR with discussions
MR_ID=$(sqlite3 "$DB_PATH" "
  SELECT m.id FROM merge_requests m
  JOIN discussions d ON d.merge_request_id = m.id
  LIMIT 1;
")

# 2. Note current note count
BEFORE=$(sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM notes n
  JOIN discussions d ON d.id = n.discussion_id
  WHERE d.merge_request_id = $MR_ID;
")
echo "Notes before: $BEFORE"

# 3. Note watermark
WATERMARK_BEFORE=$(sqlite3 "$DB_PATH" "
  SELECT discussions_synced_for_updated_at FROM merge_requests WHERE id = $MR_ID;
")
echo "Watermark before: $WATERMARK_BEFORE"

# 4. Force full sync and kill mid-run
gi ingest --type=merge_requests --full &
PID=$!
sleep 3 && kill -9 $PID 2>/dev/null || true
wait $PID 2>/dev/null || true

# 5. Verify notes preserved (should be same or more, never less)
AFTER=$(sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM notes n
  JOIN discussions d ON d.id = n.discussion_id
  WHERE d.merge_request_id = $MR_ID;
")
echo "Notes after kill: $AFTER"
[ "$AFTER" -ge "$BEFORE" ] || echo "WARNING: Notes decreased - atomicity may be broken"

# 6. Note watermark should NOT have advanced if killed mid-pagination
WATERMARK_AFTER=$(sqlite3 "$DB_PATH" "
  SELECT discussions_synced_for_updated_at FROM merge_requests WHERE id = $MR_ID;
")
echo "Watermark after: $WATERMARK_AFTER"
```

## Test Commands (Quick Verification)
```bash
# Check DiffNote data:
sqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 "
  SELECT
    (SELECT COUNT(*) FROM discussions WHERE merge_request_id IS NOT NULL) as mr_discussions,
    (SELECT COUNT(*) FROM notes WHERE position_new_path IS NOT NULL) as diffnotes,
    (SELECT COUNT(*) FROM merge_requests WHERE discussions_synced_for_updated_at IS NOT NULL) as watermarked;
"

# Find MR with DiffNotes and show it:
gi show mr $(sqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 "
  SELECT DISTINCT m.iid FROM merge_requests m
  JOIN discussions d ON d.merge_request_id = m.id
  JOIN notes n ON n.discussion_id = d.id
  WHERE n.position_new_path IS NOT NULL LIMIT 1;
")
```

## Dependencies
This gate requires:
- bd-3j6 (Discussion transformer with DiffNote position extraction)
- bd-20h (MR discussion ingestion with atomicity guarantees)
- bd-iba (Client pagination for MR discussions)
- Gates A and B must pass first

## Edge Cases
- MRs without discussions: should sync successfully, just with 0 discussions
- Discussions without DiffNotes: regular comments have NULL position fields
- Deleted discussions in GitLab: sweep pattern should remove them locally
- Invalid note timestamps: should NOT advance watermark, should log warning


[CP1] Integration tests for pagination

Integration tests for GitLab pagination with wiremock.

## Tests (tests/pagination_tests.rs)

### Page Navigation
- fetches_all_pages_when_multiple_exist
- respects_per_page_parameter
- follows_x_next_page_header_until_empty
- falls_back_to_empty_page_stop_if_headers_missing

### Cursor Behavior
- applies_cursor_rewind_for_tuple_semantics
- clamps_negative_rewind_to_zero

## Test Setup
- Use wiremock::MockServer
- Set up handlers for /api/v4/projects/:id/issues
- Return x-next-page headers
- Verify request params (updated_after, per_page)

Files: tests/pagination_tests.rs
Done when: All pagination tests pass with mocked server


Implement document regenerator

## Background
The document regenerator drains the dirty_sources queue, regenerating documents for each entry. It uses per-item transactions for crash safety, a triple-hash fast path to skip unchanged documents entirely (no writes at all), and a bounded batch loop that drains completely. Error recording includes backoff computation.

## Approach
Create `src/documents/regenerator.rs` per PRD Section 6.3.

**Core function:**
```rust
pub fn regenerate_dirty_documents(conn: &Connection) -> Result<RegenerateResult>
```

**RegenerateResult:** { regenerated, unchanged, errored }

**Algorithm (per PRD):**
1. Loop: get_dirty_sources(conn) -> Vec<(SourceType, i64)>
2. If empty, break (queue fully drained)
3. For each (source_type, source_id):
   a. Begin transaction
   b. Call regenerate_one_tx(&tx, source_type, source_id) -> Result<bool>
   c. If Ok(changed): clear_dirty_tx, commit, count regenerated or unchanged
   d. If Err: record_dirty_error_tx (with backoff), commit, count errored

**regenerate_one_tx (per PRD):**
1. Extract document via extract_{type}_document(conn, source_id)
2. If None (deleted): delete_document, return Ok(true)
3. If Some(doc): call get_existing_hash() to check current state
4. **If ALL THREE hashes match: return Ok(false) — skip ALL writes** (fast path)
5. Otherwise: upsert_document with conditional label/path relinking
6. Return Ok(content changed)

**Helper functions (PRD-exact):**

`get_existing_hash` — uses `optional()` to distinguish missing rows from DB errors:
```rust
fn get_existing_hash(
    conn: &Connection,
    source_type: SourceType,
    source_id: i64,
) -> Result<Option<String>> {
    use rusqlite::OptionalExtension;
    let hash: Option<String> = stmt
        .query_row(params, |row| row.get(0))
        .optional()?;  // IMPORTANT: Not .ok() — .ok() would hide real DB errors
    Ok(hash)
}
```

`get_document_id` — resolve document ID after upsert:
```rust
fn get_document_id(conn: &Connection, source_type: SourceType, source_id: i64) -> Result<i64>
```

`upsert_document` — checks existing triple hash before writing:
```rust
fn upsert_document(conn: &Connection, doc: &DocumentData) -> Result<()> {
    // 1. Query existing (id, content_hash, labels_hash, paths_hash) via OptionalExtension
    // 2. Triple-hash fast path: all match -> return Ok(())
    // 3. Upsert document row (ON CONFLICT DO UPDATE)
    // 4. Get doc_id (from existing or query after insert)
    // 5. Only delete+reinsert labels if labels_hash changed
    // 6. Only delete+reinsert paths if paths_hash changed
}
```

**Key PRD detail — triple-hash fast path:**
```rust
if old_content_hash == &doc.content_hash
    && old_labels_hash == &doc.labels_hash
    && old_paths_hash == &doc.paths_hash
{ return Ok(()); }  // Skip ALL writes — prevents WAL churn
```

**Error recording with backoff:**
record_dirty_error_tx reads current attempt_count from DB, computes next_attempt_at via shared backoff utility:
```rust
let next_attempt_at = crate::core::backoff::compute_next_attempt_at(now, attempt_count + 1);
```

**All internal functions use _tx suffix** (take &Transaction) for atomicity.

## Acceptance Criteria
- [ ] Queue fully drained (bounded batch loop until empty)
- [ ] Per-item transactions (crash loses at most 1 doc)
- [ ] Triple-hash fast path: ALL THREE hashes match -> skip ALL writes (return Ok(false))
- [ ] Content change: upsert document, update labels/paths
- [ ] Labels-only change: relabels but skips path writes (paths_hash unchanged)
- [ ] Deleted entity: delete document (cascade handles FTS/labels/paths/embeddings)
- [ ] get_existing_hash uses `.optional()` (not `.ok()`) to preserve DB errors
- [ ] get_document_id resolves document ID after upsert
- [ ] Error recording: increment attempt_count, compute next_attempt_at via backoff
- [ ] FTS triggers fire on insert/update/delete (verified by trigger, not regenerator)
- [ ] RegenerateResult counts accurate (regenerated, unchanged, errored)
- [ ] Errors do not abort batch (log, increment, continue)
- [ ] `cargo test regenerator` passes

## Files
- `src/documents/regenerator.rs` — new file
- `src/documents/mod.rs` — add `pub use regenerator::regenerate_dirty_documents;`

## TDD Loop
RED: Tests requiring DB:
- `test_creates_new_document` — dirty source -> document created
- `test_skips_unchanged_triple_hash` — all 3 hashes match -> unchanged count incremented, no DB writes
- `test_updates_changed_content` — content_hash mismatch -> updated
- `test_updates_changed_labels_only` — content same but labels_hash different -> updated
- `test_updates_changed_paths_only` — content same but paths_hash different -> updated
- `test_deletes_missing_source` — source deleted -> document deleted
- `test_drains_queue` — queue empty after regeneration
- `test_error_records_backoff` — error -> attempt_count incremented, next_attempt_at set
- `test_get_existing_hash_not_found` — returns Ok(None) for missing document
GREEN: Implement regenerate_dirty_documents + all helpers
VERIFY: `cargo test regenerator`

## Edge Cases
- Empty queue: return immediately with all-zero counts
- Extractor error for one item: record_dirty_error_tx, commit, continue
- Triple-hash prevents WAL churn on incremental syncs (most entities unchanged)
- Labels change but content does not: labels_hash mismatch triggers upsert with label relinking
- get_existing_hash on missing document: returns Ok(None) via .optional() (not DB error)
- get_existing_hash on corrupt DB: propagates real DB error (not masked by .ok())

Implemented document regenerator with triple-hash fast path, queue draining, fail-soft error handling + 5 tests


Implement DB upsert functions for resource events

## Background
Need to store fetched resource events into the three event tables created by migration 011. The existing DB pattern uses rusqlite prepared statements with named parameters. Timestamps from GitLab are ISO 8601 strings that need conversion to ms epoch UTC (matching the existing time.rs parse_datetime_to_ms function).

## Approach
Create src/core/events_db.rs (new module) with three upsert functions:

```rust
use rusqlite::Connection;
use super::error::Result;

/// Upsert state events for an entity.
/// Uses INSERT OR REPLACE keyed on UNIQUE(gitlab_id, project_id).
pub fn upsert_state_events(
    conn: &Connection,
    project_id: i64,          // local DB project id
    entity_type: &str,        // "issue" | "merge_request"
    entity_local_id: i64,     // local DB id of the issue/MR
    events: &[GitLabStateEvent],
) -> Result<usize>

/// Upsert label events for an entity.
pub fn upsert_label_events(
    conn: &Connection,
    project_id: i64,
    entity_type: &str,
    entity_local_id: i64,
    events: &[GitLabLabelEvent],
) -> Result<usize>

/// Upsert milestone events for an entity.
pub fn upsert_milestone_events(
    conn: &Connection,
    project_id: i64,
    entity_type: &str,
    entity_local_id: i64,
    events: &[GitLabMilestoneEvent],
) -> Result<usize>
```

Each function:
1. Prepares INSERT OR REPLACE statement
2. For each event, maps GitLab types to DB columns:
   - `actor_gitlab_id` = event.user.map(|u| u.id)
   - `actor_username` = event.user.map(|u| u.username.clone())
   - `created_at` = parse_datetime_to_ms(&event.created_at)?
   - Set issue_id or merge_request_id based on entity_type
3. Returns count of upserted rows
4. Wraps in a savepoint for atomicity per entity

Register module in src/core/mod.rs:
```rust
pub mod events_db;
```

## Acceptance Criteria
- [ ] All three upsert functions compile and handle all event fields
- [ ] Upserts are idempotent (re-inserting same event doesn't duplicate)
- [ ] Timestamps converted to ms epoch UTC via parse_datetime_to_ms
- [ ] actor_gitlab_id and actor_username populated from event.user (handles None)
- [ ] entity_type correctly maps to issue_id/merge_request_id (other is NULL)
- [ ] source_merge_request_id populated for state events (iid from source_merge_request)
- [ ] source_commit populated for state events
- [ ] label_name populated for label events
- [ ] milestone_title and milestone_id populated for milestone events
- [ ] Returns upserted count

## Files
- src/core/events_db.rs (new)
- src/core/mod.rs (add `pub mod events_db;`)

## TDD Loop
RED: tests/events_db_tests.rs (new):
- `test_upsert_state_events_basic` - insert 3 events, verify count and data
- `test_upsert_state_events_idempotent` - insert same events twice, verify no duplicates
- `test_upsert_label_events_with_actor` - verify actor fields populated
- `test_upsert_milestone_events_null_user` - verify user: null doesn't crash
- `test_upsert_state_events_entity_exclusivity` - verify only one of issue_id/merge_request_id set

Setup: create_test_db() helper that applies migrations 001-011, inserts a test project + issue + MR.

GREEN: Implement the three functions

VERIFY: `cargo test events_db -- --nocapture`

## Edge Cases
- parse_datetime_to_ms must handle GitLab's format: "2024-03-15T10:30:00.000Z" and "2024-03-15T10:30:00.000+00:00"
- INSERT OR REPLACE will fire CASCADE deletes if there are FK references to these rows — currently no other table references event rows, so this is safe
- entity_type must be validated ("issue" or "merge_request") — panic or error on invalid
- source_merge_request field contains an MR ref object, not an ID — extract .iid for DB column

Implemented upsert_state_events, upsert_label_events, upsert_milestone_events, count_events in src/core/events_db.rs. Uses savepoints for atomicity, LoreError::Database via ? operator for clean error handling.


[CP0] Final validation - tests, lint, typecheck

## Background

Final validation ensures everything works together before marking CP0 complete. This is the integration gate - all unit tests, integration tests, lint, and type checking must pass. Manual smoke tests verify the full user experience.

Reference: docs/prd/checkpoint-0.md sections "Definition of Done", "Manual Smoke Tests"

## Approach

**Automated checks:**
```bash
# All tests pass
npm run test

# TypeScript strict mode
npm run build  # or: npx tsc --noEmit

# ESLint with no errors
npm run lint
```

**Manual smoke tests (from PRD table):**

| Command | Expected | Pass Criteria |
|---------|----------|---------------|
| `gi --help` | Command list | Shows all commands |
| `gi version` | Version number | Shows installed version |
| `gi init` | Interactive prompts | Creates valid config |
| `gi init` (config exists) | Confirmation prompt | Warns before overwriting |
| `gi init --force` | No prompt | Overwrites without asking |
| `gi auth-test` | `Authenticated as @username` | Shows GitLab username |
| `GITLAB_TOKEN=invalid gi auth-test` | Error message | Non-zero exit, clear error |
| `gi doctor` | Status table | All required checks pass |
| `gi doctor --json` | JSON object | Valid JSON, `success: true` |
| `gi backup` | Backup path | Creates timestamped backup |
| `gi sync-status` | No runs message | Stub output works |

**Definition of Done gate items:**
- [ ] `gi init` writes config to XDG path and validates projects against GitLab
- [ ] `gi auth-test` succeeds with real PAT
- [ ] `gi doctor` reports DB ok + GitLab ok
- [ ] DB migrations apply; WAL + FK enabled; busy_timeout + synchronous set
- [ ] App lock mechanism works (concurrent runs blocked)
- [ ] All unit tests pass
- [ ] All integration tests pass (mocked)
- [ ] ESLint passes with no errors
- [ ] TypeScript compiles with strict mode

## Acceptance Criteria

- [ ] `npm run test` exits 0 (all tests pass)
- [ ] `npm run build` exits 0 (TypeScript compiles)
- [ ] `npm run lint` exits 0 (no ESLint errors)
- [ ] All 11 manual smoke tests pass
- [ ] All 9 Definition of Done gate items verified

## Files

No new files created. This bead verifies existing work.

## TDD Loop

This IS the final verification step:

```bash
# Automated
npm run test
npm run build
npm run lint

# Manual (requires GITLAB_TOKEN set with valid token)
gi --help
gi version
gi init  # go through setup
gi auth-test
gi doctor
gi doctor --json | jq .success  # should output true
gi backup
gi sync-status
gi reset --confirm
gi init  # re-setup
```

## Edge Cases

- Test coverage should be reasonable (aim for 80%+ on core modules)
- Integration tests may flake on CI - check MSW setup
- Manual tests require real GitLab token - document in README
- ESLint may warn vs error - only errors block
- TypeScript noImplicitAny catches missed types


Update robot-docs manifest with Phase B commands

## Background

The robot-docs manifest is the agent self-discovery mechanism. It must include all Phase B commands so agents can discover temporal intelligence features.

## Codebase Context

- handle_robot_docs() in src/main.rs (line ~1646) returns JSON with commands, exit_codes, workflows, aliases, clap_error_codes
- Currently 18 commands documented in the manifest
- VALID_COMMANDS array in src/main.rs (line ~448): ["issues", "mrs", "search", "sync", "ingest", "count", "status", "auth", "doctor", "version", "init", "stats", "generate-docs", "embed", "migrate", "health", "robot-docs", "completions"]
- Phase B adds 3 new commands: timeline, file-history, trace
- count gains new entity: "references" (bd-2ez)
- Existing workflows: first_setup, daily_sync, search, pre_flight

## Approach

### 1. Add commands to handle_robot_docs() JSON:

```json
"timeline": {
    "description": "Chronological timeline of events matching a keyword query",
    "flags": ["<QUERY>", "-p <project>", "--since <duration>", "--depth <n>", "--expand-mentions", "-n <limit>"],
    "example": "lore --robot timeline 'authentication' --since 30d"
},
"file-history": {
    "description": "Which MRs touched a file, with rename chain resolution",
    "flags": ["<PATH>", "-p <project>", "--discussions", "--no-follow-renames", "--merged", "-n <limit>"],
    "example": "lore --robot file-history src/auth/oauth.rs"
},
"trace": {
    "description": "Trace file -> MR -> issue -> discussions decision chain",
    "flags": ["<PATH>", "-p <project>", "--discussions", "--no-follow-renames", "-n <limit>"],
    "example": "lore --robot trace src/auth/oauth.rs"
}
```

### 2. Update count command to mention "references" entity

### 3. Add temporal_intelligence workflow:
```json
"temporal_intelligence": {
    "description": "Query temporal data about project history",
    "steps": [
        "lore sync (ensure events fetched with fetchResourceEvents=true)",
        "lore timeline '<keyword>' for chronological event history",
        "lore file-history <path> for file-level MR history",
        "lore trace <path> for file -> MR -> issue -> discussion chain"
    ]
}
```

### 4. Add timeline, file-history, trace to VALID_COMMANDS array

## Acceptance Criteria

- [ ] robot-docs includes timeline, file-history, trace commands
- [ ] count references documented
- [ ] temporal_intelligence workflow present
- [ ] VALID_COMMANDS includes all 3 new commands
- [ ] Examples are valid, runnable commands
- [ ] cargo check --all-targets passes
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files

- src/main.rs (update handle_robot_docs + VALID_COMMANDS array)

## TDD Loop

VERIFY: lore robot-docs | jq '.data.commands.timeline'
VERIFY: lore robot-docs | jq '.data.workflows.temporal_intelligence'


Add WorkItemStatus type and SyncConfig toggle

## Background
The GraphQL status response returns name, category, color, and iconName fields. We need a Rust struct that deserializes this directly. Category is stored as raw Option<String> (not an enum) because GitLab 18.5+ supports custom statuses with arbitrary category values. We also need a config toggle so users can disable status enrichment.

## Approach
Add WorkItemStatus to the existing types module. Add fetch_work_item_status to the existing SyncConfig with default_true() helper. Also add WorkItemStatus to pub use re-exports in src/gitlab/mod.rs.

## Files
- src/gitlab/types.rs (add struct after GitLabMergeRequest, before #[cfg(test)])
- src/core/config.rs (add field to SyncConfig struct + Default impl)
- src/gitlab/mod.rs (add WorkItemStatus to pub use)

## Implementation

In src/gitlab/types.rs (needs Serialize, Deserialize derives already in scope):
  #[derive(Debug, Clone, Serialize, Deserialize)]
  pub struct WorkItemStatus {
      pub name: String,
      pub category: Option<String>,
      pub color: Option<String>,
      #[serde(rename = "iconName")]
      pub icon_name: Option<String>,
  }

In src/core/config.rs SyncConfig struct (after fetch_mr_file_changes):
  #[serde(rename = "fetchWorkItemStatus", default = "default_true")]
  pub fetch_work_item_status: bool,

In impl Default for SyncConfig (after fetch_mr_file_changes: true):
  fetch_work_item_status: true,

## Acceptance Criteria
- [ ] WorkItemStatus deserializes: {"name":"In progress","category":"IN_PROGRESS","color":"#1f75cb","iconName":"status-in-progress"}
- [ ] Optional fields: {"name":"To do"} -> category/color/icon_name are None
- [ ] Unknown category: {"name":"Custom","category":"SOME_FUTURE_VALUE"} -> Ok
- [ ] Null category: {"name":"In progress","category":null} -> None
- [ ] SyncConfig::default().fetch_work_item_status == true
- [ ] JSON without fetchWorkItemStatus key -> defaults true
- [ ] cargo check --all-targets passes

## TDD Loop
RED: test_work_item_status_deserialize, test_work_item_status_optional_fields, test_work_item_status_unknown_category, test_work_item_status_null_category, test_config_fetch_work_item_status_default_true, test_config_deserialize_without_key
GREEN: Add struct + config field
VERIFY: cargo test test_work_item_status && cargo test test_config

## Edge Cases
- serde rename "iconName" -> icon_name (camelCase in GraphQL)
- Category is Option<String>, NOT an enum
- Config key is camelCase "fetchWorkItemStatus" matching existing convention

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


Implement AppState composition + LoadState + ScreenIntent

## Background
AppState is the top-level state composition — each field corresponds to one screen. State is preserved when navigating away (never cleared on pop). LoadState enables stale-while-revalidate: screens show last data during refresh with a spinner. ScreenIntent is the pure return type from state handlers — they never launch async tasks directly.

## Approach
Create crates/lore-tui/src/state/mod.rs:
- AppState struct: dashboard (DashboardState), issue_list (IssueListState), issue_detail (IssueDetailState), mr_list (MrListState), mr_detail (MrDetailState), search (SearchState), timeline (TimelineState), who (WhoState), sync (SyncState), command_palette (CommandPaletteState), global_scope (ScopeContext), load_state (ScreenLoadStateMap), error_toast (Option<String>), show_help (bool), terminal_size ((u16, u16))
- LoadState enum: Idle, LoadingInitial, Refreshing, Error(String)
- ScreenLoadStateMap: wraps HashMap<Screen, LoadState>, get()/set()/any_loading()
- AppState methods: set_loading(), set_error(), clear_error(), has_text_focus(), blur_text_focus(), delegate_text_event(), interpret_screen_key(), handle_screen_msg()
- ScreenIntent enum: None, Navigate(Screen), RequeryNeeded(Screen)
- handle_screen_msg() matches Msg variants and returns ScreenIntent (NEVER Cmd::task)

Create stub per-screen state files (just Default-derivable structs):
- state/dashboard.rs, issue_list.rs, issue_detail.rs, mr_list.rs, mr_detail.rs, search.rs, timeline.rs, who.rs, sync.rs, command_palette.rs

## Acceptance Criteria
- [ ] AppState derives Default and compiles with all screen state fields
- [ ] LoadState has Idle, LoadingInitial, Refreshing, Error variants
- [ ] ScreenLoadStateMap::get() returns Idle for untracked screens
- [ ] ScreenLoadStateMap::any_loading() returns true when any screen is loading
- [ ] has_text_focus() checks all filter/query focused flags
- [ ] blur_text_focus() resets all focus flags
- [ ] handle_screen_msg() returns ScreenIntent, never Cmd::task
- [ ] ScreenIntent::RequeryNeeded signals that LoreApp should dispatch supervised query

## Files
- CREATE: crates/lore-tui/src/state/mod.rs
- CREATE: crates/lore-tui/src/state/dashboard.rs (stub)
- CREATE: crates/lore-tui/src/state/issue_list.rs (stub)
- CREATE: crates/lore-tui/src/state/issue_detail.rs (stub)
- CREATE: crates/lore-tui/src/state/mr_list.rs (stub)
- CREATE: crates/lore-tui/src/state/mr_detail.rs (stub)
- CREATE: crates/lore-tui/src/state/search.rs (stub)
- CREATE: crates/lore-tui/src/state/timeline.rs (stub)
- CREATE: crates/lore-tui/src/state/who.rs (stub)
- CREATE: crates/lore-tui/src/state/sync.rs (stub)
- CREATE: crates/lore-tui/src/state/command_palette.rs (stub)

## TDD Anchor
RED: Write test_load_state_default_idle that creates ScreenLoadStateMap, asserts get(&Screen::Dashboard) returns Idle.
GREEN: Implement ScreenLoadStateMap with HashMap defaulting to Idle.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_load_state

## Edge Cases
- LoadState::set() removes Idle entries from the map to prevent unbounded growth
- Screen::IssueDetail(key) comparison for HashMap: requires Screen to impl Hash+Eq or use ScreenKind discriminant
- has_text_focus() must be kept in sync as new screens add text inputs


Write decay and scoring example-based tests (TDD)

## Background
All implementation beads (bd-1soz through bd-11mg) include their own inline TDD tests. This bead is the integration verification: run the full test suite and confirm everything works together with no regressions.

## Approach
Run cargo test and verify:
1. All NEW tests pass (31 tests across implementation beads)
2. All EXISTING tests pass unchanged (existing who tests, config tests, etc.)
3. No test interference (--test-threads=1 mode)
4. All tests in who.rs test module compile and run cleanly

Test count by bead:
- bd-1soz: 2 (test_half_life_decay_math, test_score_monotonicity_by_age)
- bd-2w1p: 3 (test_config_validation_rejects_zero_half_life, _absurd_half_life, _nan_multiplier)
- bd-18dn: 2 (test_path_normalization_handles_dot_and_double_slash, _preserves_prefix_semantics)
- bd-1hoq: 1 (test_expert_sql_returns_expected_signal_rows)
- bd-1h3f: 2 (test_old_path_probe_exact_and_prefix, test_suffix_probe_uses_old_path_sources)
- bd-13q8: 13 (decay integration + invariant tests)
- bd-11mg: 8 (CLI flag tests: explain_score, as_of, excluded_usernames, etc.)
Total: 2+3+2+1+2+13+8 = 31 new tests

This is NOT a code-writing bead — it is a verification checkpoint.

## Acceptance Criteria
- [ ] cargo test -p lore passes (all tests green)
- [ ] cargo test -p lore -- --test-threads=1 passes (no test interference)
- [ ] No existing test assertions were changed (only callsite signatures updated in bd-13q8 and ScoringConfig literals in bd-1b50)
- [ ] Total test count: existing + 31 new = all pass

## TDD Loop
N/A — this bead verifies, does not write code.
VERIFY: cargo test -p lore

## Files
None modified — read-only verification.


Design Actionable Insights panel (heuristic queries TBD)

## Background
The PRD specifies an Actionable Insights panel on the Dashboard that surfaces heuristic signals: stale P1 issues, blocked MRs awaiting review, velocity spikes/dips, and assignee workload imbalance. This requires heuristic query functions that do NOT currently exist in the lore codebase.

Since the TUI work is purely UI built on existing code, the Actionable Insights panel is deferred to a later phase when the heuristic queries are implemented. This bead tracks the design and eventual implementation.

## Approach
When ready to implement:
1. Define InsightKind enum: StaleHighPriority, BlockedMR, VelocityAnomaly, WorkloadImbalance
2. Define Insight struct: kind, severity (Info/Warning/Critical), title, description, entity_refs (Vec<EntityKey>)
3. Implement heuristic query functions in lore core (NOT in TUI crate)
4. Wire insights into DashboardState as Optional<Vec<Insight>>
5. Render as a scrollable panel with severity-colored icons

## Acceptance Criteria
- [ ] InsightKind and Insight types defined
- [ ] At least 2 heuristic queries implemented (stale P1, blocked MR)
- [ ] Dashboard renders insights panel when data available
- [ ] Insights panel is scrollable with j/k
- [ ] Enter on insight navigates to related entity
- [ ] Empty insights shows "No insights" or hides panel entirely

## Status
BLOCKED: Requires heuristic query functions that don't exist yet. This is NOT a TUI-only task — it requires backend query work first.

## Files
- CREATE: src/core/insights.rs (heuristic query functions — in main crate, not TUI)
- MODIFY: crates/lore-tui/src/state/dashboard.rs (add insights field)
- MODIFY: crates/lore-tui/src/view/dashboard.rs (add insights panel)

## Edge Cases
- Insights depend on data freshness: stale DB = stale insights. Show "last updated" timestamp.
- Heuristic thresholds should be configurable (e.g., "stale" = P1 untouched for 7 days)
- Large number of insights: cap at 20, show "N more..." link


Implement lore sync CLI command

## Background
The sync command is the unified orchestrator for the full pipeline: ingest -> generate-docs -> embed. It replaces the need to run three separate commands. It acquires a lock, runs each stage sequentially, and reports combined results. Individual stages can be skipped via flags (--no-embed, --no-docs). The command is designed for cron/scheduled execution. Individual commands (`lore generate-docs`, `lore embed`) still exist for manual recovery and debugging.

## Approach
Create `src/cli/commands/sync.rs` per PRD Section 6.4.

**IMPORTANT: run_sync is async** (embed_documents and search_hybrid are async).

**Key types (PRD-exact):**
```rust
#[derive(Debug, Serialize)]
pub struct SyncResult {
    pub issues_updated: usize,
    pub mrs_updated: usize,
    pub discussions_fetched: usize,
    pub documents_regenerated: usize,
    pub documents_embedded: usize,
}

#[derive(Debug, Default)]
pub struct SyncOptions {
    pub full: bool,       // Reset cursors, fetch everything
    pub force: bool,      // Override stale lock
    pub no_embed: bool,   // Skip embedding step
    pub no_docs: bool,    // Skip document regeneration
}
```

**Core function (async, PRD-exact):**
```rust
pub async fn run_sync(config: &Config, options: SyncOptions) -> Result<SyncResult>
```

**Pipeline (sequential steps per PRD):**
1. Acquire app lock with heartbeat (via existing `src/core/lock.rs`)
2. Ingest delta: fetch issues + MRs via cursor-based sync (calls existing ingestion orchestrator)
   - Each upserted entity marked dirty via `mark_dirty_tx(&tx)` inside ingestion transaction
3. Process `pending_discussion_fetches` queue (bounded)
   - Discussion sweep uses CTE to capture stale IDs, then cascading deletes
4. Regenerate documents from `dirty_sources` queue (unless --no-docs)
5. Embed documents with changed content_hash (unless --no-embed; skipped gracefully if Ollama unavailable)
6. Release lock, record sync_run

**NOTE (PRD):** Rolling backfill window removed — the existing cursor + watermark design handles old issues with resumed activity. GitLab updates `updated_at` when new comments are added, so the cursor naturally picks up old issues that receive new activity.

**CLI args (PRD-exact):**
```rust
#[derive(Args)]
pub struct SyncArgs {
    /// Reset cursors, fetch everything
    #[arg(long)]
    full: bool,
    /// Override stale lock
    #[arg(long)]
    force: bool,
    /// Skip embedding step
    #[arg(long)]
    no_embed: bool,
    /// Skip document regeneration
    #[arg(long)]
    no_docs: bool,
}
```

**Human output:**
```
Sync complete:
  Issues updated:             42
  MRs updated:                18
  Discussions fetched:        56
  Documents regenerated:      38
  Documents embedded:         38
  Elapsed: 2m 15s
```

**JSON output:**
```json
{"ok": true, "data": {...}, "meta": {"elapsed_ms": 135000}}
```

## Acceptance Criteria
- [ ] Function is `async fn run_sync`
- [ ] Takes `SyncOptions` struct (not separate params)
- [ ] Returns `SyncResult` with flat fields (not nested sub-structs)
- [ ] Full pipeline orchestrated: ingest -> discussion queue -> docs -> embed
- [ ] --full resets cursors (passes through to ingest)
- [ ] --force overrides stale sync lock
- [ ] --no-embed skips embedding stage (Ollama not needed)
- [ ] --no-docs skips document regeneration stage
- [ ] Discussion queue processing bounded per run
- [ ] Dirty sources marked inside ingestion transactions (via mark_dirty_tx)
- [ ] Progress reporting: stage names + elapsed time
- [ ] Lock acquired with heartbeat at start, released at end (even on error)
- [ ] Embedding skipped gracefully if Ollama unavailable (warning, not error)
- [ ] JSON summary in robot mode
- [ ] Human-readable summary with elapsed time
- [ ] `cargo build` succeeds

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

## TDD Loop
RED: Integration test requiring full pipeline
GREEN: Implement run_sync orchestration (async)
VERIFY: `cargo build && cargo test sync`

## Edge Cases
- Ollama unavailable + --no-embed not set: sync should NOT fail — embed stage logs warning, returns 0 embedded
- Lock already held: error unless --force (and lock is stale)
- No dirty sources after ingest: regeneration stage returns 0 (not error)
- --full with large dataset: keyset pagination prevents OFFSET degradation

Sync CLI: async run_sync orchestrator with 4-stage pipeline (ingest issues, ingest MRs, generate-docs, embed), SyncOptions/SyncResult, --full/--force/--no-embed/--no-docs flags, graceful Ollama degradation, human+JSON output, clean build, all tests pass


Write invariant tests for ranking system

## Background
Invariant tests catch subtle ranking regressions that example-based tests miss. These test properties that must hold for ANY input, not specific values.

## Approach

### test_score_monotonicity_by_age:
Generate 50 random (age_ms, half_life_days) pairs using a simple LCG PRNG (deterministic seed for reproducibility). Assert decay(older) <= decay(newer) for all pairs where older > newer. Tests the pure half_life_decay() function only.

### test_row_order_independence:
Insert the same 5 signals in two orderings (forward and reverse). Run query_expert on both -> assert identical username ordering and identical scores (f64 bit-equal). Use a deterministic dataset with varied timestamps.

### test_reviewer_split_is_exhaustive:
Set up 3 reviewers on the same MR:
1. Reviewer with substantive DiffNotes (>= 20 chars) -> must appear in participated ONLY
2. Reviewer with no DiffNotes -> must appear in assigned-only ONLY
3. Reviewer with trivial note (< 20 chars) -> must appear in assigned-only ONLY
Use --explain-score to verify each reviewer's components: participated reviewer has reviewer_participated > 0 and reviewer_assigned == 0; others have reviewer_assigned > 0 and reviewer_participated == 0.

### test_deterministic_accumulation_order:
Insert signals for one user with 15 MRs at varied timestamps. Run query_expert 100 times in a loop. Assert all 100 runs produce the exact same f64 score (use == not approx, to verify bit-identical results from sorted accumulation).

## Acceptance Criteria
- [ ] All 4 tests pass
- [ ] No flakiness across 10 consecutive cargo test runs
- [ ] test_score_monotonicity covers at least 50 random pairs
- [ ] test_deterministic_accumulation runs at least 100 iterations

## Files
- src/cli/commands/who.rs (test module)

## Edge Cases
- LCG PRNG for monotonicity test: use fixed seed, not rand crate (avoid dependency)
- Bit-identical f64: use assert_eq!(a, b) not approx — the deterministic ordering guarantees this
- Row order test: must insert in genuinely different orders, not just shuffled within same transaction

Tests distributed to implementation beads: monotonicity->bd-1soz, row_order+split+deterministic->bd-13q8


Implement chunk ID encoding module

## Background
sqlite-vec uses a single integer rowid for embeddings. To store multiple chunks per document, we encode (document_id, chunk_index) into a single rowid using a multiplier. This module is shared between the embedding pipeline (encode on write) and vector search (decode on read). The encoding scheme supports up to 1000 chunks per document.

## Approach
Create `src/embedding/chunk_ids.rs`:

```rust
/// Multiplier for encoding (document_id, chunk_index) into a single rowid.
/// Supports up to 1000 chunks per document (32M chars at 32k/chunk).
pub const CHUNK_ROWID_MULTIPLIER: i64 = 1000;

/// Encode (document_id, chunk_index) into a sqlite-vec rowid.
///
/// rowid = document_id * CHUNK_ROWID_MULTIPLIER + chunk_index
pub fn encode_rowid(document_id: i64, chunk_index: i64) -> i64 {
    document_id * CHUNK_ROWID_MULTIPLIER + chunk_index
}

/// Decode a sqlite-vec rowid back into (document_id, chunk_index).
pub fn decode_rowid(rowid: i64) -> (i64, i64) {
    let document_id = rowid / CHUNK_ROWID_MULTIPLIER;
    let chunk_index = rowid % CHUNK_ROWID_MULTIPLIER;
    (document_id, chunk_index)
}
```

Also create the parent module `src/embedding/mod.rs`:
```rust
pub mod chunk_ids;
// Later beads add: pub mod ollama; pub mod pipeline;
```

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

## Acceptance Criteria
- [ ] `encode_rowid(42, 0)` == 42000
- [ ] `encode_rowid(42, 5)` == 42005
- [ ] `decode_rowid(42005)` == (42, 5)
- [ ] Roundtrip: decode(encode(doc_id, chunk_idx)) == (doc_id, chunk_idx) for all valid inputs
- [ ] CHUNK_ROWID_MULTIPLIER is 1000
- [ ] `cargo test chunk_ids` passes

## Files
- `src/embedding/chunk_ids.rs` — new file
- `src/embedding/mod.rs` — new file (module root)
- `src/lib.rs` — add `pub mod embedding;`

## TDD Loop
RED: Tests in `#[cfg(test)] mod tests`:
- `test_encode_single_chunk` — encode(1, 0) == 1000
- `test_encode_multi_chunk` — encode(1, 5) == 1005
- `test_decode_roundtrip` — property test over range of doc_ids and chunk_indices
- `test_decode_zero_chunk` — decode(42000) == (42, 0)
- `test_multiplier_value` — assert CHUNK_ROWID_MULTIPLIER == 1000
GREEN: Implement encode_rowid, decode_rowid
VERIFY: `cargo test chunk_ids`

## Edge Cases
- chunk_index >= 1000: not expected (documents that large would be pathological), but no runtime panic — just incorrect decode. The embedding pipeline caps chunks well below this.
- document_id = 0: valid (encode returns chunk_index directly)

Completed: chunk_ids module with encode_rowid/decode_rowid, CHUNK_ROWID_MULTIPLIER=1000, 6 tests pass


[CP1] GitLab types for issues, discussions, notes

Add TypeScript interfaces for GitLab API responses.

Types to add to src/gitlab/types.ts:
- GitLabIssue: id, iid, project_id, title, description, state, timestamps, author, labels[], labels_details?, web_url
- GitLabDiscussion: id (string), individual_note, notes[]
- GitLabNote: id, type, body, author, timestamps, system, resolvable, resolved, resolved_by, resolved_at, position?

Files: src/gitlab/types.ts
Done when: Types compile and match GitLab API documentation


Implement rename chain resolution for file-history

## Background

Rename chain resolution is the core algorithm for Gate 4. When querying history of src/auth.rs, it finds MRs that touched the file when it was previously named src/authentication.rs. This is reused by Gate 5 (trace) as well.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 4.6 (Rename Handling).

## Codebase Context

- mr_file_changes table (migration 016, bd-1oo): merge_request_id, project_id, old_path, new_path, change_type
- change_type='renamed' rows have both old_path and new_path populated
- Partial index `idx_mfc_renamed` on (project_id, change_type) WHERE change_type='renamed' optimizes BFS queries
- Also `idx_mfc_project_path` on (project_id, new_path) and `idx_mfc_project_old_path` partial index
- No timeline/trace/file_history modules exist yet in src/core/

## Approach

Create `src/core/file_history.rs`:

```rust
use std::collections::HashSet;
use std::collections::VecDeque;
use rusqlite::Connection;
use crate::core::error::Result;

/// Resolves a file path through its rename history.
/// Returns all equivalent paths (original + renames) for use in queries.
/// BFS in both directions: forward (old_path -> new_path) and backward (new_path -> old_path).
pub fn resolve_rename_chain(
    conn: &Connection,
    project_id: i64,
    path: &str,
    max_hops: usize,       // default 10 from CLI
) -> Result<Vec<String>> {
    let mut visited: HashSet<String> = HashSet::new();
    let mut queue: VecDeque<String> = VecDeque::new();

    visited.insert(path.to_string());
    queue.push_back(path.to_string());

    let forward_sql = "SELECT mfc.new_path FROM mr_file_changes mfc \
        WHERE mfc.project_id = ?1 AND mfc.old_path = ?2 AND mfc.change_type = 'renamed'";
    let backward_sql = "SELECT mfc.old_path FROM mr_file_changes mfc \
        WHERE mfc.project_id = ?1 AND mfc.new_path = ?2 AND mfc.change_type = 'renamed'";

    while let Some(current) = queue.pop_front() {
        if visited.len() > max_hops + 1 { break; }

        // Forward: current was the old name -> discover new names
        let mut stmt = conn.prepare(forward_sql)?;
        let forward: Vec<String> = stmt.query_map(
            rusqlite::params\![project_id, current],
            |row| row.get(0),
        )?.filter_map(|r| r.ok()).collect();

        // Backward: current was the new name -> discover old names
        let mut stmt = conn.prepare(backward_sql)?;
        let backward: Vec<String> = stmt.query_map(
            rusqlite::params\![project_id, current],
            |row| row.get(0),
        )?.filter_map(|r| r.ok()).collect();

        for discovered in forward.into_iter().chain(backward) {
            if visited.insert(discovered.clone()) {
                queue.push_back(discovered);
            }
        }
    }

    Ok(visited.into_iter().collect())
}
```

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

## Acceptance Criteria

- [ ] `resolve_rename_chain()` follows renames in both directions (forward + backward)
- [ ] Cycles detected via HashSet (same path never visited twice)
- [ ] Bounded at max_hops (default 10)
- [ ] No renames found: returns vec with just the original path
- [ ] max_hops=0: returns just original path without querying DB
- [ ] Module registered in src/core/mod.rs as `pub mod file_history;`
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

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

## TDD Loop

RED:
- `test_rename_chain_no_renames` — returns just original path
- `test_rename_chain_forward` — a.rs -> b.rs -> c.rs: starting from a.rs finds all three
- `test_rename_chain_backward` — starting from c.rs finds a.rs and b.rs
- `test_rename_chain_cycle_detection` — a->b->a terminates without infinite loop
- `test_rename_chain_max_hops_zero` — returns just original path
- `test_rename_chain_max_hops_bounded` — chain longer than max is truncated

Tests need in-memory DB with migrations applied through 016 + mr_file_changes test data with change_type='renamed'.

GREEN: Implement BFS with visited set.

VERIFY: `cargo test --lib -- file_history`

## Edge Cases

- File never renamed: single-element vec
- Circular rename (a->b->a): visited set prevents infinite loop
- max_hops=0: return just original path, no queries executed
- Case sensitivity: paths are case-sensitive (Linux default, matches GitLab behavior)
- Multiple renames from same old_path: BFS discovers all branches

Implemented resolve_rename_chain() BFS in src/core/file_history.rs with 8 tests covering: no renames, forward chain, backward chain, cycle detection, max_hops=0, max_hops bounded, branching renames, project isolation. All 765 tests pass, clippy+fmt clean.


Implement MR document extraction

## Background
MR documents are similar to issue documents but include source/target branch information in the header. The extractor queries merge_requests and mr_labels tables. Like issue extraction, it produces a DocumentData struct for the regeneration pipeline.

## Approach
Implement `extract_mr_document()` in `src/documents/extractor.rs`:

```rust
/// Extract a searchable document from a merge request.
/// Returns None if the MR has been deleted from the DB.
pub fn extract_mr_document(conn: &Connection, mr_id: i64) -> Result<Option<DocumentData>>
```

**SQL queries (from PRD Section 2.2):**
```sql
-- Main entity
SELECT m.id, m.iid, m.title, m.description, m.state, m.author_username,
       m.source_branch, m.target_branch,
       m.created_at, m.updated_at, m.web_url,
       p.path_with_namespace, p.id AS project_id
FROM merge_requests m
JOIN projects p ON p.id = m.project_id
WHERE m.id = ?

-- Labels
SELECT l.name FROM mr_labels ml
JOIN labels l ON l.id = ml.label_id
WHERE ml.merge_request_id = ?
ORDER BY l.name
```

**Document format:**
```
[[MergeRequest]] !456: Implement JWT authentication
Project: group/project-one
URL: https://gitlab.example.com/group/project-one/-/merge_requests/456
Labels: ["feature", "auth"]
State: opened
Author: @johndoe
Source: feature/jwt-auth -> main

--- Description ---

This MR implements JWT-based authentication...
```

**Key difference from issues:** The `Source:` line with `source_branch -> target_branch`.

## Acceptance Criteria
- [ ] Deleted MR returns Ok(None)
- [ ] MR document has `[[MergeRequest]]` prefix with `!` before iid (not `#`)
- [ ] Source line shows `source_branch -> target_branch`
- [ ] Labels sorted alphabetically in JSON array
- [ ] content_hash computed from full content_text
- [ ] labels_hash computed from sorted labels
- [ ] paths is empty (MR-level docs don't have DiffNote paths; those are on discussion docs)
- [ ] `cargo test extract_mr` passes

## Files
- `src/documents/extractor.rs` — implement `extract_mr_document()`

## TDD Loop
RED: Tests in `#[cfg(test)] mod tests`:
- `test_mr_document_format` — verify header matches PRD template with Source line
- `test_mr_not_found` — returns Ok(None)
- `test_mr_no_description` — header only
- `test_mr_branch_info` — Source line correct
GREEN: Implement extract_mr_document with SQL queries
VERIFY: `cargo test extract_mr`

## Edge Cases
- MR with NULL description: skip "--- Description ---" section
- MR with NULL source_branch or target_branch: omit Source line (shouldn't happen in practice)
- Draft MRs: state field captures this, no special handling needed

Implemented extract_mr_document() with Source line, PRD format, and 5 tests


OBSERV: Enrich robot JSON meta with run_id and stages

## Background
Robot JSON currently has a flat meta.elapsed_ms. This enriches it with run_id and a stages array, making every lore --robot sync output a complete performance profile.

## Approach
The robot JSON output is built in src/cli/commands/sync.rs. The current SyncResult (line 15-25) is serialized into the data field. The meta field is built alongside it.

1. Find or create the SyncMeta struct (likely near SyncResult). Add fields:
```rust
#[derive(Debug, Serialize)]
struct SyncMeta {
    run_id: String,
    elapsed_ms: u64,
    stages: Vec<StageTiming>,
}
```

2. After run_sync() completes, extract timings from MetricsLayer:
```rust
let stages = metrics_handle.extract_timings();
let meta = SyncMeta {
    run_id: run_id.to_string(),
    elapsed_ms: start.elapsed().as_millis() as u64,
    stages,
};
```

3. Build the JSON envelope:
```rust
let output = serde_json::json!({
    "ok": true,
    "data": result,
    "meta": meta,
});
```

The metrics_handle (Arc<MetricsLayer>) must be passed from main.rs to the command handler. This requires adding a parameter to handle_sync_cmd() and run_sync(), or using a global. Prefer parameter passing.

Same pattern for standalone ingest: add stages to IngestMeta.

## Acceptance Criteria
- [ ] lore --robot sync output includes meta.run_id (string, 8 hex chars)
- [ ] lore --robot sync output includes meta.stages (array of StageTiming)
- [ ] meta.elapsed_ms still present (total wall clock time)
- [ ] Each stage has name, elapsed_ms, items_processed at minimum
- [ ] Top-level stages have sub_stages when applicable
- [ ] lore --robot ingest also includes run_id and stages
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/cli/commands/sync.rs (add SyncMeta struct, wire extract_timings)
- src/cli/commands/ingest.rs (same for standalone ingest)
- src/main.rs (pass metrics_handle to command handlers)

## TDD Loop
RED: test_sync_meta_includes_stages (run robot-mode sync, parse JSON, assert meta.stages is array)
GREEN: Add SyncMeta, extract timings, include in JSON output
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- Empty stages: if sync runs with --no-docs --no-embed, some stages won't exist. stages array is shorter, not padded.
- extract_timings() called before root span closes: returns incomplete tree. Must call AFTER run_sync returns (span is dropped on function exit).
- metrics_handle clone: MetricsLayer uses Arc internally, clone is cheap (reference count increment).

Wired MetricsLayer into subscriber stack (all 4 branches), added run_id to SyncResult, enriched SyncMeta with run_id + stages Vec<StageTiming>, updated print_sync_json to accept MetricsLayer and extract timings


Implement Search screen (state + action + view)

## Background
The Search screen provides full-text and semantic search across all indexed documents. It supports 3 modes (lexical FTS5, hybrid FTS+vector, semantic vector-only), a split-pane layout with results on the left and preview on the right, and capability-aware mode selection based on available indexes.

## Approach
State (state/search.rs):
- SearchState: query (String), query_input (TextInput), query_focused (bool), mode (SearchMode), results (Vec<SearchResult>), selected_index (usize), preview (Option<SearchPreview>), capabilities (SearchCapabilities), generation (u64)
- SearchMode: Lexical, Hybrid, Semantic
- SearchCapabilities: has_fts (bool), has_embeddings (bool), embedding_coverage_pct (f32)
- SearchResult: doc_id, entity_type, entity_iid, project_path, title, snippet, score, mode_used
- SearchPreview: full document text or entity detail

**Capability detection** (on screen entry):
- Probe documents_fts table: SELECT COUNT(*) FROM documents_fts_docsize (uses fast B-tree count, not FTS5 virtual table scan — see MEMORY.md perf audit)
- Probe embeddings: SELECT COUNT(*) FROM embeddings / SELECT COUNT(*) FROM documents to compute coverage pct
- If has_fts=false: disable Lexical and Hybrid modes, only Semantic available
- If has_embeddings=false: disable Semantic and Hybrid modes, only Lexical available
- If both false: show "No search indexes found. Run lore generate-docs and lore embed first."

**Score explanation (e key):**
- Press e on a selected result to toggle a score breakdown panel
- For Lexical: show FTS5 bm25 raw score
- For Hybrid: show FTS score, vector score, and RRF combined score with weights
- For Semantic: show cosine similarity score
- Panel appears below the selected result row, Esc or e dismisses

**Debounced input (200ms):**
- Uses Msg::SearchDebounceArmed and Msg::SearchDebounced timer pattern
- On keystroke in query input: arm debounce timer via Cmd::timer(200ms, Msg::SearchDebounced)
- On SearchDebounced: execute search with current query text
- This prevents flooding the search backend on rapid typing

Action (action.rs):
- fetch_search_capabilities(conn) -> SearchCapabilities: probe FTS and embedding tables
- execute_search(conn, query, mode, limit) -> Vec<SearchResult>: dispatches to correct search backend. Uses existing crate::search module functions.
- fetch_search_preview(conn, result) -> SearchPreview: loads full entity detail for selected result

View (view/search.rs):
- Split pane: results list (60%) | preview (40%)
- Query bar at top with mode indicator (L/H/S)
- Mode switching: Tab cycles modes (only available modes based on capabilities)
- Score column shows numeric score; e key expands explanation
- Empty query shows recent entities instead of empty state
- Narrow terminal (<100 cols): hide preview pane

## Acceptance Criteria
- [ ] 3 search modes: Lexical, Hybrid, Semantic
- [ ] Mode switching via Tab, only available modes selectable based on capability detection
- [ ] Capability detection probes FTS and embedding tables on screen entry
- [ ] Graceful degradation: unavailable modes shown as greyed out with reason
- [ ] "No search indexes" message when both FTS and embeddings are empty
- [ ] 200ms debounce on search input (timer-driven via Msg::SearchDebounceArmed/Fired)
- [ ] Split pane: results | preview
- [ ] Enter on result navigates to entity detail
- [ ] Score shown next to each result
- [ ] e key toggles score explanation panel for selected result
- [ ] Empty query shows recent entities instead of empty state
- [ ] Narrow terminal (<100 cols): hide preview pane

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

## TDD Anchor
RED: Write test_search_capability_detection that creates DB with FTS but no embeddings, asserts has_fts=true, has_embeddings=false, Semantic mode disabled.
GREEN: Implement fetch_search_capabilities.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_search_capability

Additional tests:
- test_debounce_prevents_rapid_search: simulate 5 keystrokes in 100ms, assert only 1 search executed
- test_score_explanation_lexical: verify bm25 score shown for Lexical mode result
- test_empty_query_shows_recent: assert recent entities returned when query is empty

## Edge Cases
- Search query < 2 chars: don't execute search (debounce filter)
- FTS5 special characters (*, ", -): escape or pass through based on mode
- Hybrid mode: uses existing RRF implementation from crate::search module
- Very large result sets: limit to 100 results, show "more results available" hint
- Preview pane on narrow terminal (<100 cols): hide preview, full-width results only
- FTS count performance: use documents_fts_docsize shadow table for COUNT (19x faster)

## Dependency Context
Uses existing search infrastructure from lore core (crate::search::{FtsQueryMode, to_fts_query} — note private submodules, import via crate::search).
Uses SearchDebounceArmed/SearchDebounced Msg variants from "Implement core types" (bd-c9gk).
Uses TaskSupervisor debounce management from "Implement TaskSupervisor" (bd-3le2).
Uses AppState composition from "Implement AppState composition" (bd-1v9m).