Update orchestrator for MR ingestion

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


Epic: CP2 Gate D - Resumability Proof

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

## TDD Loop

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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


[CP0] CLI entry point with Commander.js

## Background

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

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

## Approach

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

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

const program = new Command();

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

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

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

program.parse();
```

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

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

## Acceptance Criteria

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

## Files

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

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

## TDD Loop

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

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

## Edge Cases

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


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

## Strategic Context

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

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

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

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

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

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

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

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


Display closing MRs in lore issues <id> output

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

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

## Approach

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

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

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

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

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

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

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

    Ok(mrs)
}
```

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

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

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

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

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

## TDD Loop

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

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

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

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

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

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

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

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

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

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


Implement Rust-side decay aggregation with reviewer split

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

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

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

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

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

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

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

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

## TDD Loop

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

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

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

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

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

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

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


[CP1] Database migration 002_issues.sql

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

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

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

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


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

## Background

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

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

## Prerequisites

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

## Architecture

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

## Children (Execution Order)

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

## Gate Completion Criteria

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


[CP1] Issue transformer with label extraction

Transform GitLab issue payloads to normalized database schema.

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

## Structs

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

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

## Functions

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

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

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


OBSERV: Record item counts as span fields in sync stages

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

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

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

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

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

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

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

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

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

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

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

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


OBSERV: Add LoggingConfig to Config struct

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

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

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

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

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

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

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

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

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

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

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

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

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

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


[CP1] gi sync-status enhancement

## Background

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

## Approach

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

### Handler Function

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

### Data to Display

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

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

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

### Output Format

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

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

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

### Queries

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

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

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

## Acceptance Criteria

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

## Files

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

## TDD Loop

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

GREEN: Implement handler with queries and formatting

VERIFY: `cargo test sync_status`

## Edge Cases

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

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


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


OBSERV: Print human-readable timing summary after interactive sync

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

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

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

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

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

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

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

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

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

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


Update existing tests for new ScoringConfig fields

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

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

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

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

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

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

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


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


lore drift: detect discussion divergence from original intent

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

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

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

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

## Algorithm

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


[CP1] GitLab client pagination methods

Add async generator methods for paginated GitLab API calls.

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

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

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


Wire resource event fetching into sync pipeline

## Background
After issue/MR primary ingestion and discussion fetch, changed entities need resource_events jobs enqueued and drained. This is the integration point that connects the queue (bd-tir), API client (bd-sqw), DB upserts (bd-1uc), and config flag (bd-2e8).

## Approach
Modify the sync pipeline to add two new phases after discussion sync:

**Phase 1 — Enqueue during ingestion:**
In src/ingestion/orchestrator.rs, after each entity upsert (issue or MR), call:
```rust
if config.sync.fetch_resource_events {
    enqueue_job(conn, project_id, "issue", iid, local_id, "resource_events", None)?;
}
// For MRs, also enqueue mr_closes_issues (always) and mr_diffs (when fetchMrFileChanges)
```

The "changed entity" detection uses the existing dirty tracker: if an entity was inserted or updated during this sync run, it gets enqueued. On --full sync, all entities are enqueued.

**Phase 2 — Drain dependent queue:**
Add a new drain step in src/cli/commands/sync.rs (or new src/core/drain.rs), called after discussion sync:
```rust
pub async fn drain_dependent_queue(
    conn: &Connection,
    client: &GitLabClient,
    config: &Config,
    progress: Option<ProgressCallback>,
) -> Result<DrainResult>
```

Flow:
1. reclaim_stale_locks(conn, config.sync.stale_lock_minutes)
2. Loop: claim_jobs(conn, "resource_events", batch_size=10)
3. For each job:
   a. Fetch 3 event types via client (fetch_issue_state_events etc.)
   b. Store via upsert functions (upsert_state_events etc.)
   c. complete_job(conn, job.id) on success
   d. fail_job(conn, job.id, error_msg) on failure
4. Report progress: "Fetching resource events... [N/M]"
5. Repeat until no more claimable jobs

**Progress reporting:**
Add new ProgressEvent variants:
```rust
ResourceEventsFetchStart { total: usize },
ResourceEventsFetchProgress { completed: usize, total: usize },
ResourceEventsFetchComplete { fetched: usize, failed: usize },
```

