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 the new params (from bd-13q8).

## Approach
Modify WhoArgs struct and run_who() (who.rs:276). 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:276):
1. Default --since: "6m" -> "24m" for expert mode
2. **Path canonicalization**: Call normalize_query_path() on raw path input at top of run_who(), before build_path_query(). Store both original and normalized for robot JSON.
3. Parse --as-of: RFC3339 or YYYY-MM-DD (append T23:59:59.999Z for end-of-day UTC) -> i64 millis. Default: now_ms()
4. Parse --all-history: set since_ms = 0
5. Thread as_of_ms, explain_score, include_bots through to query_expert()
6. Update the production query_expert() callsite (line ~311) from the default values bd-13q8 set to the actual parsed flag values

### Robot JSON resolved_input additions:
- scoring_model_version: 2
- path_input_original: raw user input
- path_input_normalized: after normalize_query_path()
- as_of_ms/as_of_iso
- window_start_iso/window_end_iso/window_end_exclusive: true
- since_mode: "all" | "24m" | user value
- excluded_usernames_applied: true|false

### Robot JSON per-expert (explain_score): score_raw + components object
### Human output (explain_score): parenthetical after score: `42 (author:28.5 review:10.0 notes:3.5)`

## TDD Loop

### RED (write these 8 tests first):
- test_explain_score_components_sum_to_total: components sum == score_raw within tolerance
- test_as_of_produces_deterministic_results: two runs with same as_of -> identical
- test_as_of_excludes_future_events: event after as_of excluded entirely
- test_as_of_exclusive_upper_bound: event at exactly as_of_ms excluded (strict <)
- test_since_relative_to_as_of_clock: since window from as_of, not wall clock
- test_explain_and_detail_are_mutually_exclusive: clap parse error
- test_excluded_usernames_filters_bots: renovate-bot filtered, jsmith present
- test_include_bots_flag_disables_filtering: both appear with --include-bots

### 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_excluded 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
- [ ] Robot JSON includes path_input_original AND path_input_normalized
- [ ] Robot JSON includes score_raw + components when --explain-score
- [ ] Human output appends component parenthetical when --explain-score

## Files
- MODIFY: src/cli/commands/who.rs (WhoArgs struct, run_who at line 276, robot/human output rendering)

