Implement lore search CLI command (lexical mode)

## Background
The search CLI command is the user-facing entry point for Gate A lexical search. It orchestrates the search pipeline: query parsing -> FTS5 search -> filter application -> result hydration (single round-trip) -> display. Gate B extends this same command with --mode=hybrid and --mode=semantic. The hydration query is critical for performance — it fetches all display fields + labels + paths in one SQL query using json_each() + json_group_array().

## Approach
Create `src/cli/commands/search.rs` per PRD Section 3.4.

**Key types:**
- `SearchResultDisplay` — display-ready result with all fields (dates as ISO via `ms_to_iso`)
- `ExplainData` — ranking explanation for --explain flag (vector_rank, fts_rank, rrf_score)
- `SearchResponse` — wrapper with query, mode, total_results, results, warnings

**Core function:**
```rust
pub fn run_search(
    config: &Config,
    query: &str,
    mode: SearchMode,
    filters: SearchFilters,
    explain: bool,
) -> Result<SearchResponse>
```

**Pipeline:**
1. Parse query + filters
2. Execute search based on mode -> ranked doc_ids (+ explain ranks)
3. Apply post-retrieval filters via apply_filters() preserving ranking order
4. Hydrate results in single DB round-trip using json_each + json_group_array
5. Attach snippets: prefer FTS snippet, fallback to `generate_fallback_snippet()` for semantic-only
6. Convert timestamps via `ms_to_iso()` from `crate::core::time`
7. Build SearchResponse

**Hydration query (critical — single round-trip, replaces 60 queries with 1):**
```sql
SELECT d.id, d.source_type, d.title, d.url, d.author_username,
       d.created_at, d.updated_at, d.content_text,
       p.path_with_namespace AS project_path,
       (SELECT json_group_array(dl.label_name)
        FROM document_labels dl WHERE dl.document_id = d.id) AS labels,
       (SELECT json_group_array(dp.path)
        FROM document_paths dp WHERE dp.document_id = d.id) AS paths
FROM json_each(?) AS j
JOIN documents d ON d.id = j.value
JOIN projects p ON p.id = d.project_id
ORDER BY j.key
```

**Human output uses `console::style` for terminal formatting:**
```rust
use console::style;
// Type prefix in cyan
println!("[{}] {} - {} ({})", i+1, style(type_prefix).cyan(), title, score);
// URL in dim
println!("    {}", style(url).dim());
```

**JSON robot mode includes elapsed_ms in meta (PRD Section 3.4):**
```rust
pub fn print_search_results_json(response: &SearchResponse, elapsed_ms: u64) {
    let output = serde_json::json!({
        "ok": true,
        "data": response,
        "meta": { "elapsed_ms": elapsed_ms }
    });
    println!("{}", serde_json::to_string_pretty(&output).unwrap());
}
```

**CLI args in `src/cli/mod.rs` (PRD Section 3.4):**
```rust
#[derive(Args)]
pub struct SearchArgs {
    query: String,
    #[arg(long, default_value = "hybrid")]
    mode: String,
    #[arg(long, value_name = "TYPE")]
    r#type: Option<String>,
    #[arg(long)]
    author: Option<String>,
    #[arg(long)]
    project: Option<String>,
    #[arg(long, action = clap::ArgAction::Append)]
    label: Vec<String>,
    #[arg(long)]
    path: Option<String>,
    #[arg(long)]
    after: Option<String>,
    #[arg(long)]
    updated_after: Option<String>,
    #[arg(long, default_value = "20")]
    limit: usize,
    #[arg(long)]
    explain: bool,
    #[arg(long, default_value = "safe")]
    fts_mode: String,
}
```

**IMPORTANT: default_value = "hybrid"** — When Ollama is unavailable, hybrid mode gracefully degrades to FTS-only with a warning (not an error). `lore search` works without Ollama.

## Acceptance Criteria
- [ ] Default mode is "hybrid" (not "lexical") per PRD
- [ ] Hybrid mode degrades gracefully to FTS-only when Ollama unavailable (warning, not error)
- [ ] All filters work (type, author, project, label, path, after, updated_after, limit)
- [ ] Label filter uses `clap::ArgAction::Append` for repeatable --label flags
- [ ] Hydration in single query (not N+1) — uses json_each + json_group_array
- [ ] Timestamps converted via `ms_to_iso()` for display (ISO format)
- [ ] Human output uses `console::style` for colored type prefix (cyan) and dim URLs
- [ ] JSON robot mode includes `elapsed_ms` in `meta` field
- [ ] Semantic-only results get fallback snippets via `generate_fallback_snippet()`
- [ ] Empty results show friendly message: "No results found for 'query'"
- [ ] "No data indexed" message if documents table empty
- [ ] --explain shows vector_rank, fts_rank, rrf_score per result
- [ ] --fts-mode=safe preserves prefix `*` while escaping special chars
- [ ] --fts-mode=raw passes FTS5 MATCH syntax through unchanged
- [ ] --mode=semantic with 0% embedding coverage returns LoreError::EmbeddingsNotBuilt (not OllamaUnavailable)
- [ ] SearchArgs registered in cli/mod.rs with Clap derive
- [ ] `cargo build` succeeds

## Files
- `src/cli/commands/search.rs` — new file
- `src/cli/commands/mod.rs` — add `pub mod search;`
- `src/cli/mod.rs` — add SearchArgs struct, wire up search subcommand
- `src/main.rs` — add search command handler

## TDD Loop
RED: Integration test requiring DB with documents
- `test_lexical_search_returns_results` — FTS search returns hits
- `test_hydration_single_query` — verify no N+1 (mock/inspect query count)
- `test_json_output_includes_elapsed` — robot mode JSON has meta.elapsed_ms
- `test_empty_results_message` — zero results shows friendly message
- `test_fallback_snippet` — semantic-only result uses truncated content
GREEN: Implement run_search + hydrate_results + print functions
VERIFY: `cargo build && cargo test search`