## Acceptance Criteria
- [ ] Full sync enqueues resource_events jobs for all issues and MRs
- [ ] Incremental sync only enqueues for entities changed since last sync
- [ ] --no-events prevents enqueueing resource_events jobs
- [ ] Drain step fetches all 3 event types per entity
- [ ] Successful fetches stored and job completed
- [ ] Failed fetches recorded with error, job retried on next sync
- [ ] Stale locks reclaimed at drain start
- [ ] Progress displayed: "Fetching resource events... [N/M]"
- [ ] Robot mode progress suppressed (quiet mode)

## Files
- src/ingestion/orchestrator.rs (add enqueue calls during upsert)
- src/cli/commands/sync.rs (add drain step after discussions)
- src/core/drain.rs (new, optional — or inline in sync.rs)

## TDD Loop
RED: tests/sync_pipeline_tests.rs (or extend existing):
- `test_sync_enqueues_resource_events_for_changed_entities` - mock sync, verify jobs enqueued
- `test_sync_no_events_flag_skips_enqueue` - verify no jobs when flag false
- `test_drain_completes_jobs_on_success` - mock API responses, verify jobs deleted
- `test_drain_fails_jobs_on_error` - mock API failure, verify job attempts incremented

Note: Full pipeline integration tests may need mock HTTP server. Start with unit tests on enqueue/drain logic using the real DB with mock API responses.

GREEN: Implement enqueue hooks + drain step

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

