Implement MR document extraction

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

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

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

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

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

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

--- Description ---

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

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

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

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

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

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

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


OBSERV: Enrich robot JSON meta with run_id and stages

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

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

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

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

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

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

Same pattern for standalone ingest: add stages to IngestMeta.

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

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

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

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

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


Implement Search screen (state + action + view)

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

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

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

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

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

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

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

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

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

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

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

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

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


Display assignees, due_date, and milestone in lore issues <id> output

## Background
The `lore issues <id>` command displays issue details but omits key metadata that exists in the database: assignees, due dates, and milestones. Users need this information to understand issue context without opening GitLab.

**System fit**: This data is already ingested during issue sync (migration 005) but the show command never queries it.

## Approach

All changes in `src/cli/commands/show.rs`:

### 1. Update IssueRow struct (line ~119)
Add fields to internal row struct:
```rust
struct IssueRow {
    // ... existing 10 fields ...
    due_date: Option<String>,        // NEW
    milestone_title: Option<String>, // NEW
}
```

### 2. Update find_issue() SQL (line ~137)
Extend SELECT:
```sql
SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username,
       i.created_at, i.updated_at, i.web_url, p.path_with_namespace,
       i.due_date, i.milestone_title  -- ADD THESE
FROM issues i ...
```

Update row mapping to extract columns 10 and 11.

### 3. Add get_issue_assignees() (after get_issue_labels ~line 189)
```rust
fn get_issue_assignees(conn: &Connection, issue_id: i64) -> Result<Vec<String>> {
    let mut stmt = conn.prepare(
        "SELECT username FROM issue_assignees WHERE issue_id = ? ORDER BY username"
    )?;
    let assignees = stmt
        .query_map([issue_id], |row| row.get(0))?
        .collect::<std::result::Result<Vec<_>, _>>()?;
    Ok(assignees)
}
```

### 4. Update IssueDetail struct (line ~59)
```rust
pub struct IssueDetail {
    // ... existing 12 fields ...
    pub assignees: Vec<String>,      // NEW
    pub due_date: Option<String>,    // NEW
    pub milestone: Option<String>,   // NEW
}
```

### 5. Update IssueDetailJson struct (line ~770)
Add same 3 fields with identical types.

### 6. Update run_show_issue() (line ~89)
```rust
let assignees = get_issue_assignees(&conn, issue.id)?;
// In return struct:
assignees,
due_date: issue.due_date,
milestone: issue.milestone_title,
```

### 7. Update print_show_issue() (line ~533, after Author line ~548)
```rust
if !issue.assignees.is_empty() {
    println!("Assignee{}: {}",
        if issue.assignees.len() > 1 { "s" } else { "" },
        issue.assignees.iter().map(|a| format!("@{}", a)).collect::<Vec<_>>().join(", "));
}
if let Some(due) = &issue.due_date {
    println!("Due:       {}", due);
}
if let Some(ms) = &issue.milestone {
    println!("Milestone: {}", ms);
}
```

### 8. Update From<&IssueDetail> for IssueDetailJson (line ~799)
```rust
assignees: issue.assignees.clone(),
due_date: issue.due_date.clone(),
milestone: issue.milestone.clone(),
```

## Acceptance Criteria
- [ ] `cargo test test_get_issue_assignees` passes (3 tests)
- [ ] `lore issues <id>` shows Assignees line when assignees exist
- [ ] `lore issues <id>` shows Due line when due_date set
- [ ] `lore issues <id>` shows Milestone line when milestone set
- [ ] `lore -J issues <id>` includes assignees/due_date/milestone in JSON
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files
- `src/cli/commands/show.rs` - ALL changes

## TDD Loop

**RED** - Add tests to `src/cli/commands/show.rs` `#[cfg(test)] mod tests`:

```rust
use crate::core::db::{create_connection, run_migrations};
use std::path::Path;

fn setup_test_db() -> Connection {
    let conn = create_connection(Path::new(":memory:")).unwrap();
    run_migrations(&conn).unwrap();
    conn
}

#[test]
fn test_get_issue_assignees_empty() {
    let conn = setup_test_db();
    // seed project + issue with no assignees
    let result = get_issue_assignees(&conn, 1).unwrap();
    assert!(result.is_empty());
}

#[test]
fn test_get_issue_assignees_multiple_sorted() {
    let conn = setup_test_db();
    // seed with alice, bob
    let result = get_issue_assignees(&conn, 1).unwrap();
    assert_eq!(result, vec!["alice", "bob"]); // alphabetical
}

#[test]
fn test_get_issue_assignees_single() {
    let conn = setup_test_db();
    // seed with charlie only
    let result = get_issue_assignees(&conn, 1).unwrap();
    assert_eq!(result, vec!["charlie"]);
}
```

**GREEN** - Implement get_issue_assignees() and struct updates

**VERIFY**: `cargo test test_get_issue_assignees && cargo clippy --all-targets -- -D warnings`

## Edge Cases
- Empty assignees list -> don't print Assignees line
- NULL due_date -> don't print Due line
- NULL milestone_title -> don't print Milestone line
- Single vs multiple assignees -> "Assignee" vs "Assignees" grammar

Implemented: assignees, due_date, milestone now display in lore issues <id>. All 7 new tests pass.


[CP1] Issue ingestion module

## Background

The issue ingestion module fetches and stores issues with cursor-based incremental sync. It is the primary data ingestion component, establishing the pattern reused for MR ingestion in CP2. The module handles tuple-cursor semantics, raw payload storage, label extraction, and tracking which issues need discussion sync.

## Approach

### Module: src/ingestion/issues.rs

### Key Structs

```rust
#[derive(Debug, Default)]
pub struct IngestIssuesResult {
    pub fetched: usize,
    pub upserted: usize,
    pub labels_created: usize,
    pub issues_needing_discussion_sync: Vec<IssueForDiscussionSync>,
}

#[derive(Debug, Clone)]
pub struct IssueForDiscussionSync {
    pub local_issue_id: i64,
    pub iid: i64,
    pub updated_at: i64,  // ms epoch
}
```

### Main Function

```rust
pub async fn ingest_issues(
    conn: &Connection,
    client: &GitLabClient,
    config: &Config,
    project_id: i64,           // Local DB project ID
    gitlab_project_id: i64,
) -> Result<IngestIssuesResult>
```

### Logic (Step by Step)

1. **Get current cursor** from sync_cursors table:
```sql
SELECT updated_at_cursor, tie_breaker_id
FROM sync_cursors
WHERE project_id = ? AND resource_type = 'issues'
```

2. **Call pagination method** with cursor rewind:
```rust
let issues_stream = client.paginate_issues(
    gitlab_project_id,
    cursor.updated_at_cursor,
    config.sync.cursor_rewind_seconds,
);
```

3. **Apply local filtering** for tuple cursor semantics:
```rust
// Skip if issue.updated_at < cursor_updated_at
// Skip if issue.updated_at == cursor_updated_at AND issue.gitlab_id <= cursor_gitlab_id
fn passes_cursor_filter(issue: &GitLabIssue, cursor: &SyncCursor) -> bool {
    if issue.updated_at < cursor.updated_at_cursor {
        return false;
    }
    if issue.updated_at == cursor.updated_at_cursor
        && issue.gitlab_id <= cursor.tie_breaker_id {
        return false;
    }
    true
}
```

4. **For each issue passing filter**:
```rust
// Begin transaction (unchecked_transaction for rusqlite)
let tx = conn.unchecked_transaction()?;

// Store raw payload (compressed based on config)
let payload_id = store_raw_payload(&tx, &issue_json, config.storage.compress_raw_payloads)?;

// Transform and upsert issue
let issue_row = transform_issue(&issue)?;
upsert_issue(&tx, &issue_row, project_id, payload_id)?;
let local_issue_id = get_local_issue_id(&tx, project_id, issue.iid)?;

// Clear existing label links (stale removal!)
tx.execute("DELETE FROM issue_labels WHERE issue_id = ?", [local_issue_id])?;

// Extract and upsert labels
for label_name in &issue_row.label_names {
    let label_id = upsert_label(&tx, project_id, label_name)?;
    link_issue_label(&tx, local_issue_id, label_id)?;
}

tx.commit()?;
```

5. **Incremental cursor update** every 100 issues:
```rust
if batch_count % 100 == 0 {
    update_sync_cursor(conn, project_id, "issues", last_updated_at, last_gitlab_id)?;
}
```

6. **Final cursor update** after all issues processed

7. **Determine issues needing discussion sync**:
```sql
SELECT id, iid, updated_at
FROM issues
WHERE project_id = ?
  AND updated_at > COALESCE(discussions_synced_for_updated_at, 0)
```

### Helper Functions

```rust
fn store_raw_payload(conn, json: &Value, compress: bool) -> Result<i64>
fn upsert_issue(conn, issue: &IssueRow, project_id: i64, payload_id: i64) -> Result<()>
fn get_local_issue_id(conn, project_id: i64, iid: i64) -> Result<i64>
fn upsert_label(conn, project_id: i64, name: &str) -> Result<i64>
fn link_issue_label(conn, issue_id: i64, label_id: i64) -> Result<()>
fn update_sync_cursor(conn, project_id: i64, resource: &str, updated_at: i64, gitlab_id: i64) -> Result<()>
```

### Critical Invariant

Stale label links MUST be removed on resync. The "DELETE then INSERT" pattern ensures GitLab reality is reflected locally. If an issue had labels [A, B] and now has [A, C], the B link must be removed.

## Acceptance Criteria

- [ ] `ingest_issues` returns IngestIssuesResult with all counts
- [ ] Cursor fetched from sync_cursors at start
- [ ] Cursor rewind applied before API call
- [ ] Local filtering skips already-processed issues
- [ ] Each issue wrapped in transaction for atomicity
- [ ] Raw payload stored with correct compression
- [ ] Issue upserted (INSERT OR REPLACE pattern)
- [ ] Existing label links deleted before new links inserted
- [ ] Labels upserted (INSERT OR IGNORE by project+name)
- [ ] Cursor updated every 100 issues (crash recovery)
- [ ] Final cursor update after all issues
- [ ] issues_needing_discussion_sync populated correctly

## Files

- src/ingestion/mod.rs (add `pub mod issues;`)
- src/ingestion/issues.rs (create)

## TDD Loop

RED:
```rust
// tests/issue_ingestion_tests.rs
#[tokio::test] async fn ingests_issues_from_stream()
#[tokio::test] async fn applies_cursor_filter_correctly()
#[tokio::test] async fn updates_cursor_every_100_issues()
#[tokio::test] async fn stores_raw_payload_for_each_issue()
#[tokio::test] async fn upserts_issues_correctly()

// tests/label_linkage_tests.rs
#[tokio::test] async fn extracts_and_stores_labels()
#[tokio::test] async fn removes_stale_label_links_on_resync()
#[tokio::test] async fn handles_empty_labels_array()

// tests/discussion_eligibility_tests.rs
#[tokio::test] async fn identifies_issues_needing_discussion_sync()
#[tokio::test] async fn skips_issues_with_current_watermark()
```

