Update orchestrator for MR ingestion

## Background
Orchestrator coordinates MR ingestion followed by dependent discussion sync. Discussion sync targets are queried from DB (not collected in-memory) to handle large projects without memory growth. This is critical for projects with 10k+ MRs where collecting sync targets in memory during ingestion would cause unbounded growth.

## Approach
Update `src/ingestion/orchestrator.rs` to:
1. Support `merge_requests` resource type in `run_ingestion()` match arm
2. Query DB for MRs needing discussion sync after MR ingestion completes
3. Execute discussion sync with bounded concurrency using `futures::stream::buffer_unordered`

## Files
- `src/ingestion/orchestrator.rs` - Update existing orchestrator

## Acceptance Criteria
- [ ] `run_ingestion()` handles `resource_type == "merge_requests"`
- [ ] After MR ingestion, queries DB for MRs where `updated_at > discussions_synced_for_updated_at`
- [ ] Discussion sync uses `dependent_concurrency` from config (default 5)
- [ ] Each MR's discussion sync is independent (partial failures don't block others)
- [ ] Results aggregated from MR ingestion + all discussion ingestion results
- [ ] `cargo test orchestrator` passes

## TDD Loop
RED: `cargo test orchestrator_mr` -> merge_requests not handled
GREEN: Add MR branch to orchestrator
VERIFY: `cargo test orchestrator`

## Struct Definition
```rust
/// Lightweight struct for DB query results - only fields needed for discussion sync
struct MrForDiscussionSync {
    local_mr_id: i64,
    iid: i64,
    updated_at: i64,
}
```

## DB Query for Discussion Sync Targets
```sql
SELECT id, iid, updated_at
FROM merge_requests
WHERE project_id = ?
  AND (discussions_synced_for_updated_at IS NULL
       OR updated_at > discussions_synced_for_updated_at)
ORDER BY updated_at ASC;
```

## Orchestrator Flow
```rust
pub async fn run_ingestion(
    &self,
    resource_type: &str,
    full_sync: bool,
) -> Result<IngestResult> {
    match resource_type {
        "issues" => self.run_issue_ingestion(full_sync).await,
        "merge_requests" => self.run_mr_ingestion(full_sync).await,
        _ => Err(GiError::InvalidArgument {
            name: "type".to_string(),
            value: resource_type.to_string(),
            expected: "issues or merge_requests".to_string(),
        }),
    }
}

async fn run_mr_ingestion(&self, full_sync: bool) -> Result<IngestResult> {
    // 1. Ingest MRs (handles cursor reset if full_sync)
    let mr_result = ingest_merge_requests(
        &self.conn, &self.client, &self.config,
        self.project_id, self.gitlab_project_id, full_sync,
    ).await?;

    // 2. Query DB for MRs needing discussion sync
    // CRITICAL: Do this AFTER ingestion, not during, to avoid memory growth
    let mrs_needing_sync: Vec<MrForDiscussionSync> = {
        let mut stmt = self.conn.prepare(
            "SELECT id, iid, updated_at FROM merge_requests
             WHERE project_id = ? AND (discussions_synced_for_updated_at IS NULL
                   OR updated_at > discussions_synced_for_updated_at)
             ORDER BY updated_at ASC"
        )?;
        stmt.query_map([self.project_id], |row| {
            Ok(MrForDiscussionSync {
                local_mr_id: row.get(0)?,
                iid: row.get(1)?,
                updated_at: row.get(2)?,
            })
        })?.collect::<Result<Vec<_>, _>>()?
    };

    let total_needing_sync = mrs_needing_sync.len();
    info!("Discussion sync needed for {} MRs", total_needing_sync);

    // 3. Execute discussion sync with bounded concurrency
    let concurrency = self.config.sync.dependent_concurrency.unwrap_or(5);

    let discussion_results: Vec<Result<IngestMrDiscussionsResult>> =
        futures::stream::iter(mrs_needing_sync)
            .map(|mr| {
                let conn = &self.conn;
                let client = &self.client;
                let config = &self.config;
                let project_id = self.project_id;
                let gitlab_project_id = self.gitlab_project_id;
                async move {
                    ingest_mr_discussions(
                        conn, client, config,
                        project_id, gitlab_project_id,
                        mr.iid, mr.local_mr_id, mr.updated_at,
                    ).await
                }
            })
            .buffer_unordered(concurrency)
            .collect()
            .await;

    // 4. Aggregate results
    let mut total_discussions = 0;
    let mut total_notes = 0;
    let mut total_diffnotes = 0;
    let mut failed_syncs = 0;

    for result in discussion_results {
        match result {
            Ok(r) => {
                total_discussions += r.discussions_upserted;
                total_notes += r.notes_upserted;
                total_diffnotes += r.diffnotes_count;
            }
            Err(e) => {
                warn!("Discussion sync failed: {}", e);
                failed_syncs += 1;
            }
        }
    }

    Ok(IngestResult {
        mrs_fetched: mr_result.fetched,
        mrs_upserted: mr_result.upserted,
        labels_created: mr_result.labels_created,
        assignees_linked: mr_result.assignees_linked,
        reviewers_linked: mr_result.reviewers_linked,
        discussions_synced: total_discussions,
        notes_synced: total_notes,
        diffnotes_count: total_diffnotes,
        mrs_skipped_discussion_sync: (mr_result.fetched as usize).saturating_sub(total_needing_sync),
        failed_discussion_syncs: failed_syncs,
    })
}
```

## Required Imports
```rust
use futures::stream::StreamExt;
use crate::ingestion::merge_requests::ingest_merge_requests;
use crate::ingestion::mr_discussions::{ingest_mr_discussions, IngestMrDiscussionsResult};
```

## Config Reference
```rust
// In config.rs or similar
pub struct SyncConfig {
    pub dependent_concurrency: Option<usize>,  // Default 5
    // ... other fields
}
```

## Edge Cases
- Large projects: 10k+ MRs may need discussion sync - DB-driven query avoids memory growth
- Partial failures: Each MR's discussion sync is independent; failures logged but don't stop others
- Concurrency: Too high (>10) may hit GitLab rate limits; default 5 balances throughput with safety
- Empty result: If no MRs need sync, discussion phase completes immediately with zero counts

Updated orchestrator for MR ingestion:
- Added IngestMrProjectResult struct with all MR-specific metrics
- Added ingest_project_merge_requests() and ingest_project_merge_requests_with_progress()
- Queries DB for MRs needing discussion sync AFTER ingestion (memory-safe for large projects)
- Added MR-specific progress events (MrsFetchStarted, MrFetched, etc.)
- Sequential discussion sync using dependent_concurrency config
- All 164 tests passing


Epic: CP2 Gate D - Resumability Proof

## Background
Gate D validates resumability and crash recovery. Proves that cursor and watermark mechanics prevent massive refetch after interruption. This is critical for large projects where a full refetch would take hours.

## Acceptance Criteria (Pass/Fail)
- [ ] Kill mid-run, rerun -> bounded redo (not full refetch from beginning)
- [ ] Cursor saved at page boundary (not item boundary)
- [ ] No redundant discussion refetch after crash recovery
- [ ] No watermark advancement on partial pagination failure
- [ ] Single-flight lock prevents concurrent ingest runs
- [ ] `--full` flag resets MR cursor to NULL
- [ ] `--full` flag resets ALL `discussions_synced_for_updated_at` to NULL
- [ ] `--force` bypasses single-flight lock

## Validation Script
```bash
#!/bin/bash
set -e

DB_PATH="${XDG_DATA_HOME:-$HOME/.local/share}/gitlab-inbox/db.sqlite3"

echo "=== Gate D: Resumability Proof ==="

# 1. Test single-flight lock
echo "Step 1: Test single-flight lock..."
gi ingest --type=merge_requests &
FIRST_PID=$!
sleep 1

# Try second ingest - should fail with lock error
if gi ingest --type=merge_requests 2>&1 | grep -q "lock\|already running"; then
  echo "  PASS: Second ingest blocked by lock"
else
  echo "  FAIL: Lock not working"
fi
wait $FIRST_PID 2>/dev/null || true

# 2. Test --force bypasses lock
echo "Step 2: Test --force flag..."
gi ingest --type=merge_requests &
FIRST_PID=$!
sleep 1
if gi ingest --type=merge_requests --force 2>&1; then
  echo "  PASS: --force bypassed lock"
else
  echo "  Note: --force test inconclusive"
fi
wait $FIRST_PID 2>/dev/null || true

# 3. Check cursor state
echo "Step 3: Check cursor state..."
sqlite3 "$DB_PATH" "
  SELECT resource_type, updated_at, gitlab_id
  FROM sync_cursors
  WHERE resource_type = 'merge_requests';
"

# 4. Test crash recovery
echo "Step 4: Test crash recovery..."

# Record current cursor
CURSOR_BEFORE=$(sqlite3 "$DB_PATH" "
  SELECT updated_at FROM sync_cursors WHERE resource_type = 'merge_requests';
")
echo "  Cursor before: $CURSOR_BEFORE"

# Force full sync and kill
echo "  Starting full sync then killing..."
gi ingest --type=merge_requests --full &
PID=$!
sleep 5 && kill -9 $PID 2>/dev/null || true
wait $PID 2>/dev/null || true

# Check cursor was saved (should be non-null if any page completed)
CURSOR_AFTER=$(sqlite3 "$DB_PATH" "
  SELECT updated_at FROM sync_cursors WHERE resource_type = 'merge_requests';
")
echo "  Cursor after kill: $CURSOR_AFTER"

# Re-run and verify bounded redo
echo "  Re-running (should resume from cursor)..."
time gi ingest --type=merge_requests
# Should be faster than first full sync

# 5. Test --full reset
echo "Step 5: Test --full resets watermarks..."

# Check watermarks before
WATERMARKS_BEFORE=$(sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM merge_requests
  WHERE discussions_synced_for_updated_at IS NOT NULL;
")
echo "  Watermarks set before --full: $WATERMARKS_BEFORE"

# Record cursor before
CURSOR_BEFORE_FULL=$(sqlite3 "$DB_PATH" "
  SELECT updated_at, gitlab_id FROM sync_cursors WHERE resource_type = 'merge_requests';
")
echo "  Cursor before --full: $CURSOR_BEFORE_FULL"

# Run --full
gi ingest --type=merge_requests --full

# Check cursor was reset then rebuilt
CURSOR_AFTER_FULL=$(sqlite3 "$DB_PATH" "
  SELECT updated_at, gitlab_id FROM sync_cursors WHERE resource_type = 'merge_requests';
")
echo "  Cursor after --full: $CURSOR_AFTER_FULL"

# Watermarks should be set again (sync completed)
WATERMARKS_AFTER=$(sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM merge_requests
  WHERE discussions_synced_for_updated_at IS NOT NULL;
")
echo "  Watermarks set after --full: $WATERMARKS_AFTER"

echo ""
echo "=== Gate D: PASSED ==="
```

## Watermark Safety Test (Simulated Network Failure)
```bash
# This tests that watermark doesn't advance on partial failure
# Requires ability to simulate network issues

# 1. Get an MR that needs discussion sync
MR_ID=$(sqlite3 "$DB_PATH" "
  SELECT id FROM merge_requests
  WHERE discussions_synced_for_updated_at IS NULL
     OR updated_at > discussions_synced_for_updated_at
  LIMIT 1;
")

# 2. Note current watermark
WATERMARK_BEFORE=$(sqlite3 "$DB_PATH" "
  SELECT discussions_synced_for_updated_at FROM merge_requests WHERE id = $MR_ID;
")
echo "Watermark before: $WATERMARK_BEFORE"

# 3. Simulate network failure (requires network manipulation)
# Option A: Block GitLab API temporarily
# Option B: Run in a container with network limits
# Option C: Use the automated test instead:
cargo test does_not_advance_discussion_watermark_on_partial_failure

# 4. Verify watermark unchanged after failure
WATERMARK_AFTER=$(sqlite3 "$DB_PATH" "
  SELECT discussions_synced_for_updated_at FROM merge_requests WHERE id = $MR_ID;
")
echo "Watermark after failure: $WATERMARK_AFTER"
[ "$WATERMARK_BEFORE" = "$WATERMARK_AFTER" ] && echo "PASS: Watermark preserved"
```

