[CP1] Unit tests for transformers

Comprehensive unit tests for issue and discussion transformers.

## Issue Transformer Tests (tests/issue_transformer_tests.rs)

- transforms_gitlab_issue_to_normalized_schema
- extracts_labels_from_issue_payload
- handles_missing_optional_fields_gracefully
- converts_iso_timestamps_to_ms_epoch
- sets_last_seen_at_to_current_time

## Discussion Transformer Tests (tests/discussion_transformer_tests.rs)

- transforms_discussion_payload_to_normalized_schema
- extracts_notes_array_from_discussion
- sets_individual_note_flag_correctly
- flags_system_notes_with_is_system_true
- preserves_note_order_via_position_field
- computes_first_note_at_and_last_note_at_correctly
- computes_resolvable_and_resolved_status

## Test Setup
- Load from test fixtures
- Use serde_json for deserialization
- Compare against expected NormalizedX structs

Files: tests/issue_transformer_tests.rs, tests/discussion_transformer_tests.rs
Done when: All transformer unit tests pass


Epic: Gate A - Lexical MVP

## Background
Gate A delivers the lexical search MVP — the foundation that works without sqlite-vec or Ollama. It introduces the document layer (documents, document_labels, document_paths), FTS5 indexing, search filters, and the search + stats + generate-docs CLI commands. Gate A is independently shippable — users get working search with FTS5 only.

## Gate A Deliverables
1. Document generation from issues/MRs/discussions with FTS5 indexing
2. Lexical search + filters + snippets + lore stats

## Bead Dependencies (execution order)
1. **bd-3lc** — Rename GiError to LoreError (no deps, enables all subsequent work)
2. **bd-hrs** — Migration 007 (blocked by bd-3lc)
3. **bd-221** — Migration 008 FTS5 (blocked by bd-hrs)
4. **bd-36p** — Document types + extractor module (blocked by bd-3lc)
5. **bd-18t** — Truncation logic (blocked by bd-36p)
6. **bd-247** — Issue extraction (blocked by bd-36p, bd-hrs)
7. **bd-1yz** — MR extraction (blocked by bd-36p, bd-hrs)
8. **bd-2fp** — Discussion extraction (blocked by bd-36p, bd-hrs, bd-18t)
9. **bd-1u1** — Document regenerator (blocked by bd-36p, bd-38q, bd-hrs)
10. **bd-1k1** — FTS5 search (blocked by bd-221)
11. **bd-3q2** — Search filters (blocked by bd-36p)
12. **bd-3lu** — Search CLI (blocked by bd-1k1, bd-3q2, bd-36p)
13. **bd-3qs** — Generate-docs CLI (blocked by bd-1u1, bd-3lu)
14. **bd-pr1** — Stats CLI (blocked by bd-hrs)
15. **bd-2dk** — Project resolution (blocked by bd-3lc)

## Acceptance Criteria
- [ ] `lore search "query"` returns FTS5 results with snippets
- [ ] `lore search --type issue --label bug "query"` filters correctly
- [ ] `lore generate-docs` creates documents from all entities
- [ ] `lore generate-docs --full` regenerates everything
- [ ] `lore stats` shows document/FTS/queue counts
- [ ] `lore stats --check` verifies FTS consistency
- [ ] No sqlite-vec dependency in Gate A

All Gate A sub-beads complete. Lexical MVP delivered: document extraction (issue/MR/discussion), FTS5 indexing, search with filters/snippets/RRF, generate-docs CLI, stats CLI with integrity check/repair.


Implement trace query: file -> MR -> issue -> discussion chain

## Background

The trace query builds a chain from file path -> MRs -> issues -> discussions, combining data from mr_file_changes (Gate 4), entity_references (Gate 2), and the existing discussions/notes tables. This is the backend for the trace CLI command.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 5.4 (Query Flow Tier 1).

## Codebase Context

- entity_references table (migration 011): source_entity_type, source_entity_id, target_entity_type, target_entity_id, reference_type, source_method
- mr_file_changes table (migration 016, bd-1oo): merge_request_id, project_id, old_path, new_path, change_type
- discussions table: issue_id, merge_request_id
- notes table: discussion_id, author_username, body, created_at, is_system, position_new_path (for DiffNotes)
- merge_requests table: iid, title, state, author_username, web_url, merged_at, updated_at
- issues table: iid, title, state, web_url
- resolve_rename_chain() from bd-1yx (src/core/file_history.rs) provides multi-path matching
- reference_type values: 'closes', 'mentioned', 'related'

## Approach

Create `src/core/trace.rs`:

```rust
use rusqlite::Connection;
use crate::core::file_history::resolve_rename_chain;
use crate::core::error::Result;

#[derive(Debug, Clone, Serialize)]
pub struct TraceChain {
    pub merge_request: TraceMr,
    pub issues: Vec<TraceIssue>,
    pub discussions: Vec<TraceDiscussion>,
}

#[derive(Debug, Clone, Serialize)]
pub struct TraceMr {
    pub iid: i64,
    pub title: String,
    pub state: String,
    pub author_username: String,
    pub web_url: Option<String>,
    pub merged_at: Option<i64>,
    pub merge_commit_sha: Option<String>,
    pub file_change_type: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct TraceIssue {
    pub iid: i64,
    pub title: String,
    pub state: String,
    pub web_url: Option<String>,
    pub reference_type: String,   // "closes", "mentioned", "related"
}

#[derive(Debug, Clone, Serialize)]
pub struct TraceDiscussion {
    pub author_username: String,
    pub body_snippet: String,     // truncated to 500 chars
    pub created_at: i64,
    pub is_diff_note: bool,       // true if position_new_path matched
}

#[derive(Debug, Clone, Serialize)]
pub struct TraceResult {
    pub path: String,
    pub resolved_paths: Vec<String>,
    pub chains: Vec<TraceChain>,
}

pub fn run_trace(
    conn: &Connection,
    project_id: i64,
    path: &str,
    follow_renames: bool,
    include_discussions: bool,
    limit: usize,
) -> Result<TraceResult> {
    // 1. Resolve rename chain (unless !follow_renames)
    let paths = if follow_renames {
        resolve_rename_chain(conn, project_id, path, 10)?
    } else {
        vec![path.to_string()]
    };

    // 2. Find MRs via mr_file_changes for all resolved paths
    //    Dynamic IN-clause for path set
    // 3. For each MR, find linked issues via entity_references
    // 4. If include_discussions, fetch DiffNote discussions on traced file
    // 5. Order chains by COALESCE(merged_at, updated_at) DESC, apply limit
}
```

### SQL for step 2 (find MRs):

Build dynamic IN-clause placeholders for the resolved path set:
```sql
SELECT DISTINCT mr.id, mr.iid, mr.title, mr.state, mr.author_username,
       mr.web_url, mr.merged_at, mr.updated_at, mr.merge_commit_sha,
       mfc.change_type
FROM mr_file_changes mfc
JOIN merge_requests mr ON mr.id = mfc.merge_request_id
WHERE mfc.project_id = ?1
  AND (mfc.new_path IN (...placeholders...) OR mfc.old_path IN (...placeholders...))
ORDER BY COALESCE(mr.merged_at, mr.updated_at) DESC
LIMIT ?N
```

### SQL for step 3 (linked issues):
```sql
SELECT i.iid, i.title, i.state, i.web_url, er.reference_type
FROM entity_references er
JOIN issues i ON i.id = er.target_entity_id
WHERE er.source_entity_type = 'merge_request'
  AND er.source_entity_id = ?1
  AND er.target_entity_type = 'issue'
```

### SQL for step 4 (DiffNote discussions):
```sql
SELECT n.author_username, n.body, n.created_at, n.position_new_path
FROM notes n
JOIN discussions d ON d.id = n.discussion_id
WHERE d.merge_request_id = ?1
  AND n.position_new_path IN (...placeholders...)
  AND n.is_system = 0
ORDER BY n.created_at ASC
```

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

## Acceptance Criteria

- [ ] run_trace() returns chains ordered by COALESCE(merged_at, updated_at) DESC
- [ ] Rename-aware: uses all paths from resolve_rename_chain
- [ ] Issues linked via entity_references (closes, mentioned, related)
- [ ] DiffNote discussions correctly filtered to traced file paths via position_new_path
- [ ] Discussion body_snippet truncated to 500 chars
- [ ] Empty result (file not in any MR) returns TraceResult with empty chains
- [ ] Limit applies to number of chains (MRs), not total discussions
- [ ] Module registered in src/core/mod.rs as `pub mod trace;`
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

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

## TDD Loop

RED:
- `test_trace_empty_file` — unknown file returns empty chains
- `test_trace_finds_mr` — file in mr_file_changes returns chain with correct MR
- `test_trace_follows_renames` — renamed file finds historical MRs
- `test_trace_links_issues` — MR with entity_references shows linked issues
- `test_trace_limits_chains` — limit=1 returns at most 1 chain
- `test_trace_no_follow_renames` — follow_renames=false only matches literal path

Tests need in-memory DB with migrations applied through 016 + test fixtures for mr_file_changes, entity_references, discussions, notes.

GREEN: Implement SQL queries and chain assembly.

VERIFY: `cargo test --lib -- trace`

## Edge Cases

- MR with no linked issues: chain has empty issues vec
- Same issue linked from multiple MRs: appears in each chain independently
- DiffNote on old_path (before rename): captured via resolved path set
- include_discussions=false: skip DiffNote query for performance
- Null merged_at: falls back to updated_at for ordering
- Dynamic IN-clause: use rusqlite::params_from_iter for parameterized queries


Phase 2c: Migrate embedding/ollama.rs to HTTP adapter

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

## Why
Ollama module handles health checks (GET) and embedding requests (POST JSON). ~20 LOC changed.

## Changes

### Health check
```rust
// Before: reqwest::get(url).await?.json::<TagsResponse>()?
// After:
let response = self.client.get(&url, &[]).await?;
let tags: TagsResponse = response.json()?;
```

### Embed batch
```rust
// Before: self.client.post(&url).json(&request).send().await?
// After:
let response = self.client.post_json(&url, &[], &request).await?;
if !response.is_success() {
    let status = response.status;
    let body = response.text()?;
    return Err(LoreError::EmbeddingFailed { document_id: 0, reason: format!("HTTP {status}: {body}") });
}
let embed_response: EmbedResponse = response.json()?;
```

### Standalone health check (check_ollama_health)
Currently creates a temporary reqwest::Client. Replace:
```rust
pub async fn check_ollama_health(base_url: &str) -> bool {
    let client = Client::with_timeout(Duration::from_secs(5));
    let url = format!("{base_url}/api/tags");
    client.get(&url, &[]).await.map_or(false, |r| r.is_success())
}
```

## Files Changed
- src/embedding/ollama.rs (~20 LOC changed)

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

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

WIREMOCK COMPATIBILITY: Same risk as Phase 2a (bd-lhj8). After migration, 4 pipeline_tests run on #[tokio::test] but call code using asupersync HTTP. See Phase 2a description for resolution options.


[CP1] Issue ingestion module

Fetch and store issues with cursor-based incremental sync.

Implement ingestIssues(options) → { fetched, upserted, labelsCreated }

Logic:
1. Get current cursor from sync_cursors
2. Paginate through issues updated after cursor
3. Apply local filtering for tuple cursor semantics
4. For each issue:
   - Store raw payload (compressed)
   - Upsert issue record
   - Extract and upsert labels
   - Link issue to labels via junction
5. Update cursor after each page commit

Files: src/ingestion/issues.ts
Tests: tests/integration/issue-ingestion.test.ts
Done when: Issues, labels, issue_labels populated correctly with resumable cursor


Implement snapshot test infrastructure + terminal compat matrix

## Background
Snapshot tests ensure deterministic rendering using FakeClock and ftui's test backend. They capture rendered TUI output as styled text and compare against golden files, catching visual regressions without a real terminal. The terminal compatibility matrix is a separate documentation artifact, not an automated test.

## Approach

### Snapshot Infrastructure

**Test Backend**: Use `ftui_harness::TestBackend` (or equivalent from ftui-harness crate) which captures rendered output as a Buffer without needing a real terminal. If ftui-harness is not available, create a minimal TestBackend that implements ftui's backend trait and stores cells in a `Vec<Vec<Cell>>`.

