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


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


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.

Completed: 5 common widgets + render_screen dispatch + 27 tests + clippy clean


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.

CLI integration: lore tui subcommand delegates to lore-tui via PATH exec(). Robot mode returns structured JSON error. 5 tests.


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.


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.

Timeline screen complete: TimelineState (scope/generation/selection/scroll), action functions (4 event source collectors querying resource event tables), view renderer (color-coded event stream with scrolling), all wired into screen dispatch. 518 tests pass, clippy clean, fmt clean.


Split app.rs into app/ module (model + key dispatch)

app.rs is 712 lines and will grow as screens are added. Split into crates/lore-tui/src/app/mod.rs (LoreApp struct, new(), init()), app/update.rs (update() method, key dispatch, message handling), app/view.rs (view() delegation). Key dispatch is the largest section and the primary growth point. Keep public API identical via re-exports.

Split app.rs into app/mod.rs, app/update.rs, app/tests.rs. All 177 tests pass.


Create migration 009_embeddings.sql

## Background
Migration 009 creates the embedding storage layer for Gate B. It introduces a sqlite-vec vec0 virtual table for vector search and an embedding_metadata table for tracking provenance per chunk. Unlike migrations 007-008, this migration REQUIRES sqlite-vec to be loaded before it can be applied. The migration runner in db.rs must load the sqlite-vec extension first.

## Approach
Create `migrations/009_embeddings.sql` per PRD Section 1.3.

**Tables:**
1. `embeddings` — vec0 virtual table with `embedding float[768]`
2. `embedding_metadata` — tracks per-chunk provenance with composite PK (document_id, chunk_index)
3. Orphan cleanup trigger: `documents_embeddings_ad` — deletes ALL chunk embeddings when a document is deleted using range deletion `[doc_id * 1000, (doc_id + 1) * 1000)`

**Critical: sqlite-vec loading:**
The migration runner in `src/core/db.rs` must load sqlite-vec BEFORE applying any migrations. This means adding extension loading to the `create_connection()` or `run_migrations()` function. sqlite-vec is loaded via:
```rust
conn.load_extension_enable()?;
conn.load_extension("vec0", None)?;  // or platform-specific path
conn.load_extension_disable()?;
```

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

## Acceptance Criteria
- [ ] `migrations/009_embeddings.sql` file exists
- [ ] `embeddings` vec0 virtual table created with `embedding float[768]`
- [ ] `embedding_metadata` table has composite PK (document_id, chunk_index)
- [ ] `embedding_metadata.document_id` has FK to documents(id) ON DELETE CASCADE
- [ ] Error tracking fields: last_error, attempt_count, last_attempt_at
- [ ] Orphan cleanup trigger: deletes embeddings WHERE rowid in [doc_id*1000, (doc_id+1)*1000)
- [ ] Index on embedding_metadata(last_error) WHERE last_error IS NOT NULL
- [ ] Index on embedding_metadata(document_id)
- [ ] Schema version 9 recorded
- [ ] Migration runner loads sqlite-vec before applying migrations
- [ ] `cargo build` succeeds

## Files
- `migrations/009_embeddings.sql` — new file (copy exact SQL from PRD Section 1.3)
- `src/core/db.rs` — add migration 9 to MIGRATIONS array; add sqlite-vec extension loading

## TDD Loop
RED: Register migration in db.rs, `cargo test migration_tests` fails
GREEN: Create SQL file + add extension loading
VERIFY: `cargo test migration_tests && cargo build`

## Edge Cases
- sqlite-vec not installed: migration fails with clear error (not a silent skip)
- Migration applied without sqlite-vec loaded: `CREATE VIRTUAL TABLE` fails with "no such module: vec0"
- Documents deleted before embeddings: trigger fires but vec0 DELETE on empty range is safe
- vec0 doesn't support FK cascades: that's why we need the explicit trigger

Completed: migration 009_embeddings.sql with vec0 table, embedding_metadata with composite PK, orphan cleanup trigger, registered in db.rs


OBSERV: Enhance sync-status to show recent runs with metrics

## Background
sync_status currently queries sync_runs but always gets zero rows (nothing writes to the table). After bd-23a4 wires up SyncRunRecorder, rows will exist. This bead enhances the display to show recent runs with metrics.

## Approach
### src/cli/commands/sync_status.rs

1. Change get_last_sync_run() (line ~66) to get_recent_sync_runs() returning last N:
```rust
fn get_recent_sync_runs(conn: &Connection, limit: usize) -> Result<Vec<SyncRunInfo>> {
    let mut stmt = conn.prepare(
        "SELECT id, started_at, finished_at, status, command, error,
                run_id, total_items_processed, total_errors, metrics_json
         FROM sync_runs
         ORDER BY started_at DESC
         LIMIT ?1",
    )?;
    // ... map rows to SyncRunInfo
}
```

2. Extend SyncRunInfo to include new fields:
```rust
pub struct SyncRunInfo {
    pub id: i64,
    pub started_at: i64,
    pub finished_at: Option<i64>,
    pub status: String,
    pub command: String,
    pub error: Option<String>,
    pub run_id: Option<String>,          // NEW
    pub total_items_processed: i64,       // NEW
    pub total_errors: i64,                // NEW
    pub stages: Option<Vec<StageTiming>>, // NEW: parsed from metrics_json
}
```

3. Parse metrics_json into Vec<StageTiming>:
```rust
let stages: Option<Vec<StageTiming>> = row.get::<_, Option<String>>(9)?
    .and_then(|json| serde_json::from_str(&json).ok());
```

4. Interactive output (new format):
```
Recent sync runs:
  Run a1b2c3 | 2026-02-04 14:32 | 45.2s | 235 items | 1 error
  Run d4e5f6 | 2026-02-03 14:30 | 38.1s | 220 items | 0 errors
  Run g7h8i9 | 2026-02-02 14:29 | 42.7s | 228 items | 0 errors
```

5. Robot JSON output: runs array with stages parsed from metrics_json:
```json
{
    "ok": true,
    "data": {
        "runs": [{ "run_id": "...", "stages": [...] }],
        "cursors": [...],
        "summary": {...}
    }
}
```

6. Add --run <run_id> flag to sync-status subcommand for single-run detail view (shows full stage breakdown).

## Acceptance Criteria
- [ ] lore sync-status shows last 10 runs (not just 1) with run_id, duration, items, errors
- [ ] lore --robot sync-status JSON includes runs array with stages parsed from metrics_json
- [ ] lore sync-status --run a1b2c3 shows single run detail with full stage breakdown
- [ ] When no runs exist, shows appropriate "No sync runs recorded" message
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/cli/commands/sync_status.rs (rewrite query, extend structs, update display)

## TDD Loop
RED:
  - test_sync_status_shows_runs: insert 3 sync_runs rows, call print function, assert all 3 shown
  - test_sync_status_json_includes_stages: insert row with metrics_json, verify robot JSON has stages
  - test_sync_status_empty: no rows, verify graceful message
GREEN: Rewrite get_last_sync_run -> get_recent_sync_runs, extend SyncRunInfo, update output
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- metrics_json is NULL (old rows or failed runs): stages field is null/empty in output
- metrics_json is malformed: serde_json::from_str fails silently (.ok()), stages is None
- Duration calculation: finished_at - started_at in ms. If finished_at is NULL (running), show "in progress"

