Implement theme configuration (ftui ThemeBuilder)

## Background
The TUI uses FrankenTUI's Theme struct with 19 semantic AdaptiveColor slots for consistent styling. Each slot takes an AdaptiveColor::adaptive(light, dark) pair for automatic light/dark mode switching via terminal background detection. The palette is Flexoki by Steph Ango — an ink-inspired color scheme designed in Oklab perceptual color space for balanced contrast in both modes.

## Approach
Create `crates/lore-tui/src/theme.rs` with:

### build_theme() -> Theme
Use `Theme::builder()` from `ftui_style::theme::{Theme, ThemeBuilder, AdaptiveColor}`. Each slot gets `AdaptiveColor::adaptive(light, dark)` with Flexoki hex values:

**Flexoki Base Tones:**
- Paper #FFFCF0, Base-50 #F2F0E5, Base-100 #E6E4D9, Base-150 #DAD8CE
- Base-200 #CECDC3, Base-300 #B7B5AC, Base-400 #9F9D96, Base-500 #878580
- Base-600 #6F6E69, Base-700 #575653, Base-800 #403E3C, Base-850 #343331
- Base-900 #282726, Base-950 #1C1B1A, Black #100F0F

**Flexoki Accent Colors (light-600 / dark-400):**
- Red: #AF3029 / #D14D41, Orange: #BC5215 / #DA702C
- Yellow: #AD8301 / #D0A215, Green: #66800B / #879A39
- Cyan: #24837B / #3AA99F, Blue: #205EA6 / #4385BE
- Purple: #5E409D / #8B7EC8, Magenta: #A02F6F / #CE5D97

**19-Slot Mapping** (light / dark):

| Slot | Light | Dark |
|------|-------|------|
| primary | Blue-600 #205EA6 | Blue-400 #4385BE |
| secondary | Cyan-600 #24837B | Cyan-400 #3AA99F |
| accent | Purple-600 #5E409D | Purple-400 #8B7EC8 |
| background | Paper #FFFCF0 | Black #100F0F |
| surface | Base-50 #F2F0E5 | Base-900 #282726 |
| overlay | Base-100 #E6E4D9 | Base-850 #343331 |
| text | Base-700 #575653 | Base-200 #CECDC3 |
| text_muted | Base-500 #878580 | Base-500 #878580 |
| text_subtle | Base-400 #9F9D96 | Base-600 #6F6E69 |
| success | Green-600 #66800B | Green-400 #879A39 |
| warning | Yellow-600 #AD8301 | Yellow-400 #D0A215 |
| error | Red-600 #AF3029 | Red-400 #D14D41 |
| info | Blue-600 #205EA6 | Blue-400 #4385BE |
| border | Base-300 #B7B5AC | Base-700 #575653 |
| border_focused | Blue-600 #205EA6 | Blue-400 #4385BE |
| selection_bg | Base-100 #E6E4D9 | Base-800 #403E3C |
| selection_fg | Base-700 #575653 | Base-100 #E6E4D9 |
| scrollbar_track | Base-50 #F2F0E5 | Base-900 #282726 |
| scrollbar_thumb | Base-300 #B7B5AC | Base-700 #575653 |

Code pattern:
```rust
use ftui_style::theme::{Theme, AdaptiveColor};
use ftui_style::Color;

pub fn build_theme() -> Theme {
    Theme::builder()
        .primary(AdaptiveColor::adaptive(
            Color::rgb(0x20, 0x5E, 0xA6),  // Blue-600
            Color::rgb(0x43, 0x85, 0xBE),  // Blue-400
        ))
        // ... all 19 slots ...
        .build()
}
```

### State Colors
Helper functions for GitLab entity states:
- `state_color(state: &str) -> Color` — opened: Green-400 #879A39, closed: Red-400 #D14D41, merged: Purple-400 #8B7EC8, locked: Yellow-400 #D0A215
- These return fixed Color (not AdaptiveColor) since state colors should be consistent

### Event Type Colors
For timeline rendering:
- created: Green, updated: Blue, closed: Red, merged: Purple, commented: Cyan, labeled: Orange, milestoned: Yellow

### label_style(hex_color: &str) -> Style
Converts GitLab label hex strings (e.g., "#FF0000") to ftui Style:
- Parse hex to RGB using `u8::from_str_radix`
- Return `Style::default().fg(Color::rgb(r, g, b))`
- Invalid hex: return `Style::default().fg(text_muted_color)` as fallback

## Acceptance Criteria
- [ ] build_theme() returns Theme with all 19 slots using Flexoki AdaptiveColor::adaptive values
- [ ] Theme::builder() chain compiles and build() returns Theme
- [ ] state_color("opened") returns Green-400, state_color("closed") returns Red-400, state_color("merged") returns Purple-400, state_color("locked") returns Yellow-400
- [ ] label_style("#FF0000") returns Style with fg Color::rgb(255, 0, 0)
- [ ] label_style("invalid") returns Style with muted fallback color (no panic)
- [ ] label_style("#ff0000") handles lowercase hex

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

## TDD Anchor
RED: Write `test_build_theme_all_slots_set` that calls build_theme(), resolves for dark mode, and asserts primary == Color::rgb(0x43, 0x85, 0xBE).
GREEN: Implement build_theme() with full Flexoki mapping.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml theme