GREEN: Implement ingest_issues with all helper functions

VERIFY: `cargo test issue_ingestion && cargo test label_linkage && cargo test discussion_eligibility`

## Edge Cases

- Empty issues stream - return result with all zeros
- Cursor at epoch 0 - fetch all issues (no filtering)
- Issue with no labels - empty Vec, no label links created
- Issue with 50+ labels - all should be linked
- Crash mid-batch - cursor at last 100-boundary, some issues re-fetched
- Label already exists - upsert via INSERT OR IGNORE
- Same issue fetched twice (due to rewind) - upsert handles it


Define TimelineEvent model and TimelineEventType enum

## Background

The TimelineEvent model is the foundational data type for Gate 3's timeline feature. All pipeline stages (seed, expand, collect, interleave) produce or consume TimelineEvents. This must be defined first because every downstream bead (bd-32q, bd-ypa, bd-3as, bd-dty, bd-2f2) depends on these types.

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

## Codebase Context

- Migration 011 created: resource_state_events, resource_label_events, resource_milestone_events, entity_references, pending_dependent_fetches
- source_method CHECK constraint: `'api' | 'note_parse' | 'description_parse'` (NOT spec's 'api_closes_issues' etc.)
- reference_type CHECK constraint: `'closes' | 'mentioned' | 'related'`
- LATEST_SCHEMA_VERSION = 14

## Approach

Create `src/core/timeline.rs` with the following types:

```rust
/// The core timeline event. All pipeline stages produce or consume these.
/// Spec ref: Section 3.3 "Event Model"
#[derive(Debug, Clone, Serialize)]
pub struct TimelineEvent {
    pub timestamp: i64,                    // ms epoch UTC
    pub entity_type: String,               // "issue" | "merge_request"
    pub entity_id: i64,                    // local DB id (internal, not in JSON output)
    pub entity_iid: i64,
    pub project_path: String,
    pub event_type: TimelineEventType,
    pub summary: String,                   // human-readable one-liner
    pub actor: Option<String>,             // username or None for system
    pub url: Option<String>,               // web URL for the event source
    pub is_seed: bool,                     // true if from seed phase, false if expanded
}

/// Per spec Section 3.3. Serde tagged enum for JSON output.
/// IMPORTANT: entity_type is String (not &'static str) because serde Serialize
/// requires owned types for struct fields when deriving.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum TimelineEventType {
    Created,
    StateChanged { state: String },        // spec: just the target state
    LabelAdded { label: String },
    LabelRemoved { label: String },
    MilestoneSet { milestone: String },
    MilestoneRemoved { milestone: String },
    Merged,                                // spec: unit variant
    NoteEvidence {
        note_id: i64,                      // spec: required
        snippet: String,                   // first ~200 chars of matching note body
        discussion_id: Option<i64>,        // spec: optional
    },
    CrossReferenced { target: String },    // compact target ref like "\!567" or "#234"
}

/// Internal entity reference used across pipeline stages.
#[derive(Debug, Clone, Serialize)]
pub struct EntityRef {
    pub entity_type: String,               // String not &'static str — needed for Serialize
    pub entity_id: i64,
    pub entity_iid: i64,
    pub project_path: String,
}

/// An entity discovered via BFS expansion.
/// Spec ref: Section 3.5 "expanded_entities" JSON structure.
#[derive(Debug, Clone, Serialize)]
pub struct ExpandedEntityRef {
    pub entity_ref: EntityRef,
    pub depth: u32,
    pub via_from: EntityRef,               // the entity that referenced this one
    pub via_reference_type: String,        // "closes", "mentioned", "related"
    pub via_source_method: String,         // "api", "note_parse", "description_parse"
}

/// Reference to an unsynced external entity.
/// Spec ref: Section 3.5 "unresolved_references" JSON structure.
#[derive(Debug, Clone, Serialize)]
pub struct UnresolvedRef {
    pub source: EntityRef,
    pub target_project: Option<String>,
    pub target_type: String,
    pub target_iid: i64,
    pub reference_type: String,
}

/// Complete result from the timeline pipeline.
#[derive(Debug, Clone, Serialize)]
pub struct TimelineResult {
    pub query: String,
    pub events: Vec<TimelineEvent>,
    pub seed_entities: Vec<EntityRef>,
    pub expanded_entities: Vec<ExpandedEntityRef>,
    pub unresolved_references: Vec<UnresolvedRef>,
}
```

Implement `Ord` on `TimelineEvent` for chronological sort: primary key `timestamp`, tiebreak by `entity_id` then event_type discriminant.

Also implement `PartialEq`, `Eq`, `PartialOrd` (required by Ord).

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

## Acceptance Criteria

- [ ] `src/core/timeline.rs` compiles with no warnings
- [ ] All struct fields use `String` not `&'static str` (required for `#[derive(Serialize)]`)
- [ ] `TimelineEventType` has exactly 9 variants matching spec Section 3.3
- [ ] `NoteEvidence` has `note_id: i64`, `snippet: String`, `discussion_id: Option<i64>`
- [ ] `ExpandedEntityRef.via_source_method` documents codebase values: api, note_parse, description_parse
- [ ] `Ord` impl sorts by (timestamp, entity_id, event_type discriminant)
- [ ] `PartialEq`, `Eq`, `PartialOrd` derived or implemented
- [ ] Module registered in `src/core/mod.rs`
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

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

## TDD Loop

RED: Create `src/core/timeline.rs` with `#[cfg(test)] mod tests`:
- `test_timeline_event_sort_by_timestamp` - events sort chronologically
- `test_timeline_event_sort_tiebreak` - same-timestamp events sort stably
- `test_timeline_event_type_serializes_tagged` - serde JSON uses `kind` tag
- `test_note_evidence_has_note_id` - note_id present in serialized output

GREEN: Implement the types and Ord trait.

VERIFY: `cargo test --lib -- timeline`

## Edge Cases

- Ord must be consistent and total for all valid TimelineEvent pairs
- NoteEvidence snippet truncated to 200 chars at construction, not in the type
- entity_type uses String to satisfy serde Serialize derive requirements
- url field: constructed from project_path + entity_type + iid; None for entities without web_url

Completed: Created src/core/timeline.rs with TimelineEvent, TimelineEventType (9 variants), EntityRef, ExpandedEntityRef, UnresolvedRef, TimelineResult. Ord impl sorts by (timestamp, entity_id, event_type discriminant). entity_id skipped in serde output. 6 tests pass. All quality gates pass.


Implement MR discussion ingestion module

## Background
MR discussion ingestion with critical atomicity guarantees. Parse notes BEFORE destructive DB operations to prevent data loss. Watermark ONLY advanced on full success.

## Approach
Create `src/ingestion/mr_discussions.rs` with:
1. `IngestMrDiscussionsResult` - Per-MR stats
2. `ingest_mr_discussions()` - Main function with atomicity guarantees
3. Upsert + sweep pattern for notes (not delete-all-then-insert)
4. Sync health telemetry for debugging failures

## Files
- `src/ingestion/mr_discussions.rs` - New module
- `tests/mr_discussion_ingestion_tests.rs` - Integration tests

## Acceptance Criteria
- [ ] `IngestMrDiscussionsResult` has: discussions_fetched, discussions_upserted, notes_upserted, notes_skipped_bad_timestamp, diffnotes_count, pagination_succeeded
- [ ] `ingest_mr_discussions()` returns `Result<IngestMrDiscussionsResult>`
- [ ] CRITICAL: Notes parsed BEFORE any DELETE operations
- [ ] CRITICAL: Watermark NOT advanced if `pagination_succeeded == false`
- [ ] CRITICAL: Watermark NOT advanced if any note parse fails
- [ ] Upsert + sweep pattern using `last_seen_at`
- [ ] Stale discussions/notes removed only on full success
- [ ] Selective raw payload storage (skip system notes without position)
- [ ] Sync health telemetry recorded on failure
- [ ] `does_not_advance_discussion_watermark_on_partial_failure` test passes
- [ ] `atomic_note_replacement_preserves_data_on_parse_failure` test passes

## TDD Loop
RED: `cargo test does_not_advance_watermark` -> test fails
GREEN: Add ingestion with atomicity guarantees
VERIFY: `cargo test mr_discussion_ingestion`

## Main Function
```rust
pub async fn ingest_mr_discussions(
    conn: &Connection,
    client: &GitLabClient,
    config: &Config,
    project_id: i64,
    gitlab_project_id: i64,
    mr_iid: i64,
    local_mr_id: i64,
    mr_updated_at: i64,
) -> Result<IngestMrDiscussionsResult>
```

## CRITICAL: Atomic Note Replacement
```rust
// Record sync start time for sweep
let run_seen_at = now_ms();

while let Some(discussion_result) = stream.next().await {
    let discussion = match discussion_result {
        Ok(d) => d,
        Err(e) => {
            result.pagination_succeeded = false;
            break; // Stop but don't advance watermark
        }
    };

    // CRITICAL: Parse BEFORE destructive operations
    let notes = match transform_notes_with_diff_position(&discussion, project_id) {
        Ok(notes) => notes,
        Err(e) => {
            warn!("Note transform failed; preserving existing notes");
            result.notes_skipped_bad_timestamp += discussion.notes.len();
            result.pagination_succeeded = false;
            continue; // Skip this discussion, don't delete existing
        }
    };

    // Only NOW start transaction (after parse succeeded)
    let tx = conn.unchecked_transaction()?;

    // Upsert discussion with run_seen_at
    // Upsert notes with run_seen_at (not delete-all)

    tx.commit()?;
}
```

## Stale Data Sweep (only on success)
```rust
if result.pagination_succeeded {
    // Sweep stale discussions
    conn.execute(
        "DELETE FROM discussions
         WHERE project_id = ? AND merge_request_id = ?
           AND last_seen_at < ?",
        params![project_id, local_mr_id, run_seen_at],
    )?;

    // Sweep stale notes
    conn.execute(
        "DELETE FROM notes
         WHERE discussion_id IN (
           SELECT id FROM discussions
           WHERE project_id = ? AND merge_request_id = ?
         )
         AND last_seen_at < ?",
        params![project_id, local_mr_id, run_seen_at],
    )?;
}
```

## Watermark Update (ONLY on success)
```rust
if result.pagination_succeeded {
    mark_discussions_synced(conn, local_mr_id, mr_updated_at)?;
    clear_sync_health_error(conn, local_mr_id)?;
} else {
    record_sync_health_error(conn, local_mr_id, "Pagination incomplete or parse failure")?;
    warn!("Watermark NOT advanced; will retry on next sync");
}
```

## Selective Payload Storage
```rust
// Only store payload for DiffNotes and non-system notes
let should_store_note_payload =
    !note.is_system() ||
    note.position_new_path().is_some() ||
    note.position_old_path().is_some();
```

## Integration Tests (CRITICAL)
```rust
#[tokio::test]
async fn does_not_advance_discussion_watermark_on_partial_failure() {
    // Setup: MR with updated_at > discussions_synced_for_updated_at
    // Mock: Page 1 returns OK, Page 2 returns 500
    // Assert: discussions_synced_for_updated_at unchanged
}

#[tokio::test]
async fn does_not_advance_discussion_watermark_on_note_parse_failure() {
    // Setup: Existing notes in DB
    // Mock: Discussion with note having invalid created_at
    // Assert: Original notes preserved, watermark unchanged
}

#[tokio::test]
async fn atomic_note_replacement_preserves_data_on_parse_failure() {
    // Setup: Discussion with 3 valid notes
    // Mock: Updated discussion where note 2 has bad timestamp
    // Assert: All 3 original notes still in DB
}
```

## Edge Cases
- HTTP error mid-pagination: preserve existing data, log error, no watermark advance
- Invalid note timestamp: skip discussion, preserve existing notes
- System notes without position: don't store raw payload (saves space)
- Empty discussion: still upsert discussion record, no notes

Implemented MR discussion ingestion module with full atomicity guarantees:
- IngestMrDiscussionsResult with all required fields
- parse-before-destructive pattern (transform notes before DB ops)
- Upsert + sweep pattern with last_seen_at timestamps
- Watermark advanced ONLY on full pagination success
- Selective payload storage (skip system notes without position)
- Sync health telemetry for failure debugging
- All 163 tests passing


NOTE-1A: Note query layer data types and filters

## Background
Phase 1 adds a lore notes command for direct SQL query over the notes table. This chunk implements the data structures, filter logic, and query function following existing patterns in src/cli/commands/list.rs. The existing file contains: IssueListRow/Json/Result (for issues), MrListRow/Json/Result (for MRs), ListFilters/MrListFilters, query_issues(), query_mrs().

## Approach
Add to src/cli/commands/list.rs (after the existing MR query code):

Data structures:
- NoteListRow: id, gitlab_id, author_username, body, note_type, is_system, created_at, updated_at, position_new_path, position_new_line, position_old_path, position_old_line, resolvable, resolved, resolved_by, noteable_type (from discussions.noteable_type), parent_iid (i64), parent_title, project_path
- NoteListRowJson: ISO timestamp variants (created_at_iso, updated_at_iso using ms_to_iso from crate::core::time) + #[derive(Serialize)]
- NoteListResult: notes: Vec<NoteListRow>, total_count: i64
- NoteListResultJson: notes: Vec<NoteListRowJson>, total_count: i64, showing: usize
- NoteListFilters: limit (usize), project (Option<String>), author (Option<String>), note_type (Option<String>), include_system (bool), for_issue_iid (Option<i64>), for_mr_iid (Option<i64>), note_id (Option<i64>), gitlab_note_id (Option<i64>), discussion_id (Option<String>), since (Option<String>), until (Option<String>), path (Option<String>), contains (Option<String>), resolution (Option<String>), sort (String), order (String)

Query function pub fn query_notes(conn: &Connection, filters: &NoteListFilters, config: &Config) -> Result<NoteListResult>:
- Time window: parse since/until relative to single anchored now_ms via parse_since (from crate::core::time). --until date = end-of-day (23:59:59.999). Validate since_ms <= until_ms.
- Core SQL: SELECT from notes n JOIN discussions d ON n.discussion_id = d.id JOIN projects p ON n.project_id = p.id LEFT JOIN issues i ON d.issue_id = i.id LEFT JOIN merge_requests m ON d.merge_request_id = m.id WHERE {dynamic_filters} ORDER BY {sort} {order}, n.id {order} LIMIT ?
- Filter mappings:
  - author: COLLATE NOCASE, strip leading @ (same pattern as existing list filters)
  - note_type: exact match
  - project: resolve_project(conn, project_str) from crate::core::project
  - since/until: n.created_at >= ?ms / n.created_at <= ?ms
  - path: trailing / = LIKE prefix match with escape_like (from crate::core::project), else exact match on position_new_path
  - contains: LIKE %term% COLLATE NOCASE on n.body with escape_like for %, _
  - resolution: "unresolved" → n.resolvable = 1 AND n.resolved = 0, "resolved" → n.resolvable = 1 AND n.resolved = 1, "any" → no filter
  - for_issue_iid/for_mr_iid: requires project_id context. Validation at query layer (return error if no project and no defaultProject), NOT as clap requires.
  - include_system: when false (default), add n.is_system = 0
  - note_id: exact match on n.id
  - gitlab_note_id: exact match on n.gitlab_id
  - discussion_id: exact match on d.gitlab_discussion_id
- Use dynamic WHERE clause building with params vector (same pattern as query_issues/query_mrs)

## Files
- MODIFY: src/cli/commands/list.rs (add NoteListRow, NoteListRowJson, NoteListResult, NoteListResultJson, NoteListFilters, query_notes)

## TDD Anchor
RED: test_query_notes_empty_db — setup DB with no notes, call query_notes, assert total_count == 0.
GREEN: Implement NoteListFilters + query_notes with basic SELECT.
VERIFY: cargo test query_notes_empty_db -- --nocapture
28 tests from PRD: test_query_notes_empty_db, test_query_notes_filter_author, test_query_notes_filter_author_strips_at, test_query_notes_filter_author_case_insensitive, test_query_notes_filter_note_type, test_query_notes_filter_project, test_query_notes_filter_since, test_query_notes_filter_until, test_query_notes_filter_since_and_until_combined, test_query_notes_invalid_time_window_rejected, test_query_notes_until_date_uses_end_of_day, test_query_notes_filter_contains, test_query_notes_filter_contains_case_insensitive, test_query_notes_filter_contains_escapes_like_wildcards, test_query_notes_filter_path, test_query_notes_filter_path_prefix, test_query_notes_filter_for_issue_requires_project, test_query_notes_filter_for_mr_requires_project, test_query_notes_filter_for_issue_uses_default_project, test_query_notes_filter_resolution_unresolved, test_query_notes_filter_resolution_resolved, test_query_notes_sort_created_desc, test_query_notes_sort_created_asc, test_query_notes_deterministic_tiebreak, test_query_notes_limit, test_query_notes_combined_filters, test_query_notes_filter_note_id_exact, test_query_notes_filter_gitlab_note_id_exact, test_query_notes_filter_discussion_id_exact, test_note_list_row_json_conversion

## Acceptance Criteria
- [ ] NoteListRow/Json/Result/Filters structs defined with all fields
- [ ] query_notes returns notes matching all filter combinations
- [ ] Author filter is case-insensitive and strips @ prefix
- [ ] Time window validates since <= until with clear error message including swap suggestion
- [ ] --until date uses end-of-day (23:59:59.999)
- [ ] Path filter: trailing / = prefix match with LIKE escape, otherwise exact
- [ ] Contains filter: case-insensitive body substring with LIKE escape for %, _
- [ ] for_issue_iid/for_mr_iid require project context (error if no --project and no defaultProject)
- [ ] Default: exclude system notes (is_system = 0). --include-system overrides.
- [ ] ORDER BY includes n.id tiebreaker for deterministic results
- [ ] All 28+ tests pass

## Edge Cases
- parse_until_with_anchor: YYYY-MM-DD --until returns end-of-day (not start-of-day)
- Inverted time window: --since 30d --until 90d → error message suggesting swap
- LIKE wildcards in --contains: % and _ escaped via escape_like (from crate::core::project)
- IID without project: error at query layer (not clap) to support defaultProject
- Discussion with NULL noteable_type: LEFT JOIN handles gracefully (parent_iid/title will be None)


Phase 0a: Extract signal handler to core/shutdown.rs

## What
Extract the 4 identical Ctrl+C signal handlers from the command handler code into a single function in core/shutdown.rs.

## Why
Reduces tokio surface area before the runtime swap. 4 spawn sites -> 1 function. The function body changes in Phase 3d (signal handler rewrite for asupersync), so centralizing first makes that change trivial.

## Rearchitecture Context (2026-03-06)
A major code reorganization moved command handler dispatch logic out of main.rs into physical files under src/app/ using include!() chains:
- src/main.rs (419 LOC, thin entry) -> include!("app/dispatch.rs") -> include!("handlers.rs"), include!("errors.rs"), include!("robot_docs.rs")
- All handler code is LOGICALLY still in main.rs scope but PHYSICALLY in src/app/handlers.rs (~1730 LOC)

## Current State
The 4 signal handler sites are in src/app/handlers.rs (physically), at approximately:
- Line ~214 (sync handler)
- Line ~1535 (ingest handler)
- Line ~1661 (surgical sync handler)
- Line ~1718 (embed handler)

Each handler is a tokio::spawn block that:
1. Awaits tokio::signal::ctrl_c()
2. Prints interrupt message
3. Calls signal.cancel()
4. Awaits second ctrl_c for force quit via process::exit(130)

## Implementation

New file: src/core/shutdown.rs
```rust
pub fn install_ctrl_c_handler(signal: ShutdownSignal) {
    tokio::spawn(async move {
        let _ = tokio::signal::ctrl_c().await;
        eprintln!("\nInterrupted, finishing current batch... (Ctrl+C again to force quit)");
        signal.cancel();
        let _ = tokio::signal::ctrl_c().await;
        std::process::exit(130);
    });
}
```

Replace all 4 sites in src/app/handlers.rs with: install_ctrl_c_handler(signal.clone());

## Files Changed
- src/core/shutdown.rs (NEW ~20 LOC)
- src/core/mod.rs (add pub mod shutdown)
- src/app/handlers.rs (replace 4 handler blocks with 1-line calls)

## Testing
- cargo check --all-targets
- cargo clippy --all-targets -- -D warnings
- Existing tests should pass unchanged

## Notes
- This is independently valuable cleanup regardless of whether the full migration happens
- The function signature will change in Phase 3d to accept &Cx instead of using tokio::spawn

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


Create migration 008_fts5.sql

## Background
FTS5 (Full-Text Search 5) provides the lexical search backbone for Gate A. The virtual table + triggers keep the FTS index in sync with the documents table automatically. This migration must be applied AFTER migration 007 (documents table exists). The trigger design handles NULL titles via COALESCE and only rebuilds the FTS entry when searchable text actually changes (not metadata-only updates).

## Approach
Create `migrations/008_fts5.sql` with the exact SQL from PRD Section 1.2:

1. **Virtual table:** `documents_fts` using FTS5 with porter stemmer, prefix indexes (2,3,4), external content backed by `documents` table
2. **Insert trigger:** `documents_ai` — inserts into FTS on document insert, uses COALESCE(title, '') for NULL safety
3. **Delete trigger:** `documents_ad` — removes from FTS on document delete using the FTS5 delete command syntax
4. **Update trigger:** `documents_au` — only fires when `title` or `content_text` changes (WHEN clause), performs delete-then-insert to update FTS

Register migration 8 in `src/core/db.rs` MIGRATIONS array.

**Critical detail:** The COALESCE is required because FTS5 external-content tables require exact value matching for delete operations. If NULL was inserted, the delete trigger couldn't match it (NULL != NULL in SQL).

## Acceptance Criteria
- [ ] `migrations/008_fts5.sql` file exists
- [ ] `documents_fts` virtual table created with `tokenize='porter unicode61'` and `prefix='2 3 4'`
- [ ] `content='documents'` and `content_rowid='id'` set (external content mode)
- [ ] Insert trigger `documents_ai` fires on document insert with COALESCE(title, '')
- [ ] Delete trigger `documents_ad` fires on document delete using FTS5 delete command
- [ ] Update trigger `documents_au` only fires when `old.title IS NOT new.title OR old.content_text != new.content_text`
- [ ] Prefix search works: query `auth*` matches "authentication"
- [ ] After bulk insert of N documents, `SELECT count(*) FROM documents_fts` returns N
- [ ] Schema version 8 recorded in schema_version table
- [ ] `cargo test migration_tests` passes

## Files
- `migrations/008_fts5.sql` — new file (copy exact SQL from PRD Section 1.2)
- `src/core/db.rs` — add migration 8 to MIGRATIONS array

## TDD Loop
RED: Register migration in db.rs, `cargo test migration_tests` fails (SQL file missing)
GREEN: Create `008_fts5.sql` with all triggers
VERIFY: `cargo test migration_tests && cargo build`

## Edge Cases
- Metadata-only updates (e.g., changing `updated_at` or `labels_hash`) must NOT trigger FTS rebuild — the WHEN clause prevents this
- NULL titles must use COALESCE to empty string in both insert and delete triggers
- The update trigger does delete+insert (not FTS5 'delete' + regular insert atomically) — this is the correct FTS5 pattern for content changes

Completed: migration 008_fts5.sql with FTS5 virtual table, 3 sync triggers (insert/delete/update with COALESCE NULL safety), prefix search, registered in db.rs, cargo build + tests pass


Epic: Time-Decay Expert Scoring Model

## Background

Replace flat-weight expertise scoring with exponential half-life decay, split reviewer signals (participated vs assigned-only), dual-path rename awareness, and new CLI flags (--as-of, --explain-score, --include-bots, --all-history).

**Plan document:** plans/time-decay-expert-scoring.md (iteration 6, target 8)
**Beads revision:** 3 (updated line numbers to match v0.7.0 codebase, fixed migration 022->026, fixed test count 27->31, added explicit callsite update scope to bd-13q8)

## Children (Execution Order)

### Layer 0 — Foundation (no deps)
- **bd-2w1p** — Add half-life fields and config validation to ScoringConfig
- **bd-1soz** — Add half_life_decay() pure function
- **bd-18dn** — Add normalize_query_path() pure function

### Layer 1 — Schema + Helpers (depends on Layer 0)
- **bd-2ao4** — Add migration 026 for dual-path and reviewer participation indexes (5 indexes)
- **bd-2yu5** — Add timestamp-aware test helpers (insert_mr_at, insert_diffnote_at, insert_file_change_with_old_path)
- **bd-1b50** — Update existing tests for new ScoringConfig fields (..Default::default())

### Layer 2 — SQL + Path Probes (depends on Layer 1)
- **bd-1hoq** — Restructure expert SQL with CTE-based dual-path matching (8 CTEs, mr_activity, parameterized ?5/?6)
- **bd-1h3f** — Add rename awareness to path resolution probes (build_path_query + suffix_probe)

### Layer 3 — Rust Aggregation (depends on Layer 2)
- **bd-13q8** — Implement Rust-side decay aggregation with reviewer split + update all 17 existing query_expert() callsites

### Layer 4 — CLI (depends on Layer 3)
- **bd-11mg** — Add CLI flags: --as-of, --explain-score, --include-bots, --all-history, path normalization

### Layer 5 — Verification (depends on Layer 4)
- **bd-1vti** — Run full test suite: 31 new tests + all existing tests, no regressions
- **bd-1j5o** — Quality gates, query plan check (6 index points), real-world validation

## Revision 3 Delta (from revision 2)
- **Migration number**: 022 -> 026 (latest existing is 025_note_dirty_backfill.sql)
- **Test count**: 27 -> 31 (correct tally: 2+3+2+1+2+13+8=31)
- **Line numbers**: All beads updated to match v0.7.0 codebase (ScoringConfig at 155, validate_scoring at 274, query_expert at 641, build_path_query at 467, suffix_probe at 596, run_who at 276, test helpers at 2469-2598, test_expert_scoring_weights at 3551)
- **bd-13q8 scope**: Now explicitly documents updating all 17 existing query_expert() callsites (1 production + 16 test) when changing signature from 7 to 10 params
- **bd-2yu5**: insert_file_change_with_old_path now has complete SQL implementation (was placeholder)

## Files Modified
- src/core/config.rs (ScoringConfig struct at line 155, validation at line 274)
- src/cli/commands/who.rs (decay function, normalize_query_path, SQL, aggregation, CLI flags, tests)
- src/core/db.rs (MIGRATIONS array — add ("026", ...) entry)
- CREATE: migrations/026_scoring_indexes.sql (5 new indexes)

## Acceptance Criteria
- [ ] All 31 new tests pass (across all child beads)
- [ ] All existing tests pass unchanged (decay ~1.0 at now_ms())
- [ ] cargo check + clippy + fmt clean
- [ ] ubs scan clean on modified files
- [ ] EXPLAIN QUERY PLAN shows 6 index usage points (manual verification)
- [ ] Real-world validation: who --path on known files shows recency discounting
- [ ] who --explain-score component breakdown sums to total
- [ ] who --as-of produces deterministic results across runs
- [ ] Assigned-only reviewers rank below participated reviewers
- [ ] Old file paths resolve and credit expertise after renames
- [ ] Path normalization: ./src//foo.rs resolves identically to src/foo.rs

## Edge Cases
- f64 NaN guard in half_life_decay (hl=0 -> 0.0)
- Deterministic f64 ordering via mr_id sort before summation
- Closed MR multiplier applied via state_mult in SQL (not Rust string match)
- Trivial notes (< reviewer_min_note_chars) classified as assigned-only
- Exclusive upper bound on --as-of prevents future event leakage
- Config upper bounds prevent absurd values (3650-day cap, 4096-char cap, NaN/Inf rejection)

Epic complete: time-decay expert scoring model implemented. 3-agent swarm, 12 tasks, 621 tests, all quality gates green, real-world validation passed.


[CP1] gi count issues/discussions/notes commands

Count entities in the database.

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

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

    #[arg(long, value_parser = ["issue", "mr"])]
    r#type: Option<String>,
}

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