## Test Commands (Quick Verification)
```bash
# Check cursor state:
sqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 "
  SELECT * FROM sync_cursors WHERE resource_type = 'merge_requests';
"

# Check watermark distribution:
sqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 "
  SELECT
    SUM(CASE WHEN discussions_synced_for_updated_at IS NULL THEN 1 ELSE 0 END) as needs_sync,
    SUM(CASE WHEN discussions_synced_for_updated_at IS NOT NULL THEN 1 ELSE 0 END) as synced
  FROM merge_requests;
"

# Test --full resets (check before/after):
sqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 "SELECT COUNT(*) FROM merge_requests WHERE discussions_synced_for_updated_at IS NOT NULL;"
gi ingest --type=merge_requests --full
# During full sync, watermarks should be NULL, then repopulated
```

## Critical Automated Tests
These tests MUST pass for Gate D:
```bash
cargo test does_not_advance_discussion_watermark_on_partial_failure
cargo test full_sync_resets_discussion_watermarks
cargo test cursor_saved_at_page_boundary
```

## Dependencies
This gate requires:
- bd-mk3 (ingest command with --full and --force support)
- bd-ser (MR ingestion with cursor mechanics)
- bd-20h (MR discussion ingestion with watermark safety)
- Gates A, B, C must pass first

## Edge Cases
- Very fast sync: May complete before kill signal reaches; retest with larger project
- Lock file stale: If previous run crashed, lock file may exist; --force handles this
- Clock skew: Cursor timestamps should use server time, not local time


Add CLI flags: --as-of, --explain-score, --include-bots, --all-history

## Background
The who command needs new CLI flags for reproducible scoring (--as-of), score transparency (--explain-score), bot inclusion (--include-bots), and full-history mode (--all-history). The default --since for expert mode changes from 6m to 24m. At this point query_expert() already accepts as_of_ms, explain_score, and include_bots params (from bd-13q8).

## Approach
Modify WhoArgs struct and run_who() (who.rs:275-405). The command uses clap derive macros.

### New clap fields on WhoArgs:
```rust
#[arg(long, value_name = "TIMESTAMP")]
pub as_of: Option<String>,

#[arg(long, conflicts_with = "detail")]
pub explain_score: bool,

#[arg(long)]
pub include_bots: bool,

#[arg(long, conflicts_with = "since")]
pub all_history: bool,
```

### run_who() changes (who.rs:275-405):
1. Default --since: "6m" -> "24m" at line 309
2. Parse --as-of: RFC3339 or YYYY-MM-DD (append T23:59:59.999Z) -> i64 millis. Default: now_ms()
3. Parse --all-history: set since_ms = 0
4. Thread as_of_ms, explain_score, include_bots through to query_expert()

### Robot JSON resolved_input additions:
scoring_model_version: 2, as_of_ms/iso, window metadata, since_mode, excluded_usernames_applied

### Robot JSON per-expert (explain_score): score_raw + components object
### Human output (explain_score): parenthetical after score

## TDD Loop

### RED (write these 8 tests first):
```rust
#[test]
fn test_explain_score_components_sum_to_total() {
    let conn = setup_test_db();
    let now = now_ms();
    insert_project(&conn, 1, "team/backend");
    insert_mr_at(&conn, 1, 1, 100, "alice", "merged", now - 30*DAY_MS, Some(now - 30*DAY_MS), None);
    insert_file_change(&conn, 1, 1, "src/app.rs", "modified");
    insert_reviewer(&conn, 1, "bob");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_diffnote_at(&conn, 1, 1, 1, "bob", "src/app.rs", None, "substantive review comment here", now - 30*DAY_MS);
    let result = query_expert(&conn, "src/app.rs", None, 0, now, 20, &default_scoring(), false, true, false).unwrap();
    for expert in &result.experts {
        let c = expert.components.as_ref().unwrap();
        let sum = c.author + c.reviewer_participated + c.reviewer_assigned + c.notes;
        assert!((sum - expert.score_raw.unwrap()).abs() < 0.001,
            "Components don't sum to total for {}: {sum} vs {}", expert.username, expert.score_raw.unwrap());
    }
}

#[test]
fn test_as_of_produces_deterministic_results() {
    // Two runs with same as_of -> identical. Later as_of -> lower scores (more decay).
    let conn = setup_test_db();
    let now = now_ms();
    let as_of = now - 60*DAY_MS; // 60 days ago
    // ... setup data at known timestamps ...
    let r1 = query_expert(&conn, "src/app.rs", None, 0, as_of, 20, &default_scoring(), false, false, false).unwrap();
    let r2 = query_expert(&conn, "src/app.rs", None, 0, as_of, 20, &default_scoring(), false, false, false).unwrap();
    assert_eq!(r1.experts.len(), r2.experts.len());
    for (a, b) in r1.experts.iter().zip(r2.experts.iter()) {
        assert_eq!(a.username, b.username);
        assert_eq!(a.score, b.score);
    }
}

#[test]
fn test_as_of_excludes_future_events() {
    // Event after as_of is excluded entirely, not just decayed.
    // ... insert event at T2 > as_of ...
    // ... query with as_of between T1 and T2 ...
    // ... assert only T1 event appears ...
}

#[test]
fn test_as_of_exclusive_upper_bound() {
    // Event at exactly as_of_ms is excluded (strict <, not <=).
}

#[test]
fn test_since_relative_to_as_of_clock() {
    // --since window calculated from as_of, not wall clock.
}

#[test]
fn test_explain_and_detail_are_mutually_exclusive() {
    // Clap try_parse with both flags should fail.
    use clap::Parser;
    let result = WhoArgs::try_parse_from(["who", "--mode", "expert", "--path", "x", "--explain-score", "--detail"]);
    assert!(result.is_err());
}

#[test]
fn test_excluded_usernames_filters_bots() {
    // "renovate-bot" in excluded_usernames -> filtered from results.
    // "jsmith" -> present.
}

#[test]
fn test_include_bots_flag_disables_filtering() {
    // Same setup but include_bots=true -> both appear.
}
```

### GREEN: Add clap args, parse logic, robot JSON fields, human output format.
### VERIFY: `cargo test -p lore -- test_explain_score test_as_of test_since_relative test_explain_and_detail test_excluded_usernames test_include_bots`

## Acceptance Criteria
- [ ] All 8 tests pass green
- [ ] --as-of parses RFC3339 and YYYY-MM-DD (end-of-day UTC)
- [ ] --explain-score conflicts with --detail (clap error at parse time)
- [ ] --all-history conflicts with --since (clap error at parse time)
- [ ] Default --since is 24m for expert mode
- [ ] Robot JSON includes scoring_model_version: 2 in resolved_input
- [ ] Robot JSON includes score_raw + components when --explain-score
- [ ] Human output appends component parenthetical when --explain-score

## Files
- src/cli/commands/who.rs (WhoArgs struct, run_who at 275-405, robot/human output rendering)

## Edge Cases
- YYYY-MM-DD parsing: chrono NaiveDate::parse_from_str then to end-of-day UTC
- as_of in the past with --since: since window = as_of_ms - duration, not now - duration
- since_mode in robot JSON: "all" for --all-history, "24m" default, user value otherwise
- test_explain_and_detail: needs WhoArgs accessible for try_parse_from — check if it's public or test-accessible


OBSERV: Add structured tracing fields to rate-limit/retry handling

## Background
Rate limit and retry events are currently logged at WARN with minimal context (src/gitlab/client.rs:~157). This enriches them with structured fields so MetricsLayer can count them and -v mode shows actionable retry information.

## Approach
### src/gitlab/client.rs - request() method (line ~119-171)

Current 429 handling (~line 155-158):
```rust
if response.status() == StatusCode::TOO_MANY_REQUESTS && attempt < Self::MAX_RETRIES {
    let retry_after = Self::parse_retry_after(&response);
    tracing::warn!(retry_after_secs = retry_after, attempt, path, "Rate limited by GitLab, retrying");
    sleep(Duration::from_secs(retry_after)).await;
    continue;
}
```

Replace with INFO-level structured log:
```rust
if response.status() == StatusCode::TOO_MANY_REQUESTS && attempt < Self::MAX_RETRIES {
    let retry_after = Self::parse_retry_after(&response);
    tracing::info!(
        path = %path,
        attempt = attempt,
        retry_after_secs = retry_after,
        status_code = 429u16,
        "Rate limited, retrying"
    );
    sleep(Duration::from_secs(retry_after)).await;
    continue;
}
```

For transient errors (network errors, 5xx responses), add similar structured logging:
```rust
tracing::info!(
    path = %path,
    attempt = attempt,
    error = %e,
    "Retrying after transient error"
);
```

Key changes:
- Level: WARN -> INFO (visible in -v mode, not alarming in default mode)
- Added: status_code field for 429
- Added: structured path, attempt fields for all retry events
- These structured fields enable MetricsLayer (bd-3vqk) to count rate_limit_hits and retries

## Acceptance Criteria
- [ ] 429 responses log at INFO with fields: path, attempt, retry_after_secs, status_code=429
- [ ] Transient error retries log at INFO with fields: path, attempt, error
- [ ] lore -v sync shows retry activity on stderr (INFO is visible in -v mode)
- [ ] Default mode (no -v) does NOT show retry lines on stderr (INFO filtered out)
- [ ] File layer captures all retry events (always at DEBUG+)
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/gitlab/client.rs (modify request() method, lines ~119-171)

## TDD Loop
RED:
  - test_rate_limit_log_fields: mock 429 response, capture log output, parse JSON, assert fields
  - test_retry_log_fields: mock network error + retry, assert structured fields
GREEN: Change log level and add structured fields
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- parse_retry_after returns 0 or very large values: the existing logic handles this
- All retries exhausted: the final attempt returns the error normally. No special logging needed (the error propagates).
- path may contain sensitive data (project IDs): project IDs are not sensitive in this context

Changed 429 rate-limit logging from WARN to INFO with structured fields: path, attempt, retry_after_secs, status_code=429 in both request() and request_with_headers()


[CP0] CLI entry point with Commander.js

## Background

Commander.js provides the CLI framework. The main entry point sets up the program with all subcommands. Uses ESM with proper shebang for npx/global installation.

Reference: docs/prd/checkpoint-0.md section "CLI Commands"

## Approach

**src/cli/index.ts:**
```typescript
#!/usr/bin/env node

import { Command } from 'commander';
import { version } from '../../package.json' with { type: 'json' };
import { initCommand } from './commands/init';
import { authTestCommand } from './commands/auth-test';
import { doctorCommand } from './commands/doctor';
import { versionCommand } from './commands/version';
import { backupCommand } from './commands/backup';
import { resetCommand } from './commands/reset';
import { syncStatusCommand } from './commands/sync-status';

const program = new Command();

program
  .name('gi')
  .description('GitLab Inbox - Unified notification management')
  .version(version);

// Global --config flag available to all commands
program.option('-c, --config <path>', 'Path to config file');

// Register subcommands
program.addCommand(initCommand);
program.addCommand(authTestCommand);
program.addCommand(doctorCommand);
program.addCommand(versionCommand);
program.addCommand(backupCommand);
program.addCommand(resetCommand);
program.addCommand(syncStatusCommand);

program.parse();
```

Each command file exports a Command instance:
```typescript
// src/cli/commands/version.ts
import { Command } from 'commander';

export const versionCommand = new Command('version')
  .description('Show version information')
  .action(() => {
    console.log(`gi version ${version}`);
  });
```

## Acceptance Criteria

- [ ] `gi --help` shows all commands and global options
- [ ] `gi --version` shows version from package.json
- [ ] `gi <command> --help` shows command-specific help
- [ ] `gi --config ./path` passes config path to commands
- [ ] Unknown command shows error and suggests --help
- [ ] Exit code 0 on success, non-zero on error
- [ ] Shebang line works for npx execution

## Files

CREATE:
- src/cli/index.ts (main entry point)
- src/cli/commands/version.ts (simple command as template)

MODIFY (later beads):
- package.json (add "bin" field pointing to dist/cli/index.js)

## TDD Loop

N/A for CLI entry point - verify with manual testing:

```bash
npm run build
node dist/cli/index.js --help
node dist/cli/index.js version
node dist/cli/index.js unknown-command  # should error
```

## Edge Cases

- package.json import requires Node 20+ with { type: 'json' } assertion
- Alternative: read version from package.json with readFileSync
- Command registration order affects help display - alphabetical preferred
- Global options must be defined before subcommands