## Edge Cases
- YYYY-MM-DD parsing: chrono NaiveDate then to end-of-day UTC (T23:59:59.999Z)
- 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
- Path normalization runs BEFORE path resolution

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


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 ~line 780-810) 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:641) to:
1. Call build_expert_sql() instead of the inline SQL
2. Bind 6 params: path, since_ms, project_id, as_of_ms, closed_mr_multiplier, reviewer_min_note_chars
3. Execute and iterate rows: (username, signal, mr_id, qty, ts, state_mult)
4. Accumulate into per-user UserAccum structs
5. Compute decayed scores with deterministic ordering
6. 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,
    limit: usize,
    scoring: &ScoringConfig,
    detail: bool,
    explain_score: bool,
    include_bots: bool,
) -> Result<ExpertResult>
```

### CRITICAL: Existing callsite updates
Changing the signature from 7 to 10 params breaks ALL existing callers. There are 17 callsites that must be updated:

**Production (1):**
- run_who() at line ~311: Updated by bd-11mg (CLI flags bead), not this bead. To keep code compiling between bd-13q8 and bd-11mg, update this callsite with default values: `query_expert(conn, path, project_id, since_ms, now_ms(), limit, scoring, detail, false, false)`

**Tests (16):**
Update ALL test callsites to the new 10-param signature. The new params use defaults that preserve current behavior:
- `as_of_ms` = `now_ms() + 1000` (slightly in future, ensures all test data is within window)
- `explain_score` = `false`
- `include_bots` = `false`

Lines to update (current line numbers):
2879, 3127, 3208, 3214, 3226, 3252, 3291, 3325, 3345, 3398, 3563, 3572, 3588, 3625, 3651, 3658

Pattern: replace `query_expert(&conn, path, None, 0, limit, &scoring, detail)` with `query_expert(&conn, path, None, 0, now_ms() + 1000, limit, &scoring, detail, false, false)`

### Per-user accumulator:
```rust
struct UserAccum {
    author_mrs: HashMap<i64, (i64, f64)>,            // mr_id -> (max_ts, state_mult)
    reviewer_participated: HashMap<i64, (i64, f64)>,   // mr_id -> (max_ts, state_mult)
    reviewer_assigned: HashMap<i64, (i64, f64)>,       // mr_id -> (max_ts, state_mult)
    notes_per_mr: HashMap<i64, (u32, i64, f64)>,       // mr_id -> (count, max_ts, state_mult)
    last_seen: i64,
    components: Option<[f64; 4]>,                       // when explain_score: [author, participated, assigned, notes]
}
```

**Key**: state_mult is f64 from SQL (computed in mr_activity CTE), NOT computed from mr_state string in Rust.

### Signal routing:
- `diffnote_author` / `file_author` -> author_mrs (max ts + state_mult per mr_id)
- `diffnote_reviewer` / `file_reviewer_participated` -> reviewer_participated
- `file_reviewer_assigned` -> reviewer_assigned (skip if mr_id already in reviewer_participated)
- `note_group` -> notes_per_mr (qty from SQL row, max ts + state_mult)

### Deterministic score computation:
Sort each HashMap entries into a Vec sorted by mr_id ASC, then sum:
```
raw_score =
    sum(author_weight * state_mult * decay(as_of_ms - ts, author_hl) for (mr, ts, sm) in author_mrs sorted)
  + sum(reviewer_weight * state_mult * decay(as_of_ms - ts, reviewer_hl) for ... sorted)
  + sum(reviewer_assignment_weight * state_mult * decay(as_of_ms - ts, reviewer_assignment_hl) for ... sorted)
  + sum(note_bonus * state_mult * log2(1 + count) * decay(as_of_ms - ts, note_hl) for ... sorted)