## Implementation
- Simple COUNT(*) queries
- For notes, also count WHERE is_system = 1 for system note count
- Filter by noteable_type when --type specified

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


NOTE-2H: Backfill existing notes after upgrade (migration 025)

## Background
When a user upgrades to note document support, existing notes have no documents. Without backfill, only notes that change post-upgrade get documents. This migration seeds all existing non-system notes into dirty queue. Uses migration slot 025 (024 = note documents schema).

## Approach
Create migrations/025_note_dirty_backfill.sql:
INSERT INTO dirty_sources (source_type, source_id, queued_at)
SELECT 'note', n.id, CAST(strftime('%s', 'now') AS INTEGER) * 1000
FROM notes n
LEFT JOIN documents d ON d.source_type = 'note' AND d.source_id = n.id
WHERE n.is_system = 0 AND d.id IS NULL
ON CONFLICT(source_type, source_id) DO NOTHING;

Register as ("025", include_str!("../../migrations/025_note_dirty_backfill.sql")) in MIGRATIONS array in src/core/db.rs.
Data-only migration — no schema changes. Safe on empty DBs (no notes = no-op).

## Files
- CREATE: migrations/025_note_dirty_backfill.sql
- MODIFY: src/core/db.rs (add ("025", ...) to MIGRATIONS array)