**Deterministic Rendering**:
- Inject FakeClock (from bd-2lg6) to freeze all relative time computations ("2 hours ago" always renders the same)
- Fix terminal size to 120x40 for all snapshot tests
- Use synthetic DB fixture with known data (same fixture pattern as parity tests)

**Snapshot Capture Flow**:
```rust
fn capture_snapshot(app: &LoreApp, size: (u16, u16)) -> String {
    let backend = TestBackend::new(size.0, size.1);
    // Render app.view() to backend
    // Convert buffer cells to plain text with ANSI annotations
    // Return as String
}
```

**Golden File Management**:
- Golden files stored in `crates/lore-tui/tests/snapshots/` as `.snap` files
- Naming: `{test_name}.snap` (e.g., `dashboard_default.snap`)
- Update mode: set env var `UPDATE_SNAPSHOTS=1` to overwrite golden files instead of comparing
- Use `insta` crate (or manual file comparison) for snapshot assertion

**Fixture Data** (synthetic, deterministic):
- 50 issues (mix of opened/closed/locked states, various labels)
- 25 MRs (mix of opened/merged/closed/draft)
- 100 discussions with notes
- Known timestamps relative to FakeClock's frozen time

### Snapshot Tests

Each test:
1. Creates in-memory DB with fixture data
2. Creates LoreApp with FakeClock frozen at 2026-01-15T12:00:00Z
3. Sets initial screen state
4. Renders via TestBackend at 120x40
5. Compares output against golden file

Tests to implement:
- `test_dashboard_snapshot`: Dashboard screen with fixture counts and recent activity
- `test_issue_list_snapshot`: Issue list with default sort, showing state badges and relative times
- `test_issue_detail_snapshot`: Single issue detail with description and discussion thread
- `test_mr_list_snapshot`: MR list showing draft indicators and review status
- `test_search_results_snapshot`: Search results with highlighted matches
- `test_empty_state_snapshot`: Dashboard with empty DB (zero issues/MRs)

### Terminal Compatibility Matrix (Documentation)

This is a manual verification checklist, NOT an automated test. Document results in `crates/lore-tui/TERMINAL_COMPAT.md`:

| Feature | iTerm2 | tmux | Alacritty | kitty |
|---------|--------|------|-----------|-------|
| True color (RGB) | | | | |
| Unicode width (CJK) | | | | |
| Box-drawing chars | | | | |
| Bold/italic/underline | | | | |
| Mouse events | | | | |
| Resize handling | | | | |
| Alt screen | | | | |

Fill in during manual QA, not during automated test implementation.

## Acceptance Criteria
- [ ] At least 6 snapshot tests pass with golden files committed to repo
- [ ] All snapshots use FakeClock frozen at 2026-01-15T12:00:00Z
- [ ] All snapshots render at fixed 120x40 terminal size
- [ ] Dashboard snapshot matches golden file (deterministic)
- [ ] Issue list snapshot matches golden file (deterministic)
- [ ] Empty state snapshot matches golden file
- [ ] UPDATE_SNAPSHOTS=1 env var overwrites golden files for updates
- [ ] Golden files are plain text (diffable in version control)
- [ ] TERMINAL_COMPAT.md template created (to be filled during manual QA)

## Files
- CREATE: crates/lore-tui/tests/snapshot_tests.rs
- CREATE: crates/lore-tui/tests/snapshots/ (directory for golden files)
- CREATE: crates/lore-tui/tests/snapshots/dashboard_default.snap
- CREATE: crates/lore-tui/tests/snapshots/issue_list_default.snap
- CREATE: crates/lore-tui/tests/snapshots/issue_detail.snap
- CREATE: crates/lore-tui/tests/snapshots/mr_list_default.snap
- CREATE: crates/lore-tui/tests/snapshots/search_results.snap
- CREATE: crates/lore-tui/tests/snapshots/empty_state.snap
- CREATE: crates/lore-tui/TERMINAL_COMPAT.md (template)

## TDD Anchor
RED: Write `test_dashboard_snapshot` that creates LoreApp with FakeClock and fixture DB, renders Dashboard at 120x40, asserts output matches `snapshots/dashboard_default.snap`. Fails because golden file does not exist yet.
GREEN: Render the Dashboard, run with UPDATE_SNAPSHOTS=1 to generate golden file, then run normally to verify match.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml snapshot

## Edge Cases
- Golden file encoding: always UTF-8, normalize line endings to LF
- FakeClock must be injected into all components that compute relative time (e.g., "2 hours ago")
- Snapshot diffs on CI: print a clear diff showing expected vs actual when mismatch occurs
- Fixture data must NOT include non-deterministic values (random IDs, current timestamps)
- If ftui-harness API changes, TestBackend shim may need updating

## Dependency Context
- Uses FakeClock from bd-2lg6 (Implement Clock trait)
- Uses all screen views from Phase 2 (Dashboard, Issue List, MR List, Detail views)
- Uses TestBackend from ftui-harness crate (or custom implementation)
- Depends on bd-3h00 (session persistence) per phase ordering — screens must be complete before snapshotting
- Downstream: bd-nu0d (fuzz tests) and bd-3fjk (race tests) depend on this infrastructure


OBSERV Epic: Phase 2 - Spans + Correlation IDs

Add tracing spans to all sync stages and generate UUID-based run_id for correlation. Every log line within a sync run includes run_id in JSON span context. Nested spans produce correct parent-child chains.

Depends on: Phase 1 (subscriber must support span recording)
Unblocks: Phase 3 (metrics), Phase 5 (rate limit logging)

Files: src/cli/commands/sync.rs, src/cli/commands/ingest.rs, src/ingestion/orchestrator.rs, src/documents/regenerator.rs, src/embedding/pipeline.rs, src/main.rs

Acceptance criteria (PRD Section 6.2):
- Every log line includes run_id in JSON span context
- Nested spans produce chain: fetch_pages includes parent ingest_issues span
- run_id is 8-char hex (truncated UUIDv4)
- Spans visible in -vv stderr output

Phase 2 complete: run_id correlation IDs generated at sync/ingest entry, root spans with .instrument() for async, #[instrument] on 5 key pipeline functions


Implement activity feed: assignment detection from system notes

## Background
`lore me` activity must surface assignment/reviewer events from system notes (`is_system=1`) as the fifth activity source. The main risk is false-positive username matching (`@ann` matching `@anna`) if naive `LIKE '%@ann%'` is used.

## Approach
Implement assignment-system-note extraction in `src/cli/commands/me/activity.rs` with explicit token-boundary matching.

### 1. Normalize note body for stable matching
Create a normalized expression used in predicates:
- lowercase body
- replace common punctuation/newlines with spaces
- pad leading/trailing spaces

Example SQL expression:
```sql
' ' || LOWER(
  REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(n.body, '\n', ' '), ',', ' '), '.', ' '), ';', ' '), ':', ' ')
) || ' '
```

Call this `body_norm` (inline or CTE).

### 2. Event detection predicates (word-boundary safe)
Use patterns with trailing-space boundary:
- assign: `body_norm LIKE '% assigned to @' || LOWER(?username) || ' %'`
- unassign: `body_norm LIKE '% unassigned @' || LOWER(?username) || ' %'`
- review request: `body_norm LIKE '% requested review from @' || LOWER(?username) || ' %'`

This avoids matching `@jdoe` inside `@jdoe2`.

### 3. Column contract
Return identical columns as other activity sources:
`timestamp, event_type, entity_type, entity_iid, project_path, actor, is_own, summary, body_preview`

Rules:
- `event_type`: `assign` | `unassign` | `review_request`
- `actor`: `COALESCE(n.author_username, 'system')`
- `is_own`: `(LOWER(COALESCE(n.author_username, '')) = LOWER(?username))`
- `body_preview`: `NULL`
- include `since` and `ProjectScope` filters
- include current-association `my_items` filter

## Acceptance Criteria
- [ ] Detects assign events from `assigned to @username`
- [ ] Detects unassign events from `unassigned @username`
- [ ] Detects review-request events from `requested review from @username`
- [ ] Username matching is boundary-safe (`@user` does not match `@user2`)
- [ ] Matching is case-insensitive
- [ ] Only system notes (`is_system = 1`) are considered
- [ ] Result shape matches other activity-source queries exactly
- [ ] `actor` is populated from note author with `'system'` fallback
- [ ] `is_own` compares actor vs dashboard username
- [ ] Respects `--since` and `ProjectScope`

## Files
- MODIFY: `src/cli/commands/me/activity.rs`

## TDD Anchor
RED:
- `test_assignment_detection_assign`
- `test_assignment_detection_unassign`
- `test_assignment_detection_review_request`
- `test_assignment_detection_case_insensitive`
- `test_assignment_detection_username_boundary_no_false_match`
- `test_assignment_detection_ignores_non_system_notes`

GREEN:
- Add normalized-body boundary-safe predicates and mapping.

VERIFY:
- `cargo test assignment_detection`
- `cargo test me_activity`

## Edge Cases
- Multi-target notes (`assigned to @alice and @jdoe`) should still match the target username.
- Punctuation adjacency (`@jdoe,`) is handled by punctuation-to-space normalization.
- Unexpected note text variants should fail closed (not misclassified).

## Dependency Context
Extends activity query layer from `bd-b3r3` and is consumed by `bd-2tl5` UNION assembly.

Dependencies:
  -> bd-b3r3 (blocks) - Implement activity feed: note and resource event queries

Dependents:
  <- bd-2tl5 (blocks) - Implement activity feed UNION ALL assembly


Write integration tests

## Background
Integration tests verify that modules work together with a real SQLite database. They test FTS search (stemming, empty results), embedding storage (sqlite-vec ops), hybrid search (combined retrieval), and sync orchestration (full pipeline). Each test creates a fresh in-memory DB with migrations applied.

## Approach
Create integration test files in `tests/`:

**1. tests/fts_search.rs:**
- Create DB, apply migrations 001-008
- Insert test documents via SQL
- Verify FTS5 triggers fired (documents_fts has matching count)
- Search with various queries: stemming, prefix, empty, special chars
- Verify result ranking (BM25 ordering)
- Verify snippet generation

**2. tests/embedding.rs:**
- Create DB, apply migrations 001-009 (requires sqlite-vec)
- Insert test documents + embeddings with known vectors
- Verify KNN search returns nearest neighbors
- Verify chunk deduplication
- Verify orphan cleanup trigger (delete document -> embeddings gone)

**3. tests/hybrid_search.rs:**
- Create DB, apply all migrations
- Insert documents + embeddings
- Test all three modes: lexical, semantic, hybrid
- Verify RRF ranking produces expected order
- Test graceful degradation (no embeddings -> FTS fallback)
- Test adaptive recall with filters

**4. tests/sync.rs:**
- Test sync orchestration with mock/stub GitLab responses
- Verify pipeline stages execute in order
- Verify lock acquisition/release
- Verify --no-embed and --no-docs flags

**Test fixtures:**
- Deterministic embedding vectors (no Ollama required): e.g., [1.0, 0.0, 0.0, ...] for doc1, [0.0, 1.0, 0.0, ...] for doc2
- Known documents with predictable search results
- Fixed timestamps for reproducibility

## Acceptance Criteria
- [ ] FTS search tests pass (stemming, prefix, empty, special chars)
- [ ] Embedding tests pass (KNN, dedup, orphan cleanup)
- [ ] Hybrid search tests pass (all 3 modes, graceful degradation)
- [ ] Sync tests pass (pipeline orchestration)
- [ ] All tests use in-memory DB (no file I/O)
- [ ] No external dependencies (no Ollama, no GitLab) — use fixtures/stubs
- [ ] `cargo test --test fts_search --test embedding --test hybrid_search --test sync` passes

## Files
- `tests/fts_search.rs` — new file
- `tests/embedding.rs` — new file
- `tests/hybrid_search.rs` — new file
- `tests/sync.rs` — new file
- `tests/fixtures/` — optional: test helper functions (shared DB setup)

## TDD Loop
These ARE integration tests — they verify the combined behavior of multiple beads.
VERIFY: `cargo test --test fts_search && cargo test --test embedding && cargo test --test hybrid_search && cargo test --test sync`

## Edge Cases
- sqlite-vec not available: embedding tests should skip gracefully (or require feature flag)
- In-memory DB with WAL mode: may behave differently than file DB — test both if critical
- Concurrent test execution: each test creates its own DB (no shared state)

