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


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


[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


Restructure expert SQL with CTE-based dual-path matching

## Background
The current query_expert() at who.rs:637-810 uses a 4-signal UNION ALL that only matches position_new_path and new_path, with flat COUNT-based scoring computed entirely in SQL. The new model needs dual-path matching, 5 signal types, state-aware timestamps, and returns per-signal rows for Rust-side decay computation (bd-13q8).

## Approach
**Important**: This bead builds the new SQL as a separate function WITHOUT modifying query_expert() yet. bd-13q8 wires it into query_expert(). This keeps this bead independently testable.

Add a new function:
```rust
/// Build the CTE-based expert scoring SQL for a given path query mode.
/// Returns (sql_string, params_count) where params are: ?1=path, ?2=since_ms, ?3=project_id, ?4=as_of_ms
fn build_expert_sql(path_op: &str, reviewer_min_note_chars: u32) -> String {
    // ... format the SQL with {path_op} and {reviewer_min_note_chars} and {state_aware_ts} inlined
}
```

### SQL structure (7 CTEs + final SELECT):
1. matched_notes_raw: UNION ALL on position_new_path + position_old_path
2. matched_notes: DISTINCT dedup by id
3. matched_file_changes_raw: UNION ALL on new_path + old_path
4. matched_file_changes: DISTINCT dedup by (merge_request_id, project_id)
5. reviewer_participation: substantive DiffNotes WHERE LENGTH(TRIM(body)) >= {reviewer_min_note_chars}
6. raw: 5 signals (diffnote_reviewer, diffnote_author, file_author, file_reviewer_participated, file_reviewer_assigned) with mr_state
7. aggregated: MR-level GROUP BY + note_group with COUNT

{state_aware_ts} = CASE WHEN m.state='merged' THEN COALESCE(m.merged_at, m.created_at) WHEN m.state='closed' THEN COALESCE(m.closed_at, m.created_at) ELSE COALESCE(m.updated_at, m.created_at) END

Returns rows: (username TEXT, signal TEXT, mr_id INTEGER, qty INTEGER, ts INTEGER, mr_state TEXT)

## TDD Loop

### RED (write first):
```rust
#[test]
fn test_expert_sql_returns_expected_signal_rows() {
    let conn = setup_test_db();
    insert_project(&conn, 1, "team/backend");
    // MR authored by alice, reviewed by bob (assigned), carol left DiffNote
    insert_mr(&conn, 1, 1, 100, "alice", "merged");
    insert_file_change(&conn, 1, 1, "src/app.rs", "modified");
    insert_reviewer(&conn, 1, "bob");
    insert_reviewer(&conn, 1, "carol");
    insert_discussion(&conn, 1, 1, Some(1), None, true, false);
    insert_diffnote(&conn, 1, 1, 1, "carol", "src/app.rs", "This needs error handling for the edge case");

    let sql = build_expert_sql("= ?1", 20);
    let mut stmt = conn.prepare(&sql).unwrap();
    let rows: Vec<(String, String, i64, i64, i64, String)> = stmt
        .query_map(
            rusqlite::params\!["src/app.rs", 0_i64, Option::<i64>::None, now_ms() + 1000],
            |row| Ok((
                row.get(0).unwrap(), row.get(1).unwrap(), row.get(2).unwrap(),
                row.get(3).unwrap(), row.get(4).unwrap(), row.get(5).unwrap(),
            ))
        ).unwrap().filter_map(|r| r.ok()).collect();

    // alice should appear as file_author
    assert\!(rows.iter().any(|(u, s, ..)| u == "alice" && s == "file_author"));
    // carol should appear as file_reviewer_participated (left substantive DiffNote)
    assert\!(rows.iter().any(|(u, s, ..)| u == "carol" && s == "file_reviewer_participated"));
    // bob should appear as file_reviewer_assigned (no DiffNotes)
    assert\!(rows.iter().any(|(u, s, ..)| u == "bob" && s == "file_reviewer_assigned"));
    // carol should appear as diffnote_reviewer with note_group
    assert\!(rows.iter().any(|(u, s, ..)| u == "carol" && s == "note_group"));
    // alice should appear as diffnote_author (MR author on DiffNote thread)
    assert\!(rows.iter().any(|(u, s, ..)| u == "alice" && s == "diffnote_author"));
    // All rows have mr_state = "merged"
    assert\!(rows.iter().all(|(.., state)| state == "merged"));
}
```

### GREEN: Implement build_expert_sql() with the 7 CTEs.
### VERIFY: `cargo test -p lore -- test_expert_sql_returns_expected_signal_rows`

## Acceptance Criteria
- [ ] test_expert_sql_returns_expected_signal_rows passes (all 5 signal types appear correctly)
- [ ] SQL compiles against :memory: DB with indexes from bd-2ao4
- [ ] 6 columns returned: username, signal, mr_id, qty, ts, mr_state
- [ ] reviewer_participation CTE correctly classifies carol as participated (>= 20 chars) and bob as assigned-only
- [ ] Existing query_expert() and all existing tests UNTOUCHED (still pass)
- [ ] build_expert_sql() is a pure function (no side effects, no Connection param)

## Files
- src/cli/commands/who.rs (new build_expert_sql function + test)

## Edge Cases
- {reviewer_min_note_chars} inlined as SQL literal (not bound param) to avoid param index shifting
- COALESCE fallback to created_at for NULL merged_at/closed_at
- Dedup in matched_notes/matched_file_changes prevents double-counting when old_path = new_path


Epic: Gate 5 - Code Trace (lore trace)

## Background

Gate 5 implements 'lore trace' — answers 'Why was this code introduced?' by tracing from a file path through the MR that modified it, to the issue that motivated the MR, to the discussions with decision rationale. Capstone of Phase B.

Gate 5 ships Tier 1 only (API-only, no local git). Tier 2 (git blame via git2-rs) deferred to Phase C.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Gate 5 (Sections 5.1-5.7).

## Prerequisites

- Gates 1-2 COMPLETE: entity_references populated, resource events fetched
- Gate 4 (bd-14q): provides mr_file_changes table + resolve_rename_chain algorithm
- entity_references source_method: 'api' | 'note_parse' | 'description_parse'
- discussions/notes tables for DiffNote content
- merge_requests.merged_at exists (migration 006). Use COALESCE(merged_at, updated_at) for ordering.

## Architecture

- **No new tables.** Trace queries combine mr_file_changes, entity_references, discussions/notes
- **Query flow:** file -> mr_file_changes -> MRs -> entity_references (closes/related) -> issues -> discussions with DiffNote context
- **Tier 1:** File-level granularity only. Cannot trace a specific line to its introducing commit.
- **Path parsing:** Supports 'src/foo.rs:45' syntax — line number parsed but deferred with Tier 2 warning.
- **Rename aware:** Reuses file_history::resolve_rename_chain for multi-path matching.

## Children (Execution Order)

1. **bd-2n4** — Trace query logic: file -> MR -> issue -> discussion chain (src/core/trace.rs)
2. **bd-9dd** — CLI command with human + robot output (src/cli/commands/trace.rs)

## Gate Completion Criteria

- [ ] `lore trace <file>` shows MRs with linked issues + discussion context
- [ ] Output includes MR -> issue -> discussion chain
- [ ] DiffNote snippets show content on the traced file
- [ ] Cross-references from entity_references used for MR->issue linking
- [ ] :line suffix parses and emits Tier 2 warning
- [ ] Robot mode JSON with tier: 'api_only'
- [ ] Graceful handling when no MR data found (suggest sync with fetchMrFileChanges)


Integrate mark_dirty_tx into ingestion modules

## Background
This bead integrates dirty source tracking into the existing ingestion pipelines. Every entity upserted during ingestion must be marked dirty so the document regenerator knows to update the corresponding search document. The critical constraint: mark_dirty_tx() must be called INSIDE the same transaction that upserts the entity — not after commit.

**Key PRD clarification:** Mark ALL upserted entities dirty (not just changed ones). The regenerator's hash comparison handles "unchanged" detection cheaply — this avoids needing change detection in ingestion.

## Approach
Modify 4 existing ingestion files to add mark_dirty_tx() calls inside existing transaction blocks per PRD Section 6.1.

**1. src/ingestion/issues.rs:**
Inside the issue upsert loop, after each successful INSERT/UPDATE:
```rust
dirty_tracker::mark_dirty_tx(&tx, SourceType::Issue, issue_row.id)?;
```

**2. src/ingestion/merge_requests.rs:**
Inside the MR upsert loop:
```rust
dirty_tracker::mark_dirty_tx(&tx, SourceType::MergeRequest, mr_row.id)?;
```

**3. src/ingestion/discussions.rs:**
Inside discussion insert (issue discussions, full-refresh transaction):
```rust
dirty_tracker::mark_dirty_tx(&tx, SourceType::Discussion, discussion_row.id)?;
```

**4. src/ingestion/mr_discussions.rs:**
Inside discussion upsert (write phase):
```rust
dirty_tracker::mark_dirty_tx(&tx, SourceType::Discussion, discussion_row.id)?;
```

**Discussion Sweep Cleanup (PRD Section 6.1 — CRITICAL):**
When the MR discussion sweep deletes stale discussions (`last_seen_at < run_start_time`), **delete the corresponding document rows directly** — do NOT use the dirty queue for cleanup. The `ON DELETE CASCADE` on `document_labels`/`document_paths` and the `documents_embeddings_ad` trigger handle all downstream cleanup.

**PRD-exact CTE pattern:**
```sql
-- In src/ingestion/mr_discussions.rs, during sweep phase.
-- Uses a CTE to capture stale IDs atomically before cascading deletes.
-- This is more defensive than two separate statements because the CTE
-- guarantees the ID set is captured before any row is deleted.
WITH stale AS (
  SELECT id FROM discussions
  WHERE merge_request_id = ? AND last_seen_at < ?
)
-- Step 1: delete orphaned documents (must happen while source_id still resolves)
DELETE FROM documents
  WHERE source_type = 'discussion' AND source_id IN (SELECT id FROM stale);
-- Step 2: delete the stale discussions themselves
DELETE FROM discussions
  WHERE id IN (SELECT id FROM stale);
```

**NOTE:** If SQLite version doesn't support CTE-based multi-statement, execute as two sequential statements capturing IDs in Rust first:
```rust
let stale_ids: Vec<i64> = conn.prepare(
    "SELECT id FROM discussions WHERE merge_request_id = ? AND last_seen_at < ?"
)?.query_map(params![mr_id, run_start], |r| r.get(0))?
  .collect::<Result<Vec<_>, _>>()?;

if !stale_ids.is_empty() {
    // Delete documents FIRST (while source_id still resolves)
    conn.execute(
        "DELETE FROM documents WHERE source_type = 'discussion' AND source_id IN (...)",
        ...
    )?;
    // Then delete the discussions
    conn.execute(
        "DELETE FROM discussions WHERE id IN (...)",
        ...
    )?;
}
```

**IMPORTANT difference from dirty queue pattern:** The sweep deletes documents DIRECTLY (not via dirty_sources queue). This is because the source entity is being deleted — there's nothing for the regenerator to regenerate from. The cascade handles FTS, labels, paths, and embeddings cleanup.

## Acceptance Criteria
- [ ] Every upserted issue is marked dirty inside the same transaction
- [ ] Every upserted MR is marked dirty inside the same transaction
- [ ] Every upserted discussion (issue + MR) is marked dirty inside the same transaction
- [ ] ALL upserted entities marked dirty (not just changed ones) — regenerator handles skip
- [ ] mark_dirty_tx called with &Transaction (not &Connection)
- [ ] mark_dirty_tx uses upsert with ON CONFLICT to reset backoff state (not INSERT OR IGNORE)
- [ ] Discussion sweep deletes documents DIRECTLY (not via dirty queue)
- [ ] Discussion sweep uses CTE (or Rust-side ID capture) to capture stale IDs before cascading deletes
- [ ] Documents deleted BEFORE discussions (while source_id still resolves)
- [ ] ON DELETE CASCADE handles document_labels, document_paths cleanup
- [ ] documents_embeddings_ad trigger handles embedding cleanup
- [ ] `cargo build` succeeds
- [ ] Existing ingestion tests still pass

## Files
- `src/ingestion/issues.rs` — add mark_dirty_tx calls in upsert loop
- `src/ingestion/merge_requests.rs` — add mark_dirty_tx calls in upsert loop
- `src/ingestion/discussions.rs` — add mark_dirty_tx calls in insert loop
- `src/ingestion/mr_discussions.rs` — add mark_dirty_tx calls + direct document deletion in sweep

## TDD Loop
RED: Existing tests should still pass (regression); new tests:
- `test_issue_upsert_marks_dirty` — after issue ingest, dirty_sources has entry
- `test_mr_upsert_marks_dirty` — after MR ingest, dirty_sources has entry
- `test_discussion_upsert_marks_dirty` — after discussion ingest, dirty_sources has entry
- `test_discussion_sweep_deletes_documents` — stale discussion documents deleted directly
- `test_sweep_cascade_cleans_labels_paths` — ON DELETE CASCADE works
GREEN: Add mark_dirty_tx calls in all 4 files, implement sweep with CTE
VERIFY: `cargo test ingestion && cargo build`

## Edge Cases
- Upsert that doesn't change data: still marks dirty (regenerator hash check handles skip)
- Transaction rollback: dirty mark also rolled back (atomic, inside same txn)
- Discussion sweep with zero stale IDs: CTE returns empty, no DELETE executed
- Large batch of upserts: each mark_dirty_tx is O(1) INSERT with ON CONFLICT
- Sweep deletes document before discussion: order matters for source_id resolution

Added mark_dirty_tx calls in issues.rs, merge_requests.rs, discussions.rs, mr_discussions.rs (2 paths)