## TDD Anchor
RED: test_migration_025_backfills_existing_notes — setup: run migrations through 024, insert 5 non-system + 2 system notes, run migration 025, assert 5 dirty entries with source_type='note'.
GREEN: Create migration with the INSERT...SELECT...ON CONFLICT statement.
VERIFY: cargo test migration_025 -- --nocapture
Tests: test_migration_025_idempotent_with_existing_documents (notes already having documents are skipped), test_migration_025_skips_notes_already_in_dirty_queue

## Acceptance Criteria
- [ ] Migration seeds non-system notes without documents into dirty queue
- [ ] System notes excluded (is_system = 0 filter)
- [ ] Notes already having documents excluded (LEFT JOIN + d.id IS NULL)
- [ ] Idempotent: re-running doesn't create duplicates (ON CONFLICT DO NOTHING)
- [ ] Notes already in dirty queue not duplicated
- [ ] All 3 tests pass

## Dependency Context
- Depends on NOTE-2A (bd-1oi7): dirty_sources must accept source_type='note' (migration 024 adds CHECK constraint). Must run after migration 024.

## Edge Cases
- Empty database (fresh install): no notes exist, migration is a no-op
- Database with only system notes: no entries queued
- Concurrent sync running: ON CONFLICT DO NOTHING handles race safely


OBSERV: Implement SyncRunRecorder lifecycle helper

## Background
The sync_runs table exists (migration 001) but NOTHING writes to it. SyncRunRecorder encapsulates the INSERT-on-start, UPDATE-on-finish lifecycle, fixing this bug and enabling sync history tracking.

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

```rust
use crate::core::metrics::StageTiming;
use crate::core::error::Result;
use rusqlite::Connection;

pub struct SyncRunRecorder {
    row_id: i64,
}

impl SyncRunRecorder {
    /// Insert a new sync_runs row with status='running'.
    pub fn start(conn: &Connection, command: &str, run_id: &str) -> Result<Self> {
        let now_ms = crate::core::time::now_ms();
        conn.execute(
            "INSERT INTO sync_runs (started_at, heartbeat_at, status, command, run_id)
             VALUES (?1, ?2, 'running', ?3, ?4)",
            rusqlite::params![now_ms, now_ms, command, run_id],
        )?;
        let row_id = conn.last_insert_rowid();
        Ok(Self { row_id })
    }

    /// Mark run as succeeded with full metrics.
    pub fn succeed(
        self,
        conn: &Connection,
        metrics: &[StageTiming],
        total_items: usize,
        total_errors: usize,
    ) -> Result<()> {
        let now_ms = crate::core::time::now_ms();
        let metrics_json = serde_json::to_string(metrics)
            .unwrap_or_else(|_| "[]".to_string());
        conn.execute(
            "UPDATE sync_runs
             SET finished_at = ?1, status = 'succeeded',
                 metrics_json = ?2, total_items_processed = ?3, total_errors = ?4
             WHERE id = ?5",
            rusqlite::params![now_ms, metrics_json, total_items, total_errors, self.row_id],
        )?;
        Ok(())
    }

    /// Mark run as failed with error message and optional partial metrics.
    pub fn fail(
        self,
        conn: &Connection,
        error: &str,
        metrics: Option<&[StageTiming]>,
    ) -> Result<()> {
        let now_ms = crate::core::time::now_ms();
        let metrics_json = metrics
            .map(|m| serde_json::to_string(m).unwrap_or_else(|_| "[]".to_string()));
        conn.execute(
            "UPDATE sync_runs
             SET finished_at = ?1, status = 'failed', error = ?2,
                 metrics_json = ?3
             WHERE id = ?4",
            rusqlite::params![now_ms, error, metrics_json, self.row_id],
        )?;
        Ok(())
    }
}
```

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