Integration tests: 10 FTS search tests (stemming, empty, special chars, ordering, triggers, null title), 5 embedding tests (KNN, limit, dedup, orphan trigger, empty DB), 6 hybrid search tests (lexical mode, FTS-only, graceful degradation, RRF ranking, filters, mode variants). 310 total tests pass.


OBSERV Epic: Phase 1 - Verbosity Flags + Structured File Logging

Foundation layer for observability. Add -v/-vv/-vvv CLI flags, dual-layer tracing subscriber (stderr + file), daily log rotation via tracing-appender, log retention cleanup, --log-format json flag, and LoggingConfig.

Depends on: nothing (first phase)
Unblocks: Phase 2, and transitively all other phases

Files: Cargo.toml, src/cli/mod.rs, src/main.rs, src/core/config.rs, src/core/paths.rs, src/cli/commands/doctor.rs

Acceptance criteria (PRD Section 6.1):
- JSON log files written to ~/.local/share/lore/logs/ with zero config
- -v/-vv/-vvv control stderr verbosity per table in PRD 4.3
- RUST_LOG overrides -v for both layers
- --log-format json emits JSON on stderr
- Daily rotation, retention cleanup on startup
- --quiet suppresses stderr, does NOT affect file layer
- lore doctor reports log directory info

Phase 1 complete: dual-layer subscriber, -v/--verbose flags, --log-format json, LoggingConfig, get_log_dir(), log retention, doctor diagnostics


Epic: TUI Phase 5.6 — CLI/TUI Parity Pack

## Background
Phase 5.6 ensures the TUI displays the same data as the CLI robot mode, preventing drift between interfaces. Tests compare TUI query results against CLI --robot output for counts, list data, detail data, and search results.

## Acceptance Criteria
- [ ] Dashboard counts match lore --robot count output
- [ ] Issue/MR list data matches lore --robot issues/mrs output
- [ ] Issue/MR detail data matches lore --robot issues/mrs <iid> output
- [ ] Search results identity (same IDs, same order) matches lore --robot search output
- [ ] Terminal safety sanitization applied consistently in TUI and CLI


Implement entity cache + render cache

## Background
Entity cache provides near-instant detail view reopens during Enter/Esc drill workflows by caching IssueDetail/MrDetail payloads. Render cache prevents per-frame recomputation of expensive render artifacts (markdown to styled text, discussion tree shaping). Both use bounded LRU eviction with selective invalidation.

## Approach

### Entity Cache (entity_cache.rs)

```rust
use std::collections::HashMap;

pub struct EntityCache<V> {
    entries: HashMap<EntityKey, (V, u64)>,  // value + last-access tick
    capacity: usize,
    tick: u64,
}

impl<V> EntityCache<V> {
    pub fn new(capacity: usize) -> Self;
    pub fn get(&mut self, key: &EntityKey) -> Option<&V>;  // updates tick
    pub fn put(&mut self, key: EntityKey, value: V);       // evicts oldest if at capacity
    pub fn invalidate(&mut self, keys: &[EntityKey]);      // selective by key set
}
```

- `EntityKey` is `(EntityType, i64)` from core types (bd-c9gk) — e.g., `(EntityType::Issue, 42)`
- Default capacity: 64 entries (sufficient for typical drill-in/out workflows)
- LRU eviction: on `put()` when at capacity, find entry with lowest tick and remove it
- `get()` bumps the access tick to keep recently-accessed entries alive
- `invalidate()` takes a slice of changed keys (from sync results) and removes only those entries — NOT a blanket clear

### Render Cache (render_cache.rs)

```rust
pub struct RenderCacheKey {
    content_hash: u64,      // FxHash of source content
    terminal_width: u16,    // width affects line wrapping
}

pub struct RenderCache<V> {
    entries: HashMap<RenderCacheKey, V>,
    capacity: usize,
}

impl<V> RenderCache<V> {
    pub fn new(capacity: usize) -> Self;
    pub fn get(&self, key: &RenderCacheKey) -> Option<&V>;
    pub fn put(&mut self, key: RenderCacheKey, value: V);
    pub fn invalidate_width(&mut self, keep_width: u16);  // remove entries NOT matching this width
    pub fn invalidate_all(&mut self);                      // theme change = full clear
}
```

- Default capacity: 256 entries
- Used for: markdown->styled text, discussion tree layout, issue body rendering
- `content_hash` uses `std::hash::Hasher` with FxHash (or std DefaultHasher) on source text
- `invalidate_width(keep_width)`: on terminal resize, remove entries cached at old width
- `invalidate_all()`: on theme change, clear everything (colors changed)
- Both caches are NOT thread-safe (single-threaded TUI event loop). No Arc/Mutex needed.

### Integration Point
Both caches live as fields on the main LoreApp struct. Cache miss falls through to normal DB query transparently — the action functions check cache first, query DB on miss, populate cache on return.

## Acceptance Criteria
- [ ] EntityCache::get returns Some for recently put items
- [ ] EntityCache::put evicts the least-recently-accessed entry when at capacity
- [ ] EntityCache::invalidate removes only the specified keys, leaves others intact
- [ ] EntityCache capacity defaults to 64
- [ ] RenderCache::get returns Some for matching (hash, width) pair
- [ ] RenderCache::invalidate_width removes entries with non-matching width
- [ ] RenderCache::invalidate_all clears everything
- [ ] RenderCache capacity defaults to 256
- [ ] Both caches are Send (no Rc, no raw pointers) but NOT required to be Sync
- [ ] No unsafe code

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

## TDD Anchor
RED: Write `test_entity_cache_lru_eviction` that creates EntityCache with capacity 3, puts 4 items, asserts first item (lowest tick) is evicted and the other 3 remain.
GREEN: Implement LRU eviction using tick-based tracking.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml entity_cache

Additional tests:
- test_entity_cache_get_bumps_tick (accessed item survives eviction over older untouched items)
- test_entity_cache_invalidate_selective (removes only specified keys)
- test_entity_cache_invalidate_nonexistent_key (no panic)
- test_render_cache_width_invalidation (entries at old width removed, current width kept)
- test_render_cache_invalidate_all (empty after call)
- test_render_cache_capacity_eviction

## Edge Cases
- Invalidating an EntityKey not in the cache is a no-op (no panic)
- Zero-capacity cache: all gets return None, all puts are no-ops (degenerate but safe)
- RenderCacheKey equality: two different strings can have the same hash (collision) — accept this; worst case is a wrong cached render that gets corrected on next invalidation
- Entity cache should NOT be prewarmed synchronously during sync — sync results just invalidate stale entries, and the next view() call repopulates on demand

## Dependency Context
Depends on bd-c9gk (core types) for EntityKey type definition.
Both caches are integrated into LoreApp (bd-6pmy) as struct fields.
Action functions (from Phase 2/3 screen beads) check cache before querying DB.


[CP1] Epic: Issue Ingestion

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

## Success Criteria
- gi ingest --type=issues fetches all issues (count matches GitLab UI)
- Labels extracted from issue payloads (name-only)
- Label linkage reflects current GitLab state (removed labels unlinked on re-sync)
- Issue discussions fetched per-issue (dependent sync)
- Cursor-based sync is resumable (re-running fetches 0 new items)
- Discussion sync skips unchanged issues (per-issue watermark)
- Sync tracking records all runs
- Single-flight lock prevents concurrent runs

## Internal Gates
- Gate A: Issues only (cursor + upsert + raw payloads + list/count/show)
- Gate B: Labels correct (stale-link removal verified)
- Gate C: Dependent discussion sync (watermark prevents redundant refetch)
- Gate D: Resumability proof (kill mid-run, rerun; bounded redo)

Reference: docs/prd/checkpoint-1.md


Phase 4d: E2E runtime acceptance + structured logging

## What
Add a dedicated end-to-end acceptance bead that runs the production CLI flow under asupersync with structured, high-signal logging. This is the missing bridge between unit/integration validation and real user workflow confidence.

## Why
Current beads cover adapter tests, cancellation tests, parity checks, and hardening, but we still need a single e2e acceptance path that proves the full CLI behavior with runtime semantics, cancellation, and resume behavior in one place.

## Scope
Create an e2e test harness (script or integration test) that:
1. Seeds a realistic project fixture (issues + MRs + discussions) via mocked GitLab endpoints.
2. Runs `lore sync` with robot output and verbose logging enabled.
3. Triggers Ctrl+C during active fan-out to validate graceful cancellation behavior.
4. Re-runs sync to confirm resume behavior and no corruption/stuck dependent jobs.
5. Emits machine-readable run artifacts (JSON summary + log file path) for CI debugging.

## Logging Requirements (detailed)
At minimum, logs/artifacts must include:
- run/session ID
- phase transitions (preflight, fetch, ingest, dependent drains, finalize)
- per-entity outcome (`synced|failed|skipped`) with IID and entity type
- cancellation signal timing and source (first Ctrl+C vs force exit path)
- timeout classifications (`NetworkErrorKind`) when applicable
- retry attempts and terminal failure reasons
- final counters matching SyncResult fields

## Acceptance Criteria
- [ ] One command executes the full e2e flow deterministically in CI/local
- [ ] Cancellation mid-flight is exercised and validated (not just happy path)
- [ ] Second run resumes cleanly; no stuck locks/jobs remain
- [ ] Structured logs are persisted and easy to inspect after failure
- [ ] E2E summary includes pass/fail per scenario and key counters
- [ ] `cargo test` or CI task wiring documented in bead comments

## Suggested Location
- `tests/asupersync_e2e.rs` OR `tests/e2e/asupersync_smoke.sh` (+ Rust helper)
- Keep implementation in-repo (no external infra requirement)

## Dependency Context
This bead executes after major migration and phase-4 runtime test work is in place. It is an acceptance gate input for Phase 5 hardening.


WHO: CLI skeleton — WhoArgs, Commands::Who, dispatch arm

## Background

Wire up the CLI plumbing so `lore who --help` works and dispatch reaches the who module. This is pure boilerplate — no query logic yet.

## Approach

### 1. src/cli/mod.rs — WhoArgs struct (after TimelineArgs, ~line 195)

```rust
#[derive(Parser)]
#[command(after_help = "\x1b[1mExamples:\x1b[0m
  lore who src/features/auth/           # Who knows about this area?
  lore who @asmith                      # What is asmith working on?
  lore who @asmith --reviews            # What review patterns does asmith have?
  lore who --active                     # What discussions need attention?
  lore who --overlap src/features/auth/ # Who else is touching these files?
  lore who --path README.md             # Expert lookup for a root file")]
pub struct WhoArgs {
    /// Username or file path (path if contains /)
    pub target: Option<String>,

    /// Force expert mode for a file/directory path (handles root files like README.md, Makefile)
    #[arg(long, help_heading = "Mode", conflicts_with_all = ["active", "overlap", "reviews"])]
    pub path: Option<String>,

    /// Show active unresolved discussions
    #[arg(long, help_heading = "Mode", conflicts_with_all = ["target", "overlap", "reviews", "path"])]
    pub active: bool,

    /// Find users with MRs/notes touching this file path
    #[arg(long, help_heading = "Mode", conflicts_with_all = ["target", "active", "reviews", "path"])]
    pub overlap: Option<String>,

    /// Show review pattern analysis (requires username target)
    #[arg(long, help_heading = "Mode", requires = "target", conflicts_with_all = ["active", "overlap", "path"])]
    pub reviews: bool,

    /// Time window (7d, 2w, 6m, YYYY-MM-DD). Default varies by mode.
    #[arg(long, help_heading = "Filters")]
    pub since: Option<String>,

    /// Scope to a project (supports fuzzy matching)
    #[arg(short = 'p', long, help_heading = "Filters")]
    pub project: Option<String>,

    /// Maximum results per section (1..=500)
    #[arg(short = 'n', long = "limit", default_value = "20",
          value_parser = clap::value_parser!(u16).range(1..=500),
          help_heading = "Output")]
    pub limit: u16,
}
```

### 2. Commands enum — add Who(WhoArgs) after Timeline, before hidden List

### 3. src/cli/commands/mod.rs — add `pub mod who;` and re-exports:
```rust
pub use who::{run_who, print_who_human, print_who_json, WhoRun};
```

### 4. src/main.rs — dispatch arm + handler:
```rust
Some(Commands::Who(args)) => handle_who(cli.config.as_deref(), args, robot_mode),
```

### 5. src/cli/commands/who.rs — stub file with signatures that compile