```

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

Add new struct:
```rust
pub struct ScoreComponents {
    pub author: f64,
    pub reviewer_participated: f64,
    pub reviewer_assigned: f64,
    pub notes: f64,
}
```

### Bot filtering:
Post-query: if !include_bots, filter out usernames in scoring.excluded_usernames (case-insensitive via .to_lowercase() comparison).

## TDD Loop

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

**Core decay integration:**
- test_expert_scores_decay_with_time: recent (10d) vs old (360d), recent scores ~24, old ~6
- test_expert_reviewer_decays_faster_than_author: same MR at 90d, author > reviewer
- test_reviewer_participated_vs_assigned_only: participated ~10*decay vs 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
- test_old_path_match_credits_expertise: query old path -> author appears
- test_closed_mr_multiplier: closed MR at 0.5x merged (state_mult from SQL)
- test_trivial_note_does_not_count_as_participation: 4-char LGTM -> assigned-only
- test_null_timestamp_fallback_to_created_at: merged with NULL merged_at
- test_row_order_independence: 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

All tests use insert_mr_at/insert_diffnote_at from bd-2yu5 for timestamp control, and call the NEW query_expert() with 10 params.

### GREEN: Wire build_expert_sql into query_expert, implement UserAccum + scoring loop, update all 17 existing callsites.
### VERIFY: cargo test -p lore -- test_expert_scores test_reviewer_participated test_note_diminishing

## Acceptance Criteria
- [ ] All 13 new tests pass green
- [ ] All 16 existing test callsites updated to 10-param signature
- [ ] Production caller (run_who at ~line 311) updated with default values
- [ ] Existing who tests pass unchanged (decay ~1.0 for now_ms() data)
- [ ] state_mult comes from SQL f64 column, NOT from string matching on mr_state
- [ ] 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
- [ ] cargo check --all-targets passes (no broken callers)

## Files
- MODIFY: src/cli/commands/who.rs (query_expert at line 641, Expert struct at line 141, all test callsites)

## 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()
- state_mult is always 1.0 or closed_mr_multiplier (from SQL) — no other values possible
- Production caller uses now_ms() as as_of_ms default until bd-11mg adds --as-of flag


[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)


Split commands.rs into commands/ module (registry + defs)

commands.rs is 807 lines. Split into crates/lore-tui/src/commands/mod.rs (re-exports), commands/registry.rs (CommandRegistry, lookup, status_hints, help_entries, palette_entries, build_registry), and commands/defs.rs (command definitions, KeyCombo, CommandDef struct). Keep public API identical via re-exports. All downstream imports should continue to work unchanged.

Split commands.rs into commands/ module (defs.rs + registry.rs + mod.rs)


[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


Add get_issue_by_iid and get_mr_by_iid to GitLabClient with wiremock tests

## Background
Surgical sync needs to fetch a single issue or MR by its project-scoped IID from GitLab REST API during the preflight phase. The existing `GitLabClient` has `paginate_issues` and `paginate_merge_requests` for bulk streaming, but no single-entity fetch by IID. The GitLab v4 API provides `/api/v4/projects/:id/issues/:iid` and `/api/v4/projects/:id/merge_requests/:iid` endpoints that return exactly one entity or 404.

These methods are used by the surgical preflight (bd-3sez) to validate that requested IIDs actually exist on GitLab before committing to the ingest phase. They must return the full `GitLabIssue` / `GitLabMergeRequest` structs (same as the paginated endpoints return) so they can be passed directly to `process_single_issue` / `process_single_mr`.

## Approach

### Step 1: Add `get_issue_by_iid` method (src/gitlab/client.rs)

Add after the existing `get_version` method (~line 112):

```rust
pub async fn get_issue_by_iid(
    &self,
    project_id: u64,
    iid: u64,
) -> Result<GitLabIssue> {
    self.request(&format!("/api/v4/projects/{project_id}/issues/{iid}"))
        .await
}
```

This reuses the existing `request()` method which already handles:
- Rate limiting (via `RateLimiter`)
- Retry on 429 (up to `MAX_RETRIES`)
- 404 → `LoreError::GitLabNotFound { resource }`
- 401 → `LoreError::GitLabAuthFailed`
- JSON deserialization into `GitLabIssue`

### Step 2: Add `get_mr_by_iid` method (src/gitlab/client.rs)

```rust
pub async fn get_mr_by_iid(
    &self,
    project_id: u64,
    iid: u64,
) -> Result<GitLabMergeRequest> {
    self.request(&format!("/api/v4/projects/{project_id}/merge_requests/{iid}"))
        .await
}
```

### Step 3: Add wiremock tests (src/gitlab/client_tests.rs or inline #[cfg(test)])

Four tests using the same wiremock pattern as `src/gitlab/graphql_tests.rs`:
1. `get_issue_by_iid_success` — mock 200 with full GitLabIssue JSON, verify deserialized fields
2. `get_issue_by_iid_not_found` — mock 404, verify `LoreError::GitLabNotFound`
3. `get_mr_by_iid_success` — mock 200 with full GitLabMergeRequest JSON, verify deserialized fields
4. `get_mr_by_iid_not_found` — mock 404, verify `LoreError::GitLabNotFound`

## Acceptance Criteria
- [ ] `GitLabClient::get_issue_by_iid(project_id, iid)` returns `Result<GitLabIssue>`
- [ ] `GitLabClient::get_mr_by_iid(project_id, iid)` returns `Result<GitLabMergeRequest>`
- [ ] 404 response maps to `LoreError::GitLabNotFound`
- [ ] 401 response maps to `LoreError::GitLabAuthFailed` (inherited from `handle_response`)
- [ ] Successful responses deserialize into the correct struct types
- [ ] All 4 wiremock tests pass
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files
- MODIFY: src/gitlab/client.rs (add two pub async methods)
- CREATE: src/gitlab/client_tests.rs (wiremock tests, referenced via `#[cfg(test)] #[path = "client_tests.rs"] mod tests;` at bottom of client.rs)