Note: SyncRunRecorder takes self (not &self) in succeed/fail to enforce single-use lifecycle. You start a run, then either succeed or fail it -- never both.

The existing time::now_ms() helper (src/core/time.rs) returns milliseconds since epoch as i64. Used by the existing sync_runs schema (started_at, finished_at are INTEGER ms).

## Acceptance Criteria
- [ ] SyncRunRecorder::start() inserts row with status='running', started_at set
- [ ] SyncRunRecorder::succeed() updates status='succeeded', finished_at set, metrics_json populated
- [ ] SyncRunRecorder::fail() updates status='failed', error set, finished_at set
- [ ] fail() with Some(metrics) stores partial metrics in metrics_json
- [ ] fail() with None leaves metrics_json as NULL
- [ ] succeed/fail consume self (single-use enforcement)
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/core/sync_run.rs (new file)
- src/core/mod.rs (register module)

## TDD Loop
RED:
  - test_sync_run_recorder_start: in-memory DB, start(), query sync_runs, assert status='running'
  - test_sync_run_recorder_succeed: start() then succeed(), assert status='succeeded', metrics_json parseable
  - test_sync_run_recorder_fail: start() then fail(), assert status='failed', error set
  - test_sync_run_recorder_fail_with_partial_metrics: fail with Some(metrics), assert metrics_json has data
GREEN: Implement SyncRunRecorder
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- Connection lifetime: SyncRunRecorder stores row_id, not Connection. The caller must ensure the same Connection is used for start/succeed/fail.
- Panic during sync: if the program panics between start() and succeed()/fail(), the row stays as 'running'. The existing stale lock detection (stale_lock_minutes) handles this.
- metrics_json encoding: serde_json::to_string on Vec<StageTiming> produces a JSON array string.

Implemented SyncRunRecorder with start/succeed/fail lifecycle, 4 passing tests


NOTE-2F: Search CLI --type note support

## Background
Allow lore search --type note to filter search results to note-type documents. The search pipeline uses SearchFilters.source_type which maps to SourceType::parse() for filtering.

## Approach
1. Update SearchArgs.source_type value_parser in src/cli/mod.rs (line 560):
   Current: value_parser = ["issue", "mr", "discussion"]
   New: value_parser = ["issue", "mr", "discussion", "note"]

2. Update search results display in src/cli/commands/search.rs (line 333-338):
   Add match arm in the type_prefix match:
   "note" => "Note",

3. Verify SourceType::parse() already handles "note" (done by NOTE-2B). The search pipeline in search.rs line 105-108 calls SourceType::parse() on the CLI source_type value. No changes needed to search logic itself — the filter propagates through SearchFilters to the SQL WHERE clause automatically.

## Files
- MODIFY: src/cli/mod.rs (line 560, add "note" to value_parser array)
- MODIFY: src/cli/commands/search.rs (line 333-338, add "note" => "Note" match arm)

## TDD Anchor
Compile test: verify lore search --type note is accepted by clap (no "invalid value" error).
Smoke test: with note documents in DB, verify lore search --type note returns only note results.
VERIFY: cargo test -- --nocapture (existing search tests still pass)

## Acceptance Criteria
- [ ] lore search --type note accepted by clap parser
- [ ] lore search --type note filters to note documents only
- [ ] Note results display "Note" prefix in human table output (line 340-346)
- [ ] Robot JSON includes source_type: "note" field (already handled by SearchResultDisplay serialization)
- [ ] All existing search tests still pass

## Dependency Context
- Depends on NOTE-2D (bd-2ezb): note documents must exist in DB to be searched (regenerator must handle SourceType::Note)

## Edge Cases
- No note documents indexed yet: returns 0 results (same as any empty type filter)
- Mixed search (no --type flag): note documents appear alongside issue/mr/discussion results


OBSERV: Wire SyncRunRecorder into sync and ingest commands

## Background
With SyncRunRecorder implemented and MetricsLayer available, we wire them into the actual sync and ingest command handlers. This makes every sync/ingest invocation create a database record with full metrics.

## Approach
### src/cli/commands/sync.rs - run_sync() (line ~54)

Before the pipeline:
```rust
let recorder = SyncRunRecorder::start(&conn, "sync", &run_id)?;
```

After pipeline succeeds:
```rust
let stages = metrics_handle.extract_timings();
let total_items = stages.iter().map(|s| s.items_processed).sum::<usize>();
let total_errors = stages.iter().map(|s| s.errors).sum::<usize>();
recorder.succeed(&conn, &stages, total_items, total_errors)?;
```

On pipeline failure (wrap pipeline in match or use a helper):
```rust
match pipeline_result {
    Ok(result) => {
        let stages = metrics_handle.extract_timings();
        recorder.succeed(&conn, &stages, total_items, total_errors)?;
        Ok(result)
    }
    Err(e) => {
        let stages = metrics_handle.extract_timings();
        recorder.fail(&conn, &e.to_string(), Some(&stages))?;
        Err(e)
    }
}
```

### src/cli/commands/ingest.rs - run_ingest() (line ~107)

Same pattern: start before pipeline, succeed/fail after.

Note: run_sync() calls run_ingest() internally. Both will create sync_runs records. This is intentional -- standalone ingest should also be tracked. But when run_sync calls run_ingest, the ingest record is a child operation. Consider: should we skip the ingest recorder when called from sync? Decision: keep both records. The run_id differs, and sync-status can distinguish by the "command" column.

Actually, re-reading the code: run_sync() (line 54-178) calls run_ingest() for issues and MRs. If both create sync_runs rows, we get 3 rows per sync (1 sync + 2 ingest). This is fine -- command='sync' vs command='ingest:issues' distinguishes them.

### Connection sharing
run_sync and run_ingest already have access to a Connection. SyncRunRecorder::start takes &Connection.

### MetricsLayer handle
metrics_handle must be passed from main.rs through handle_sync_cmd/handle_ingest to run_sync/run_ingest. This requires adding a parameter. Alternative: use a thread-local or global. Prefer parameter passing for testability.

## Acceptance Criteria
- [ ] Every lore sync creates a sync_runs row with status transitioning running -> succeeded/failed
- [ ] Every lore ingest creates a sync_runs row
- [ ] metrics_json contains serialized Vec<StageTiming> on success
- [ ] Failed syncs record error message and partial metrics
- [ ] sync_runs.run_id matches run_id in log files and robot JSON
- [ ] total_items_processed and total_errors are populated
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/cli/commands/sync.rs (wire SyncRunRecorder + extract_timings in run_sync)
- src/cli/commands/ingest.rs (wire SyncRunRecorder in run_ingest)
- src/main.rs (pass metrics_handle to command handlers)

## TDD Loop
RED: test_sync_creates_run_record (integration: run sync, query sync_runs, assert row exists with metrics)
GREEN: Wire SyncRunRecorder into run_sync and run_ingest
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- Database locked: SyncRunRecorder operations happen on the main connection. If a concurrent process holds the lock, the INSERT/UPDATE will wait (WAL mode) or error. Use existing lock handling.
- Partial failure: if ingest issues succeeds but ingest MRs fails, the sync recorder should fail() with partial metrics (stages from issues but not MRs).
- metrics_handle lifetime: must outlive the root span. Since it's an Arc clone, this is guaranteed.

Wired SyncRunRecorder into handle_sync_cmd and handle_ingest in main.rs


Phase 5c: Full verification and hardening pass

## What
Run the complete verification checklist and resolve any remaining issues.

## Verification Checklist

```bash
cargo check --all-targets
cargo clippy --all-targets -- -D warnings
cargo fmt --check
cargo test
```

## Specific Things to Verify

1. **async-stream on nightly** — Does async_stream 0.3 compile on current nightly? If not, manual Stream impl is the fallback.

2. **TLS root certs on macOS** — Does tls-native-roots pick up system CA certs? Test with production GitLab endpoint.

3. **Connection pool under concurrency** — Do join_all batches (4-8 concurrent requests to same host) work without pool deadlock? Stress test.

4. **Pagination streams** — Do async_stream::stream! pagination generators work unchanged? Test with multi-page GitLab results.

5. **Wiremock test isolation** — Do 42 wiremock tests pass with tokio only in dev-deps? Verify no accidental production tokio usage.

6. **Build time comparison** — Measure before/after build times. Document delta.

7. **Binary size comparison** — Measure before/after binary sizes.

## Rollback readiness
Verify that reverting the atomic Phases 1-3 commit cleanly restores the tokio+reqwest setup. Test by checking out the pre-migration commit and running cargo test.

## Files Changed
- Potentially any file if issues are found
- Documentation updates if behavior differs

## Depends On
- Phase 4a, 4b, 4c (all test migration complete)
- Phase 5a (HTTP parity verified)
- Phase 5b (DB invariants verified)


Implement issue document extraction

## Background
Issue documents are the simplest document type — a structured header + description text. The extractor queries the existing issues and issue_labels tables (populated by ingestion) and assembles a DocumentData struct. This is one of three entity-specific extractors (issue, MR, discussion) that feed the document regeneration pipeline.

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

```rust
/// Extract a searchable document from an issue.
/// Returns None if the issue has been deleted from the DB.
pub fn extract_issue_document(conn: &Connection, issue_id: i64) -> Result<Option<DocumentData>>
```

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

-- Labels
SELECT l.name FROM issue_labels il
JOIN labels l ON l.id = il.label_id
WHERE il.issue_id = ?
ORDER BY l.name
```

**Document format:**
```
[[Issue]] #234: Authentication redesign
Project: group/project-one
URL: https://gitlab.example.com/group/project-one/-/issues/234
Labels: ["bug", "auth"]
State: opened
Author: @johndoe

--- Description ---