## Files

- `src/cli/mod.rs` — WhoArgs struct + Commands::Who variant
- `src/cli/commands/mod.rs` — pub mod who + re-exports
- `src/main.rs` — dispatch arm + handle_who function + imports
- `src/cli/commands/who.rs` — CREATE stub file

## TDD Loop

RED: `cargo check --all-targets` fails (missing who module)
GREEN: Create stub who.rs with empty/todo!() implementations, wire up all 4 files
VERIFY: `cargo check --all-targets && cargo run -- who --help`

## Acceptance Criteria

- [ ] `cargo check --all-targets` passes
- [ ] `lore who --help` displays all flags with correct grouping (Mode, Filters, Output)
- [ ] `lore who --active --overlap foo` rejected by clap (conflicts_with)
- [ ] `lore who --reviews` rejected by clap (requires target)
- [ ] WhoArgs is pub and importable from lore::cli

## Edge Cases

- conflicts_with_all on --path must NOT include "target" (--path is used alongside positional target in some cases... actually no, --path replaces target — check the plan: it conflicts with active/overlap/reviews but NOT target. Wait, looking at the plan: --path does NOT conflict with target. But if both target and --path are provided, --path takes priority in resolve_mode. The clap struct allows both.)

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


Dynamic shell completions for file paths (lore complete-path)

## Background

Tab-completion for lore commands currently only covers static subcommand/flag names via clap_complete v4 (src/main.rs handle_completions(), line ~1667). Users frequently type file paths (for who --path, file-history) and entity IIDs (for issues, mrs, show) manually. Dynamic completions would allow tab-completing these from the local SQLite database.

**Pattern:** kubectl, gh, docker all use hidden subcommands for dynamic completions. clap_complete v4 has a custom completer API that can shell out to these hidden subcommands.

## Codebase Context

- **Static completions**: Commands::Completions variant in src/cli/mod.rs, handled by handle_completions() in src/main.rs (line ~1667) using clap_complete::generate()
- **clap_complete v4**: Already in Cargo.toml. Supports custom completer API for dynamic values.
- **Commands taking IIDs**: IssuesArgs (iid: Option<i64>), MrsArgs (iid: Option<i64>), Drift (for: EntityRef), Show (hidden, takes entity ref)
- **path_resolver**: src/core/path_resolver.rs (245 lines). build_path_query() (lines 71-187) and suffix_probe() (lines 192-240) resolve partial paths against mr_file_changes. SuffixResult::Ambiguous(Vec<String>) returns multiple matches — perfect for completions.
- **who --path**: WhoArgs has `path: Option<String>` field, already uses path_resolver
- **DB access**: create_connection() from src/core/db.rs, config loading from src/core/config.rs
- **Performance**: Must complete in <100ms. SQLite queries against indexed columns are sub-ms.

## Approach

### 1. Hidden Subcommands (src/cli/mod.rs)

Add hidden subcommands that query the DB and print completion candidates:

```rust
/// Hidden: emit file path completions for shell integration
#[command(name = "complete-path", hide = true)]
CompletePath {
    /// Partial path prefix to complete
    prefix: String,
    /// Project scope
    #[arg(short = 'p', long)]
    project: Option<String>,
},

/// Hidden: emit issue IID completions
#[command(name = "complete-issue", hide = true)]
CompleteIssue {
    /// Partial IID prefix
    prefix: String,
    #[arg(short = 'p', long)]
    project: Option<String>,
},

/// Hidden: emit MR IID completions
#[command(name = "complete-mr", hide = true)]
CompleteMr {
    /// Partial IID prefix
    prefix: String,
    #[arg(short = 'p', long)]
    project: Option<String>,
},
```

### 2. Completion Handlers (src/cli/commands/completions.rs NEW)

```rust
pub fn complete_path(conn: &Connection, prefix: &str, project_id: Option<i64>) -> Result<Vec<String>> {
    // Use suffix_probe() from path_resolver if prefix looks like a suffix (no leading /)
    // Otherwise: SELECT DISTINCT new_path FROM mr_file_changes WHERE new_path LIKE ?||'%' LIMIT 50
    // Also check old_path for rename awareness
}

pub fn complete_issue(conn: &Connection, prefix: &str, project_id: Option<i64>) -> Result<Vec<String>> {
    // SELECT iid, title FROM issues WHERE CAST(iid AS TEXT) LIKE ?||'%' ORDER BY updated_at DESC LIMIT 30
    // Output: "123\tFix login bug" (tab-separated for shell description)
}

pub fn complete_mr(conn: &Connection, prefix: &str, project_id: Option<i64>) -> Result<Vec<String>> {
    // SELECT iid, title FROM merge_requests WHERE CAST(iid AS TEXT) LIKE ?||'%' ORDER BY updated_at DESC LIMIT 30
    // Output: "456\tAdd OAuth support"
}
```

### 3. Wire in main.rs

Add match arms for CompletePath, CompleteIssue, CompleteMr. Each:
1. Opens DB connection (read-only)
2. Resolves project if -p given
3. Calls completion handler
4. Prints one candidate per line to stdout
5. Exits 0

### 4. Shell Integration

Update handle_completions() to generate shell scripts that call the hidden subcommands. For fish:
```fish
complete -c lore -n '__fish_seen_subcommand_from issues' -a '(lore complete-issue "")'
complete -c lore -n '__fish_seen_subcommand_from who' -l path -a '(lore complete-path (commandline -ct))'
```

Similar for bash (using `_lore_complete()` function) and zsh.

## Acceptance Criteria

- [ ] `lore complete-path "src/co"` prints matching file paths from mr_file_changes
- [ ] `lore complete-issue "12"` prints matching issue IIDs with titles
- [ ] `lore complete-mr "45"` prints matching MR IIDs with titles
- [ ] All three hidden subcommands respect -p for project scoping
- [ ] All three complete in <100ms (SQLite indexed queries)
- [ ] Empty prefix returns recent/popular results (not all rows)
- [ ] Hidden subcommands don't appear in --help or completions themselves
- [ ] Shell completion scripts (fish, bash, zsh) call hidden subcommands for dynamic values
- [ ] Static completions (subcommands, flags) still work as before
- [ ] No DB connection attempted if DB doesn't exist (graceful degradation — return no completions)
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes
- [ ] `cargo fmt --check` passes

## Files

- MODIFY: src/cli/mod.rs (add CompletePath, CompleteIssue, CompleteMr hidden variants)
- CREATE: src/cli/commands/completions.rs (complete_path, complete_issue, complete_mr handlers)
- MODIFY: src/cli/commands/mod.rs (add pub mod completions)
- MODIFY: src/main.rs (match arms for hidden subcommands + update handle_completions shell scripts)

## TDD Anchor

RED:
- test_complete_path_suffix_match (in-memory DB with mr_file_changes rows, verify suffix matching returns correct paths)
- test_complete_issue_prefix (in-memory DB with issues, verify IID prefix filtering)
- test_complete_mr_prefix (same for MRs)
- test_complete_empty_prefix_returns_recent (verify limited results ordered by updated_at DESC)

GREEN: Implement completion handlers with SQL queries.

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

## Edge Cases

- DB doesn't exist yet (first run before sync): return empty completions, exit 0 (not error)
- mr_file_changes empty (sync hasn't run with --fetch-mr-diffs): complete-path returns nothing, no error
- Very long prefix with no matches: empty output, exit 0
- Special characters in paths (spaces, brackets): shell quoting handled by completion framework
- Project ambiguous with -p: exit 18, same as other commands (resolve_project pattern)
- IID prefix "0": return nothing (no issues/MRs have iid=0)

## Dependency Context

- **path_resolver** (src/core/path_resolver.rs): provides suffix_probe() which returns SuffixResult::Exact/Ambiguous/NotFound — reuse for complete-path instead of raw SQL when prefix looks like a suffix
- **mr_file_changes** (migration 016): provides new_path/old_path columns for file path completions
- **clap_complete v4** (Cargo.toml): provides generate() for static completions and custom completer API for dynamic shell integration


OBSERV: Replace subscriber init with dual-layer setup

## Background
This is the core infrastructure bead for Phase 1. It replaces the single-layer subscriber (src/main.rs:44-58) with a dual-layer registry that separates stderr and file concerns. The file layer provides always-on post-mortem data; the stderr layer respects -v flags.

## Approach
Replace src/main.rs lines 44-58 with a function (e.g., init_tracing()) that:

1. Build stderr filter from -v count (or RUST_LOG override):
```rust
fn build_stderr_filter(verbose: u8, quiet: bool) -> EnvFilter {
    if let Ok(rust_log) = std::env::var("RUST_LOG") {
        return EnvFilter::new(rust_log);
    }
    if quiet {
        return EnvFilter::new("lore=warn,error");
    }
    match verbose {
        0 => EnvFilter::new("lore=info,warn"),
        1 => EnvFilter::new("lore=debug,warn"),
        2 => EnvFilter::new("lore=debug,info"),
        _ => EnvFilter::new("trace,debug"),
    }
}
```

2. Build file filter (always lore=debug,warn unless RUST_LOG set):
```rust
fn build_file_filter() -> EnvFilter {
    if let Ok(rust_log) = std::env::var("RUST_LOG") {
        return EnvFilter::new(rust_log);
    }
    EnvFilter::new("lore=debug,warn")
}
```

3. Assemble the registry:
```rust
let stderr_layer = fmt::layer()
    .with_target(false)
    .with_writer(SuspendingWriter);
// Conditionally add .json() based on log_format

let file_appender = tracing_appender::rolling::daily(log_dir, "lore");
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
let file_layer = fmt::layer()
    .json()
    .with_writer(non_blocking);

tracing_subscriber::registry()
    .with(stderr_layer.with_filter(build_stderr_filter(cli.verbose, cli.quiet)))
    .with(file_layer.with_filter(build_file_filter()))
    .init();
```

CRITICAL: The non_blocking _guard must be held for the program's lifetime. Store it in main() scope, NOT in the init function. If the guard drops, the file writer thread stops and buffered logs are lost.

CRITICAL: Per-layer filtering requires each .with_filter() to produce a Filtered<L, F, S> type. The two layers will have different concrete types (one with json, one without). This is fine -- the registry accepts heterogeneous layers via .with().

When --log-format json: wrap stderr_layer with .json() too. This requires conditional construction. Two approaches:
  A) Use Box<dyn Layer<S>> for dynamic dispatch (simpler, tiny perf hit)
  B) Use an enum wrapper (zero cost but more code)
Recommend approach A for simplicity. The overhead is one vtable indirection per log event, dwarfed by I/O.

When file_logging is false (LoggingConfig.file_logging == false): skip adding the file layer entirely.

## Acceptance Criteria
- [ ] lore sync writes JSON log lines to ~/.local/share/lore/logs/lore.YYYY-MM-DD.log
- [ ] lore -v sync shows DEBUG lore::* on stderr, deps at WARN
- [ ] lore -vv sync shows DEBUG lore::* + INFO deps on stderr
- [ ] lore -vvv sync shows TRACE everything on stderr
- [ ] RUST_LOG=lore::gitlab=trace overrides -v for both layers
- [ ] lore --log-format json sync emits JSON on stderr
- [ ] -q + -v: -q wins (stderr at WARN+)
- [ ] -q does NOT affect file layer (still DEBUG+)
- [ ] File layer does NOT use SuspendingWriter
- [ ] Non-blocking guard kept alive for program duration
- [ ] Existing behavior unchanged when no new flags passed
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/main.rs (replace lines 44-58, add init_tracing function or inline)

## TDD Loop
RED:
  - test_verbosity_filter_construction: assert filter directives for verbose=0,1,2,3
  - test_rust_log_overrides_verbose: set env, assert TRACE not DEBUG
  - test_quiet_overrides_verbose: -q + -v => WARN+
  - test_json_log_output_format: capture file output, parse as JSON
  - test_suspending_writer_dual_layer: no garbled stderr with progress bars
GREEN: Implement build_stderr_filter, build_file_filter, assemble registry
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- _guard lifetime: if guard is dropped early, buffered log lines are lost. MUST hold in main() scope.
- Type erasure: stderr layer with/without .json() produces different types. Use Box<dyn Layer<_>> or separate init paths.
- Empty RUST_LOG string: env::var returns Ok(""), which EnvFilter::new("") defaults to TRACE. May want to check is_empty().
- File I/O error on log dir: tracing-appender handles this gracefully (no panic), but logs will be silently lost. The doctor command (bd-2i10) can diagnose this.