## Edge Cases
- Zero results: display friendly empty message, JSON returns empty array
- --mode=semantic with 0% embedding coverage: return LoreError::EmbeddingsNotBuilt
- json_group_array returns "[]" for documents with no labels — parse as empty array
- Very long snippets: truncated at display time
- Hybrid default works without Ollama: degrades to FTS-only with warning
- ms_to_iso with epoch 0: return valid ISO string (not crash)

Implemented search CLI with FTS5 + RRF ranking, single-query hydration (json_each + json_group_array), adaptive recall, all filters, --explain, human + JSON output. Builds clean.


WHO: Robot JSON output for all 5 modes

## Background

Robot-mode JSON output following the standard lore envelope: `{"ok":true,"data":{...},"meta":{"elapsed_ms":N}}`. Includes both raw CLI args (input) and computed values (resolved_input) for agent reproducibility.

## Approach

### Envelope structs:
```rust
#[derive(Serialize)]
struct WhoJsonEnvelope { ok: bool, data: WhoJsonData, meta: RobotMeta }

#[derive(Serialize)]
struct WhoJsonData {
    mode: String,
    input: serde_json::Value,
    resolved_input: serde_json::Value,
    #[serde(flatten)]
    result: serde_json::Value,
}
```

### print_who_json(run, args, elapsed_ms):
- `input`: raw CLI args `{ target, path, project, since, limit }`
- `resolved_input`: `{ mode, project_id, project_path, since_ms, since_iso, since_mode, limit }`
- `result`: mode-specific JSON via *_to_json() functions using serde_json::json\!() macro

### Mode-specific JSON fields:
- **Expert**: path_query, path_match, truncated, experts[] with ISO last_seen_at
- **Workload**: username, 4 entity arrays with ref/project_path/ISO timestamps, summary{} counts, truncation{} per-section bools
- **Reviews**: username, total_diffnotes, categorized_count, mrs_reviewed, categories[] with rounded percentages
- **Active**: total_unresolved_in_window, truncated, discussions[] with discussion_id + participants + participants_total + participants_truncated
- **Overlap**: path_query, path_match, truncated, users[] with role + touch counts + mr_refs + mr_refs_total + mr_refs_truncated

### Key implementation detail — #[serde(flatten)] on result field:
The `result` field uses `#[serde(flatten)]` so mode-specific keys are merged into the top-level data object rather than nested. This means `data.experts` (not `data.result.experts`).

### Timestamps: all use ms_to_iso() for ISO 8601 format in JSON output

### Percentage rounding: Reviews categories use `(percentage * 10.0).round() / 10.0` for single decimal precision

## Files

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

## TDD Loop

No unit tests for JSON serialization — the serde_json::json\!() macro produces correct JSON by construction. Verification via manual robot mode invocation.
VERIFY: `cargo check && cargo run --release -- -J who src/features/global-search/ | python3 -m json.tool`

## Acceptance Criteria

- [ ] cargo check passes
- [ ] JSON output validates (valid JSON, no trailing content)
- [ ] input echoes raw CLI args
- [ ] resolved_input includes since_mode tri-state (default/explicit/none)
- [ ] All timestamps in ISO 8601 format
- [ ] Bounded metadata present (participants_total, mr_refs_total, truncation object)
- [ ] #[serde(flatten)] correctly merges result keys into data object

## Edge Cases

- `#[serde(flatten)]` on the result Value means mode-specific keys must not collide with mode/input/resolved_input — verified by convention (expert uses "experts", workload uses "username", etc.)
- serde_json::json\!() panics are impossible for valid Rust expressions, but verify that all row.get() values in *_to_json() handle None fields correctly (author_username in WorkloadMr is Option<String> — json\!() serializes None as null, which is correct)
- ms_to_iso() must handle 0 and very old timestamps gracefully — produces "1970-01-01T00:00:00Z" for epoch 0, which is valid
- Reviews percentage rounding: categories summing to >100% due to rounding is acceptable (display artifact) — agent consumers should not assert sum == 100
- println\!() for JSON output (not eprintln\!) — errors go to stderr, data to stdout, matching all other robot-mode commands
- If a mode returns empty results, the JSON should still be valid (empty arrays, zero counts) — serde handles this correctly

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


[CP1] gi list issues command

List issues from the database.

Flags:
- --limit=N (default: 20)
- --project=PATH (filter by project)
- --state=opened|closed|all (default: all)

Output: Table with iid, title, state, author, relative time

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


[CP1] gi list issues command

## Background

The `gi list issues` command displays a paginated list of issues from the local database. It supports filtering by project and state, with configurable limit. This provides quick access to synced issues without opening GitLab.

## Approach

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

### Clap Definition

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

    /// Maximum results
    #[arg(long, default_value = "20")]
    pub limit: usize,

    /// Filter by project path
    #[arg(long)]
    pub project: Option<String>,

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

### Handler Function

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

### Query (for issues)

```sql
SELECT i.iid, i.title, i.state, i.author_username, i.updated_at, p.path
FROM issues i
JOIN projects p ON i.project_id = p.id
WHERE (p.path = ? OR ? IS NULL)
  AND (i.state = ? OR ? IS NULL OR ? = 'all')
ORDER BY i.updated_at DESC
LIMIT ?
```

### Output Format (matches PRD)