Additional tests:
- test_theme_light_mode_primary (resolves to #205EA6)
- test_state_color_opened_is_green
- test_state_color_unknown_returns_muted
- test_label_style_valid_hex
- test_label_style_invalid_hex_fallback
- test_label_style_lowercase_hex

## Edge Cases
- Terminal may not support true color (RGB) — AdaptiveColor handles fallback via ftui's backend detection
- Label colors from GitLab may include or omit the # prefix — handle both
- Empty string label color — return fallback
- State color for unknown states (e.g., "all") — return text_muted as default

## Dependency Context
Depends on bd-3ddw (scaffold) for the crate structure to exist and ftui-style dependency in Cargo.toml.
Dependents: bd-26f2 (common widgets) consumes build_theme() to style status bar, breadcrumb, etc.

Implemented Flexoki theme: build_theme() with 19 AdaptiveColor slots, state_color(), event_color(), label_style(). 16 tests passing, clippy clean.


Add GitLab MR types to types.rs

## Background
GitLab API types for merge requests. These structs define how we deserialize GitLab API responses. Must handle deprecated field aliases for backward compatibility with older GitLab instances.

## Approach
Add new structs to `src/gitlab/types.rs`:
- `GitLabMergeRequest` - Main MR struct with all fields
- `GitLabReviewer` - Reviewer with optional approval state
- `GitLabReferences` - Short and full reference strings

Use serde `#[serde(alias = "...")]` for deprecated field fallbacks.

## Files
- `src/gitlab/types.rs` - Add new structs after existing GitLabIssue
- `tests/fixtures/gitlab_merge_request.json` - Test fixture

## Acceptance Criteria
- [ ] `GitLabMergeRequest` struct exists with all fields from PRD
- [ ] `detailed_merge_status` field exists (non-deprecated)
- [ ] `#[serde(alias = "merge_status")]` on `merge_status_legacy` for fallback
- [ ] `merge_user` field exists (non-deprecated)
- [ ] `merged_by` field exists for fallback
- [ ] `draft` and `work_in_progress` both exist (draft preferred, WIP fallback)
- [ ] `sha` field maps to `head_sha` in transformer
- [ ] `references: Option<GitLabReferences>` for short/full refs
- [ ] `state: String` supports "opened", "merged", "closed", "locked"
- [ ] Fixture deserializes without error
- [ ] `cargo test` passes

## TDD Loop
RED: Add test that deserializes fixture -> struct not found
GREEN: Add GitLabMergeRequest, GitLabReviewer, GitLabReferences structs
VERIFY: `cargo test gitlab_types`

## Struct Definitions (from PRD)
```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabMergeRequest {
    pub id: i64,
    pub iid: i64,
    pub project_id: i64,
    pub title: String,
    pub description: Option<String>,
    pub state: String,                // "opened" | "merged" | "closed" | "locked"
    #[serde(default)]
    pub draft: bool,
    #[serde(default)]
    pub work_in_progress: bool,       // Deprecated fallback
    pub source_branch: String,
    pub target_branch: String,
    pub sha: Option<String>,          // head_sha
    pub references: Option<GitLabReferences>,
    pub detailed_merge_status: Option<String>,
    #[serde(alias = "merge_status")]
    pub merge_status_legacy: Option<String>,
    pub created_at: String,
    pub updated_at: String,
    pub merged_at: Option<String>,
    pub closed_at: Option<String>,
    pub author: GitLabAuthor,
    pub merge_user: Option<GitLabAuthor>,
    pub merged_by: Option<GitLabAuthor>,
    #[serde(default)]
    pub labels: Vec<String>,
    #[serde(default)]
    pub assignees: Vec<GitLabAuthor>,
    #[serde(default)]
    pub reviewers: Vec<GitLabReviewer>,
    pub web_url: String,
}

#[derive(Debug, Clone, Deserialize)]
pub struct GitLabReferences {
    pub short: String,   // e.g. "\!123"
    pub full: String,    // e.g. "group/project\!123"
}

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

## Test Fixture (create tests/fixtures/gitlab_merge_request.json)
```json
{
  "id": 12345,
  "iid": 42,
  "project_id": 100,
  "title": "Add user authentication",
  "description": "Implements JWT auth flow",
  "state": "merged",
  "draft": false,
  "work_in_progress": false,
  "source_branch": "feature/auth",
  "target_branch": "main",
  "sha": "abc123def456",
  "references": { "short": "\!42", "full": "group/project\!42" },
  "detailed_merge_status": "mergeable",
  "merge_status": "can_be_merged",
  "created_at": "2024-01-15T10:00:00Z",
  "updated_at": "2024-01-20T14:30:00Z",
  "merged_at": "2024-01-20T14:30:00Z",
  "closed_at": null,
  "author": { "id": 1, "username": "johndoe", "name": "John Doe" },
  "merge_user": { "id": 2, "username": "janedoe", "name": "Jane Doe" },
  "merged_by": { "id": 2, "username": "janedoe", "name": "Jane Doe" },
  "labels": ["enhancement", "auth"],
  "assignees": [{ "id": 3, "username": "bob", "name": "Bob Smith" }],
  "reviewers": [{ "id": 4, "username": "alice", "name": "Alice Wong" }],
  "web_url": "https://gitlab.example.com/group/project/-/merge_requests/42"
}
```

## Edge Cases
- `locked` state is transitional (merge in progress) - rare but valid
- Some older instances may not return `detailed_merge_status`
- Some older instances may not return `merge_user` (use `merged_by` fallback)
- `work_in_progress` is deprecated but still returned by some instances

Added GitLabMergeRequest, GitLabReviewer, GitLabReferences structs. Updated GitLabNotePosition with position_type, line_range, and SHA triplet fields. All 23 type tests passing.


Implement LoreApp Model trait (full update/view skeleton)

## Background
LoreApp is the central Model implementation for FrankenTUI's Elm Architecture. It owns all state (AppState), the navigation stack, task supervisor, db manager, clock, config, and crash context. The update() method is the single entry point for all state transitions, implementing a 5-stage key dispatch pipeline. The view() method routes to per-screen render functions.

## Approach
Expand crates/lore-tui/src/app.rs:
- LoreApp struct fields: config (Config), db (DbManager), state (AppState), navigation (NavigationStack), supervisor (TaskSupervisor), clock (Box<dyn Clock>), input_mode (InputMode), command_registry (CommandRegistry), crash_context (CrashContext)
- init() -> Cmd<Msg>: install crash_context panic hook, return Cmd::task that loads dashboard data
- update(msg: Msg) -> Option<Cmd<Msg>>: push CrashEvent to crash_context FIRST, then full dispatch with 5-stage interpret_key pipeline:
  1. Quit check (q in Normal mode, Ctrl+C always)
  2. InputMode routing (Text->delegate to text widget, Palette->delegate to palette, GoPrefix->check timeout+destination)
  3. Global shortcuts (H=Home, Esc=back, Ctrl+P=palette, g=prefix, Ctrl+O/I=jump)
  4. Screen-local keys (delegate to AppState::interpret_screen_key)
  5. Fallback (unhandled key, no-op)

**Key normalization pass in interpret_key():**
Before the 5-stage pipeline, normalize terminal key variants:
- Backspace variants: map Delete/Backspace to canonical Backspace
- Alt key variants: map Meta+key to Alt+key
- Shift+Tab: map BackTab to Shift+Tab
- This ensures consistent behavior across terminals (iTerm2, Alacritty, Terminal.app, tmux)

- For non-key messages: match on Msg variants, update state, optionally return Cmd::task for async work
- Stale result guard: check supervisor.is_current() before applying *Loaded results
- view(frame): match navigation.current() to dispatch to per-screen view functions (stub initially)
- subscriptions(): tick timer (250ms for spinner animation), debounce timers

## Acceptance Criteria
- [ ] LoreApp struct compiles with all required fields including crash_context
- [ ] init() installs panic hook and returns a Cmd that triggers dashboard load
- [ ] update() pushes CrashEvent to crash_context before dispatching
- [ ] update() handles Msg::Quit by returning None
- [ ] update() handles NavigateTo by pushing nav stack and spawning load_screen
- [ ] update() handles GoBack by popping nav stack
- [ ] interpret_key normalizes Backspace/Alt/Shift+Tab variants before dispatch
- [ ] interpret_key 5-stage pipeline dispatches correctly per InputMode
- [ ] GoPrefix times out after 500ms (checked via clock.now())
- [ ] Stale results dropped: IssueListLoaded with old generation ignored
- [ ] view() routes to correct screen render function based on navigation.current()
- [ ] subscriptions() returns tick timer

## Files
- MODIFY: crates/lore-tui/src/app.rs (expand from minimal to full implementation)

## TDD Anchor
RED: Write test_quit_returns_none that creates LoreApp (with FakeClock, in-memory DB), calls update(Msg::Quit), asserts it returns None.
GREEN: Implement update() with Quit match arm.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_quit

Additional tests:
- test_navigate_to_pushes_stack: update(NavigateTo(IssueList)) changes navigation.current()
- test_go_back_pops_stack: after push, GoBack returns to previous screen
- test_stale_result_dropped: IssueListLoaded with old generation doesn't update state
- test_go_prefix_timeout: GoPrefix cancels after 500ms (using FakeClock)
- test_key_normalization_backspace: both Delete and Backspace map to canonical Backspace
- test_crash_context_records_events: after update(), crash_context.events.len() increases

## Edge Cases
- update() must handle rapid-fire messages without blocking (no long computations in update)
- Ctrl+C must always quit regardless of InputMode (safety escape)
- GoPrefix must cancel on any non-destination key, not just on timeout
- Text mode must pass Esc through to blur text input first, then Normal mode handles Esc for navigation
- Key normalization must handle unknown/exotic key codes gracefully (pass through unchanged)

## Dependency Context
Uses DbManager from "Implement DbManager" (bd-2kop).
Uses Clock/FakeClock from "Implement Clock trait" (bd-2lg6).
Uses Msg, Screen, InputMode from "Implement core types" (bd-c9gk).
Uses NavigationStack from "Implement NavigationStack" (bd-1qpp).
Uses TaskSupervisor from "Implement TaskSupervisor" (bd-3le2).
Uses CrashContext from "Implement crash_context ring buffer" (bd-2fr7).
Uses CommandRegistry from "Implement CommandRegistry" (bd-38lb).
Uses AppState from "Implement AppState composition" (bd-1v9m).

LoreApp full Model impl: struct with all fields, 5-stage key dispatch, navigate_to, handle_msg with stale guard, 22 app tests. Fixed crossterm→ftui type migration.


[CP1] Issue ingestion module

Fetch and store issues with cursor-based incremental sync.

## Module
src/ingestion/issues.rs

## Key Structs

### IngestIssuesResult
- fetched: usize
- upserted: usize
- labels_created: usize
- issues_needing_discussion_sync: Vec<IssueForDiscussionSync>

### IssueForDiscussionSync
- local_issue_id: i64
- iid: i64
- updated_at: i64

## Main Function
pub async fn ingest_issues(conn, client, config, project_id, gitlab_project_id) -> Result<IngestIssuesResult>

## Logic
1. Get current cursor from sync_cursors (updated_at_cursor, tie_breaker_id)
2. Paginate through issues updated after cursor with cursor_rewind_seconds
3. Apply local filtering for tuple cursor semantics:
   - Skip if issue.updated_at < cursor_updated_at
   - Skip if issue.updated_at == cursor_updated_at AND issue.id <= cursor_gitlab_id
4. For each issue passing filter:
   - Begin transaction
   - Store raw payload (compressed)
   - Transform and upsert issue
   - Clear existing label links (DELETE FROM issue_labels)
   - Extract and upsert labels
   - Link issue to labels via junction
   - Commit transaction
   - Track for discussion sync eligibility
5. Incremental cursor update every 100 issues
6. Final cursor update
7. Determine issues needing discussion sync: where updated_at > discussions_synced_for_updated_at

## Helper Functions
- get_cursor(conn, project_id) -> (Option<i64>, Option<i64>)
- get_discussions_synced_at(conn, issue_id) -> Option<i64>
- upsert_issue(conn, issue, payload_id) -> usize
- get_local_issue_id(conn, gitlab_id) -> i64
- clear_issue_labels(conn, issue_id)
- upsert_label(conn, label) -> bool
- get_label_id(conn, project_id, name) -> i64
- link_issue_label(conn, issue_id, label_id)
- update_cursor(conn, project_id, resource_type, updated_at, gitlab_id)

Files: src/ingestion/mod.rs, src/ingestion/issues.rs
Tests: tests/issue_ingestion_tests.rs
Done when: Issues, labels, issue_labels populated correctly with resumable cursor


Implement Issue Detail (state + action + view)

## Background
The Issue Detail screen shows a single issue with progressive hydration: Phase 1 loads metadata (fast), Phase 2 loads discussions asynchronously, Phase 3 loads thread bodies on expand. All subqueries run inside a single read transaction for snapshot consistency.

## Approach
State (state/issue_detail.rs):
- IssueDetailState: current_key (Option<EntityKey>), metadata (Option<IssueMetadata>), discussions (Vec<Discussion>), discussions_loaded (bool), cross_refs (Vec<CrossRef>), tree_state (TreePersistState), scroll_offset (usize)
- IssueMetadata: iid, title, description, state, author, assignee, labels, milestone, created_at, updated_at, web_url, status_name, status_icon, closing_mr_iids, related_issue_iids
- handle_key(): j/k scroll, Enter expand discussion thread, d open description, x cross-refs, o open in browser, t scoped timeline, Esc back to list

Action (action.rs):
- fetch_issue_detail(conn, key, clock) -> Result<IssueDetail, LoreError>: uses with_read_snapshot for snapshot consistency. Fetches metadata, discussion count, cross-refs in single transaction.
- fetch_discussions(conn, key) -> Result<Vec<Discussion>, LoreError>: loads discussions for the issue, separate async call (Phase 2 of hydration)

View (view/issue_detail.rs):
- render_issue_detail(frame, state, area, theme): header (IID, title, state badge, labels), description (markdown rendered with sanitization), discussions (tree widget), cross-references section
- Header: "Issue #42 — Fix auth flow [opened]" with colored state badge
- Description: rendered markdown, scrollable
- Discussions: loaded async, shown with spinner until ready
- Cross-refs: closing MRs, related issues as navigable links

## Acceptance Criteria
- [ ] Metadata loads in Phase 1 (p95 < 75ms on M-tier)
- [ ] Discussions load async in Phase 2 (spinner shown while loading)
- [ ] All detail subqueries run inside single read transaction (snapshot consistency)
- [ ] Description text sanitized via sanitize_for_terminal()
- [ ] Discussion tree renders with expand/collapse
- [ ] Cross-references navigable via Enter
- [ ] Esc returns to Issue List with cursor position preserved
- [ ] Open in browser (o) uses classify_safe_url before launching
- [ ] Scoped timeline (t) navigates to Timeline filtered for this entity

## Files
- MODIFY: crates/lore-tui/src/state/issue_detail.rs (expand from stub)
- MODIFY: crates/lore-tui/src/action.rs (add fetch_issue_detail, fetch_discussions)
- CREATE: crates/lore-tui/src/view/issue_detail.rs

## TDD Anchor
RED: Write test_fetch_issue_detail_snapshot in action.rs that inserts an issue with 2 discussions, calls fetch_issue_detail, asserts metadata and discussion count are correct.
GREEN: Implement fetch_issue_detail with read transaction.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_fetch_issue_detail

## Edge Cases
- Issue with no description: show placeholder "[No description]"
- Issue with hundreds of discussions: paginate or lazy-load beyond first 50
- Cross-refs to entities not in local DB: show as text-only (not navigable)
- Issue description with embedded images: show [image] placeholder (no inline rendering)
- Entity cache (future): near-instant reopen during Enter/Esc drill workflows

## Dependency Context
Uses discussion tree and cross-ref widgets from "Implement discussion tree + cross-reference widgets" task.
Uses EntityKey, Msg from "Implement core types" task.
Uses with_read_snapshot from DbManager from "Implement DbManager" task.
Uses sanitize_for_terminal from "Implement terminal safety module" task.
Uses Clock for timestamps from "Implement Clock trait" task.


lore related: semantic similarity discovery

## Background
Given any entity or free text, find semantically related entities using vector embeddings. No other GitLab tool does this — glab, GitLab Advanced Search, and even paid tiers are keyword-only. This finds conceptual connections humans miss.

## Current Infrastructure (Verified 2026-02-12)
- sqlite-vec extension loaded via sqlite3_vec_init in src/core/db.rs:84
- Embeddings stored in: embedding_metadata table (chunk info) + vec0 virtual table named `embeddings` (vectors)
- Migration 009 creates embedding infrastructure
- search_vector() at src/search/vector.rs:43 — works with sqlite-vec KNN queries
- OllamaClient::embed_batch() at src/embedding/ollama.rs:103 — batch embedding
- Model: nomic-embed-text, 768 dimensions, context_length=2048 tokens (~1500 bytes)
- 61K documents in DB, embedding coverage TBD

### sqlite-vec Distance Metric
The `embeddings` virtual table is `vec0(embedding float[768])`. sqlite-vec's MATCH query returns L2 (Euclidean) distance by default. Lower distance = more similar. The `search_vector()` function returns `VectorResult { document_id: i64, distance: f64 }`.

## Approach

### Entity Mode: lore related issues N
1. Look up document for issue N:
```sql
SELECT d.id, d.content_text
FROM documents d
JOIN issues i ON d.source_type = 'issue' AND d.source_id = i.id
WHERE i.iid = ?1 AND i.project_id = (SELECT id FROM projects WHERE ...)
```
NOTE: `documents.source_id` is the internal DB id from the source table (issues.id), NOT the GitLab IID. See migration 007 comment: `source_id INTEGER NOT NULL -- local DB id in the source table`.

2. Get its embedding: Look up via embedding_metadata which maps document_id -> rowid in the vec0 table:
```sql
SELECT em.rowid
FROM embedding_metadata em
WHERE em.document_id = ?1
LIMIT 1  -- use first chunk's embedding as representative
```
Then extract the embedding vector from the vec0 table to use as the KNN query.

Alternatively, embed the document's content_text on-the-fly via OllamaClient (simpler, more robust):
```rust
let embedding = client.embed_batch(&[&doc.content_text]).await?[0].clone();
```

3. Call search_vector(conn, &embedding, limit * 2) for KNN — multiply limit to have room after filtering self
4. Exclude self (filter out source document_id from results)
5. Hydrate results: join documents -> issues/mrs/discussions for title, url, labels, author
6. Compute shared_labels: parse `documents.label_names` (JSON array string) for both source and each result, intersect
7. Return ranked list

### Query Mode: lore related 'free text'
1. Embed query via OllamaClient::embed_batch(&[query_text])
2. Call search_vector(conn, &query_embedding, limit)
3. Hydrate and return (same as entity mode minus self-exclusion)

### Key Design Decision
This is intentionally SIMPLER than hybrid search. No FTS, no RRF. Pure vector similarity. The point is conceptual relatedness, not keyword matching.

### Distance to Similarity Score Conversion
sqlite-vec returns L2 (Euclidean) distance. Convert to 0-1 similarity:
```rust
/// Convert L2 distance to a 0-1 similarity score.
/// Uses inverse relationship: closer (lower distance) = higher similarity.
/// The +1 prevents division by zero and ensures score is in (0, 1].
fn distance_to_similarity(distance: f64) -> f64 {
    1.0 / (1.0 + distance)
}
```
For normalized embeddings (which nomic-embed-text produces), L2 distance ranges roughly 0-2. This formula maps:
- distance 0.0 -> similarity 1.0 (identical)
- distance 1.0 -> similarity 0.5
- distance 2.0 -> similarity 0.33

### Label Extraction for shared_labels
```rust
fn parse_label_names(label_names_json: &Option<String>) -> HashSet<String> {
    label_names_json
        .as_deref()
        .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
        .unwrap_or_default()
        .into_iter()
        .collect()
}

let source_labels = parse_label_names(&source_doc.label_names);
let result_labels = parse_label_names(&result_doc.label_names);
let shared: Vec<String> = source_labels.intersection(&result_labels).cloned().collect();
```

## Function Signatures

```rust
// New: src/cli/commands/related.rs
pub struct RelatedArgs {
    pub entity_type: Option<String>,  // "issues" or "mrs"
    pub entity_iid: Option<i64>,
    pub query: Option<String>,        // free text mode
    pub project: Option<String>,
    pub limit: Option<usize>,
}

pub async fn run_related(
    config: &Config,
    args: RelatedArgs,
) -> Result<RelatedResponse>

// Reuse from src/search/vector.rs:43
pub fn search_vector(
    conn: &Connection,
    query_embedding: &[f32],
    limit: usize,
) -> Result<Vec<VectorResult>>
// VectorResult { document_id: i64, distance: f64 }

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

## Robot Mode Output Schema
```json
{
  "ok": true,
  "data": {
    "source": { "type": "issue", "iid": 3864, "title": "..." },
    "query": "switch throw time...",
    "results": [{
      "source_type": "issue",
      "iid": 3800,
      "title": "Rail Break Card",
      "url": "...",
      "similarity_score": 0.87,
      "shared_labels": ["customer:BNSF"],
      "shared_authors": [],
      "project_path": "vs/typescript-code"
    }]
  },
  "meta": { "elapsed_ms": 42, "mode": "entity", "embedding_dims": 768, "distance_metric": "l2" }
}
```

## Clap Registration
```rust
// In src/main.rs Commands enum, add:
Related {
    /// Entity type ("issues" or "mrs") or free text query
    query_or_type: String,
    /// Entity IID (when first arg is entity type)
    iid: Option<i64>,
    /// Maximum results
    #[arg(short = 'n', long, default_value = "10")]
    limit: usize,
    /// Scope to project (fuzzy match)
    #[arg(short, long)]
    project: Option<String>,
},
```

## TDD Loop
RED: Tests in src/cli/commands/related.rs:
- test_related_entity_excludes_self: insert doc + embedding for issue, query related, assert source doc not in results
- test_related_shared_labels: insert 2 docs with overlapping labels (JSON in label_names), assert shared_labels computed correctly
- test_related_empty_embeddings: no embeddings in DB, assert exit code 14 with helpful error
- test_related_query_mode: embed free text via mock, assert results returned
- test_related_similarity_score_range: all scores between 0.0 and 1.0
- test_distance_to_similarity: unit test the conversion function (0.0->1.0, 1.0->0.5, large->~0.0)

GREEN: Implement related command using search_vector + hydration

VERIFY:
```bash
cargo test related:: && cargo clippy --all-targets -- -D warnings
cargo run --release -- -J related issues 3864 -n 5 | jq '.data.results[0].similarity_score'
```

## Acceptance Criteria
- [ ] lore related issues N returns top-K semantically similar entities
- [ ] lore related mrs N works for merge requests
- [ ] lore related 'free text' works as concept search (requires Ollama)
- [ ] Results exclude the input entity itself
- [ ] similarity_score is 0-1 range (higher = more similar), converted from L2 distance
- [ ] Robot mode includes shared_labels (from documents.label_names JSON), shared_authors per result
- [ ] Human mode shows ranked list with titles, scores, common labels
- [ ] No embeddings in DB: exit code 14 with message "Run 'lore embed' first"
- [ ] Ollama unavailable (query mode only): exit code 14 with suggestion
- [ ] Performance: <1s for 61K documents
- [ ] Command registered in main.rs and robot-docs

## Edge Cases
- Entity has no embedding (added after last lore embed): embed its content_text on-the-fly via OllamaClient, or exit 14 if Ollama unavailable
- All results have very low similarity (<0.3): include warning "No strongly related entities found"
- Entity is a discussion (not issue/MR): should still work (documents table has discussion docs)
- Multiple documents per entity (discussion docs): use the entity-level document, not discussion subdocs
- Free text query very short (1-2 words): may produce noisy results, add warning
- Entity not found in DB: exit code 17 with suggestion to sync
- Ambiguous project: exit code 18 with suggestion to use -p flag
- documents.label_names may be NULL or invalid JSON — parse_label_names handles both gracefully

## Dependency Context
- **bd-1ksf (hybrid search)**: BLOCKER. Shares OllamaClient infrastructure. Also ensures async search.rs patterns are established. Related reuses the same vector search infrastructure.

## Files to Create/Modify
- NEW: src/cli/commands/related.rs
- src/cli/commands/mod.rs (add pub mod related; re-export)
- src/main.rs (register Related subcommand in Commands enum, add handle_related fn)
- Reuse: search_vector() from src/search/vector.rs, OllamaClient from src/embedding/ollama.rs

Implemented lore related command with entity and query modes, vector similarity search, hydration, human + robot output, 11 unit tests. All quality gates pass.


Extract cross-references from resource_state_events

## Background
resource_state_events includes source_merge_request (with iid) for 'closed by MR' events. After state events are stored (Gate 1), post-processing extracts these into entity_references for the cross-reference graph.

## Approach
Create src/core/references.rs (new module) or add to events_db.rs:

```rust
/// Extract cross-references from stored state events and insert into entity_references.
/// Looks for state events with source_merge_request_id IS NOT NULL (meaning "closed by MR").
///
/// Directionality: source = MR (that caused the close), target = issue (that was closed)
pub fn extract_refs_from_state_events(
    conn: &Connection,
    project_id: i64,
) -> Result<usize>  // returns count of new references inserted
```

SQL logic:
```sql
INSERT OR IGNORE INTO entity_references (
    source_entity_type, source_entity_id,
    target_entity_type, target_entity_id,
    reference_type, source_method, created_at
)
SELECT
    'merge_request',
    mr.id,
    'issue',
    rse.issue_id,
    'closes',
    'api_state_event',
    rse.created_at
FROM resource_state_events rse
JOIN merge_requests mr ON mr.project_id = rse.project_id AND mr.iid = rse.source_merge_request_id
WHERE rse.source_merge_request_id IS NOT NULL
  AND rse.issue_id IS NOT NULL
  AND rse.project_id = ?1;
```

Key: source_merge_request_id stores the MR iid, so we JOIN on merge_requests.iid to get the local DB id.

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

Call this after drain_dependent_queue in the sync pipeline (after all state events are stored).

## Acceptance Criteria
- [ ] State events with source_merge_request_id produce 'closes' references
- [ ] Source = MR (resolved by iid), target = issue
- [ ] source_method = 'api_state_event'
- [ ] INSERT OR IGNORE prevents duplicates with api_closes_issues data
- [ ] Returns count of newly inserted references
- [ ] No-op when no state events have source_merge_request_id

## Files
- src/core/references.rs (new)
- src/core/mod.rs (add `pub mod references;`)
- src/cli/commands/sync.rs (call after drain step)

## TDD Loop
RED: tests/references_tests.rs:
- `test_extract_refs_from_state_events_basic` - seed a "closed" state event with source_merge_request_id, verify entity_reference created
- `test_extract_refs_dedup_with_closes_issues` - insert ref from closes_issues API first, verify state event extraction doesn't duplicate
- `test_extract_refs_no_source_mr` - state events without source_merge_request_id produce no refs

Setup: create_test_db with migrations 001-011, seed project + issue + MR + state events.

GREEN: Implement extract_refs_from_state_events

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

## Edge Cases
- source_merge_request_id may reference an MR not synced locally (cross-project close) — the JOIN will produce no match, which is correct behavior (ref simply not created)
- Multiple state events can reference the same MR for the same issue (reopen + re-close) — INSERT OR IGNORE handles dedup
- The merge_requests table might not have the MR yet if sync is still running — call this after all dependent fetches complete


Comprehensive robot-docs as agent bootstrap

## Background
Agents reach for glab because they already know it from training data. lore robot-docs exists but is not comprehensive enough to serve as a zero-training bootstrap. An agent encountering lore for the first time should be able to use any command correctly after reading robot-docs output alone.

## Current State (Verified 2026-02-12)
- `handle_robot_docs()` at src/main.rs:2069
- Called at no-args in robot mode (main.rs:165) and via Commands::RobotDocs { brief } (main.rs:229)
- Current output top-level keys: name, version, description, activation, commands, aliases, exit_codes, clap_error_codes, error_format, workflows
- Missing: response_schema per command, example_output per command, quick_start section, glab equivalence table
- --brief flag exists but returns shorter version of same structure
- main.rs is 2579 lines total

## Current robot-docs Output Structure
```json
{
  "name": "lore",
  "version": "0.6.1",
  "description": "...",
  "activation": { "flags": ["--robot", "-J"], "env": "LORE_ROBOT=1", "auto_detect": "non-TTY" },
  "commands": [{ "name": "...", "description": "...", "flags": [...], "example": "..." }],
  "aliases": { ... },
  "exit_codes": { ... },
  "clap_error_codes": { ... },
  "error_format": { ... },
  "workflows": { ... }
}
```

## Approach

### 1. Add quick_start section
Top-level key with glab-to-lore translation and lore-exclusive feature summary:
```json
"quick_start": {
  "glab_equivalents": [
    { "glab": "glab issue list", "lore": "lore -J issues -n 50", "note": "Richer: includes labels, status, closing MRs" },
    { "glab": "glab issue view 123", "lore": "lore -J issues 123", "note": "Includes discussions, work-item status" },
    { "glab": "glab mr list", "lore": "lore -J mrs", "note": "Includes draft status, reviewers" },
    { "glab": "glab mr view 456", "lore": "lore -J mrs 456", "note": "Includes discussions, file changes" },
    { "glab": "glab api '/projects/:id/issues'", "lore": "lore -J issues -p project", "note": "Fuzzy project matching" }
  ],
  "lore_exclusive": [
    "search: FTS5 + vector hybrid search across all entities",
    "who: Expert/workload/reviews analysis per file path or person",
    "timeline: Chronological event reconstruction across entities",
    "stats: Database statistics with document/note/discussion counts",
    "count: Entity counts with state breakdowns"
  ]
}
```

### 2. Add response_schema per command
For each command in the commands array, add a `response_schema` field showing the JSON shape:
```json
{
  "name": "issues",
  "response_schema": {
    "ok": "boolean",
    "data": { "type": "array|object", "fields": ["iid", "title", "state", "..."] },
    "meta": { "elapsed_ms": "integer" }
  }
}
```
Commands with multiple output shapes (list vs detail) need both documented.

### 3. Add example_output per command
Realistic truncated JSON for each command. Keep each example under 500 bytes.

### 4. Token budget enforcement
- --brief mode: ONLY quick_start + command names + invocation syntax. Target <4000 tokens (~16000 bytes).
- Full mode: everything. Target <12000 tokens (~48000 bytes).
- Measure with: `cargo run --release -- --robot robot-docs --brief | wc -c`

## TDD Loop
RED: Tests in src/main.rs or new src/cli/commands/robot_docs.rs:
- test_robot_docs_has_quick_start: parse output JSON, assert quick_start.glab_equivalents array has >= 5 entries
- test_robot_docs_brief_size: --brief output < 16000 bytes
- test_robot_docs_full_size: full output < 48000 bytes
- test_robot_docs_has_response_schemas: every command entry has response_schema key
- test_robot_docs_commands_complete: assert all registered commands appear (issues, mrs, search, who, timeline, count, stats, sync, embed, doctor, health, ingest, generate-docs, show)

GREEN: Add quick_start, response_schema, example_output to robot-docs output

VERIFY:
```bash
cargo test robot_docs && cargo clippy --all-targets -- -D warnings
cargo run --release -- --robot robot-docs | jq '.quick_start.glab_equivalents | length'
# Should return >= 5
cargo run --release -- --robot robot-docs --brief | wc -c
# Should be < 16000
```

## Acceptance Criteria
- [ ] robot-docs JSON has quick_start.glab_equivalents array with >= 5 entries
- [ ] robot-docs JSON has quick_start.lore_exclusive array
- [ ] Every command entry has response_schema showing the JSON shape
- [ ] Every command entry has example_output with realistic truncated data
- [ ] --brief output is under 16000 bytes (~4000 tokens)
- [ ] Full output is under 48000 bytes (~12000 tokens)
- [ ] An agent reading ONLY robot-docs can correctly invoke any lore command
- [ ] cargo test passes with new robot_docs tests

## Edge Cases
- Commands with multiple output shapes (e.g., issues list vs issues detail via iid) need both schemas documented
- --fields flag changes output shape -- document the effect in the response_schema
- robot-docs output must be stable across versions (agents may cache it)
- Version field should match Cargo.toml version

## Files to Modify
- src/main.rs fn handle_robot_docs() (~line 2069) — add quick_start section, response_schema, example_output
- Consider extracting to src/cli/commands/robot_docs.rs if the function exceeds 200 lines

Robot-docs enhanced with quick_start (glab equivalents, lore exclusives, read/write split) and example_output for issues/mrs/search/who


[CP1] gi sync-status enhancement

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

## Changes to src/cli/commands/sync_status.rs

Update the existing stub to show:
- Last run timestamp and duration
- Cursor positions per project (issues resource_type)
- Entity counts (issues, discussions, notes)

## Output Format
Last sync: 2026-01-25 10:30:00 (succeeded, 45s)

Cursors:
  group/project-one
    issues: 2026-01-25T10:25:00Z (gitlab_id: 12345678)

Counts:
  Issues: 1,234
  Discussions: 5,678
  Notes: 23,456 (4,567 system)

Files: src/cli/commands/sync_status.rs
Done when: Shows cursor positions and counts after ingestion


Implement 'lore trace' command with human and robot output

## Background

The trace command is Gate 5's capstone CLI. It answers 'Why was this code introduced?' by building file -> MR -> issue -> discussion chains.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 5.3.

## Codebase Context

- CLI pattern: same as file-history (Commands enum, handler in main.rs)
- trace.rs (bd-2n4): run_trace() returns TraceResult with chains
- Path parsing: support 'src/foo.rs:45' syntax (line number for future Tier 2)
- merge_requests.merged_at exists (migration 006) — use COALESCE(merged_at, updated_at) for ordering

## Approach

### 1. TraceArgs (`src/cli/mod.rs`):
```rust
#[derive(Parser)]
pub struct TraceArgs {
    pub path: String,                      // supports :line suffix
    #[arg(short = 'p', long)] pub project: Option<String>,
    #[arg(long)] pub discussions: bool,
    #[arg(long = "no-follow-renames")] pub no_follow_renames: bool,
    #[arg(short = 'n', long = "limit", default_value = "20")] pub limit: usize,
}
```

### 2. Path parsing:
```rust
fn parse_trace_path(input: &str) -> (String, Option<u32>) {
    if let Some((path, line)) = input.rsplit_once(':') {
        if let Ok(n) = line.parse::<u32>() { return (path.to_string(), Some(n)); }
    }
    (input.to_string(), None)
}
```
If line present: warn 'Line-level tracing requires Tier 2. Showing file-level results.'

### 3. Human output shows chains with MR -> issue -> discussion context

### 4. Robot JSON:
```json
{"ok": true, "data": {"path": "...", "resolved_paths": [...], "trace_chains": [...]}, "meta": {"tier": "api_only", "line_requested": null}}
```

## Acceptance Criteria

- [ ] `lore trace src/foo.rs` with human output
- [ ] `lore --robot trace src/foo.rs` with JSON
- [ ] :line suffix parses and emits Tier 2 warning
- [ ] -p, --discussions, --no-follow-renames, -n all work
- [ ] Rename-aware via resolve_rename_chain
- [ ] meta.tier = 'api_only'
- [ ] Added to VALID_COMMANDS and robot-docs
- [ ] `cargo check --all-targets` passes

## Files

- `src/cli/mod.rs` (TraceArgs + Commands::Trace)
- `src/cli/commands/trace.rs` (NEW)
- `src/cli/commands/mod.rs` (re-export)
- `src/main.rs` (handler + VALID_COMMANDS + robot-docs)

## TDD Loop

RED:
- `test_parse_trace_path_simple` - "src/foo.rs" -> (path, None)
- `test_parse_trace_path_with_line` - "src/foo.rs:42" -> (path, Some(42))
- `test_parse_trace_path_windows` - "C:/foo.rs" -> (path, None) — don't misparse drive letter

GREEN: Implement CLI wiring and handlers.

VERIFY: `cargo check --all-targets`

## Edge Cases

- Windows paths: don't misparse C: as line number
- No MR data: friendly message with suggestion to sync
- Very deep rename chain: bounded by resolve_rename_chain

Implementation complete: trace CLI command with human + robot output, all tests passing


lore explain: auto-generate issue/MR narrative

## Background
Given an issue or MR, auto-generate a structured narrative of what happened: who was involved, what decisions were made, what changed, and what is unresolved. Template-based v1 (no LLM dependency), deterministic and reproducible.

## Current Infrastructure (Verified 2026-02-12)
- show.rs: IssueDetail (line 69) and MrDetail (line 14) — entity detail with discussions
- timeline.rs: 5-stage pipeline SHIPPED — chronological event reconstruction
- notes table: 282K rows with body, author, created_at, is_system, discussion_id
- discussions table: links notes to parent entity (noteable_type, noteable_id), has resolved flag
- resource_state_events table: state changes with created_at, user_username (src/core/events_db.rs)
- resource_label_events table: label add/remove with created_at, user_username
- entity_references table (src/core/references.rs): cross-references between entities (closing MRs, related issues). Column names: `source_entity_type`, `source_entity_id`, `target_entity_type`, `target_entity_id`, `target_project_path`, `target_entity_iid`, `reference_type`, `source_method`

## Approach
New command: `lore explain issues N` / `lore explain mrs N`

### Data Assembly (reuse existing internals as library calls)
1. Entity detail: reuse show.rs query logic for IssueDetail/MrDetail
2. Timeline events: reuse timeline pipeline with entity-scoped seed
3. Discussion notes:
```sql
SELECT n.id, n.body, n.author_username, n.created_at
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE d.noteable_type = ? AND d.noteable_id = ?
  AND n.is_system = 0
ORDER BY n.created_at
```
4. Cross-references:
```sql
SELECT target_entity_type, target_entity_id, target_project_path,
       target_entity_iid, reference_type, source_method
FROM entity_references
WHERE (source_entity_type = ?1 AND source_entity_id = ?2)
UNION ALL
SELECT source_entity_type, source_entity_id, NULL,
       NULL, reference_type, source_method
FROM entity_references
WHERE (target_entity_type = ?1 AND target_entity_id = ?2)
```

### Key Decisions Heuristic
Notes from assignees/author that follow state or label changes within 1 hour:
```rust
struct StateOrLabelEvent {
    created_at: i64,  // ms epoch
    user: String,
    description: String,  // e.g. "state: opened -> closed" or "label: +bug"
}

fn extract_key_decisions(
    state_events: &[ResourceStateEvent],
    label_events: &[ResourceLabelEvent],
    notes: &[Note],
) -> Vec<KeyDecision> {
    // Merge both event types into a unified chronological list
    let mut events: Vec<StateOrLabelEvent> = Vec::new();
    for e in state_events {
        events.push(StateOrLabelEvent {
            created_at: e.created_at,
            user: e.user_username.clone(),
            description: format!("state: {} -> {}", e.from_state.as_deref().unwrap_or("?"), e.to_state),
        });
    }
    for e in label_events {
        let action = if e.action == "add" { "+" } else { "-" };
        events.push(StateOrLabelEvent {
            created_at: e.created_at,
            user: e.user_username.clone(),
            description: format!("label: {}{}", action, e.label_name.as_deref().unwrap_or("?")),
        });
    }
    events.sort_by_key(|e| e.created_at);

    let mut decisions = Vec::new();
    let one_hour_ms: i64 = 60 * 60 * 1000;

    for event in &events {
        // Find notes by same actor within 60 min after the event
        for note in notes {
            if note.author_username == event.user
                && note.created_at >= event.created_at
                && note.created_at <= event.created_at + one_hour_ms
            {
                decisions.push(KeyDecision {
                    timestamp: event.created_at,
                    actor: event.user.clone(),
                    action: event.description.clone(),
                    context_note: truncate(&note.body, 500),
                });
                break;  // one note per event
            }
        }
    }
    decisions.truncate(10);  // Cap at 10 key decisions
    decisions
}
```

### Narrative Sections
1. **Header**: title, author, opened date, state, assignees, labels, status_name
2. **Description excerpt**: first 500 chars of description (or full if shorter)
3. **Key decisions**: notes correlated with state/label changes (heuristic above)
4. **Activity summary**: counts of state changes, label changes, notes, time range
5. **Open threads**: discussions WHERE resolved = false
6. **Related entities**: closing MRs (with state), related issues from entity_references
7. **Timeline excerpt**: first 20 events from timeline pipeline

## Robot Mode Output Schema
```json
{
  "ok": true,
  "data": {
    "entity": {
      "type": "issue", "iid": 3864, "title": "...", "state": "opened",
      "author": "teernisse", "assignees": ["teernisse"],
      "labels": ["customer:BNSF"], "created_at": "...", "updated_at": "...",
      "url": "...", "status_name": "In progress"
    },
    "description_excerpt": "First 500 chars of description...",
    "key_decisions": [{
      "timestamp": "2026-01-15T...",
      "actor": "teernisse",
      "action": "state: opened -> in_progress",
      "context_note": "Starting work on the BNSF throw time integration..."
    }],
    "activity": {
      "state_changes": 3, "label_changes": 5, "notes": 42,
      "first_event": "2026-01-10T...", "last_event": "2026-02-12T..."
    },
    "open_threads": [{
      "discussion_id": "abc123",
      "started_by": "cseiber",
      "started_at": "2026-02-01T...",
      "note_count": 5,
      "last_note_at": "2026-02-10T..."
    }],
    "related": {
      "closing_mrs": [{ "iid": 200, "title": "...", "state": "merged" }],
      "related_issues": [{ "iid": 3800, "title": "Rail Break Card", "relation": "related" }]
    },
    "timeline_excerpt": [{ "timestamp": "...", "event_type": "...", "actor": "...", "summary": "..." }]
  },
  "meta": { "elapsed_ms": 350 }
}
```

## Clap Registration
```rust
// In src/main.rs Commands enum, add:
Explain {
    /// Entity type: "issues" or "mrs"
    entity_type: String,
    /// Entity IID
    iid: i64,
    /// Scope to project (fuzzy match)
    #[arg(short, long)]
    project: Option<String>,
},
```

## TDD Loop
RED: Tests in src/cli/commands/explain.rs:
- test_explain_issue_basic: insert issue + notes + state events, run explain, assert all sections present (entity, description_excerpt, key_decisions, activity, open_threads, related, timeline_excerpt)
- test_explain_key_decision_heuristic: insert state change event + note by same author within 30 min, assert note appears in key_decisions
- test_explain_key_decision_ignores_unrelated_notes: insert note by different author, assert it does NOT appear in key_decisions
- test_explain_open_threads: insert 2 discussions (1 resolved, 1 unresolved), assert only unresolved in open_threads
- test_explain_no_notes: issue with zero notes produces header + description + empty sections
- test_explain_mr: insert MR with merged_at, assert entity includes type="merge_request"
- test_explain_activity_counts: insert 3 state events + 2 label events + 10 notes, assert counts match

GREEN: Implement explain command with section assembly

VERIFY:
```bash
cargo test explain:: && cargo clippy --all-targets -- -D warnings
cargo run --release -- -J explain issues 3864 | jq '.data | keys'
# Should include: entity, description_excerpt, key_decisions, activity, open_threads, related, timeline_excerpt
```

## Acceptance Criteria
- [ ] lore explain issues N produces structured output for any synced issue
- [ ] lore explain mrs N produces structured output for any synced MR
- [ ] Robot mode returns all 7 sections
- [ ] Human mode renders readable narrative with headers and indentation
- [ ] Key decisions heuristic: captures notes within 60 min of state/label changes by same actor
- [ ] Works fully offline (no API calls, no LLM)
- [ ] Performance: <500ms for issue with 50 notes
- [ ] Command registered in main.rs and robot-docs
- [ ] key_decisions capped at 10, timeline_excerpt capped at 20 events

## Edge Cases
- Issue with empty description: description_excerpt = "(no description)"
- Issue with 500+ notes: timeline_excerpt capped at 20, key_decisions capped at 10
- Issue not found in local DB: exit code 17 with suggestion to sync
- Ambiguous project: exit code 18 with suggestion to use -p flag
- MR with no review activity: activity section shows zeros
- Cross-project references: show as unresolved with project path hint
- Notes that are pure code blocks: include in key_decisions if correlated with events (they may contain implementation decisions)
- ResourceStateEvent/ResourceLabelEvent field names: check src/core/events_db.rs for exact struct definitions before implementing

## Dependency Context
- **bd-2g50 (data gaps)**: BLOCKER. Provides `closed_at` field on IssueDetail for the header section. Without it, explain can still show state="closed" but won't have the exact close timestamp.

## Files to Create/Modify
- NEW: src/cli/commands/explain.rs
- src/cli/commands/mod.rs (add pub mod explain; re-export)
- src/main.rs (register Explain subcommand in Commands enum, add handle_explain fn)
- Reuse: show.rs queries, timeline pipeline, notes/discussions/resource_events queries from src/core/events_db.rs

Implemented lore explain: auto-generated narrative for issues/MRs. 7 unit tests, CLI wired, robot-docs, autocorrect.


NOTE-2G: Parent metadata change propagation to note documents

## Background
Note documents inherit labels and title from parent issue/MR. When parent metadata changes, note documents become stale. The existing pipeline already marks discussion documents dirty on parent changes — note documents need the same treatment.

## Approach
Find where ingestion detects parent entity changes and marks discussion documents dirty. The dirty marking for discussions happens in:
- src/ingestion/discussions.rs line 127: mark_dirty_tx(&tx, SourceType::Discussion, local_discussion_id)
- src/ingestion/mr_discussions.rs line 162 and 362: mark_dirty_tx(&tx, SourceType::Discussion, local_discussion_id)

These fire when a discussion is upserted (which happens when parent entity is re-ingested). For note documents, we need to additionally mark all non-system notes of that discussion as dirty:

After each mark_dirty_tx for Discussion, add:
   // Mark child note documents dirty (they inherit parent metadata)
   let note_ids: Vec<i64> = tx.prepare("SELECT id FROM notes WHERE discussion_id = ? AND is_system = 0")?
       .query_map([local_discussion_id], |r| r.get(0))?
       .collect::<std::result::Result<Vec<_>, _>>()?;
   for note_id in note_ids {
       dirty_tracker::mark_dirty_tx(&tx, SourceType::Note, note_id)?;
   }

Alternative (more efficient, set-based):
   INSERT INTO dirty_sources (source_type, source_id, queued_at)
   SELECT 'note', n.id, ?1
   FROM notes n
   WHERE n.discussion_id = ?2 AND n.is_system = 0
   ON CONFLICT(source_type, source_id) DO UPDATE SET queued_at = excluded.queued_at, attempt_count = 0

Use the set-based approach for better performance with large discussions.

## Files
- MODIFY: src/ingestion/discussions.rs (add note dirty marking after line 127)
- MODIFY: src/ingestion/mr_discussions.rs (add note dirty marking after lines 162 and 362)

## TDD Anchor
RED: test_parent_title_change_marks_notes_dirty — change issue title, re-ingest discussions, assert note documents appear in dirty_sources.
GREEN: Add set-based INSERT INTO dirty_sources after discussion dirty marking.
VERIFY: cargo test parent_title_change_marks_notes -- --nocapture
Tests: test_parent_label_change_marks_notes_dirty (modify issue labels, re-ingest, check dirty queue)

## Acceptance Criteria
- [ ] Discussion upsert for issue marks child non-system note documents dirty
- [ ] Discussion upsert for MR marks child non-system note documents dirty (both call sites)
- [ ] Only non-system notes marked dirty (is_system = 0 filter)
- [ ] Set-based SQL (not per-note loop) for performance
- [ ] Both tests pass

## Dependency Context
- Depends on NOTE-2D (bd-2ezb): dirty tracking infrastructure for notes must exist (dirty_sources accepts source_type='note', regenerator handles it)

## Edge Cases
- Discussion with 0 non-system notes: set-based INSERT is a no-op
- Discussion with 100+ notes: set-based approach handles efficiently in one SQL statement
- Concurrent discussion ingestion: ON CONFLICT DO UPDATE handles race safely


Implement responsive breakpoints for all TUI screens


Implement embedding pipeline with chunking

## Background
The embedding pipeline takes documents, chunks them (paragraph-boundary splitting with overlap), sends chunks to Ollama for embedding via async HTTP, and stores vectors in sqlite-vec + metadata. It uses keyset pagination, concurrent HTTP requests via FuturesUnordered, per-batch transactions, and dimension validation.

## Approach
Create \`src/embedding/pipeline.rs\` per PRD Section 4.4. **The pipeline is async.**

**Constants (per PRD):**
```rust
const BATCH_SIZE: usize = 32;           // texts per Ollama API call
const DB_PAGE_SIZE: usize = 500;        // keyset pagination page size
const EXPECTED_DIMS: usize = 768;       // nomic-embed-text dimensions
const CHUNK_MAX_CHARS: usize = 32_000;  // max chars per chunk
const CHUNK_OVERLAP_CHARS: usize = 500; // overlap between chunks
```

**Core async function:**
```rust
pub async fn embed_documents(
    conn: &Connection,
    client: &OllamaClient,
    selection: EmbedSelection,
    concurrency: usize,              // max in-flight HTTP requests
    progress_callback: Option<Box<dyn Fn(usize, usize)>>,
) -> Result<EmbedResult>
```

**EmbedSelection:** Pending | RetryFailed
**EmbedResult:** { embedded, failed, skipped }

**Algorithm (per PRD):**
1. count_pending_documents(conn, selection) for progress total
2. Keyset pagination loop: find_pending_documents(conn, DB_PAGE_SIZE, last_id, selection)
3. For each page:
   a. Begin transaction
   b. For each doc: clear_document_embeddings(&tx, doc.id), split_into_chunks(&doc.content)
   c. Build ChunkWork items with doc_hash + chunk_hash
   d. Commit clearing transaction
4. Batch ChunkWork texts into Ollama calls (BATCH_SIZE=32)
5. Use **FuturesUnordered** for concurrent HTTP, cap at \`concurrency\`
6. collect_writes() in per-batch transactions: validate dims (768), store LE bytes, write metadata
7. On error: record_embedding_error per chunk (not abort)
8. Advance keyset cursor

**ChunkWork struct:**
```rust
struct ChunkWork {
    doc_id: i64,
    chunk_index: usize,
    doc_hash: String,    // SHA-256 of FULL document (staleness detection)
    chunk_hash: String,  // SHA-256 of THIS chunk (provenance)
    text: String,
}
```

**Splitting:** split_into_chunks(content) -> Vec<(usize, String)>
- Documents <= CHUNK_MAX_CHARS: single chunk (index 0)
- Longer: split at paragraph boundaries (\\n\\n), fallback to sentence/word, with CHUNK_OVERLAP_CHARS overlap

**Storage:** embeddings as raw LE bytes, rowid = encode_rowid(doc_id, chunk_idx)
**Staleness detection:** uses document_hash (not chunk_hash) because it's document-level

Also create \`src/embedding/change_detector.rs\` (referenced in PRD module structure):
```rust
pub fn detect_embedding_changes(conn: &Connection) -> Result<Vec<PendingDocument>>;
```

## Acceptance Criteria
- [ ] Pipeline is async (uses FuturesUnordered for concurrent HTTP)
- [ ] concurrency parameter caps in-flight HTTP requests
- [ ] progress_callback reports (processed, total)
- [ ] New documents embedded, changed re-embedded, unchanged skipped
- [ ] clear_document_embeddings before re-embedding (range delete vec0 + metadata)
- [ ] Chunking at paragraph boundaries with 500-char overlap
- [ ] Short documents (<32k chars) produce exactly 1 chunk
- [ ] Embeddings stored as raw LE bytes in vec0
- [ ] Rowids encoded via encode_rowid(doc_id, chunk_index)
- [ ] Dimension validation: 768 floats per embedding (mismatch -> record error, not store)
- [ ] Per-batch transactions for writes
- [ ] Errors recorded in embedding_metadata per chunk (last_error, attempt_count)
- [ ] Keyset pagination (d.id > last_id, not OFFSET)
- [ ] Pending detection uses document_hash (not chunk_hash)
- [ ] \`cargo build\` succeeds

## Files
- \`src/embedding/pipeline.rs\` — new file (async)
- \`src/embedding/change_detector.rs\` — new file
- \`src/embedding/mod.rs\` — add \`pub mod pipeline; pub mod change_detector;\` + re-exports

## TDD Loop
RED: Unit tests for chunking:
- \`test_short_document_single_chunk\` — <32k produces [(0, full_content)]
- \`test_long_document_multiple_chunks\` — >32k splits at paragraph boundaries
- \`test_chunk_overlap\` — adjacent chunks share 500-char overlap
- \`test_no_paragraph_boundary\` — falls back to char boundary
Integration tests need Ollama or mock.
GREEN: Implement split_into_chunks, embed_documents (async)
VERIFY: \`cargo test pipeline\`

## Edge Cases
- Empty document content_text: skip (don't embed)
- No paragraph boundaries: split at CHUNK_MAX_CHARS with overlap
- Ollama error for one batch: record error per chunk, continue with next batch
- Dimension mismatch (model returns 512 instead of 768): record error, don't store corrupt data
- Document deleted between pagination and embedding: skip gracefully

Implemented embedding pipeline: chunking at paragraph boundaries with 500-char overlap, change detector (keyset pagination, hash-based staleness), async embed via Ollama with batch processing, dimension validation, per-chunk error recording, LE byte vector storage. 7 chunking tests pass. 289 total tests.


OBSERV: Create migration 014 for sync_runs enrichment

## Background
The sync_runs table (created in migration 001) has columns id, started_at, heartbeat_at, finished_at, status, command, error, metrics_json but NOTHING writes to it. This migration adds columns for the observability correlation ID and aggregate counts, enabling queryable sync history.

## Approach
Create migrations/014_sync_runs_enrichment.sql:

```sql
-- Migration 014: sync_runs enrichment for observability
-- Adds correlation ID and aggregate counts for queryable sync history

ALTER TABLE sync_runs ADD COLUMN run_id TEXT;
ALTER TABLE sync_runs ADD COLUMN total_items_processed INTEGER DEFAULT 0;
ALTER TABLE sync_runs ADD COLUMN total_errors INTEGER DEFAULT 0;

-- Index for correlation queries (find run by run_id from logs)
CREATE INDEX IF NOT EXISTS idx_sync_runs_run_id ON sync_runs(run_id);
```

Migration naming convention: check migrations/ directory. Current latest is 013_resource_event_watermarks.sql. Next is 014.

Note: SQLite ALTER TABLE ADD COLUMN is always safe -- it sets NULL for existing rows. DEFAULT 0 applies to new INSERTs only.

## Acceptance Criteria
- [ ] Migration 014 applies cleanly on a fresh DB (all migrations 001-014)
- [ ] Migration 014 applies cleanly on existing DB with 001-013 already applied
- [ ] sync_runs table has run_id TEXT column
- [ ] sync_runs table has total_items_processed INTEGER DEFAULT 0 column
- [ ] sync_runs table has total_errors INTEGER DEFAULT 0 column
- [ ] idx_sync_runs_run_id index exists
- [ ] Existing sync_runs rows (if any) have NULL run_id, 0 for counts
- [ ] cargo clippy --all-targets -- -D warnings passes (no code changes, but verify migration is picked up)

## Files
- migrations/014_sync_runs_enrichment.sql (new file)

## TDD Loop
RED:
  - test_migration_014_applies: apply all migrations on fresh in-memory DB, query sync_runs schema
  - test_migration_014_idempotent: CREATE INDEX IF NOT EXISTS makes re-run safe; ALTER TABLE ADD COLUMN is NOT idempotent in SQLite (will error). Consider: skip this test or use IF NOT EXISTS workaround
GREEN: Create migration file
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- ALTER TABLE ADD COLUMN in SQLite: NOT idempotent. Running migration twice will error "duplicate column name." The migration system should prevent re-runs, but IF NOT EXISTS is not available for ALTER TABLE in SQLite. Rely on migration tracking.
- Migration numbering conflict: if another PR adds 014 first, renumber to 015. Check before merging.
- metrics_json already exists (from migration 001): we don't touch it. The new columns supplement it with queryable aggregates.

Created migration 014 adding run_id TEXT, total_items_processed INTEGER, total_errors INTEGER to sync_runs, with idx_sync_runs_run_id index


Extend SyncRunRecorder with surgical mode lifecycle methods

## Background
The existing `SyncRunRecorder` in `src/core/sync_run.rs` manages sync run lifecycle with three methods: `start()` (creates row, returns Self), `succeed(self, ...)` (consumes self, sets succeeded), and `fail(self, ...)` (consumes self, sets failed). Both `succeed()` and `fail()` take ownership of `self` — this is intentional to prevent double-finalization.

Surgical sync needs additional lifecycle methods to:
1. Set surgical-specific metadata (mode, phase, IIDs JSON) after `start()`
2. Record per-entity results (increment counters, store entity-level outcomes)
3. Cancel a run (distinct from failure — user-initiated or timeout)
4. Update phase progression during the surgical pipeline

These methods operate on the columns added by migration 027 (bead bd-tiux).

## Approach

### Step 1: Add `set_surgical_metadata` method

Called once after `start()` to set the surgical mode columns:

```rust
pub fn set_surgical_metadata(
    &self,
    conn: &Connection,
    mode: &str,
    phase: &str,
    iids_json: &str,
) -> Result<()> {
    conn.execute(
        "UPDATE sync_runs SET mode = ?1, phase = ?2, surgical_iids_json = ?3 WHERE id = ?4",
        rusqlite::params![mode, phase, iids_json, self.row_id],
    )?;
    Ok(())
}
```

Takes `&self` (not `self`) because the recorder continues to be used after metadata is set.

### Step 2: Add `update_phase` method

Called as the surgical pipeline progresses through phases:

```rust
pub fn update_phase(&self, conn: &Connection, phase: &str) -> Result<()> {
    conn.execute(
        "UPDATE sync_runs SET phase = ?1, heartbeat_at = ?2 WHERE id = ?3",
        rusqlite::params![phase, now_ms(), self.row_id],
    )?;
    Ok(())
}
```

### Step 3: Add `record_entity_result` method

Called after each entity (issue or MR) is processed to increment counters:

```rust
pub fn record_entity_result(
    &self,
    conn: &Connection,
    entity_type: &str,
    stage: &str,
) -> Result<()> {
    let column = match (entity_type, stage) {
        ("issue", "fetched") => "issues_fetched",
        ("issue", "ingested") => "issues_ingested",
        ("mr", "fetched") => "mrs_fetched",
        ("mr", "ingested") => "mrs_ingested",
        ("issue" | "mr", "skipped_stale") => "skipped_stale",
        ("doc", "regenerated") => "docs_regenerated",
        ("doc", "embedded") => "docs_embedded",
        (_, "warning") => "warnings_count",
        _ => return Ok(()),  // Unknown combinations are silently ignored
    };
    conn.execute(
        &format!("UPDATE sync_runs SET {column} = {column} + 1 WHERE id = ?1"),
        rusqlite::params![self.row_id],
    )?;
    Ok(())
}
```

Note: The column name comes from a hardcoded match, NOT from user input — no SQL injection risk.

### Step 4: Add `cancel` method

Consumes self (like succeed/fail) to finalize the run as cancelled:

```rust
pub fn cancel(self, conn: &Connection, reason: &str) -> Result<()> {
    let now = now_ms();
    conn.execute(
        "UPDATE sync_runs SET finished_at = ?1, cancelled_at = ?2, status = 'cancelled', error = ?3 WHERE id = ?4",
        rusqlite::params![now, now, reason, self.row_id],
    )?;
    Ok(())
}
```

Takes `self` (ownership) like `succeed()` and `fail()` — prevents further use after cancellation.

### Step 5: Expose `row_id` getter

The orchestrator (bd-1i4i) may need the row_id for logging/tracing:

```rust
pub fn row_id(&self) -> i64 {
    self.row_id
}
```

## Acceptance Criteria
- [ ] `set_surgical_metadata(&self, conn, mode, phase, iids_json)` writes mode/phase/surgical_iids_json columns
- [ ] `update_phase(&self, conn, phase)` updates phase and heartbeat_at
- [ ] `record_entity_result(&self, conn, entity_type, stage)` increments the correct counter column
- [ ] `record_entity_result` silently ignores unknown entity_type/stage combinations
- [ ] `cancel(self, conn, reason)` consumes self, sets status='cancelled', finished_at, cancelled_at, error
- [ ] `row_id()` returns the internal row_id
- [ ] `succeed(self, ...)` still works after `set_surgical_metadata` + `record_entity_result` calls
- [ ] `fail(self, ...)` still works after `set_surgical_metadata` + `update_phase` calls
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes
- [ ] All existing sync_run tests continue to pass

## Files
- MODIFY: src/core/sync_run.rs (add methods to SyncRunRecorder impl block)
- MODIFY: src/core/sync_run_tests.rs (add new tests)

## TDD Anchor
RED: Write tests in `src/core/sync_run_tests.rs`:

```rust
#[test]
fn surgical_lifecycle_start_metadata_succeed() {
    let conn = setup_test_db();
    let recorder = SyncRunRecorder::start(&conn, "sync", "surg001").unwrap();
    let row_id = recorder.row_id();

    recorder.set_surgical_metadata(
        &conn, "surgical", "preflight", r#"{"issues":[7,8],"mrs":[101]}"#,
    ).unwrap();

    recorder.update_phase(&conn, "ingest").unwrap();
    recorder.record_entity_result(&conn, "issue", "fetched").unwrap();
    recorder.record_entity_result(&conn, "issue", "fetched").unwrap();
    recorder.record_entity_result(&conn, "issue", "ingested").unwrap();
    recorder.record_entity_result(&conn, "mr", "fetched").unwrap();
    recorder.record_entity_result(&conn, "mr", "ingested").unwrap();

    recorder.succeed(&conn, &[], 3, 0).unwrap();

    let (mode, phase, iids, issues_fetched, mrs_fetched, issues_ingested, mrs_ingested, status): (
        String, String, String, i64, i64, i64, i64, String,
    ) = conn.query_row(
        "SELECT mode, phase, surgical_iids_json, issues_fetched, mrs_fetched, issues_ingested, mrs_ingested, status
         FROM sync_runs WHERE id = ?1",
        [row_id],
        |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?, r.get(7)?)),
    ).unwrap();

    assert_eq!(mode, "surgical");
    assert_eq!(phase, "ingest");  // Last phase set before succeed
    assert!(iids.contains("101"));
    assert_eq!(issues_fetched, 2);
    assert_eq!(mrs_fetched, 1);
    assert_eq!(issues_ingested, 1);
    assert_eq!(mrs_ingested, 1);
    assert_eq!(status, "succeeded");
}

