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


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


[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 adds file-level decision history — "which MRs touched this file, and why?" This bridges the gap between code and project management by linking file paths to MRs, MRs to issues, and issues to discussions. The key innovation is rename chain resolution: when a file was renamed from src/auth/handler.rs to src/auth/oauth.rs, querying either path finds all historical MRs.

Gate 4 also captures merge_commit_sha and squash_commit_sha on merge_requests, which Gate 5 uses for code tracing and which Phase C will use for git blame integration.

## Architecture
- **New table:** mr_file_changes (migration 012) — file paths + change types per MR
- **New columns:** merge_requests.merge_commit_sha, merge_requests.squash_commit_sha (migration 012)
- **Opt-in config:** sync.fetchMrFileChanges (default true). --no-file-changes CLI flag.
- **Data source:** GET /projects/:id/merge_requests/:iid/diffs — extract file metadata only, discard diff content.
- **Queue integration:** Uses dependent fetch queue with job_type=mr_diffs
- **Rename chain resolution:** BFS over mr_file_changes WHERE change_type=renamed, bounded at 10 hops with cycle detection.

## Children (Execution Order)
1. **bd-1oo** [OPEN] — Migration 012: mr_file_changes + merge_commit_sha + squash_commit_sha
2. **bd-jec** [OPEN] — fetchMrFileChanges config flag + --no-file-changes CLI flag
3. **bd-2yo** [OPEN] — Fetch MR diffs API, populate mr_file_changes, capture commit SHAs
4. **bd-1yx** [OPEN] — Rename chain resolution algorithm (src/core/file_history.rs)
5. **bd-z94** [OPEN] — lore file-history command with human + robot output

## Gate Completion Criteria
- [ ] mr_file_changes populated from GitLab diffs API for synced MRs
- [ ] merge_commit_sha and squash_commit_sha captured in merge_requests
- [ ] `lore file-history <path>` returns MRs ordered by merge/creation date
- [ ] Output includes MR title, state, author, change type, discussion count
- [ ] --discussions shows DiffNote snippets on the queried file
- [ ] Rename chains resolved with bounded hop count (default 10) + cycle detection
- [ ] --no-follow-renames disables chain resolution
- [ ] Robot JSON includes rename_chain when renames detected
- [ ] -p required when path exists in multiple projects (Ambiguous error, exit 18)
- [ ] Graceful empty state: "No MR data found. Run lore sync with fetchMrFileChanges: true"

## Dependencies
- Depends on: Gate 1 (bd-2zl) for dependent fetch queue, Gate 2 (bd-1se) for entity_references (MR→issue linking)
- Downstream: Gate 5 (bd-1ht) depends on mr_file_changes and commit SHAs


[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


[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


[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


Epic: Gate 5 - Code Trace (lore trace)

## Background
Gate 5 implements "lore trace" — the command that 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 that contain the decision rationale. This is the capstone of Phase B, combining data from all previous gates.

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

## Architecture
- **No new tables.** Trace queries combine mr_file_changes (Gate 4), entity_references (Gate 2), and discussions/notes (existing).
- **Query flow:** file → mr_file_changes → MRs → entity_references (closes/related) → issues → discussions with DiffNote context
- **Tier 1 limitation:** 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 warning about Tier 2.
- **Rename aware:** Reuses file_history::resolve_rename_chain for multi-path matching.

## Children (Execution Order)
1. **bd-2n4** [OPEN] — Trace query logic: file → MR → issue → discussion chain (src/core/trace.rs)
2. **bd-9dd** [OPEN] — CLI command with human + robot output (src/cli/commands/trace.rs)

## Gate Completion Criteria
- [ ] `lore trace <file>` shows MRs that touched the file with linked issues + discussion context
- [ ] Output includes MR → issue → discussion chain
- [ ] DiffNote snippets show content positioned on the traced file
- [ ] Cross-references from entity_references used for MR→issue linking
- [ ] Robot JSON output with trace_chains array and meta.tier = "api_only"
- [ ] :line suffix parsed with Tier 2 warning message
- [ ] -p flag for project scoping
- [ ] --no-follow-renames disables rename chain
- [ ] Graceful empty state: "No MR data found. Run lore sync with fetchMrFileChanges: true"

## Dependencies
- Depends on: Gate 2 (bd-1se) for entity_references, Gate 4 (bd-14q) for mr_file_changes + commit SHAs
- Downstream: None — Gate 5 is the terminal gate of Phase B


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)


Integration test: full Phase B sync pipeline

## Background
After all Gate 1-2 components are built, we need an integration test proving the full pipeline works end-to-end: sync → enqueue dependent fetches → drain queue → extract refs from state events → parse system notes for refs. Without this, individual unit tests pass but the pipeline may not wire together correctly.

## Approach
Create tests/phase_b_integration.rs with a comprehensive test suite:

```rust
#[tokio::test]
async fn test_phase_b_sync_pipeline_integration() {
    // 1. Create test DB with migrations 001-012
    // 2. Seed: project, issues, MRs, discussions with system notes
    // 3. Seed: resource_state_events with source_merge_request_id
    // 4. Seed: dependent_fetch_queue entries (state_events, label_events)
    // 5. Run drain_dependent_queue (mocked HTTP → fixture JSON)
    // 6. Run extract_refs_from_state_events
    // 7. Run extract_refs_from_system_notes
    // 8. Assert: entity_references populated with correct source/target/type/method
    // 9. Assert: no duplicate refs (INSERT OR IGNORE worked)
    // 10. Assert: unresolved cross-project refs stored correctly
}
```

Use wiremock or a trait-based HTTP mock for GitLab API responses. Fixture files in tests/fixtures/phase_b/.

## Acceptance Criteria
- [ ] Test creates DB, runs all migrations through 012
- [ ] Test seeds realistic data (issues, MRs, state events, system notes)
- [ ] Test runs the full pipeline in correct order
- [ ] Test verifies entity_references from all 3 sources: closes_issues API, state events, system notes
- [ ] Test verifies deduplication across sources
- [ ] Test verifies unresolved cross-project references
- [ ] Test passes with `cargo test phase_b_integration -- --nocapture`

## Files
- tests/phase_b_integration.rs (new)
- tests/fixtures/phase_b/state_events.json (new)
- tests/fixtures/phase_b/label_events.json (new)
- tests/fixtures/phase_b/system_notes.json (new)

## TDD Loop
RED: tests/phase_b_integration.rs:
- `test_full_pipeline_produces_entity_references` - seeds all data, runs full pipeline, asserts entity_references populated from state events + system notes + closes_issues API
- `test_pipeline_deduplication_across_sources` - same ref discovered by API and system note → single row in entity_references
- `test_pipeline_unresolved_cross_project_refs` - system note mentioning external project → entity_references row with target_entity_id=NULL and target_iid populated
- `test_pipeline_empty_queue_succeeds` - no queue entries → pipeline completes with 0 refs, no error
- `test_pipeline_migrations_001_through_012` - verify all migrations apply cleanly in sequence on fresh DB

Setup: create_test_db helper applying all migrations, seed_phase_b_fixtures() populating issues, MRs, discussions, notes (including system notes with "closed by !123" patterns), resource_state_events with source_merge_request fields.

GREEN: Wire pipeline calls in correct order, create fixture JSON files

VERIFY: `cargo test phase_b_integration -- --nocapture`

## Edge Cases
- Empty queue: pipeline completes successfully with 0 refs
- All refs duplicate: INSERT OR IGNORE produces 0 new inserts
- Mixed sources: same ref discovered by API + system note → single entry
- Migration failure: test should fail clearly if migrations don't apply cleanly


Implement pending discussion queue

## Background
The pending discussion queue tracks discussions that need to be fetched from GitLab. When an issue or MR is updated, its discussions may need re-fetching. This queue is separate from dirty_sources (which tracks entities needing document regeneration) — it tracks entities needing API calls to GitLab. The queue uses the same backoff pattern as dirty_sources for consistency.

## Approach
Create `src/ingestion/discussion_queue.rs`:

```rust
use crate::core::backoff::compute_next_attempt_at;

/// Noteable type for discussion queue.
#[derive(Debug, Clone, Copy)]
pub enum NoteableType {
    Issue,
    MergeRequest,
}

impl NoteableType {
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Issue => "Issue",
            Self::MergeRequest => "MergeRequest",
        }
    }
}

pub struct PendingFetch {
    pub project_id: i64,
    pub noteable_type: NoteableType,
    pub noteable_iid: i64,
    pub attempt_count: i32,
}

/// Queue a discussion fetch. ON CONFLICT DO UPDATE resets backoff (consistent with dirty_sources).
pub fn queue_discussion_fetch(
    conn: &Connection,
    project_id: i64,
    noteable_type: NoteableType,
    noteable_iid: i64,
) -> Result<()>;

/// Get next batch of pending fetches (WHERE next_attempt_at IS NULL OR <= now).
pub fn get_pending_fetches(conn: &Connection, limit: usize) -> Result<Vec<PendingFetch>>;

/// Mark fetch complete (remove from queue).
pub fn complete_fetch(
    conn: &Connection,
    project_id: i64,
    noteable_type: NoteableType,
    noteable_iid: i64,
) -> Result<()>;

/// Record fetch error with backoff.
pub fn record_fetch_error(
    conn: &Connection,
    project_id: i64,
    noteable_type: NoteableType,
    noteable_iid: i64,
    error: &str,
) -> Result<()>;
```

## Acceptance Criteria
- [ ] queue_discussion_fetch uses ON CONFLICT DO UPDATE (consistent with dirty_sources pattern)
- [ ] Re-queuing resets: attempt_count=0, next_attempt_at=NULL, last_error=NULL
- [ ] get_pending_fetches respects next_attempt_at backoff
- [ ] get_pending_fetches returns entries ordered by queued_at ASC
- [ ] complete_fetch removes entry from queue
- [ ] record_fetch_error increments attempt_count, computes next_attempt_at via shared backoff
- [ ] NoteableType.as_str() returns "Issue" or "MergeRequest" (matches DB CHECK constraint)
- [ ] `cargo test discussion_queue` passes

## Files
- `src/ingestion/discussion_queue.rs` — new file
- `src/ingestion/mod.rs` — add `pub mod discussion_queue;`

## TDD Loop
RED: Tests in `#[cfg(test)] mod tests`:
- `test_queue_and_get` — queue entry, get returns it
- `test_requeue_resets_backoff` — queue, error, re-queue -> attempt_count=0
- `test_backoff_respected` — entry with future next_attempt_at not returned
- `test_complete_removes` — complete_fetch removes entry
- `test_error_increments_attempts` — error -> attempt_count=1, next_attempt_at set
GREEN: Implement all functions
VERIFY: `cargo test discussion_queue`

## Edge Cases
- Queue same (project_id, noteable_type, noteable_iid) twice: ON CONFLICT resets state
- NoteableType must match DB CHECK constraint exactly ("Issue", "MergeRequest" — capitalized)
- Empty queue: get_pending_fetches returns empty Vec

Implemented discussion_queue with queue/get/complete/record_error + 6 tests


Parse system notes for cross-reference patterns

## Background
System notes contain cross-reference patterns like 'mentioned in !{iid}', 'closed by !{iid}', etc. This is best-effort, English-only extraction that supplements the structured API data from bd-3ia and bd-8t4. Runs as a local post-processing step (no API calls).

## Approach
Create src/core/note_parser.rs:

```rust
use regex::Regex;
use lazy_static::lazy_static;

/// A parsed cross-reference from a system note.
pub struct ParsedCrossRef {
    pub reference_type: String,    // "mentioned" | "closes"
    pub target_entity_type: String, // "issue" | "merge_request"
    pub target_iid: i64,
    pub target_project_path: Option<String>,  // None = same project
}

lazy_static! {
    static ref MENTIONED_RE: Regex = Regex::new(
        r"mentioned in (?:(?P<project>[\w\-]+/[\w\-]+))?(?P<sigil>[#!])(?P<iid>\d+)"
    ).unwrap();
    static ref CLOSED_BY_RE: Regex = Regex::new(
        r"closed by (?:(?P<project>[\w\-]+/[\w\-]+))?(?P<sigil>[#!])(?P<iid>\d+)"
    ).unwrap();
}

/// Parse a system note body for cross-references.
pub fn parse_cross_refs(body: &str) -> Vec<ParsedCrossRef>

/// Extract cross-references from all system notes and insert into entity_references.
/// Queries notes WHERE is_system = 1, parses body text, resolves to entity_references.
pub fn extract_refs_from_system_notes(
    conn: &Connection,
    project_id: i64,
) -> Result<ExtractResult>

pub struct ExtractResult {
    pub inserted: usize,
    pub skipped_unresolvable: usize,
    pub parse_failures: usize,   // logged at debug level
}
```

Sigil mapping: `#` = issue, `!` = merge_request

Resolution logic:
1. If target_project_path is None (same project): look up entity by iid in local DB → set target_entity_id
2. If target_project_path is Some: check if project is synced locally
   - If yes: resolve to local entity id
   - If no: store as unresolved (target_entity_id=NULL, target_project_path=path, target_entity_iid=iid)

Insert with source_method='system_note_parse', INSERT OR IGNORE for dedup.

Call after drain_dependent_queue and extract_refs_from_state_events in the sync pipeline.

## Acceptance Criteria
- [ ] 'mentioned in !123' → mentioned ref, target=MR iid 123
- [ ] 'mentioned in #456' → mentioned ref, target=issue iid 456
- [ ] 'mentioned in group/project!789' → cross-project mentioned ref
- [ ] 'closed by !123' → closes ref
- [ ] Cross-project refs stored as unresolved when target project not synced
- [ ] source_method = 'system_note_parse'
- [ ] Parse failures logged at debug level (not errors)
- [ ] Idempotent (INSERT OR IGNORE)
- [ ] Only processes is_system=1 notes

## Files
- src/core/note_parser.rs (new)
- src/core/mod.rs (add `pub mod note_parser;`)
- src/cli/commands/sync.rs (call after other ref extraction steps)

## TDD Loop
RED: tests/note_parser_tests.rs:
- `test_parse_mentioned_in_mr` - "mentioned in !567" → ParsedCrossRef { mentioned, merge_request, 567 }
- `test_parse_mentioned_in_issue` - "mentioned in #234" → ParsedCrossRef { mentioned, issue, 234 }
- `test_parse_mentioned_cross_project` - "mentioned in group/repo!789" → with project path
- `test_parse_closed_by_mr` - "closed by !567" → ParsedCrossRef { closes, merge_request, 567 }
- `test_parse_multiple_refs` - note with two mentions → two refs
- `test_parse_no_refs` - "Updated the description" → empty vec
- `test_extract_refs_from_system_notes_integration` - seed DB with system notes, verify entity_references created

GREEN: Implement regex patterns and extraction logic

VERIFY: `cargo test note_parser -- --nocapture`

## Edge Cases
- Non-English GitLab instances: "ajouté l'étiquette ~bug" won't match — this is accepted limitation, logged at debug
- Multi-level group paths: "mentioned in top/sub/project#123" — regex needs to handle arbitrary depth ([\w\-]+(?:/[\w\-]+)+)
- Note body may contain markdown links that look like refs: "[#123](url)" — the regex should handle this correctly since the prefix "mentioned in" is required
- Same ref mentioned multiple times in same note — dedup via INSERT OR IGNORE
- Note may reference itself (e.g., system note on issue #123 says "mentioned in #123") — technically valid, store it


Implement FTS5 search function and query sanitization

## Background
FTS5 search is the core lexical retrieval engine. It wraps SQLite's FTS5 with safe query parsing that prevents user input from causing SQL syntax errors, while preserving useful features like prefix search for type-ahead. The search function returns ranked results with BM25 scores and contextual snippets. This module is the Gate A search backbone and also provides fallback search when Ollama is unavailable in Gate B.

## Approach
Create `src/search/` module with `mod.rs` and `fts.rs` per PRD Section 3.1-3.2.

**src/search/mod.rs:**
```rust
mod fts;
mod filters;
// Later beads add: mod vector; mod hybrid; mod rrf;
pub use fts::{search_fts, to_fts_query, FtsResult, FtsQueryMode, generate_fallback_snippet, get_result_snippet};
```

**src/search/fts.rs — key functions:**

1. `to_fts_query(raw: &str, mode: FtsQueryMode) -> String`
   - Safe mode: wrap each token in quotes, escape internal quotes, preserve trailing * on alphanumeric tokens
   - Raw mode: pass through unchanged

2. `search_fts(conn: &Connection, query: &str, limit: usize, mode: FtsQueryMode) -> Result<Vec<FtsResult>>`
   - Uses `bm25(documents_fts)` for ranking
   - Uses `snippet(documents_fts, 1, '<mark>', '</mark>', '...', 64)` for context
   - Column index 1 = content_text (0=title)

3. `generate_fallback_snippet(content_text: &str, max_chars: usize) -> String`
   - For semantic-only results without FTS snippets
   - Uses `truncate_utf8()` for safe byte boundaries

4. `truncate_utf8(s: &str, max_bytes: usize) -> &str`
   - Walks backward from max_bytes to find nearest char boundary

5. `get_result_snippet(fts_snippet: Option<&str>, content_text: &str) -> String`
   - Prefers FTS snippet, falls back to truncated content

Update `src/lib.rs`: add `pub mod search;`

## Acceptance Criteria
- [ ] Porter stemming works: search "searching" matches document containing "search"
- [ ] Prefix search works: `auth*` matches "authentication"
- [ ] Empty query returns empty Vec (no error)
- [ ] Special characters don't cause FTS5 errors: `-`, `"`, `:`, `*`
- [ ] Query `"-DWITH_SSL"` returns results (dash not treated as NOT operator)
- [ ] Query `C++` returns results (special chars preserved in quotes)
- [ ] Safe mode preserves trailing `*` on alphanumeric tokens: `auth*` -> `"auth"*`
- [ ] Raw mode passes query unchanged
- [ ] BM25 scores returned (lower = better match)
- [ ] Snippets contain `<mark>` tags around matches
- [ ] `generate_fallback_snippet` truncates at word boundary, appends "..."
- [ ] `truncate_utf8` never panics on multi-byte codepoints
- [ ] `cargo test fts` passes

## Files
- `src/search/mod.rs` — new file (module root)
- `src/search/fts.rs` — new file (FTS5 search + query sanitization)
- `src/lib.rs` — add `pub mod search;`

## TDD Loop
RED: Tests in `fts.rs` `#[cfg(test)] mod tests`:
- `test_safe_query_basic` — "auth error" -> `"auth" "error"`
- `test_safe_query_prefix` — "auth*" -> `"auth"*`
- `test_safe_query_special_chars` — "C++" -> `"C++"`
- `test_safe_query_dash` — "-DWITH_SSL" -> `"-DWITH_SSL"`
- `test_safe_query_quotes` — `he said "hello"` -> escaped
- `test_raw_mode_passthrough` — raw query unchanged
- `test_empty_query` — returns empty vec
- `test_truncate_utf8_emoji` — truncate mid-emoji walks back
- `test_fallback_snippet_word_boundary` — truncates at space
GREEN: Implement to_fts_query, search_fts, helpers
VERIFY: `cargo test fts`

## Edge Cases
- Query with only whitespace: treated as empty, returns empty
- Query with only special characters: quoted, may return no results (not an error)
- Very long query (1000+ chars): works but may be slow (no explicit limit)
- FTS5 snippet returns empty string: fallback to truncated content_text
- Non-alphanumeric prefix: `C++*` — NOT treated as prefix (special chars present)

Completed: to_fts_query (safe/raw modes), search_fts with BM25+snippets, generate_fallback_snippet, get_result_snippet, truncate_utf8 reuse, 13 tests pass


OBSERV: Add get_log_dir() helper to paths module

## Background
A centralized helper for the log directory path ensures consistent XDG compliance and directory creation. The existing get_data_dir() (src/core/paths.rs:40-43) returns ~/.local/share/lore/. We add a sibling that appends /logs/.

## Approach
Add to src/core/paths.rs, after get_db_path() (around line 53):

```rust
/// Get the log directory path. Creates the directory if it doesn't exist.
pub fn get_log_dir(config_override: Option<&str>) -> PathBuf {
    let dir = if let Some(path) = config_override {
        PathBuf::from(path)
    } else {
        get_data_dir().join("logs")
    };
    std::fs::create_dir_all(&dir).ok();
    dir
}
```

The config_override comes from LoggingConfig.log_dir (bd-17n). When None, uses XDG default.

Existing pattern to follow (src/core/paths.rs:40-53):
- get_data_dir() -> PathBuf (returns ~/.local/share/lore/)
- get_db_path(config_override: Option<&str>) -> PathBuf

## Acceptance Criteria
- [ ] get_log_dir(None) returns ~/.local/share/lore/logs/
- [ ] get_log_dir(Some("/tmp/custom")) returns /tmp/custom
- [ ] Directory is created if it doesn't exist
- [ ] Function is pub and accessible from other modules
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/core/paths.rs (add get_log_dir function after line ~53)

## TDD Loop
RED: test_get_log_dir_default, test_get_log_dir_override (use tempdir)
GREEN: Add get_log_dir() function
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- create_dir_all failure (e.g., permissions): .ok() swallows error silently. This matches get_db_path() which also doesn't create dirs. Consider: should we propagate the error? The subscriber init will fail anyway if the dir doesn't exist, providing a clear error.
- Trailing slash: PathBuf handles this correctly

Added get_log_dir() helper mirroring get_db_path/get_backup_dir pattern


[CP0] Raw payload handling - compression and deduplication

## Background

Raw payload storage allows replaying API responses for debugging and audit. Compression reduces storage for large payloads. SHA-256 deduplication prevents storing identical payloads multiple times (important for frequently polled resources that haven't changed).

Reference: docs/prd/checkpoint-0.md section "Raw Payload Handling"

## Approach

**src/core/payloads.ts:**
```typescript
import { createHash } from 'node:crypto';
import { gzipSync, gunzipSync } from 'node:zlib';
import Database from 'better-sqlite3';
import { nowMs } from './time';

interface StorePayloadOptions {
  projectId: number | null;
  resourceType: string;  // 'project' | 'issue' | 'mr' | 'note' | 'discussion'
  gitlabId: string;      // TEXT because discussion IDs are strings
  payload: unknown;      // JSON-serializable object
  compress: boolean;     // from config.storage.compressRawPayloads
}

export function storePayload(db: Database.Database, options: StorePayloadOptions): number | null {
  // 1. JSON.stringify the payload
  // 2. SHA-256 hash the JSON bytes
  // 3. Check for duplicate by (project_id, resource_type, gitlab_id, payload_hash)
  // 4. If duplicate, return existing ID
  // 5. If compress=true, gzip the JSON bytes
  // 6. INSERT with content_encoding='gzip' or 'identity'
  // 7. Return lastInsertRowid
}

export function readPayload(db: Database.Database, id: number): unknown {
  // 1. SELECT content_encoding, payload FROM raw_payloads WHERE id = ?
  // 2. If gzip, decompress
  // 3. JSON.parse and return
}
```

## Acceptance Criteria

- [ ] storePayload() with compress=true stores gzip-encoded payload
- [ ] storePayload() with compress=false stores identity-encoded payload
- [ ] Duplicate payload (same hash) returns existing row ID, not new row
- [ ] readPayload() correctly decompresses gzip payloads
- [ ] readPayload() returns null for non-existent ID
- [ ] SHA-256 hash computed from pre-compression JSON bytes
- [ ] Large payloads (100KB+) compress to ~10-20% of original size

## Files

CREATE:
- src/core/payloads.ts
- tests/unit/payloads.test.ts

## TDD Loop

RED:
```typescript
// tests/unit/payloads.test.ts
describe('Payload Storage', () => {
  describe('storePayload', () => {
    it('stores uncompressed payload with identity encoding')
    it('stores compressed payload with gzip encoding')
    it('deduplicates identical payloads by hash')
    it('stores different payloads for same gitlab_id')
  })

  describe('readPayload', () => {
    it('reads uncompressed payload')
    it('reads and decompresses gzip payload')
    it('returns null for non-existent id')
  })
})
```

GREEN: Implement storePayload() and readPayload()

VERIFY: `npm run test -- tests/unit/payloads.test.ts`

## Edge Cases

- gitlabId is TEXT not INTEGER - discussion IDs are UUIDs
- Compression ratio varies - some JSON compresses better than others
- null projectId valid for global resources (like user profile)
- Hash collision extremely unlikely with SHA-256 but unique index enforces


[CP0] GitLab API client with rate limiting

## Background

The GitLab client handles all API communication with rate limiting to avoid 429 errors. Uses native fetch (Node 18+). Rate limiter adds jitter to prevent thundering herd. All errors are typed for clean error handling in CLI commands.

Reference: docs/prd/checkpoint-0.md section "GitLab Client"

## Approach

**src/gitlab/client.ts:**
```typescript
export class GitLabClient {
  private baseUrl: string;
  private token: string;
  private rateLimiter: RateLimiter;

  constructor(options: { baseUrl: string; token: string; requestsPerSecond?: number }) {
    this.baseUrl = options.baseUrl.replace(/\/$/, '');
    this.token = options.token;
    this.rateLimiter = new RateLimiter(options.requestsPerSecond ?? 10);
  }

  async getCurrentUser(): Promise<GitLabUser>
  async getProject(pathWithNamespace: string): Promise<GitLabProject>
  private async request<T>(path: string, options?: RequestInit): Promise<T>
}

class RateLimiter {
  private lastRequest = 0;
  private minInterval: number;

  constructor(requestsPerSecond: number) {
    this.minInterval = 1000 / requestsPerSecond;
  }

  async acquire(): Promise<void> {
    // Wait if too soon since last request
    // Add 0-50ms jitter
  }
}
```

**src/gitlab/types.ts:**
```typescript
export interface GitLabUser {
  id: number;
  username: string;
  name: string;
}

export interface GitLabProject {
  id: number;
  path_with_namespace: string;
  default_branch: string;
  web_url: string;
  created_at: string;
  updated_at: string;
}
```

**Integration tests with MSW (Mock Service Worker):**
Set up MSW handlers that mock GitLab API responses for /api/v4/user and /api/v4/projects/:path

## Acceptance Criteria

- [ ] getCurrentUser() returns GitLabUser with id, username, name
- [ ] getProject("group/project") URL-encodes path correctly
- [ ] 401 response throws GitLabAuthError
- [ ] 404 response throws GitLabNotFoundError
- [ ] 429 response throws GitLabRateLimitError with retryAfter from header
- [ ] Network failure throws GitLabNetworkError
- [ ] Rate limiter enforces minimum interval between requests
- [ ] Rate limiter adds random jitter (0-50ms)
- [ ] tests/integration/gitlab-client.test.ts passes (6 tests)

## Files

CREATE:
- src/gitlab/client.ts
- src/gitlab/types.ts
- tests/integration/gitlab-client.test.ts
- tests/fixtures/mock-responses/gitlab-user.json
- tests/fixtures/mock-responses/gitlab-project.json

## TDD Loop

RED:
```typescript
// tests/integration/gitlab-client.test.ts
describe('GitLab Client', () => {
  it('authenticates with valid PAT')
  it('returns 401 for invalid PAT')
  it('fetches project by path')
  it('handles rate limiting (429) with Retry-After')
  it('respects rate limit (requests per second)')
  it('adds jitter to rate limiting')
})
```

GREEN: Implement client.ts and types.ts

VERIFY: `npm run test -- tests/integration/gitlab-client.test.ts`

## Edge Cases

- Path with special characters (spaces, slashes) must be URL-encoded
- Retry-After header may be missing - default to 60s
- Network timeout should be handled (use AbortController)
- Rate limiter jitter prevents multiple clients syncing in lockstep
- baseUrl trailing slash should be stripped


Extend 'lore stats --check' for event table integrity and queue health

## Background
The existing stats --check command validates data integrity. Need to extend it for event tables (referential integrity) and dependent job queue health (stuck locks, retryable jobs). This provides operators and agents a way to detect data quality issues after sync.

## Approach
Extend src/cli/commands/stats.rs check mode:

**New checks:**

1. Event FK integrity:
```sql
-- Orphaned state events (issue_id points to non-existent issue)
SELECT COUNT(*) FROM resource_state_events rse
WHERE rse.issue_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM issues i WHERE i.id = rse.issue_id);
-- (repeat for merge_request_id, and for label + milestone event tables)
```

2. Queue health:
```sql
-- Pending jobs by type
SELECT job_type, COUNT(*) FROM pending_dependent_fetches GROUP BY job_type;
-- Stuck locks (locked_at older than 5 minutes)
SELECT COUNT(*) FROM pending_dependent_fetches WHERE locked_at IS NOT NULL AND locked_at < ?;
-- Retryable jobs (attempts > 0, not locked)
SELECT COUNT(*) FROM pending_dependent_fetches WHERE attempts > 0 AND locked_at IS NULL;
-- Max attempts (jobs that may be permanently failing)
SELECT job_type, MAX(attempts) FROM pending_dependent_fetches GROUP BY job_type;
```

3. Human output per check: PASS / WARN / FAIL with counts
```
Event FK integrity:     PASS (0 orphaned events)
Queue health:           WARN (3 stuck locks, 12 retryable jobs)
```

4. Robot JSON: structured health report
```json
{
  "event_integrity": {
    "status": "pass",
    "orphaned_state_events": 0,
    "orphaned_label_events": 0,
    "orphaned_milestone_events": 0
  },
  "queue_health": {
    "status": "warn",
    "pending_by_type": {"resource_events": 5, "mr_closes_issues": 2},
    "stuck_locks": 3,
    "retryable_jobs": 12,
    "max_attempts_by_type": {"resource_events": 5}
  }
}
```

## Acceptance Criteria
- [ ] Detects orphaned events (FK target missing)
- [ ] Detects stuck locks (locked_at older than threshold)
- [ ] Reports retryable job count and max attempts
- [ ] Human output shows PASS/WARN/FAIL per check
- [ ] Robot JSON matches structured schema
- [ ] Graceful when event/queue tables don't exist

## Files
- src/cli/commands/stats.rs (extend check mode)

## TDD Loop
RED: tests/stats_check_tests.rs:
- `test_stats_check_events_pass` - clean data, verify PASS
- `test_stats_check_events_orphaned` - delete an issue with events remaining, verify FAIL count
- `test_stats_check_queue_stuck_locks` - set old locked_at, verify WARN
- `test_stats_check_queue_retryable` - fail some jobs, verify retryable count

GREEN: Add the check queries and formatting

VERIFY: `cargo test stats_check -- --nocapture`

## Edge Cases
- FK with CASCADE should prevent orphaned events in normal operation — but manual DB edits or bugs could cause them
- Tables may not exist if migration 011 not applied — check table existence before querying
- Empty queue is PASS (not WARN for "no jobs found")
- Distinguish between "0 stuck locks" (good) and "queue table doesn't exist" (skip check)

Extended IntegrityResult with orphan_state/label/milestone_events and queue_stuck_locks/queue_max_attempts. Added FK integrity queries for all 3 event tables and queue health checks. Updated human output with PASS/WARN/FAIL indicators and robot JSON.


[CP1] gi sync-status enhancement

Enhance sync-status from CP0 stub to show issue cursors.

Output:
- Last run timestamp and duration
- Cursor positions per project (issues resource_type)
- Entity counts (issues, discussions, notes)

Files: src/cli/commands/sync-status.ts (update existing)
Done when: Shows cursor positions and counts after ingestion


[CP1] gi ingest --type=issues command

CLI command to orchestrate issue ingestion.

Implementation:
1. Acquire app lock with heartbeat
2. Create sync_run record (status='running')
3. For each configured project:
   - Call ingestIssues()
   - For each ingested issue, call ingestIssueDiscussions()
   - Show progress (spinner or progress bar)
4. Update sync_run (status='succeeded', metrics_json)
5. Release lock

Flags:
- --type=issues (required)
- --project=PATH (optional, filter to single project)
- --force (override stale lock)

Output: Progress bar, then summary with counts

Files: src/cli/commands/ingest.ts
Tests: tests/integration/sync-runs.test.ts
Done when: Full issue + discussion ingestion works end-to-end


Register 'lore timeline' command with all flags

## Background
Wire the timeline command into clap CLI with all flags. This is the integration point that connects the query engine (seed → expand → collect) with the output renderers (human/robot).

## Approach
1. Add to src/cli/mod.rs Commands enum:
```rust
/// Show chronological decision timeline for a keyword
#[command(name = "timeline")]
Timeline(TimelineArgs),
```

```rust
#[derive(clap::Args)]
pub struct TimelineArgs {
    /// Keyword or phrase to search for
    #[arg(required = true)]
    pub query: String,

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

    /// Only include events after this date (Nd, Nw, Nm, or YYYY-MM-DD)
    #[arg(long)]
    pub since: Option<String>,

    /// Cross-reference expansion depth (0 = disabled)
    #[arg(long, default_value = "1")]
    pub depth: usize,

    /// Also follow 'mentioned' edges during expansion
    #[arg(long = "expand-mentions")]
    pub expand_mentions: bool,

    /// Maximum number of events to return
    #[arg(short = 'n', long = "limit", default_value = "100")]
    pub limit: usize,
}
```

2. Add handler in src/main.rs:
```rust
Commands::Timeline(args) => handle_timeline(cli.config.as_deref(), args, robot_mode).await,
```

3. Implement handle_timeline:
```rust
async fn handle_timeline(config_override: Option<&str>, args: TimelineArgs, robot_mode: bool) -> Result<...> {
    let config = Config::load(config_override)?;
    let db_path = get_db_path(config.storage.db_path.as_deref());
    let conn = create_connection(&db_path)?;

    let project_id = args.project.as_ref()
        .map(|p| resolve_project(&conn, p))
        .transpose()?;
    let since = args.since.as_ref()
        .map(|s| parse_since(s))   // reuse existing time parsing from core/time.rs
        .transpose()?;

    // Pipeline: seed → expand → collect
    let seed = seed_from_fts(&conn, &args.query, project_id, since, args.limit)?;
    let expansion = expand_via_references(&conn, &seed.entities, args.depth, args.expand_mentions, 100)?;
    let events = collect_and_interleave_events(&conn, &seed.entities, &expansion.expanded, &seed.evidence_notes, Some(args.limit))?;

    let result = TimelineResult { query: args.query, events, seed_entities: ..., expanded_entities: ..., unresolved_references: ... };

    if robot_mode {
        print_timeline_json(&result, args.depth, args.expand_mentions);
    } else {
        print_timeline(&result, use_color);
    }
}
```

4. Parse --since: reuse existing parse_since/parse_duration from src/core/time.rs. Support:
   - Relative: 7d, 2w, 6m (days, weeks, months)
   - Absolute: YYYY-MM-DD

5. Export from src/cli/commands/mod.rs: `pub mod timeline;`

## Acceptance Criteria
- [ ] `lore timeline "auth"` works with all defaults
- [ ] `lore timeline "auth" -p group/repo` scopes to project
- [ ] `lore timeline "auth" --since 7d` filters by date
- [ ] `lore timeline "auth" --depth 0` disables expansion
- [ ] `lore timeline "auth" --depth 2` expands to 2 hops
- [ ] `lore timeline "auth" --expand-mentions` includes mentioned edges
- [ ] `lore timeline "auth" -n 50` limits events
- [ ] `lore -J timeline "auth"` outputs robot JSON
- [ ] --since supports relative (7d, 2w, 6m) and absolute (YYYY-MM-DD)
- [ ] Help text is descriptive for all flags

## Files
- src/cli/mod.rs (add Timeline variant + TimelineArgs)
- src/cli/commands/mod.rs (add `pub mod timeline;`)
- src/main.rs (add handler)

## TDD Loop
RED: tests/cli_tests.rs (or integration):
- `test_timeline_cli_parse_defaults` - verify default depth=1, limit=100
- `test_timeline_cli_parse_all_flags` - verify all flags parsed
- `test_timeline_cli_since_relative` - verify 7d parsed to ms epoch
- `test_timeline_cli_since_absolute` - verify 2024-01-01 parsed

GREEN: Add CLI definition, handler, and wiring

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

## Edge Cases
- Empty query string: clap requires it, but could be whitespace-only — validate non-empty after trim
- --since with invalid format: return user-friendly error with examples
- --depth > 5 could be expensive — consider warning or cap
- Project resolution may be ambiguous — existing resolve_project handles this with exit code 18


[CP1] GitLab types for issues, discussions, notes

## Background

GitLab types define the Rust structs for deserializing GitLab API responses. These types are the foundation for all ingestion work - issues, discussions, and notes must be correctly typed for serde to parse them.

## Approach

Add types to `src/gitlab/types.rs` with serde derives:

### GitLabIssue

```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabIssue {
    pub id: i64,                      // GitLab global ID
    pub iid: i64,                     // Project-scoped issue number
    pub project_id: i64,
    pub title: String,
    pub description: Option<String>,
    pub state: String,                // "opened" | "closed"
    pub created_at: String,           // ISO 8601
    pub updated_at: String,           // ISO 8601
    pub closed_at: Option<String>,
    pub author: GitLabAuthor,
    pub labels: Vec<String>,          // Array of label names (CP1 canonical)
    pub web_url: String,
}
```

NOTE: `labels_details` intentionally NOT modeled - varies across GitLab versions.

### GitLabAuthor

```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabAuthor {
    pub id: i64,
    pub username: String,
    pub name: String,
}
```

### GitLabDiscussion

```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabDiscussion {
    pub id: String,                   // String ID like "6a9c1750b37d..."
    pub individual_note: bool,        // true = standalone comment
    pub notes: Vec<GitLabNote>,
}
```

### GitLabNote

```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabNote {
    pub id: i64,
    #[serde(rename = "type")]
    pub note_type: Option<String>,    // "DiscussionNote" | "DiffNote" | null
    pub body: String,
    pub author: GitLabAuthor,
    pub created_at: String,           // ISO 8601
    pub updated_at: String,           // ISO 8601
    pub system: bool,                 // true for system-generated notes
    #[serde(default)]
    pub resolvable: bool,
    #[serde(default)]
    pub resolved: bool,
    pub resolved_by: Option<GitLabAuthor>,
    pub resolved_at: Option<String>,
    pub position: Option<GitLabNotePosition>,
}
```

### GitLabNotePosition

```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabNotePosition {
    pub old_path: Option<String>,
    pub new_path: Option<String>,
    pub old_line: Option<i32>,
    pub new_line: Option<i32>,
}
```

## Acceptance Criteria

- [ ] GitLabIssue deserializes from API response JSON
- [ ] GitLabAuthor embedded correctly in issue and note
- [ ] GitLabDiscussion with notes array deserializes
- [ ] GitLabNote handles null note_type (use Option<String>)
- [ ] GitLabNote uses #[serde(rename = "type")] for reserved keyword
- [ ] resolvable/resolved default to false via #[serde(default)]
- [ ] All timestamp fields are String (ISO 8601 parsed elsewhere)

## Files

- src/gitlab/types.rs (edit - add types)

## TDD Loop

RED:
```rust
// tests/gitlab_types_tests.rs
#[test] fn deserializes_gitlab_issue_from_json()
#[test] fn deserializes_gitlab_discussion_from_json()
#[test] fn handles_null_note_type()
#[test] fn handles_missing_resolvable_field()
#[test] fn deserializes_labels_as_string_array()
```

GREEN: Add type definitions with serde attributes

VERIFY: `cargo test gitlab_types`

## Edge Cases

- note_type can be null, "DiscussionNote", or "DiffNote"
- labels array can be empty
- description can be null
- resolved_by/resolved_at can be null
- position is only present for DiffNotes

Implemented GitLabAuthor, GitLabIssue, GitLabDiscussion, GitLabNote, GitLabNotePosition types with 10 passing tests


OBSERV: Add -v/--verbose and --log-format CLI flags

## Background
Users and agents need CLI-controlled verbosity without knowing RUST_LOG syntax. The -v flag convention (cargo, curl, ssh) is universally understood. --log-format json enables lore sync 2>&1 | jq workflows without reading log files.

## Approach
Add two new global flags to the Cli struct in src/cli/mod.rs (insert after the quiet field at line ~37):

```rust
/// Increase log verbosity (-v, -vv, -vvv)
#[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count, global = true)]
pub verbose: u8,

/// Log format for stderr output: text (default) or json
#[arg(long = "log-format", global = true, value_parser = ["text", "json"], default_value = "text")]
pub log_format: String,
```

The existing Cli struct (src/cli/mod.rs:13-42) has these global flags: config, robot, json, color, quiet. The new flags follow the same pattern.

Note: clap::ArgAction::Count allows -v, -vv, -vvv as a single flag with increasing count (0, 1, 2, 3).

## Acceptance Criteria
- [ ] lore -v sync parses without error (verbose=1)
- [ ] lore -vv sync parses (verbose=2)
- [ ] lore -vvv sync parses (verbose=3)
- [ ] lore --log-format json sync parses (log_format="json")
- [ ] lore --log-format text sync parses (default)
- [ ] lore --log-format xml sync errors (invalid value)
- [ ] Existing commands unaffected (verbose defaults to 0, log_format to "text")
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/cli/mod.rs (modify Cli struct, lines 13-42)

## TDD Loop
RED: Write test that parses Cli with -v flag and asserts verbose=1
GREEN: Add the two fields to Cli struct
VERIFY: cargo test -p lore && cargo clippy --all-targets -- -D warnings

## Edge Cases
- -v and -q together: both parse fine; conflict resolution happens in subscriber setup (bd-2rr), not here
- -v flag must be global=true so it works before and after subcommands: lore -v sync AND lore sync -v
- --log-format is a string, not enum, to keep Cli struct simple

Added -v/--verbose (count) and --log-format (text|json) global CLI flags


OBSERV: Define StageTiming struct in src/core/metrics.rs

## Background
StageTiming is the materialized view of span timing data. It's the data structure that flows through robot JSON output, sync_runs.metrics_json, and the human-readable timing summary. Defined in a new file because it's genuinely new functionality that doesn't fit existing modules.

## Approach
Create src/core/metrics.rs:

```rust
use serde::Serialize;

fn is_zero(v: &usize) -> bool { *v == 0 }

#[derive(Debug, Clone, Serialize)]
pub struct StageTiming {
    pub name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub project: Option<String>,
    pub elapsed_ms: u64,
    pub items_processed: usize,
    #[serde(skip_serializing_if = "is_zero")]
    pub items_skipped: usize,
    #[serde(skip_serializing_if = "is_zero")]
    pub errors: usize,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub sub_stages: Vec<StageTiming>,
}
```

Register module in src/core/mod.rs (line ~11, add):
```rust
pub mod metrics;
```

The is_zero helper is a private function used by serde's skip_serializing_if. It must take &usize (reference) and return bool.

## Acceptance Criteria
- [ ] StageTiming serializes to JSON matching PRD Section 4.6.2 example
- [ ] items_skipped omitted when 0
- [ ] errors omitted when 0
- [ ] sub_stages omitted when empty vec
- [ ] project omitted when None
- [ ] name, elapsed_ms, items_processed always present
- [ ] Struct is Debug + Clone + Serialize
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/core/metrics.rs (new file)
- src/core/mod.rs (register module, add line after existing pub mod declarations)

## TDD Loop
RED:
  - test_stage_timing_serialization: create StageTiming with sub_stages, serialize, assert JSON structure
  - test_stage_timing_zero_fields_omitted: errors=0, items_skipped=0, assert no "errors" or "items_skipped" keys
  - test_stage_timing_empty_sub_stages: sub_stages=vec![], assert no "sub_stages" key
GREEN: Create metrics.rs with StageTiming struct and is_zero helper
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- is_zero must be a function, not a closure (serde skip_serializing_if requires a function path)
- Vec::is_empty is a method on Vec, and serde accepts "Vec::is_empty" as a path for skip_serializing_if
- Recursive StageTiming (sub_stages contains StageTiming): serde handles this naturally, no special handling needed

Created src/core/metrics.rs with StageTiming struct, serde skip_serializing_if for zero/empty fields, 5 tests