```
Issues (showing 20 of 3,801)

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

### Column Layout

| Column | Width | Alignment |
|--------|-------|-----------|
| IID | 6 | right |
| Title | 45 | left (truncate) |
| State | 8 | left |
| Author | 12 | left |
| Updated | 12 | right (relative) |

### Relative Time Formatting

```rust
fn format_relative_time(ms_epoch: i64) -> String {
    let now = now_ms();
    let diff = now - ms_epoch;
    match diff {
        d if d < 60_000 => "just now".to_string(),
        d if d < 3_600_000 => format!("{} min ago", d / 60_000),
        d if d < 86_400_000 => format!("{} hours ago", d / 3_600_000),
        d if d < 604_800_000 => format!("{} days ago", d / 86_400_000),
        d if d < 2_592_000_000 => format!("{} weeks ago", d / 604_800_000),
        _ => format!("{} months ago", diff / 2_592_000_000),
    }
}
```

## Acceptance Criteria

- [ ] Lists issues ordered by updated_at DESC
- [ ] Shows "showing X of Y" with total count
- [ ] Respects --limit parameter
- [ ] --project filters to single project
- [ ] --state filters to opened/closed/all
- [ ] Title truncated if longer than column width
- [ ] Updated time shown as relative ("3 days ago")

## Files

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

## TDD Loop

RED:
```rust
#[tokio::test] async fn list_issues_shows_correct_columns()
#[tokio::test] async fn list_issues_respects_limit()
#[tokio::test] async fn list_issues_filters_by_project()
#[tokio::test] async fn list_issues_filters_by_state()
```

GREEN: Implement handler with query and formatting

VERIFY: `cargo test list_issues`

## Edge Cases

- No issues match filters - show "No issues found"
- Title exactly 45 chars - no truncation
- Title 46+ chars - truncate with "..."
- --state=all shows both opened and closed
- Default state filter is all (not just opened)


[CP1] Issue transformer with label extraction

## Background

The issue transformer converts GitLab API responses into our local schema format. It extracts core fields and, critically, the array of label names from each issue. This transformer is pure logic with no I/O, making it easy to test.

## Approach

Create a transformer module with a function that:
1. Takes a `GitLabIssue` and returns an `IssueRow` struct
2. Extracts the `labels: Vec<String>` directly from the issue (GitLab returns label names as strings)

### Structs

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

pub struct IssueRow {
    pub gitlab_id: i64,
    pub iid: i64,
    pub project_id: i64,
    pub title: String,
    pub description: Option<String>,
    pub state: String,
    pub author_username: String,
    pub created_at: i64,      // ms epoch UTC
    pub updated_at: i64,      // ms epoch UTC
    pub web_url: String,
}

pub struct IssueWithLabels {
    pub issue: IssueRow,
    pub label_names: Vec<String>,
}
```

### Function

```rust
pub fn transform_issue(issue: GitLabIssue) -> Result<IssueWithLabels, TransformError> {
    // Parse ISO 8601 timestamps to ms epoch
    // Extract author.username
    // Return IssueWithLabels with label_names from issue.labels
}
```

## Acceptance Criteria

- [ ] `IssueRow` struct exists with all fields from schema
- [ ] `IssueWithLabels` struct bundles issue + label names
- [ ] `transform_issue` parses ISO 8601 to ms epoch correctly
- [ ] `transform_issue` handles missing description (None)
- [ ] Label names are preserved exactly as received from GitLab
- [ ] Unit tests cover all edge cases

## Files

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

## TDD Loop

RED:
```rust
// tests/unit/issue_transformer_test.rs
#[test] fn transforms_issue_with_all_fields()
#[test] fn handles_missing_description()
#[test] fn extracts_label_names()
#[test] fn parses_timestamps_to_ms_epoch()
```

GREEN: Implement transform_issue function

VERIFY: `cargo test issue_transformer`

## Edge Cases

- GitLab timestamps are ISO 8601 with timezone - use chrono::DateTime::parse_from_rfc3339
- Description can be null in GitLab API - map to Option<String>
- Empty labels array is valid - return empty Vec
- Do NOT parse labels_details - it varies across GitLab versions

Implemented IssueRow, IssueWithLabels, transform_issue with 6 passing unit tests covering all edge cases


[CP0] Database setup with migrations and app lock

## Background

The database is the backbone of gitlab-inbox. SQLite with WAL mode for performance, foreign keys for integrity, and proper pragmas for reliability. Migrations allow schema evolution. App lock prevents concurrent sync corruption.

Reference: docs/prd/checkpoint-0.md sections "Database Schema", "SQLite Runtime Pragmas", "App Lock Mechanism"

## Approach

**src/core/db.ts:**
```typescript
import Database from 'better-sqlite3';
import { join, dirname } from 'node:path';
import { mkdirSync, readdirSync, readFileSync } from 'node:fs';
import { getDbPath } from './paths';
import { dbLogger } from './logger';

export function createConnection(dbPath: string): Database.Database {
  mkdirSync(dirname(dbPath), { recursive: true });
  const db = new Database(dbPath);

  // Production-grade pragmas
  db.pragma('journal_mode = WAL');
  db.pragma('synchronous = NORMAL');
  db.pragma('foreign_keys = ON');
  db.pragma('busy_timeout = 5000');
  db.pragma('temp_store = MEMORY');

  return db;
}

export function runMigrations(db: Database.Database, migrationsDir: string): void {
  // Create schema_version table if not exists
  // Read migration files sorted by version
  // Apply migrations not yet applied
  // Track in schema_version table
}
```

**migrations/001_initial.sql:**
Full schema with tables: schema_version, projects, sync_runs, app_locks, sync_cursors, raw_payloads

**src/core/lock.ts:**
AppLock class with:
- acquire(force?): acquires lock or throws DatabaseLockError
- release(): releases lock and stops heartbeat
- Heartbeat timer that updates heartbeat_at every N seconds
- Stale lock detection (heartbeat_at > staleLockMinutes ago)

## Acceptance Criteria

- [ ] createConnection() creates parent directories if missing
- [ ] WAL mode verified: `db.pragma('journal_mode')` returns 'wal'
- [ ] Foreign keys verified: `db.pragma('foreign_keys')` returns 1
- [ ] busy_timeout verified: `db.pragma('busy_timeout')` returns 5000
- [ ] 001_initial.sql creates all 6 tables
- [ ] schema_version shows version 1 after migration
- [ ] AppLock.acquire() succeeds for first caller
- [ ] AppLock.acquire() throws DatabaseLockError for second concurrent caller
- [ ] Stale lock (heartbeat > 10 min old) can be taken over
- [ ] tests/unit/db.test.ts passes (8 tests)
- [ ] tests/integration/app-lock.test.ts passes (6 tests)