#[test]
fn surgical_lifecycle_cancel() {
    let conn = setup_test_db();
    let recorder = SyncRunRecorder::start(&conn, "sync", "cancel01").unwrap();
    let row_id = recorder.row_id();

    recorder.set_surgical_metadata(&conn, "surgical", "preflight", "{}").unwrap();
    recorder.cancel(&conn, "User requested cancellation").unwrap();

    let (status, error, cancelled_at, finished_at): (String, Option<String>, Option<i64>, Option<i64>) = conn.query_row(
        "SELECT status, error, cancelled_at, finished_at FROM sync_runs WHERE id = ?1",
        [row_id],
        |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
    ).unwrap();

    assert_eq!(status, "cancelled");
    assert_eq!(error.as_deref(), Some("User requested cancellation"));
    assert!(cancelled_at.is_some());
    assert!(finished_at.is_some());
}

#[test]
fn record_entity_result_ignores_unknown() {
    let conn = setup_test_db();
    let recorder = SyncRunRecorder::start(&conn, "sync", "unk001").unwrap();
    // Should not panic or error on unknown combinations
    recorder.record_entity_result(&conn, "widget", "exploded").unwrap();
}

#[test]
fn record_entity_result_json_counters() {
    let conn = setup_test_db();
    let recorder = SyncRunRecorder::start(&conn, "sync", "cnt001").unwrap();
    let row_id = recorder.row_id();

    recorder.record_entity_result(&conn, "doc", "regenerated").unwrap();
    recorder.record_entity_result(&conn, "doc", "regenerated").unwrap();
    recorder.record_entity_result(&conn, "doc", "embedded").unwrap();
    recorder.record_entity_result(&conn, "issue", "skipped_stale").unwrap();

    let (docs_regen, docs_embed, skipped): (i64, i64, i64) = conn.query_row(
        "SELECT docs_regenerated, docs_embedded, skipped_stale FROM sync_runs WHERE id = ?1",
        [row_id],
        |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
    ).unwrap();

    assert_eq!(docs_regen, 2);
    assert_eq!(docs_embed, 1);
    assert_eq!(skipped, 1);
}
```

GREEN: Add all methods to `SyncRunRecorder`.
VERIFY: `cargo test surgical_lifecycle && cargo test record_entity_result`

## Edge Cases
- `succeed()` and `fail()` consume `self` — the compiler enforces that no methods are called after finalization. `cancel()` also consumes self for the same reason.
- `set_surgical_metadata`, `update_phase`, and `record_entity_result` take `&self` — they can be called multiple times before finalization.
- The `record_entity_result` match uses a hardcoded column name derived from known string constants, not user input. The `format!` is safe because the column name is always one of the hardcoded strings.
- `record_entity_result` silently returns Ok(()) for unknown entity_type/stage combos rather than erroring — this avoids breaking the pipeline for non-critical telemetry.
- Phase is NOT overwritten by `succeed()`/`fail()`/`cancel()` — the last phase set via `update_phase()` is preserved as the "phase at completion" for observability.

## Dependency Context
Depends on bd-tiux (migration 027) for the surgical columns to exist. Downstream beads bd-1i4i (orchestrator) and bd-3jqx (integration tests) use these methods.


WHO: Overlap mode query (query_overlap)

## Background

Overlap mode answers "Who else has MRs/notes touching my files?" — helps identify potential reviewers, collaborators, or conflicting work at a path. Tracks author and reviewer roles separately for richer signal.

## Approach

### SQL: two static variants (prefix/exact) with reviewer + author UNION ALL

Both branches return: username, role, touch_count (COUNT DISTINCT m.id), last_seen_at, mr_refs (GROUP_CONCAT of project-qualified refs).

Key differences from Expert:
- No scoring formula — just touch_count ranking
- mr_refs collected for actionable output (group/project!iid format)
- Rust-side merge needed (can't fully aggregate in SQL due to HashSet dedup of mr_refs across branches)

### Reviewer branch includes:
- Self-review exclusion: `n.author_username != m.author_username`
- MR state filter: `m.state IN ('opened','merged')`
- Project-qualified refs: `GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid))`

### Rust accumulator pattern:
```rust
struct OverlapAcc {
    username: String,
    author_touch_count: u32,
    review_touch_count: u32,
    touch_count: u32,
    last_seen_at: i64,
    mr_refs: HashSet<String>,  // O(1) dedup from the start
}
// Build HashMap<String, OverlapAcc> from rows
// Convert to Vec, sort, bound mr_refs
```

### Bounded mr_refs:
```rust
const MAX_MR_REFS_PER_USER: usize = 50;
let mr_refs_total = mr_refs.len() as u32;
let mr_refs_truncated = mr_refs.len() > MAX_MR_REFS_PER_USER;
```

### Deterministic sort: touch_count DESC, last_seen_at DESC, username ASC

### format_overlap_role():
```rust
fn format_overlap_role(user: &OverlapUser) -> &'static str {
    match (user.author_touch_count > 0, user.review_touch_count > 0) {
        (true, true) => "A+R", (true, false) => "A",
        (false, true) => "R", (false, false) => "-",
    }
}
```

### OverlapResult/OverlapUser structs include path_match ("exact"/"prefix"), truncated bool, per-user mr_refs_total + mr_refs_truncated

## Files

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

## TDD Loop

RED:
```
test_overlap_dual_roles — user is author of MR 1 and reviewer of MR 2 at same path; verify A+R role, both touch counts > 0, mr_refs contain "team/backend!"
test_overlap_multi_project_mr_refs — same iid 100 in two projects; verify both "team/backend!100" and "team/frontend!100" present
test_overlap_excludes_self_review_notes — author comments on own MR; review_touch_count must be 0
```

GREEN: Implement query_overlap with both SQL variants + accumulator
VERIFY: `cargo test -- overlap`

## Acceptance Criteria

- [ ] test_overlap_dual_roles passes (A+R role detection)
- [ ] test_overlap_multi_project_mr_refs passes (project-qualified refs unique)
- [ ] test_overlap_excludes_self_review_notes passes
- [ ] Default since window: 30d
- [ ] mr_refs sorted alphabetically for deterministic output
- [ ] touch_count uses coherent units (COUNT DISTINCT m.id on BOTH branches)

## Edge Cases

- Both branches count MRs (not DiffNotes) for coherent touch_count — mixing units produces misleading totals
- mr_refs from GROUP_CONCAT may contain duplicates across branches — HashSet handles dedup
- Project scoping on n.project_id (not m.project_id) for index alignment
- mr_refs sorted before output (HashSet iteration is nondeterministic)

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


Implement filter DSL parser state machine

## Background

The Issue List and MR List filter bars accept typed filter expressions (e.g., `state:opened author:@asmith label:"high priority" -milestone:v2.0`). The PRD Appendix B defines a full state machine: Inactive -> Active -> FieldSelect/FreeText -> ValueInput. The parser needs to handle field:value pairs, negation prefix (`-`), quoted values with spaces, bare text as free-text search, and inline error diagnostics when an unrecognized field name is typed. This is a substantial subsystem that the entity table filter bar widget (bd-18qs) depends on for its core functionality.

## Approach

Create a `filter_dsl.rs` module with:

1. **FilterToken enum** — `Field { name: String, value: String, negated: bool }` | `FreeText(String)` | `Error { position: usize, message: String }`
2. **`parse_filter(input: &str) -> Vec<FilterToken>`** — Tokenizer that handles:
   - `field:value` — recognized fields: state, author, assignee, label, milestone, since, project (issue); + reviewer, draft, target, source (MR)
   - `-field:value` — negation prefix strips the `-` and sets `negated: true`
   - `field:"quoted value"` — double-quoted values preserve spaces
   - bare words — collected as `FreeText` tokens
   - unrecognized field names — produce `Error` token with position and message
3. **FilterBarState** state machine:
   - `Inactive` — filter bar not focused
   - `Active(Typing)` — user typing, no suggestion yet
   - `Active(Suggesting)` — 200ms pause triggers field name suggestions
   - `FieldSelect` — dropdown showing recognized field names after `:`
   - `ValueInput` — context-dependent completions (e.g., state values: opened/closed/all)
4. **`apply_issue_filter(tokens: &[FilterToken]) -> IssueFilterParams`** — converts tokens to query parameters
5. **`apply_mr_filter(tokens: &[FilterToken]) -> MrFilterParams`** — MR variant with reviewer, draft, target/source fields

## Acceptance Criteria
- [ ] `parse_filter("state:opened")` returns one Field token with name="state", value="opened", negated=false
- [ ] `parse_filter("-label:bug")` returns one Field with negated=true
- [ ] `parse_filter('author:"Jane Doe"')` returns one Field with value="Jane Doe" (quotes stripped)
- [ ] `parse_filter("foo:bar")` where "foo" is not a recognized field returns Error token with position
- [ ] `parse_filter("state:opened some text")` returns Field + FreeText tokens
- [ ] `parse_filter("")` returns empty vec
- [ ] FilterBarState transitions match the Appendix B state machine diagram
- [ ] apply_issue_filter correctly maps all 7 issue fields (state, author, assignee, label, milestone, since, project)
- [ ] apply_mr_filter correctly maps additional MR fields (reviewer, draft, target, source)
- [ ] Inline error diagnostics include the character position of the unrecognized field

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

## TDD Anchor
RED: Write `test_parse_simple_field_value` that asserts `parse_filter("state:opened")` returns `[Field { name: "state", value: "opened", negated: false }]`.
GREEN: Implement the tokenizer for the simplest case.
VERIFY: cargo test -p lore-tui parse_simple

Additional tests:
- test_parse_negation
- test_parse_quoted_value
- test_parse_unrecognized_field_produces_error
- test_parse_mixed_tokens
- test_parse_empty_input
- test_apply_issue_filter_maps_all_fields
- test_apply_mr_filter_maps_additional_fields
- test_filter_bar_state_transitions

## Edge Cases
- Unclosed quote (`author:"Jane`) — treat rest of input as the value, produce warning token
- Empty value (`state:`) — produce Error token, not a Field with empty value
- Multiple colons (`field:val:ue`) — first colon splits, rest is part of value
- Unicode in field values (`author:@rene`) — must handle multi-byte chars correctly
- Very long filter strings (>1000 chars) — must not allocate unbounded; truncate with error