## TDD Anchor
RED: Write 4 wiremock tests in `src/gitlab/client_tests.rs`:

```rust
use super::*;
use crate::core::error::LoreError;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

#[tokio::test]
async fn get_issue_by_iid_success() {
    let server = MockServer::start().await;
    let issue_json = serde_json::json!({
        "id": 1001,
        "iid": 42,
        "project_id": 5,
        "title": "Fix login bug",
        "state": "opened",
        "created_at": "2026-01-15T10:00:00Z",
        "updated_at": "2026-02-01T14:30:00Z",
        "author": { "id": 1, "username": "dev1", "name": "Developer One", "avatar_url": null, "web_url": "https://gitlab.example.com/dev1" },
        "web_url": "https://gitlab.example.com/group/repo/-/issues/42",
        "labels": [],
        "milestone": null,
        "assignees": [],
        "closed_at": null,
        "closed_by": null,
        "description": "Login fails on mobile"
    });

    Mock::given(method("GET"))
        .and(path("/api/v4/projects/5/issues/42"))
        .and(header("PRIVATE-TOKEN", "test-token"))
        .respond_with(ResponseTemplate::new(200).set_body_json(&issue_json))
        .mount(&server)
        .await;

    let client = GitLabClient::new(&server.uri(), "test-token", Some(100.0));
    let issue = client.get_issue_by_iid(5, 42).await.unwrap();
    assert_eq!(issue.iid, 42);
    assert_eq!(issue.title, "Fix login bug");
}

#[tokio::test]
async fn get_issue_by_iid_not_found() {
    let server = MockServer::start().await;

    Mock::given(method("GET"))
        .and(path("/api/v4/projects/5/issues/999"))
        .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({"message": "404 Not Found"})))
        .mount(&server)
        .await;

    let client = GitLabClient::new(&server.uri(), "test-token", Some(100.0));
    let err = client.get_issue_by_iid(5, 999).await.unwrap_err();
    assert!(matches!(err, LoreError::GitLabNotFound { .. }));
}

#[tokio::test]
async fn get_mr_by_iid_success() {
    let server = MockServer::start().await;
    let mr_json = serde_json::json!({
        "id": 2001,
        "iid": 101,
        "project_id": 5,
        "title": "Add caching layer",
        "state": "merged",
        "created_at": "2026-01-20T09:00:00Z",
        "updated_at": "2026-02-10T16:00:00Z",
        "author": { "id": 2, "username": "dev2", "name": "Developer Two", "avatar_url": null, "web_url": "https://gitlab.example.com/dev2" },
        "web_url": "https://gitlab.example.com/group/repo/-/merge_requests/101",
        "source_branch": "feature/caching",
        "target_branch": "main",
        "draft": false,
        "merge_status": "can_be_merged",
        "labels": [],
        "milestone": null,
        "assignees": [],
        "reviewers": [],
        "merged_by": null,
        "merged_at": null,
        "closed_at": null,
        "closed_by": null,
        "description": "Adds Redis caching"
    });

    Mock::given(method("GET"))
        .and(path("/api/v4/projects/5/merge_requests/101"))
        .and(header("PRIVATE-TOKEN", "test-token"))
        .respond_with(ResponseTemplate::new(200).set_body_json(&mr_json))
        .mount(&server)
        .await;

    let client = GitLabClient::new(&server.uri(), "test-token", Some(100.0));
    let mr = client.get_mr_by_iid(5, 101).await.unwrap();
    assert_eq!(mr.iid, 101);
    assert_eq!(mr.title, "Add caching layer");
    assert_eq!(mr.source_branch, "feature/caching");
}

#[tokio::test]
async fn get_mr_by_iid_not_found() {
    let server = MockServer::start().await;

    Mock::given(method("GET"))
        .and(path("/api/v4/projects/5/merge_requests/999"))
        .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({"message": "404 Not Found"})))
        .mount(&server)
        .await;

    let client = GitLabClient::new(&server.uri(), "test-token", Some(100.0));
    let err = client.get_mr_by_iid(5, 999).await.unwrap_err();
    assert!(matches!(err, LoreError::GitLabNotFound { .. }));
}
```