## Files

CREATE:
- src/core/db.ts
- src/core/lock.ts
- migrations/001_initial.sql
- tests/unit/db.test.ts
- tests/integration/app-lock.test.ts

## TDD Loop

RED:
```typescript
// tests/unit/db.test.ts
describe('Database', () => {
  it('creates database file if not exists')
  it('applies migrations in order')
  it('sets WAL journal mode')
  it('enables foreign keys')
  it('sets busy_timeout=5000')
  it('sets synchronous=NORMAL')
  it('sets temp_store=MEMORY')
  it('tracks schema version')
})

// tests/integration/app-lock.test.ts
describe('App Lock', () => {
  it('acquires lock successfully')
  it('updates heartbeat during operation')
  it('detects stale lock and recovers')
  it('refuses concurrent acquisition')
  it('allows force override')
  it('releases lock on completion')
})
```

GREEN: Implement db.ts, lock.ts, 001_initial.sql

VERIFY:
```bash
npm run test -- tests/unit/db.test.ts
npm run test -- tests/integration/app-lock.test.ts
```

## Edge Cases

- Migration file with syntax error should rollback and throw MigrationError
- Lock heartbeat timer must be unref()'d to not block process exit
- Database file permissions - fail clearly if not writable
- Concurrent lock tests need separate database files


OBSERV Epic: Phase 5 - Rate Limit + Retry Instrumentation

Enhanced logging in GitLab HTTP client for rate limits and retries. Structured fields on retry/rate-limit events. StageTiming gets rate_limit_hits and retries counters.

Depends on: Phase 2 (spans for context)
Parallel with: Phase 3

Files: src/gitlab/client.rs, src/core/metrics.rs

Acceptance criteria (PRD Section 6.5):
- 429 events log at INFO with path, attempt, retry_after_secs, status_code
- Retry events log with path, attempt, error
- StageTiming includes rate_limit_hits and retries (omitted when zero)
- lore -v sync shows retry activity on stderr
- Rate limit counts included in metrics_json

All Phase 5 tasks complete: structured rate-limit logging (bd-12ae) and rate_limit_hits/retries counters in StageTiming (bd-3vqk)


OBSERV Epic: Phase 4 - Sync History Enrichment

Wire up sync_runs INSERT/UPDATE lifecycle (table exists but nothing writes to it), schema migration 014, enhanced sync-status with recent runs and metrics.

Depends on: Phase 3 (needs Vec<StageTiming> to store in metrics_json)
Unblocks: nothing (terminal phase)

Files: migrations/014_sync_runs_enrichment.sql (new), src/core/sync_run.rs (new), src/cli/commands/sync.rs, src/cli/commands/ingest.rs, src/cli/commands/sync_status.rs

Acceptance criteria (PRD Section 6.4):
- lore sync creates sync_runs row with status=running, updated to succeeded/failed
- sync_runs.run_id matches log files and robot JSON
- metrics_json contains serialized Vec<StageTiming>
- lore sync-status shows last 10 runs with metrics
- Failed syncs record error and partial metrics
- Migration 014 applies cleanly

Phase 4 complete: migration 014, SyncRunRecorder, wiring, sync-status enhancement


Implement search filters module

## Background
Search filters are applied post-retrieval to narrow results by source type, author, project, date, labels, and file paths. The filter module must preserve ranking order from the search pipeline (FTS/RRF scores). It uses SQLite's JSON1 extension (json_each) to pass ranked document IDs efficiently and maintain their original order.

## Approach
Create `src/search/filters.rs` per PRD Section 3.3. The full implementation is specified in the PRD including the SQL query.

**Key types:**
- `SearchFilters` struct with all filter fields + `has_any_filter()` + `clamp_limit()`
- `PathFilter` enum: `Prefix(String)` (trailing `/`) or `Exact(String)`

**Core function:**
```rust
pub fn apply_filters(
    conn: &Connection,
    document_ids: &[i64],
    filters: &SearchFilters,
) -> Result<Vec<i64>>
```

**SQL pattern (JSON1 for ordered ID passing):**
```sql
SELECT d.id
FROM json_each(?) AS j
JOIN documents d ON d.id = j.value
WHERE 1=1
  AND d.source_type = ?      -- if source_type filter set
  AND d.author_username = ?  -- if author filter set
  -- ... dynamic WHERE clauses
ORDER BY j.key               -- preserves ranking order
LIMIT ?
```

**Filter logic:**
- Labels: AND logic via `EXISTS (SELECT 1 FROM document_labels dl WHERE dl.document_id = d.id AND dl.label_name = ?)`
- Path prefix: `LIKE ? ESCAPE '\\'` with escaped wildcards
- Path exact: `= ?`
- Limit: clamped to [1, 100], default 20

## Acceptance Criteria
- [ ] source_type filter works (issue, merge_request, discussion)
- [ ] author filter: exact username match
- [ ] project_id filter: restricts to single project
- [ ] after filter: created_at >= value
- [ ] updated_after filter: updated_at >= value
- [ ] labels filter: AND logic (all specified labels must be present)
- [ ] path exact filter: matches exact path string
- [ ] path prefix filter: trailing `/` triggers LIKE with escaped wildcards
- [ ] Ranking order preserved (ORDER BY j.key from json_each)
- [ ] Limit clamped: 0 -> 20 (default), 200 -> 100 (max)
- [ ] Empty document_ids returns empty Vec
- [ ] Multiple filters compose correctly (all applied via AND)
- [ ] `cargo test filters` passes

## Files
- `src/search/filters.rs` — new file
- `src/search/mod.rs` — add `pub use filters::{SearchFilters, PathFilter, apply_filters};`