## Edge Cases
- Entity deleted between enqueue and drain: API returns 404, fail_job with "entity not found" (retry won't help but backoff caps it)
- Rate limiting during drain: GitLabRateLimited error should fail_job with retry (transient)
- Network error during drain: GitLabNetworkError should fail_job with retry
- Multiple sync runs competing: locked_at prevents double-processing; stale lock reclaim handles crashes
- Drain should have a max iterations guard to prevent infinite loop if jobs keep failing and being retried within the same run

Implemented: enqueue + drain resource events in orchestrator, wired counts through ingest→sync pipeline, added progress events, 4 new tests, all 209 tests pass


[CP1] Integration tests for discussion watermark

Integration tests verifying discussion sync watermark behavior.

## Tests (tests/discussion_watermark_tests.rs)

- skips_discussion_fetch_when_updated_at_unchanged
- fetches_discussions_when_updated_at_advanced
- updates_watermark_after_successful_discussion_sync
- does_not_update_watermark_on_discussion_sync_failure

## Test Scenario
1. Ingest issue with updated_at = T1
2. Verify discussions_synced_for_updated_at = T1
3. Re-run ingest with same issue (updated_at = T1)
4. Verify NO discussion API calls made (watermark prevents)
5. Simulate issue update (updated_at = T2)
6. Re-run ingest
7. Verify discussion API calls made for T2
8. Verify watermark updated to T2

## Why This Matters
Discussion API is expensive (1 call per issue). Watermark ensures
we only refetch when issue actually changed, even with cursor rewind.

Files: tests/discussion_watermark_tests.rs
Done when: Watermark correctly prevents redundant discussion refetch


[CP0] gi auth-test command

## Background

auth-test is a quick diagnostic command to verify GitLab connectivity. Used for troubleshooting and CI pipelines. Simpler than doctor because it only checks auth, not full system health.

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

## Approach

**src/cli/commands/auth-test.ts:**
```typescript
import { Command } from 'commander';
import { loadConfig } from '../../core/config';
import { GitLabClient } from '../../gitlab/client';
import { TokenNotSetError } from '../../core/errors';

export const authTestCommand = new Command('auth-test')
  .description('Verify GitLab authentication')
  .action(async (options, command) => {
    const globalOpts = command.optsWithGlobals();

    // 1. Load config
    const config = loadConfig(globalOpts.config);

    // 2. Get token from environment
    const token = process.env[config.gitlab.tokenEnvVar];
    if (!token) {
      throw new TokenNotSetError(config.gitlab.tokenEnvVar);
    }

    // 3. Create client and test auth
    const client = new GitLabClient({
      baseUrl: config.gitlab.baseUrl,
      token,
    });

    // 4. Get current user
    const user = await client.getCurrentUser();

    // 5. Output success
    console.log(`Authenticated as @${user.username} (${user.name})`);
    console.log(`GitLab: ${config.gitlab.baseUrl}`);
  });
```

**Output format:**
```
Authenticated as @johndoe (John Doe)
GitLab: https://gitlab.example.com
```

## Acceptance Criteria

- [ ] Loads config from default or --config path
- [ ] Gets token from configured env var (default GITLAB_TOKEN)
- [ ] Throws TokenNotSetError if env var not set
- [ ] Calls GET /api/v4/user to verify auth
- [ ] Prints username and display name on success
- [ ] Exit 0 on success
- [ ] Exit 1 on auth failure (GitLabAuthError)
- [ ] Exit 1 if config not found (ConfigNotFoundError)

## Files

CREATE:
- src/cli/commands/auth-test.ts

## TDD Loop

N/A - simple command, verify manually and with integration test in init.test.ts

```bash
# Manual verification
export GITLAB_TOKEN="valid-token"
gi auth-test

# With invalid token
export GITLAB_TOKEN="invalid"
gi auth-test  # should exit 1
```

## Edge Cases

- Config exists but token env var not set - clear error message
- Token exists but wrong scopes - GitLabAuthError (401)
- Network unreachable - GitLabNetworkError
- Token with extra whitespace - should trim


Implement status fetcher with adaptive paging and pagination guard

## Background
With the GraphQL client in place, we need a status-specific fetcher that paginates through all issues in a project, extracts status widgets via __typename matching, and handles edge cases like complexity errors and cursor stalls.

## Approach
All code goes in src/gitlab/graphql.rs alongside GraphqlClient. The fetcher uses the workItems(types:[ISSUE]) resolver (NOT project.issues which returns the old Issue type without status widgets). Widget matching uses __typename == "WorkItemWidgetStatus" for deterministic identification.

## Files
- src/gitlab/graphql.rs (add to existing file created by bd-2dlt)

## Implementation

Constants:
  ISSUE_STATUS_QUERY: GraphQL query string with $projectPath, $after, $first variables
  PAGE_SIZES: &[u32] = &[100, 50, 25, 10]

Private deserialization types:
  WorkItemsResponse { project: Option<ProjectNode> }
  ProjectNode { work_items: Option<WorkItemConnection> } (serde rename workItems)
  WorkItemConnection { nodes: Vec<WorkItemNode>, page_info: PageInfo } (serde rename pageInfo)
  WorkItemNode { iid: String, widgets: Vec<serde_json::Value> }
  PageInfo { end_cursor: Option<String>, has_next_page: bool } (serde renames)
  StatusWidget { status: Option<WorkItemStatus> }

Public types:
  UnsupportedReason enum: GraphqlEndpointMissing, AuthForbidden (Debug, Clone)
  FetchStatusResult struct:
    statuses: HashMap<i64, WorkItemStatus>
    all_fetched_iids: HashSet<i64>
    unsupported_reason: Option<UnsupportedReason>
    partial_error_count: usize
    first_partial_error: Option<String>

is_complexity_or_timeout_error(msg) -> bool: lowercase contains "complexity" or "timeout"

fetch_issue_statuses(client, project_path) -> Result<FetchStatusResult>:
  Pagination loop:
    1. Build variables with current page_size from PAGE_SIZES[page_size_idx]
    2. Call client.query() — match errors:
       - GitLabNotFound -> Ok(empty + GraphqlEndpointMissing) + warn
       - GitLabAuthFailed -> Ok(empty + AuthForbidden) + warn
       - Other with complexity/timeout msg -> reduce page_size_idx, continue (retry same cursor)
       - Other with smallest page size exhausted -> return Err
       - Other -> return Err
    3. Track partial errors from GraphqlQueryResult
    4. Parse response into WorkItemsResponse
    5. For each node: parse iid to i64, add to all_fetched_iids, check widgets for __typename == "WorkItemWidgetStatus", insert status into map
    6. Reset page_size_idx to 0 after successful page
    7. Pagination guard: if has_next_page but new cursor == old cursor or is None, warn + break
    8. Update cursor, continue loop

## Acceptance Criteria
- [ ] Paginates: 2-page mock returns all statuses + all IIDs
- [ ] No status widget: IID in all_fetched_iids but not in statuses
- [ ] Status widget with null status: IID in all_fetched_iids but not in statuses
- [ ] 404 -> Ok(empty, unsupported_reason: GraphqlEndpointMissing)
- [ ] 403 -> Ok(empty, unsupported_reason: AuthForbidden)
- [ ] Success -> unsupported_reason: None
- [ ] __typename != "WorkItemWidgetStatus" -> ignored, no error
- [ ] Cursor stall (same endCursor twice) -> aborts, returns partial result
- [ ] Complexity error at first=100 -> retries at 50, succeeds
- [ ] Timeout error -> reduces page size
- [ ] All page sizes fail -> returns Err
- [ ] After successful page, next page starts at first=100 again
- [ ] Partial-data pages -> partial_error_count incremented, first_partial_error captured

## TDD Loop
RED: test_fetch_statuses_pagination, test_fetch_statuses_no_status_widget, test_fetch_statuses_404_graceful, test_fetch_statuses_403_graceful, test_typename_matching_ignores_non_status_widgets, test_fetch_statuses_cursor_stall_aborts, test_fetch_statuses_complexity_error_reduces_page_size, test_fetch_statuses_timeout_error_reduces_page_size, test_fetch_statuses_smallest_page_still_fails, test_fetch_statuses_page_size_resets_after_success, test_fetch_statuses_unsupported_reason_none_on_success, test_fetch_statuses_partial_errors_tracked
  Adaptive tests: mock must inspect $first variable in request body to return different responses per page size
GREEN: Implement all types + fetch_issue_statuses function
VERIFY: cargo test fetch_statuses && cargo test typename

## Edge Cases
- GraphQL returns iid as String — parse to i64
- widgets is Vec<serde_json::Value> — match __typename field, then deserialize matching widgets
- let-chain syntax: if is_status_widget && let Ok(sw) = serde_json::from_value::<StatusWidget>(...)
- Pagination guard: new_cursor.is_none() || new_cursor == cursor
- Page size resets to 0 (index into PAGE_SIZES) after each successful page
- FetchStatusResult is NOT Clone — test fields individually


Add rename awareness to path resolution probes

## Background
The path resolution layer (build_path_query at who.rs:467-578 and suffix_probe at who.rs:594-625) only checks position_new_path and new_path. If a user queries an old filename (e.g., 'login.rs' after rename to 'auth.rs'), the probes return 'not found' and scoring never runs — even though the scoring SQL (bd-1hoq) now matches old_path.

## Approach

### build_path_query() changes (who.rs:467-535):

Probe 1 (exact_exists, ~line 476-497):
- Notes query: add OR position_old_path = ?1
- File changes query: add OR old_path = ?1

Probe 2 (prefix_exists, ~line 500-526):
- Notes query: add OR position_old_path LIKE ?1 ESCAPE '\\'
- File changes query: add OR old_path LIKE ?1 ESCAPE '\\'

Note: These probes use simple OR (not UNION ALL) since they only check existence (SELECT 1 ... LIMIT 1) — no risk of planner degradation on single-row probes.

### suffix_probe() changes (who.rs:594-625):

Add two UNION branches to the existing query (who.rs:599-611):
```sql
UNION
SELECT position_old_path AS full_path FROM notes
WHERE note_type = 'DiffNote' AND is_system = 0
  AND position_old_path IS NOT NULL
  AND (position_old_path LIKE ?1 ESCAPE '\\' OR position_old_path = ?2)
  AND (?3 IS NULL OR project_id = ?3)
UNION
SELECT old_path AS full_path FROM mr_file_changes
WHERE old_path IS NOT NULL
  AND (old_path LIKE ?1 ESCAPE '\\' OR old_path = ?2)
  AND (?3 IS NULL OR project_id = ?3)
```

Use UNION (not UNION ALL) — the existing query uses UNION for dedup.

### Current SuffixResult enum (who.rs:581-590):
No changes needed.

## TDD Loop

### RED (write first):
```rust
#[test]
fn test_old_path_probe_exact_and_prefix() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "alice", "merged");
    // File was renamed: old_path=src/old/foo.rs -> new_path=src/new/foo.rs
    insert_file_change_with_old_path(&conn, 1, 1, "src/new/foo.rs", Some("src/old/foo.rs"), "renamed");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_diffnote_at(&conn, 1, 1, 1, "alice", "src/new/foo.rs", Some("src/old/foo.rs"), "review comment here", now_ms());

    // Exact probe by OLD path should resolve
    let pq = build_path_query(&conn, "src/old/foo.rs", None).unwrap();
    assert\!(matches\!(pq, PathQuery::Exact { .. } | PathQuery::Prefix { .. }));

    // Prefix probe by OLD directory should resolve
    let pq = build_path_query(&conn, "src/old/", None).unwrap();
    assert\!(matches\!(pq, PathQuery::Prefix { .. }));

    // New path still works
    let pq = build_path_query(&conn, "src/new/foo.rs", None).unwrap();
    assert\!(matches\!(pq, PathQuery::Exact { .. }));
}

#[test]
fn test_suffix_probe_uses_old_path_sources() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    insert_mr(&conn, 1, 1, 100, "alice", "merged");
    insert_file_change_with_old_path(&conn, 1, 1, "src/utils.rs", Some("legacy/utils.rs"), "renamed");

    // Suffix probe for "utils.rs" should find both old and new paths
    let result = suffix_probe(&conn, "utils.rs", None).unwrap();
    match result {
        SuffixResult::Ambiguous(paths) => {
            assert\!(paths.contains(&"src/utils.rs".to_string()));
            assert\!(paths.contains(&"legacy/utils.rs".to_string()));
        }
        SuffixResult::Unique(p) => {
            // If deduped to one, either is acceptable
            assert\!(p == "src/utils.rs" || p == "legacy/utils.rs");
        }
        other => panic\!("Expected Ambiguous or Unique, got {other:?}"),
    }

    // Suffix probe for "legacy/utils.rs" should resolve uniquely via old_path
    let result = suffix_probe(&conn, "legacy/utils.rs", None).unwrap();
    assert\!(matches\!(result, SuffixResult::Unique(ref p) if p == "legacy/utils.rs"));
}
```

### GREEN: Add OR old_path clauses to probes + UNION branches to suffix_probe.
### VERIFY: `cargo test -p lore -- test_old_path_probe test_suffix_probe_uses_old_path`

## Acceptance Criteria
- [ ] test_old_path_probe_exact_and_prefix passes (exact + prefix resolution via old_path)
- [ ] test_suffix_probe_uses_old_path_sources passes (old_path appears in suffix candidates)
- [ ] Existing path probe tests still pass (NULL old_path never matches, no false positives)
- [ ] No changes to PathQuery or SuffixResult enums

## Files
- src/cli/commands/who.rs (lines 467-535: build_path_query probes, lines 594-625: suffix_probe)

## Edge Cases
- position_old_path can be NULL — OR clause handles naturally (NULL \!= ?1)
- Old path might match multiple new paths (copy+rename) — suffix_probe Ambiguous handles this
- Need insert_file_change_with_old_path helper from bd-2yu5


[CP1] Ingestion orchestrator

Coordinate issue + dependent discussion sync with bounded concurrency.

## Module
src/ingestion/orchestrator.rs

## Canonical Pattern (CP1)

When gi ingest --type=issues runs:

1. **Ingest issues** - cursor-based with incremental cursor updates per page
2. **Collect touched issues** - record IssueForDiscussionSync for each issue passing cursor filter
3. **Filter for discussion sync** - enqueue issues where:
   issue.updated_at > issues.discussions_synced_for_updated_at
4. **Execute discussion sync** - with bounded concurrency (dependent_concurrency from config)
5. **Update watermark** - after each issue's discussions successfully ingested

## Concurrency Notes

Runtime decision: Use single-threaded Tokio runtime (flavor = "current_thread")
- rusqlite::Connection is !Send, conflicts with multi-threaded runtimes
- Single-threaded avoids Send bounds entirely
- Use tokio::task::spawn_local + LocalSet for concurrent discussion fetches
- Keeps code simple; can upgrade to channel-based DB writer in CP2 if needed

## Configuration Used
- config.sync.dependent_concurrency - limits parallel discussion requests
- config.sync.cursor_rewind_seconds - safety margin for cursor

## Progress Reporting
- Show total issues fetched
- Show issues needing discussion sync
- Show discussion/note counts per project

Files: src/ingestion/orchestrator.rs
Tests: Integration tests with mocked GitLab
Done when: Full issue + discussion ingestion orchestrated correctly