GREEN: Add the two methods to `GitLabClient`.
VERIFY: `cargo test get_issue_by_iid && cargo test get_mr_by_iid`

## Edge Cases
- The `request()` method already handles 429 retries, so no extra retry logic is needed in the new methods.
- The GitLabIssue/GitLabMergeRequest fixture JSON must include all required (non-Option) fields. Check the struct definitions in `src/gitlab/types.rs` if deserialization fails — the test fixtures above include the minimum required fields based on the struct definitions.
- The `project_id` parameter is the GitLab-side numeric project ID (not the local SQLite row ID). The caller must resolve this from the local `projects` table's `gitlab_project_id` column.

## Dependency Context
This is a leaf/foundation bead with no upstream dependencies. Downstream bead bd-3sez (surgical.rs) calls these methods during preflight to fetch entities by IID before ingesting.


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


Add normalize_query_path() pure function for path canonicalization

## Background
Plan section 3a (iteration 6, feedback-6). User input paths like `./src//foo.rs` or whitespace-padded paths fail path resolution even when the file exists in the database. A syntactic normalization function runs before `build_path_query()` to reduce false negatives.

## Approach
Add `normalize_query_path()` as a private pure function in who.rs (near `half_life_decay()`):

```rust
fn normalize_query_path(input: &str) -> String {
    let trimmed = input.trim();
    let stripped = trimmed.strip_prefix("./").unwrap_or(trimmed);
    // Collapse repeated /
    let mut result = String::with_capacity(stripped.len());
    let mut prev_slash = false;
    for ch in stripped.chars() {
        if ch == '/' {
            if !prev_slash { result.push('/'); }
            prev_slash = true;
        } else {
            result.push(ch);
            prev_slash = false;
        }
    }
    result
}
```

Called once at top of `run_who()` before `build_path_query()`. Robot JSON `resolved_input` includes both `path_input_original` (raw) and `path_input_normalized` (after canonicalization).

Rules:
- Strip leading `./`
- Collapse repeated `/` (e.g., `src//foo.rs` -> `src/foo.rs`)
- Trim leading/trailing whitespace
- Preserve trailing `/` (signals explicit prefix intent)
- Purely syntactic — no filesystem or DB lookups

## TDD Loop

### RED (write first):
```rust
#[test]
fn test_path_normalization_handles_dot_and_double_slash() {
    assert_eq!(normalize_query_path("./src//foo.rs"), "src/foo.rs");
    assert_eq!(normalize_query_path("  src/bar.rs  "), "src/bar.rs");
    assert_eq!(normalize_query_path("src/foo.rs"), "src/foo.rs"); // unchanged
    assert_eq!(normalize_query_path(""), ""); // empty passthrough
}

#[test]
fn test_path_normalization_preserves_prefix_semantics() {
    assert_eq!(normalize_query_path("./src/dir/"), "src/dir/"); // trailing slash preserved
    assert_eq!(normalize_query_path("src/dir"), "src/dir"); // no trailing slash = file
}
```

### GREEN: Implement normalize_query_path (5-10 lines).
### VERIFY: `cargo test -p lore -- test_path_normalization`

## Acceptance Criteria
- [ ] test_path_normalization_handles_dot_and_double_slash passes
- [ ] test_path_normalization_preserves_prefix_semantics passes
- [ ] Function is private (`fn` not `pub fn`)
- [ ] No DB or filesystem dependency — pure string function
- [ ] Called in run_who() before build_path_query()
- [ ] Robot JSON resolved_input includes path_input_original and path_input_normalized

