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


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

Implement 'lore file-history <path>' command showing MRs that touched a file, with rename chain resolution, discussion context, and linked issues. Includes migration 012 (renumbered from spec's 011) for mr_file_changes and commit SHA fields.

Children: bd-1oo (migration 012), bd-jec (config), bd-2yo (MR diffs API), bd-1yx (rename chains), bd-z94 (file-history command)


[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


[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


[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


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

Implement 'lore trace <file>' command (Tier 1 API-only) that traces file -> MR -> issue -> discussion chains. Builds on file-history (Gate 4) and cross-reference data (Gate 2) to answer 'Why was this code introduced?' Tier 2 (git blame integration) is deferred to Phase C.

Children: bd-2n4 (trace query), bd-9dd (trace command with human/robot output)


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


[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


Write migration 012: mr_file_changes + commit SHA columns

## Background
Need to track which files each MR touched, plus commit SHAs for git linking. This is migration 012 (since 011 is resource events + entity_references + dependent fetch queue).

## Approach
Create migrations/012_file_changes.sql with the exact schema from spec §4.1:

```sql
-- Files changed by each merge request
CREATE TABLE mr_file_changes (
  id INTEGER PRIMARY KEY,
  merge_request_id INTEGER NOT NULL REFERENCES merge_requests(id) ON DELETE CASCADE,
  project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  old_path TEXT,
  new_path TEXT NOT NULL,
  change_type TEXT NOT NULL CHECK (change_type IN ('added', 'modified', 'deleted', 'renamed')),
  UNIQUE(merge_request_id, new_path)
);

CREATE INDEX idx_mr_files_new_path ON mr_file_changes(new_path);
CREATE INDEX idx_mr_files_old_path ON mr_file_changes(old_path) WHERE old_path IS NOT NULL;
CREATE INDEX idx_mr_files_mr ON mr_file_changes(merge_request_id);

-- Add commit SHAs to merge_requests (cherry-picked from Phase A)
ALTER TABLE merge_requests ADD COLUMN merge_commit_sha TEXT;
ALTER TABLE merge_requests ADD COLUMN squash_commit_sha TEXT;

INSERT INTO schema_version (version, applied_at, description)
VALUES (12, strftime('%s', 'now') * 1000, 'MR file changes and commit SHA columns');
```

Register in src/core/db.rs MIGRATIONS array:
```rust
("012", include_str!("../../migrations/012_file_changes.sql")),
```

## Acceptance Criteria
- [ ] migrations/012_file_changes.sql exists with mr_file_changes table + indexes
- [ ] merge_requests table has merge_commit_sha and squash_commit_sha columns
- [ ] src/core/db.rs MIGRATIONS array includes ("012", ...)
- [ ] Migration applies cleanly after 011
- [ ] `cargo test migration` passes
- [ ] UNIQUE(merge_request_id, new_path) enforced
- [ ] change_type CHECK constraint enforced

## Files
- migrations/012_file_changes.sql (new)
- src/core/db.rs (add to MIGRATIONS array)

## TDD Loop
RED: tests/migration_tests.rs:
- `test_migration_012_creates_mr_file_changes` - verify table exists
- `test_migration_012_adds_commit_sha_columns` - verify columns on merge_requests
- `test_migration_012_change_type_constraint` - verify CHECK rejects invalid values
- `test_migration_012_unique_constraint` - verify duplicate (mr_id, new_path) rejected

GREEN: Write the migration SQL + register in db.rs

VERIFY: `cargo test migration_tests -- --nocapture`

## Edge Cases
- ALTER TABLE on merge_requests with existing data — SQLite handles this gracefully (new columns default to NULL)
- old_path can be NULL (new files) or same as new_path (modified files) — only populated for renames
- If migration 011 hasn't been applied, 012 should still fail cleanly (schema_version check in runner)


[CP1] Discussion and note transformers

## Background

Discussion and note transformers convert GitLab API discussion responses into our normalized schema. They compute derived fields like `first_note_at`, `last_note_at`, resolvable/resolved status, and note positions. These are pure functions with no I/O.

## Approach

Create transformer module with:

### Structs

```rust
// src/gitlab/transformers/discussion.rs

pub struct NormalizedDiscussion {
    pub gitlab_discussion_id: String,
    pub project_id: i64,
    pub issue_id: i64,
    pub noteable_type: String,        // "Issue"
    pub individual_note: bool,
    pub first_note_at: Option<i64>,   // min(note.created_at) in ms epoch
    pub last_note_at: Option<i64>,    // max(note.created_at) in ms epoch
    pub last_seen_at: i64,
    pub resolvable: bool,             // any note is resolvable
    pub resolved: bool,               // all resolvable notes are resolved
}

pub struct NormalizedNote {
    pub gitlab_id: i64,
    pub project_id: i64,
    pub note_type: Option<String>,    // "DiscussionNote" | "DiffNote" | null
    pub is_system: bool,              // from note.system
    pub author_username: String,
    pub body: String,
    pub created_at: i64,              // ms epoch
    pub updated_at: i64,              // ms epoch
    pub last_seen_at: i64,
    pub position: i32,                // 0-indexed array position
    pub resolvable: bool,
    pub resolved: bool,
    pub resolved_by: Option<String>,
    pub resolved_at: Option<i64>,
}
```

### Functions

```rust
pub fn transform_discussion(
    gitlab_discussion: &GitLabDiscussion,
    local_project_id: i64,
    local_issue_id: i64,
) -> NormalizedDiscussion

pub fn transform_notes(
    gitlab_discussion: &GitLabDiscussion,
    local_project_id: i64,
) -> Vec<NormalizedNote>
```

## Acceptance Criteria

- [ ] `NormalizedDiscussion` struct with all fields
- [ ] `NormalizedNote` struct with all fields
- [ ] `transform_discussion` computes first_note_at/last_note_at from notes array
- [ ] `transform_discussion` computes resolvable (any note is resolvable)
- [ ] `transform_discussion` computes resolved (all resolvable notes resolved)
- [ ] `transform_notes` preserves array order via position field (0-indexed)
- [ ] `transform_notes` maps system flag to is_system
- [ ] Unit tests cover all computed fields

## Files

- src/gitlab/transformers/mod.rs (add `pub mod discussion;`)
- src/gitlab/transformers/discussion.rs (create)

## TDD Loop

RED:
```rust
// tests/discussion_transformer_tests.rs
#[test] fn transforms_discussion_payload_to_normalized_schema()
#[test] fn extracts_notes_array_from_discussion()
#[test] fn sets_individual_note_flag_correctly()
#[test] fn flags_system_notes_with_is_system_true()
#[test] fn preserves_note_order_via_position_field()
#[test] fn computes_first_note_at_and_last_note_at_correctly()
#[test] fn computes_resolvable_and_resolved_status()
```

GREEN: Implement transform_discussion and transform_notes

VERIFY: `cargo test discussion_transformer`

## Edge Cases

- Discussion with single note - first_note_at == last_note_at
- All notes are system notes - still compute timestamps
- No notes resolvable - resolvable=false, resolved=false
- Mix of resolved/unresolved notes - resolved=false until all done

Implemented NormalizedDiscussion, NormalizedNote, transform_discussion, transform_notes with 9 passing unit tests


[CP1] Database migration 002_issues.sql

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

## Tables

### issues
- id INTEGER PRIMARY KEY
- gitlab_id INTEGER UNIQUE NOT NULL
- project_id INTEGER NOT NULL REFERENCES projects(id)
- iid INTEGER NOT NULL
- title TEXT, description TEXT, state TEXT
- author_username TEXT
- created_at, updated_at, last_seen_at INTEGER (ms epoch UTC)
- discussions_synced_for_updated_at INTEGER (watermark for dependent sync)
- web_url TEXT
- raw_payload_id INTEGER REFERENCES raw_payloads(id)

### labels (name-only for CP1)
- id INTEGER PRIMARY KEY
- gitlab_id INTEGER (optional, for future Labels API)
- project_id INTEGER NOT NULL REFERENCES projects(id)
- name TEXT NOT NULL
- color TEXT, description TEXT (nullable, deferred)
- UNIQUE(project_id, name)

### issue_labels (junction)
- issue_id, label_id with CASCADE DELETE
- Clear existing links before INSERT to handle removed labels

### discussions
- gitlab_discussion_id TEXT (string ID from API)
- project_id, issue_id/merge_request_id FKs
- noteable_type TEXT ('Issue' | 'MergeRequest')
- individual_note INTEGER, first_note_at, last_note_at, last_seen_at
- resolvable, resolved flags
- CHECK constraint for Issue vs MR exclusivity

### notes
- gitlab_id INTEGER UNIQUE NOT NULL
- discussion_id, project_id FKs
- note_type, is_system, author_username, body
- timestamps, position (array order)
- resolution fields, DiffNote position fields

## Indexes
- idx_issues_project_updated, idx_issues_author, idx_issues_discussions_sync
- uq_issues_project_iid, uq_labels_project_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, schema_version = 2


[CP1] gi show issue command

Show issue details with discussions.

Flags:
- --project=PATH (required if iid is ambiguous across projects)

Output:
- Title, project, state, author, dates, labels, URL
- Description text
- All discussions with notes (formatted thread view)

Handle ambiguity: If multiple projects have same iid, prompt for --project or show error.

Files: src/cli/commands/show.ts
Done when: Issue detail view displays all fields including threaded discussions


[CP1] Integration tests for issue ingestion

Full integration tests for issue ingestion module.

## Tests (tests/issue_ingestion_tests.rs)

- inserts_issues_into_database
- creates_labels_from_issue_payloads
- links_issues_to_labels_via_junction_table
- removes_stale_label_links_on_resync
- stores_raw_payload_for_each_issue
- stores_raw_payload_for_each_discussion
- updates_cursor_incrementally_per_page
- resumes_from_cursor_on_subsequent_runs
- handles_issues_with_no_labels
- upserts_existing_issues_on_refetch
- skips_discussion_refetch_for_unchanged_issues

## Test Setup
- tempfile::TempDir for isolated database
- wiremock::MockServer for GitLab API
- Mock handlers returning fixture data

Files: tests/issue_ingestion_tests.rs
Done when: All integration tests pass with mocked GitLab


Epic: Gate 2 - Cross-Reference Extraction

Build entity relationship graph from structured APIs (closes_issues endpoint, state event source_merge_request_id) and best-effort system note parsing. The entity_references table is included in migration 011 (same migration as Gate 1 event tables).

Children: bd-czk (entity_references schema, folded into bd-hu3's migration), bd-8t4 (state event extraction), bd-3ia (closes_issues API), bd-1ji (system note parsing)


Epic: CP2 Gate C - Dependent Discussion Sync

## Background
Gate C validates the dependent discussion sync with DiffNote position capture. This is critical for code review context preservation - without DiffNote positions, we lose the file/line context for review comments.

## Acceptance Criteria (Pass/Fail)
- [ ] Discussions fetched for MRs with updated_at > discussions_synced_for_updated_at
- [ ] `SELECT COUNT(*) FROM discussions WHERE merge_request_id IS NOT NULL` > 0
- [ ] DiffNotes have `position_new_path` populated (file path)
- [ ] DiffNotes have `position_new_line` populated (line number)
- [ ] DiffNotes have `position_type` populated (text/image/file)
- [ ] DiffNotes have SHA triplet: `position_base_sha`, `position_start_sha`, `position_head_sha`
- [ ] Multi-line DiffNotes have `position_line_range_start` and `position_line_range_end`
- [ ] Unchanged MRs skip discussion refetch (watermark comparison works)
- [ ] Watermark NOT advanced on HTTP error mid-pagination
- [ ] Watermark NOT advanced on note timestamp parse failure
- [ ] `gi show mr <iid>` displays DiffNote with file context `[path:line]`

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

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

echo "=== Gate C: Dependent Discussion Sync ==="

# 1. Check discussion count for MRs
echo "Step 1: Check MR discussion count..."
MR_DISC_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM discussions WHERE merge_request_id IS NOT NULL;")
echo "  MR discussions: $MR_DISC_COUNT"
[ "$MR_DISC_COUNT" -gt 0 ] || { echo "FAIL: No MR discussions found"; exit 1; }

# 2. Check note count
echo "Step 2: Check note count..."
NOTE_COUNT=$(sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM notes n
  JOIN discussions d ON d.id = n.discussion_id
  WHERE d.merge_request_id IS NOT NULL;
")
echo "  MR notes: $NOTE_COUNT"

# 3. Check DiffNote position data
echo "Step 3: Check DiffNote positions..."
DIFFNOTE_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM notes WHERE position_new_path IS NOT NULL;")
echo "  DiffNotes with position: $DIFFNOTE_COUNT"

# 4. Sample DiffNote data
echo "Step 4: Sample DiffNote data..."
sqlite3 "$DB_PATH" "
  SELECT
    n.gitlab_id,
    n.position_new_path,
    n.position_new_line,
    n.position_type,
    SUBSTR(n.position_head_sha, 1, 7) as head_sha
  FROM notes n
  WHERE n.position_new_path IS NOT NULL
  LIMIT 5;
"

# 5. Check multi-line DiffNotes
echo "Step 5: Check multi-line DiffNotes..."
MULTILINE_COUNT=$(sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM notes
  WHERE position_line_range_start IS NOT NULL
    AND position_line_range_end IS NOT NULL
    AND position_line_range_start != position_line_range_end;
")
echo "  Multi-line DiffNotes: $MULTILINE_COUNT"

# 6. Check watermarks set
echo "Step 6: Check watermarks..."
WATERMARKED=$(sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM merge_requests
  WHERE discussions_synced_for_updated_at IS NOT NULL;
")
echo "  MRs with watermark set: $WATERMARKED"

# 7. Check last_seen_at for sweep pattern
echo "Step 7: Check last_seen_at (sweep pattern)..."
sqlite3 "$DB_PATH" "
  SELECT
    MIN(last_seen_at) as oldest,
    MAX(last_seen_at) as newest
  FROM discussions
  WHERE merge_request_id IS NOT NULL;
"

# 8. Test show command with DiffNote
echo "Step 8: Find MR with DiffNotes for show test..."
MR_IID=$(sqlite3 "$DB_PATH" "
  SELECT DISTINCT m.iid
  FROM merge_requests m
  JOIN discussions d ON d.merge_request_id = m.id
  JOIN notes n ON n.discussion_id = d.id
  WHERE n.position_new_path IS NOT NULL
  LIMIT 1;
")
if [ -n "$MR_IID" ]; then
  echo "  Testing: gi show mr $MR_IID"
  gi show mr "$MR_IID" | head -50
fi

# 9. Re-run and verify skip count
echo "Step 9: Re-run ingest (should skip unchanged MRs)..."
gi ingest --type=merge_requests
# Should report "Skipped discussion sync for N unchanged MRs"

echo ""
echo "=== Gate C: PASSED ==="
```

## Atomicity Test (Manual - Kill Test)
```bash
# This tests that partial failure preserves data

# 1. Get an MR with discussions
MR_ID=$(sqlite3 "$DB_PATH" "
  SELECT m.id FROM merge_requests m
  JOIN discussions d ON d.merge_request_id = m.id
  LIMIT 1;
")

# 2. Note current note count
BEFORE=$(sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM notes n
  JOIN discussions d ON d.id = n.discussion_id
  WHERE d.merge_request_id = $MR_ID;
")
echo "Notes before: $BEFORE"

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

# 4. Force full sync and kill mid-run
gi ingest --type=merge_requests --full &
PID=$!
sleep 3 && kill -9 $PID 2>/dev/null || true
wait $PID 2>/dev/null || true

# 5. Verify notes preserved (should be same or more, never less)
AFTER=$(sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM notes n
  JOIN discussions d ON d.id = n.discussion_id
  WHERE d.merge_request_id = $MR_ID;
")
echo "Notes after kill: $AFTER"
[ "$AFTER" -ge "$BEFORE" ] || echo "WARNING: Notes decreased - atomicity may be broken"

# 6. Note watermark should NOT have advanced if killed mid-pagination
WATERMARK_AFTER=$(sqlite3 "$DB_PATH" "
  SELECT discussions_synced_for_updated_at FROM merge_requests WHERE id = $MR_ID;
")
echo "Watermark after: $WATERMARK_AFTER"
```

## Test Commands (Quick Verification)
```bash
# Check DiffNote data:
sqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 "
  SELECT
    (SELECT COUNT(*) FROM discussions WHERE merge_request_id IS NOT NULL) as mr_discussions,
    (SELECT COUNT(*) FROM notes WHERE position_new_path IS NOT NULL) as diffnotes,
    (SELECT COUNT(*) FROM merge_requests WHERE discussions_synced_for_updated_at IS NOT NULL) as watermarked;
"

# Find MR with DiffNotes and show it:
gi show mr $(sqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 "
  SELECT DISTINCT m.iid FROM merge_requests m
  JOIN discussions d ON d.merge_request_id = m.id
  JOIN notes n ON n.discussion_id = d.id
  WHERE n.position_new_path IS NOT NULL LIMIT 1;
")
```

## Dependencies
This gate requires:
- bd-3j6 (Discussion transformer with DiffNote position extraction)
- bd-20h (MR discussion ingestion with atomicity guarantees)
- bd-iba (Client pagination for MR discussions)
- Gates A and B must pass first

## Edge Cases
- MRs without discussions: should sync successfully, just with 0 discussions
- Discussions without DiffNotes: regular comments have NULL position fields
- Deleted discussions in GitLab: sweep pattern should remove them locally
- Invalid note timestamps: should NOT advance watermark, should log warning


[CP1] Integration tests for pagination

Integration tests for GitLab pagination with wiremock.

## Tests (tests/pagination_tests.rs)

### Page Navigation
- fetches_all_pages_when_multiple_exist
- respects_per_page_parameter
- follows_x_next_page_header_until_empty
- falls_back_to_empty_page_stop_if_headers_missing

### Cursor Behavior
- applies_cursor_rewind_for_tuple_semantics
- clamps_negative_rewind_to_zero

## Test Setup
- Use wiremock::MockServer
- Set up handlers for /api/v4/projects/:id/issues
- Return x-next-page headers
- Verify request params (updated_after, per_page)

Files: tests/pagination_tests.rs
Done when: All pagination tests pass with mocked server


Implement document regenerator

## Background
The document regenerator drains the dirty_sources queue, regenerating documents for each entry. It uses per-item transactions for crash safety, a triple-hash fast path to skip unchanged documents entirely (no writes at all), and a bounded batch loop that drains completely. Error recording includes backoff computation.

## Approach
Create `src/documents/regenerator.rs` per PRD Section 6.3.

**Core function:**
```rust
pub fn regenerate_dirty_documents(conn: &Connection) -> Result<RegenerateResult>
```

**RegenerateResult:** { regenerated, unchanged, errored }

**Algorithm (per PRD):**
1. Loop: get_dirty_sources(conn) -> Vec<(SourceType, i64)>
2. If empty, break (queue fully drained)
3. For each (source_type, source_id):
   a. Begin transaction
   b. Call regenerate_one_tx(&tx, source_type, source_id) -> Result<bool>
   c. If Ok(changed): clear_dirty_tx, commit, count regenerated or unchanged
   d. If Err: record_dirty_error_tx (with backoff), commit, count errored

**regenerate_one_tx (per PRD):**
1. Extract document via extract_{type}_document(conn, source_id)
2. If None (deleted): delete_document, return Ok(true)
3. If Some(doc): call get_existing_hash() to check current state
4. **If ALL THREE hashes match: return Ok(false) — skip ALL writes** (fast path)
5. Otherwise: upsert_document with conditional label/path relinking
6. Return Ok(content changed)

**Helper functions (PRD-exact):**

`get_existing_hash` — uses `optional()` to distinguish missing rows from DB errors:
```rust
fn get_existing_hash(
    conn: &Connection,
    source_type: SourceType,
    source_id: i64,
) -> Result<Option<String>> {
    use rusqlite::OptionalExtension;
    let hash: Option<String> = stmt
        .query_row(params, |row| row.get(0))
        .optional()?;  // IMPORTANT: Not .ok() — .ok() would hide real DB errors
    Ok(hash)
}
```

`get_document_id` — resolve document ID after upsert:
```rust
fn get_document_id(conn: &Connection, source_type: SourceType, source_id: i64) -> Result<i64>
```

`upsert_document` — checks existing triple hash before writing:
```rust
fn upsert_document(conn: &Connection, doc: &DocumentData) -> Result<()> {
    // 1. Query existing (id, content_hash, labels_hash, paths_hash) via OptionalExtension
    // 2. Triple-hash fast path: all match -> return Ok(())
    // 3. Upsert document row (ON CONFLICT DO UPDATE)
    // 4. Get doc_id (from existing or query after insert)
    // 5. Only delete+reinsert labels if labels_hash changed
    // 6. Only delete+reinsert paths if paths_hash changed
}
```

**Key PRD detail — triple-hash fast path:**
```rust
if old_content_hash == &doc.content_hash
    && old_labels_hash == &doc.labels_hash
    && old_paths_hash == &doc.paths_hash
{ return Ok(()); }  // Skip ALL writes — prevents WAL churn
```

**Error recording with backoff:**
record_dirty_error_tx reads current attempt_count from DB, computes next_attempt_at via shared backoff utility:
```rust
let next_attempt_at = crate::core::backoff::compute_next_attempt_at(now, attempt_count + 1);
```

**All internal functions use _tx suffix** (take &Transaction) for atomicity.

## Acceptance Criteria
- [ ] Queue fully drained (bounded batch loop until empty)
- [ ] Per-item transactions (crash loses at most 1 doc)
- [ ] Triple-hash fast path: ALL THREE hashes match -> skip ALL writes (return Ok(false))
- [ ] Content change: upsert document, update labels/paths
- [ ] Labels-only change: relabels but skips path writes (paths_hash unchanged)
- [ ] Deleted entity: delete document (cascade handles FTS/labels/paths/embeddings)
- [ ] get_existing_hash uses `.optional()` (not `.ok()`) to preserve DB errors
- [ ] get_document_id resolves document ID after upsert
- [ ] Error recording: increment attempt_count, compute next_attempt_at via backoff
- [ ] FTS triggers fire on insert/update/delete (verified by trigger, not regenerator)
- [ ] RegenerateResult counts accurate (regenerated, unchanged, errored)
- [ ] Errors do not abort batch (log, increment, continue)
- [ ] `cargo test regenerator` passes

## Files
- `src/documents/regenerator.rs` — new file
- `src/documents/mod.rs` — add `pub use regenerator::regenerate_dirty_documents;`

## TDD Loop
RED: Tests requiring DB:
- `test_creates_new_document` — dirty source -> document created
- `test_skips_unchanged_triple_hash` — all 3 hashes match -> unchanged count incremented, no DB writes
- `test_updates_changed_content` — content_hash mismatch -> updated
- `test_updates_changed_labels_only` — content same but labels_hash different -> updated
- `test_updates_changed_paths_only` — content same but paths_hash different -> updated
- `test_deletes_missing_source` — source deleted -> document deleted
- `test_drains_queue` — queue empty after regeneration
- `test_error_records_backoff` — error -> attempt_count incremented, next_attempt_at set
- `test_get_existing_hash_not_found` — returns Ok(None) for missing document
GREEN: Implement regenerate_dirty_documents + all helpers
VERIFY: `cargo test regenerator`

## Edge Cases
- Empty queue: return immediately with all-zero counts
- Extractor error for one item: record_dirty_error_tx, commit, continue
- Triple-hash prevents WAL churn on incremental syncs (most entities unchanged)
- Labels change but content does not: labels_hash mismatch triggers upsert with label relinking
- get_existing_hash on missing document: returns Ok(None) via .optional() (not DB error)
- get_existing_hash on corrupt DB: propagates real DB error (not masked by .ok())

Implemented document regenerator with triple-hash fast path, queue draining, fail-soft error handling + 5 tests