We need to modernize our authentication system...
```

**Implementation steps:**
1. Query issue row — if not found, return Ok(None)
2. Query labels via junction table
3. Format header with [[Issue]] prefix
4. Compute content_hash via compute_content_hash()
5. Compute labels_hash via compute_list_hash()
6. paths is always empty for issues (paths are only for DiffNote discussions)
7. Return DocumentData with all fields populated

## Acceptance Criteria
- [ ] Deleted issue (not in DB) returns Ok(None)
- [ ] Issue with no description: content_text has header only (no "--- Description ---" section)
- [ ] Issue with no labels: Labels line shows "[]"
- [ ] Issue with labels: Labels line shows sorted JSON array
- [ ] content_hash is SHA-256 of the full content_text
- [ ] labels_hash is SHA-256 of sorted label names joined by newline
- [ ] paths_hash is empty string hash (issues have no paths)
- [ ] project_id comes from the JOIN with projects table
- [ ] `cargo test extract_issue` passes

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

## TDD Loop
RED: Test in `#[cfg(test)] mod tests`:
- `test_issue_document_format` — verify header format matches PRD template
- `test_issue_not_found` — returns Ok(None) for nonexistent issue_id
- `test_issue_no_description` — no description section when description is NULL
- `test_issue_labels_sorted` — labels appear in alphabetical order
- `test_issue_hash_deterministic` — same issue produces same content_hash
GREEN: Implement extract_issue_document with SQL queries
VERIFY: `cargo test extract_issue`

## Edge Cases
- Issue with NULL description: skip "--- Description ---" section entirely
- Issue with empty string description: include section but with empty body
- Issue with very long description: no truncation here (hard cap applied by caller)
- Labels with special characters (quotes, commas): JSON array handles escaping

Implemented extract_issue_document() with SQL queries, PRD-compliant format, and 7 tests


OBSERV: Add #[instrument] spans to ingestion stages

## Background
Tracing spans on each sync stage create the hierarchy that (1) makes log lines filterable by stage, (2) Phase 3's MetricsLayer reads to build StageTiming trees, and (3) gives meaningful context in -vv stderr output.

## Approach
Add #[instrument] attributes or manual spans to these functions:

### src/ingestion/orchestrator.rs
1. ingest_project_issues_with_progress() (line ~110):
```rust
#[instrument(skip_all, fields(stage = "ingest_issues", project = %project_path))]
pub async fn ingest_project_issues_with_progress(...) -> Result<IngestProjectResult> {
```

2. The MR equivalent (ingest_project_mrs_with_progress or similar):
```rust
#[instrument(skip_all, fields(stage = "ingest_mrs", project = %project_path))]
```

3. Inside the issue ingest function, add child spans for sub-stages:
```rust
let _fetch_span = tracing::info_span!("fetch_pages", project = %project_path).entered();
// ... fetch logic
drop(_fetch_span);

let _disc_span = tracing::info_span!("sync_discussions", project = %project_path).entered();
// ... discussion sync logic
drop(_disc_span);
```

4. drain_resource_events() (line ~566):
```rust
let _span = tracing::info_span!("fetch_resource_events", project = %project_path).entered();
```

### src/documents/regenerator.rs
5. regenerate_dirty_documents() (line ~24):
```rust
#[instrument(skip_all, fields(stage = "generate_docs"))]
pub fn regenerate_dirty_documents(conn: &Connection) -> Result<RegenerateResult> {
```

### src/embedding/pipeline.rs
6. embed_documents() (line ~36):
```rust
#[instrument(skip_all, fields(stage = "embed"))]
pub async fn embed_documents(...) -> Result<EmbedResult> {
```

### Important: field declarations for Phase 3
The #[instrument] fields should include empty recording fields that Phase 3 (bd-16m8) will populate:
```rust
#[instrument(skip_all, fields(
    stage = "ingest_issues",
    project = %project_path,
    items_processed = tracing::field::Empty,
    items_skipped = tracing::field::Empty,
    errors = tracing::field::Empty,
))]
```

This declares the fields on the span so MetricsLayer can capture them when span.record() is called later.

## Acceptance Criteria
- [ ] JSON log lines show nested span context: sync > ingest_issues > fetch_pages
- [ ] Each stage span has a "stage" field with the stage name
- [ ] Per-project spans include "project" field
- [ ] Spans are visible in -vv stderr output as bracketed context
- [ ] Empty recording fields declared for items_processed, items_skipped, errors
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/ingestion/orchestrator.rs (spans on ingest functions and sub-stages)
- src/documents/regenerator.rs (span on regenerate_dirty_documents)
- src/embedding/pipeline.rs (span on embed_documents)

## TDD Loop
RED:
  - test_span_context_in_json_logs: mock sync, capture JSON, verify span chain
  - test_nested_span_chain: verify parent-child: sync > ingest_issues > fetch_pages
  - test_span_elapsed_on_close: create span, sleep 10ms, verify elapsed >= 10
GREEN: Add #[instrument] and manual spans to all stage functions
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- #[instrument] on async fn: uses tracing::Instrument trait automatically. Works with tokio.
- skip_all is essential: without it, #[instrument] tries to Debug-format all parameters, which may not implement Debug or may be expensive.
- Manual span drop: for sub-stages within a single function, use explicit drop(_span) to end the span before the next sub-stage starts. Otherwise spans overlap.
- tracing::field::Empty: declares a field that can be recorded later. If never recorded, it appears as empty/missing in output (not zero).

Added #[instrument] spans to ingest_project_issues_with_progress, ingest_project_merge_requests_with_progress, drain_resource_events, regenerate_dirty_documents, embed_documents


NOTE-1C: Human and robot output formatting for notes

## Background
Implement the 4 output formatters for the notes command: human table, robot JSON, JSONL streaming, and CSV export.

## Approach
Add to src/cli/commands/list.rs (after the query_notes function from NOTE-1A):

1. pub fn print_list_notes(result: &NoteListResult) — human table:
   Use comfy-table (already in Cargo.toml) following the pattern of print_list_issues/print_list_mrs.
   Columns: ID | Author | Type | Body (truncated to 60 chars + "...") | Path:Line | Parent | Created
   ID: colored_cell with Cyan for gitlab_id
   Author: @username with Magenta
   Type: "Diff" for DiffNote, "Disc" for DiscussionNote, "-" for others
   Path: position_new_path:line (or "-" if no path)
   Parent: "Issue #N" or "MR !N" from noteable_type + parent_iid
   Created: format_relative_time (existing helper in list.rs)

2. pub fn print_list_notes_json(result: &NoteListResult, elapsed_ms: u64, fields: Option<&[String]>) — robot JSON:
   Standard envelope: {"ok":true,"data":{"notes":[...],"total_count":N,"showing":M},"meta":{"elapsed_ms":U64}}
   Supports --fields via filter_fields() from crate::cli::robot
   Same pattern as print_list_issues_json.

3. pub fn print_list_notes_jsonl(result: &NoteListResult) — one JSON object per line:
   Each line is one NoteListRowJson serialized. No envelope. Ideal for jq/notebook pipelines.
   Use serde_json::to_string for each row, println! each line.

4. pub fn print_list_notes_csv(result: &NoteListResult) — CSV output:
   Check if csv crate is already used in the project. If not, use manual CSV with proper escaping:
   - Header row with field names matching NoteListRowJson
   - Quote fields containing commas, quotes, or newlines
   - Escape internal quotes by doubling them
   Alternatively, if adding csv crate (add csv = "1" to Cargo.toml [dependencies]), use csv::WriterBuilder for RFC 4180 compliance.

Helper: Add a truncate_body(body: &str, max_len: usize) -> String function for the human table truncation.

## Files
- MODIFY: src/cli/commands/list.rs (4 print functions + truncate_body helper)
- POSSIBLY MODIFY: Cargo.toml (add csv = "1" if using csv crate for CSV output)

## TDD Anchor
RED: test_truncate_note_body — assert 200-char body truncated to 60 + "..."
GREEN: Implement truncate_body helper.
VERIFY: cargo test truncate_note_body -- --nocapture
Tests: test_csv_output_basic (CSV output has correct header + escaped fields), test_jsonl_output_one_per_line (each line parses as valid JSON)

## Acceptance Criteria
- [ ] Human table renders with colored columns, truncated body, relative time
- [ ] Robot JSON follows standard envelope with timing metadata
- [ ] --fields filtering works on JSON output (via filter_fields)
- [ ] JSONL outputs one valid JSON object per line
- [ ] CSV properly escapes commas, quotes, and newlines in body text
- [ ] Multi-byte chars handled correctly in CSV and truncation
- [ ] All 3 tests pass

## Dependency Context
- Depends on NOTE-1A (bd-20p9): uses NoteListRow, NoteListRowJson, NoteListResult structs

## Edge Cases
- Empty body in table: show "-" or empty cell
- Very long body with multi-byte chars: truncation must respect char boundaries (use .chars().take(n) not byte slicing)
- JSONL with body containing newlines: serde_json::to_string escapes \n correctly
- CSV with body containing quotes: must double them per RFC 4180


Phase 2b: Migrate gitlab/graphql.rs to HTTP adapter

## What
Replace all reqwest usage in gitlab/graphql.rs with the crate::http adapter.

## Why
GraphQL module handles POST requests with Bearer auth + JSON body. Smaller scope than client.rs (~20 LOC changed).

## Changes

### Request construction
```rust
// Before
let response = self.http.post(&url)
    .header("Authorization", format!("Bearer {}", self.token))
    .header("Content-Type", "application/json")
    .json(&body).send().await?;
let json: Value = response.json().await?;

// After
let bearer = format!("Bearer {}", self.token);
let response = self.http.post_json(&url, &[
    ("Authorization", &bearer),
], &body).await?;
let json: Value = response.json()?;  // sync — body already buffered
```

### Status matching
```rust
// Before: response.status().as_u16()
// After: response.status  // already u16
```

## Files Changed
- src/gitlab/graphql.rs (~20 LOC changed)

## Testing
- All 30 graphql_tests.rs tests must pass (wiremock, still on tokio)
- cargo check --all-targets
- cargo clippy --all-targets -- -D warnings

## Depends On
- Phase 1 (adapter layer must exist)

WIREMOCK COMPATIBILITY: Same risk as Phase 2a (bd-lhj8). After migration, 30 graphql_tests run on #[tokio::test] but call code using asupersync HTTP. See Phase 2a description for resolution options. If asupersync HTTP is runtime-agnostic, no issue. Verify during Decision Gate.


robot-docs: Add Ollama dependency discovery to manifest

## Background

Add Ollama dependency discovery to robot-docs so agents know which commands need Ollama and which work without it. Currently robot-docs lists commands, exit codes, workflows, and aliases — but has no dependency information.

## Codebase Context