## Files
- src/cli/commands/who.rs (function near half_life_decay, call site in run_who)

## Edge Cases
- Empty string -> empty string
- Only whitespace -> empty string
- Multiple leading ./ ("././src") -> strip first "./" only per plan spec
- Trailing slash preserved for prefix intent

Completed: implemented normalize_query_path() with TDD, wired into Expert/Overlap branches, added path_input_original/normalized to WhoResolvedInput and robot JSON


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)


Audit and improve test coverage across ingestion module

During code reorganization, discovered that ingestion/issues.rs has only 4 tests covering passes_cursor_filter, while 10 production functions (~400 lines) are untested:

UNTESTED FUNCTIONS in ingestion/issues.rs:
- ingest_issues() - main async pipeline with cursor-based pagination, shutdown handling
- process_single_issue() - transforms GitLab issue, wraps in transaction
- process_issue_in_transaction() - DB upsert with ON CONFLICT, label/assignee/milestone association, dirty tracking
- upsert_label_tx() - label upsert with INSERT OR IGNORE + created count tracking
- link_issue_label_tx() - issue-label junction table insert
- upsert_milestone_tx() - milestone upsert with RETURNING id
- get_sync_cursor() - reads sync_cursors table for incremental sync
- update_sync_cursor() - writes sync cursor with tie-breaker ID
- get_issues_needing_discussion_sync() - identifies issues needing discussion refresh
- parse_timestamp() - RFC3339 parsing with error wrapping

LIKELY SIMILAR GAPS in sibling files:
- ingestion/merge_requests.rs (479 lines) - parallel structure to issues.rs
- ingestion/discussions.rs (469 lines prod code) - discussion upsert pipeline
- ingestion/mr_discussions.rs (738 lines prod before tests) - MR discussion pipeline
- ingestion/orchestrator.rs (1703 lines) - full pipeline orchestration

The ingestion module handles the most critical data path (GitLab API -> SQLite) yet relies primarily on integration-level orchestrator tests rather than unit tests for individual functions.

PRIORITY AREAS:
1. DB upsert logic with ON CONFLICT handling (data correctness)
2. Cursor-based pagination (incremental sync correctness)
3. Label/milestone/assignee association (relational integrity)
4. Dirty tracker marking after upserts (document pipeline triggering)
5. Discussion sync queue population (cascading sync correctness)
6. Error handling paths (invalid timestamps, missing data)

APPROACH: Use in-memory SQLite (create_connection(Path::new(":memory:")) + run_migrations) for unit tests. See existing patterns in core/db_tests.rs and documents/regenerator_tests.rs.


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:3551-3574) constructs a ScoringConfig with only the original 3 fields. After bd-2w1p adds 8 new fields, this test will not compile without ..Default::default().

## Approach
Find the test at who.rs:3551-3574. The flipped config construction at line 3567:
```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 at line 2451 — it calls ScoringConfig::default() which already works.

### Important: Scope boundary
This bead ONLY handles ScoringConfig struct literal changes. The query_expert() function signature change (7 params -> 10 params) happens in bd-13q8 (Layer 3), which is responsible for updating all test callsites at that time.

### Why existing assertions do not 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
- [ ] cargo check --all-targets clean

## Files
- MODIFY: src/cli/commands/who.rs (ScoringConfig literal at line 3567)

## TDD Loop
N/A — mechanical change, no new tests.
VERIFY: cargo check --all-targets && cargo test -p lore -- test_expert_scoring_weights_are_configurable

## Edge Cases
- Search for ALL ScoringConfig { ... } literals in test module — there may be more than the one at line 3567
- The default_scoring() helper at line 2451 uses ScoringConfig::default() — no change needed


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).

All Phase 0 children complete: scaffold (bd-3ddw), core types (bd-c9gk), clock (bd-2lg6), safety (bd-3ir1), DbManager (bd-2kop), theme (bd-5ofk), FrankenTUI integration (bd-2emv). 68 tests, quality gate green.