## Dependency Context
- Depends on bd-18qs (entity table + filter bar widgets) which provides the TextInput widget and filter bar rendering. This bead provides the PARSER that bd-18qs's filter bar CALLS.
- Consumed by bd-3ei1 (Issue List) and bd-2kr0 (MR List) for converting user filter input into query parameters.

Already implemented in filter_dsl.rs with parser, field validation, negation, quoted values, free-text, and 11 tests.


Implement vector search function

## Background
Vector search queries the sqlite-vec virtual table for nearest-neighbor documents. Because documents may have multiple chunks, the raw KNN results need deduplication by document_id (keeping the best/lowest distance per document). The function over-fetches 3x to ensure enough unique documents after dedup.

## Approach
Create `src/search/vector.rs`:

```rust
pub struct VectorResult {
    pub document_id: i64,
    pub distance: f64,  // Lower = closer match
}

/// Search documents using sqlite-vec KNN query.
/// Over-fetches 3x limit to handle chunk dedup.
pub fn search_vector(
    conn: &Connection,
    query_embedding: &[f32],  // 768-dim embedding of search query
    limit: usize,
) -> Result<Vec<VectorResult>>
```

**SQL (KNN query):**
```sql
SELECT rowid, distance
FROM embeddings
WHERE embedding MATCH ?
  AND k = ?
ORDER BY distance
```