Enhanced sync-status: shows last 10 runs with run_id, duration, items, errors, parsed stages; JSON includes full stages array


Add migration for dual-path and reviewer participation indexes

## Background
The restructured expert SQL (bd-1hoq) uses UNION ALL + dedup to match both old_path and new_path columns. Without indexes on old_path columns, these branches would force table scans. The reviewer_participation CTE joins notes -> discussions and needs index coverage on discussion_id. Path resolution probes (build_path_query, suffix_probe) need their own old_path index optimized for existence checks.

## Approach
Create migration file migrations/026_scoring_indexes.sql (latest is 025_note_dirty_backfill.sql). Add entry to MIGRATIONS array in db.rs. LATEST_SCHEMA_VERSION auto-increments via MIGRATIONS.len().

### Migration SQL (5 indexes):
```sql
-- 1. Support old_path leg of matched_notes CTE (scoring queries)
CREATE INDEX IF NOT EXISTS idx_notes_old_path_author
  ON notes(position_old_path, author_username, created_at)
  WHERE note_type = 'DiffNote' AND is_system = 0 AND position_old_path IS NOT NULL;

-- 2. Support old_path leg of matched_file_changes CTE
CREATE INDEX IF NOT EXISTS idx_mfc_old_path_project_mr
  ON mr_file_changes(old_path, project_id, merge_request_id)
  WHERE old_path IS NOT NULL;

-- 3. Ensure new_path index parity for matched_file_changes CTE
CREATE INDEX IF NOT EXISTS idx_mfc_new_path_project_mr
  ON mr_file_changes(new_path, project_id, merge_request_id);

-- 4. Support reviewer_participation CTE: notes -> discussions join
CREATE INDEX IF NOT EXISTS idx_notes_diffnote_discussion_author
  ON notes(discussion_id, author_username, created_at)
  WHERE note_type = 'DiffNote' AND is_system = 0;

-- 5. Support path resolution probes on old_path (build_path_query + suffix_probe)
CREATE INDEX IF NOT EXISTS idx_notes_old_path_project_created
  ON notes(position_old_path, project_id, created_at)
  WHERE note_type = 'DiffNote' AND is_system = 0 AND position_old_path IS NOT NULL;
```

### MIGRATIONS array addition (src/core/db.rs, after the ("025", ...) entry):
```rust
("026", include_str!("../../migrations/026_scoring_indexes.sql")),
```

## Acceptance Criteria
- [ ] Migration file at migrations/026_scoring_indexes.sql with 5 CREATE INDEX statements
- [ ] MIGRATIONS array has ("026", include_str!("../../migrations/026_scoring_indexes.sql"))
- [ ] LATEST_SCHEMA_VERSION auto-increments to 26 (MIGRATIONS.len())
- [ ] cargo test (migration tests pass on both fresh and migrated :memory: DBs)
- [ ] Existing indexes unaffected

## Files
- CREATE: migrations/026_scoring_indexes.sql
- MODIFY: src/core/db.rs (MIGRATIONS array — add entry after ("025", ...) at end of array)

## TDD Loop
RED: cargo test should fail if migration not applied (schema version mismatch)
GREEN: Add migration file + MIGRATIONS entry
VERIFY: cargo test -p lore

## Edge Cases
- Use CREATE INDEX IF NOT EXISTS (idempotent)
- Partial indexes with WHERE clauses keep size minimal
- position_old_path and old_path can be NULL (handled by WHERE clause)
- idx_notes_old_path_project_created vs idx_notes_old_path_author: former for probes (no author constraint), latter for scoring (author in covering index)

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


[CP1] Epic: Issue Ingestion

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

Success Criteria:
- gi ingest --type=issues fetches all issues (count matches GitLab UI)
- Labels extracted from issue payloads
- Issue discussions fetched per-issue
- Cursor-based sync is resumable
- Sync tracking records all runs
- Single-flight lock prevents concurrent runs

Reference: docs/prd/checkpoint-1.md


NOTE-0C: Sweep safety guard for partial fetch protection

## Background
The sweep pattern (delete notes where last_seen_at < run_seen_at) is correct only when a discussion's notes were fully fetched. If a page fails mid-fetch, the current logic would incorrectly delete valid notes that weren't seen during the incomplete fetch. Especially dangerous for long threads spanning multiple API pages.

## Approach
Add a fetch_complete: bool parameter to discussion ingestion functions. Only run sweep when fetch completed successfully:

if fetch_complete {
    sweep_stale_issue_notes(&tx, local_discussion_id, last_seen_at)?;
} else {
    tracing::warn!(discussion_id = local_discussion_id, "Skipping stale note sweep due to partial/incomplete fetch");
}

Determining fetch_complete: Look at the existing pagination_error pattern in src/ingestion/discussions.rs lines 148-154. When pagination_error is None (all pages fetched successfully), fetch_complete = true. When pagination_error is Some (network error, rate limit, interruption), fetch_complete = false. The MR path has a similar pattern in src/ingestion/mr_discussions.rs — search for where sweep_stale_discussions (line 539) and sweep_stale_notes (line 551) are called to find the equivalent guard.

The fetch_complete flag should be threaded from the outer discussion-fetch loop into the per-discussion upsert transaction, NOT as a parameter on sweep itself (sweep always sweeps — the caller decides whether to call it).

## Files
- MODIFY: src/ingestion/discussions.rs (guard sweep call with fetch_complete, lines 132-146)
- MODIFY: src/ingestion/mr_discussions.rs (guard sweep call, near line 551 call site)

## TDD Anchor
RED: test_partial_fetch_does_not_sweep_notes — 5 notes in DB, partial fetch returns 2, assert all 5 still exist.
GREEN: Add fetch_complete guard around sweep call.
VERIFY: cargo test partial_fetch_does_not_sweep -- --nocapture
Tests: test_complete_fetch_runs_sweep_normally, test_partial_fetch_then_complete_fetch_cleans_up

## Acceptance Criteria
- [ ] Sweep only runs when fetch_complete = true
- [ ] Partial fetch logs a warning (tracing::warn!) but preserves all notes
- [ ] Second complete fetch correctly sweeps notes deleted on GitLab
- [ ] Both issue and MR discussion paths support fetch_complete
- [ ] All 3 tests pass

## Dependency Context
- Depends on NOTE-0A (bd-3bpk): modifies the sweep call site from NOTE-0A. The sweep functions must exist before this guard can wrap them.

## Edge Cases
- Rate limit mid-page: pagination_error triggers partial fetch — sweep must be skipped
- Discussion with 1 page of notes: always fully fetched if no error, sweep runs normally
- Empty discussion (0 notes returned): still counts as complete fetch — sweep is a no-op anyway


[CP1] GitLab types for issues, discussions, notes

Add Rust types to src/gitlab/types.rs for GitLab API responses.

## Types to Add

### GitLabIssue
- id: i64 (GitLab global ID)
- iid: i64 (project-scoped issue number)
- project_id: i64
- title: String
- description: Option<String>
- state: String ("opened" | "closed")
- created_at, updated_at: String (ISO 8601)
- closed_at: Option<String>
- author: GitLabAuthor
- labels: Vec<String> (array of label names - CP1 canonical)
- web_url: String
NOTE: labels_details intentionally NOT modeled - varies across GitLab versions

### GitLabAuthor
- id: i64
- username: String
- name: String