Replaced single-layer subscriber with dual-layer setup: stderr (human/json, -v controlled) + file (always-on JSON, daily rotation via tracing-appender)


Robot sync envelope: status enrichment metadata

## Background
Agents need machine-readable status enrichment metadata in the robot sync output to detect issues like unsupported GraphQL, partial errors, or enrichment failures. Without this, enrichment problems are invisible to automation.

## Approach
Wire IngestProjectResult status fields into the per-project robot sync JSON. Add aggregate error count to top-level summary.

## Files
- Wherever robot sync output JSON is constructed (likely src/cli/commands/ingest.rs or the sync output serialization path — search for IngestProjectResult -> JSON conversion)

## Implementation

Per-project status_enrichment object in robot sync JSON:
{
  "mode": "fetched" | "unsupported" | "skipped",
  "reason": null | "graphql_endpoint_missing" | "auth_forbidden",
  "seen": N,
  "enriched": N,
  "cleared": N,
  "without_widget": N,
  "partial_errors": N,
  "first_partial_error": null | "message",
  "error": null | "message"
}

Source fields from IngestProjectResult:
  mode <- status_enrichment_mode
  reason <- status_unsupported_reason
  seen <- statuses_seen
  enriched <- statuses_enriched
  cleared <- statuses_cleared
  without_widget <- statuses_without_widget
  partial_errors <- partial_error_count
  first_partial_error <- first_partial_error
  error <- status_enrichment_error

Top-level sync summary: add status_enrichment_errors: N (count of projects where error is Some)

Field semantics:
  mode "fetched": enrichment ran (even if 0 statuses or error occurred)
  mode "unsupported": 404/403 from GraphQL
  mode "skipped": config toggle off
  seen > 0 + enriched == 0: project has issues but none with status
  partial_errors > 0: some pages returned incomplete data

## Acceptance Criteria
- [ ] Robot sync JSON includes per-project status_enrichment object
- [ ] All 9 fields present with correct types
- [ ] mode reflects actual enrichment outcome (fetched/unsupported/skipped)
- [ ] Top-level status_enrichment_errors count present
- [ ] Test: full robot sync output validates structure

## TDD Loop
RED: test_robot_sync_includes_status_enrichment
GREEN: Wire fields into JSON serialization
VERIFY: cargo test robot_sync

## Edge Cases
- Find the exact location where IngestProjectResult is serialized to JSON — may be in a Serialize impl or manual json! macro
- All numeric fields default to 0, all Option fields default to null in JSON
- mode is always present (never null)

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


Implement lore embed CLI command

## Background
The embed CLI command is the user-facing wrapper for the embedding pipeline. It runs Ollama health checks, selects documents to embed (pending or failed), shows progress, and reports results. This is the standalone command for building embeddings outside of the sync orchestrator.

## Approach
Create `src/cli/commands/embed.rs` per PRD Section 4.4.

**IMPORTANT: The embed command is async.** The underlying `embed_documents()` function is `async fn` (uses `FuturesUnordered` for concurrent HTTP to Ollama). The CLI runner must use tokio runtime.

**Core function (async):**
```rust
pub async fn run_embed(
    config: &Config,
    retry_failed: bool,
) -> Result<EmbedResult>
```

**Pipeline:**
1. Create OllamaClient from config.embedding (base_url, model, timeout_secs)
2. Run `client.health_check().await` — fail early with clear error if Ollama unavailable or model missing
3. Determine selection: `EmbedSelection::RetryFailed` if --retry-failed, else `EmbedSelection::Pending`
4. Call `embed_documents(conn, &client, selection, concurrency, progress_callback).await`
   - `concurrency` param controls max in-flight HTTP requests to Ollama
   - `progress_callback` drives indicatif progress bar
5. Show progress bar (indicatif) during embedding
6. Return EmbedResult with counts

**CLI args:**
```rust
#[derive(Args)]
pub struct EmbedArgs {
    #[arg(long)]
    retry_failed: bool,
}
```

**Output:**
- Human: "Embedded 42 documents (15 chunks), 2 errors, 5 skipped (unchanged)"
- JSON: `{"ok": true, "data": {"embedded": 42, "chunks": 15, "errors": 2, "skipped": 5}}`

**Tokio integration note:**
The embed command runs async code. Either:
- Use `#[tokio::main]` on main and propagate async through CLI dispatch
- Or use `tokio::runtime::Runtime::new()` in the embed command handler

## Acceptance Criteria
- [ ] Command is async (embed_documents is async, health_check is async)
- [ ] OllamaClient created from config.embedding settings
- [ ] Health check runs first — clear error if Ollama down (exit code 14)
- [ ] Clear error if model not found: "Pull the model: ollama pull nomic-embed-text" (exit code 15)
- [ ] Embeds pending documents (no existing embeddings or stale content_hash)
- [ ] --retry-failed re-attempts documents with last_error
- [ ] Progress bar shows during embedding (indicatif)
- [ ] embed_documents called with concurrency parameter
- [ ] embed_documents called with progress_callback for progress bar
- [ ] Human + JSON output
- [ ] `cargo build` succeeds

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

## TDD Loop
RED: Integration test needing Ollama
GREEN: Implement run_embed (async)
VERIFY: `cargo build && cargo test embed`

## Edge Cases
- No documents in DB: "No documents to embed" (not error)
- All documents already embedded and unchanged: "0 documents to embed (all up to date)"
- Ollama goes down mid-embedding: pipeline records errors for remaining docs, returns partial result
- --retry-failed with no failed docs: "No failed documents to retry"

Embed CLI command fully wired: EmbedArgs, Commands::Embed variant, handle_embed handler, clean build, all tests pass


Implement activity feed UNION ALL assembly

## Background
The activity feed combines 5 event sources (human comments, state events, label events, milestone events, assignment system notes) into a single chronologically sorted feed. This bead assembles the UNION ALL query, executes it, and returns the final `Vec<MeActivityItem>`.

## Approach
Add the top-level activity fetch function in `src/cli/commands/me/activity.rs` (same file as bd-b3r3 and bd-2nl3):

```rust
use rusqlite::{Connection, params};
use crate::core::error::Result;
use super::types::MeActivityItem;
// ProjectScope from bd-a7ba

pub fn fetch_my_activity(
    conn: &Connection,
    username: &str,
    scope: &ProjectScope,
    since_ms: i64,
) -> Result<Vec<MeActivityItem>>
```

### Assembly

Combine the 5 SQL source fragments from bd-b3r3 and bd-2nl3:
```sql
SELECT * FROM (
    {notes_sql}
    UNION ALL
    {state_events_sql}
    UNION ALL
    {label_events_sql}
    UNION ALL
    {milestone_events_sql}
    UNION ALL
    {assignment_notes_sql}
) ORDER BY timestamp DESC
```

### Row mapping

Map each row to `MeActivityItem`:
```rust
MeActivityItem {
    timestamp_iso: ms_to_iso(row.get::<_, i64>("timestamp")?),
    event_type: row.get("event_type")?,
    entity_type: row.get("entity_type")?,
    entity_iid: row.get("entity_iid")?,
    project_path: row.get("project_path")?,
    actor: row.get::<_, Option<String>>("actor")?.unwrap_or_default(),
    is_own: row.get::<_, i64>("is_own")? != 0,
    summary: row.get("summary")?,
    body_preview: row.get("body_preview")?,
}
```

Use `crate::core::time::ms_to_iso(ms)` to convert ms epoch → ISO string.

### Parameter binding

All 5 subqueries share the same parameters: `?username`, `?since_ms`, and optionally `?project_id`. Use named parameters (`?1`, `?2`, etc.) or build the SQL with the same positional params repeated. Since rusqlite doesn't support named params in UNION ALL easily, the cleanest approach is to use `format!()` to build the SQL string, then bind shared params. Be careful with SQL injection — the `username` param must ALWAYS be bound, never interpolated.

## Acceptance Criteria
- [ ] Combines all 5 activity sources into one result set via UNION ALL
- [ ] Sorted by timestamp DESC — newest first (AC-9.3)
- [ ] is_own flag correctly set: `row["is_own"] != 0` (SQLite returns 0/1 for boolean expressions)
- [ ] Only includes activity on items the user is CURRENTLY associated with (AC-3.6)
- [ ] Respects --since window (only activity within the window)
- [ ] Respects ProjectScope filtering
- [ ] Includes activity on closed items (AC-5.4)
- [ ] timestamp converted to ISO string via `ms_to_iso()`
- [ ] Returns `Vec<MeActivityItem>` with all fields populated
- [ ] actor NULL handled gracefully (COALESCE or unwrap_or_default)

## Files
- MODIFY: src/cli/commands/me/activity.rs (add top-level assembly function)

## TDD Anchor
RED: Write `test_activity_feed_combines_all_sources` using in-memory DB. Insert:
- A project, issue, issue_assignee(username="jdoe")
- A discussion + human note (event_type="note")
- A resource_state_event (event_type="status")
- A system note with "assigned to @jdoe" (event_type="assign")
Call `fetch_my_activity`. Assert result contains all 3 event types, sorted newest first.

GREEN: Implement the UNION ALL assembly.
VERIFY: `cargo test activity_feed_combines`

Additional tests:
- test_activity_feed_sorted_newest_first (3 events at different timestamps, verify order)
- test_activity_feed_respects_since (event before since_ms excluded)
- test_activity_feed_only_current_associations (event on unassigned issue excluded)
- test_activity_feed_includes_closed_items (closed issue with assignee still shows activity)
- test_activity_feed_is_own_flag (actor=me → is_own=true, actor=other → is_own=false)

## Edge Cases
- Large activity feeds: the UNION ALL has no LIMIT — returns everything within the since window. SQLite handles this fine for typical use.
- No duplication between sources: notes source uses is_system=0, assignment source uses is_system=1, resource events are separate tables entirely — no overlap.
- The "current association" filter is critical: if user was unassigned from an issue, activity on it should NOT appear even if it was recent.
- Empty result (no activity) → return empty Vec, not an error (AC-10.3).
- `actor_username` can be NULL on resource events — use `unwrap_or_default()` to get empty string.

## Dependency Context
Combines SQL fragments from bd-b3r3 (note + resource event queries) and bd-2nl3 (assignment detection).
Uses `MeActivityItem` from bd-3bwh, `ProjectScope` from bd-a7ba, `ms_to_iso` from `crate::core::time`.
Called by the handler in bd-1vv8.


Epic: TUI Phase 1 — Foundation

## Background
Phase 1 builds the foundational infrastructure that all screens depend on: the full LoreApp Model implementation with key dispatch, navigation stack, task supervisor for async work management, theme configuration, common widgets, and the state/action architecture. Phase 1 deliverables are the skeleton that Phase 2 screens plug into.

## Acceptance Criteria
- [ ] LoreApp update() dispatches all Msg variants through 5-stage key pipeline
- [ ] NavigationStack supports push/pop/forward/jump with state preservation
- [ ] TaskSupervisor manages background tasks with dedup, cancellation, and generation IDs
- [ ] Theme renders correctly with adaptive light/dark colors
- [ ] Status bar, breadcrumb, loading, error toast, and help overlay widgets render
- [ ] CommandRegistry is the single source of truth for keybindings/help/palette
- [ ] AppState composition with per-screen states and LoadState map

## Scope
Blocked by Phase 0 (Toolchain Gate). Blocks Phase 2 (Core Screens).


[CP1] gi ingest --type=issues command

CLI command to orchestrate issue ingestion.

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

## Clap Definition
#[derive(Subcommand)]
pub enum Commands {
    Ingest {
        #[arg(long, value_parser = ["issues", "merge_requests"])]
        r#type: String,

        #[arg(long)]
        project: Option<String>,

        #[arg(long)]
        force: bool,
    },
}

## Implementation
1. Acquire app lock with heartbeat (respect --force for stale lock)
2. Create sync_run record (status='running')
3. For each configured project (or filtered --project):
   - Call orchestrator to ingest issues and discussions
   - Show progress (spinner or progress bar)
4. Update sync_run (status='succeeded', metrics_json with counts)
5. Release lock

## Output Format
Ingesting issues...

  group/project-one: 1,234 issues fetched, 45 new labels