- handle_robot_docs() in src/main.rs (line ~1646) returns RobotDocsData JSON
- RobotDocsData struct has fields: commands, exit_codes, workflows, aliases, clap_error_codes
- Currently 18 documented commands in the manifest
- Ollama required for: embed, search --mode=semantic, search --mode=hybrid
- Not required for: all Phase B temporal commands (timeline, file-history, trace), lexical search, count, ingest, stats, sync, doctor, health, who, show, issues, mrs, etc.
- No dependencies field exists yet in RobotDocsData

## Approach

### 1. Add dependencies field to RobotDocsData (src/main.rs):

```rust
#[derive(Serialize)]
struct RobotDocsData {
    // ... existing fields ...
    dependencies: DependencyInfo,
}

#[derive(Serialize)]
struct DependencyInfo {
    ollama: OllamaDependency,
}

#[derive(Serialize)]
struct OllamaDependency {
    required_by: Vec<String>,
    not_required_by: Vec<String>,
    install: HashMap<String, String>,  // {"macos": "brew install ollama", "linux": "curl ..."}
    setup: String,                      // "ollama pull nomic-embed-text"
    note: String,
}
```

### 2. Populate in handle_robot_docs():

```json
{
  "ollama": {
    "required_by": ["embed", "search --mode=semantic", "search --mode=hybrid"],
    "not_required_by": ["issues", "mrs", "search --mode=lexical", "timeline", "file-history", "count", "ingest", "stats", "sync", "doctor", "health", "who", "show", "status"],
    "install": {"macos": "brew install ollama", "linux": "curl -fsSL https://ollama.ai/install.sh | sh"},
    "setup": "ollama pull nomic-embed-text",
    "note": "Lexical search and all temporal features work without Ollama."
  }
}
```

## Acceptance Criteria

- [ ] `lore robot-docs | jq '.data.dependencies.ollama'` returns structured info
- [ ] required_by lists embed and semantic/hybrid search modes
- [ ] not_required_by lists all commands that work without Ollama (including Phase B if they exist)
- [ ] Install instructions for macos and linux
- [ ] setup field includes "ollama pull nomic-embed-text"
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes
- [ ] `cargo fmt --check` passes

## Files

- MODIFY: src/main.rs (add DependencyInfo/OllamaDependency structs, update RobotDocsData, populate in handle_robot_docs)

## TDD Anchor

No unit test needed — this is static metadata. Verify with:

```bash
cargo check --all-targets
cargo run --release -- robot-docs | jq '.data.dependencies.ollama.required_by'
cargo run --release -- robot-docs | jq '.data.dependencies.ollama.not_required_by'
```

## Edge Cases

- Keep not_required_by up to date as new commands are added — consider a comment in the code listing which commands to check
- Phase B commands (timeline, file-history, trace) must be in not_required_by once they exist
- If a command conditionally needs Ollama (like search with --mode flag), list the specific flag combination in required_by

## Dependency Context

- **RobotDocsData** (src/main.rs ~line 1646): the existing struct that this bead extends. Currently has commands (Vec), exit_codes (Vec), workflows (Vec), aliases (Vec), clap_error_codes (Vec). Adding a dependencies field is additive — no breaking changes.
- **handle_robot_docs()**: the function that constructs and returns the JSON. All data is hardcoded in the function — no runtime introspection needed.


Implement common widgets (status bar, breadcrumb, loading, error toast, help overlay)

## Background
Common widgets appear across all screens: the status bar shows context-sensitive key hints and sync status, the breadcrumb shows navigation depth, the loading spinner indicates background work, the error toast shows transient errors with auto-dismiss, and the help overlay (?) shows available keybindings.

## Approach
Create crates/lore-tui/src/view/common/mod.rs and individual widget files:

view/common/mod.rs:
- render_breadcrumb(frame, area, nav: &NavigationStack, theme: &Theme): renders "Dashboard > Issues > #42" trail
- render_status_bar(frame, area, registry: &CommandRegistry, screen: &Screen, mode: &InputMode, theme: &Theme): renders bottom bar with key hints and sync indicator
- render_loading(frame, area, load_state: &LoadState, theme: &Theme): renders centered spinner for LoadingInitial, or subtle refresh indicator for Refreshing
- render_error_toast(frame, area, msg: &str, theme: &Theme): renders floating toast at bottom-right with error message
- render_help_overlay(frame, area, registry: &CommandRegistry, screen: &Screen, theme: &Theme): renders centered modal with keybinding list from registry

Create crates/lore-tui/src/view/mod.rs:
- render_screen(frame, app: &LoreApp): top-level dispatch — renders breadcrumb + screen content + status bar + optional overlays (help, error toast, command palette)

## Acceptance Criteria
- [ ] Breadcrumb renders all stack entries with " > " separator
- [ ] Status bar shows contextual hints from CommandRegistry
- [ ] Loading spinner animates via tick subscription
- [ ] Error toast auto-positions at bottom-right of screen
- [ ] Help overlay shows all commands for current screen from registry
- [ ] render_screen routes to correct per-screen view function
- [ ] Overlays (help, error, palette) render on top of screen content

## Files
- CREATE: crates/lore-tui/src/view/mod.rs
- CREATE: crates/lore-tui/src/view/common/mod.rs

## TDD Anchor
RED: Write test_breadcrumbs_format that creates a NavigationStack with Dashboard > IssueList, calls breadcrumbs(), asserts ["Dashboard", "Issues"].
GREEN: Implement breadcrumbs() in NavigationStack (already in nav task) and render_breadcrumb.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_breadcrumbs

## Edge Cases
- Breadcrumb must truncate from the left if stack is too deep for terminal width
- Status bar must handle narrow terminals (<60 cols) gracefully — show abbreviated hints
- Error toast must handle very long messages with truncation
- Help overlay must scroll if there are more commands than terminal height

## Dependency Context
Uses NavigationStack from "Implement NavigationStack" task.
Uses CommandRegistry from "Implement CommandRegistry" task.
Uses LoadState from "Implement AppState composition" task.
Uses Theme from "Implement theme configuration" task.


Phase 5b: DB transaction invariant audit

## What
Audit all ingestion paths for transaction boundary correctness under cancellation. Verify 4 invariants. This is a CODE AUDIT — the corresponding tests are written in Phase 4c (bd-1yky). This bead identifies violations; 4c tests enforce them.

## Rearchitecture Context (2026-03-06)
Key files for this audit:
- src/ingestion/orchestrator.rs — main orchestration (unchanged location)
- src/ingestion/surgical.rs — ingestion-level surgical sync logic (unchanged location)
- src/cli/commands/sync/surgical.rs — CLI command wrapper (was cli/commands/sync_surgical.rs)
- src/ingestion/storage/ — persistence helpers moved here from core/ (payloads.rs, events.rs, queue.rs, sync_run.rs)

## Invariants

### INV-1: Atomic batch writes
Each ingestion batch (issues, MRs, discussions) writes to DB inside a single unchecked_transaction(). If not committed, no partial data visible. Audit all paths — fix any that write outside a transaction.

### INV-2: Region cancellation cannot corrupt committed data
Cancelled region may abandon in-flight HTTP but must not interrupt DB transaction mid-write. This holds because SQLite transactions are synchronous — once tx.execute() starts, it runs to completion regardless of task cancellation. VERIFY this holds for WAL mode.

### HARD RULE: No .await between transaction open and commit/rollback
Cancellation can fire at any .await point. If .await exists between unchecked_transaction() and tx.commit(), cancelled region could drop transaction guard mid-batch, rolling back partial writes silently.

If any path must do async work mid-transaction, restructure to fetch-then-write: complete all async work first, then open transaction, write synchronously, commit.

### INV-3: No partial batch visibility
If cancellation fires after fetching N items but before batch transaction commits, zero items from that batch are persisted. Next sync resumes via cursor-based pagination.

### INV-4: ShutdownSignal + region cancellation are complementary
Existing ShutdownSignal check-before-write pattern in orchestrator loops (if signal.is_cancelled() { break; }) remains first line of defense. Region cancellation is second — ensures in-flight HTTP tasks are drained even if orchestrator loop moved past signal check. Both must remain active.

## Audit Procedure
1. Grep for all unchecked_transaction() and begin_transaction() calls in ingestion/ and cli/commands/sync/
2. For each: verify no .await between open and commit
3. For each: verify the entire batch write is inside a single transaction
4. Document any violations and fix them (restructure to fetch-then-write)
5. Verify WAL mode compatibility for INV-2

## Relationship to Phase 4c
Phase 4c (bd-1yky) writes integration tests that ENFORCE these invariants at runtime. This bead (5b) is the static code AUDIT that finds violations. If this audit finds issues, fix the code first, then Phase 4c tests confirm the fix holds under cancellation.

## Files to Audit
- src/ingestion/orchestrator.rs (primary target)
- src/ingestion/surgical.rs (ingestion-level surgical logic)
- src/ingestion/issues.rs, merge_requests.rs, discussions.rs, mr_discussions.rs (batch write paths)
- src/ingestion/storage/payloads.rs, events.rs, queue.rs, sync_run.rs (persistence helpers, moved from core/)
- No new test files (tests are in Phase 4c)


Implement CLI integration (lore tui command + binary delegation)

## Background
The lore CLI binary needs a tui subcommand that launches the lore-tui binary. This is runtime binary delegation — lore finds lore-tui via PATH lookup and execs it, passing through relevant flags. Zero compile-time dependency from lore to lore-tui. The TUI is the human interface; the CLI is the robot/script interface.

## Approach
Add a tui subcommand to the lore CLI:

**CLI side** (`src/cli/tui.rs`):
- Add `Tui` variant to the main CLI enum with flags: --config, --sync, --fresh, --render-mode, --ascii, --no-alt-screen
- Implementation: resolve lore-tui binary via PATH lookup (std::process::Command with "lore-tui")
- Pass through all flags as CLI arguments
- If lore-tui not found in PATH, print helpful error: "lore-tui binary not found. Install with: cargo install --path crates/lore-tui"
- Exec (not spawn+wait) using std::os::unix::process::CommandExt::exec() for clean process replacement on Unix

**Binary naming**: The binary is `lore-tui` (hyphenated), matching the crate name.

## Acceptance Criteria
- [ ] lore tui launches lore-tui binary from PATH
- [ ] All flags (--config, --sync, --fresh, --render-mode, --ascii, --no-alt-screen) are passed through
- [ ] Missing binary produces helpful error with install instructions
- [ ] Uses exec() on Unix for clean process replacement (no zombie parent)
- [ ] Robot mode: lore --robot tui returns JSON error if binary not found
- [ ] lore tui --help shows TUI-specific flags

## Files
- CREATE: src/cli/tui.rs
- MODIFY: src/cli/mod.rs (add tui subcommand to CLI enum)
- MODIFY: src/main.rs (add match arm for Tui variant)

## TDD Anchor
RED: Write `test_tui_binary_not_found_error` that asserts the error message includes install instructions when lore-tui is not in PATH.
GREEN: Implement the binary lookup and error handling.
VERIFY: cargo test tui_binary -- --nocapture