**Algorithm:**
1. Convert query_embedding to raw LE bytes
2. Execute KNN with k = limit * 3 (over-fetch for dedup)
3. Decode each rowid via decode_rowid() -> (document_id, chunk_index)
4. Group by document_id, keep minimum distance (best chunk)
5. Sort by distance ascending
6. Take first `limit` results

## Acceptance Criteria
- [ ] Returns deduplicated document-level results (not chunk-level)
- [ ] Best chunk distance kept per document (lowest distance wins)
- [ ] KNN with k parameter (3x limit)
- [ ] Query embedding passed as raw LE bytes
- [ ] Results sorted by distance ascending (closest first)
- [ ] Returns at most `limit` results
- [ ] Empty embeddings table returns empty Vec
- [ ] `cargo build` succeeds

## Files
- `src/search/vector.rs` — new file
- `src/search/mod.rs` — add `pub use vector::{search_vector, VectorResult};`

## TDD Loop
RED: Integration tests need sqlite-vec + seeded embeddings:
- `test_vector_search_basic` — finds nearest document
- `test_vector_search_dedup` — multi-chunk doc returns once with best distance
- `test_vector_search_empty` — empty table returns empty
- `test_vector_search_limit` — respects limit parameter
GREEN: Implement search_vector
VERIFY: `cargo test vector`