## TDD Loop
RED: Tests in `filters.rs` `#[cfg(test)] mod tests`:
- `test_no_filters` — all docs returned up to limit
- `test_source_type_filter` — only issues returned
- `test_author_filter` — exact match
- `test_labels_and_logic` — must have ALL specified labels
- `test_path_exact` — matches exact path
- `test_path_prefix` — trailing slash matches prefix
- `test_limit_clamping` — 0 -> 20, 200 -> 100
- `test_ranking_preserved` — output order matches input order
- `test_has_any_filter` — true when any filter set, false when default
GREEN: Implement apply_filters with dynamic SQL
VERIFY: `cargo test filters`

## Edge Cases
- Path containing SQL LIKE wildcards (`%`, `_`): must be escaped before LIKE
- Empty labels list: no label filter applied (not "must have zero labels")
- `has_any_filter()` returns false for default SearchFilters (no filters set)
- Large document_ids array (1000+): JSON1 handles efficiently

Completed: SearchFilters with has_any_filter/clamp_limit, PathFilter enum, apply_filters with dynamic SQL + json_each ordering, escape_like, 8 tests pass


[CP1] Final validation - tests, smoke tests, integrity checks

Run all tests and perform data integrity checks.

Validation steps:
1. Run all unit tests (vitest)
2. Run all integration tests
3. Run ESLint
4. Run TypeScript strict check
5. Manual smoke tests per PRD table
6. Data integrity SQL checks:
   - Issue count matches GitLab
   - Every issue has raw_payload
   - Labels in junction exist in labels table
   - sync_cursors has entry per project
   - Re-run fetches 0 new items
   - Discussion count > 0
   - Every discussion has >= 1 note
   - individual_note=true has exactly 1 note

Files: All CP1 files
Done when: All gate criteria from Definition of Done pass


Rewrite who --path to use mr_file_changes for authorship signal

## Problem

who --path currently only queries DiffNote records (notes.position_new_path), so it only finds people who left inline review comments on that exact file. This is highly misleading -- it reports 'no experts' for files that have been actively authored and reviewed, just without inline comments on that specific path.

## Solution

Rewrite query_expert() to incorporate mr_file_changes as a primary signal source:

1. MR authorship signal: JOIN mr_file_changes to find MR authors who touched the file (strongest signal)
2. MR reviewer signal: JOIN mr_file_changes + merge_request_reviewers to find reviewers of MRs that touched the file (even without DiffNotes on that file)
3. DiffNote signal: Keep existing DiffNote query as a supplementary signal (inline comments show deep familiarity)

### Scoring weights (to tune):
- MR author who touched the file: 15 points per MR
- MR reviewer of MR touching the file: 10 points per MR
- DiffNote reviewer on that file: 20 points per MR + 1 per note (existing)
- DiffNote MR author: 12 points per MR (existing)

### Path matching:
- Reuse build_path_query() but extend DB probes to also check mr_file_changes.new_path
- For prefix matching, LIKE on mr_file_changes.new_path

### Also fix:
- build_path_query() probes should check mr_file_changes in addition to notes, so path resolution works even when no DiffNotes exist

## Acceptance Criteria
- who --path returns results for files touched in MRs even without DiffNotes
- Existing DiffNote-based scoring still contributes
- build_path_query probes mr_file_changes for path existence
- Tests cover: MR-only authorship, DiffNote-only, combined scoring
- Robot mode JSON output unchanged (same schema)

Rewrote query_expert() and query_overlap() in who.rs to incorporate mr_file_changes + mr_reviewers as signal sources alongside existing DiffNote data. Uses 4-branch UNION ALL with COUNT(DISTINCT CASE) for proper deduplication across signal types. 8 new tests, all 397 pass.


Implement lore generate-docs CLI command

## Background
The generate-docs CLI command is the user-facing wrapper around the document regeneration pipeline. It has two modes: incremental (default, processes dirty_sources queue only) and full (seeds dirty_sources with ALL entities, then drains). Both modes use the same regenerator codepath to avoid logic divergence. Full mode uses keyset pagination (WHERE id > last_id) for seeding to avoid O(n^2) OFFSET degradation on large tables.

## Approach
Create `src/cli/commands/generate_docs.rs` per PRD Section 2.4.

**Core function:**
```rust
pub fn run_generate_docs(
    config: &Config,
    full: bool,
    project_filter: Option<&str>,
) -> Result<GenerateDocsResult>
```

**Full mode seeding (keyset pagination):**
```rust
const FULL_MODE_CHUNK_SIZE: usize = 2000;

// For each source type (issues, MRs, discussions):
let mut last_id: i64 = 0;
loop {
    let tx = conn.transaction()?;
    let inserted = tx.execute(
        "INSERT INTO dirty_sources (source_type, source_id, queued_at, ...)
         SELECT 'issue', id, ?, 0, NULL, NULL, NULL
         FROM issues WHERE id > ? ORDER BY id LIMIT ?
         ON CONFLICT(source_type, source_id) DO NOTHING",
        params![now_ms(), last_id, FULL_MODE_CHUNK_SIZE],
    )?;
    if inserted == 0 { tx.commit()?; break; }
    // Advance keyset cursor...
    tx.commit()?;
}
```

**After draining (full mode only):**
```sql
INSERT INTO documents_fts(documents_fts) VALUES('optimize')
```

**CLI args:**
```rust
#[derive(Args)]
pub struct GenerateDocsArgs {
    #[arg(long)]
    full: bool,
    #[arg(long)]
    project: Option<String>,
}
```

**Output:** Human-readable table + JSON robot mode.