Epic: CLI Intelligence & Market Position (CLI-IMP)

## Strategic Context

Analysis of glab (GitLab CLI) vs lore reveals a clean architectural split: lore = ALL reads (issues, MRs, search, who, timeline, intelligence); glab = ALL writes (create, update, approve, merge, CI/CD).

Lore is NOT duplicating glab. The overlap is minimal (both list issues/MRs), but lore output is curated, flatter, and richer (closing MRs, work-item status, discussions pre-joined). Agents reach for glab by default because they have been trained on it — a discovery problem, not a capability problem.

Three layers: (1) Foundation — make lore the definitive read path; (2) Intelligence — ship half-built features (hybrid search, timeline, per-note search); (3) Alien Artifact — novel intelligence (explain, related, brief, drift).

## Progress (as of 2026-02-12)

### Shipped
- Timeline CLI (bd-2wpf): CLOSED. 5-stage pipeline with human and robot renderers working end-to-end.
- `who` command: Expert, Workload, Reviews, Active, Overlap modes all functional.
- Search infrastructure: hybrid.rs, vector.rs, rrf.rs all implemented and tested (not yet wired to CLI).

### In Progress
- Foundation: bd-kvij (skill rewrite), bd-91j1 (robot-docs), bd-2g50 (data gaps)
- Intelligence: bd-1ksf (hybrid search wiring), bd-2l3s (per-note search)
- Alien Artifact: bd-1n5q (brief), bd-8con (related), bd-9lbr (explain), bd-1cjx (drift)

## Success Criteria
- [x] Timeline CLI shipped with human and robot renderers (bd-2wpf CLOSED)
- [ ] Zero agent skill files reference glab for read operations
- [ ] robot-docs comprehensive enough for zero-training agent bootstrap
- [ ] Hybrid search (FTS + vector + RRF) wired to CLI and default
- [ ] Per-note search operational at note granularity
- [ ] At least one Tier 3 alien artifact feature prototyped (brief, related, explain, or drift)