## Edge Cases
- All chunks belong to same document: returns single result
- Query embedding wrong dimension: sqlite-vec may error — handle gracefully
- Over-fetch returns fewer than limit unique docs: return what we have
- Distance = 0.0: exact match (valid result)

Implemented search_vector with KNN query, 3x over-fetch, chunk dedup. 3 tests pass.


Implement core types (Msg, Screen, EntityKey, AppError, InputMode)

## Background
The core types form the message-passing backbone of the Elm Architecture. Every user action and async result flows through the Msg enum. Screen identifies navigation targets. EntityKey provides safe cross-project entity identity. AppError enables structured error display. InputMode controls key dispatch routing.

## Approach
Create crates/lore-tui/src/message.rs with:
- Msg enum (~40 variants): RawEvent, Tick, Resize, NavigateTo, GoBack, GoForward, GoHome, JumpBack, JumpForward, OpenCommandPalette, CloseCommandPalette, CommandPaletteInput, CommandPaletteSelect, IssueListLoaded{generation, rows}, IssueListFilterChanged, IssueListSortChanged, IssueSelected, MrListLoaded{generation, rows}, MrListFilterChanged, MrSelected, IssueDetailLoaded{generation, key, detail}, MrDetailLoaded{generation, key, detail}, DiscussionsLoaded{generation, discussions}, SearchQueryChanged, SearchRequestStarted{generation, query}, SearchExecuted{generation, results}, SearchResultSelected, SearchModeChanged, SearchCapabilitiesLoaded, TimelineLoaded, TimelineEntitySelected, WhoResultLoaded, WhoModeChanged, SyncStarted, SyncProgress, SyncProgressBatch, SyncLogLine, SyncBackpressureDrop, SyncCompleted, SyncCancelled, SyncFailed, SyncStreamStats, SearchDebounceArmed, SearchDebounceFired, DashboardLoaded, Error, ShowHelp, ShowCliEquivalent, OpenInBrowser, BlurTextInput, ScrollToTopCurrentScreen, Quit
- impl From<Event> for Msg (FrankenTUI requirement) — maps Resize, Tick, and wraps everything else in RawEvent
- Screen enum: Dashboard, IssueList, IssueDetail(EntityKey), MrList, MrDetail(EntityKey), Search, Timeline, Who, Sync, Stats, Doctor, Bootstrap
- Screen::label() -> &str and Screen::is_detail_or_entity() -> bool
- EntityKey { project_id: i64, iid: i64, kind: EntityKind } with EntityKey::issue() and EntityKey::mr() constructors
- EntityKind enum: Issue, MergeRequest
- AppError enum: DbBusy, DbCorruption(String), NetworkRateLimited{retry_after_secs}, NetworkUnavailable, AuthFailed, ParseError(String), Internal(String) with Display impl
- InputMode enum: Normal, Text, Palette, GoPrefix{started_at: Instant} with Default -> Normal