## Acceptance Criteria
- [ ] Default mode (no --full): processes only existing dirty_sources entries
- [ ] --full mode: seeds dirty_sources with ALL issues, MRs, and discussions
- [ ] Full mode uses keyset pagination (WHERE id > last_id, not OFFSET)
- [ ] Full mode chunk size is 2000
- [ ] Full mode does FTS optimize after completion
- [ ] Both modes use regenerate_dirty_documents() (same codepath)
- [ ] Progress bar shown in human mode (via indicatif)
- [ ] JSON output in robot mode with GenerateDocsResult
- [ ] GenerateDocsResult has issues/mrs/discussions/total/truncated/skipped counts
- [ ] `cargo build` succeeds

## Files
- `src/cli/commands/generate_docs.rs` — new file
- `src/cli/commands/mod.rs` — add `pub mod generate_docs;`
- `src/cli/mod.rs` — add GenerateDocsArgs, wire up generate-docs subcommand
- `src/main.rs` — add generate-docs command handler

## TDD Loop
RED: Integration test with seeded DB
GREEN: Implement run_generate_docs with seeding + drain
VERIFY: `cargo build && cargo test generate_docs`

## Edge Cases
- Empty database (no issues/MRs/discussions): full mode seeds nothing, returns all-zero counts
- --project filter in full mode: only seed dirty_sources for entities in that project
- Interrupted full mode: dirty_sources entries persist (ON CONFLICT DO NOTHING), resume by re-running
- FTS optimize on empty FTS table: no-op (safe)

Implemented generate-docs command with incremental + full mode, keyset pagination seeding, FTS optimize, project filter, human + JSON output. Builds clean.


Epic: Gate C - Sync MVP

## Background
Gate C adds the sync orchestrator and queue infrastructure that makes the search pipeline incremental and self-maintaining. It introduces dirty source tracking (change detection during ingestion), the discussion fetch queue, and the unified lore sync command that orchestrates the full pipeline. Gate C also adds integrity checks and repair paths.

## Gate C Deliverables
1. Orchestrated lore sync command with incremental doc regen + re-embedding
2. Integrity checks + repair paths for FTS/embeddings consistency

## Bead Dependencies (execution order, after Gate A)
1. **bd-mem** — Shared backoff utility (no deps, shared with Gate B)
2. **bd-38q** — Dirty source tracking (blocked by bd-36p, bd-hrs, bd-mem)
3. **bd-1je** — Discussion queue (blocked by bd-hrs, bd-mem)
4. **bd-1i2** — Integrate dirty tracking into ingestion (blocked by bd-38q)
5. **bd-1x6** — Sync CLI (blocked by bd-38q, bd-1je, bd-1i2, bd-3qs, bd-2sx)

## Acceptance Criteria
- [ ] `lore sync` runs full pipeline: ingest -> generate-docs -> embed
- [ ] `lore sync --full` does full re-sync + regeneration
- [ ] `lore sync --no-embed` skips embedding stage
- [ ] Dirty tracking: upserted entities automatically marked for regeneration
- [ ] Queue draining: dirty_sources fully drained in bounded batch loop
- [ ] Backoff: failed items use exponential backoff with jitter
- [ ] `lore stats --check` detects inconsistencies
- [ ] `lore stats --repair` fixes FTS/embedding inconsistencies

All Gate C sub-beads complete: backoff utility, dirty tracking, discussion queue, ingestion integration, sync CLI, stats CLI


Add 'lore count events' command with robot mode

## Background
Need to verify event ingestion and report counts by type. The existing count command (src/cli/commands/count.rs) handles issues, mrs, discussions, notes with both human and robot output. This adds 'events' as a new count subcommand.

## Approach
Extend the existing count command in src/cli/commands/count.rs:

1. Add CountTarget::Events variant (or string match) in the count dispatcher
2. Query each event table with GROUP BY entity type:
```sql
SELECT
  CASE WHEN issue_id IS NOT NULL THEN 'issue' ELSE 'merge_request' END as entity_type,
  COUNT(*) as count
FROM resource_state_events
GROUP BY entity_type;
-- (repeat for label and milestone events)
```

3. Human output: table format
```
Event Type       Issues  MRs    Total
State events     1,234   567    1,801
Label events     2,345   890    3,235
Milestone events   456   123      579
Total            4,035   1,580  5,615
```

4. Robot JSON:
```json
{
  "ok": true,
  "data": {
    "state_events": {"issue": 1234, "merge_request": 567, "total": 1801},
    "label_events": {"issue": 2345, "merge_request": 890, "total": 3235},
    "milestone_events": {"issue": 456, "merge_request": 123, "total": 579},
    "total": 5615
  }
}
```

5. Register in CLI: add "events" to count's entity_type argument in src/cli/mod.rs

## Acceptance Criteria
- [ ] `lore count events` shows correct counts by event type and entity type
- [ ] Robot JSON matches the schema above
- [ ] Works with empty tables (all zeros)
- [ ] Does not error if migration 011 hasn't been applied (graceful degradation or "no event tables" message)