Additional tests:
- test_tui_flag_passthrough (verify all flags are forwarded)
- test_tui_robot_mode_json_error (structured error when binary missing)

## Edge Cases
- lore-tui binary exists but is not executable — should produce clear error
- PATH contains multiple lore-tui versions — uses first match (standard PATH behavior)
- Windows: exec() not available — fall back to spawn+wait+exit with same code
- User runs lore tui in robot mode — should fail with structured JSON error (TUI is human-only)

## Dependency Context
Depends on bd-2iqk (Doctor + Stats screens) for phase ordering. The CLI integration is one of the last Phase 4 tasks because it requires lore-tui to be substantially complete for the delegation to be useful.


WHO: Reviews mode query (query_reviews)

## Background

Reviews mode answers "What review patterns does person X have?" by analyzing the **prefix** convention in DiffNote bodies (e.g., **suggestion**: ..., **question**: ..., **nit**: ...). Only counts DiffNotes on MRs the user did NOT author (m.author_username != ?1).

## Approach

### Three queries:
1. **Total DiffNotes**: COUNT(*) of DiffNotes by user on others' MRs
2. **Distinct MRs reviewed**: COUNT(DISTINCT m.id)
3. **Category extraction**: SQL-level prefix parsing + Rust normalization

### Category extraction SQL:
```sql
SELECT
    SUBSTR(ltrim(n.body), 3, INSTR(SUBSTR(ltrim(n.body), 3), '**') - 1) AS raw_prefix,
    COUNT(*) AS cnt
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
JOIN merge_requests m ON d.merge_request_id = m.id
WHERE n.author_username = ?1
  AND n.note_type = 'DiffNote' AND n.is_system = 0
  AND m.author_username != ?1
  AND ltrim(n.body) LIKE '**%**%'   -- only bodies with **prefix** pattern
  AND n.created_at >= ?2
  AND (?3 IS NULL OR n.project_id = ?3)
GROUP BY raw_prefix ORDER BY cnt DESC
```

Key: `ltrim(n.body)` tolerates leading whitespace before **prefix** (common in practice).

### normalize_review_prefix() in Rust:
```rust
fn normalize_review_prefix(raw: &str) -> String {
    let s = raw.trim().trim_end_matches(':').trim().to_lowercase();
    // Strip parentheticals like "(non-blocking)"
    let s = if let Some(idx) = s.find('(') { s[..idx].trim().to_string() } else { s };
    // Merge nit/nitpick variants
    match s.as_str() {
        "nitpick" | "nit" => "nit".to_string(),
        other => other.to_string(),
    }
}
```

### HashMap merge for normalized categories, then sort by count DESC

### ReviewsResult struct:
```rust
pub struct ReviewsResult {
    pub username: String,
    pub total_diffnotes: u32,
    pub categorized_count: u32,
    pub mrs_reviewed: u32,
    pub categories: Vec<ReviewCategory>,
}
pub struct ReviewCategory { pub name: String, pub count: u32, pub percentage: f64 }
```

No LIMIT needed — categories are naturally bounded (few distinct prefixes).

## Files

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

## TDD Loop

RED:
```
test_reviews_query — insert 3 DiffNotes (2 with **prefix**, 1 without); verify total=3, categorized=2, categories.len()=2
test_normalize_review_prefix — "suggestion" "Suggestion:" "suggestion (non-blocking):" "Nitpick:" "nit (non-blocking):" "question" "TODO:"
```

GREEN: Implement query_reviews + normalize_review_prefix
VERIFY: `cargo test -- reviews`

## Acceptance Criteria

- [ ] test_reviews_query passes (total=3, categorized=2)
- [ ] test_normalize_review_prefix passes (nit/nitpick merge, parenthetical strip)
- [ ] Only counts DiffNotes on MRs user did NOT author
- [ ] Default since window: 6m

## Edge Cases

- Self-authored MRs excluded (m.author_username != ?1) — user's notes on own MRs are not "reviews"
- ltrim() handles leading whitespace before **prefix**
- Empty raw_prefix after normalization filtered out (!normalized.is_empty())
- Percentage calculated from categorized_count (not total_diffnotes)

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


Phase 5a: HTTP behavior parity verification

## What
Verify HTTP behavior parity between reqwest (old) and asupersync h1 (new) for all implicit behaviors reqwest provided.

## Acceptance Criteria Table

| reqwest default | Criterion | Test |
|-----------------|-----------|------|
| Auto redirect (up to 10) | If GitLab returns 3xx, must not silently lose response. Follow or surface clear error. | wiremock 301 -> verify adapter returns redirect status |
| Auto gzip/deflate | Not required (JSON small) | N/A |
| Proxy from HTTP_PROXY | If set, requests route through it. If unsupported, document. | Set HTTP_PROXY=127.0.0.1:9999 -> verify connection targets proxy |
| Connection keep-alive | Pagination batches (4-8 sequential) must reuse connections | Measure with ss/netstat: 8 requests should use <=2 TCP connections |
| System DNS | Hostnames must resolve via OS resolver | lore sync against hostname (not IP) |
| Content-Length on POST | Must include header (some proxies/WAFs require) | Inspect outgoing headers in wiremock test |
| TLS cert validation | HTTPS must validate certs using system CA store | lore sync against production GitLab + fail against self-signed |

## Implementation
Write specific test for each row. Document pass/fail results. Any failure is a blocker that must be resolved before migration is complete.

## Files Changed
- tests/http_parity_tests.rs (NEW, ~200 LOC)
- Or extend existing adapter integration tests

## Depends On
- Phase 2a, 2b, 2c (all HTTP modules migrated)
- Phase 3a (asupersync in deps)


NOTE-1E: Composite query index and author_id column (migration 022)

## Background
The notes table needs composite covering indexes for the new query_notes() function, plus the author_id column for immutable identity (NOTE-0D). Combined in a single migration to avoid an extra migration step. Migration slot 022 is available (021 = work_item_status, 023 = issue_detail_fields already exists).

## Approach
Create migrations/022_notes_query_index.sql with:

1. Composite index for author-scoped queries (most common pattern):
   CREATE INDEX IF NOT EXISTS idx_notes_user_created
   ON notes(project_id, author_username COLLATE NOCASE, created_at DESC, id DESC)
   WHERE is_system = 0;

2. Composite index for project-scoped date-range queries:
   CREATE INDEX IF NOT EXISTS idx_notes_project_created
   ON notes(project_id, created_at DESC, id DESC)
   WHERE is_system = 0;

3. Discussion JOIN indexes (check if they already exist first):
   CREATE INDEX IF NOT EXISTS idx_discussions_issue_id ON discussions(issue_id);
   CREATE INDEX IF NOT EXISTS idx_discussions_mr_id ON discussions(merge_request_id);

4. Immutable author identity column (for NOTE-0D):
   ALTER TABLE notes ADD COLUMN author_id INTEGER;
   CREATE INDEX IF NOT EXISTS idx_notes_author_id ON notes(author_id) WHERE author_id IS NOT NULL;

Register in src/core/db.rs MIGRATIONS array as ("022", include_str!("../../migrations/022_notes_query_index.sql")). Insert BEFORE the existing ("023", ...) entry. LATEST_SCHEMA_VERSION auto-increments via MIGRATIONS.len().

## Files
- CREATE: migrations/022_notes_query_index.sql
- MODIFY: src/core/db.rs (add ("022", include_str!(...)) to MIGRATIONS array, insert at position before "023" entry around line 73)

## TDD Anchor
RED: test_migration_022_indexes_exist — run_migrations on in-memory DB, verify 4 new indexes exist in sqlite_master.
GREEN: Create migration file with all CREATE INDEX statements.
VERIFY: cargo test migration_022 -- --nocapture

## Acceptance Criteria
- [ ] Migration 022 creates idx_notes_user_created partial index
- [ ] Migration 022 creates idx_notes_project_created partial index
- [ ] Migration 022 creates idx_discussions_issue_id (or is no-op if exists)
- [ ] Migration 022 creates idx_discussions_mr_id (or is no-op if exists)
- [ ] Migration 022 adds author_id INTEGER column to notes
- [ ] Migration 022 creates idx_notes_author_id partial index
- [ ] MIGRATIONS array in db.rs includes ("022", ...) before ("023", ...)
- [ ] Existing tests still pass with new migration
- [ ] Test verifying all indexes exist passes

## Edge Cases
- Partial indexes exclude system notes (is_system = 0) — filters 30-50% of notes
- COLLATE NOCASE on author_username matches the query's case-insensitive comparison
- author_id is nullable (existing notes won't have it until re-synced)
- IF NOT EXISTS on all CREATE INDEX statements makes migration idempotent


Implement Timeline screen (state + action + view)

## Background
The Timeline screen renders a chronological event stream from the 5-stage timeline pipeline (SEED -> HYDRATE -> EXPAND -> COLLECT -> RENDER). Events are color-coded by type and can be scoped to an entity, author, or time range.

## Approach
State (state/timeline.rs):
- TimelineState: events (Vec<TimelineEvent>), query (String), query_input (TextInput), query_focused (bool), selected_index (usize), scroll_offset (usize), scope (TimelineScope)
- TimelineScope: All, Entity(EntityKey), Author(String), DateRange(DateTime, DateTime)

Action (action.rs):
- fetch_timeline(conn, scope, limit, clock) -> Vec<TimelineEvent>: runs the timeline pipeline against DB

View (view/timeline.rs):
- Vertical event stream with timestamp gutter on the left
- Color-coded event types: Created(green), Updated(yellow), Closed(red), Merged(purple), Commented(blue), Labeled(cyan), Milestoned(orange)
- Each event: timestamp | entity ref | event description
- Entity refs navigable via Enter
- Query bar for filtering by text or entity
- Keyboard: j/k scroll, Enter navigate to entity, / focus query, g+g top

## Acceptance Criteria
- [ ] Timeline renders chronological event stream
- [ ] Events color-coded by type
- [ ] Entity references navigable
- [ ] Scope filters: all, per-entity, per-author, date range
- [ ] Query bar filters events
- [ ] Keyboard navigation works (j/k/Enter/Esc)
- [ ] Timestamps use injected Clock

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

## TDD Anchor
RED: Write test_fetch_timeline_scoped that creates issues with events, calls fetch_timeline with Entity scope, asserts only that entity's events returned.
GREEN: Implement fetch_timeline with scope filtering.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_fetch_timeline

## Edge Cases
- Timeline pipeline may not be fully implemented in core yet — degrade gracefully if SEED/HYDRATE/EXPAND stages are not available, fall back to raw events
- Very long timelines: VirtualizedList or lazy loading for performance
- Events with identical timestamps: stable sort by entity type, then iid

## Dependency Context
Uses timeline pipeline types from src/core/timeline.rs if available.
Uses Clock for timestamp rendering from "Implement Clock trait" task.
Uses EntityKey navigation from "Implement core types" task.