## Acceptance Criteria
- [ ] Msg enum compiles with all ~40 variants
- [ ] From<Event> impl converts Resize->Msg::Resize, Tick->Msg::Tick, other->Msg::RawEvent
- [ ] Screen enum has all 12 variants with label() and is_detail_or_entity() methods
- [ ] EntityKey::issue() and EntityKey::mr() constructors work correctly
- [ ] EntityKey derives Debug, Clone, PartialEq, Eq, Hash
- [ ] AppError Display shows user-friendly messages for each variant
- [ ] InputMode defaults to Normal

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

## TDD Anchor
RED: Write test_entity_key_equality that asserts EntityKey::issue(1, 42) == EntityKey::issue(1, 42) and EntityKey::issue(1, 42) != EntityKey::mr(1, 42).
GREEN: Implement EntityKey with derives.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_entity_key

## Edge Cases
- Generation fields (u64) in Msg variants are critical for stale result detection — must be present on all async result variants
- EntityKey equality must include both project_id AND iid AND kind — bare iid is unsafe with multi-project datasets
- AppError::NetworkRateLimited retry_after_secs is Option<u64> — GitLab may not provide Retry-After header

All core types implemented with 11 passing tests: Msg (~40 variants with From<Event>), Screen (12 variants with label/is_detail_or_entity), EntityKey (project_id+iid+kind with Hash), AppError (Display impl), InputMode (Default=Normal). Clippy clean.


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