## Files
- src/cli/commands/count.rs (add events counting logic)
- src/cli/mod.rs (add "events" to count's accepted entity types)

## TDD Loop
RED: tests/count_tests.rs (or extend existing):
- `test_count_events_empty_tables` - verify all zeros on fresh DB
- `test_count_events_with_data` - seed state + label events, verify correct counts
- `test_count_events_robot_json` - verify JSON structure

GREEN: Add the events branch to count command

VERIFY: `cargo test count -- --nocapture`

## Edge Cases
- Tables don't exist if user hasn't run migrate — check table existence first or catch the error
- COUNT with GROUP BY returns no rows for empty tables — need to handle missing entity types as 0

Added 'events' to count CLI parser, run_count_events function, print_event_count (table format) and print_event_count_json (structured JSON). Wired into handle_count in main.rs.


OBSERV: Add rate_limit_hits and retries counters to StageTiming

## Background
MetricsLayer counts span timing but doesn't yet count rate-limit hits and retries. These counters complete the observability picture, showing HOW MUCH time was spent waiting vs. working.

## Approach
### src/core/metrics.rs - StageTiming struct

Add two new fields:
```rust
#[derive(Debug, Clone, Serialize)]
pub struct StageTiming {
    // ... existing fields ...
    #[serde(skip_serializing_if = "is_zero")]
    pub rate_limit_hits: usize,
    #[serde(skip_serializing_if = "is_zero")]
    pub retries: usize,
}
```

### src/core/metrics.rs - MetricsLayer

The structured log events from bd-12ae use info!() with specific fields (status_code=429, "Rate limited, retrying"). MetricsLayer needs to count these events within each span.

Add to SpanData:
```rust
struct SpanData {
    // ... existing fields ...
    rate_limit_hits: usize,
    retries: usize,
}
```

Add on_event() to MetricsLayer:
```rust
fn on_event(&self, event: &tracing::Event<'_>, ctx: Context<'_, S>) {
    // Check if event message contains rate-limit or retry indicators
    // Increment counters on the current span
    if let Some(span_ref) = ctx.event_span(event) {
        let id = span_ref.id();
        if let Some(data) = self.spans.lock().unwrap().get_mut(&id.into_u64()) {
            let mut visitor = EventVisitor::default();
            event.record(&mut visitor);

            if visitor.status_code == Some(429) {
                data.rate_limit_hits += 1;
            }
            if visitor.is_retry {
                data.retries += 1;
            }
        }
    }
}
```

The EventVisitor checks for status_code=429 and message containing "retrying" to classify events.

On span close, propagate counts to parent (bubble up):
```rust
fn on_close(&self, id: Id, _ctx: Context<'_, S>) {
    if let Some(data) = self.spans.lock().unwrap().remove(&id.into_u64()) {
        let timing = StageTiming {
            // ... existing fields ...
            rate_limit_hits: data.rate_limit_hits,
            retries: data.retries,
        };
        // ... push to completed
    }
}
```

## Acceptance Criteria
- [ ] StageTiming has rate_limit_hits and retries fields
- [ ] Fields omitted when zero in JSON serialization
- [ ] MetricsLayer counts 429 events as rate_limit_hits
- [ ] MetricsLayer counts retry events as retries
- [ ] Counts bubble up to parent spans in extract_timings()
- [ ] Rate limit counts appear in metrics_json stored in sync_runs
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/core/metrics.rs (add fields to StageTiming, add on_event to MetricsLayer, add EventVisitor)

## TDD Loop
RED:
  - test_stage_timing_rate_limit_counts: simulate 3 rate-limit events, extract, assert rate_limit_hits=3
  - test_stage_timing_retry_counts: simulate 2 retries, extract, assert retries=2
  - test_rate_limit_fields_omitted_when_zero: StageTiming with zero counts, serialize, assert no keys
GREEN: Add fields to StageTiming, implement on_event in MetricsLayer
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- Events outside any span: ctx.event_span() returns None. Skip counting. This shouldn't happen in practice since all GitLab calls happen within stage spans.
- Event classification: rely on structured fields (status_code=429) not message text. More reliable and less fragile.
- Count bubbling: parent stage should aggregate child counts. In extract_timings(), sum children's counts into parent.

Implemented rate_limit_hits and retries counters in StageTiming with skip_serializing_if for zero values


Write unit tests for core algorithms

## Background
Unit tests verify the core algorithms in isolation: document extraction formatting, FTS query sanitization, RRF scoring, content hashing, backoff curves, and filter helpers. These tests don't require a database or external services — they test pure functions and logic.

## Approach
Add #[cfg(test)] mod tests blocks to each module:

**1. src/documents/extractor.rs:**
- test_source_type_parse_all_aliases — every alias resolves correctly
- test_source_type_parse_unknown — returns None
- test_source_type_as_str_roundtrip — as_str matches parse input
- test_content_hash_deterministic — same input = same hash
- test_list_hash_order_independent — sorted before hashing
- test_list_hash_empty — empty vec produces consistent hash

**2. src/documents/truncation.rs:**
- test_truncation_edge_cases (per bd-18t TDD Loop)

**3. src/search/fts.rs:**
- test_to_fts_query_basic — "auth error" -> quoted tokens
- test_to_fts_query_prefix — "auth*" preserves prefix
- test_to_fts_query_special_chars — "C++" quoted correctly
- test_to_fts_query_dash — "-DWITH_SSL" quoted (not NOT operator)
- test_to_fts_query_internal_quotes — escaped by doubling
- test_to_fts_query_empty — empty string returns empty

**4. src/search/rrf.rs:**
- test_rrf_dual_list — docs in both lists score higher
- test_rrf_normalization — best score = 1.0
- test_rrf_empty — empty returns empty

**5. src/core/backoff.rs:**
- test_exponential_curve — delays double each attempt
- test_cap_at_one_hour — high attempt_count capped
- test_jitter_range — within [0.9, 1.1) factor

**6. src/search/filters.rs:**
- test_has_any_filter — true/false for various filter combos
- test_clamp_limit — 0->20, 200->100, 50->50
- test_path_filter_from_str — trailing slash = Prefix

**7. src/search/hybrid.rs (hydration round-trip):**
- test_single_round_trip_query — verify hydration SQL produces correct structure

## Acceptance Criteria
- [ ] All edge cases covered per PRD acceptance criteria
- [ ] Tests are unit tests (no DB, no network, no Ollama)
- [ ] `cargo test` passes with all new tests
- [ ] No test depends on execution order
- [ ] Tests cover: document extractor formats, truncation, RRF, hashing, FTS sanitization, backoff, filters

## Files
- In-module tests in: extractor.rs, truncation.rs, fts.rs, rrf.rs, backoff.rs, filters.rs, hybrid.rs

## TDD Loop
These tests ARE the TDD loop for their respective beads. Each implementation bead should write its tests first (RED), then implement (GREEN).
VERIFY: `cargo test`

## Edge Cases
- Tests with Unicode: include emoji, CJK characters in truncation tests
- Tests with empty strings: empty queries, empty content, empty labels
- Tests with boundary values: limit=0, limit=100, limit=101

All acceptance criteria tests already exist across modules. 276 tests passing (189 unit + 87 integration).


Implement theme configuration (ftui ThemeBuilder)

## Background
FrankenTUI provides a Theme struct with 19 semantic color slots and AdaptiveColor for automatic light/dark mode switching based on terminal background detection. The theme defines the visual identity of the TUI.

## Approach
Create crates/lore-tui/src/theme.rs:
- build_theme() -> Theme using Theme::builder() with 19 semantic AdaptiveColor slots: primary, secondary, accent, background, surface, overlay, on_primary, on_secondary, on_background, on_surface, success, warning, error, info, border, muted, highlight, selection, text
- State-specific colors for issue/MR states: opened (green), closed (red), merged (purple), locked (yellow)
- Event type colors for timeline: created, updated, closed, merged, commented, labeled, milestoned
- Label color mapping: fn label_style(label_color: &str) -> Style that converts GitLab hex colors to terminal colors

## Acceptance Criteria
- [ ] build_theme() returns a Theme with all 19 semantic slots populated
- [ ] Each slot uses AdaptiveColor::adaptive(light_variant, dark_variant)
- [ ] State colors map correctly: opened->green, closed->red, merged->purple, locked->yellow
- [ ] label_style() converts hex color strings to terminal Style objects
- [ ] Theme compiles and can be passed to ftui App

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

## TDD Anchor
RED: Write test_build_theme_has_all_slots that calls build_theme() and verifies the returned Theme is non-default (check primary color is set).
GREEN: Implement build_theme() with ThemeBuilder.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_build_theme

## Edge Cases
- Terminal may not support true color (RGB) — AdaptiveColor handles fallback to 256-color
- Label colors from GitLab are hex strings (#FF0000) — must parse and convert
- High contrast ratio needed for text-on-background: check meets_wcag_aa() for critical text


Add GitLab MR types to types.rs

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

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

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

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

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

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

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

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

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

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

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

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


Implement LoreApp Model trait (full update/view skeleton)

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

## Approach
Expand crates/lore-tui/src/app.rs:
- LoreApp struct fields: config (Config), db (DbManager), state (AppState), navigation (NavigationStack), supervisor (TaskSupervisor), clock (Box<dyn Clock>), input_mode (InputMode), command_registry (CommandRegistry)
- init() -> Cmd<Msg>: return Cmd::task that loads dashboard data
- update(msg: Msg) -> Option<Cmd<Msg>>: full dispatch with 5-stage interpret_key pipeline:
  1. Quit check (q in Normal mode, Ctrl+C always)
  2. InputMode routing (Text->delegate to text widget, Palette->delegate to palette, GoPrefix->check timeout+destination)
  3. Global shortcuts (H=Home, Esc=back, Ctrl+P=palette, g=prefix, Ctrl+O/I=jump)
  4. Screen-local keys (delegate to AppState::interpret_screen_key)
  5. Fallback (unhandled key, no-op)
- For non-key messages: match on Msg variants, update state, optionally return Cmd::task for async work
- Stale result guard: check supervisor.is_current() before applying *Loaded results
- view(frame): match navigation.current() to dispatch to per-screen view functions (stub initially)
- subscriptions(): tick timer (250ms for spinner animation), debounce timers

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

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

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

Additional tests:
- test_navigate_to_pushes_stack: update(NavigateTo(IssueList)) changes navigation.current()
- test_go_back_pops_stack: after push, GoBack returns to previous screen
- test_stale_result_dropped: IssueListLoaded with old generation doesn't update state
- test_go_prefix_timeout: GoPrefix cancels after 500ms (using FakeClock)

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

## Dependency Context
Uses DbManager from "Implement DbManager" task.
Uses Clock/FakeClock from "Implement Clock trait" task.
Uses Msg, Screen, InputMode from "Implement core types" task.
Uses NavigationStack from "Implement NavigationStack" task (same phase, can stub initially).
Uses TaskSupervisor from "Implement TaskSupervisor" task (same phase, can stub initially).


[CP1] Issue ingestion module

Fetch and store issues with cursor-based incremental sync.

## Module
src/ingestion/issues.rs

## Key Structs

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

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

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

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

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

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


Implement Issue Detail (state + action + view)

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

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

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

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

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

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

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

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

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


lore related: semantic similarity discovery

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

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

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

## Approach

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

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

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

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

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

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

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

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

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

## Function Signatures

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

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

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

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

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

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

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

GREEN: Implement related command using search_vector + hydration

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

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

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

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

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


Extract cross-references from resource_state_events

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

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

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

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

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

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

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

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

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

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

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

GREEN: Implement extract_refs_from_state_events

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

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


Comprehensive robot-docs as agent bootstrap

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

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

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

## Approach

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

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

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

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

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

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

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

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

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

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

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


[CP1] gi sync-status enhancement

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

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

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

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

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

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

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


Implement 'lore trace' command with human and robot output

## Background

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

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

## Codebase Context

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

## Approach

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

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

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

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

## Acceptance Criteria

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

## Files

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

## TDD Loop

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

GREEN: Implement CLI wiring and handlers.

VERIFY: `cargo check --all-targets`

## Edge Cases

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


lore explain: auto-generate issue/MR narrative

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

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

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

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

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

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

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

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

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

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

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

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

GREEN: Implement explain command with section assembly

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

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

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

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

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


Implement embedding pipeline with chunking

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


OBSERV: Create migration 014 for sync_runs enrichment

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

## Approach
Create migrations/014_sync_runs_enrichment.sql:

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

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

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

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

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

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

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

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

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

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


WHO: Overlap mode query (query_overlap)

## Background

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

## Approach

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

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

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

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

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

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

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

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

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

## Files

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

## TDD Loop

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

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

## Acceptance Criteria

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

## Edge Cases

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