### GitLabDiscussion
- id: String (like "6a9c1750b37d...")
- individual_note: bool
- notes: Vec<GitLabNote>

### GitLabNote
- id: i64
- note_type: Option<String> ("DiscussionNote" | "DiffNote" | null)
- body: String
- author: GitLabAuthor
- created_at, updated_at: String (ISO 8601)
- system: bool
- resolvable: bool (default false)
- resolved: bool (default false)
- resolved_by: Option<GitLabAuthor>
- resolved_at: Option<String>
- position: Option<GitLabNotePosition>

### GitLabNotePosition
- old_path, new_path: Option<String>
- old_line, new_line: Option<i32>

Files: src/gitlab/types.rs
Tests: Test deserialization with fixtures
Done when: Types compile and deserialize sample API responses correctly


Split who.rs into who/ module (queries + scoring + formatting)

who.rs is 3742 lines — the largest file in the project. Split into src/cli/commands/who/mod.rs (re-exports, CLI arg handling), who/queries.rs (SQL queries, data retrieval), who/scoring.rs (half_life_decay, normalize_query_path, scoring aggregation), who/format.rs (human-readable + robot-mode output formatting). Keep public API identical via re-exports.


[CP1] Discussion ingestion module

Fetch and store discussions/notes for each issue.

## Module
src/ingestion/discussions.rs

## Key Structs

### IngestDiscussionsResult
- discussions_fetched: usize
- discussions_upserted: usize
- notes_upserted: usize
- system_notes_count: usize

## Main Function
pub async fn ingest_issue_discussions(
    conn, client, config,
    project_id, gitlab_project_id,
    issue_iid, local_issue_id, issue_updated_at
) -> Result<IngestDiscussionsResult>

## Logic
1. Paginate through all discussions for given issue
2. For each discussion:
   - Begin transaction
   - Store raw payload (compressed)
   - Transform and upsert discussion record with correct issue FK
   - Get local discussion ID
   - Transform notes from discussion
   - For each note:
     - Store raw payload
     - Upsert note with discussion_id FK
     - Count system notes
   - Commit transaction
3. After all discussions synced: mark_discussions_synced(conn, local_issue_id, issue_updated_at)
   - UPDATE issues SET discussions_synced_for_updated_at = ? WHERE id = ?

## Invariant
A rerun MUST NOT refetch discussions for issues whose updated_at has not advanced, even with cursor rewind. The discussions_synced_for_updated_at watermark ensures this.

## Helper Functions
- upsert_discussion(conn, discussion, payload_id)
- get_local_discussion_id(conn, project_id, gitlab_id) -> i64
- upsert_note(conn, discussion_id, note, payload_id)
- mark_discussions_synced(conn, issue_id, issue_updated_at)

Files: src/ingestion/discussions.rs
Tests: tests/discussion_watermark_tests.rs
Done when: Discussions and notes populated with correct FKs, watermark prevents redundant refetch


Implement project resolution for --project filter

## Background
The --project filter on search (and other commands) accepts a string that must be resolved to a project_id. Users may type the full path, a partial path, or just the project name. The resolution logic provides cascading match with helpful error messages when ambiguous. This improves ergonomics for multi-project installations.

## Approach
Implement project resolution function (location TBD — likely `src/core/project.rs` or inline in search filters):

```rust
/// Resolve a project string to a project_id using cascading match:
/// 1. Exact match on path_with_namespace
/// 2. Case-insensitive exact match
/// 3. Suffix match (only if unambiguous)
/// 4. Error with available projects list
pub fn resolve_project(conn: &Connection, project_str: &str) -> Result<i64>
```

**SQL queries:**
```sql
-- Step 1: exact match
SELECT id FROM projects WHERE path_with_namespace = ?

-- Step 2: case-insensitive
SELECT id FROM projects WHERE LOWER(path_with_namespace) = LOWER(?)

-- Step 3: suffix match
SELECT id, path_with_namespace FROM projects
WHERE path_with_namespace LIKE '%/' || ?
   OR path_with_namespace = ?

-- Step 4: list all for error message
SELECT path_with_namespace FROM projects ORDER BY path_with_namespace
```

**Error format:**
```
Error: Project 'auth-service' not found.

Available projects:
  backend/auth-service
  frontend/auth-service-ui
  infra/auth-proxy

Hint: Use the full path, e.g., --project=backend/auth-service
```

Uses `LoreError::Ambiguous` variant for multiple suffix matches.

## Acceptance Criteria
- [ ] Exact match: "group/project" resolves correctly
- [ ] Case-insensitive: "Group/Project" resolves to "group/project"
- [ ] Suffix match: "project-name" resolves when only one "*/project-name" exists
- [ ] Ambiguous suffix: error lists matching projects with hint
- [ ] No match: error lists all available projects with hint
- [ ] Empty projects table: clear error message
- [ ] `cargo test project_resolution` passes

## Files
- `src/core/project.rs` — new file (or add to existing module)
- `src/core/mod.rs` — add `pub mod project;`

## TDD Loop
RED: Tests:
- `test_exact_match` — full path resolves
- `test_case_insensitive` — mixed case resolves
- `test_suffix_unambiguous` — short name resolves when unique
- `test_suffix_ambiguous` — error with list when multiple match
- `test_no_match` — error with available projects
GREEN: Implement resolve_project
VERIFY: `cargo test project_resolution`

## Edge Cases
- Project path containing special LIKE characters (%, _): unlikely but escape for safety
- Single project in DB: suffix always unambiguous
- Project path with multiple slashes: "org/group/project" — suffix match on "project" works

Implemented resolve_project() with cascading match (exact, CI, suffix) + 6 tests


Implement GraphQL client with partial-error handling

## Background
GitLab's GraphQL endpoint (/api/graphql) uses different auth than REST (Bearer token, not PRIVATE-TOKEN). We need a minimal GraphQL client that handles the GitLab-specific error codes and partial-data responses per GraphQL spec. The client returns a GraphqlQueryResult struct that propagates partial-error metadata end-to-end.

## Approach
Create a new file src/gitlab/graphql.rs with GraphqlClient (uses reqwest). Add httpdate crate for Retry-After HTTP-date parsing. Wire into the module tree. Factory on GitLabClient keeps token encapsulated.

## Files
- src/gitlab/graphql.rs (NEW) — GraphqlClient struct, GraphqlQueryResult, ansi256_from_rgb
- src/gitlab/mod.rs (add pub mod graphql;)
- src/gitlab/client.rs (add graphql_client() factory method)
- Cargo.toml (add httpdate dependency)

## Implementation

GraphqlClient struct:
  Fields: http (reqwest::Client with 30s timeout), base_url (String), token (String)
  Constructor: new(base_url, token) — trims trailing slash from base_url

query() method:
  - POST to {base_url}/api/graphql
  - Headers: Authorization: Bearer {token}, Content-Type: application/json
  - Body: {"query": "...", "variables": {...}}
  - Returns Result<GraphqlQueryResult>

GraphqlQueryResult struct (pub):
  data: serde_json::Value
  had_partial_errors: bool
  first_partial_error: Option<String>

HTTP status mapping:
  401 | 403 -> LoreError::GitLabAuthFailed
  404 -> LoreError::GitLabNotFound { resource: "GraphQL endpoint" }
  429 -> LoreError::GitLabRateLimited { retry_after } (parse Retry-After: try u64 first, then httpdate::parse_http_date, fallback 60)
  Other non-success -> LoreError::Other