Fetching discussions (312 issues with updates)...

  group/project-one: 312 issues → 1,234 discussions, 5,678 notes

Total: 1,234 issues, 1,234 discussions, 5,678 notes (excluding 1,234 system notes)
Skipped discussion sync for 922 unchanged issues.

## Error Handling
- Lock acquisition failure: exit with DatabaseLockError message
- Network errors: show GitLabNetworkError, exit non-zero
- Rate limiting: respect backoff, show progress

Files: src/cli/commands/ingest.rs, src/cli/commands/mod.rs
Tests: tests/integration/sync_runs_tests.rs
Done when: Full issue + discussion ingestion works end-to-end


[CP1] Epic: Issue Ingestion

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

## Success Criteria
- gi ingest --type=issues fetches all issues (count matches GitLab UI)
- Labels extracted from issue payloads (name-only)
- Label linkage reflects current GitLab state (removed labels unlinked on re-sync)
- Issue discussions fetched per-issue (dependent sync)
- Cursor-based sync is resumable (re-running fetches 0 new items)
- Discussion sync skips unchanged issues (per-issue watermark)
- Sync tracking records all runs (sync_runs table)
- Single-flight lock prevents concurrent runs

## Internal Gates
- **Gate A**: Issues only - cursor + upsert + raw payloads + list/count/show working
- **Gate B**: Labels correct - stale-link removal verified; label count matches GitLab
- **Gate C**: Dependent discussion sync - watermark prevents redundant refetch; concurrency bounded
- **Gate D**: Resumability proof - kill mid-run, rerun; bounded redo and no redundant discussion refetch

## Reference
docs/prd/checkpoint-1.md

CP1 Issue Ingestion complete: all sub-tasks done, 71 tests pass, CLI commands working


Add half-life fields and config validation to ScoringConfig

## Background
The flat-weight ScoringConfig (config.rs:155-167) has only 3 fields: author_weight (25), reviewer_weight (10), note_bonus (1). Time-decay scoring needs half-life parameters, a reviewer split (participated vs assigned-only), closed MR discount, substantive-note threshold, and bot filtering.

## Approach
Extend the existing ScoringConfig struct at config.rs:155. Add new fields with #[serde(default)] and camelCase rename to match existing convention (authorWeight, reviewerWeight, noteBonus). Extend the Default impl at config.rs:169 with new defaults. Extend validate_scoring() at config.rs:274-291 (currently validates 3 weights >= 0).

### New fields to add:
```rust
#[serde(rename = "reviewerAssignmentWeight")]
pub reviewer_assignment_weight: i64,     // default: 3
#[serde(rename = "authorHalfLifeDays")]
pub author_half_life_days: u32,          // default: 180
#[serde(rename = "reviewerHalfLifeDays")]
pub reviewer_half_life_days: u32,        // default: 90
#[serde(rename = "reviewerAssignmentHalfLifeDays")]
pub reviewer_assignment_half_life_days: u32, // default: 45
#[serde(rename = "noteHalfLifeDays")]
pub note_half_life_days: u32,            // default: 45
#[serde(rename = "closedMrMultiplier")]
pub closed_mr_multiplier: f64,           // default: 0.5
#[serde(rename = "reviewerMinNoteChars")]
pub reviewer_min_note_chars: u32,        // default: 20
#[serde(rename = "excludedUsernames")]
pub excluded_usernames: Vec<String>,     // default: vec![]
```

### Validation additions to validate_scoring() (config.rs:274):
- All *_half_life_days must be > 0 AND <= 3650
- All *_weight / *_bonus must be >= 0
- reviewer_assignment_weight must be >= 0
- closed_mr_multiplier must be finite (not NaN/Inf) AND in (0.0, 1.0]
- reviewer_min_note_chars must be >= 0 AND <= 4096
- excluded_usernames entries must be non-empty strings
- Return LoreError::ConfigInvalid with clear message on failure

## TDD Loop

### RED (write first):
```rust
#[test]
fn test_config_validation_rejects_zero_half_life() {
    let mut cfg = ScoringConfig::default();
    assert!(validate_scoring(&cfg).is_ok());
    cfg.author_half_life_days = 0;
    assert!(validate_scoring(&cfg).is_err());
    cfg.author_half_life_days = 180;
    cfg.reviewer_half_life_days = 0;
    assert!(validate_scoring(&cfg).is_err());
    cfg.reviewer_half_life_days = 90;
    cfg.closed_mr_multiplier = 0.0;
    assert!(validate_scoring(&cfg).is_err());
    cfg.closed_mr_multiplier = 1.5;
    assert!(validate_scoring(&cfg).is_err());
    cfg.closed_mr_multiplier = 1.0;
    assert!(validate_scoring(&cfg).is_ok());
}

#[test]
fn test_config_validation_rejects_absurd_half_life() {
    let mut cfg = ScoringConfig::default();
    cfg.author_half_life_days = 5000; // > 3650 cap
    assert!(validate_scoring(&cfg).is_err());
    cfg.author_half_life_days = 3650; // boundary: valid
    assert!(validate_scoring(&cfg).is_ok());
    cfg.reviewer_min_note_chars = 5000; // > 4096 cap
    assert!(validate_scoring(&cfg).is_err());
    cfg.reviewer_min_note_chars = 4096; // boundary: valid
    assert!(validate_scoring(&cfg).is_ok());
}

#[test]
fn test_config_validation_rejects_nan_multiplier() {
    let mut cfg = ScoringConfig::default();
    cfg.closed_mr_multiplier = f64::NAN;
    assert!(validate_scoring(&cfg).is_err());
    cfg.closed_mr_multiplier = f64::INFINITY;
    assert!(validate_scoring(&cfg).is_err());
    cfg.closed_mr_multiplier = f64::NEG_INFINITY;
    assert!(validate_scoring(&cfg).is_err());
}
```

### GREEN: Add fields to struct + Default impl + validation rules.
### VERIFY: cargo test -p lore -- test_config_validation