Add required dependencies for async pagination streams.

## Changes
Add to Cargo.toml:
- async-stream = "0.3"
- futures = "0.3"

## Why
The pagination methods use async generators which require async-stream crate.
futures crate provides StreamExt for consuming the streams.

## Done When
- cargo check passes with new deps
- No unused dependency warnings

Files: Cargo.toml


[CP1] Integration tests for label linkage

Integration tests verifying label linkage and stale removal.

## Tests (tests/label_linkage_tests.rs)

- clears_existing_labels_before_linking_new_set
- removes_stale_label_links_on_issue_update
- handles_issue_with_all_labels_removed
- preserves_labels_that_still_exist

## Test Scenario
1. Create issue with labels [A, B]
2. Verify issue_labels has links to A and B
3. Update issue with labels [B, C]
4. Verify A link removed, B preserved, C added

## Why This Matters
The clear-and-relink pattern ensures GitLab reality is reflected locally.
If we only INSERT, removed labels would persist incorrectly.

Files: tests/label_linkage_tests.rs
Done when: Stale label links correctly removed on resync


Add entity_references table to migration 010

## Background
The entity_references table is now part of migration 011 (combined with resource event tables and dependent fetch queue). This bead is satisfied by bd-hu3 since the entity_references table schema is included in the same migration.

## Approach
This bead's work is folded into bd-hu3 (Write migration 011). The entity_references table from Phase B spec §2.2 is included in migrations/011_resource_events.sql alongside the event tables and queue.

The entity_references schema includes:
- source/target entity type + id with reference_type and source_method
- Unresolved reference support (target_entity_id NULL with target_project_path + target_entity_iid)
- UNIQUE constraint using COALESCE for nullable columns
- Partial indexes for source, target (where not null), and unresolved refs

No separate migration file needed — this is in 011.

## Acceptance Criteria
- [ ] entity_references table exists in migration 011 (verified by bd-hu3)
- [ ] UNIQUE constraint handles NULL columns via COALESCE
- [ ] Indexes created: source composite, target composite (partial), unresolved (partial)
- [ ] reference_type CHECK includes 'closes', 'mentioned', 'related'
- [ ] source_method CHECK includes 'api_closes_issues', 'api_state_event', 'system_note_parse'

## Files
- migrations/011_resource_events.sql (part of bd-hu3)

## TDD Loop
Covered by bd-hu3's test_migration_011_entity_references_dedup test.

VERIFY: `cargo test migration_tests -- --nocapture`

## Edge Cases
- Same as bd-hu3's entity_references edge cases

Work folded into bd-hu3 (migration 011 includes entity_references table)


Implement timeline robot mode JSON output

## Background

Robot mode JSON for timeline follows the {ok, data, meta} envelope pattern. The JSON schema MUST match spec Section 3.5 exactly — this is the contract for AI agent consumers.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.5 (Robot Mode JSON).

## Codebase Context

- Robot mode pattern: all commands use {ok: true, data: {...}, meta: {...}} envelope
- Timestamps: internal ms epoch UTC -> output ISO 8601 via core::time::ms_to_iso()
- source_method values in DB: 'api', 'note_parse', 'description_parse' (NOT spec's api_closes_issues etc.)
- Serde rename: use #[serde(rename = "type")] for entity objects per spec

## Approach

Create `print_timeline_json()` in `src/cli/commands/timeline.rs`:

### Key JSON structure (spec Section 3.5):
- data.seed_entities: [{type, iid, project}] — note "type" not "entity_type", "project" not "project_path"
- data.expanded_entities: [{type, iid, project, depth, via: {from: {type,iid,project}, reference_type, source_method}}]
- data.unresolved_references: [{source: {type,iid,project}, target_project, target_type, target_iid, reference_type}]
- data.events: [{timestamp (ISO 8601), entity_type, entity_iid, project, event_type, summary, actor, url, is_seed, details}]
- meta: {search_mode: "lexical", expansion_depth, expand_mentions, total_entities, total_events, evidence_notes_included, unresolved_references, showing}

### Details object per event type:
- created: {labels: [...]}
- note_evidence: {note_id, snippet}
- state_changed: {state}
- label_added: {label}

### Rust JSON Structs

```rust
#[derive(Serialize)]
struct TimelineJson {
    ok: bool,
    data: TimelineDataJson,
    meta: TimelineMetaJson,
}

#[derive(Serialize)]
struct TimelineDataJson {
    query: String,
    event_count: usize,
    seed_entities: Vec<EntityJson>,
    expanded_entities: Vec<ExpandedEntityJson>,
    unresolved_references: Vec<UnresolvedRefJson>,
    events: Vec<EventJson>,
}

#[derive(Serialize)]
struct EntityJson {
    #[serde(rename = "type")]
    entity_type: String,
    iid: i64,
    project: String,
}

#[derive(Serialize)]
struct TimelineMetaJson {
    search_mode: String,        // always "lexical"
    expansion_depth: u32,
    expand_mentions: bool,
    total_entities: usize,
    total_events: usize,        // before limit
    evidence_notes_included: usize,
    unresolved_references: usize,
    showing: usize,             // after limit
}
```

### source_method values: use CODEBASE values (api/note_parse/description_parse), not spec values

## Acceptance Criteria

- [ ] Valid JSON to stdout
- [ ] {ok, data, meta} envelope
- [ ] ISO 8601 timestamps
- [ ] Entity objects use "type" and "project" keys per spec
- [ ] Nested "via" object on expanded entities per spec
- [ ] Events include url and details fields
- [ ] meta.total_events before limit; meta.showing after limit
- [ ] source_method uses codebase values
- [ ] `cargo check --all-targets` passes

## Files

- `src/cli/commands/timeline.rs` (add print_timeline_json + JSON structs)
- `src/cli/commands/mod.rs` (re-export)

## TDD Loop

Verify: `lore --robot timeline "test" | jq '.data.expanded_entities[0].via.from'`

## Edge Cases

- Empty results: events=[], meta.showing=0
- Null actor/url: serialize as null (not omitted)
- source_method: use actual DB values, not spec originals

Implemented print_timeline_json_with_meta() robot JSON output in src/cli/commands/timeline.rs with {ok,data,meta} envelope, ISO timestamps, entity/expanded/unresolved JSON structs, event details per type


NOTE-2B: SourceType enum extension for notes

## Background
The SourceType enum in src/documents/extractor.rs (line 15-19) needs a Note variant for the document pipeline to handle note-type documents.

## Approach
In src/documents/extractor.rs:
1. Add Note variant to SourceType enum (line 15-19, after Discussion):
   pub enum SourceType { Issue, MergeRequest, Discussion, Note }

2. Add match arm to as_str() (line 22-28): Self::Note => "note"

3. Add parse aliases (line 30-37): "note" | "notes" => Some(Self::Note)

4. Display impl (line 40-43) already delegates to as_str() — no change needed.

5. IMPORTANT: Also update seed_dirty() in src/cli/commands/generate_docs.rs (line 66-70) which has a match on SourceType that maps to table names. SourceType::Note should NOT be added to this match — notes are seeded differently (by querying the notes table, not by table name pattern). This is handled by NOTE-2E.

## Files
- MODIFY: src/documents/extractor.rs (SourceType enum at line 15, as_str at line 22, parse at line 30)

## TDD Anchor
RED: test_source_type_parse_note — assert SourceType::parse("note") == Some(SourceType::Note)
GREEN: Add Note variant and match arms.
VERIFY: cargo test source_type_parse_note -- --nocapture
Tests: test_source_type_note_as_str (assert as_str() == "note"), test_source_type_note_display (assert format!("{}", SourceType::Note) == "note"), test_source_type_parse_notes_alias (assert parse("notes") works)

## Acceptance Criteria
- [ ] SourceType::Note variant exists
- [ ] as_str() returns "note"
- [ ] parse() accepts "note", "notes" (case-insensitive via to_lowercase)
- [ ] Display trait works via as_str delegation
- [ ] No change to seed_dirty() match — that's a separate bead (NOTE-2E)
- [ ] All 4 tests pass, clippy clean
- [ ] CRITICAL: regenerate_one() in src/documents/regenerator.rs (line 86-91) has exhaustive match on SourceType — adding Note variant will cause a compile error until NOTE-2D adds the match arm. Either add a temporary todo!() or coordinate with NOTE-2D.

## Dependency Context
- Depends on NOTE-2A (bd-1oi7): migration 024 must exist so test DBs accept source_type='note' in documents/dirty_sources tables

## Edge Cases
- Exhaustive match: Adding the variant breaks regenerate_one() (line 86-91) and seed_dirty() (line 66-70) until downstream beads handle it. Agent should add temporary unreachable!() arms with comments referencing the downstream bead IDs.


[CP0] Config loading with Zod validation

## Background

Config loading is critical infrastructure - every CLI command needs the config. Uses Zod for schema validation with sensible defaults. Must handle missing files gracefully with typed errors.

Reference: docs/prd/checkpoint-0.md sections "Configuration Schema", "Config Resolution Order"

## Approach

**src/core/config.ts:**
```typescript
import { z } from 'zod';
import { readFileSync } from 'node:fs';
import { ConfigNotFoundError, ConfigValidationError } from './errors';
import { getConfigPath } from './paths';

export const ConfigSchema = z.object({
  gitlab: z.object({
    baseUrl: z.string().url(),
    tokenEnvVar: z.string().default('GITLAB_TOKEN'),
  }),
  projects: z.array(z.object({
    path: z.string().min(1),
  })).min(1),
  sync: z.object({
    backfillDays: z.number().int().positive().default(14),
    staleLockMinutes: z.number().int().positive().default(10),
    heartbeatIntervalSeconds: z.number().int().positive().default(30),
    cursorRewindSeconds: z.number().int().nonnegative().default(2),
    primaryConcurrency: z.number().int().positive().default(4),
    dependentConcurrency: z.number().int().positive().default(2),
  }).default({}),
  storage: z.object({
    dbPath: z.string().optional(),
    backupDir: z.string().optional(),
    compressRawPayloads: z.boolean().default(true),
  }).default({}),
  embedding: z.object({
    provider: z.literal('ollama').default('ollama'),
    model: z.string().default('nomic-embed-text'),
    baseUrl: z.string().url().default('http://localhost:11434'),
    concurrency: z.number().int().positive().default(4),
  }).default({}),
});

export type Config = z.infer<typeof ConfigSchema>;

export function loadConfig(cliOverride?: string): Config {
  const path = getConfigPath(cliOverride);
  // throws ConfigNotFoundError if missing
  // throws ConfigValidationError if invalid
}
```

## Acceptance Criteria

- [ ] `loadConfig()` returns validated Config object
- [ ] `loadConfig()` throws ConfigNotFoundError if file missing
- [ ] `loadConfig()` throws ConfigValidationError with Zod errors if invalid
- [ ] Empty optional fields get default values
- [ ] projects array must have at least 1 item
- [ ] gitlab.baseUrl must be valid URL
- [ ] All number fields must be positive integers
- [ ] tests/unit/config.test.ts passes (8 tests)

## Files

CREATE:
- src/core/config.ts
- tests/unit/config.test.ts
- tests/fixtures/mock-responses/valid-config.json
- tests/fixtures/mock-responses/invalid-config.json

## TDD Loop

RED:
```typescript
// tests/unit/config.test.ts
describe('Config', () => {
  it('loads config from file path')
  it('throws ConfigNotFoundError if file missing')
  it('throws ConfigValidationError if required fields missing')
  it('validates project paths are non-empty strings')
  it('applies default values for optional fields')
  it('loads from XDG path by default')
  it('respects GI_CONFIG_PATH override')
  it('respects --config flag override')
})
```

GREEN: Implement loadConfig() function

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

## Edge Cases

- JSON parse error should wrap in ConfigValidationError
- Zod error messages should be human-readable
- File exists but empty → ConfigValidationError
- File has extra fields → should pass (Zod strips by default)


Interactive path selection for ambiguous matches (TTY picker)

When a partial file path matches multiple files, show an interactive numbered picker in TTY mode instead of a hard error. In robot mode, return candidates as structured JSON in the error envelope. Use dialoguer crate for selection UI. The path_resolver module already detects ambiguity via SuffixResult::Ambiguous and limits to 11 candidates.