## Architecture Notes
- main.rs is 2579 lines with all subcommand handlers
- CLI commands in src/cli/commands/ (16 modules: auth_test, count, doctor, embed, generate_docs, init, ingest, list, search, show, stats, sync, sync_status, timeline, who, plus mod.rs)
- Database: 21 migrations wired (001-021), LATEST_SCHEMA_VERSION = 21
- Raw payloads for issues store 15 fields: assignees, author, closed_at, created_at, description, due_date, id, iid, labels, milestone, project_id, state, title, updated_at, web_url
- Missing from raw payloads: closed_by, confidential, upvotes, downvotes, weight, issue_type, time_stats, health_status (ingestion pipeline doesn't capture these)
- robot-docs current output keys: name, version, description, activation, commands, aliases, exit_codes, clap_error_codes, error_format, workflows


Display closing MRs in lore issues <id> output

## Background
The `entity_references` table stores MR->Issue 'closes' relationships (from the closes_issues API), but this data is never displayed when viewing an issue. This is the 'Development' section in GitLab UI showing which MRs will close an issue when merged.

**System fit**: Data already flows through `fetch_mr_closes_issues()` -> `store_closes_issues_refs()` -> `entity_references` table. We just need to query and display it.

## Approach

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

### 1. Add ClosingMrRef struct (after DiffNotePosition ~line 57)
```rust
#[derive(Debug, Clone, Serialize)]
pub struct ClosingMrRef {
    pub iid: i64,
    pub title: String,
    pub state: String,
    pub web_url: Option<String>,
}
```

### 2. Update IssueDetail struct (line ~59)
```rust
pub struct IssueDetail {
    // ... existing fields ...
    pub closing_merge_requests: Vec<ClosingMrRef>,  // NEW - add after discussions
}
```

### 3. Add ClosingMrRefJson struct (after NoteDetailJson ~line 797)
```rust
#[derive(Serialize)]
pub struct ClosingMrRefJson {
    pub iid: i64,
    pub title: String,
    pub state: String,
    pub web_url: Option<String>,
}
```

### 4. Update IssueDetailJson struct (line ~770)
```rust
pub struct IssueDetailJson {
    // ... existing fields ...
    pub closing_merge_requests: Vec<ClosingMrRefJson>,  // NEW
}
```

### 5. Add get_closing_mrs() function (after get_issue_discussions ~line 245)
```rust
fn get_closing_mrs(conn: &Connection, issue_id: i64) -> Result<Vec<ClosingMrRef>> {
    let mut stmt = conn.prepare(
        "SELECT mr.iid, mr.title, mr.state, mr.web_url
         FROM entity_references er
         JOIN merge_requests mr ON mr.id = er.source_entity_id
         WHERE er.target_entity_type = 'issue'
           AND er.target_entity_id = ?
           AND er.source_entity_type = 'merge_request'
           AND er.reference_type = 'closes'
         ORDER BY mr.iid"
    )?;

    let mrs = stmt
        .query_map([issue_id], |row| {
            Ok(ClosingMrRef {
                iid: row.get(0)?,
                title: row.get(1)?,
                state: row.get(2)?,
                web_url: row.get(3)?,
            })
        })?
        .collect::<std::result::Result<Vec<_>, _>>()?;

    Ok(mrs)
}
```

### 6. Update run_show_issue() (line ~89)
```rust
let closing_mrs = get_closing_mrs(&conn, issue.id)?;
// In return struct:
closing_merge_requests: closing_mrs,
```

### 7. Update print_show_issue() (after Labels section ~line 556)
```rust
if !issue.closing_merge_requests.is_empty() {
    println!("Development:");
    for mr in &issue.closing_merge_requests {
        let state_indicator = match mr.state.as_str() {
            "merged" => style("merged").green(),
            "opened" => style("opened").cyan(),
            "closed" => style("closed").red(),
            _ => style(&mr.state).dim(),
        };
        println!("  !{} {} ({})", mr.iid, mr.title, state_indicator);
    }
}
```

### 8. Update From<&IssueDetail> for IssueDetailJson (line ~799)
```rust
closing_merge_requests: issue.closing_merge_requests.iter().map(|mr| ClosingMrRefJson {
    iid: mr.iid,
    title: mr.title.clone(),
    state: mr.state.clone(),
    web_url: mr.web_url.clone(),
}).collect(),
```

## Acceptance Criteria
- [ ] `cargo test test_get_closing_mrs` passes (4 tests)
- [ ] `lore issues <id>` shows Development section when closing MRs exist
- [ ] Development section shows MR iid, title, and state
- [ ] State is color-coded (green=merged, cyan=opened, red=closed)
- [ ] `lore -J issues <id>` includes closing_merge_requests array
- [ ] `cargo clippy --all-targets -- -D warnings` passes

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

## TDD Loop

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

```rust
fn seed_issue_with_closing_mr(conn: &Connection) -> (i64, i64) {
    conn.execute(
        "INSERT INTO projects (id, gitlab_project_id, path_with_namespace, web_url, created_at, updated_at)
         VALUES (1, 100, 'group/repo', 'https://gitlab.example.com', 1000, 2000)", []
    ).unwrap();
    conn.execute(
        "INSERT INTO issues (id, gitlab_id, iid, project_id, title, state, author_username,
         created_at, updated_at, last_seen_at) VALUES (1, 200, 10, 1, 'Bug fix', 'opened', 'dev', 1000, 2000, 2000)", []
    ).unwrap();
    conn.execute(
        "INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, author_username,
         source_branch, target_branch, created_at, updated_at, last_seen_at)
         VALUES (1, 300, 5, 1, 'Fix the bug', 'merged', 'dev', 'fix', 'main', 1000, 2000, 2000)", []
    ).unwrap();
    conn.execute(
        "INSERT INTO entity_references (project_id, source_entity_type, source_entity_id,
         target_entity_type, target_entity_id, reference_type, source_method, created_at)
         VALUES (1, 'merge_request', 1, 'issue', 1, 'closes', 'api', 3000)", []
    ).unwrap();
    (1, 1) // (issue_id, mr_id)
}

#[test]
fn test_get_closing_mrs_empty() {
    let conn = setup_test_db();
    // seed project + issue with no closing MRs
    conn.execute("INSERT INTO projects ...", []).unwrap();
    conn.execute("INSERT INTO issues ...", []).unwrap();
    let result = get_closing_mrs(&conn, 1).unwrap();
    assert!(result.is_empty());
}

#[test]
fn test_get_closing_mrs_single() {
    let conn = setup_test_db();
    seed_issue_with_closing_mr(&conn);
    let result = get_closing_mrs(&conn, 1).unwrap();
    assert_eq!(result.len(), 1);
    assert_eq!(result[0].iid, 5);
    assert_eq!(result[0].title, "Fix the bug");
    assert_eq!(result[0].state, "merged");
}

#[test]
fn test_get_closing_mrs_ignores_mentioned() {
    let conn = setup_test_db();
    seed_issue_with_closing_mr(&conn);
    // Add a 'mentioned' reference that should be ignored
    conn.execute(
        "INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, author_username,
         source_branch, target_branch, created_at, updated_at, last_seen_at)
         VALUES (2, 301, 6, 1, 'Other MR', 'opened', 'dev', 'other', 'main', 1000, 2000, 2000)", []
    ).unwrap();
    conn.execute(
        "INSERT INTO entity_references (project_id, source_entity_type, source_entity_id,
         target_entity_type, target_entity_id, reference_type, source_method, created_at)
         VALUES (1, 'merge_request', 2, 'issue', 1, 'mentioned', 'note_parse', 3000)", []
    ).unwrap();
    let result = get_closing_mrs(&conn, 1).unwrap();
    assert_eq!(result.len(), 1); // Only the 'closes' ref
}

#[test]
fn test_get_closing_mrs_multiple_sorted() {
    let conn = setup_test_db();
    seed_issue_with_closing_mr(&conn);
    // Add second closing MR with higher iid
    conn.execute(
        "INSERT INTO merge_requests (id, gitlab_id, iid, project_id, title, state, author_username,
         source_branch, target_branch, created_at, updated_at, last_seen_at)
         VALUES (2, 301, 8, 1, 'Another fix', 'opened', 'dev', 'fix2', 'main', 1000, 2000, 2000)", []
    ).unwrap();
    conn.execute(
        "INSERT INTO entity_references (project_id, source_entity_type, source_entity_id,
         target_entity_type, target_entity_id, reference_type, source_method, created_at)
         VALUES (1, 'merge_request', 2, 'issue', 1, 'closes', 'api', 3000)", []
    ).unwrap();
    let result = get_closing_mrs(&conn, 1).unwrap();
    assert_eq!(result.len(), 2);
    assert_eq!(result[0].iid, 5); // Lower iid first
    assert_eq!(result[1].iid, 8);
}
```

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

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

## Edge Cases
- Empty closing MRs -> don't print Development section
- MR in different states -> color-coded appropriately
- Cross-project closes (target_entity_id IS NULL) -> not displayed (unresolved refs)
- Multiple MRs closing same issue -> all shown, ordered by iid

Implemented: closing MRs (Development section) now display in lore issues <id>. All 4 new tests pass.


Implement Rust-side decay aggregation with reviewer split

## Background
The current accumulation (who.rs:776-803) maps SQL rows directly to Expert structs with integer scores computed in SQL. The new model receives per-signal rows from build_expert_sql() (bd-1hoq) and needs Rust-side decay computation, reviewer split, closed MR multiplier, and deterministic f64 ordering. This bead wires the new SQL into query_expert() and replaces the accumulation logic.

## Approach
Modify query_expert() (who.rs:637-810) to:
1. Call build_expert_sql() instead of the inline SQL
2. Execute and iterate rows: (username, signal, mr_id, qty, ts, mr_state)
3. Accumulate into per-user UserAccum structs
4. Compute decayed scores with deterministic ordering
5. Build Expert structs from accumulators

### Updated query_expert() signature:
```rust
#[allow(clippy::too_many_arguments)]
fn query_expert(
    conn: &Connection,
    path: &str,
    project_id: Option<i64>,
    since_ms: i64,
    as_of_ms: i64,          // NEW
    limit: usize,
    scoring: &ScoringConfig,
    detail: bool,
    explain_score: bool,     // NEW
    include_bots: bool,      // NEW
) -> Result<ExpertResult>
```

### Per-user accumulator:
```rust
struct UserAccum {
    author_mrs: HashMap<i64, (i64, String)>,
    reviewer_participated: HashMap<i64, (i64, String)>,
    reviewer_assigned: HashMap<i64, (i64, String)>,
    notes_per_mr: HashMap<i64, (u32, i64, String)>,
    last_seen: i64,
    components: Option<[f64; 4]>, // [author, review_part, review_asgn, notes] when explain
}
```

### Signal routing:
- 'diffnote_author' / 'file_author' -> author_mrs
- 'diffnote_reviewer' / 'file_reviewer_participated' -> reviewer_participated
- 'file_reviewer_assigned' -> reviewer_assigned (skip if mr_id in reviewer_participated)
- 'note_group' -> notes_per_mr

### Deterministic score computation:
Sort each HashMap's entries by mr_id ASC, then sum:
```
state_mult = if state == "closed" { closed_mr_multiplier } else { 1.0 }
```

### Expert struct additions (who.rs:141-154):
```rust
pub score_raw: Option<f64>,
pub components: Option<ScoreComponents>,
```

### Bot filtering:
Post-query: if !include_bots, filter excluded_usernames (case-insensitive .to_lowercase()).

## TDD Loop

### RED (write these 13 tests first):

**Core decay integration:**
```rust
#[test]
fn test_expert_scores_decay_with_time() {
    // Two authors: recent (10d ago) vs old (360d ago). Recent scores ~24, old ~6.
    let conn = setup_test_db();
    let now = now_ms();
    insert_project(&conn, 1, "team/backend");
    insert_mr_at(&conn, 1, 1, 100, "recent_author", "merged", now - 10*DAY_MS, Some(now - 10*DAY_MS), None);
    insert_mr_at(&conn, 2, 1, 101, "old_author", "merged", now - 360*DAY_MS, Some(now - 360*DAY_MS), None);
    insert_file_change(&conn, 1, 1, "src/app.rs", "modified");
    insert_file_change(&conn, 2, 1, "src/app.rs", "modified");
    let result = query_expert(&conn, "src/app.rs", None, 0, now, 20, &default_scoring(), false, false, false).unwrap();
    assert_eq!(result.experts[0].username, "recent_author");
    assert!(result.experts[0].score > result.experts[1].score);
    // recent: 25 * 2^(-10/180) ≈ 24.1
    // old: 25 * 2^(-360/180) = 25 * 0.25 = 6.25
}
```

**Plus these 12 additional tests** (listed with key assertion, full bodies follow the pattern above):
- test_expert_reviewer_decays_faster_than_author: author > reviewer at 90d
- test_reviewer_participated_vs_assigned_only: participated ~10*decay > assigned ~3*decay
- test_note_diminishing_returns_per_mr: 20-note/1-note ratio ~4.4x not 20x
- test_file_change_timestamp_uses_merged_at: merged MR uses merged_at not updated_at
- test_open_mr_uses_updated_at: opened MR uses updated_at not created_at
- test_old_path_match_credits_expertise: query old path -> author appears
- test_closed_mr_multiplier: closed MR contributes at 0.5x merged
- test_trivial_note_does_not_count_as_participation: 4-char "LGTM" -> assigned-only
- test_null_timestamp_fallback_to_created_at: merged MR with NULL merged_at
- test_row_order_independence: same data, different insert order -> identical rankings
- test_reviewer_split_is_exhaustive: every reviewer in exactly one bucket
- test_deterministic_accumulation_order: 100 runs, bit-identical f64

### GREEN: Wire build_expert_sql into query_expert, implement UserAccum + scoring loop.
### VERIFY: `cargo test -p lore -- test_expert_scores_decay test_reviewer_participated test_note_diminishing test_file_change_timestamp test_open_mr_uses test_old_path_match test_closed_mr test_trivial_note test_null_timestamp test_row_order test_reviewer_split test_deterministic`

## Acceptance Criteria
- [ ] All 13 tests pass green
- [ ] Existing who tests pass (decay ~1.0 for now_ms() data, integer scores unchanged)
- [ ] Score uses f64 with decay, sorted before rounding
- [ ] reviewer_assigned excludes mr_ids already in reviewer_participated
- [ ] Deterministic: 100 runs produce bit-identical f64 (sorted by mr_id)
- [ ] Bot filtering applied when include_bots=false

## Files
- src/cli/commands/who.rs (query_expert at 637-810, Expert struct at 141-154)

## Edge Cases
- log2(1.0 + 0) = 0.0 — zero notes contribute nothing
- f64 NaN: half_life_decay guards hl=0
- HashMap to sorted Vec for deterministic summing
- as_of_ms: use passed value, not now_ms() — enables bd-11mg's --as-of


[CP1] Database migration 002_issues.sql

Create migration file with tables for issues, labels, issue_labels, discussions, and notes.

Tables to create:
- issues: gitlab_id, project_id, iid, title, description, state, author_username, timestamps, web_url, raw_payload_id
- labels: gitlab_id, project_id, name, color, description (unique on project_id+name)
- issue_labels: junction table
- discussions: gitlab_discussion_id, project_id, issue_id, noteable_type, individual_note, timestamps, resolvable/resolved
- notes: gitlab_id, discussion_id, project_id, type, is_system, author_username, body, timestamps, position, resolution fields, DiffNote position fields

Include appropriate indexes:
- idx_issues_project_updated, idx_issues_author, uq_issues_project_iid
- uq_labels_project_name, idx_labels_name
- idx_issue_labels_label
- uq_discussions_project_discussion_id, idx_discussions_issue/mr/last_note
- idx_notes_discussion/author/system

Files: migrations/002_issues.sql
Done when: Migration applies cleanly on top of 001_initial.sql


Implement soak test + concurrent pagination/write race tests

## Background
The 30-minute soak test verifies no panic, deadlock, or memory leak under sustained use. Concurrent pagination/write race tests prove browse snapshot fences prevent duplicate or skipped rows during sync writes.

## Approach
Soak test:
- Automated script that drives the TUI for 30 minutes: random navigation, filter changes, sync starts/cancels, search queries
- Monitors: no panic (exit code), no deadlock (watchdog timer), memory growth < 5% (RSS sampling)
- Uses FakeClock with accelerated time for time-dependent features

Concurrent pagination/write race:
- Thread A: paginating through Issue List (fetching pages via keyset cursor)
- Thread B: writing new issues to DB (simulating sync)
- Assert: no duplicate rows across pages, no skipped rows within a browse snapshot fence
- BrowseSnapshot token ensures stable ordering until explicit refresh

## Acceptance Criteria
- [ ] 30-min soak: no panic
- [ ] 30-min soak: no deadlock (watchdog detects)
- [ ] 30-min soak: memory growth < 5%
- [ ] Concurrent pagination: no duplicate rows across pages
- [ ] Concurrent pagination: no skipped rows within snapshot fence
- [ ] BrowseSnapshot invalidated on manual refresh, not on background writes

## Files
- CREATE: crates/lore-tui/tests/soak_test.rs
- CREATE: crates/lore-tui/tests/pagination_race_test.rs

## TDD Anchor
RED: Write test_pagination_no_duplicates that runs paginator and writer concurrently for 1000 iterations, collects all returned row IDs, asserts no duplicates.
GREEN: Implement browse snapshot fence in keyset pagination.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_pagination_no_duplicates

## Edge Cases
- Soak test needs headless mode (no real terminal) — use ftui test harness
- Memory sampling on macOS: use mach_task_info or /proc equivalent
- Writer must use WAL mode to not block readers
- Snapshot fence: deferred read transaction holds snapshot until page sequence completes

## Dependency Context
Uses DbManager from "Implement DbManager" task.
Uses BrowseSnapshot from "Implement NavigationStack" task.
Uses keyset pagination from "Implement Issue List" task.


Epic: Gate 4 - File Decision History (lore file-history)

## Background

Gate 4 implements `lore file-history` — answers "Which MRs touched this file, and why?" by linking files to MRs via a new mr_file_changes table and resolving rename chains.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Gate 4 (Sections 4.1-4.7).

## Prerequisites

- Gates 1-2 COMPLETE: entity_references populated, resource events fetched
- Migration 015 exists on disk (commit SHAs + closes watermark) — registered by bd-1oo
- pending_dependent_fetches has job_type='mr_diffs' in CHECK constraint (migration 011)

## Architecture

- **New table:** mr_file_changes (migration 016) stores file paths per MR
- **New config:** fetchMrFileChanges (default true) gates the API calls
- **API source:** GET /projects/:id/merge_requests/:iid/diffs — extract paths only, discard diff content
- **Rename resolution:** BFS both directions on mr_file_changes WHERE change_type='renamed', bounded at 10 hops
- **Query:** Join mr_file_changes -> merge_requests, optionally enrich with entity_references and discussions

## Children (Execution Order)

1. **bd-1oo** — Register migration 015 + create migration 016 (mr_file_changes table)
2. **bd-jec** — Add fetchMrFileChanges config flag
3. **bd-2yo** — Fetch MR diffs API and populate mr_file_changes
4. **bd-1yx** — Implement rename chain resolution (BFS algorithm)
5. **bd-z94** — Implement lore file-history CLI command (human + robot output)

## Gate Completion Criteria

- [ ] mr_file_changes table populated from GitLab diffs API
- [ ] merge_commit_sha and squash_commit_sha captured in merge_requests (already done in code, needs migration 015 registered)
- [ ] `lore file-history <path>` returns MRs ordered by merge/creation date
- [ ] Output includes: MR title, state, author, change type, discussion count
- [ ] --discussions shows inline discussion snippets from DiffNotes on the file
- [ ] Rename chains resolved with bounded hop count (default 10) and cycle detection
- [ ] --no-follow-renames disables chain resolution
- [ ] Robot mode JSON includes rename_chain when renames detected
- [ ] -p required when path in multiple projects (exit 18 Ambiguous)


[CP1] Issue transformer with label extraction

Transform GitLab issue payloads to normalized database schema.

## Module
src/gitlab/transformers/issue.rs

## Structs

### NormalizedIssue
- gitlab_id: i64
- project_id: i64 (local DB project ID)
- iid: i64
- title: String
- description: Option<String>
- state: String
- author_username: String
- created_at, updated_at, last_seen_at: i64 (ms epoch)
- web_url: String

### NormalizedLabel (CP1: name-only)
- project_id: i64
- name: String

## Functions

### transform_issue(gitlab_issue: &GitLabIssue, local_project_id: i64) -> NormalizedIssue
- Convert ISO timestamps to ms epoch using iso_to_ms()
- Set last_seen_at to now_ms()
- Clone string fields

### extract_labels(gitlab_issue: &GitLabIssue, local_project_id: i64) -> Vec<NormalizedLabel>
- Map labels vec to NormalizedLabel structs

Files:
- src/gitlab/transformers/mod.rs
- src/gitlab/transformers/issue.rs
Tests: tests/issue_transformer_tests.rs
Done when: Unit tests pass for payload transformation and label extraction


OBSERV: Record item counts as span fields in sync stages

## Background
MetricsLayer (bd-34ek) captures span fields, but the stage functions must actually record item counts INTO their spans. This is the bridge between "work happened" and "MetricsLayer knows about it."

## Approach
In each stage function, after the work loop completes, record counts into the current span:

### src/ingestion/orchestrator.rs - ingest_project_issues_with_progress() (~line 110)
After issues are fetched and discussions synced:
```rust
tracing::Span::current().record("items_processed", result.issues_upserted);
tracing::Span::current().record("items_skipped", result.issues_skipped);
tracing::Span::current().record("errors", result.errors);
```

### src/ingestion/orchestrator.rs - drain_resource_events() (~line 566)
After the drain loop:
```rust
tracing::Span::current().record("items_processed", result.fetched);
tracing::Span::current().record("errors", result.failed);
```

### src/documents/regenerator.rs - regenerate_dirty_documents() (~line 24)
After the regeneration loop:
```rust
tracing::Span::current().record("items_processed", result.regenerated);
tracing::Span::current().record("items_skipped", result.unchanged);
tracing::Span::current().record("errors", result.errored);
```

### src/embedding/pipeline.rs - embed_documents() (~line 36)
After embedding completes:
```rust
tracing::Span::current().record("items_processed", result.embedded);
tracing::Span::current().record("items_skipped", result.skipped);
tracing::Span::current().record("errors", result.failed);
```

IMPORTANT: These fields must be declared as tracing::field::Empty in the #[instrument] attribute (done in bd-24j1). You can only record() a field that was declared at span creation. Attempting to record an undeclared field silently does nothing.

## Acceptance Criteria
- [ ] MetricsLayer captures items_processed for each stage
- [ ] MetricsLayer captures items_skipped and errors when non-zero
- [ ] Fields match the span declarations from bd-24j1
- [ ] extract_timings() returns correct counts in StageTiming
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/ingestion/orchestrator.rs (record counts in ingest + drain functions)
- src/documents/regenerator.rs (record counts in regenerate)
- src/embedding/pipeline.rs (record counts in embed)

## TDD Loop
RED: test_stage_fields_recorded (integration: run pipeline, extract timings, verify counts > 0)
GREEN: Add Span::current().record() calls at end of each stage
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- Span::current() returns a disabled span if no subscriber is registered (e.g., in tests without subscriber setup). record() on disabled span is a no-op. Tests need a subscriber.
- Field names must exactly match the declaration: "items_processed" not "itemsProcessed"
- Recording must happen BEFORE the span closes (before function returns). Place at end of function but before Ok(result).

Added tracing::field::Empty declarations and Span::current().record() calls in 4 functions: ingest_project_issues, ingest_project_merge_requests, drain_resource_events, regenerate_dirty_documents, embed_documents


OBSERV: Add LoggingConfig to Config struct

## Background
LoggingConfig centralizes log file settings so users can customize retention and disable file logging. It follows the same #[serde(default)] pattern as SyncConfig (src/core/config.rs:32-78) so existing config.json files continue working with zero changes.

## Approach
Add to src/core/config.rs, after the EmbeddingConfig struct (around line 120):

```rust
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct LoggingConfig {
    /// Directory for log files. Default: None (= XDG data dir + /logs/)
    pub log_dir: Option<String>,

    /// Days to retain log files. Default: 30. Set to 0 to disable file logging.
    pub retention_days: u32,

    /// Enable JSON log files. Default: true.
    pub file_logging: bool,
}

impl Default for LoggingConfig {
    fn default() -> Self {
        Self {
            log_dir: None,
            retention_days: 30,
            file_logging: true,
        }
    }
}
```

Add to the Config struct (src/core/config.rs:123-137), after the embedding field:

```rust
#[serde(default)]
pub logging: LoggingConfig,
```

Note: Using impl Default rather than default helper functions (default_retention_days, default_true) because #[serde(default)] on the struct applies Default::default() to the entire struct when the key is missing. This is the same pattern used by SyncConfig.

## Acceptance Criteria
- [ ] Deserializing {} as LoggingConfig yields retention_days=30, file_logging=true, log_dir=None
- [ ] Deserializing {"retention_days": 7} preserves file_logging=true default
- [ ] Existing config.json files (no "logging" key) deserialize without error
- [ ] Config struct has .logging field accessible
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/core/config.rs (add LoggingConfig struct + Default impl, add field to Config)

## TDD Loop
RED: tests/config_tests.rs (or inline #[cfg(test)] mod):
  - test_logging_config_defaults
  - test_logging_config_partial
GREEN: Add LoggingConfig struct, Default impl, field on Config
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- retention_days=0 means disable file logging entirely (not "delete all files") -- document this in the struct doc comment
- log_dir with a relative path: should be resolved relative to CWD or treated as absolute? Decision: treat as absolute, document it
- Missing "logging" key in JSON: #[serde(default)] handles this -- the entire LoggingConfig gets Default::default()

Added LoggingConfig struct with log_dir, retention_days, file_logging fields and serde defaults


[CP1] gi sync-status enhancement

## Background

The `gi sync-status` command shows synchronization state: last successful sync time, cursor positions per project/resource, and overall health. This helps users understand when data was last refreshed and diagnose sync issues.

## Approach

### Module: src/cli/commands/sync_status.rs (enhance existing or create)

### Handler Function

```rust
pub async fn handle_sync_status(conn: &Connection) -> Result<()>
```

### Data to Display

1. **Last sync run**: From `sync_runs` table
   - Started at, completed at, status
   - Issues fetched, discussions fetched

2. **Cursor positions**: From `sync_cursors` table
   - Per (project, resource_type) pair
   - Show updated_at_cursor as human-readable date
   - Show tie_breaker_id (GitLab ID of last processed item)

3. **Overall counts**: Quick summary
   - Total issues, discussions, notes in DB

### Output Format

```
Last Sync
─────────
Status:    completed
Started:   2024-01-25 10:30:00
Completed: 2024-01-25 10:35:00
Duration:  5m 23s

Cursor Positions
────────────────
group/project-one (issues):
  Last updated_at: 2024-01-25 10:30:00
  Last GitLab ID:  12345

Data Summary
────────────
Issues:      1,234
Discussions: 5,678
Notes:       12,345 (excluding 2,000 system)
```

### Queries

```sql
-- Last sync run
SELECT * FROM sync_runs ORDER BY started_at DESC LIMIT 1

-- Cursor positions
SELECT p.path, sc.resource_type, sc.updated_at_cursor, sc.tie_breaker_id
FROM sync_cursors sc
JOIN projects p ON sc.project_id = p.id

-- Data summary
SELECT COUNT(*) FROM issues
SELECT COUNT(*) FROM discussions
SELECT COUNT(*), SUM(is_system) FROM notes
```

## Acceptance Criteria

- [ ] Shows last sync run with status and timing
- [ ] Shows cursor position per project/resource
- [ ] Shows total counts for issues, discussions, notes
- [ ] Handles case where no sync has run yet
- [ ] Formats timestamps as human-readable local time

## Files

- src/cli/commands/sync_status.rs (create or enhance)
- src/cli/mod.rs (add SyncStatus variant if new)

## TDD Loop

RED:
```rust
#[tokio::test] async fn sync_status_shows_last_run()
#[tokio::test] async fn sync_status_shows_cursor_positions()
#[tokio::test] async fn sync_status_handles_no_sync_yet()
```

GREEN: Implement handler with queries and formatting

VERIFY: `cargo test sync_status`

## Edge Cases

- No sync has ever run - show "No sync runs recorded"
- Sync in progress - show "Status: running" with started_at
- Cursor at epoch 0 - means fresh start, show "Not started"
- Multiple projects - show cursor for each

Implemented gi sync-status showing last run, cursor positions, and data summary


NOTE-0B: Immediate deletion propagation for swept notes

## Background
When sweep deletes stale notes, orphaned note documents remain in search results until generate-docs --full runs. This erodes dataset trust. Propagate deletion to documents immediately in the same transaction.

## Approach
Update both sweep functions (issue + MR) to use set-based SQL that deletes documents and dirty_sources entries for stale notes before deleting the note rows:

Step 1: DELETE FROM documents WHERE source_type = 'note' AND source_id IN (SELECT id FROM notes WHERE discussion_id = ? AND last_seen_at < ? AND is_system = 0)
Step 2: DELETE FROM dirty_sources WHERE source_type = 'note' AND source_id IN (same subquery)
Step 3: DELETE FROM notes WHERE discussion_id = ? AND last_seen_at < ?

Document DELETE cascades to document_labels/document_paths via ON DELETE CASCADE (defined in migration 007_documents.sql). FTS trigger documents_ad auto-removes FTS entry (defined in migration 008_fts5.sql). Same pattern for mr_discussions.rs sweep.

Note: MR sweep_stale_notes() at line 551 uses a different WHERE clause (project_id + discussion_id IN subquery + last_seen_at). Apply the same document propagation pattern with the matching subquery.

## Files
- MODIFY: src/ingestion/discussions.rs (update sweep_stale_issue_notes from NOTE-0A)
- MODIFY: src/ingestion/mr_discussions.rs (update sweep_stale_notes at line 551)

## TDD Anchor
RED: test_issue_note_sweep_deletes_note_documents_immediately — setup 3 notes with documents, re-sync 2, sweep, assert stale doc deleted.
GREEN: Add document/dirty_sources DELETE before note DELETE in sweep functions.
VERIFY: cargo test sweep_deletes_note_documents -- --nocapture
Tests: test_mr_note_sweep_deletes_note_documents_immediately, test_sweep_deletion_handles_note_without_document, test_set_based_deletion_atomicity

## Acceptance Criteria
- [ ] Stale note sweep deletes corresponding documents in same transaction
- [ ] Stale note sweep deletes corresponding dirty_sources entries
- [ ] Non-system notes only — system notes never have documents (is_system = 0 filter)
- [ ] Set-based SQL (not per-note loops) for performance
- [ ] Works for both issue and MR discussion sweeps
- [ ] No error when sweeping notes that have no documents (DELETE WHERE on absent rows = no-op)
- [ ] All 4 tests pass

## Dependency Context
- Depends on NOTE-0A (bd-3bpk): uses sweep_stale_issue_notes/sweep_stale_notes functions created/modified in that bead
- Depends on NOTE-2A (bd-1oi7): documents table must accept source_type='note' (migration 024 adds CHECK constraint)

## Edge Cases
- System notes: WHERE clause filters with is_system = 0 (system notes never get documents)
- Notes without documents: DELETE WHERE on non-existent document is a no-op in SQLite
- FTS consistency: documents_ad trigger (migration 008) handles FTS cleanup on document DELETE


Implement entity table + filter bar widgets

## Background
The entity table and filter bar are shared widgets used by Issue List, MR List, and potentially Search results. The entity table supports sortable columns with responsive width allocation. The filter bar provides a typed DSL for filtering with inline diagnostics.

## Approach
Entity Table (view/common/entity_table.rs):
- EntityTable<R: TableRow> widget: generic over row type
- TableRow trait: fn cells(&self) -> Vec<Cell>, fn sort_key(&self, col: usize) -> Ordering
- Column definitions: name, min_width, flex_weight, alignment, sort_field
- Responsive column fitting: hide low-priority columns as terminal narrows
- Keyboard: j/k scroll, J/K page scroll, Tab cycle sort column, Enter select, g+g top, G bottom
- Visual: alternating row colors, selected row highlight, sort indicator arrow

Filter Bar (view/common/filter_bar.rs):
- FilterBar widget wrapping ftui TextInput
- DSL parsing (crate filter_dsl.rs): quoted values ("in progress"), negation prefix (-closed), field:value syntax (author:taylor, state:opened, label:bug), free-text search
- Inline diagnostics: unknown field names highlighted, cursor position for error
- Applied filter chips shown as tags below the input

Filter DSL (filter_dsl.rs):
- parse_filter_tokens(input: &str) -> Vec<FilterToken>
- FilterToken enum: FieldValue{field, value}, Negation{field, value}, FreeText(String), QuotedValue(String)
- Validation: known fields per entity type (issues: state, author, assignee, label, milestone, status; MRs: state, author, reviewer, target_branch, source_branch, label, draft)

## Acceptance Criteria
- [ ] EntityTable renders with responsive column widths
- [ ] Columns hide gracefully when terminal is too narrow
- [ ] j/k scrolls, Enter selects, Tab cycles sort column
- [ ] Sort indicator (arrow) shows on active sort column
- [ ] FilterBar captures text input and parses DSL tokens
- [ ] Quoted values preserved as single token
- [ ] Negation prefix (-closed) creates exclusion filter
- [ ] field:value syntax maps to typed filter fields
- [ ] Unknown field names highlighted as error
- [ ] Filter chips rendered below input bar

## Files
- CREATE: crates/lore-tui/src/view/common/entity_table.rs
- CREATE: crates/lore-tui/src/view/common/filter_bar.rs
- CREATE: crates/lore-tui/src/filter_dsl.rs

## TDD Anchor
RED: Write test_parse_filter_basic in filter_dsl.rs that parses "state:opened author:taylor" and asserts two FieldValue tokens.
GREEN: Implement parse_filter_tokens with field:value splitting.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_parse_filter

Additional tests:
- test_parse_quoted_value: "in progress" -> single QuotedValue token
- test_parse_negation: -closed -> Negation token
- test_parse_mixed: state:opened "bug fix" -wontfix -> 3 tokens of correct types
- test_column_hiding: EntityTable with 5 columns hides lowest priority at 60 cols

## Edge Cases
- Filter DSL must handle Unicode in values (CJK issue titles)
- Empty filter string should show all results (no-op)
- Very long filter strings must not overflow the input area
- Tab cycling sort must wrap around (last column -> first)
- Column widths must respect min_width even when terminal is very narrow


Implement discussion truncation logic

## Background
Discussion threads can contain dozens of notes spanning thousands of characters. The truncation module ensures discussion documents stay within a 32k character limit (suitable for embedding chunking) by dropping middle notes while preserving first and last notes for context. A separate hard safety cap of 2MB applies to ALL document types for pathological content (pasted logs, base64 blobs). Issue/MR documents are NOT truncated by the discussion logic — only the hard cap applies.

## Approach
Create `src/documents/truncation.rs` per PRD Section 2.3:

```rust
pub const MAX_DISCUSSION_CHARS: usize = 32_000;
pub const MAX_DOCUMENT_CHARS_HARD: usize = 2_000_000;

pub struct NoteContent {
    pub author: String,
    pub date: String,
    pub body: String,
}

pub struct TruncationResult {
    pub content: String,
    pub is_truncated: bool,
    pub reason: Option<TruncationReason>,
}

pub enum TruncationReason {
    TokenLimitMiddleDrop,
    SingleNoteOversized,
    FirstLastOversized,
    HardCapOversized,
}
```

**Core functions:**
- `truncate_discussion(notes: &[NoteContent], max_chars: usize) -> TruncationResult`
- `truncate_utf8(s: &str, max_bytes: usize) -> &str` (shared with fts.rs)
- `truncate_hard_cap(content: &str) -> TruncationResult` (for any doc type)

**Algorithm for truncate_discussion:**
1. Format all notes as `@author (date):\nbody\n\n`
2. If total <= max_chars: return as-is
3. If single note: truncate at UTF-8 boundary, append `[truncated]`, reason = SingleNoteOversized
4. Binary search: find max N where first N notes + last 1 note + marker fit within max_chars
5. If first + last > max_chars: keep only first (truncated), reason = FirstLastOversized
6. Otherwise: first N + marker + last M, reason = TokenLimitMiddleDrop

**Marker format:** `\n\n[... N notes omitted for length ...]\n\n`

## Acceptance Criteria
- [ ] Discussion with total < 32k chars returns untruncated
- [ ] Discussion > 32k chars: middle notes dropped, first + last preserved
- [ ] Truncation marker shows correct count of omitted notes
- [ ] Single note > 32k chars: truncated at UTF-8-safe boundary with `[truncated]` appended
- [ ] First + last note > 32k: only first note kept (truncated if needed)
- [ ] Hard cap (2MB) truncates any document type at UTF-8-safe boundary
- [ ] `truncate_utf8` never panics on multi-byte codepoints (emoji, CJK, accented chars)
- [ ] `TruncationReason::as_str()` returns DB-compatible strings matching CHECK constraint

## Files
- `src/documents/truncation.rs` — new file
- `src/documents/mod.rs` — add `pub use truncation::{truncate_discussion, truncate_hard_cap, TruncationResult, NoteContent};`

## TDD Loop
RED: Tests in `#[cfg(test)] mod tests`:
- `test_no_truncation_under_limit` — 3 short notes, all fit
- `test_middle_notes_dropped` — 10 notes totaling > 32k, first+last preserved
- `test_single_note_oversized` — one note of 50k chars, truncated safely
- `test_first_last_oversized` — first=20k, last=20k, only first kept
- `test_one_note_total` — single note under limit: no truncation
- `test_utf8_boundary_safety` — content with emoji/CJK at truncation point
- `test_hard_cap` — 3MB content truncated to 2MB
- `test_marker_count_correct` — marker says "[... 5 notes omitted ...]" when 5 dropped
GREEN: Implement truncation logic
VERIFY: `cargo test truncation`

## Edge Cases
- Empty notes list: return empty content, not truncated
- All notes are empty strings: total = 0, no truncation
- Note body contains only multi-byte characters: truncate_utf8 walks backward to find safe boundary
- Note body with trailing newlines: formatted output should not have excessive blank lines

Completed: truncate_discussion, truncate_hard_cap, truncate_utf8, TruncationReason with as_str(), 12 tests pass


NOTE-2C: Note document extractor function

## Background
Each non-system note becomes a searchable document in the FTS/embedding pipeline. Follows the pattern of extract_issue_document() (line 85), extract_mr_document() (line 186), extract_discussion_document() (line 302) in src/documents/extractor.rs.

## Approach
Add pub fn extract_note_document(conn: &Connection, note_id: i64) -> Result<Option<DocumentData>> to src/documents/extractor.rs:

1. Fetch note with JOIN to discussions and projects:
   SELECT n.id, n.gitlab_id, n.author_username, n.body, n.note_type, n.is_system, n.created_at, n.updated_at, n.position_new_path, n.position_new_line, n.position_old_path, n.position_old_line, n.resolvable, n.resolved, n.resolved_by, d.noteable_type, d.issue_id, d.merge_request_id, p.path_with_namespace, p.id as project_id
   FROM notes n
   JOIN discussions d ON n.discussion_id = d.id
   JOIN projects p ON n.project_id = p.id
   WHERE n.id = ?

2. Return None for: system notes (is_system = 1), not found, orphaned discussions (no parent issue/MR)

3. Fetch parent entity (Issue or MR) — get iid, title, web_url, labels:
   For issues: SELECT iid, title, web_url FROM issues WHERE id = ?
   For MRs: SELECT iid, title, web_url FROM merge_requests WHERE id = ?
   Labels: SELECT label_name FROM issue_labels/mr_labels WHERE issue_id/mr_id = ?
   (Same pattern as extract_discussion_document lines 332-401)

4. Build paths: BTreeSet from position_old_path + position_new_path (filter None values)

5. Build URL: parent_web_url + "#note_{gitlab_id}"

6. Format content with structured key-value header:
   [[Note]]
   source_type: note
   note_gitlab_id: {gitlab_id}
   project: {path_with_namespace}
   parent_type: {Issue|MergeRequest}
   parent_iid: {iid}
   parent_title: {title}
   note_type: {DiffNote|DiscussionNote|...}
   author: @{author}
   created_at: {iso8601}
   resolved: {true|false} (only if resolvable)
   path: {position_new_path}:{line} (only if DiffNote with path)
   labels: {comma-separated parent labels}
   url: {url}

   --- Body ---

   {body}

7. Title: "Note by @{author} on {Issue|MR} #{iid}: {parent_title}"

8. Compute hashes: content_hash via compute_content_hash() (line 66), labels_hash via compute_list_hash(), paths_hash via compute_list_hash(). Apply truncate_hard_cap() (imported from truncation.rs at line 9).

9. Return DocumentData (struct defined at line 47) with: source_type: SourceType::Note, source_id: note_id, project_id, author_username, labels, paths (as Vec<String>), labels_hash, paths_hash, created_at, updated_at, url, title, content_text (from hard_cap), content_hash, is_truncated, truncated_reason.

## Files
- MODIFY: src/documents/extractor.rs (add extract_note_document after extract_discussion_document, ~line 500)
- MODIFY: src/documents/mod.rs (add extract_note_document to pub use exports, line 12 area)

## TDD Anchor
RED: test_note_document_basic_format — insert project, issue, discussion, note; extract; assert content contains [[Note]], author, parent reference.
GREEN: Implement extract_note_document with SQL JOIN and content formatting.
VERIFY: cargo test note_document_basic_format -- --nocapture
Tests: test_note_document_diffnote_with_path, test_note_document_inherits_parent_labels, test_note_document_mr_parent, test_note_document_system_note_returns_none, test_note_document_not_found, test_note_document_orphaned_discussion, test_note_document_hash_deterministic, test_note_document_empty_body, test_note_document_null_body

## Acceptance Criteria
- [ ] extract_note_document returns Some(DocumentData) for non-system notes
- [ ] Returns None for system notes, not-found, orphaned discussions
- [ ] Content includes structured [[Note]] header with all parent context fields
- [ ] DiffNote includes file path and line info in content header
- [ ] Labels inherited from parent issue/MR
- [ ] URL format: parent_url#note_{gitlab_id}
- [ ] Title format: "Note by @{author} on {Issue|MR} #{iid}: {parent_title}"
- [ ] Hash is deterministic across calls (same input = same hash)
- [ ] Empty/null body handled gracefully (use empty string)
- [ ] truncate_hard_cap applied to content
- [ ] All 10 tests pass

## Dependency Context
- Depends on NOTE-2B (bd-ef0u): SourceType::Note variant must exist to construct DocumentData

## Edge Cases
- NULL body: use empty string "" — not all notes have body text
- Orphaned discussion: parent issue/MR deleted but discussion remains — return None
- Very long note body: truncate_hard_cap handles this (2MB limit)
- Note with no position data: skip path line in content header
- Note on MR vs Issue: different label table (mr_labels vs issue_labels)


OBSERV: Print human-readable timing summary after interactive sync

## Background
Interactive users want a quick timing summary after sync completes. This is the human-readable equivalent of meta.stages in robot JSON. Gated behind IngestDisplay::show_text so it doesn't appear in -q, robot, or progress_only modes.

## Approach
Add a function to format and print the timing summary, called from run_sync() after the pipeline completes:

```rust
fn print_timing_summary(stages: &[StageTiming], total_elapsed: Duration) {
    eprintln!();
    eprintln!("Sync complete in {:.1}s", total_elapsed.as_secs_f64());
    for stage in stages {
        let dots = ".".repeat(20_usize.saturating_sub(stage.name.len()));
        eprintln!(
            "  {} {} {:.1}s ({} items{})",
            stage.name,
            dots,
            stage.elapsed_ms as f64 / 1000.0,
            stage.items_processed,
            if stage.errors > 0 { format!(", {} errors", stage.errors) } else { String::new() },
        );
    }
}
```

Call in run_sync() (src/cli/commands/sync.rs), after pipeline and before return:
```rust
if display.show_text {
    let stages = metrics_handle.extract_timings();
    print_timing_summary(&stages, start.elapsed());
}
```

Output format per PRD Section 4.6.4:
```
Sync complete in 45.2s
  Ingest issues .... 12.3s (150 items, 42 discussions)
  Ingest MRs ....... 18.9s (85 items, 1 error)
  Generate docs .... 8.5s  (235 documents)
  Embed ............ 5.5s  (1024 chunks)
```

## Acceptance Criteria
- [ ] Interactive lore sync prints timing summary to stderr after completion
- [ ] Summary shows total time and per-stage breakdown
- [ ] lore -q sync does NOT print timing summary
- [ ] Robot mode does NOT print timing summary (only JSON)
- [ ] Error counts shown when non-zero
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/cli/commands/sync.rs (add print_timing_summary function, call after pipeline)

## TDD Loop
RED: test_timing_summary_format (capture stderr, verify format matches PRD example pattern)
GREEN: Implement print_timing_summary, gate behind display.show_text
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- Empty stages (e.g., sync with no projects configured): print "Sync complete in 0.0s" with no stage lines
- Very fast stages (<1ms): show "0.0s" not scientific notation
- Stage names with varying lengths: dot padding keeps alignment readable

Added print_timing_summary with per-stage breakdown (name, elapsed, items, errors, rate limits), nested sub-stage support, gated behind metrics Option


Update existing tests for new ScoringConfig fields

## Background
The existing test test_expert_scoring_weights_are_configurable (who.rs:3508-3531) constructs a ScoringConfig with only the original 3 fields. After bd-2w1p adds 8 new fields, this test won't compile without ..Default::default().

## Approach
Find the test at who.rs:3508-3531. The flipped config construction:
```rust
let flipped = ScoringConfig {
    author_weight: 5,
    reviewer_weight: 30,
    note_bonus: 1,
};
```
Change to:
```rust
let flipped = ScoringConfig {
    author_weight: 5,
    reviewer_weight: 30,
    note_bonus: 1,
    ..Default::default()
};
```

Also check default_scoring() helper — it likely calls ScoringConfig::default() which already works.

### Why existing assertions don't break:
All test data is inserted with now_ms(). With as_of_ms also at ~now_ms(), elapsed ~0ms, decay ~1.0. So integer-rounded scores are identical to the flat-weight model.

## Acceptance Criteria
- [ ] cargo test passes with zero assertion changes to existing test values
- [ ] test_expert_scoring_weights_are_configurable compiles and passes
- [ ] All other existing who tests pass unchanged
- [ ] No new test code needed — only ..Default::default() additions

## Files
- src/cli/commands/who.rs (test at lines 3508-3531, any other ScoringConfig literals in tests)

## Edge Cases
- Search for ALL ScoringConfig { ... } literals in test module — there may be more than one
- The default_scoring() helper may need updating if it creates ScoringConfig without Default


Epic: TUI Phase 5.5 — Reliability Test Pack

## Background
Phase 5.5 is a comprehensive reliability test suite covering race conditions, stress tests, property-based testing, and deterministic clock verification. These tests ensure the TUI is robust under adverse conditions (rapid input, concurrent writes, resize storms, backpressure).

## Acceptance Criteria
- [ ] Stale response drop tests pass
- [ ] Sync cancel/resume tests pass
- [ ] SQLITE_BUSY retry tests pass
- [ ] Resize storm + rapid keypress tests pass without panic
- [ ] Property tests for navigation invariants pass
- [ ] Performance benchmark fixtures (S/M/L tiers) pass SLOs
- [ ] Event fuzz tests: 10k traces with zero invariant violations
- [ ] Deterministic clock/render tests produce identical output
- [ ] 30-minute soak test: no panic, no deadlock, memory growth < 5%
- [ ] Concurrent pagination/write race tests: no duplicate/skipped rows
- [ ] Query cancellation race tests: no cross-task bleed, no stuck loading


CLI: show issue status display (human + robot)

## Background
Once status data is in the DB, lore show issue needs to display it. Human view shows colored status text; robot view includes all 5 fields as JSON.

## Approach
Add 5 fields to the IssueRow/IssueDetail/IssueDetailJson structs. Extend both find_issue SQL queries. Add status display line after State in human view. New style_with_hex() helper converts hex color to ANSI 256.

## Files
- src/cli/commands/show.rs

## Implementation

Add to IssueRow (private struct):
  status_name: Option<String>, status_category: Option<String>,
  status_color: Option<String>, status_icon_name: Option<String>,
  status_synced_at: Option<i64>

Update BOTH find_issue SQL queries (with and without project filter) SELECT list — add after existing columns:
  i.status_name, i.status_category, i.status_color, i.status_icon_name, i.status_synced_at
Column indices: status_name=12, status_category=13, status_color=14, status_icon_name=15, status_synced_at=16

Row mapping (after milestone_title: row.get(11)?):
  status_name: row.get(12)?, ..., status_synced_at: row.get(16)?

Add to IssueDetail (public struct) — same 5 fields
Add to IssueDetailJson — same 5 fields
Add to From<&IssueDetail> for IssueDetailJson — clone/copy fields

Human display in print_show_issue (after State line):
  if let Some(status) = &issue.status_name {
      let display = match &issue.status_category {
          Some(cat) => format!("{status} ({})", cat.to_ascii_lowercase()),
          None => status.clone(),
      };
      println!("Status:   {}", style_with_hex(&display, issue.status_color.as_deref()));
  }

New helper:
  fn style_with_hex<'a>(text: &'a str, hex: Option<&str>) -> console::StyledObject<&'a str>
  Parses 6-char hex (strips #), converts via ansi256_from_rgb, falls back to unstyled

## Acceptance Criteria
- [ ] Human: "Status:   In progress (in_progress)" shown after State line
- [ ] Status colored by hex -> ANSI 256
- [ ] Status line omitted when status_name IS NULL
- [ ] Robot: all 5 fields present as null when no status
- [ ] Robot: status_synced_at is integer (ms epoch) or null
- [ ] Both SQL queries updated (with and without project filter)
- [ ] cargo check --all-targets passes

## TDD Loop
RED: No new dedicated test file — verify via cargo test show (existing tests should still pass)
GREEN: Add fields, SQL columns, display logic
VERIFY: cargo test show && cargo check --all-targets

## Edge Cases
- Two separate SQL strings in find_issue — BOTH must be updated identically
- Column indices are positional — count carefully from 0
- style_with_hex: hex.len() == 6 check after trimming # prefix
- Invalid hex -> fall back to unstyled (no panic)
- NULL hex color -> fall back to unstyled
- clippy: use let-chain for combined if conditions (if hex.len() == 6 && let (...) = ...)

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


[CP0] gi doctor command - health checks

## Background

doctor is the primary diagnostic command. It checks all system components and reports their status. Supports JSON output for scripting and CI integration. Must degrade gracefully - warn about optional components (Ollama) without failing.

Reference: docs/prd/checkpoint-0.md section "gi doctor"

## Approach

**src/cli/commands/doctor.ts:**

Performs 5 checks:
1. **Config**: Load and validate config file
2. **Database**: Open DB, verify pragmas, check schema version
3. **GitLab**: Auth with token, verify connectivity
4. **Projects**: Count configured vs resolved in DB
5. **Ollama**: Ping embedding endpoint (optional - warn if unavailable)

**DoctorResult interface:**
```typescript
interface DoctorResult {
  success: boolean;  // All required checks passed
  checks: {
    config: { status: 'ok' | 'error'; path?: string; error?: string };
    database: { status: 'ok' | 'error'; path?: string; schemaVersion?: number; error?: string };
    gitlab: { status: 'ok' | 'error'; url?: string; username?: string; error?: string };
    projects: { status: 'ok' | 'error'; configured?: number; resolved?: number; error?: string };
    ollama: { status: 'ok' | 'warning' | 'error'; url?: string; model?: string; error?: string };
  };
}
```

**Human-readable output (default):**
```
gi doctor

  Config     ✓  Loaded from ~/.config/gi/config.json
  Database   ✓  ~/.local/share/gi/data.db (schema v1)
  GitLab     ✓  https://gitlab.example.com (authenticated as @johndoe)
  Projects   ✓  2 configured, 2 resolved
  Ollama     ⚠  Not running (semantic search unavailable)

Status: Ready (lexical search available, semantic search requires Ollama)
```

**JSON output (--json flag):**
Outputs DoctorResult as JSON to stdout

## Acceptance Criteria

- [ ] Config check: shows path and validation status
- [ ] Database check: shows path, schema version, pragma verification
- [ ] GitLab check: shows URL and authenticated username
- [ ] Projects check: shows configured count and resolved count
- [ ] Ollama check: warns if not running, doesn't fail overall
- [ ] success=true only if config, database, gitlab, projects all ok
- [ ] --json outputs valid JSON matching DoctorResult interface
- [ ] Exit 0 if success=true, exit 1 if any required check fails
- [ ] Colors and symbols in human output (✓, ⚠, ✗)

## Files

CREATE:
- src/cli/commands/doctor.ts
- src/types/doctor.ts (DoctorResult interface)

## TDD Loop

N/A - diagnostic command, verify with manual testing:

```bash
# All good
gi doctor

# JSON output
gi doctor --json | jq .

# With missing Ollama
# (just don't run Ollama - should show warning)

# With bad config
mv ~/.config/gi/config.json ~/.config/gi/config.json.bak
gi doctor  # should show config error
```

## Edge Cases

- Ollama timeout should be short (2s) - don't block on slow network
- Ollama 404 (wrong model) vs connection refused (not running)
- Database file exists but wrong schema version
- Projects in config but not in database (init not run)
- Token valid for user but project access revoked


Epic: TUI Phase 0 — Toolchain Gate

## Background
Phase 0 is the hard gate for the TUI implementation. It validates that FrankenTUI (nightly Rust) can coexist with the stable lore workspace, that core infrastructure types compile and pass basic tests, and that terminal compatibility meets the bar. If Phase 0 fails, we evaluate alternatives before proceeding.

## Acceptance Criteria
- [ ] crates/lore-tui/ scaffold exists with Cargo.toml, rust-toolchain.toml, main.rs, lib.rs
- [ ] cargo +stable check --workspace --all-targets passes for root workspace (lore-tui EXCLUDED)
- [ ] cargo +nightly check --manifest-path crates/lore-tui/Cargo.toml --all-targets passes
- [ ] FrankenTUI Model trait skeleton compiles and renders a hello-world frame
- [ ] DbManager, Clock, safety, and core type modules compile with tests
- [ ] Terminal compat smoke test passes in iTerm2 and tmux

## Scope
All Phase 0 tasks are blockers for Phase 1 (Foundation).


lore drift: detect discussion divergence from original intent

## Background
Detect when a discussion thread has evolved away from the original issue description. Surfaces hidden scope creep. No existing tool does this — not GitLab, Jira, Linear, or any CLI.

## Current Infrastructure (Verified 2026-02-12)
- Embeddings: nomic-embed-text model, 768 dimensions, stored in embedding_metadata + vec0 tables
- OllamaClient::embed_batch() at src/embedding/ollama.rs:103 — batch embedding
- notes table: 282K rows with body, author, created_at, is_system, discussion_id
- issues table: description column contains original intent text
- CHUNK_MAX_BYTES = 1500 bytes for embedding input
- No `strip_markdown()` utility exists in the codebase — must be written (see Edge Cases)

## Dependencies
This command is standalone. It only requires:
- OllamaClient (already shipped at src/embedding/ollama.rs) for embedding computation
- notes + discussions tables (already in DB since migration 001/004)
- issues table (already in DB since migration 002)

No dependency on hybrid search (bd-1ksf) or per-note search (bd-2l3s). Drift embeds on-the-fly.

## Algorithm

### Step 1: Embed issue description
```rust
let desc_text = issue.description.unwrap_or_default();
if desc_text.len() < 20 {
    // Too short for meaningful drift analysis
    return Ok(DriftResponse::no_drift("Description too short for analysis"));
}
let desc_embedding = client.embed_batch(&[&desc_text]).await?[0].clone();
```

### Step 2: Get non-system notes chronologically
```sql
SELECT n.id, n.body, n.author_username, n.created_at
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE d.noteable_type = 'Issue' AND d.noteable_id = ?
  AND n.is_system = 0
  AND LENGTH(n.body) >= 20
ORDER BY n.created_at ASC
LIMIT 200  -- cap for performance
```

### Step 3: Embed each note
```rust
let note_texts: Vec<&str> = notes.iter().map(|n| n.body.as_str()).collect();
// Batch in groups of 32 (BATCH_SIZE from embedding pipeline)
let note_embeddings = client.embed_batch(&note_texts).await?;
```

### Step 4: Compute cosine similarity curve
```rust
/// Cosine similarity between two embedding vectors.
/// Returns value in [-1, 1] range; higher = more similar.
/// Place in src/embedding/similarity.rs for reuse by related (bd-8con) and drift.
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
    debug_assert_eq!(a.len(), b.len(), "embedding dimensions must match");
    let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
    let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
    let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
    if norm_a == 0.0 || norm_b == 0.0 {
        return 0.0;  // zero vector = no similarity
    }
    dot / (norm_a * norm_b)
}

let similarity_curve: Vec<SimilarityPoint> = notes.iter().zip(&note_embeddings)
    .enumerate()
    .map(|(i, (note, emb))| SimilarityPoint {
        note_index: i,
        note_id: note.id,
        similarity: cosine_similarity(&desc_embedding, emb),
        author: note.author.clone(),
        created_at: note.created_at.clone(),
    })
    .collect();
```

### Step 5: Detect drift via sliding window
```rust
const DEFAULT_THRESHOLD: f32 = 0.4;
const WINDOW_SIZE: usize = 3;

fn detect_drift(curve: &[SimilarityPoint], threshold: f32) -> Option<&SimilarityPoint> {
    if curve.len() < WINDOW_SIZE {
        return None;  // need minimum 3 notes for window
    }
    for window in curve.windows(WINDOW_SIZE) {
        let avg: f32 = window.iter().map(|p| p.similarity).sum::<f32>() / WINDOW_SIZE as f32;
        if avg < threshold {
            return Some(&window[0]);  // first note in drifting window
        }
    }
    None
}
```

### Step 6: Extract drift topics (simple term frequency v1)
```rust
/// Simple markdown stripping for embedding quality.
/// Remove code blocks (```...```), inline code (`...`), links [text](url),
/// block quotes (> ...), and HTML tags (<...>).
/// This function must be written — no existing utility in the codebase.
fn strip_markdown(text: &str) -> String {
    // Phase 1: Remove fenced code blocks (```...```)
    let re_code_block = regex::Regex::new(r"(?s)```.*?```").unwrap();
    let text = re_code_block.replace_all(text, "");
    // Phase 2: Remove inline code (`...`)
    let re_inline = regex::Regex::new(r"`[^`]+`").unwrap();
    let text = re_inline.replace_all(&text, "");
    // Phase 3: Remove markdown links, keep text: [text](url) -> text
    let re_link = regex::Regex::new(r"\[([^\]]+)\]\([^)]+\)").unwrap();
    let text = re_link.replace_all(&text, "$1");
    // Phase 4: Remove block quotes
    let text = text.lines()
        .filter(|l| !l.trim_start().starts_with('>'))
        .collect::<Vec<_>>()
        .join("\n");
    // Phase 5: Remove HTML tags
    let re_html = regex::Regex::new(r"<[^>]+>").unwrap();
    re_html.replace_all(&text, "").to_string()
}

fn extract_drift_topics(
    notes_after_drift: &[Note],
    description_words: &HashSet<String>,
) -> Vec<String> {
    let stopwords: HashSet<&str> = [
        "the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
        "have", "has", "had", "do", "does", "did", "will", "would", "could",
        "should", "may", "might", "shall", "can", "need", "dare", "ought",
        "used", "to", "of", "in", "for", "on", "with", "at", "by", "from",
        "as", "into", "through", "during", "before", "after", "above", "below",
        "between", "out", "off", "over", "under", "again", "further", "then",
        "once", "here", "there", "when", "where", "why", "how", "all", "each",
        "every", "both", "few", "more", "most", "other", "some", "such", "no",
        "nor", "not", "only", "own", "same", "so", "than", "too", "very",
        "just", "because", "but", "and", "or", "if", "while", "that", "this",
        "these", "those", "it", "its", "they", "them", "their", "we", "our",
        "you", "your", "he", "she", "his", "her", "what", "which", "who",
    ].into_iter().collect();

    let mut term_freq: HashMap<String, usize> = HashMap::new();
    for note in notes_after_drift {
        let body = strip_markdown(&note.body);
        for word in body.split_whitespace() {
            let word = word.to_lowercase()
                .trim_matches(|c: char| !c.is_alphanumeric())
                .to_string();
            if word.len() >= 3
                && !stopwords.contains(word.as_str())
                && !description_words.contains(&word)
            {
                *term_freq.entry(word).or_default() += 1;
            }
        }
    }

    let mut ranked: Vec<_> = term_freq.into_iter().collect();
    ranked.sort_by(|a, b| b.1.cmp(&a.1));
    ranked.into_iter().take(3).map(|(word, _)| word).collect()
}
```

NOTE: The `regex` crate is likely already a dependency (check Cargo.toml). If not, add it. Consider compiling regexes once with `lazy_static!` or `std::sync::LazyLock` instead of in-function `Regex::new()`.

## Robot Mode Output Schema
```json
{
  "ok": true,
  "data": {
    "entity": { "type": "issue", "iid": 3864, "title": "..." },
    "drift_detected": true,
    "threshold": 0.4,
    "drift_point": {
      "note_index": 12,
      "note_id": 456,
      "author": "devname",
      "created_at": "2026-01-20T...",
      "similarity": 0.32
    },
    "drift_topics": ["ingestion", "maintenance", "lubrication"],
    "similarity_curve": [
      { "note_index": 0, "similarity": 0.91, "author": "...", "created_at": "..." },
      { "note_index": 1, "similarity": 0.85, "author": "...", "created_at": "..." }
    ],
    "recommendation": "Consider splitting: notes after #12 discuss ingestion, maintenance, lubrication -- topics not in original description"
  },
  "meta": { "elapsed_ms": 1500, "notes_analyzed": 25, "description_tokens": 150 }
}
```

## Clap Registration
```rust
// In src/main.rs Commands enum, add:
Drift {
    /// Entity type: "issues" (MRs not supported in v1)
    entity_type: String,
    /// Entity IID
    iid: i64,
    /// Similarity threshold for drift detection (0.0-1.0, default 0.4)
    #[arg(long, default_value = "0.4")]
    threshold: f32,
    /// Scope to project (fuzzy match)
    #[arg(short, long)]
    project: Option<String>,
},
```

## TDD Loop
RED: Tests in src/cli/commands/drift.rs:
- test_cosine_similarity_identical: same vector -> 1.0
- test_cosine_similarity_orthogonal: orthogonal vectors -> 0.0
- test_cosine_similarity_zero_vector: zero vector -> 0.0 (not NaN)
- test_drift_detected_when_notes_diverge: mock embeddings where first 5 notes are similar (>0.8) to desc, last 5 are dissimilar (<0.3), assert drift_detected=true
- test_no_drift_on_consistent_discussion: all notes similar to desc (>0.6), assert drift_detected=false
- test_drift_point_is_first_divergent: assert drift_point.note_index is the first note in the first sub-threshold window
- test_drift_topics_exclude_original_terms: terms from description body should NOT appear in drift_topics
- test_single_note: assert drift_detected=false (need min 3 notes)
- test_empty_description: assert response with "Description too short for analysis" message
- test_strip_markdown_code_blocks: verify fenced code blocks removed
- test_strip_markdown_preserves_text: verify plain text preserved

GREEN: Implement drift command with cosine_similarity + sliding window + topic extraction

VERIFY:
```bash
cargo test drift:: && cargo clippy --all-targets -- -D warnings
cargo run --release -- -J drift issues 3864 | jq '.data.drift_detected'
```

## Acceptance Criteria
- [ ] lore drift issues N computes similarity curve between description and notes
- [ ] Drift detected when sliding window of 3 notes averages below threshold
- [ ] Drift topics extracted from divergent notes (top 3 terms not in description)
- [ ] --threshold flag to adjust sensitivity (default 0.4)
- [ ] Robot mode returns structured analysis with similarity_curve array
- [ ] Human mode shows visual indication (similarity bar or sparkline per note)
- [ ] Suggests splitting when drift detected
- [ ] Performance: <2s for issue with 100 notes (mostly embedding time)
- [ ] Command registered in main.rs and robot-docs
- [ ] cosine_similarity function has its own unit tests
- [ ] strip_markdown function has its own unit tests

## Edge Cases
- Empty description: return early with message "Description too short for analysis"
- Single note: drift_detected = false, similarity_curve has 1 entry
- Very short notes (<20 chars): filtered out in SQL query
- All notes by same author: still valid analysis (self-drift is real)
- Notes that are mostly quotes/code blocks: strip_markdown before embedding (remove ``` blocks, > quotes)
- Issue with 500+ notes: SQL LIMIT 200 on notes, note in meta that analysis is partial
- Ollama unavailable: exit code 14 with message (drift requires embedding computation)
- No stored note embeddings: always embed on-the-fly (drift needs to compare against description, not stored embeddings)
- Embedding dimension mismatch: assert desc and note embeddings have same length (768 for nomic-embed-text)
- Regex compilation: use LazyLock or lazy_static to avoid recompiling regexes on every call

## Files to Create/Modify
- NEW: src/cli/commands/drift.rs (main command implementation)
- NEW: src/embedding/similarity.rs (cosine_similarity utility, reusable by bd-8con)
- src/embedding/mod.rs (export similarity module)
- src/cli/commands/mod.rs (add pub mod drift; re-export)
- src/main.rs (register Drift subcommand in Commands enum, add handle_drift fn)

Drift detection command implemented: cosine similarity curve, sliding window, topic extraction, human+robot output


Epic: TUI Phase 2 — Core Screens

## Background
Phase 2 implements the five core screens: Dashboard, Issue List, Issue Detail, MR List, and MR Detail. These screens cover the primary read workflows. Each screen has a state struct, view function, and action query bridge. The entity table and filter bar widgets are shared across list screens.

## Acceptance Criteria
- [ ] Dashboard renders project overview with stats, recent activity, sync status
- [ ] Issue List supports keyset pagination, filtering, sorting, and Quick Peek
- [ ] Issue Detail shows progressive hydration (metadata, discussions, cross-refs)
- [ ] MR List mirrors Issue List patterns with MR-specific columns
- [ ] MR Detail shows file changes, diff discussions, and general discussions
- [ ] All screens use TaskSupervisor for data loading with stale-result guards
- [ ] Navigation between screens preserves state

## Scope
Blocked by Phase 1 (Foundation). Blocks Phase 2.5 (Vertical Slice Gate).


[CP1] GitLab client pagination methods

Add async generator methods for paginated GitLab API calls.

Methods to add to src/gitlab/client.ts:
- paginateIssues(gitlabProjectId, updatedAfter?) → AsyncGenerator<GitLabIssue>
- paginateIssueDiscussions(gitlabProjectId, issueIid) → AsyncGenerator<GitLabDiscussion>
- requestWithHeaders<T>(path) → { data: T, headers: Headers }

Implementation:
- Use scope=all, state=all for issues
- Order by updated_at ASC
- Follow X-Next-Page header until empty/absent
- Apply cursor rewind (subtract cursorRewindSeconds) for tuple semantics
- Fall back to empty-page detection if headers missing

Files: src/gitlab/client.ts
Tests: tests/unit/pagination.test.ts
Done when: Pagination handles multiple pages and respects cursors


Implement discussion tree + cross-reference widgets

## Background
The discussion tree renders threaded conversations from GitLab issues/MRs using FrankenTUI's Tree widget. Cross-references show linked entities (closing MRs, related issues) as navigable links. Both are used in Issue Detail and MR Detail views.

## Approach
Discussion Tree (view/common/discussion_tree.rs):
- Wraps ftui Tree widget with TreePersistState for expand/collapse persistence
- Tree structure: top-level discussions as roots, notes within discussion as children
- Each node renders: author, timestamp (relative via Clock), note body (sanitized)
- System notes rendered with muted style
- Diff notes show file path + line reference
- Keyboard: j/k navigate, Enter expand/collapse, Space toggle thread
- Expand-on-demand: thread bodies loaded only when expanded (progressive hydration phase 3)

Cross-Reference (view/common/cross_ref.rs):
- CrossRefWidget: renders list of entity references with type icon and navigable links
- CrossRef struct: kind (ClosingMR, RelatedIssue, MentionedIn), entity_key (EntityKey), label (String)
- Enter on a cross-ref navigates to that entity (pushes nav stack)
- Renders as: "Closing MR !42: Fix authentication flow" with colored kind indicator

## Acceptance Criteria
- [ ] Discussion tree renders top-level discussions as expandable nodes
- [ ] Notes within discussion shown as children with indentation
- [ ] System notes visually distinguished (muted color)
- [ ] Diff notes show file path context
- [ ] Timestamps use injected Clock for deterministic rendering
- [ ] All note text sanitized via sanitize_for_terminal()
- [ ] Cross-references render with entity type icons
- [ ] Enter on cross-ref navigates to entity detail
- [ ] Tree state persists across navigation (expand/collapse remembered)

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

## TDD Anchor
RED: Write test_cross_ref_entity_key that creates a CrossRef with EntityKey::mr(1, 42), asserts kind and key are correct.
GREEN: Implement CrossRef struct.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_cross_ref

## Edge Cases
- Deeply nested discussions (rare in GitLab but possible): limit indent depth to 4 levels
- Very long note bodies: wrap text within tree node area
- Empty discussions (resolved with no notes): show "[resolved]" indicator
- Cross-references to entities not in local DB: show as non-navigable text

## Dependency Context
Uses sanitize_for_terminal() from "Implement terminal safety module" task.
Uses Clock for timestamps from "Implement Clock trait" task.
Uses EntityKey, Screen from "Implement core types" task.
Uses NavigationStack from "Implement NavigationStack" task.


Epic: TUI Phase 4 — Operations

## Background
Phase 4 adds operational screens: Sync (real-time progress + post-sync summary), Doctor/Stats (health checks), and CLI integration (lore tui command for binary delegation). The Sync screen is the most complex — it needs real-time streaming progress with backpressure handling.

## Acceptance Criteria
- [ ] Sync screen shows real-time progress during sync with per-lane indicators
- [ ] Sync summary shows exact changed entities after completion
- [ ] Doctor screen shows environment health checks
- [ ] Stats screen shows database statistics
- [ ] CLI integration: lore tui launches lore-tui binary via runtime delegation