GraphQL-level error handling:
  errors array present + data absent/null -> Err(LoreError::Other("GraphQL error: {first_msg}"))
  errors array present + data present -> Ok(GraphqlQueryResult { data, had_partial_errors: true, first_partial_error: Some(first_msg) })
  No errors + data present -> Ok(GraphqlQueryResult { data, had_partial_errors: false, first_partial_error: None })
  No errors + no data -> Err(LoreError::Other("missing 'data' field"))

ansi256_from_rgb(r, g, b) -> u8:
  Maps RGB to nearest ANSI 256-color index using 6x6x6 cube (indices 16-231).
  MUST be placed BEFORE #[cfg(test)] module (clippy::items_after_test_module).

Factory in src/gitlab/client.rs:
  pub fn graphql_client(&self) -> crate::gitlab::graphql::GraphqlClient {
      crate::gitlab::graphql::GraphqlClient::new(&self.base_url, &self.token)
  }

## Acceptance Criteria
- [ ] query() sends POST with Bearer auth header
- [ ] Success: returns GraphqlQueryResult { data, had_partial_errors: false }
- [ ] Errors-only (no data): returns Err with first error message
- [ ] Partial data + errors: returns Ok with had_partial_errors: true
- [ ] 401 -> GitLabAuthFailed
- [ ] 403 -> GitLabAuthFailed
- [ ] 404 -> GitLabNotFound
- [ ] 429 -> GitLabRateLimited (parses Retry-After delta-seconds and HTTP-date, fallback 60)
- [ ] ansi256_from_rgb: (0,0,0)->16, (255,255,255)->231
- [ ] cargo check --all-targets passes

## TDD Loop
RED: test_graphql_query_success, test_graphql_query_with_errors_no_data, test_graphql_auth_uses_bearer, test_graphql_401_maps_to_auth_failed, test_graphql_403_maps_to_auth_failed, test_graphql_404_maps_to_not_found, test_graphql_partial_data_with_errors_returns_data, test_retry_after_http_date_format, test_retry_after_invalid_falls_back_to_60, test_ansi256_from_rgb
  Tests use wiremock or similar mock HTTP server
GREEN: Implement GraphqlClient, add httpdate to Cargo.toml
VERIFY: cargo test graphql && cargo test ansi256

## Edge Cases
- Use r##"..."## in tests containing "#1f75cb" hex colors (# breaks r#"..."#)
- LoreError::GitLabRateLimited uses u64 not Option<u64> — use .unwrap_or(60)
- httpdate::parse_http_date returns SystemTime — compute duration_since(now) for delta
- GraphqlQueryResult is NOT Clone — tests must check fields individually

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


Add fetchResourceEvents config flag to SyncConfig

## Background
Event fetching should be opt-in (default true) so users who don't need temporal queries skip 3 extra API calls per entity. This follows the existing SyncConfig pattern with serde defaults and camelCase JSON aliases.

## Approach
Add to SyncConfig in src/core/config.rs:
```rust
#[serde(rename = "fetchResourceEvents", default = "default_true")]
pub fetch_resource_events: bool,
```

Add default function (if not already present):
```rust
fn default_true() -> bool { true }
```

Update Default impl for SyncConfig to include `fetch_resource_events: true`.

Add --no-events flag to sync command in src/cli/mod.rs (SyncArgs):
```rust
/// Skip resource event fetching (overrides config)
#[arg(long = "no-events", help_heading = "Sync Options")]
pub no_events: bool,
```

In the sync command handler (src/cli/commands/sync.rs), override config when flag is set:
```rust
if args.no_events {
    config.sync.fetch_resource_events = false;
}
```

## Acceptance Criteria
- [ ] SyncConfig deserializes `fetchResourceEvents: false` from JSON config
- [ ] SyncConfig defaults to `fetch_resource_events: true` when field absent
- [ ] `--no-events` flag parses correctly in CLI
- [ ] `--no-events` overrides config to false
- [ ] `cargo test` passes with no regressions

## Files
- src/core/config.rs (add field to SyncConfig + default fn + Default impl)
- src/cli/mod.rs (add --no-events to SyncArgs)
- src/cli/commands/sync.rs (override config when flag set)

## TDD Loop
RED: tests/config_tests.rs (or inline in config.rs):
- `test_sync_config_fetch_resource_events_default_true` - omit field from JSON, verify default
- `test_sync_config_fetch_resource_events_explicit_false` - set field false, verify parsed
- `test_sync_config_no_events_flag` - verify CLI arg parsing

GREEN: Add the field, default fn, Default impl update, CLI flag, and override logic

VERIFY: `cargo test config -- --nocapture && cargo build`

## Edge Cases
- Ensure serde rename matches camelCase convention used by all other SyncConfig fields
- The default_true fn may already exist for other fields — check before adding duplicate
- The --no-events flag must NOT be confused with --no-X negation flags already in CLI (check mod.rs for conflicts)

Completed: Added fetch_resource_events bool to SyncConfig with serde rename, default_true, --no-events CLI flag, and config override in sync handler


FrankenTUI integration proof + terminal compat smoke test

## Background
This is the critical validation that FrankenTUI works with our setup. A minimal Model trait implementation must compile, render a frame, and handle basic input. Terminal compatibility must be verified in iTerm2 and tmux. This proves the toolchain gate before investing in the full implementation.

## Approach
In crates/lore-tui/src/app.rs, implement a minimal LoreApp that:
- implements ftui_runtime::program::Model with type Message = Msg
- init() returns Cmd::none()
- update() handles Msg::Quit to return None (exit) and ignores everything else
- view() renders a simple "lore TUI" text centered on screen
- subscriptions() returns empty vec

Add a smoke test binary or integration test that:
- Creates a TerminalSession with ftui test harness
- Verifies Model::view() produces non-empty output
- Verifies resize events are handled without panic
- Tests render in both fullscreen and inline(12) modes

Terminal compat: manually verify ftui demo-showcase renders correctly in iTerm2 and tmux (document results in test notes).

## Acceptance Criteria
- [ ] LoreApp implements Model trait with Msg as message type
- [ ] App::fullscreen(lore_app).run() compiles (even if not runnable in CI without a TTY)
- [ ] App::inline(lore_app, 12).run() compiles
- [ ] Panic hook installed: terminal restored on crash (crossterm disable_raw_mode + LeaveAlternateScreen)
- [ ] Crash report written to ~/.local/share/lore/crash-{timestamp}.log with redacted sensitive data
- [ ] Crash file retention: max 20 files, oldest deleted
- [ ] ftui demo-showcase renders correctly in iTerm2 (documented)
- [ ] ftui demo-showcase renders correctly in tmux (documented)
- [ ] Binary size increase < 5MB over current lore binary

## Files
- CREATE: crates/lore-tui/src/app.rs (minimal Model impl)
- MODIFY: crates/lore-tui/src/lib.rs (add install_panic_hook_for_tui, crash report logic)
- CREATE: crates/lore-tui/src/crash_context.rs (ring buffer stub for crash diagnostics)

## TDD Anchor
RED: Write test_app_model_compiles that creates LoreApp and calls init(), verifying it returns without error.
GREEN: Implement minimal LoreApp struct with Model trait.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_app_model