## Acceptance Criteria
- [ ] test_config_validation_rejects_zero_half_life passes
- [ ] test_config_validation_rejects_absurd_half_life passes
- [ ] test_config_validation_rejects_nan_multiplier passes
- [ ] ScoringConfig::default() returns correct values for all 11 fields
- [ ] cargo check --all-targets passes
- [ ] Existing config deserialization works (#[serde(default)] fills new fields)
- [ ] validate_scoring() is pub(crate) or accessible from config.rs test module

## Files
- MODIFY: src/core/config.rs (struct at line 155, Default impl at line 169, validate_scoring at line 274)

## Edge Cases
- f64 comparison: use .is_finite() for NaN/Inf check, > 0.0 and <= 1.0 for range
- Vec<String> default: use Vec::new()
- Upper bounds prevent silent misconfig (5000-day half-life effectively disables decay)

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


Phase 3c: Entrypoint migration (#[asupersync::main])

## What
Replace #[tokio::main] with #[asupersync::main] in main.rs and thread Cx through command dispatch.

## Rearchitecture Context (2026-03-06)
main.rs has been thinned to ~419 LOC. It now uses include!() to pull in handler code:
- src/main.rs -> include!("app/dispatch.rs") -> include!("errors.rs"), include!("handlers.rs"), include!("robot_docs.rs")
- The #[tokio::main] attribute is on the main() fn in src/main.rs
- Command dispatch handler functions are physically in src/app/handlers.rs but logically in main.rs scope

## Implementation

```rust
// Before
#[tokio::main]
async fn main() -> Result<()> { ... }

// After
#[asupersync::main]
async fn main(cx: &Cx) -> Outcome<()> { ... }
```

The Cx parameter is the runtime context that enables region-scoped task management. It flows from main() through all async command dispatch arms.

## Key Considerations
- Return type changes from Result<()> to Outcome<()> — verify what Outcome provides and how error handling maps
- Cx is borrowed (&Cx), not owned — it lives for the duration of the program
- Command dispatch handler functions in src/app/handlers.rs must receive cx through the dispatch chain
- The include!() chain means cx flows from main.rs through the included handler functions seamlessly (same scope)

## Files Changed
- src/main.rs (~10 LOC for entrypoint attribute change)
- src/app/handlers.rs (handler function signatures gain cx: &Cx — these are include!'d into main.rs scope)

## Testing
- cargo check --all-targets (part of atomic commit)
- Binary must launch and show help text

## Depends On
- Phase 3a (asupersync must be in Cargo.toml)


Ship timeline CLI with human and robot renderers

## Problem
The timeline pipeline (5-stage SEED->HYDRATE->EXPAND->COLLECT->RENDER) is implemented but not wired to the CLI. This is one of lore's most unique features — chronological narrative reconstruction from resource events, cross-references, and notes — and it is invisible to users and agents.

## Current State
- Types defined: src/core/timeline.rs (TimelineEvent, TimelineSeed, etc.)
- Seed stage: src/core/timeline_seed.rs (FTS search -> seed entities)
- Expand stage: src/core/timeline_expand.rs (cross-reference expansion)
- Collect stage: src/core/timeline_collect.rs (event gathering from resource events + notes)
- CLI command structure: src/cli/commands/timeline.rs (exists but incomplete)
- Remaining beads: bd-1nf (CLI wiring), bd-2f2 (human renderer), bd-dty (robot renderer)

## Acceptance Criteria
1. lore timeline 'authentication refactor' works end-to-end:
   - Searches for matching entities (SEED)
   - Fetches raw data (HYDRATE)
   - Expands via cross-references (EXPAND with --depth flag, default 1)
   - Collects events chronologically (COLLECT)
   - Renders human-readable narrative (RENDER)
2. Human renderer output:
   - Chronological event stream with timestamps
   - Color-coded by event type (state change, label change, note, reference)
   - Actor names with role context
   - Grouped by day/week for readability
   - Evidence snippets from notes (first 200 chars)
3. Robot renderer output (--robot / -J):
   - JSON array of events with: timestamp, event_type, actor, entity_ref, body/snippet, metadata
   - Seed entities listed separately (what matched the query)
   - Expansion depth metadata (how far from seed)
   - Total event count and time range
4. CLI flags:
   - --project (scope to project)
   - --since (time range)
   - --depth N (expansion depth, default 1, max 3)
   - --expand-mentions (follow mention references, not just closes/related)
   - -n LIMIT (max events)
5. Performance: timeline for a single issue with 50 events renders in <200ms

## Relationship to Existing Beads
This supersedes/unifies: bd-1nf (CLI wiring), bd-2f2 (human renderer), bd-dty (robot renderer). Those can be closed when this ships.

## Files to Modify
- src/cli/commands/timeline.rs (CLI wiring, flag parsing, output dispatch)
- src/core/timeline.rs (may need RENDER stage types)
- New: src/cli/render/timeline_human.rs or inline in timeline.rs
- New: src/cli/render/timeline_robot.rs or inline in timeline.rs

Already implemented: run_timeline(), print_timeline(), print_timeline_json_with_meta(), handle_timeline() all exist and are fully wired. Code audit 2026-02-12.


Implement Sync screen (running + summary modes + progress coalescer)

## Background
The Sync screen provides real-time progress visualization during data synchronization. The TUI drives sync directly via lore library calls (not subprocess) — this gives direct access to progress callbacks, proper error propagation, and cooperative cancellation via CancelToken. The TUI is the primary human interface; the CLI serves robots/scripts.

After sync completes, the screen transitions to a summary view showing exact changed entity counts. A progress coalescer prevents render thrashing by batching rapid progress updates.

Design principle: the TUI is self-contained. It does NOT detect or react to external CLI sync operations. If someone runs lore sync externally, the TUI's natural re-query on navigation handles stale data implicitly.

## Approach
Create state, action, and view modules for the Sync screen:

**State** (crates/lore-tui/src/screen/sync/state.rs):
- SyncScreenMode enum: FullScreen, Inline (for use from Bootstrap screen)
- SyncState enum: Idle, Running(SyncProgress), Complete(SyncSummary), Error(String)
- SyncProgress: per-lane progress (issues, MRs, discussions, notes, events, statuses) with counts and ETA
- SyncSummary: changed entity counts (new, updated, deleted per type), duration, errors
- ProgressCoalescer: buffers progress updates, emits at most every 100ms to prevent render thrash

**sync_delta_ledger** (crates/lore-tui/src/screen/sync/delta_ledger.rs):
- SyncDeltaLedger: in-memory per-run record of changed entity IDs
- Fields: new_issue_iids (Vec<i64>), updated_issue_iids (Vec<i64>), new_mr_iids (Vec<i64>), updated_mr_iids (Vec<i64>)
- record_change(entity_type, iid, change_kind) — called by sync progress callback
- summary() -> SyncSummary — produces the final counts for the summary view
- Purpose: after sync completes, the dashboard and list screens can use the ledger to highlight "new since last sync" items

**Action** (crates/lore-tui/src/screen/sync/action.rs):
- start_sync(db: &DbManager, config: &Config, cancel: CancelToken) -> Cmd<Msg>
- Calls lore library ingestion functions directly: ingest_issues, ingest_mrs, ingest_discussions, etc.
- Progress callback sends Msg::SyncProgress(lane, count, total) via channel
- On completion sends Msg::SyncComplete(SyncSummary)
- On cancel sends Msg::SyncCancelled(partial_summary)

**Per-project fault isolation:** If sync for one project fails, continue syncing other projects. Collect per-project errors and display in summary view. Don't abort entire sync on single project failure.

**View** (crates/lore-tui/src/screen/sync/view.rs):
- Running view: per-lane progress bars with counts/totals, overall ETA, cancel hint (Esc)
- Stream stats footer: show items/sec throughput for active lanes
- Summary view: table of entity types with new/updated/deleted columns, total duration, per-project error list
- Error view: error message with retry option
- Inline mode: compact single-line progress for embedding in Bootstrap screen

The Sync screen uses TaskSupervisor for the background sync task with cooperative cancellation.

## Acceptance Criteria
- [ ] Sync screen launches sync via lore library calls (NOT subprocess)
- [ ] Per-lane progress bars update in real-time during sync
- [ ] ProgressCoalescer batches updates to at most 10/second (100ms floor)
- [ ] Esc cancels sync cooperatively via CancelToken, shows partial summary
- [ ] Sync completion transitions to summary view with accurate change counts
- [ ] Summary view shows new/updated/deleted counts per entity type
- [ ] Error during sync shows error message with retry option
- [ ] Sync task registered with TaskSupervisor (dedup by TaskKey::Sync)
- [ ] Per-project fault isolation: single project failure doesn't abort entire sync
- [ ] SyncDeltaLedger records changed entity IDs for post-sync highlighting
- [ ] Stream stats footer shows items/sec throughput
- [ ] ScreenMode::Inline renders compact single-line progress for Bootstrap embedding
- [ ] Unit tests for ProgressCoalescer batching behavior
- [ ] Unit tests for SyncDeltaLedger record/summary
- [ ] Integration test: mock sync with FakeClock verifies progress -> summary transition

## Files
- CREATE: crates/lore-tui/src/screen/sync/state.rs
- CREATE: crates/lore-tui/src/screen/sync/action.rs
- CREATE: crates/lore-tui/src/screen/sync/view.rs
- CREATE: crates/lore-tui/src/screen/sync/delta_ledger.rs
- CREATE: crates/lore-tui/src/screen/sync/mod.rs
- MODIFY: crates/lore-tui/src/screen/mod.rs (add pub mod sync)

## TDD Anchor
RED: Write test_progress_coalescer_batches_rapid_updates that sends 50 progress updates in 10ms and asserts coalescer emits at most 1.
GREEN: Implement ProgressCoalescer with configurable floor interval.
VERIFY: cargo test -p lore-tui sync -- --nocapture

Additional tests:
- test_sync_cancel_produces_partial_summary
- test_sync_complete_produces_full_summary
- test_sync_error_shows_retry
- test_sync_dedup_prevents_double_launch
- test_delta_ledger_records_changes: record 5 new issues and 3 updated MRs, assert summary counts
- test_per_project_fault_isolation: simulate one project failure, verify others complete

## Edge Cases
- Sync cancelled immediately after start — partial summary with zero counts is valid
- Network timeout during sync — error state with last-known progress preserved
- Very large sync (100k+ entities) — progress coalescer prevents render thrash
- Sync started while another sync TaskKey::Sync exists — TaskSupervisor dedup rejects it
- Inline mode from Bootstrap: compact rendering, no full progress bars

## Dependency Context
Uses TaskSupervisor from bd-3le2 for dedup and cancellation. Uses DbManager from bd-2kop for database access. Uses lore library ingestion module directly for sync operations. Used by Bootstrap screen (bd-3ty8) in inline mode.


Phase 0b: Replace tokio::sync::Mutex with std::sync::Mutex in rate limiter

## What
In gitlab/client.rs, replace tokio::sync::Mutex with std::sync::Mutex for the rate limiter lock.

## Why
The rate limiter lock guards a tiny synchronous critical section (check Instant::now(), compute delay). No async work inside the lock. std::sync::Mutex is correct here and removes a tokio dependency.

## Current State (gitlab/client.rs ~line 9-10)
```rust
use tokio::sync::Mutex;
// ...
let delay = self.rate_limiter.lock().await.check_delay();
```

## Implementation
```rust
use std::sync::Mutex;
// ...
let delay = self.rate_limiter.lock().expect("rate limiter poisoned").check_delay();
```

Use .expect() over .unwrap() for clarity. Poisoning is near-impossible here (trivial Instant::now() check), but the explicit message aids debugging.

## CRITICAL CONSTRAINT (document at lock site)
std::sync::Mutex blocks the executor thread while held. This is safe ONLY because the critical section is a single Instant::now() comparison with no I/O. If the rate limiter ever grows to include async work (HTTP calls, DB queries), it must move back to an async-aware lock.

Add a comment at the lock site documenting this constraint.

## Files Changed
- src/gitlab/client.rs (~5 LOC changed)

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


Add work item status via GraphQL enrichment

## Summary

GitLab 18.2+ has native work item status (To do, In progress, Done, Won't do, Duplicate) available ONLY via GraphQL, not REST. This enriches synced issues with status information by making supplementary GraphQL calls after REST ingestion.

**Plan document:** plans/work-item-status-graphql.md

## Critical Findings (from API research)

- **EE-only (Premium/Ultimate)** — Free tier won't have the widget at all
- **GraphQL auth differs from REST** — must use `Authorization: Bearer <token>`, NOT `PRIVATE-TOKEN`
- **Must use `workItems` resolver, NOT `project.issues`** — legacy issues path doesn't expose status widgets
- **5 categories:** TRIAGE, TO_DO, IN_PROGRESS, DONE, CANCELED (not 3 as originally assumed)
- **Max 100 items per GraphQL page** (standard GitLab limit)
- **Custom statuses possible on 18.5+** — can't assume only system-defined statuses

## Migration

Uses migration **021** (001-020 already exist on disk).
Adds `status_name TEXT` and `status_category TEXT` to `issues` table (both nullable).

## Files

- src/gitlab/graphql.rs (NEW — minimal GraphQL client + status fetcher)
- src/gitlab/mod.rs (add pub mod graphql)
- src/gitlab/types.rs (WorkItemStatus, WorkItemStatusCategory enum)
- src/core/db.rs (migration 021 in MIGRATIONS array)
- src/core/config.rs (fetch_work_item_status toggle in SyncConfig)
- src/ingestion/orchestrator.rs (enrichment step after issue sync)
- src/cli/commands/show.rs (display status)
- src/cli/commands/list.rs (status in list output + --status filter)

## Acceptance Criteria

- [ ] GraphQL client POSTs queries with Bearer auth and handles errors
- [ ] Status fetched via workItems resolver with pagination
- [ ] Migration 021 adds status_name and status_category to issues
- [ ] lore show issue displays status (when available)
- [ ] lore --robot show issue includes status in JSON
- [ ] lore list issues --status filter works
- [ ] Graceful degradation: Free tier, old GitLab, disabled GraphQL all handled
- [ ] Config toggle: fetch_work_item_status (default true)
- [ ] cargo check + clippy + tests pass

Already implemented: GraphQL status enrichment shipped in v0.8.x — migration 021, graphql.rs, --status filter, --no-status flag all complete


Implement user flow integration tests (9 PRD flows)

## Background

The PRD Section 6 defines 9 end-to-end user flows that exercise cross-screen navigation, state preservation, and data flow. The existing vertical slice test (bd-1mju) covers one flow (Dashboard -> Issue List -> Issue Detail -> Sync). These integration tests cover the remaining 8 flows plus re-test the vertical slice from a user-journey perspective. Each test simulates a realistic keystroke sequence using FrankenTUI's test harness and verifies that the correct screens are reached with the correct data visible.

## Approach

Create a test module `tests/tui_user_flows.rs` with 9 test functions, each simulating a keystroke sequence against a FrankenTUI `TestHarness` with a pre-populated test database. Tests use `FakeClock` for deterministic timestamps.

**Test database fixture**: A shared setup function creates an in-memory SQLite DB with ~20 issues, ~10 MRs, ~30 discussions, a few experts, and timeline events. This fixture is reused across all flow tests.

**Flow tests**:

1. **`test_flow_find_expert`** — Dashboard -> `w` -> type "src/auth/" -> verify Expert mode results appear -> `↓` select first person -> `Enter` -> verify navigation to Issue List filtered by that person
2. **`test_flow_timeline_query`** — Dashboard -> `t` -> type "auth timeout" -> `Enter` -> verify Timeline shows seed events -> `Enter` on first event -> verify entity detail opens -> `Esc` -> back on Timeline
3. **`test_flow_quick_search`** — Any screen -> `/` -> type query -> verify results appear -> `Tab` (switch mode) -> verify mode label changes -> `Enter` -> verify entity detail opens
4. **`test_flow_sync_and_browse`** — Dashboard -> `s` -> `Enter` (start sync) -> wait for completion -> verify Summary shows deltas -> `i` -> verify Issue List filtered to new items
5. **`test_flow_review_workload`** — Dashboard -> `w` -> `Tab` (Workload mode) -> type "@bjones" -> verify workload sections appear (assigned, authored, reviewing)
6. **`test_flow_command_palette`** — Any screen -> `Ctrl+P` -> type "mrs draft" -> verify fuzzy match -> `Enter` -> verify MR List opened with draft filter
7. **`test_flow_morning_triage`** — Dashboard -> `i` -> verify Issue List (opened, sorted by updated) -> `Enter` on first -> verify Issue Detail -> `Esc` -> verify cursor preserved on same row -> `j` -> verify cursor moved
8. **`test_flow_direct_screen_jumps`** — Issue Detail -> `gt` -> verify Timeline -> `gw` -> verify Who -> `gi` -> verify Issue List -> `H` -> verify Dashboard (clean reset)
9. **`test_flow_risk_sweep`** — Dashboard -> scroll to Insights -> `Enter` on first insight -> verify pre-filtered Issue List

Each test follows the pattern:
```rust
#[test]
fn test_flow_X() {
    let (harness, app) = setup_test_harness_with_fixture();
    // Send keystrokes
    harness.send_key(Key::Char('w'));
    // Assert screen state
    assert_eq!(app.current_screen(), Screen::Who);
    // Assert visible content
    let frame = harness.render();
    assert!(frame.contains("Expert"));
}
```

## Acceptance Criteria
- [ ] All 9 flow tests exist and compile
- [ ] Each test uses the shared DB fixture (no per-test DB setup)
- [ ] Each test verifies screen transitions via `current_screen()` assertions
- [ ] Each test verifies at least one content assertion (rendered text contains expected data)
- [ ] test_flow_morning_triage verifies cursor preservation after Enter/Esc round-trip
- [ ] test_flow_direct_screen_jumps verifies the g-prefix navigation chain
- [ ] test_flow_sync_and_browse verifies delta-filtered navigation after sync
- [ ] All tests use FakeClock for deterministic timestamps
- [ ] Tests complete in <5 seconds each (no real I/O)

## Files
- CREATE: crates/lore-tui/tests/tui_user_flows.rs
- MODIFY: (none — this is a new test file only)

## TDD Anchor
RED: Write `test_flow_morning_triage` first — it exercises the most common daily workflow (Dashboard -> Issue List -> Issue Detail -> back with cursor preservation). Start with just the Dashboard -> Issue List transition.
GREEN: Requires all Phase 2 core screens to be working; the test itself is the GREEN verification.
VERIFY: cargo test -p lore-tui test_flow_morning_triage

Additional tests: All 9 flows listed above.

## Edge Cases
- Flow tests must handle async data loading — use harness.tick() or harness.wait_for_idle() to let async tasks complete before asserting
- g-prefix timeout (500ms) — tests must send the second key within the timeout; use harness clock control
- Sync flow test needs a mock sync that completes quickly — use a pre-populated SyncDeltaLedger rather than running actual sync

## Dependency Context
- Depends on bd-1mju (vertical slice integration test) which establishes the test harness patterns and fixture setup.
- Depends on bd-2nfs (snapshot test infrastructure) which provides the FakeClock and TestHarness setup.
- Depends on all Phase 2 core screen beads (bd-35g5 Dashboard, bd-3ei1 Issue List, bd-8ab7 Issue Detail, bd-2kr0 MR List, bd-3t1b MR Detail) being implemented.
- Depends on Phase 3 power feature beads (bd-1zow Search, bd-29qw Timeline, bd-u7se Who, bd-wzqi Command Palette) being implemented.
- Depends on bd-2x2h (Sync screen) for the sync+browse flow test.


Fetch MR diffs API and populate mr_file_changes

## Background

This bead fetches MR diff metadata from the GitLab API and populates the mr_file_changes table created by migration 016. It extracts only file-level metadata (paths, change type) and discards actual diff content.

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

## Codebase Context

- pending_dependent_fetches already has `job_type='mr_diffs'` in CHECK constraint (migration 011)
- dependent_queue.rs has: enqueue_job(), claim_jobs(), complete_job(), fail_job() with exponential backoff
- Orchestrator pattern: enqueue after entity ingestion, drain after primary ingestion completes
- GitLab client uses fetch_all_pages() for pagination
- Existing drain patterns in orchestrator.rs: drain_resource_events() and drain_mr_closes_issues() — follow same pattern
- config.sync.fetch_mr_file_changes flag guards enqueue (see bd-jec)
- mr_file_changes table created by migration 016 (bd-1oo) — NOT 015 (015 is commit SHAs)
- merge_commit_sha and squash_commit_sha already captured during MR ingestion (src/ingestion/merge_requests.rs lines 184, 205-206, 230-231) — no work needed for those fields

## Approach

### 1. API Client — add to `src/gitlab/client.rs`:

```rust
pub async fn fetch_mr_diffs(
    &self,
    project_id: i64,
    mr_iid: i64,
) -> Result<Vec<GitLabMrDiff>> {
    let path = format\!("/projects/{project_id}/merge_requests/{mr_iid}/diffs");
    self.fetch_all_pages(&path, &[("per_page", "100")]).await
        .or_else(|e| coalesce_not_found(e, Vec::new()))
}
```

### 2. Types — add to `src/gitlab/types.rs`:

```rust
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitLabMrDiff {
    pub old_path: String,
    pub new_path: String,
    pub new_file: bool,
    pub renamed_file: bool,
    pub deleted_file: bool,
    // Ignore: diff, a_mode, b_mode, generated_file (not stored)
}
```

Add `GitLabMrDiff` to `src/gitlab/mod.rs` re-exports.

### 3. Change Type Derivation (in new file):

```rust
fn derive_change_type(diff: &GitLabMrDiff) -> &'static str {
    if diff.new_file { "added" }
    else if diff.renamed_file { "renamed" }
    else if diff.deleted_file { "deleted" }
    else { "modified" }
}
```

### 4. DB Storage — new `src/ingestion/mr_diffs.rs`:

```rust
pub fn upsert_mr_file_changes(
    conn: &Connection,
    mr_local_id: i64,
    project_id: i64,
    diffs: &[GitLabMrDiff],
) -> Result<usize> {
    // DELETE FROM mr_file_changes WHERE merge_request_id = ?
    // INSERT each diff row with derived change_type
    // DELETE+INSERT is simpler than UPSERT for array replacement
}
```

Add `pub mod mr_diffs;` to `src/ingestion/mod.rs`.

### 5. Queue Integration — in orchestrator.rs:

```rust
// After MR upsert, if config.sync.fetch_mr_file_changes:
enqueue_job(conn, project_id, "merge_request", mr_iid, mr_local_id, "mr_diffs")?;
```

Add `drain_mr_diffs()` following the drain_mr_closes_issues() pattern. Call it after drain_mr_closes_issues() in the sync pipeline.

## Acceptance Criteria

- [ ] `fetch_mr_diffs()` calls GET /projects/:id/merge_requests/:iid/diffs with pagination
- [ ] GitLabMrDiff type added to src/gitlab/types.rs and re-exported from src/gitlab/mod.rs
- [ ] Change type derived: new_file->added, renamed_file->renamed, deleted_file->deleted, else->modified
- [ ] mr_file_changes rows have correct old_path, new_path, change_type
- [ ] Old rows deleted before insert (clean replacement per MR)
- [ ] Jobs only enqueued when config.sync.fetch_mr_file_changes is true
- [ ] 404/403 API errors handled gracefully (empty result, not failure)
- [ ] drain_mr_diffs() added to orchestrator.rs sync pipeline
- [ ] `pub mod mr_diffs;` added to src/ingestion/mod.rs
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

- `src/gitlab/client.rs` (add fetch_mr_diffs method)
- `src/gitlab/types.rs` (add GitLabMrDiff struct)
- `src/gitlab/mod.rs` (re-export GitLabMrDiff)
- `src/ingestion/mr_diffs.rs` (NEW — upsert_mr_file_changes + derive_change_type)
- `src/ingestion/mod.rs` (add pub mod mr_diffs)
- `src/ingestion/orchestrator.rs` (enqueue mr_diffs jobs + drain_mr_diffs)

## TDD Loop

RED:
- `test_derive_change_type_added` - new_file=true -> "added"
- `test_derive_change_type_renamed` - renamed_file=true -> "renamed"
- `test_derive_change_type_deleted` - deleted_file=true -> "deleted"
- `test_derive_change_type_modified` - all false -> "modified"
- `test_upsert_replaces_existing` - second upsert replaces first

GREEN: Implement API client, type derivation, DB ops, orchestrator wiring.

VERIFY: `cargo test --lib -- mr_diffs`

## Edge Cases

- MR with 500+ files: paginate properly via fetch_all_pages
- Binary files: handled as modified (renamed_file/new_file/deleted_file all false)
- File renamed AND modified: renamed_file=true takes precedence
- Draft MRs: still fetch diffs
- Deleted MR: 404 -> empty vec via coalesce_not_found()
- merge_commit_sha/squash_commit_sha: already handled in merge_requests.rs ingestion — NOT part of this bead

Implemented: GitLabMrDiff type, fetch_mr_diffs client method, upsert_mr_file_changes in new mr_diffs.rs module, enqueue_mr_diffs_jobs + drain_mr_diffs in orchestrator, migration 020 for diffs_synced_for_updated_at watermark, progress events, autocorrect registry. All 390 tests pass, clippy clean.


[CP1] Issue transformer with label extraction

Transform GitLab issue payloads to normalized database schema.

Functions to implement:
- transformIssue(gitlabIssue, localProjectId) → NormalizedIssue
- extractLabels(gitlabIssue, localProjectId) → Label[]

Transformation rules:
- Convert ISO timestamps to ms epoch using isoToMs()
- Set last_seen_at to nowMs()
- Handle labels vs labels_details (prefer details when available)
- Handle missing optional fields gracefully

Files: src/gitlab/transformers/issue.ts
Tests: tests/unit/issue-transformer.test.ts
Done when: Unit tests pass for payload transformation and label extraction


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

## Background

The GitLab client pagination methods require async streaming capabilities. The `async-stream` crate provides the `stream!` macro for creating async iterators, and `futures` provides `StreamExt` for consuming them with `.next()` and other combinators.

## Approach

Add these dependencies to Cargo.toml:

```toml
[dependencies]
async-stream = "0.3"
futures = { version = "0.3", default-features = false, features = ["alloc"] }
```

Use minimal features on `futures` to avoid pulling unnecessary code.

## Acceptance Criteria

- [ ] `async-stream = "0.3"` is in Cargo.toml [dependencies]
- [ ] `futures` with `alloc` feature is in Cargo.toml [dependencies]
- [ ] `cargo check` succeeds after adding dependencies

## Files

- Cargo.toml (edit)

## TDD Loop

RED: Not applicable (dependency addition)
GREEN: Add lines to Cargo.toml
VERIFY: `cargo check`

## Edge Cases

- If `futures` is already present, merge features rather than duplicate
- Use exact version pins for reproducibility

Added async-stream 0.3 and futures 0.3 (alloc feature) to Cargo.toml, cargo check passes


Add timestamp-aware test helpers

## Background
Existing test helpers (who.rs:2469-2598) use now_ms() for all timestamps. Time-decay tests need precise timestamp control to verify decay math, state-aware timestamps, and as-of filtering.

## Approach
Add three new helpers in the test module (who.rs after insert_file_change at line 2598), patterned after existing helpers:

### insert_mr_at() — variant of insert_mr (who.rs:2469-2493):
```rust
#[allow(clippy::too_many_arguments)]
fn insert_mr_at(
    conn: &Connection,
    id: i64,
    project_id: i64,
    iid: i64,
    author: &str,
    state: &str,
    updated_at_ms: i64,
    merged_at_ms: Option<i64>,
    closed_at_ms: Option<i64>,
) {
    conn.execute(
        "INSERT INTO merge_requests (id, gitlab_id, project_id, iid, title, author_username, state, last_seen_at, updated_at, merged_at, closed_at)
         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
        rusqlite::params![id, id * 10, project_id, iid, format!("MR {iid}"), author, state, now_ms(), updated_at_ms, merged_at_ms, closed_at_ms],
    ).unwrap();
}
```

### insert_diffnote_at() — variant of insert_diffnote (who.rs:2541-2580):
```rust
#[allow(clippy::too_many_arguments)]
fn insert_diffnote_at(
    conn: &Connection,
    id: i64,
    discussion_id: i64,
    project_id: i64,
    author: &str,
    new_path: &str,
    old_path: Option<&str>,
    body: &str,
    created_at_ms: i64,
) {
    conn.execute(
        "INSERT INTO notes (id, gitlab_id, project_id, discussion_id, author_username, note_type, is_system, position_new_path, position_old_path, body, created_at, updated_at)
         VALUES (?1, ?2, ?3, ?4, ?5, 'DiffNote', 0, ?6, ?7, ?8, ?9, ?9)",
        rusqlite::params![id, id * 10, project_id, discussion_id, author, new_path, old_path, body, created_at_ms],
    ).unwrap();
}
```

### insert_file_change_with_old_path() — variant of insert_file_change (who.rs:2585-2598):
```rust
fn insert_file_change_with_old_path(
    conn: &Connection,
    mr_id: i64,
    project_id: i64,
    new_path: &str,
    old_path: Option<&str>,
    change_type: &str,
) {
    conn.execute(
        "INSERT INTO mr_file_changes (merge_request_id, project_id, new_path, old_path, change_type)
         VALUES (?1, ?2, ?3, ?4, ?5)",
        rusqlite::params![mr_id, project_id, new_path, old_path, change_type],
    ).unwrap();
}
```

Note: mr_file_changes already has an old_path column (from migration 016). The existing insert_file_change helper simply omits it (defaults to NULL).

## Acceptance Criteria
- [ ] insert_mr_at compiles and inserts with explicit updated_at, merged_at, closed_at
- [ ] insert_diffnote_at compiles and inserts with explicit old_path and created_at
- [ ] insert_file_change_with_old_path compiles and inserts with explicit old_path
- [ ] Existing helpers (insert_mr, insert_diffnote, insert_file_change) unchanged
- [ ] cargo check --all-targets passes
- [ ] All three helpers are used by downstream beads (bd-13q8, bd-1h3f)

## Files
- MODIFY: src/cli/commands/who.rs (test module, after insert_file_change at line ~2598)

## TDD Loop
N/A — these are test utilities. Verified indirectly by tests in bd-13q8 and bd-1h3f.
VERIFY: cargo check --all-targets (compiles cleanly)

## Edge Cases
- merged_at_ms/closed_at_ms as Option<i64> — NULL when not applicable
- old_path as Option<&str> — NULL when no rename occurred
- created_at_ms used for both created_at and updated_at on notes (matching existing insert_diffnote pattern)
- #[allow(clippy::too_many_arguments)] needed on helpers with 8+ params (project uses pedantic clippy)

Dependencies:
  -> bd-2w1p (blocks) - Add half-life fields and config validation to ScoringConfig