## Edge Cases
- CI environments have no TTY — tests must use ftui test harness, not actual terminal
- tmux may not support all ANSI features — FrankenTUI's BOCPD resize coalescing must be verified
- Panic hook must handle double-panic gracefully (don't panic inside the panic hook)
- Crash context ring buffer must be lock-free readable from panic hook (signal safety)

## Dependency Context
Uses crate scaffold (Cargo.toml, rust-toolchain.toml) from "Create lore-tui crate scaffold" task.
Uses Msg enum and Screen type from "Implement core types" task.

LoreApp Model trait impl: init/update/view + compile-time App::fullscreen/inline assertions. 4 tests, quality gate green.


Add 'lore count references' command

## Background

The count command currently supports issues, mrs, discussions, notes, and events. This adds 'references' as a new entity type, showing cross-reference totals and breakdowns by reference_type and source_method.

## Codebase Context

- entity_references table (migration 011) with:
  - reference_type CHECK: 'closes' | 'mentioned' | 'related'
  - source_method CHECK: 'api' | 'note_parse' | 'description_parse'
  - target_entity_id: NULL for unresolved cross-project refs
- Count command pattern in src/cli/commands/count.rs: run_count() returns CountResult, handle_count formats output
- events count already implemented as a special case: run_count_events() in main.rs (line ~829)
- count.rs has value_parser list for entity arg
- 26 migrations exist (001-026). entity_references was introduced in migration 011.

## Approach

### 1. Add to CountArgs value_parser in `src/cli/mod.rs`:
```rust
#[arg(value_parser = ["issues", "mrs", "discussions", "notes", "events", "references"])]
pub entity: String,
```

### 2. Add types and query in `src/cli/commands/count.rs`:

```rust
pub struct ReferenceCountResult {
    pub total: i64,
    pub by_type: HashMap<String, i64>,     // closes, mentioned, related
    pub by_method: HashMap<String, i64>,   // api, note_parse, description_parse
    pub unresolved: i64,
}
```

### 3. SQL (single conditional aggregate query — no N+1):
```sql
SELECT
    COUNT(*) as total,
    COALESCE(SUM(CASE WHEN reference_type = 'closes' THEN 1 ELSE 0 END), 0) as closes,
    COALESCE(SUM(CASE WHEN reference_type = 'mentioned' THEN 1 ELSE 0 END), 0) as mentioned,
    COALESCE(SUM(CASE WHEN reference_type = 'related' THEN 1 ELSE 0 END), 0) as related,
    COALESCE(SUM(CASE WHEN source_method = 'api' THEN 1 ELSE 0 END), 0) as api,
    COALESCE(SUM(CASE WHEN source_method = 'note_parse' THEN 1 ELSE 0 END), 0) as note_parse,
    COALESCE(SUM(CASE WHEN source_method = 'description_parse' THEN 1 ELSE 0 END), 0) as desc_parse,
    COALESCE(SUM(CASE WHEN target_entity_id IS NULL THEN 1 ELSE 0 END), 0) as unresolved
FROM entity_references
```

### 4. Human output:
```
References: 1,234
  By type:
    closes:    456
    mentioned: 678
    related:   100
  By source:
    api:              234
    note_parse:       890
    description_parse: 110
  Unresolved: 45 (3.6%)
```

### 5. Robot JSON:
```json
{
  "ok": true,
  "data": {
    "entity": "references",
    "total": 1234,
    "by_type": { "closes": 456, "mentioned": 678, "related": 100 },
    "by_method": { "api": 234, "note_parse": 890, "description_parse": 110 },
    "unresolved": 45
  }
}
```

### 6. Wire in main.rs handle_count:
Add "references" branch, similar to the existing "events" special case.

## Acceptance Criteria

- [ ] `lore count references` works with human output
- [ ] `lore --robot count references` returns JSON with {ok, data, meta} envelope
- [ ] by_type uses codebase values: closes, mentioned, related
- [ ] by_method uses codebase values: api, note_parse, description_parse
- [ ] Unresolved = COUNT WHERE target_entity_id IS NULL
- [ ] Zero references: all counts 0, not error
- [ ] entity_references table missing (pre-migration-011 schema): graceful error with migration suggestion
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes
- [ ] `cargo fmt --check` passes

## Files

- MODIFY: src/cli/mod.rs (add "references" to value_parser list)
- MODIFY: src/cli/commands/count.rs (add count_references() + ReferenceCountResult)
- MODIFY: src/main.rs (add "references" branch in handle_count)

## TDD Anchor

RED: test_count_references_query — in-memory DB with migration 011+, insert 3 entity_references rows (one closes/api, one mentioned/note_parse, one related/api with target_entity_id=NULL), verify all counts.

GREEN: Implement query, result type, output formatters.

VERIFY: cargo test --lib -- count && cargo check --all-targets

## Edge Cases

- entity_references table doesn't exist (pre-migration-011): catch SQL error, return user-friendly message suggesting `lore sync`
- All references unresolved: unresolved = total, percentage = 100%
- Division by zero in percentage: guard with `if total > 0`
- New reference_type/source_method values added in future: they won't appear in breakdown but will be in total — consider logging unknown values

## Dependency Context

- **bd-hu3 / migration 011**: provides the entity_references table with reference_type and source_method CHECK constraints. This bead reads from that table — no writes.
- **count.rs pattern**: run_count() dispatches to entity-specific queries. events already has a special-case function run_count_events() — follow the same pattern for references.

Implemented lore count references with by_type/by_method breakdowns, unresolved count, human + robot output, 2 unit tests. All quality gates pass.


NOTE-2D: Regenerator and dirty tracking for note documents

## Background
Wire note document extraction into the regenerator and add change-aware dirty marking in the ingestion pipeline. When a note's semantic content changes during upsert, it gets queued for document regeneration.

## Approach
1. Update regenerate_one() in src/documents/regenerator.rs (line 86-91):
   Add match arm: SourceType::Note => extract_note_document(conn, source_id)?
   Add import: use crate::documents::extract_note_document;
   This replaces the temporary unreachable!() from NOTE-2B.

2. Add change-aware dirty marking in src/ingestion/discussions.rs (in upsert loop modified by NOTE-0A):
   After each upsert_note_for_issue call:
   if !note.is_system && outcome.changed_semantics {
       dirty_tracker::mark_dirty_tx(&tx, SourceType::Note, outcome.local_note_id)?;
   }
   Import: use crate::documents::SourceType;

3. Same in src/ingestion/mr_discussions.rs for MR note upserts (after upsert_note call near line 470 area, once NOTE-0A modifies it to return NoteUpsertOutcome).

4. Update test setup helpers:
   - src/documents/regenerator.rs tests: the setup_db() function creates test tables. Add notes + discussions tables so regenerate_one can be tested with SourceType::Note. Also update the dirty_sources CHECK constraint in test setup to include 'note'.
   - src/ingestion/dirty_tracker.rs tests: similar test setup_db() update for CHECK constraint.

## Files
- MODIFY: src/documents/regenerator.rs (add Note match arm at line 90, add import, update test setup_db)
- MODIFY: src/ingestion/discussions.rs (add dirty marking after upsert loop)
- MODIFY: src/ingestion/mr_discussions.rs (add dirty marking after upsert)
- MODIFY: src/ingestion/dirty_tracker.rs (update test setup_db CHECK constraint if present)

## TDD Anchor
RED: test_regenerate_note_document — create project, issue, discussion, note, mark dirty, call regenerate_dirty_documents, assert document created with source_type='note'.
GREEN: Add SourceType::Note arm to regenerate_one.
VERIFY: cargo test regenerate_note_document -- --nocapture
Tests: test_regenerate_note_system_note_deletes (system note in dirty queue → document gets deleted), test_regenerate_note_unchanged (same content hash → no update), test_note_ingestion_idempotent_across_two_syncs (identical re-sync produces no new dirty entries), test_mark_dirty_note_type

## Acceptance Criteria
- [ ] regenerate_one() handles SourceType::Note via extract_note_document
- [ ] Changed notes queued as dirty during issue discussion ingestion
- [ ] Changed notes queued as dirty during MR discussion ingestion
- [ ] System notes never queued as dirty (is_system guard)
- [ ] Unchanged notes not re-queued (changed_semantics = false from NOTE-0A)
- [ ] Second sync of identical data produces no new dirty entries
- [ ] All 5 tests pass

## Dependency Context
- Depends on NOTE-0A (bd-3bpk): uses NoteUpsertOutcome.changed_semantics from upsert functions
- Depends on NOTE-2B (bd-ef0u): SourceType::Note enum variant for dirty marking and match arm
- Depends on NOTE-2C (bd-18yh): extract_note_document function for the regenerator dispatch

## Edge Cases
- Note deleted during regeneration: extract_note_document returns None → delete_document called (line 93-95 of regenerator.rs)
- System note in dirty queue (from manual INSERT): extract returns None → document deleted
- Concurrent sync + regeneration: dirty_tracker uses ON CONFLICT handling


[CP1] gi count issues/discussions/notes commands

## Background

The `gi count` command provides quick counts of entities in the local database. It supports counting issues, MRs, discussions, and notes, with optional filtering by noteable type. This enables quick validation that sync is working correctly.

## Approach

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

### Clap Definition

```rust
#[derive(Args)]
pub struct CountArgs {
    /// Entity type to count
    #[arg(value_parser = ["issues", "mrs", "discussions", "notes"])]
    pub entity: String,

    /// Filter by noteable type (for discussions/notes)
    #[arg(long, value_parser = ["issue", "mr"])]
    pub r#type: Option<String>,
}
```

### Handler Function

```rust
pub async fn handle_count(args: CountArgs, conn: &Connection) -> Result<()>
```

### Queries by Entity

**issues:**
```sql
SELECT COUNT(*) FROM issues
```
Output: `Issues: 3,801`

**discussions:**
```sql
-- Without type filter
SELECT COUNT(*) FROM discussions

-- With --type=issue
SELECT COUNT(*) FROM discussions WHERE noteable_type = 'Issue'
```
Output: `Issue Discussions: 1,234`

**notes:**
```sql
-- Total and system count
SELECT COUNT(*), SUM(is_system) FROM notes

-- With --type=issue (join through discussions)
SELECT COUNT(*), SUM(n.is_system)
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE d.noteable_type = 'Issue'
```
Output: `Issue Notes: 5,678 (excluding 1,234 system)`

### Output Format

```
Issues: 3,801
```

```
Issue Discussions: 1,234
```

```
Issue Notes: 5,678 (excluding 1,234 system)
```

## Acceptance Criteria

- [ ] `gi count issues` shows total issue count
- [ ] `gi count discussions` shows total discussion count
- [ ] `gi count discussions --type=issue` filters to issue discussions
- [ ] `gi count notes` shows total note count with system note exclusion
- [ ] `gi count notes --type=issue` filters to issue notes
- [ ] Numbers formatted with thousands separators (1,234)

## Files

- src/cli/commands/mod.rs (add `pub mod count;`)
- src/cli/commands/count.rs (create)
- src/cli/mod.rs (add Count variant to Commands enum)

## TDD Loop

RED:
```rust
#[tokio::test] async fn count_issues_returns_total()
#[tokio::test] async fn count_discussions_with_type_filter()
#[tokio::test] async fn count_notes_excludes_system_notes()
```

GREEN: Implement handler with queries

VERIFY: `cargo test count`

## Edge Cases

- Zero entities - show "Issues: 0"
- --type flag invalid for issues/mrs - ignore or error
- All notes are system notes - show "Notes: 0 (excluding 1,234 system)"

Implemented gi count command with issues/discussions/notes support, format_number helper, and system note exclusion


Implement timeline human output renderer

## Background

This bead implements the human-readable (non-robot) output renderer for `lore timeline`. It takes a collection of TimelineEvents and renders them as a colored, chronological timeline in the terminal.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.4 (Human Output Format).

## Codebase Context

- Colored output pattern: src/cli/commands/show.rs uses `colored` crate for terminal styling
- Existing formatters: `print_show_issue()`, `print_show_mr()`, `print_list_issues()`
- TimelineEvent model (bd-20e): timestamp, entity_type, entity_iid, project_path, event_type, summary, actor, url, is_seed
- TimelineEventType enum (bd-20e): Created, StateChanged, LabelAdded, LabelRemoved, MilestoneSet, MilestoneRemoved, Merged, NoteEvidence, CrossReferenced
- Expansion provenance: expanded entities have `via` info (from which seed, what edge type)
- Convention: all output functions take `&[TimelineEvent]` and metadata, not raw DB results

## Approach

Create `src/cli/commands/timeline.rs`:

```rust
use colored::Colorize;
use crate::core::timeline::{TimelineEvent, TimelineEventType, TimelineQueryResult};

pub fn print_timeline(result: &TimelineQueryResult) {
    // Header
    println\!();
    println\!("{}", format\!("Timeline: \"{}\" ({} events across {} entities)",
        result.query, result.events.len(), result.total_entities).bold());
    println\!("{}", "─".repeat(60));
    println\!();

    // Events
    for event in &result.events {
        print_timeline_event(event);
    }

    // Footer
    println\!();
    println\!("{}", "─".repeat(60));
    print_timeline_footer(result);
}

fn print_timeline_event(event: &TimelineEvent) {
    let date = format_date(event.timestamp);
    let tag = format_event_tag(&event.event_type);
    let entity = format_entity_ref(event.entity_type.as_str(), event.entity_iid);
    let actor = event.actor.as_deref().map(|a| format\!("@{a}")).unwrap_or_default();
    let expanded_marker = if event.is_seed { "" } else { "  [expanded]" };

    println\!("{date}  {tag:10}  {entity:6}  {summary:40}  {actor}{expanded_marker}",
        summary = &event.summary);

    // Extra lines for specific event types
    match &event.event_type {
        TimelineEventType::NoteEvidence { snippet, .. } => {
            // Show snippet indented, wrapped to ~70 chars
            for line in wrap_text(snippet, 70) {
                println\!("                             \"{line}\"");
            }
        }
        TimelineEventType::Created => {
            // Could show labels if available in details
        }
        _ => {}
    }
}
```

### Event Tag Colors:
| Tag | Color |
|-----|-------|
| CREATED | green |
| CLOSED | red |
| REOPENED | yellow |
| MERGED | cyan |
| LABEL | blue |
| MILESTONE | magenta |
| NOTE | white/dim |
| REF | dim |

### Date Format:
```
2024-03-15  CREATED   #234  Migrate to OAuth2                    @alice
```
Use `YYYY-MM-DD` for dates. Group consecutive same-day events visually.

Add `pub mod timeline;` to `src/cli/commands/mod.rs` and re-export `print_timeline`.

## Acceptance Criteria

- [ ] `print_timeline()` renders header with query, event count, entity count
- [ ] Events displayed chronologically with: date, tag, entity ref, summary, actor
- [ ] Expanded entities marked with [expanded] suffix
- [ ] NoteEvidence events show snippet text indented and quoted
- [ ] Tags colored by event type
- [ ] Footer shows seed entities and expansion info
- [ ] Module registered in src/cli/commands/mod.rs
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

- `src/cli/commands/timeline.rs` (NEW)
- `src/cli/commands/mod.rs` (add `pub mod timeline;` and re-export `print_timeline`)

## TDD Loop

No unit tests for terminal rendering. Verify visually:

```bash
cargo check --all-targets
# After full pipeline: lore timeline "some query"
```

## Edge Cases

- Empty result: print "No events found for query." and exit 0
- Very long summaries: truncate to 60 chars with "..."
- NoteEvidence snippets: wrap at 70 chars, cap at 4 lines
- Null actors (system events): show no @username
- Entity types: # for issues, \! for MRs (GitLab convention)

Implemented print_timeline() human renderer in src/cli/commands/timeline.rs with colored chronological output, event tags, entity refs, evidence note snippets, and footer summary


Update AGENTS.md and CLAUDE.md with Phase B commands

## Background

After Phase B implementation, update AGENTS.md and CLAUDE.md with temporal intelligence command documentation so agents can discover and use the new commands.

## Codebase Context

- AGENTS.md section "Gitlore Robot Mode" (line ~592) has Robot Mode Commands table
- ~/.claude/CLAUDE.md has matching "Gitlore (lore)" section with command reference
- New Phase B commands: timeline, file-history, trace
- New count entity: references
- sync gains --no-file-changes flag (bd-jec)
- Config gains fetchMrFileChanges (bd-jec) and fetchResourceEvents (already exists)

## Approach

Add "Temporal Intelligence Commands" section after existing Robot Mode Commands in both files:

```bash
# Timeline - chronological event history
lore --robot timeline "authentication" --since 30d
lore --robot timeline "deployment" --depth 2 --expand-mentions

# File History - which MRs touched a file
lore --robot file-history src/auth/oauth.rs --discussions

# Trace - file -> MR -> issue -> discussion chain
lore --robot trace src/auth/oauth.rs --discussions

# Count references - cross-reference statistics
lore --robot count references

# Sync with file changes
lore --robot sync --no-file-changes  # skip MR diff fetching
```

Also document config flags:
```json
{
  "sync": {
    "fetchResourceEvents": true,
    "fetchMrFileChanges": true
  }
}
```

## Acceptance Criteria

- [ ] AGENTS.md has Temporal Intelligence Commands section
- [ ] ~/.claude/CLAUDE.md has matching section
- [ ] All examples are valid, runnable commands
- [ ] Config flags documented (fetchResourceEvents, fetchMrFileChanges)
- [ ] --no-events and --no-file-changes CLI flags documented
- [ ] sync-related changes documented
- [ ] Mentions resource events requirement for timeline queries

## Files

- AGENTS.md (add temporal intelligence section)
- ~/.claude/CLAUDE.md (add matching section)

## Edge Cases

- Both files must stay in sync
- Examples must use --robot flag consistently
- Config flag names use camelCase in JSON, snake_case in Rust


Add GitLab Resource Event serde types

## Background
Need Rust types for deserializing GitLab Resource Events API responses. These map directly to the API JSON shape from three endpoints: resource_state_events, resource_label_events, resource_milestone_events.

Existing pattern: types.rs uses #[derive(Debug, Clone, Deserialize)] with Option<T> for nullable fields. GitLabAuthor is already defined (id, username, name). Tests in tests/gitlab_types_tests.rs use serde_json::from_str with sample payloads.

## Approach
Add to src/gitlab/types.rs (after existing types):

```rust
/// Reference to an MR in state event's source_merge_request field
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitLabMergeRequestRef {
    pub iid: i64,
    pub title: Option<String>,
    pub web_url: Option<String>,
}

/// Reference to a label in label event's label field
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitLabLabelRef {
    pub id: i64,
    pub name: String,
    pub color: Option<String>,
    pub description: Option<String>,
}

/// Reference to a milestone in milestone event's milestone field
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitLabMilestoneRef {
    pub id: i64,
    pub iid: i64,
    pub title: String,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitLabStateEvent {
    pub id: i64,
    pub user: Option<GitLabAuthor>,
    pub created_at: String,
    pub resource_type: String,       // "Issue" | "MergeRequest"
    pub resource_id: i64,
    pub state: String,               // "opened" | "closed" | "reopened" | "merged" | "locked"
    pub source_commit: Option<String>,
    pub source_merge_request: Option<GitLabMergeRequestRef>,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitLabLabelEvent {
    pub id: i64,
    pub user: Option<GitLabAuthor>,
    pub created_at: String,
    pub resource_type: String,
    pub resource_id: i64,
    pub label: GitLabLabelRef,
    pub action: String,              // "add" | "remove"
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitLabMilestoneEvent {
    pub id: i64,
    pub user: Option<GitLabAuthor>,
    pub created_at: String,
    pub resource_type: String,
    pub resource_id: i64,
    pub milestone: GitLabMilestoneRef,
    pub action: String,              // "add" | "remove"
}
```

Also export from src/gitlab/mod.rs if needed.

## Acceptance Criteria
- [ ] All 6 types (3 events + 3 refs) compile
- [ ] GitLabStateEvent deserializes from real GitLab API JSON (with and without source_merge_request)
- [ ] GitLabLabelEvent deserializes with nested label object
- [ ] GitLabMilestoneEvent deserializes with nested milestone object
- [ ] All Optional fields handle null/missing correctly
- [ ] Types exported from lore::gitlab::types

## Files
- src/gitlab/types.rs (add 6 new types)
- tests/gitlab_types_tests.rs (add deserialization tests)

## TDD Loop
RED: Add to tests/gitlab_types_tests.rs:
- `test_deserialize_state_event_closed_by_mr` - JSON with source_merge_request present
- `test_deserialize_state_event_simple` - JSON with source_merge_request null, user null
- `test_deserialize_label_event_add` - label add with full label object
- `test_deserialize_label_event_remove` - label remove
- `test_deserialize_milestone_event` - milestone add with nested milestone
Import new types: `use lore::gitlab::types::{GitLabStateEvent, GitLabLabelEvent, GitLabMilestoneEvent, GitLabMergeRequestRef, GitLabLabelRef, GitLabMilestoneRef};`

GREEN: Add the type definitions to types.rs

VERIFY: `cargo test gitlab_types_tests -- --nocapture`

## Edge Cases
- GitLab sometimes returns user: null for system-generated events (e.g., auto-close on merge) — user must be Option
- source_merge_request can be null even when state is "closed" (manually closed, not by MR)
- label.color may be null for labels created via API without color
- The resource_type field uses PascalCase ("MergeRequest" not "merge_request") — don't confuse with DB entity_type

Completed: Added 6 new types (GitLabMergeRequestRef, GitLabLabelRef, GitLabMilestoneRef, GitLabStateEvent, GitLabLabelEvent, GitLabMilestoneEvent) to types.rs with exports and 8 passing tests


Implement discussion document extraction

## Background
Discussion documents are the most complex extraction — they involve querying discussions + notes + parent entity (issue or MR) + parent labels + DiffNote file paths. The output includes a threaded conversation format with author/date prefixes per note. System notes (bot-generated) are excluded. DiffNote paths are extracted for the --path search filter.

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

```rust
/// Extract a searchable document from a discussion thread.
/// Returns None if the discussion or its parent has been deleted.
pub fn extract_discussion_document(conn: &Connection, discussion_id: i64) -> Result<Option<DocumentData>>
```

**SQL queries (from PRD Section 2.2):**
```sql
-- Discussion metadata
SELECT d.id, d.noteable_type, d.issue_id, d.merge_request_id,
       p.path_with_namespace, p.id AS project_id
FROM discussions d
JOIN projects p ON p.id = d.project_id
WHERE d.id = ?

-- Parent entity (conditional on noteable_type)
-- If Issue: SELECT i.iid, i.title, i.web_url FROM issues i WHERE i.id = ?
-- If MR: SELECT m.iid, m.title, m.web_url FROM merge_requests m WHERE m.id = ?

-- Parent labels (via issue_labels or mr_labels junction)

-- Non-system notes in thread order
SELECT n.author_username, n.body, n.created_at, n.gitlab_id,
       n.note_type, n.position_old_path, n.position_new_path
FROM notes n
WHERE n.discussion_id = ? AND n.is_system = 0
ORDER BY n.created_at ASC, n.id ASC
```

**Document format:**
```
[[Discussion]] Issue #234: Authentication redesign
Project: group/project-one
URL: https://gitlab.example.com/group/project-one/-/issues/234#note_12345
Labels: ["bug", "auth"]
Files: ["src/auth/login.ts"]

--- Thread ---

@johndoe (2024-03-15):
I think we should move to JWT-based auth...

@janedoe (2024-03-15):
Agreed. What about refresh token strategy?
```

**Implementation steps:**
1. Query discussion row — if not found, return Ok(None)
2. Determine parent type (Issue or MR) from noteable_type
3. Query parent entity for iid, title, web_url — if not found, return Ok(None)
4. Query parent labels via appropriate junction table
5. Query non-system notes ordered by created_at ASC, id ASC
6. Extract DiffNote paths: collect position_old_path and position_new_path, dedup
7. Construct URL: `{parent_web_url}#note_{first_note_gitlab_id}`
8. Format header with [[Discussion]] prefix
9. Format thread body: `@author (YYYY-MM-DD):\nbody\n\n` per note
10. Apply discussion truncation via `truncate_discussion()` if needed
11. Author = first non-system note's author_username
12. Compute hashes, return DocumentData

## Acceptance Criteria
- [ ] System notes (is_system=1) excluded from content
- [ ] DiffNote paths extracted from position_old_path and position_new_path
- [ ] Paths deduplicated and sorted
- [ ] URL constructed as `parent_web_url#note_GITLAB_ID`
- [ ] Header uses parent entity type: "Issue #N" or "MR !N"
- [ ] Parent title included in header
- [ ] Labels come from PARENT entity (not the discussion itself)
- [ ] First non-system note author used as document author
- [ ] Thread formatted with `@author (date):` per note
- [ ] Truncation applied for long threads via truncate_discussion()
- [ ] `cargo test extract_discussion` passes

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

## TDD Loop
RED: Tests in `#[cfg(test)] mod tests`:
- `test_discussion_document_format` — verify header + thread format
- `test_discussion_not_found` — returns Ok(None)
- `test_discussion_parent_deleted` — returns Ok(None) when parent issue/MR missing
- `test_discussion_system_notes_excluded` — system notes not in content
- `test_discussion_diffnote_paths` — old_path + new_path extracted and deduped
- `test_discussion_url_construction` — URL has #note_GITLAB_ID anchor
- `test_discussion_uses_parent_labels` — labels from parent entity, not discussion
GREEN: Implement extract_discussion_document
VERIFY: `cargo test extract_discussion`

## Edge Cases
- Discussion with all system notes: no non-system notes -> return empty thread (or skip document entirely?)
- Discussion with NULL parent (orphaned): return Ok(None)
- DiffNote with same old_path and new_path: dedup produces single entry
- Notes with NULL body: skip or use empty string
- Discussion on MR: header shows "MR !N" (not "MergeRequest !N")

Implemented extract_discussion_document() with parent entity lookup, DiffNote paths, system note exclusion, URL construction + 9 tests


Implement crash_context ring buffer + panic hook

## Background
crash_context.rs provides a ring buffer of the last 2000 app events (key presses, messages, state transitions) plus a panic hook that dumps this buffer to a crash file for post-mortem diagnostics. This is critical for debugging TUI issues that only reproduce under specific interaction sequences.

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

**CrashContext struct:**
- events: VecDeque<CrashEvent> (capacity 2000)
- push(event: CrashEvent) — append, auto-evict oldest when full
- dump_to_file(path: &Path) -> io::Result<()> — write all events as newline-delimited JSON
- install_panic_hook() — set_hook that calls dump_to_file to ~/.local/share/lore/crash-<timestamp>.json

**CrashEvent enum:**
- KeyPress { key: String, mode: InputMode, screen: Screen }
- MsgDispatched { msg_name: String, screen: Screen }
- StateTransition { from: Screen, to: Screen }
- Error { message: String }
- Custom { tag: String, detail: String }

**Retention policy:** Keep last 5 crash files, delete older ones on startup.

**Integration:** LoreApp.update() calls crash_context.push() for every Msg before dispatch. The crash_context is a field on LoreApp.

## Acceptance Criteria
- [ ] CrashContext stores up to 2000 events in ring buffer
- [ ] push() evicts oldest event when buffer is full
- [ ] dump_to_file() writes all events as newline-delimited JSON
- [ ] Panic hook installed via std::panic::set_hook
- [ ] Crash file written to ~/.local/share/lore/ with timestamp in filename
- [ ] Retention: only last 5 crash files kept, older deleted on startup
- [ ] Unit test: push 2500 events, assert only last 2000 retained
- [ ] Unit test: dump_to_file writes valid NDJSON

## Files
- CREATE: crates/lore-tui/src/crash_context.rs
- MODIFY: crates/lore-tui/src/lib.rs (add pub mod crash_context)
- MODIFY: crates/lore-tui/src/app.rs (add crash_context field, push in update(), install hook in init())

## TDD Anchor
RED: Write test_ring_buffer_evicts_oldest that creates CrashContext, pushes 2500 events, asserts len()==2000 and first event is event #501.
GREEN: Implement CrashContext with VecDeque and capacity check.
VERIFY: cargo test -p lore-tui crash_context

## Edge Cases
- Crash file directory doesn't exist: create it with fs::create_dir_all
- Disk full during dump: best-effort, don't panic in the panic hook
- Concurrent access: CrashContext is only accessed from the main update() thread, no sync needed
- Event serialization: use serde_json::to_string, fallback to debug format if serialization fails

## Dependency Context
Used by LoreApp (bd-6pmy) — added as a field and called in update(). Uses Screen and InputMode from core types (bd-c9gk).

Implemented crash_context.rs: CrashContext ring buffer (VecDeque, cap 2000), CrashEvent enum (5 variants, serde Serialize), dump_to_file NDJSON, install_panic_hook with chaining, prune_crash_files retention (keep 5). 10 tests. Quality gate green (91 total).