Add 'lore count references' command

## Background
The entity_references table is the cross-reference graph powering Gates 3-5. Operators and agents need a way to inspect this graph for debugging and validation: how many refs exist, breakdown by type and method, unresolved count. Follows the existing `lore count` pattern.

## Approach
1. Add 'references' to CountArgs entity value_parser in src/cli/mod.rs
2. Add handler in src/cli/commands/count.rs matching the existing pattern:

```rust
"references" => {
    let total: i64 = conn.query_row(
        "SELECT COUNT(*) FROM entity_references", [], |r| r.get(0)
    )?;
    let by_type: Vec<(String, i64)> = conn.prepare(
        "SELECT reference_type, COUNT(*) FROM entity_references GROUP BY reference_type"
    )?.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?.collect::<Result<_,_>>()?;
    let by_method: Vec<(String, i64)> = conn.prepare(
        "SELECT source_method, COUNT(*) FROM entity_references GROUP BY source_method"
    )?.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?.collect::<Result<_,_>>()?;
    let unresolved: i64 = conn.query_row(
        "SELECT COUNT(*) FROM entity_references WHERE target_entity_id IS NULL", [], |r| r.get(0)
    )?;
    // Format as table (human) or JSON (robot)
}
```

Robot JSON:
```json
{"ok":true,"data":{"total":142,"by_type":{"closes":45,"mentioned":89,"related":8},"by_method":{"api_closes_issues":40,"api_state_event":5,"system_note_parse":97},"unresolved":12}}
```

## Acceptance Criteria
- [ ] `lore count references` prints total, by-type, by-method, unresolved counts
- [ ] `lore --robot count references` returns structured JSON
- [ ] Human output uses the same table formatting as other count subcommands
- [ ] Works with empty table (all zeros)
- [ ] Exit code 0 on success

## Files
- src/cli/mod.rs (add 'references' to CountArgs value_parser)
- src/cli/commands/count.rs (add references handler)

## TDD Loop
RED: tests/count_references_test.rs:
- `test_count_references_empty` - no refs → all zeros
- `test_count_references_with_data` - seed refs, verify counts match
- `test_count_references_robot_json` - verify JSON structure

GREEN: Add the count handler with SQL queries
REFACTOR: Share query helpers if pattern is reusable

VERIFY: `cargo test count_references -- --nocapture`

## Edge Cases
- entity_references table doesn't exist (pre-migration 011): return meaningful error
- All references are unresolved: unresolved count equals total
- Reference types not in CHECK constraint (future extension): GROUP BY still works


[CP1] gi count issues/discussions/notes commands

## Background

The `gi count` command provides quick counts of entities in the local database. It supports counting issues, MRs, discussions, and notes, with optional filtering by noteable type. This enables quick validation that sync is working correctly.

## Approach

### Module: src/cli/commands/count.rs

### Clap Definition

```rust
#[derive(Args)]
pub struct CountArgs {
    /// Entity type to count
    #[arg(value_parser = ["issues", "mrs", "discussions", "notes"])]
    pub entity: String,

    /// Filter by noteable type (for discussions/notes)
    #[arg(long, value_parser = ["issue", "mr"])]
    pub r#type: Option<String>,
}
```

### Handler Function

```rust
pub async fn handle_count(args: CountArgs, conn: &Connection) -> Result<()>
```

### Queries by Entity

**issues:**
```sql
SELECT COUNT(*) FROM issues
```
Output: `Issues: 3,801`

**discussions:**
```sql
-- Without type filter
SELECT COUNT(*) FROM discussions

-- With --type=issue
SELECT COUNT(*) FROM discussions WHERE noteable_type = 'Issue'
```
Output: `Issue Discussions: 1,234`

**notes:**
```sql
-- Total and system count
SELECT COUNT(*), SUM(is_system) FROM notes

-- With --type=issue (join through discussions)
SELECT COUNT(*), SUM(n.is_system)
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE d.noteable_type = 'Issue'
```
Output: `Issue Notes: 5,678 (excluding 1,234 system)`

### Output Format

```
Issues: 3,801
```

```
Issue Discussions: 1,234
```

```
Issue Notes: 5,678 (excluding 1,234 system)
```

## Acceptance Criteria

- [ ] `gi count issues` shows total issue count
- [ ] `gi count discussions` shows total discussion count
- [ ] `gi count discussions --type=issue` filters to issue discussions
- [ ] `gi count notes` shows total note count with system note exclusion
- [ ] `gi count notes --type=issue` filters to issue notes
- [ ] Numbers formatted with thousands separators (1,234)

## Files

- src/cli/commands/mod.rs (add `pub mod count;`)
- src/cli/commands/count.rs (create)
- src/cli/mod.rs (add Count variant to Commands enum)

## TDD Loop

RED:
```rust
#[tokio::test] async fn count_issues_returns_total()
#[tokio::test] async fn count_discussions_with_type_filter()
#[tokio::test] async fn count_notes_excludes_system_notes()
```

GREEN: Implement handler with queries

VERIFY: `cargo test count`

## Edge Cases

- Zero entities - show "Issues: 0"
- --type flag invalid for issues/mrs - ignore or error
- All notes are system notes - show "Notes: 0 (excluding 1,234 system)"

Implemented gi count command with issues/discussions/notes support, format_number helper, and system note exclusion


Implement timeline human output renderer

## Background
Human output for timeline needs colored, readable format per spec §3.4. Uses the existing color infrastructure (--color flag support from src/cli/mod.rs). The output format is a vertical timeline with date, event type, entity ref, summary, and actor.

## Approach
Create src/cli/commands/timeline.rs (new file):

```rust
use crate::core::timeline::{TimelineResult, TimelineEvent, TimelineEventType};

/// Print timeline in human-readable format with optional colors.
pub fn print_timeline(result: &TimelineResult, use_color: bool) {
    // Header
    println!("Timeline: \"{}\" ({} events across {} entities)",
        result.query, result.events.len(),
        result.seed_entities.len() + result.expanded_entities.len());
    println!("{}", "─".repeat(55));
    println!();

    // Events
    for event in &result.events {
        let date = format_date(event.timestamp);  // YYYY-MM-DD
        let event_tag = format_event_tag(&event.event_type, use_color);  // CREATED, LABEL, etc.
        let entity_ref = format_entity_ref(event);  // #234 or !567
        let expanded_marker = if !event.is_seed { " [expanded]" } else { "" };

        println!("{:10}  {:8}  {:5}  {:<40}  @{}{}",
            date, event_tag, entity_ref, event.summary,
            event.actor.as_deref().unwrap_or("-"), expanded_marker);

        // Evidence notes: show snippet indented in quotes
        if let TimelineEventType::NoteEvidence { snippet, .. } = &event.event_type {
            println!("                             \"{}\"", snippet);
        }
    }

    // Footer
    println!();
    println!("{}", "─".repeat(55));
    print!("Seed entities: ");
    // list seed entity refs
    if !result.expanded_entities.is_empty() {
        print!(" | Expanded: ");
        // list expanded with depth + via info
    }
    println!();
}
```

**Event tag formatting:**
- Created → "CREATED" (green)
- StateChanged("closed") → "CLOSED" (red)
- StateChanged("reopened") → "REOPENED" (yellow)
- StateChanged("merged") → "MERGED" (magenta)
- LabelAdded → "LABEL+" (cyan)
- LabelRemoved → "LABEL-" (cyan)
- MilestoneSet → "MILESTONE" (blue)
- Merged → "MERGED" (magenta)
- NoteEvidence → "NOTE" (white/default)
- CrossReferenced → "XREF" (yellow)

**Entity ref formatting:**
- issue → "#" + iid
- merge_request → "!" + iid

**Expanded entity annotation:**
```
2024-03-28  CREATED   #299  OAuth2 login fails for SSO users     @dave   [expanded]
            (via !567, closes)
```

## Acceptance Criteria
- [ ] Header shows query, event count, entity count
- [ ] Each event shows date, tag, entity ref, summary, actor
- [ ] Evidence notes show snippet in quotes, indented
- [ ] Expanded entities marked with [expanded] and provenance
- [ ] Footer lists seed and expanded entities
- [ ] Colors applied when use_color=true
- [ ] No colors when use_color=false (piped output)

## Files
- src/cli/commands/timeline.rs (new)
- src/cli/commands/mod.rs (add `pub mod timeline;`)

## TDD Loop
RED: tests/timeline_output_tests.rs:
- `test_print_timeline_header` - capture stdout, verify header format
- `test_print_timeline_event_formatting` - verify date/tag/ref/summary layout
- `test_print_timeline_evidence_note_indented` - verify snippet in quotes
- `test_print_timeline_expanded_marker` - verify [expanded] and provenance
- `test_print_timeline_no_color` - verify no ANSI codes when color off

Note: Capturing stdout in tests requires a buffer-based approach. Consider making print_timeline write to impl Write instead of stdout directly for testability.

GREEN: Implement the human output renderer

VERIFY: `cargo test timeline_output -- --nocapture`

## Edge Cases
- Very long summaries: truncate to ~50 chars for table alignment
- Actors with long usernames: alignment may shift — use fixed-width columns
- Empty timeline (no events): print "No events found" message
- Timestamp conversion: ms epoch → YYYY-MM-DD display, handle timezone (UTC)


Update AGENTS.md and CLAUDE.md with Phase B commands

## Background
AGENTS.md and the user's CLAUDE.md contain robot-mode documentation for agent consumption. After Phase B ships, the new commands (timeline, file-history, trace) and the extended count command must be documented so agents know they exist and how to use them.

## Approach
Add a new section to AGENTS.md under '### Phase B: Temporal Intelligence Commands':

```markdown
### Temporal Intelligence Commands

```bash
# Entity decision timeline
lore --robot timeline issue 123
lore --robot timeline mr 456 --since 30d
lore --robot timeline issue 123 --type state,label

# File decision history
lore --robot file-history src/core/db.rs
lore --robot file-history src/core/db.rs --since 90d

# Code trace (file → MRs → issues → cross-refs)
lore --robot trace src/core/db.rs
lore --robot trace 'src/embedding/**' --depth 2

# Cross-reference stats
lore --robot count references
```
```

Also update the Commands table in AGENTS.md Robot Mode section to include the new commands.

For CLAUDE.md: the user maintains this separately at ~/.claude/CLAUDE.md. Add a note in AGENTS.md that agents should check CLAUDE.md for the latest command list.

## Acceptance Criteria
- [ ] AGENTS.md has a 'Temporal Intelligence Commands' section
- [ ] All 4 new commands documented with examples
- [ ] Commands table updated with timeline, file-history, trace entries
- [ ] Examples use --robot flag consistently
- [ ] Documentation matches actual CLI behavior (flags, args, output format)

## Files
- AGENTS.md (update existing robot mode section)

## TDD Loop
This is a documentation bead — no automated tests. Verify manually:
- [ ] Read AGENTS.md and confirm new section exists
- [ ] Verify each example command matches CLI arg definitions in src/cli/mod.rs
- [ ] Run `lore --robot timeline --help` and confirm flags match docs

## Edge Cases
- Don't document commands before they're implemented — this bead should be one of the last in the dependency chain
- Keep examples consistent with existing documentation style (no emojis, concise)


Add GitLab Resource Event serde types

## Background
Need Rust types for deserializing GitLab Resource Events API responses. These map directly to the API JSON shape from three endpoints: resource_state_events, resource_label_events, resource_milestone_events.

Existing pattern: types.rs uses #[derive(Debug, Clone, Deserialize)] with Option<T> for nullable fields. GitLabAuthor is already defined (id, username, name). Tests in tests/gitlab_types_tests.rs use serde_json::from_str with sample payloads.

## Approach
Add to src/gitlab/types.rs (after existing types):

```rust
/// Reference to an MR in state event's source_merge_request field
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitLabMergeRequestRef {
    pub iid: i64,
    pub title: Option<String>,
    pub web_url: Option<String>,
}

/// Reference to a label in label event's label field
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitLabLabelRef {
    pub id: i64,
    pub name: String,
    pub color: Option<String>,
    pub description: Option<String>,
}

/// Reference to a milestone in milestone event's milestone field
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitLabMilestoneRef {
    pub id: i64,
    pub iid: i64,
    pub title: String,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitLabStateEvent {
    pub id: i64,
    pub user: Option<GitLabAuthor>,
    pub created_at: String,
    pub resource_type: String,       // "Issue" | "MergeRequest"
    pub resource_id: i64,
    pub state: String,               // "opened" | "closed" | "reopened" | "merged" | "locked"
    pub source_commit: Option<String>,
    pub source_merge_request: Option<GitLabMergeRequestRef>,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitLabLabelEvent {
    pub id: i64,
    pub user: Option<GitLabAuthor>,
    pub created_at: String,
    pub resource_type: String,
    pub resource_id: i64,
    pub label: GitLabLabelRef,
    pub action: String,              // "add" | "remove"
}

#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitLabMilestoneEvent {
    pub id: i64,
    pub user: Option<GitLabAuthor>,
    pub created_at: String,
    pub resource_type: String,
    pub resource_id: i64,
    pub milestone: GitLabMilestoneRef,
    pub action: String,              // "add" | "remove"
}
```

Also export from src/gitlab/mod.rs if needed.

## Acceptance Criteria
- [ ] All 6 types (3 events + 3 refs) compile
- [ ] GitLabStateEvent deserializes from real GitLab API JSON (with and without source_merge_request)
- [ ] GitLabLabelEvent deserializes with nested label object
- [ ] GitLabMilestoneEvent deserializes with nested milestone object
- [ ] All Optional fields handle null/missing correctly
- [ ] Types exported from lore::gitlab::types

## Files
- src/gitlab/types.rs (add 6 new types)
- tests/gitlab_types_tests.rs (add deserialization tests)

## TDD Loop
RED: Add to tests/gitlab_types_tests.rs:
- `test_deserialize_state_event_closed_by_mr` - JSON with source_merge_request present
- `test_deserialize_state_event_simple` - JSON with source_merge_request null, user null
- `test_deserialize_label_event_add` - label add with full label object
- `test_deserialize_label_event_remove` - label remove
- `test_deserialize_milestone_event` - milestone add with nested milestone
Import new types: `use lore::gitlab::types::{GitLabStateEvent, GitLabLabelEvent, GitLabMilestoneEvent, GitLabMergeRequestRef, GitLabLabelRef, GitLabMilestoneRef};`

GREEN: Add the type definitions to types.rs

VERIFY: `cargo test gitlab_types_tests -- --nocapture`

## Edge Cases
- GitLab sometimes returns user: null for system-generated events (e.g., auto-close on merge) — user must be Option
- source_merge_request can be null even when state is "closed" (manually closed, not by MR)
- label.color may be null for labels created via API without color
- The resource_type field uses PascalCase ("MergeRequest" not "merge_request") — don't confuse with DB entity_type

Completed: Added 6 new types (GitLabMergeRequestRef, GitLabLabelRef, GitLabMilestoneRef, GitLabStateEvent, GitLabLabelEvent, GitLabMilestoneEvent) to types.rs with exports and 8 passing tests


Implement discussion document extraction

## Background
Discussion documents are the most complex extraction — they involve querying discussions + notes + parent entity (issue or MR) + parent labels + DiffNote file paths. The output includes a threaded conversation format with author/date prefixes per note. System notes (bot-generated) are excluded. DiffNote paths are extracted for the --path search filter.

## Approach
Implement `extract_discussion_document()` in `src/documents/extractor.rs`:

```rust
/// Extract a searchable document from a discussion thread.
/// Returns None if the discussion or its parent has been deleted.
pub fn extract_discussion_document(conn: &Connection, discussion_id: i64) -> Result<Option<DocumentData>>
```

**SQL queries (from PRD Section 2.2):**
```sql
-- Discussion metadata
SELECT d.id, d.noteable_type, d.issue_id, d.merge_request_id,
       p.path_with_namespace, p.id AS project_id
FROM discussions d
JOIN projects p ON p.id = d.project_id
WHERE d.id = ?

-- Parent entity (conditional on noteable_type)
-- If Issue: SELECT i.iid, i.title, i.web_url FROM issues i WHERE i.id = ?
-- If MR: SELECT m.iid, m.title, m.web_url FROM merge_requests m WHERE m.id = ?

-- Parent labels (via issue_labels or mr_labels junction)

-- Non-system notes in thread order
SELECT n.author_username, n.body, n.created_at, n.gitlab_id,
       n.note_type, n.position_old_path, n.position_new_path
FROM notes n
WHERE n.discussion_id = ? AND n.is_system = 0
ORDER BY n.created_at ASC, n.id ASC
```

**Document format:**
```
[[Discussion]] Issue #234: Authentication redesign
Project: group/project-one
URL: https://gitlab.example.com/group/project-one/-/issues/234#note_12345
Labels: ["bug", "auth"]
Files: ["src/auth/login.ts"]

--- Thread ---

@johndoe (2024-03-15):
I think we should move to JWT-based auth...

@janedoe (2024-03-15):
Agreed. What about refresh token strategy?
```

**Implementation steps:**
1. Query discussion row — if not found, return Ok(None)
2. Determine parent type (Issue or MR) from noteable_type
3. Query parent entity for iid, title, web_url — if not found, return Ok(None)
4. Query parent labels via appropriate junction table
5. Query non-system notes ordered by created_at ASC, id ASC
6. Extract DiffNote paths: collect position_old_path and position_new_path, dedup
7. Construct URL: `{parent_web_url}#note_{first_note_gitlab_id}`
8. Format header with [[Discussion]] prefix
9. Format thread body: `@author (YYYY-MM-DD):\nbody\n\n` per note
10. Apply discussion truncation via `truncate_discussion()` if needed
11. Author = first non-system note's author_username
12. Compute hashes, return DocumentData

## Acceptance Criteria
- [ ] System notes (is_system=1) excluded from content
- [ ] DiffNote paths extracted from position_old_path and position_new_path
- [ ] Paths deduplicated and sorted
- [ ] URL constructed as `parent_web_url#note_GITLAB_ID`
- [ ] Header uses parent entity type: "Issue #N" or "MR !N"
- [ ] Parent title included in header
- [ ] Labels come from PARENT entity (not the discussion itself)
- [ ] First non-system note author used as document author
- [ ] Thread formatted with `@author (date):` per note
- [ ] Truncation applied for long threads via truncate_discussion()
- [ ] `cargo test extract_discussion` passes

## Files
- `src/documents/extractor.rs` — implement `extract_discussion_document()`

## TDD Loop
RED: Tests in `#[cfg(test)] mod tests`:
- `test_discussion_document_format` — verify header + thread format
- `test_discussion_not_found` — returns Ok(None)
- `test_discussion_parent_deleted` — returns Ok(None) when parent issue/MR missing
- `test_discussion_system_notes_excluded` — system notes not in content
- `test_discussion_diffnote_paths` — old_path + new_path extracted and deduped
- `test_discussion_url_construction` — URL has #note_GITLAB_ID anchor
- `test_discussion_uses_parent_labels` — labels from parent entity, not discussion
GREEN: Implement extract_discussion_document
VERIFY: `cargo test extract_discussion`

## Edge Cases
- Discussion with all system notes: no non-system notes -> return empty thread (or skip document entirely?)
- Discussion with NULL parent (orphaned): return Ok(None)
- DiffNote with same old_path and new_path: dedup produces single entry
- Notes with NULL body: skip or use empty string
- Discussion on MR: header shows "MR !N" (not "MergeRequest !N")

Implemented extract_discussion_document() with parent entity lookup, DiffNote paths, system note exclusion, URL construction + 9 tests


[CP1] gi list issues command

List issues from the database.

## Module
src/cli/commands/list.rs

## Clap Definition
List {
    #[arg(value_parser = ["issues", "mrs"])]
    entity: String,

    #[arg(long, default_value = "20")]
    limit: usize,

    #[arg(long)]
    project: Option<String>,

    #[arg(long, value_parser = ["opened", "closed", "all"])]
    state: Option<String>,
}

## Output Format
Issues (showing 20 of 3,801)

  #1234  Authentication redesign                       opened   @johndoe   3 days ago
  #1233  Fix memory leak in cache                      closed   @janedoe   5 days ago
  #1232  Add dark mode support                         opened   @bobsmith  1 week ago
  ...

## Implementation
- Query issues table with filters
- Join with projects table for display
- Format updated_at as relative time ("3 days ago")
- Truncate title if too long

Files: src/cli/commands/list.rs
Done when: List displays issues with proper filtering and formatting


OBSERV: Add log file diagnostics to lore doctor

## Background
lore doctor is the diagnostic entry point. Adding log file info lets users verify logging is working and check disk usage. The existing DoctorChecks struct (src/cli/commands/doctor.rs:43-51) has checks for config, database, gitlab, projects, ollama.

## Approach
Add a new LoggingCheck struct and field to DoctorChecks:

```rust
#[derive(Debug, Serialize)]
pub struct LoggingCheck {
    pub result: CheckResult,
    pub log_dir: String,
    pub file_count: usize,
    pub total_bytes: u64,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub oldest_file: Option<String>,
}
```

Add to DoctorChecks (src/cli/commands/doctor.rs:43-51):
```rust
pub logging: LoggingCheck,
```

Implement check_logging() function:
```rust
fn check_logging() -> LoggingCheck {
    let log_dir = get_log_dir(None); // TODO: accept config override
    let mut file_count = 0;
    let mut total_bytes = 0u64;
    let mut oldest: Option<String> = None;

    if let Ok(entries) = std::fs::read_dir(&log_dir) {
        for entry in entries.flatten() {
            let name = entry.file_name().to_string_lossy().to_string();
            if name.starts_with("lore.") && name.ends_with(".log") {
                file_count += 1;
                if let Ok(meta) = entry.metadata() {
                    total_bytes += meta.len();
                }
                if oldest.as_ref().map_or(true, |o| name < *o) {
                    oldest = Some(name);
                }
            }
        }
    }

    LoggingCheck {
        result: CheckResult { status: CheckStatus::Ok, message: None },
        log_dir: log_dir.display().to_string(),
        file_count,
        total_bytes,
        oldest_file: oldest,
    }
}
```

Call from run_doctor() (src/cli/commands/doctor.rs:91-126) and add to DoctorChecks construction.

For interactive output in print_doctor_results(), add a section:
```
Logging
  Log directory: ~/.local/share/lore/logs/
  Log files: 7 (2.3 MB)
  Oldest: lore.2026-01-28.log
```

## Acceptance Criteria
- [ ] lore doctor shows log directory path, file count, total size
- [ ] lore --robot doctor JSON includes logging field with log_dir, file_count, total_bytes, oldest_file
- [ ] When no log files exist: file_count=0, total_bytes=0, oldest_file=null
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/cli/commands/doctor.rs (add LoggingCheck struct, check_logging fn, wire into DoctorChecks)

## TDD Loop
RED: test_check_logging_with_files, test_check_logging_empty_dir
GREEN: Implement LoggingCheck struct and check_logging function
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- Log directory doesn't exist yet (first run before any sync): report file_count=0, status Ok
- Permission errors on read_dir: report status Warning with message

Added LoggingCheck to DoctorChecks with log_dir, file_count, total_bytes; shows in both interactive and robot output


[CP1] Database migration 002_issues.sql

## Background

The 002_issues.sql migration creates tables for issues, labels, issue_labels, discussions, and notes. This is the data foundation for Checkpoint 1, enabling issue ingestion with cursor-based sync, label tracking, and discussion storage.

## Approach

Create `migrations/002_issues.sql` with complete SQL statements.

### Full Migration SQL

```sql
-- Migration 002: Issue Ingestion Tables
-- Applies on top of 001_initial.sql

-- Issues table
CREATE TABLE issues (
    id INTEGER PRIMARY KEY,
    gitlab_id INTEGER UNIQUE NOT NULL,
    project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    iid INTEGER NOT NULL,
    title TEXT,
    description TEXT,
    state TEXT NOT NULL CHECK (state IN ('opened', 'closed')),
    author_username TEXT,
    created_at INTEGER NOT NULL,           -- ms epoch UTC
    updated_at INTEGER NOT NULL,           -- ms epoch UTC
    last_seen_at INTEGER NOT NULL,         -- updated on every upsert
    discussions_synced_for_updated_at INTEGER, -- watermark for dependent sync
    web_url TEXT,
    raw_payload_id INTEGER REFERENCES raw_payloads(id)
);

CREATE INDEX idx_issues_project_updated ON issues(project_id, updated_at);
CREATE INDEX idx_issues_author ON issues(author_username);
CREATE UNIQUE INDEX uq_issues_project_iid ON issues(project_id, iid);

-- Labels table (name-only for CP1)
CREATE TABLE labels (
    id INTEGER PRIMARY KEY,
    gitlab_id INTEGER,                      -- optional, for future Labels API
    project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    name TEXT NOT NULL,
    color TEXT,
    description TEXT
);

CREATE UNIQUE INDEX uq_labels_project_name ON labels(project_id, name);
CREATE INDEX idx_labels_name ON labels(name);

-- Issue-label junction (DELETE before INSERT for stale removal)
CREATE TABLE issue_labels (
    issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE,
    label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
    PRIMARY KEY(issue_id, label_id)
);

CREATE INDEX idx_issue_labels_label ON issue_labels(label_id);

-- Discussion threads for issues (MR discussions added in CP2)
CREATE TABLE discussions (
    id INTEGER PRIMARY KEY,
    gitlab_discussion_id TEXT NOT NULL,     -- GitLab string ID (e.g., "6a9c1750b37d...")
    project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    issue_id INTEGER REFERENCES issues(id) ON DELETE CASCADE,
    merge_request_id INTEGER,               -- FK added in CP2 via ALTER TABLE
    noteable_type TEXT NOT NULL CHECK (noteable_type IN ('Issue', 'MergeRequest')),
    individual_note INTEGER NOT NULL DEFAULT 0,  -- 0=threaded, 1=standalone
    first_note_at INTEGER,                  -- min(note.created_at) for ordering
    last_note_at INTEGER,                   -- max(note.created_at) for "recently active"
    last_seen_at INTEGER NOT NULL,          -- updated on every upsert
    resolvable INTEGER NOT NULL DEFAULT 0,  -- MR discussions can be resolved
    resolved INTEGER NOT NULL DEFAULT 0,
    CHECK (
        (noteable_type = 'Issue' AND issue_id IS NOT NULL AND merge_request_id IS NULL) OR
        (noteable_type = 'MergeRequest' AND merge_request_id IS NOT NULL AND issue_id IS NULL)
    )
);

CREATE UNIQUE INDEX uq_discussions_project_discussion_id ON discussions(project_id, gitlab_discussion_id);
CREATE INDEX idx_discussions_issue ON discussions(issue_id);
CREATE INDEX idx_discussions_mr ON discussions(merge_request_id);
CREATE INDEX idx_discussions_last_note ON discussions(last_note_at);

-- Notes belong to discussions
CREATE TABLE notes (
    id INTEGER PRIMARY KEY,
    gitlab_id INTEGER UNIQUE NOT NULL,
    discussion_id INTEGER NOT NULL REFERENCES discussions(id) ON DELETE CASCADE,
    project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    note_type TEXT,                         -- 'DiscussionNote' | 'DiffNote' | null
    is_system INTEGER NOT NULL DEFAULT 0,   -- 1 for system-generated notes
    author_username TEXT,
    body TEXT,
    created_at INTEGER NOT NULL,            -- ms epoch
    updated_at INTEGER NOT NULL,            -- ms epoch
    last_seen_at INTEGER NOT NULL,          -- updated on every upsert
    position INTEGER,                       -- 0-indexed array order from API
    resolvable INTEGER NOT NULL DEFAULT 0,
    resolved INTEGER NOT NULL DEFAULT 0,
    resolved_by TEXT,
    resolved_at INTEGER,
    -- DiffNote position metadata (populated for MR DiffNotes in CP2)
    position_old_path TEXT,
    position_new_path TEXT,
    position_old_line INTEGER,
    position_new_line INTEGER,
    raw_payload_id INTEGER REFERENCES raw_payloads(id)
);

CREATE INDEX idx_notes_discussion ON notes(discussion_id);
CREATE INDEX idx_notes_author ON notes(author_username);
CREATE INDEX idx_notes_system ON notes(is_system);

-- Update schema version
INSERT INTO schema_version (version, applied_at, description)
VALUES (2, strftime('%s', 'now') * 1000, 'Issue ingestion tables');
```

## Acceptance Criteria

- [ ] Migration file exists at `migrations/002_issues.sql`
- [ ] All tables created: issues, labels, issue_labels, discussions, notes
- [ ] All indexes created as specified
- [ ] CHECK constraints on state and noteable_type work correctly
- [ ] CASCADE deletes work (project deletion cascades)
- [ ] Migration applies cleanly on fresh DB after 001_initial.sql
- [ ] schema_version updated to 2 after migration
- [ ] `gi doctor` shows schema_version = 2

## Files

- migrations/002_issues.sql (create)

## TDD Loop

RED:
```rust
// tests/migration_tests.rs
#[test] fn migration_002_creates_issues_table()
#[test] fn migration_002_creates_labels_table()
#[test] fn migration_002_creates_discussions_table()
#[test] fn migration_002_creates_notes_table()
#[test] fn migration_002_enforces_state_check()
#[test] fn migration_002_enforces_noteable_type_check()
#[test] fn migration_002_cascades_on_project_delete()
```

GREEN: Create migration file with all SQL

VERIFY:
```bash
# Apply migration to test DB
sqlite3 :memory: < migrations/001_initial.sql
sqlite3 :memory: < migrations/002_issues.sql

# Verify schema_version
sqlite3 test.db "SELECT version FROM schema_version ORDER BY version DESC LIMIT 1"
# Expected: 2

cargo test migration_002
```

## Edge Cases

- Applying twice - should fail on UNIQUE constraint (idempotency via version check)
- Missing 001 - foreign key to projects fails
- Long label names - TEXT handles any length
- NULL description - allowed by schema
- Empty discussions_synced_for_updated_at - NULL means never synced

Created 002_issues.sql with issues/labels/issue_labels/discussions/notes tables, 8 passing tests verify schema, constraints, and cascades


[CP1] Unit tests for transformers

Comprehensive unit tests for issue and discussion transformers.

## Issue Transformer Tests (tests/issue_transformer_tests.rs)

- transforms_gitlab_issue_to_normalized_schema
- extracts_labels_from_issue_payload
- handles_missing_optional_fields_gracefully
- converts_iso_timestamps_to_ms_epoch
- sets_last_seen_at_to_current_time

## Discussion Transformer Tests (tests/discussion_transformer_tests.rs)

- transforms_discussion_payload_to_normalized_schema
- extracts_notes_array_from_discussion
- sets_individual_note_flag_correctly
- flags_system_notes_with_is_system_true
- preserves_note_order_via_position_field
- computes_first_note_at_and_last_note_at_correctly
- computes_resolvable_and_resolved_status

## Test Setup
- Load from test fixtures
- Use serde_json for deserialization
- Compare against expected NormalizedX structs

Files: tests/issue_transformer_tests.rs, tests/discussion_transformer_tests.rs
Done when: All transformer unit tests pass


Epic: Gate A - Lexical MVP

## Background
Gate A delivers the lexical search MVP — the foundation that works without sqlite-vec or Ollama. It introduces the document layer (documents, document_labels, document_paths), FTS5 indexing, search filters, and the search + stats + generate-docs CLI commands. Gate A is independently shippable — users get working search with FTS5 only.

## Gate A Deliverables
1. Document generation from issues/MRs/discussions with FTS5 indexing
2. Lexical search + filters + snippets + lore stats

## Bead Dependencies (execution order)
1. **bd-3lc** — Rename GiError to LoreError (no deps, enables all subsequent work)
2. **bd-hrs** — Migration 007 (blocked by bd-3lc)
3. **bd-221** — Migration 008 FTS5 (blocked by bd-hrs)
4. **bd-36p** — Document types + extractor module (blocked by bd-3lc)
5. **bd-18t** — Truncation logic (blocked by bd-36p)
6. **bd-247** — Issue extraction (blocked by bd-36p, bd-hrs)
7. **bd-1yz** — MR extraction (blocked by bd-36p, bd-hrs)
8. **bd-2fp** — Discussion extraction (blocked by bd-36p, bd-hrs, bd-18t)
9. **bd-1u1** — Document regenerator (blocked by bd-36p, bd-38q, bd-hrs)
10. **bd-1k1** — FTS5 search (blocked by bd-221)
11. **bd-3q2** — Search filters (blocked by bd-36p)
12. **bd-3lu** — Search CLI (blocked by bd-1k1, bd-3q2, bd-36p)
13. **bd-3qs** — Generate-docs CLI (blocked by bd-1u1, bd-3lu)
14. **bd-pr1** — Stats CLI (blocked by bd-hrs)
15. **bd-2dk** — Project resolution (blocked by bd-3lc)

## Acceptance Criteria
- [ ] `lore search "query"` returns FTS5 results with snippets
- [ ] `lore search --type issue --label bug "query"` filters correctly
- [ ] `lore generate-docs` creates documents from all entities
- [ ] `lore generate-docs --full` regenerates everything
- [ ] `lore stats` shows document/FTS/queue counts
- [ ] `lore stats --check` verifies FTS consistency
- [ ] No sqlite-vec dependency in Gate A

All Gate A sub-beads complete. Lexical MVP delivered: document extraction (issue/MR/discussion), FTS5 indexing, search with filters/snippets/RRF, generate-docs CLI, stats CLI with integrity check/repair.


Implement trace query: file -> MR -> issue -> discussion chain

## Background
lore trace builds a chain from file to motivating decisions (Tier 1: API-only, no git blame). Combines file-history (bd-z94) with cross-reference data (bd-3ia) to answer "Why was this code introduced?"

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

```rust
pub struct TraceResult {
    pub file_path: String,
    pub rename_chain: Option<Vec<String>>,
    pub trace_chains: Vec<TraceChain>,
}

pub struct TraceChain {
    pub mr: TraceMR,
    pub issues: Vec<TraceIssue>,
    pub file_discussions: Vec<TraceDiscussion>,
}

pub struct TraceMR {
    pub iid: i64,
    pub title: String,
    pub state: String,
    pub author: String,
    pub merged_at: Option<i64>,
    pub created_at: i64,
    pub web_url: String,
    pub change_type: String,
    pub discussion_count: usize,
    pub file_discussion_count: usize,
}

pub struct TraceIssue {
    pub iid: i64,
    pub title: String,
    pub state: String,
    pub web_url: String,
    pub reference_type: String,    // "closes" | "related"
    pub discussion_count: usize,
}

pub struct TraceDiscussion {
    pub note_id: i64,
    pub author: String,
    pub snippet: String,           // first ~200 chars
    pub position_path: String,
    pub position_line: Option<i64>,
    pub created_at: i64,
}

/// Build trace chains for a file path.
pub fn build_trace(
    conn: &Connection,
    path: &str,
    project_id: Option<i64>,
    follow_renames: bool,
) -> Result<TraceResult>
```

Query flow (spec §5.4):
1. Resolve rename chain (reuse file_history::resolve_rename_chain)
2. Find MRs via mr_file_changes (same query as file-history)
3. For each MR, find related issues via entity_references WHERE source=MR AND reference_type IN ('closes', 'related')
4. For each issue, count discussions
5. For each MR, fetch DiffNote discussions on the traced file:
```sql
SELECT n.id, n.author_username, n.body, n.position_new_path, n.position_new_line, n.created_at
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE d.merge_request_id = ?
  AND (n.position_new_path IN (rename_chain_paths))
ORDER BY n.created_at;
```
6. Build trace chains, ordered by MR merge date (most recent first)

Register in src/core/mod.rs: `pub mod trace;`

## Acceptance Criteria
- [ ] Correct MR-to-issue linking via entity_references
- [ ] DiffNote snippets for the traced file included
- [ ] Multi-hop rename resolution applied
- [ ] Chains ordered by merge date (most recent first)
- [ ] Graceful when no MR data found
- [ ] Issues show reference_type (closes vs related)

## Files
- src/core/trace.rs (new)
- src/core/mod.rs (add `pub mod trace;`)

## TDD Loop
RED: tests/trace_tests.rs:
- `test_trace_basic_chain` - MR→issue→discussions chain built correctly
- `test_trace_with_file_discussions` - DiffNotes on the file included
- `test_trace_no_data` - empty result, not error
- `test_trace_multiple_mrs` - multiple MRs touching file, each with its own chain
- `test_trace_rename_chain` - renamed file finds MRs at old path

Setup: create_test_db with full migrations, seed issues + MRs + entity_references + notes with positions + mr_file_changes.

GREEN: Implement build_trace

VERIFY: `cargo test trace -- --nocapture`

## Edge Cases
- MR with no linked issues (no entity_references): chain has MR but empty issues list
- DiffNote with position on OLD path (before rename): check position_old_path too
- Very large repos: file may have 50+ MRs — respect caller's limit
- Deleted issues: entity_reference target may point to deleted issue — skip gracefully


[CP1] Issue ingestion module

Fetch and store issues with cursor-based incremental sync.

Implement ingestIssues(options) → { fetched, upserted, labelsCreated }

Logic:
1. Get current cursor from sync_cursors
2. Paginate through issues updated after cursor
3. Apply local filtering for tuple cursor semantics
4. For each issue:
   - Store raw payload (compressed)
   - Upsert issue record
   - Extract and upsert labels
   - Link issue to labels via junction
5. Update cursor after each page commit

Files: src/ingestion/issues.ts
Tests: tests/integration/issue-ingestion.test.ts
Done when: Issues, labels, issue_labels populated correctly with resumable cursor


OBSERV Epic: Phase 2 - Spans + Correlation IDs

Add tracing spans to all sync stages and generate UUID-based run_id for correlation. Every log line within a sync run includes run_id in JSON span context. Nested spans produce correct parent-child chains.

Depends on: Phase 1 (subscriber must support span recording)
Unblocks: Phase 3 (metrics), Phase 5 (rate limit logging)

Files: src/cli/commands/sync.rs, src/cli/commands/ingest.rs, src/ingestion/orchestrator.rs, src/documents/regenerator.rs, src/embedding/pipeline.rs, src/main.rs

Acceptance criteria (PRD Section 6.2):
- Every log line includes run_id in JSON span context
- Nested spans produce chain: fetch_pages includes parent ingest_issues span
- run_id is 8-char hex (truncated UUIDv4)
- Spans visible in -vv stderr output

Phase 2 complete: run_id correlation IDs generated at sync/ingest entry, root spans with .instrument() for async, #[instrument] on 5 key pipeline functions


Write integration tests

## Background
Integration tests verify that modules work together with a real SQLite database. They test FTS search (stemming, empty results), embedding storage (sqlite-vec ops), hybrid search (combined retrieval), and sync orchestration (full pipeline). Each test creates a fresh in-memory DB with migrations applied.

## Approach
Create integration test files in `tests/`:

**1. tests/fts_search.rs:**
- Create DB, apply migrations 001-008
- Insert test documents via SQL
- Verify FTS5 triggers fired (documents_fts has matching count)
- Search with various queries: stemming, prefix, empty, special chars
- Verify result ranking (BM25 ordering)
- Verify snippet generation

**2. tests/embedding.rs:**
- Create DB, apply migrations 001-009 (requires sqlite-vec)
- Insert test documents + embeddings with known vectors
- Verify KNN search returns nearest neighbors
- Verify chunk deduplication
- Verify orphan cleanup trigger (delete document -> embeddings gone)

**3. tests/hybrid_search.rs:**
- Create DB, apply all migrations
- Insert documents + embeddings
- Test all three modes: lexical, semantic, hybrid
- Verify RRF ranking produces expected order
- Test graceful degradation (no embeddings -> FTS fallback)
- Test adaptive recall with filters

**4. tests/sync.rs:**
- Test sync orchestration with mock/stub GitLab responses
- Verify pipeline stages execute in order
- Verify lock acquisition/release
- Verify --no-embed and --no-docs flags

**Test fixtures:**
- Deterministic embedding vectors (no Ollama required): e.g., [1.0, 0.0, 0.0, ...] for doc1, [0.0, 1.0, 0.0, ...] for doc2
- Known documents with predictable search results
- Fixed timestamps for reproducibility

## Acceptance Criteria
- [ ] FTS search tests pass (stemming, prefix, empty, special chars)
- [ ] Embedding tests pass (KNN, dedup, orphan cleanup)
- [ ] Hybrid search tests pass (all 3 modes, graceful degradation)
- [ ] Sync tests pass (pipeline orchestration)
- [ ] All tests use in-memory DB (no file I/O)
- [ ] No external dependencies (no Ollama, no GitLab) — use fixtures/stubs
- [ ] `cargo test --test fts_search --test embedding --test hybrid_search --test sync` passes

## Files
- `tests/fts_search.rs` — new file
- `tests/embedding.rs` — new file
- `tests/hybrid_search.rs` — new file
- `tests/sync.rs` — new file
- `tests/fixtures/` — optional: test helper functions (shared DB setup)

## TDD Loop
These ARE integration tests — they verify the combined behavior of multiple beads.
VERIFY: `cargo test --test fts_search && cargo test --test embedding && cargo test --test hybrid_search && cargo test --test sync`

## Edge Cases
- sqlite-vec not available: embedding tests should skip gracefully (or require feature flag)
- In-memory DB with WAL mode: may behave differently than file DB — test both if critical
- Concurrent test execution: each test creates its own DB (no shared state)

Integration tests: 10 FTS search tests (stemming, empty, special chars, ordering, triggers, null title), 5 embedding tests (KNN, limit, dedup, orphan trigger, empty DB), 6 hybrid search tests (lexical mode, FTS-only, graceful degradation, RRF ranking, filters, mode variants). 310 total tests pass.


OBSERV Epic: Phase 1 - Verbosity Flags + Structured File Logging

Foundation layer for observability. Add -v/-vv/-vvv CLI flags, dual-layer tracing subscriber (stderr + file), daily log rotation via tracing-appender, log retention cleanup, --log-format json flag, and LoggingConfig.

Depends on: nothing (first phase)
Unblocks: Phase 2, and transitively all other phases

Files: Cargo.toml, src/cli/mod.rs, src/main.rs, src/core/config.rs, src/core/paths.rs, src/cli/commands/doctor.rs

Acceptance criteria (PRD Section 6.1):
- JSON log files written to ~/.local/share/lore/logs/ with zero config
- -v/-vv/-vvv control stderr verbosity per table in PRD 4.3
- RUST_LOG overrides -v for both layers
- --log-format json emits JSON on stderr
- Daily rotation, retention cleanup on startup
- --quiet suppresses stderr, does NOT affect file layer
- lore doctor reports log directory info

Phase 1 complete: dual-layer subscriber, -v/--verbose flags, --log-format json, LoggingConfig, get_log_dir(), log retention, doctor diagnostics


[CP1] Epic: Issue Ingestion

Ingest all issues, labels, and issue discussions from configured GitLab repositories with resumable cursor-based incremental sync. This establishes the core data ingestion pattern reused for MRs in CP2.

## Success Criteria
- gi ingest --type=issues fetches all issues (count matches GitLab UI)
- Labels extracted from issue payloads (name-only)
- Label linkage reflects current GitLab state (removed labels unlinked on re-sync)
- Issue discussions fetched per-issue (dependent sync)
- Cursor-based sync is resumable (re-running fetches 0 new items)
- Discussion sync skips unchanged issues (per-issue watermark)
- Sync tracking records all runs
- Single-flight lock prevents concurrent runs

## Internal Gates
- Gate A: Issues only (cursor + upsert + raw payloads + list/count/show)
- Gate B: Labels correct (stale-link removal verified)
- Gate C: Dependent discussion sync (watermark prevents redundant refetch)
- Gate D: Resumability proof (kill mid-run, rerun; bounded redo)

Reference: docs/prd/checkpoint-1.md


OBSERV: Replace subscriber init with dual-layer setup

## Background
This is the core infrastructure bead for Phase 1. It replaces the single-layer subscriber (src/main.rs:44-58) with a dual-layer registry that separates stderr and file concerns. The file layer provides always-on post-mortem data; the stderr layer respects -v flags.

## Approach
Replace src/main.rs lines 44-58 with a function (e.g., init_tracing()) that:

1. Build stderr filter from -v count (or RUST_LOG override):
```rust
fn build_stderr_filter(verbose: u8, quiet: bool) -> EnvFilter {
    if let Ok(rust_log) = std::env::var("RUST_LOG") {
        return EnvFilter::new(rust_log);
    }
    if quiet {
        return EnvFilter::new("lore=warn,error");
    }
    match verbose {
        0 => EnvFilter::new("lore=info,warn"),
        1 => EnvFilter::new("lore=debug,warn"),
        2 => EnvFilter::new("lore=debug,info"),
        _ => EnvFilter::new("trace,debug"),
    }
}
```

2. Build file filter (always lore=debug,warn unless RUST_LOG set):
```rust
fn build_file_filter() -> EnvFilter {
    if let Ok(rust_log) = std::env::var("RUST_LOG") {
        return EnvFilter::new(rust_log);
    }
    EnvFilter::new("lore=debug,warn")
}
```

3. Assemble the registry:
```rust
let stderr_layer = fmt::layer()
    .with_target(false)
    .with_writer(SuspendingWriter);
// Conditionally add .json() based on log_format

let file_appender = tracing_appender::rolling::daily(log_dir, "lore");
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
let file_layer = fmt::layer()
    .json()
    .with_writer(non_blocking);

tracing_subscriber::registry()
    .with(stderr_layer.with_filter(build_stderr_filter(cli.verbose, cli.quiet)))
    .with(file_layer.with_filter(build_file_filter()))
    .init();
```

CRITICAL: The non_blocking _guard must be held for the program's lifetime. Store it in main() scope, NOT in the init function. If the guard drops, the file writer thread stops and buffered logs are lost.

CRITICAL: Per-layer filtering requires each .with_filter() to produce a Filtered<L, F, S> type. The two layers will have different concrete types (one with json, one without). This is fine -- the registry accepts heterogeneous layers via .with().

When --log-format json: wrap stderr_layer with .json() too. This requires conditional construction. Two approaches:
  A) Use Box<dyn Layer<S>> for dynamic dispatch (simpler, tiny perf hit)
  B) Use an enum wrapper (zero cost but more code)
Recommend approach A for simplicity. The overhead is one vtable indirection per log event, dwarfed by I/O.

When file_logging is false (LoggingConfig.file_logging == false): skip adding the file layer entirely.

## Acceptance Criteria
- [ ] lore sync writes JSON log lines to ~/.local/share/lore/logs/lore.YYYY-MM-DD.log
- [ ] lore -v sync shows DEBUG lore::* on stderr, deps at WARN
- [ ] lore -vv sync shows DEBUG lore::* + INFO deps on stderr
- [ ] lore -vvv sync shows TRACE everything on stderr
- [ ] RUST_LOG=lore::gitlab=trace overrides -v for both layers
- [ ] lore --log-format json sync emits JSON on stderr
- [ ] -q + -v: -q wins (stderr at WARN+)
- [ ] -q does NOT affect file layer (still DEBUG+)
- [ ] File layer does NOT use SuspendingWriter
- [ ] Non-blocking guard kept alive for program duration
- [ ] Existing behavior unchanged when no new flags passed
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/main.rs (replace lines 44-58, add init_tracing function or inline)

## TDD Loop
RED:
  - test_verbosity_filter_construction: assert filter directives for verbose=0,1,2,3
  - test_rust_log_overrides_verbose: set env, assert TRACE not DEBUG
  - test_quiet_overrides_verbose: -q + -v => WARN+
  - test_json_log_output_format: capture file output, parse as JSON
  - test_suspending_writer_dual_layer: no garbled stderr with progress bars
GREEN: Implement build_stderr_filter, build_file_filter, assemble registry
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- _guard lifetime: if guard is dropped early, buffered log lines are lost. MUST hold in main() scope.
- Type erasure: stderr layer with/without .json() produces different types. Use Box<dyn Layer<_>> or separate init paths.
- Empty RUST_LOG string: env::var returns Ok(""), which EnvFilter::new("") defaults to TRACE. May want to check is_empty().
- File I/O error on log dir: tracing-appender handles this gracefully (no panic), but logs will be silently lost. The doctor command (bd-2i10) can diagnose this.

Replaced single-layer subscriber with dual-layer setup: stderr (human/json, -v controlled) + file (always-on JSON, daily rotation via tracing-appender)


Implement lore embed CLI command

## Background
The embed CLI command is the user-facing wrapper for the embedding pipeline. It runs Ollama health checks, selects documents to embed (pending or failed), shows progress, and reports results. This is the standalone command for building embeddings outside of the sync orchestrator.

## Approach
Create `src/cli/commands/embed.rs` per PRD Section 4.4.

**IMPORTANT: The embed command is async.** The underlying `embed_documents()` function is `async fn` (uses `FuturesUnordered` for concurrent HTTP to Ollama). The CLI runner must use tokio runtime.

**Core function (async):**
```rust
pub async fn run_embed(
    config: &Config,
    retry_failed: bool,
) -> Result<EmbedResult>
```

**Pipeline:**
1. Create OllamaClient from config.embedding (base_url, model, timeout_secs)
2. Run `client.health_check().await` — fail early with clear error if Ollama unavailable or model missing
3. Determine selection: `EmbedSelection::RetryFailed` if --retry-failed, else `EmbedSelection::Pending`
4. Call `embed_documents(conn, &client, selection, concurrency, progress_callback).await`
   - `concurrency` param controls max in-flight HTTP requests to Ollama
   - `progress_callback` drives indicatif progress bar
5. Show progress bar (indicatif) during embedding
6. Return EmbedResult with counts

**CLI args:**
```rust
#[derive(Args)]
pub struct EmbedArgs {
    #[arg(long)]
    retry_failed: bool,
}
```

**Output:**
- Human: "Embedded 42 documents (15 chunks), 2 errors, 5 skipped (unchanged)"
- JSON: `{"ok": true, "data": {"embedded": 42, "chunks": 15, "errors": 2, "skipped": 5}}`

**Tokio integration note:**
The embed command runs async code. Either:
- Use `#[tokio::main]` on main and propagate async through CLI dispatch
- Or use `tokio::runtime::Runtime::new()` in the embed command handler

## Acceptance Criteria
- [ ] Command is async (embed_documents is async, health_check is async)
- [ ] OllamaClient created from config.embedding settings
- [ ] Health check runs first — clear error if Ollama down (exit code 14)
- [ ] Clear error if model not found: "Pull the model: ollama pull nomic-embed-text" (exit code 15)
- [ ] Embeds pending documents (no existing embeddings or stale content_hash)
- [ ] --retry-failed re-attempts documents with last_error
- [ ] Progress bar shows during embedding (indicatif)
- [ ] embed_documents called with concurrency parameter
- [ ] embed_documents called with progress_callback for progress bar
- [ ] Human + JSON output
- [ ] `cargo build` succeeds

## Files
- `src/cli/commands/embed.rs` — new file
- `src/cli/commands/mod.rs` — add `pub mod embed;`
- `src/cli/mod.rs` — add EmbedArgs, wire up embed subcommand
- `src/main.rs` — add embed command handler (async dispatch)

## TDD Loop
RED: Integration test needing Ollama
GREEN: Implement run_embed (async)
VERIFY: `cargo build && cargo test embed`

## Edge Cases
- No documents in DB: "No documents to embed" (not error)
- All documents already embedded and unchanged: "0 documents to embed (all up to date)"
- Ollama goes down mid-embedding: pipeline records errors for remaining docs, returns partial result
- --retry-failed with no failed docs: "No failed documents to retry"

Embed CLI command fully wired: EmbedArgs, Commands::Embed variant, handle_embed handler, clean build, all tests pass


[CP1] gi ingest --type=issues command

CLI command to orchestrate issue ingestion.

## Module
src/cli/commands/ingest.rs

## Clap Definition
#[derive(Subcommand)]
pub enum Commands {
    Ingest {
        #[arg(long, value_parser = ["issues", "merge_requests"])]
        r#type: String,

        #[arg(long)]
        project: Option<String>,

        #[arg(long)]
        force: bool,
    },
}

## Implementation
1. Acquire app lock with heartbeat (respect --force for stale lock)
2. Create sync_run record (status='running')
3. For each configured project (or filtered --project):
   - Call orchestrator to ingest issues and discussions
   - Show progress (spinner or progress bar)
4. Update sync_run (status='succeeded', metrics_json with counts)
5. Release lock

## Output Format
Ingesting issues...

  group/project-one: 1,234 issues fetched, 45 new labels

Fetching discussions (312 issues with updates)...

  group/project-one: 312 issues → 1,234 discussions, 5,678 notes

Total: 1,234 issues, 1,234 discussions, 5,678 notes (excluding 1,234 system notes)
Skipped discussion sync for 922 unchanged issues.

## Error Handling
- Lock acquisition failure: exit with DatabaseLockError message
- Network errors: show GitLabNetworkError, exit non-zero
- Rate limiting: respect backoff, show progress

Files: src/cli/commands/ingest.rs, src/cli/commands/mod.rs
Tests: tests/integration/sync_runs_tests.rs
Done when: Full issue + discussion ingestion works end-to-end


[CP1] Epic: Issue Ingestion

Ingest all issues, labels, and issue discussions from configured GitLab repositories with resumable cursor-based incremental sync. This checkpoint establishes the core data ingestion pattern that will be reused for MRs in Checkpoint 2.

## Success Criteria
- gi ingest --type=issues fetches all issues (count matches GitLab UI)
- Labels extracted from issue payloads (name-only)
- Label linkage reflects current GitLab state (removed labels unlinked on re-sync)
- Issue discussions fetched per-issue (dependent sync)
- Cursor-based sync is resumable (re-running fetches 0 new items)
- Discussion sync skips unchanged issues (per-issue watermark)
- Sync tracking records all runs (sync_runs table)
- Single-flight lock prevents concurrent runs

## Internal Gates
- **Gate A**: Issues only - cursor + upsert + raw payloads + list/count/show working
- **Gate B**: Labels correct - stale-link removal verified; label count matches GitLab
- **Gate C**: Dependent discussion sync - watermark prevents redundant refetch; concurrency bounded
- **Gate D**: Resumability proof - kill mid-run, rerun; bounded redo and no redundant discussion refetch

## Reference
docs/prd/checkpoint-1.md

CP1 Issue Ingestion complete: all sub-tasks done, 71 tests pass, CLI commands working


Fetch MR diffs API and populate mr_file_changes

## Background
GET /projects/:id/merge_requests/:iid/diffs returns file change metadata. We extract file paths and change types but NOT diff content. Uses the generic dependent fetch queue (job_type = 'mr_diffs').

## Approach

**1. Add API endpoint (src/gitlab/client.rs):**
```rust
pub async fn fetch_mr_diffs(&self, project_id: i64, iid: i64) -> Result<Vec<GitLabDiffFile>>
```

New type in src/gitlab/types.rs:
```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabDiffFile {
    pub old_path: String,
    pub new_path: String,
    pub new_file: bool,
    pub renamed_file: bool,
    pub deleted_file: bool,
    // diff content fields exist but we ignore them
}
```

URL: `GET /api/v4/projects/{project_id}/merge_requests/{iid}/diffs?per_page=100`

**2. Enqueue jobs during MR ingestion:**
In orchestrator.rs, after MR upsert (when fetch_mr_file_changes is true):
```rust
if config.sync.fetch_mr_file_changes {
    enqueue_job(conn, project_id, "merge_request", iid, local_id, "mr_diffs", None)?;
}
```

**3. Process jobs in drain step:**
Handle "mr_diffs" job_type:
```rust
let diffs = client.fetch_mr_diffs(gitlab_project_id, job.entity_iid).await?;
// DELETE existing rows for this MR (diffs can change on rebase)
conn.execute("DELETE FROM mr_file_changes WHERE merge_request_id = ?", [job.entity_local_id])?;
// Insert new rows
for diff in &diffs {
    let change_type = if diff.new_file { "added" }
        else if diff.renamed_file { "renamed" }
        else if diff.deleted_file { "deleted" }
        else { "modified" };
    conn.execute(
        "INSERT INTO mr_file_changes (merge_request_id, project_id, old_path, new_path, change_type) VALUES (?, ?, ?, ?, ?)",
        params![job.entity_local_id, project_id,
                if diff.renamed_file { Some(&diff.old_path) } else { None },
                &diff.new_path, change_type],
    )?;
}
```

**4. Also capture commit SHAs:**
During MR ingestion (orchestrator.rs), update merge_requests with merge_commit_sha and squash_commit_sha from the GitLab API response. These fields need to be added to GitLabMergeRequest type and transformer.

Add to src/gitlab/types.rs GitLabMergeRequest:
```rust
pub merge_commit_sha: Option<String>,
pub squash_commit_sha: Option<String>,
```

Update MR transformer to pass these through, and UPDATE merge_requests SET merge_commit_sha = ?, squash_commit_sha = ? during upsert.

## Acceptance Criteria
- [ ] fetch_mr_diffs returns file metadata (no diff content)
- [ ] Change types correctly derived: new_file→added, renamed_file→renamed, deleted_file→deleted, else→modified
- [ ] Re-sync DELETEs + re-inserts (handles rebased MRs)
- [ ] old_path only populated for renamed files
- [ ] merge_commit_sha and squash_commit_sha captured in merge_requests table
- [ ] Jobs only enqueued when fetch_mr_file_changes is true

## Files
- src/gitlab/client.rs (add fetch_mr_diffs)
- src/gitlab/types.rs (add GitLabDiffFile, add fields to GitLabMergeRequest)
- src/gitlab/transformers/merge_request.rs (pass through commit SHAs)
- src/ingestion/orchestrator.rs (enqueue mr_diffs jobs, update commit SHAs)
- src/core/drain.rs or sync.rs (handle mr_diffs in drain dispatcher)

## TDD Loop
RED: tests/file_changes_tests.rs:
- `test_derive_change_type_added` - new_file=true → "added"
- `test_derive_change_type_renamed` - renamed_file=true → "renamed", old_path populated
- `test_derive_change_type_deleted` - deleted_file=true → "deleted"
- `test_derive_change_type_modified` - all false → "modified"
- `test_resync_deletes_and_reinserts` - insert, then re-process with different files, verify old rows gone

tests/gitlab_types_tests.rs:
- `test_deserialize_diff_file` - verify GitLabDiffFile deserialization
- `test_deserialize_mr_with_commit_shas` - verify new fields on GitLabMergeRequest

GREEN: Implement API endpoint, change type derivation, drain handler, commit SHA capture

VERIFY: `cargo test file_changes -- --nocapture && cargo test gitlab_types -- --nocapture`

## Edge Cases
- MR with 1000+ files (monorepo): pagination essential on diffs endpoint
- old_path for non-renames: GitLab still returns old_path (same as new_path) — only store when renamed_file=true
- Draft MRs: diffs may change frequently — DELETE+INSERT handles this
- MR with no diffs (empty MR): returns empty array, no rows inserted, job still completed
- merge_commit_sha is NULL until MR is merged — don't error on NULL


[CP1] Issue transformer with label extraction

Transform GitLab issue payloads to normalized database schema.

Functions to implement:
- transformIssue(gitlabIssue, localProjectId) → NormalizedIssue
- extractLabels(gitlabIssue, localProjectId) → Label[]

Transformation rules:
- Convert ISO timestamps to ms epoch using isoToMs()
- Set last_seen_at to nowMs()
- Handle labels vs labels_details (prefer details when available)
- Handle missing optional fields gracefully

Files: src/gitlab/transformers/issue.ts
Tests: tests/unit/issue-transformer.test.ts
Done when: Unit tests pass for payload transformation and label extraction


[CP1] Cargo.toml updates - async-stream and futures

## Background

The GitLab client pagination methods require async streaming capabilities. The `async-stream` crate provides the `stream!` macro for creating async iterators, and `futures` provides `StreamExt` for consuming them with `.next()` and other combinators.

## Approach

Add these dependencies to Cargo.toml:

```toml
[dependencies]
async-stream = "0.3"
futures = { version = "0.3", default-features = false, features = ["alloc"] }
```

Use minimal features on `futures` to avoid pulling unnecessary code.

## Acceptance Criteria

- [ ] `async-stream = "0.3"` is in Cargo.toml [dependencies]
- [ ] `futures` with `alloc` feature is in Cargo.toml [dependencies]
- [ ] `cargo check` succeeds after adding dependencies

## Files

- Cargo.toml (edit)

## TDD Loop

RED: Not applicable (dependency addition)
GREEN: Add lines to Cargo.toml
VERIFY: `cargo check`

## Edge Cases

- If `futures` is already present, merge features rather than duplicate
- Use exact version pins for reproducibility

Added async-stream 0.3 and futures 0.3 (alloc feature) to Cargo.toml, cargo check passes


Epic: Gate 1 - Resource Events Ingestion

Ingest structured event data from GitLab Resource Events APIs (state, label, milestone) into local event tables. Includes migration 011 (renumbered from spec's 010 since 010_chunk_config.sql already exists), config extension, API client endpoints, generic dependent fetch queue, and ingestion pipeline integration.

Children: bd-hu3 (migration 011), bd-2e8 (config), bd-2fm (types), bd-sqw (API), bd-1uc (DB upserts), bd-tir (queue), bd-1ep (pipeline wiring), bd-3sh (count events), bd-1m8 (stats check)


[CP1] GitLab client pagination methods

Add async stream methods for paginated GitLab API calls.

## Methods to Add to GitLabClient

### paginate_issues(gitlab_project_id, updated_after, cursor_rewind_seconds) -> Stream<GitLabIssue>
- Use async_stream::try_stream! macro
- Query params: scope=all, state=all, order_by=updated_at, sort=asc, per_page=100
- If updated_after provided, apply cursor_rewind_seconds (subtract from timestamp)
- Clamp to 0 to avoid underflow: (ts - rewind_ms).max(0)
- Follow x-next-page header until empty/absent
- Fall back to empty-page detection if headers missing

### paginate_issue_discussions(gitlab_project_id, issue_iid) -> Stream<GitLabDiscussion>
- Paginate through discussions for single issue
- per_page=100
- Follow x-next-page header

### request_with_headers<T>(path, params) -> Result<(T, HeaderMap)>
- Acquire rate limiter
- Make request with PRIVATE-TOKEN header
- Return both deserialized data and response headers

## Dependencies
- async-stream = "0.3" (for try_stream! macro)
- futures = "0.3" (for Stream trait and StreamExt)

Files: src/gitlab/client.rs
Tests: tests/pagination_tests.rs
Done when: Pagination handles multiple pages and respects cursors, tests pass


[CP1] Discussion ingestion module

Fetch and store discussions/notes for each issue.

Implement ingestIssueDiscussions(options) → { discussionsFetched, discussionsUpserted, notesUpserted, systemNotesCount }

Logic:
1. Paginate through all discussions for given issue
2. For each discussion:
   - Store raw payload (compressed)
   - Upsert discussion record with correct issue FK
   - Transform and upsert all notes
   - Store raw payload per note
   - Track system notes count

Files: src/ingestion/discussions.ts
Tests: tests/integration/issue-discussion-ingestion.test.ts
Done when: Discussions and notes populated with correct FKs and is_system flags


Epic: CP2 Gate B - Labels + Assignees + Reviewers

## Background
Gate B validates junction tables for labels, assignees, and reviewers. Ensures relationships are tracked correctly and stale links are removed on resync. This is critical for filtering (`--reviewer=alice`) and display.

## Acceptance Criteria (Pass/Fail)
- [ ] `mr_labels` table has rows for MRs with labels
- [ ] Label count per MR matches GitLab UI (spot check 3 MRs)
- [ ] `mr_assignees` table has rows for MRs with assignees
- [ ] Assignee usernames match GitLab UI (spot check 3 MRs)
- [ ] `mr_reviewers` table has rows for MRs with reviewers
- [ ] Reviewer usernames match GitLab UI (spot check 3 MRs)
- [ ] Remove label in GitLab -> resync -> link removed from mr_labels
- [ ] Add reviewer in GitLab -> resync -> link added to mr_reviewers
- [ ] `gi list mrs --label=bugfix` filters correctly
- [ ] `gi list mrs --reviewer=alice` filters correctly

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

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

echo "=== Gate B: Labels + Assignees + Reviewers ==="

# 1. Check label linkage exists
echo "Step 1: Check label linkage..."
LABEL_LINKS=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM mr_labels;")
echo "  Total label links: $LABEL_LINKS"

# 2. Show sample label linkage
echo "Step 2: Sample label linkage..."
sqlite3 "$DB_PATH" "
  SELECT m.iid, GROUP_CONCAT(l.name, ', ') as labels
  FROM merge_requests m
  JOIN mr_labels ml ON ml.merge_request_id = m.id
  JOIN labels l ON l.id = ml.label_id
  GROUP BY m.id
  LIMIT 5;
"

# 3. Check assignee linkage
echo "Step 3: Check assignee linkage..."
ASSIGNEE_LINKS=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM mr_assignees;")
echo "  Total assignee links: $ASSIGNEE_LINKS"

# 4. Show sample assignee linkage
echo "Step 4: Sample assignee linkage..."
sqlite3 "$DB_PATH" "
  SELECT m.iid, GROUP_CONCAT(a.username, ', ') as assignees
  FROM merge_requests m
  JOIN mr_assignees a ON a.merge_request_id = m.id
  GROUP BY m.id
  LIMIT 5;
"

# 5. Check reviewer linkage
echo "Step 5: Check reviewer linkage..."
REVIEWER_LINKS=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM mr_reviewers;")
echo "  Total reviewer links: $REVIEWER_LINKS"

# 6. Show sample reviewer linkage
echo "Step 6: Sample reviewer linkage..."
sqlite3 "$DB_PATH" "
  SELECT m.iid, GROUP_CONCAT(r.username, ', ') as reviewers
  FROM merge_requests m
  JOIN mr_reviewers r ON r.merge_request_id = m.id
  GROUP BY m.id
  LIMIT 5;
"

# 7. Test filter commands
echo "Step 7: Test filter commands..."
# Get a label that exists
LABEL=$(sqlite3 "$DB_PATH" "SELECT name FROM labels LIMIT 1;")
if [ -n "$LABEL" ]; then
  echo "  Testing --label=$LABEL"
  gi list mrs --label="$LABEL" --limit=3
fi

# Get a reviewer that exists
REVIEWER=$(sqlite3 "$DB_PATH" "SELECT username FROM mr_reviewers LIMIT 1;")
if [ -n "$REVIEWER" ]; then
  echo "  Testing --reviewer=$REVIEWER"
  gi list mrs --reviewer="$REVIEWER" --limit=3
fi

echo ""
echo "=== Gate B: PASSED ==="
```

## Stale Link Removal Test (Manual)
```bash
# 1. Pick an MR with labels in GitLab UI
MR_IID=123

# 2. Note current label count
sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM mr_labels ml
  JOIN merge_requests m ON m.id = ml.merge_request_id
  WHERE m.iid = $MR_IID;
"
# Example: 3 labels

# 3. Remove a label in GitLab UI (manually)

# 4. Resync
gi ingest --type=merge_requests

# 5. Verify label removed
sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM mr_labels ml
  JOIN merge_requests m ON m.id = ml.merge_request_id
  WHERE m.iid = $MR_IID;
"
# Should be: 2 labels (one less)
```

## Test Commands (Quick Verification)
```bash
# Check counts:
sqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 "
  SELECT
    (SELECT COUNT(*) FROM mr_labels) as label_links,
    (SELECT COUNT(*) FROM mr_assignees) as assignee_links,
    (SELECT COUNT(*) FROM mr_reviewers) as reviewer_links;
"

# Test filtering:
gi list mrs --label=enhancement --limit=5
gi list mrs --reviewer=alice --limit=5
gi list mrs --assignee=bob --limit=5
```

## Dependencies
This gate requires:
- bd-ser (MR ingestion with label/assignee/reviewer linking via clear-and-relink pattern)
- Gate A must pass first

## Edge Cases
- MRs with no labels/assignees/reviewers: junction tables should have no rows for that MR
- Labels shared across issues and MRs: labels table is shared, only junction differs
- Usernames are case-sensitive: `Alice` != `alice`


[CP1] Test fixtures for mocked GitLab responses

Create mock response files for integration tests.

Fixtures to create:
- gitlab-issue.json (single issue with labels)
- gitlab-issues-page.json (paginated list)
- gitlab-discussion.json (single discussion with notes)
- gitlab-discussions-page.json (paginated list)

Include edge cases:
- Issue with labels_details
- Issue with no labels
- Discussion with individual_note=true
- System notes with system=true

Files: tests/fixtures/mock-responses/gitlab-issue*.json, gitlab-discussion*.json
Done when: MSW handlers can use fixtures for deterministic tests


[CP0] Project scaffold

## Background

This is the foundational scaffold for the GitLab Inbox CLI tool. Every subsequent bead depends on having the correct project structure, TypeScript configuration, and tooling in place. The configuration choices here (ESM modules, strict TypeScript, Vitest for testing) set constraints for all future code.

## Approach

Create a Node.js 20+ ESM project with TypeScript strict mode. Use flat ESLint config (v9+) with TypeScript plugin. Configure Vitest with coverage. Create the directory structure matching the PRD exactly.

**package.json essentials:**
- `"type": "module"` for ESM
- `"bin": { "gi": "./dist/cli/index.js" }` for CLI entry point
- Runtime deps: better-sqlite3, sqlite-vec, commander, zod, pino, pino-pretty, ora, chalk, cli-table3, inquirer
- Dev deps: typescript, @types/better-sqlite3, @types/node, vitest, msw, eslint, @typescript-eslint/*

**tsconfig.json:**
- `target: ES2022`, `module: Node16`, `moduleResolution: Node16`
- `strict: true`, `noImplicitAny: true`, `strictNullChecks: true`
- `outDir: ./dist`, `rootDir: ./src`

**vitest.config.ts:**
- Exclude `tests/live/**` unless `GITLAB_LIVE_TESTS=1`
- Coverage with v8 provider

## Acceptance Criteria

- [ ] `npm install` completes without errors
- [ ] `npm run build` compiles TypeScript to dist/
- [ ] `npm run test` runs vitest (0 tests is fine at this stage)
- [ ] `npm run lint` runs ESLint with no config errors
- [ ] All directories exist: src/cli/commands/, src/core/, src/gitlab/, src/types/, tests/unit/, tests/integration/, tests/live/, tests/fixtures/mock-responses/, migrations/

## Files

CREATE:
- package.json
- tsconfig.json
- vitest.config.ts
- eslint.config.js
- .gitignore
- src/cli/index.ts (empty placeholder with shebang)
- src/cli/commands/.gitkeep
- src/core/.gitkeep
- src/gitlab/.gitkeep
- src/types/index.ts (empty)
- tests/unit/.gitkeep
- tests/integration/.gitkeep
- tests/live/.gitkeep
- tests/fixtures/mock-responses/.gitkeep
- migrations/.gitkeep

## TDD Loop

N/A - scaffold only. Verify with:

```bash
npm install
npm run build
npm run lint
npm run test
```

## Edge Cases

- Node.js version < 20 will fail on ESM features - add `engines` field
- better-sqlite3 requires native compilation - may need python/build-essential
- sqlite-vec installation can fail on some platforms - document fallback


OBSERV: Implement log retention cleanup at startup

## Background
Log files accumulate at ~1-10 MB/day. Without cleanup, they grow unbounded. Retention runs BEFORE subscriber init so deleted file handles aren't held open by the appender.

## Approach
Add a cleanup function, called from main.rs before the subscriber is initialized (before current line 44):

```rust
/// Delete log files older than retention_days.
/// Matches files named lore.YYYY-MM-DD.log in the log directory.
pub fn cleanup_old_logs(log_dir: &Path, retention_days: u32) -> std::io::Result<usize> {
    if retention_days == 0 {
        return Ok(0); // 0 means file logging disabled, don't delete
    }
    let cutoff = SystemTime::now() - Duration::from_secs(u64::from(retention_days) * 86400);
    let mut deleted = 0;

    for entry in std::fs::read_dir(log_dir)? {
        let entry = entry?;
        let name = entry.file_name();
        let name_str = name.to_string_lossy();

        // Only match lore.YYYY-MM-DD.log pattern
        if !name_str.starts_with("lore.") || !name_str.ends_with(".log") {
            continue;
        }

        if let Ok(metadata) = entry.metadata() {
            if let Ok(modified) = metadata.modified() {
                if modified < cutoff {
                    std::fs::remove_file(entry.path())?;
                    deleted += 1;
                }
            }
        }
    }
    Ok(deleted)
}
```

Place this function in src/core/paths.rs (next to get_log_dir) or a new src/core/log_retention.rs. Prefer paths.rs since it's small and related.

Call from main.rs:
```rust
let log_dir = get_log_dir(config.logging.log_dir.as_deref());
let _ = cleanup_old_logs(&log_dir, config.logging.retention_days);
// THEN init subscriber
```

Note: Config must be loaded before cleanup runs. Current main.rs parses Cli at line 60, but config loading happens inside command handlers. This means we need to either:
  A) Load config early in main() before subscriber init (preferred)
  B) Defer cleanup to after config load

Since the subscriber must also know log_dir, approach A is natural: load config -> cleanup -> init subscriber -> dispatch command.

## Acceptance Criteria
- [ ] Files matching lore.*.log older than retention_days are deleted
- [ ] Files matching lore.*.log within retention_days are preserved
- [ ] Non-matching files (e.g., other.txt) are never deleted
- [ ] retention_days=0 skips cleanup entirely (no files deleted)
- [ ] Errors on individual files don't prevent cleanup of remaining files
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/core/paths.rs (add cleanup_old_logs function)
- src/main.rs (call cleanup before subscriber init)

## TDD Loop
RED:
  - test_log_retention_cleanup: create tempdir with lore.2026-01-01.log through lore.2026-02-04.log, run with retention_days=7, assert old deleted, recent preserved
  - test_log_retention_ignores_non_log_files: create other.txt alongside old log files, assert other.txt untouched
  - test_log_retention_zero_days: retention_days=0, assert nothing deleted
GREEN: Implement cleanup_old_logs
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- SystemTime::now() precision varies by OS; use file modified time, not name parsing (simpler and more reliable)
- read_dir on non-existent directory: get_log_dir creates it first, so this shouldn't happen. But handle gracefully.
- Permissions error on individual file: log a warning, continue with remaining files (don't propagate)
- Race condition: another process creates a file during cleanup. Not a concern -- we only delete old files.

Implemented cleanup_old_logs() with date-pattern matching and retention_days config, runs at startup before subscriber init


Implement timeline seed phase: FTS5 keyword search to entity IDs

## Background
Step 1 of timeline query: find seed entities matching the keyword. Uses the existing FTS5 infrastructure from CP3 (documents_fts table). Documents map back to source entities (issues, MRs) via documents.source_type and source_id.

## Approach
Add to src/core/timeline.rs:

```rust
/// Seed phase: FTS5 keyword search to find matching entities.
/// Returns seed entities (issues, MRs) and top evidence notes.
pub fn seed_from_fts(
    conn: &Connection,
    query: &str,
    project_id: Option<i64>,    // from -p flag
    since: Option<i64>,          // ms epoch timestamp filter
    limit: usize,                // -n flag
) -> Result<SeedResult>

pub struct SeedResult {
    pub entities: Vec<SeedEntity>,
    pub evidence_notes: Vec<EvidenceNote>,
}

pub struct SeedEntity {
    pub entity_type: String,     // "issue" | "merge_request"
    pub local_id: i64,           // DB id
    pub iid: i64,
    pub project_id: i64,
    pub project_path: String,
}

pub struct EvidenceNote {
    pub note_id: i64,
    pub discussion_id: Option<i64>,
    pub entity_type: String,     // parent entity type
    pub entity_iid: i64,
    pub snippet: String,         // first ~200 chars
    pub author: String,
    pub created_at: i64,
    pub url: Option<String>,
    pub fts_rank: f64,
}
```

SQL approach:
1. FTS5 query on documents_fts:
```sql
SELECT d.id, d.source_type, d.source_id, d.project_id, d.title,
       rank AS fts_rank
FROM documents_fts
JOIN documents d ON documents_fts.rowid = d.id
WHERE documents_fts MATCH ?1
ORDER BY rank
LIMIT ?2;
```

2. Map document results to source entities:
   - source_type 'issue' → get issue by id
   - source_type 'merge_request' → get MR by id
   - source_type 'note' / 'discussion' → get parent entity

3. Collect top 10 note matches as evidence candidates:
   - Filter documents where source relates to a note/discussion
   - Get note body, truncate to ~200 chars
   - Record note_id, discussion_id, parent entity info

4. Deduplicate entities (if a note match and its parent entity both appear)

5. Apply filters: --since (filter by entity created_at), -p (project scope)

## Acceptance Criteria
- [ ] FTS5 search returns matching documents
- [ ] Documents correctly mapped to source entities (issues, MRs)
- [ ] Note matches produce evidence note entries (top 10 by FTS rank)
- [ ] Deduplication: note match + parent entity don't double-count
- [ ] --since filter applied to entity timestamps
- [ ] -p filter scopes to project
- [ ] Empty query returns error (not all entities)

## Files
- src/core/timeline.rs (add seed_from_fts function)

## TDD Loop
RED: tests/timeline_seed_tests.rs:
- `test_seed_finds_matching_issue` - FTS match on issue title
- `test_seed_finds_matching_note` - FTS match on note body, maps to parent entity
- `test_seed_deduplicates_entities` - note match and issue match for same entity = 1 seed
- `test_seed_collects_evidence_notes` - top notes returned with snippets
- `test_seed_applies_since_filter` - old entities excluded
- `test_seed_applies_project_filter` - wrong project excluded

Setup: create_test_db with full migrations, seed documents + FTS index.

GREEN: Implement seed_from_fts

VERIFY: `cargo test timeline_seed -- --nocapture`

## Edge Cases
- FTS5 MATCH syntax: user may pass natural language — FTS5 handles this but special chars may need escaping
- Documents table may have stale data (entity deleted but document remains) — skip missing entities
- Note-type documents: need to map from note → discussion → parent entity. The documents table has source_type and source_id but the chain may be complex
- Empty result set is valid (no matches) — return empty SeedResult, not error


Implement Ollama API client

## Background
The Ollama API client provides the HTTP interface to the local Ollama embedding server. It handles health checks (is Ollama running? does the model exist?), batch embedding requests (up to 32 texts per call), and error translation to LoreError variants. This is the lowest-level embedding component — the pipeline (bd-am7) builds on top of it.

## Approach
Create \`src/embedding/ollama.rs\` per PRD Section 4.2. **Uses async reqwest (not blocking).**

```rust
use reqwest::Client;  // NOTE: async Client, not reqwest::blocking
use serde::{Deserialize, Serialize};
use crate::core::error::{LoreError, Result};

pub struct OllamaConfig {
    pub base_url: String,      // default \"http://localhost:11434\"
    pub model: String,         // default \"nomic-embed-text\"
    pub timeout_secs: u64,     // default 60
}

impl Default for OllamaConfig { /* PRD defaults */ }

pub struct OllamaClient {
    client: Client,      // async reqwest::Client
    config: OllamaConfig,
}

#[derive(Serialize)]
struct EmbedRequest { model: String, input: Vec<String> }

#[derive(Deserialize)]
struct EmbedResponse { model: String, embeddings: Vec<Vec<f32>> }

#[derive(Deserialize)]
struct TagsResponse { models: Vec<ModelInfo> }

#[derive(Deserialize)]
struct ModelInfo { name: String }

impl OllamaClient {
    pub fn new(config: OllamaConfig) -> Self;

    /// Async health check: GET /api/tags
    /// Model matched via starts_with (\"nomic-embed-text\" matches \"nomic-embed-text:latest\")
    pub async fn health_check(&self) -> Result<()>;

    /// Async batch embedding: POST /api/embed
    /// Input: Vec<String> of texts, Response: Vec<Vec<f32>> of 768-dim embeddings
    pub async fn embed_batch(&self, texts: Vec<String>) -> Result<Vec<Vec<f32>>>;
}

/// Quick health check without full client (async).
pub async fn check_ollama_health(base_url: &str) -> bool;
```

**Error mapping (per PRD):**
- Connection refused/timeout -> LoreError::OllamaUnavailable { base_url, source: Some(e) }
- Model not in /api/tags -> LoreError::OllamaModelNotFound { model }
- Non-200 from /api/embed -> LoreError::EmbeddingFailed { document_id: 0, reason: format!(\"HTTP {}: {}\", status, body) }

**Key PRD detail:** Model matching uses \`starts_with\` (not exact match) so \"nomic-embed-text\" matches \"nomic-embed-text:latest\".

## Acceptance Criteria
- [ ] Uses async reqwest::Client (not blocking)
- [ ] health_check() is async, detects server availability and model presence
- [ ] Model matched via starts_with (handles \":latest\" suffix)
- [ ] embed_batch() is async, sends POST /api/embed
- [ ] Batch size up to 32 texts
- [ ] Returns Vec<Vec<f32>> with 768 dimensions each
- [ ] OllamaUnavailable error includes base_url and source error
- [ ] OllamaModelNotFound error includes model name
- [ ] Non-200 response mapped to EmbeddingFailed with status + body
- [ ] Timeout: 60 seconds default (configurable via OllamaConfig)
- [ ] \`cargo build\` succeeds

## Files
- \`src/embedding/ollama.rs\` — new file
- \`src/embedding/mod.rs\` — add \`pub mod ollama;\` and re-exports

## TDD Loop
RED: Tests (unit tests with mock, integration needs Ollama):
- \`test_config_defaults\` — verify default base_url, model, timeout
- \`test_health_check_model_starts_with\` — \"nomic-embed-text\" matches \"nomic-embed-text:latest\"
- \`test_embed_batch_parse\` — mock response parsed correctly
- \`test_connection_error_maps_to_ollama_unavailable\`
GREEN: Implement OllamaClient
VERIFY: \`cargo test ollama\`

## Edge Cases
- Ollama returns model name with version tag (\"nomic-embed-text:latest\"): starts_with handles this
- Empty texts array: send empty batch, Ollama returns empty embeddings
- Ollama returns wrong number of embeddings (2 texts, 1 embedding): caller (pipeline) validates
- Non-JSON response: reqwest deserialization error -> wrap appropriately

Completed: OllamaClient with async health_check (starts_with model matching), embed_batch, error mapping to LoreError variants, check_ollama_health helper, 4 tests pass


OBSERV: Implement MetricsLayer custom tracing subscriber layer

## Background
MetricsLayer is a custom tracing subscriber layer that records span timing and structured fields, then materializes them into Vec<StageTiming>. This avoids threading a mutable collector through every function signature -- spans are the single source of truth.

## Approach
Add to src/core/metrics.rs (same file as StageTiming):

```rust
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Instant;
use tracing::span::{Attributes, Id, Record};
use tracing::Subscriber;
use tracing_subscriber::layer::{Context, Layer};
use tracing_subscriber::registry::LookupSpan;

#[derive(Debug)]
struct SpanData {
    name: String,
    parent_id: Option<Id>,
    start: Instant,
    fields: HashMap<String, serde_json::Value>,
}

#[derive(Debug, Clone)]
pub struct MetricsLayer {
    spans: Arc<Mutex<HashMap<u64, SpanData>>>,
    completed: Arc<Mutex<Vec<(u64, StageTiming)>>>,
}

impl MetricsLayer {
    pub fn new() -> Self {
        Self {
            spans: Arc::new(Mutex::new(HashMap::new())),
            completed: Arc::new(Mutex::new(Vec::new())),
        }
    }

    /// Extract timing tree for a completed run.
    /// Call this after the root span closes.
    pub fn extract_timings(&self) -> Vec<StageTiming> {
        let completed = self.completed.lock().unwrap();
        // Build tree: find root entries (no parent), attach children
        // ... tree construction logic
    }
}

impl<S> Layer<S> for MetricsLayer
where
    S: Subscriber + for<'a> LookupSpan<'a>,
{
    fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
        let parent_id = ctx.span(id).and_then(|s| s.parent().map(|p| p.id()));
        let mut fields = HashMap::new();
        // Visit attrs to capture initial field values
        let mut visitor = FieldVisitor(&mut fields);
        attrs.record(&mut visitor);

        self.spans.lock().unwrap().insert(id.into_u64(), SpanData {
            name: attrs.metadata().name().to_string(),
            parent_id,
            start: Instant::now(),
            fields,
        });
    }

    fn on_record(&self, id: &Id, values: &Record<'_>, _ctx: Context<'_, S>) {
        // Capture recorded fields (items_processed, items_skipped, errors)
        if let Some(data) = self.spans.lock().unwrap().get_mut(&id.into_u64()) {
            let mut visitor = FieldVisitor(&mut data.fields);
            values.record(&mut visitor);
        }
    }

    fn on_close(&self, id: Id, _ctx: Context<'_, S>) {
        if let Some(data) = self.spans.lock().unwrap().remove(&id.into_u64()) {
            let elapsed = data.start.elapsed();
            let timing = StageTiming {
                name: data.name,
                project: data.fields.get("project").and_then(|v| v.as_str()).map(String::from),
                elapsed_ms: elapsed.as_millis() as u64,
                items_processed: data.fields.get("items_processed").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
                items_skipped: data.fields.get("items_skipped").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
                errors: data.fields.get("errors").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
                sub_stages: vec![], // Will be populated during extract_timings tree construction
            };
            self.completed.lock().unwrap().push((id.into_u64(), timing));
        }
    }
}
```

Need a FieldVisitor struct implementing tracing::field::Visit to capture field values.

Register in subscriber stack (src/main.rs), alongside stderr and file layers:
```rust
let metrics_layer = MetricsLayer::new();
let metrics_handle = metrics_layer.clone(); // Clone Arc for later extraction

registry()
    .with(stderr_layer.with_filter(stderr_filter))
    .with(file_layer.with_filter(file_filter))
    .with(metrics_layer) // No filter -- captures all spans
    .init();
```

Pass metrics_handle to command handlers so they can call extract_timings() after the pipeline completes.

## Acceptance Criteria
- [ ] MetricsLayer captures span enter/close timing
- [ ] on_record captures items_processed, items_skipped, errors fields
- [ ] extract_timings() returns correctly nested Vec<StageTiming> tree
- [ ] Parallel spans (multiple projects) both appear as sub_stages of parent
- [ ] Thread-safe: Arc<Mutex<>> allows concurrent span operations
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/core/metrics.rs (add MetricsLayer, FieldVisitor, tree construction)
- src/main.rs (register MetricsLayer in subscriber stack)

## TDD Loop
RED:
  - test_metrics_layer_single_span: enter/exit one span, extract, assert one StageTiming
  - test_metrics_layer_nested_spans: parent + child, assert child in parent.sub_stages
  - test_metrics_layer_parallel_spans: two sibling spans, assert both in parent.sub_stages
  - test_metrics_layer_field_recording: record items_processed=42, assert captured
GREEN: Implement MetricsLayer with on_new_span, on_record, on_close, extract_timings
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- Span ID reuse: tracing may reuse span IDs after close. Using remove on close prevents stale data.
- Lock contention: Mutex per operation. For high-span-count scenarios, consider parking_lot::Mutex. But lore's span count is low (<100 per run), so std::sync::Mutex is fine.
- extract_timings tree construction: iterate completed Vec, build parent->children map, then recursively construct StageTiming tree. Root entries have parent_id matching the root span or None.
- MetricsLayer has no filter: it sees ALL spans. To avoid noise from dependency spans, check if span name starts with known stage names, or rely on the "stage" field being present.

Implemented MetricsLayer custom tracing subscriber layer with span timing capture, rate-limit/retry event detection, tree extraction, and 12 unit tests


Implement MR transformer

## Background
Transforms GitLab MR API responses into normalized schema for database storage. Handles deprecated field fallbacks and extracts metadata (labels, assignees, reviewers).

## Approach
Create new transformer module following existing issue transformer pattern:
- `NormalizedMergeRequest` - Database-ready struct
- `MergeRequestWithMetadata` - MR + extracted labels/assignees/reviewers
- `transform_merge_request()` - Main transformation function
- `extract_labels()` - Label extraction helper

## Files
- `src/gitlab/transformers/merge_request.rs` - New transformer module
- `src/gitlab/transformers/mod.rs` - Export new module
- `tests/mr_transformer_tests.rs` - Unit tests

## Acceptance Criteria
- [ ] `NormalizedMergeRequest` struct exists with all DB columns
- [ ] `MergeRequestWithMetadata` contains MR + label_names + assignee_usernames + reviewer_usernames
- [ ] `transform_merge_request()` returns `Result<MergeRequestWithMetadata, String>`
- [ ] `draft` computed as `gitlab_mr.draft || gitlab_mr.work_in_progress`
- [ ] `detailed_merge_status` prefers `detailed_merge_status` over `merge_status_legacy`
- [ ] `merge_user_username` prefers `merge_user` over `merged_by`
- [ ] `head_sha` extracted from `sha` field
- [ ] `references_short` and `references_full` extracted from `references` Option
- [ ] Timestamps parsed with `iso_to_ms()`, errors returned (not zeroed)
- [ ] `last_seen_at` set to `now_ms()`
- [ ] `cargo test mr_transformer` passes

## TDD Loop
RED: `cargo test mr_transformer` -> module not found
GREEN: Add transformer with all fields
VERIFY: `cargo test mr_transformer`

## Struct Definitions
```rust
#[derive(Debug, Clone)]
pub struct NormalizedMergeRequest {
    pub gitlab_id: i64,
    pub project_id: i64,
    pub iid: i64,
    pub title: String,
    pub description: Option<String>,
    pub state: String,
    pub draft: bool,
    pub author_username: String,
    pub source_branch: String,
    pub target_branch: String,
    pub head_sha: Option<String>,
    pub references_short: Option<String>,
    pub references_full: Option<String>,
    pub detailed_merge_status: Option<String>,
    pub merge_user_username: Option<String>,
    pub created_at: i64,
    pub updated_at: i64,
    pub merged_at: Option<i64>,
    pub closed_at: Option<i64>,
    pub last_seen_at: i64,
    pub web_url: String,
}

#[derive(Debug, Clone)]
pub struct MergeRequestWithMetadata {
    pub merge_request: NormalizedMergeRequest,
    pub label_names: Vec<String>,
    pub assignee_usernames: Vec<String>,
    pub reviewer_usernames: Vec<String>,
}
```

## Function Signature
```rust
pub fn transform_merge_request(
    gitlab_mr: &GitLabMergeRequest,
    local_project_id: i64,
) -> Result<MergeRequestWithMetadata, String>
```

## Key Logic
```rust
// Draft: prefer draft, fallback to work_in_progress
let is_draft = gitlab_mr.draft || gitlab_mr.work_in_progress;

// Merge status: prefer detailed_merge_status
let detailed_merge_status = gitlab_mr.detailed_merge_status
    .clone()
    .or_else(|| gitlab_mr.merge_status_legacy.clone());

// Merge user: prefer merge_user
let merge_user_username = gitlab_mr.merge_user
    .as_ref()
    .map(|u| u.username.clone())
    .or_else(|| gitlab_mr.merged_by.as_ref().map(|u| u.username.clone()));

// References extraction
let (references_short, references_full) = gitlab_mr.references
    .as_ref()
    .map(|r| (Some(r.short.clone()), Some(r.full.clone())))
    .unwrap_or((None, None));

// Head SHA
let head_sha = gitlab_mr.sha.clone();
```

## Edge Cases
- Invalid timestamps should return `Err`, not zero values
- Empty labels/assignees/reviewers should return empty Vecs, not None
- `state` must pass through as-is (including "locked")


Create golden query test suite

## Background
Golden query tests verify end-to-end search quality with known-good expected results. They use a seeded SQLite DB with deterministic fixture data and fixed embedding vectors (no Ollama dependency). Each test query must return at least one expected URL in the top 10 results. These tests catch search regressions (ranking changes, filter bugs, missing results).

## Approach
Create test infrastructure:

**1. tests/fixtures/golden_queries.json:**
```json
[
  {
    "query": "authentication login",
    "mode": "lexical",
    "filters": {},
    "expected_urls": ["https://gitlab.example.com/group/project/-/issues/234"],
    "min_results": 1,
    "max_rank": 10
  },
  {
    "query": "jwt token refresh",
    "mode": "hybrid",
    "filters": {"type": "merge_request"},
    "expected_urls": ["https://gitlab.example.com/group/project/-/merge_requests/456"],
    "min_results": 1,
    "max_rank": 10
  }
]
```

**2. Test harness (tests/golden_query_tests.rs):**
- Load golden_queries.json
- Create in-memory DB, apply all migrations
- Seed with deterministic fixture documents (issues, MRs, discussions)
- For hybrid/semantic queries: seed with fixed embedding vectors (768-dim, manually constructed for known similarity)
- For each query: run search, verify expected URL in top N results

**Fixture data design:**
- 10-20 documents covering different source types
- Known content that matches expected queries
- Fixed embeddings: construct vectors where similar documents have small cosine distance
- No randomness — fully deterministic

## Acceptance Criteria
- [ ] Golden queries file exists with at least 5 test queries
- [ ] Test harness loads queries and validates each
- [ ] All golden queries pass: expected URL in top 10
- [ ] No external dependencies (no Ollama, no GitLab)
- [ ] Deterministic fixture data (fixed embeddings, fixed content)
- [ ] `cargo test --test golden_query_tests` passes in CI

## Files
- `tests/fixtures/golden_queries.json` — new file
- `tests/golden_query_tests.rs` — new file (or tests/golden_queries.rs)

## TDD Loop
RED: Create golden_queries.json with expected results, harness fails (no fixture data)
GREEN: Seed fixture data that satisfies expected results
VERIFY: `cargo test --test golden_query_tests`

## Edge Cases
- Query matches multiple expected URLs: all must be present
- Lexical queries: FTS ranking determines position, not vector
- Hybrid queries: RRF combines both signals — fixed vectors must be designed to produce expected ranking
- Empty result for a golden query: test failure with clear message showing actual results

Golden query test suite: 7 golden queries in fixture, 8 seeded documents, 2 test functions (all_pass + fixture_valid), deterministic in-memory DB, no external deps. 312 total tests pass.


[CP1] Discussion and note transformers

Transform GitLab discussion/note payloads to normalized database schema.

Functions to implement:
- transformDiscussion(gitlabDiscussion, localProjectId, localIssueId) → NormalizedDiscussion
- transformNotes(gitlabDiscussion, localProjectId) → NormalizedNote[]

Transformation rules:
- Compute first_note_at/last_note_at from notes array
- Compute resolvable/resolved status from notes
- Set is_system from note.system
- Preserve note order via position (array index)
- Convert ISO timestamps to ms epoch

Files: src/gitlab/transformers/discussion.ts
Tests: tests/unit/discussion-transformer.test.ts
Done when: Unit tests pass for discussion/note transformation with system note flagging


Final validation and test coverage

## Background
Final validation gate ensuring all CP2 features work correctly. Verifies tests, lint, and manual smoke tests pass.

## Approach
Run comprehensive validation:
1. Automated tests (unit + integration)
2. Clippy and formatting
3. Critical test case verification
4. Gate A/B/C/D/E checklist
5. Manual smoke tests

## Files
None - validation only

## Acceptance Criteria
- [ ] `cargo test` passes (all tests green)
- [ ] `cargo test --release` passes
- [ ] `cargo clippy -- -D warnings` passes (zero warnings)
- [ ] `cargo fmt --check` passes
- [ ] Critical tests pass (see list below)
- [ ] Gate A/B/C/D/E verification complete
- [ ] Manual smoke tests pass

## Validation Commands
```bash
# 1. Build and test
cargo build --release
cargo test --release

# 2. Lint
cargo clippy -- -D warnings
cargo fmt --check

# 3. Run specific critical tests
cargo test does_not_advance_discussion_watermark_on_partial_failure
cargo test prefers_detailed_merge_status_when_both_fields_present
cargo test prefers_merge_user_when_both_fields_present
cargo test prefers_draft_when_both_draft_and_work_in_progress_present
cargo test atomic_note_replacement_preserves_data_on_parse_failure
cargo test full_sync_resets_discussion_watermarks
```

## Critical Test Cases
| Test | What It Verifies |
|------|------------------|
| `does_not_advance_discussion_watermark_on_partial_failure` | Pagination failure doesn't lose data |
| `prefers_detailed_merge_status_when_both_fields_present` | Non-deprecated field wins |
| `prefers_merge_user_when_both_fields_present` | Non-deprecated field wins |
| `prefers_draft_when_both_draft_and_work_in_progress_present` | OR semantics for draft |
| `atomic_note_replacement_preserves_data_on_parse_failure` | Parse before delete |
| `full_sync_resets_discussion_watermarks` | --full truly refreshes |

## Gate Checklist

### Gate A: MRs Only
- [ ] `gi ingest --type=merge_requests` fetches all MRs
- [ ] MR state supports: opened, merged, closed, locked
- [ ] draft field captured with work_in_progress fallback
- [ ] detailed_merge_status used with merge_status fallback
- [ ] head_sha and references captured
- [ ] Cursor-based sync is resumable

### Gate B: Labels + Assignees + Reviewers
- [ ] Labels linked via mr_labels junction
- [ ] Stale labels removed on resync
- [ ] Assignees linked via mr_assignees
- [ ] Reviewers linked via mr_reviewers

### Gate C: Dependent Discussion Sync
- [ ] Discussions fetched for MRs with updated_at advancement
- [ ] DiffNote position metadata captured
- [ ] DiffNote SHA triplet captured
- [ ] Upsert + sweep pattern for notes
- [ ] Watermark NOT advanced on partial failure
- [ ] Unchanged MRs skip discussion refetch

### Gate D: Resumability Proof
- [ ] Kill mid-run, rerun -> bounded redo
- [ ] `--full` resets cursor AND discussion watermarks
- [ ] Single-flight lock prevents concurrent runs

### Gate E: CLI Complete
- [ ] `gi list mrs` with all filters including --draft/--no-draft
- [ ] `gi show mr <iid>` with discussions and DiffNote context
- [ ] `gi count mrs` with state breakdown
- [ ] `gi sync-status` shows MR cursors

## Manual Smoke Tests
| Command | Expected |
|---------|----------|
| `gi ingest --type=merge_requests` | Completes, shows counts |
| `gi list mrs --limit=10` | Shows 10 MRs with correct columns |
| `gi list mrs --state=merged` | Only merged MRs |
| `gi list mrs --draft` | Only draft MRs with [DRAFT] prefix |
| `gi show mr <iid>` | Full detail with discussions |
| `gi count mrs` | Count with state breakdown |
| Re-run ingest | "0 new MRs", skipped discussion count |
| `gi ingest --type=merge_requests --full` | Full resync |

## Data Integrity Checks
```sql
-- MR count matches GitLab
SELECT COUNT(*) FROM merge_requests;

-- Every MR has raw payload
SELECT COUNT(*) FROM merge_requests WHERE raw_payload_id IS NULL;
-- Should be 0

-- Labels linked correctly
SELECT m.iid, COUNT(ml.label_id)
FROM merge_requests m
LEFT JOIN mr_labels ml ON ml.merge_request_id = m.id
GROUP BY m.id;

-- DiffNotes have position metadata
SELECT COUNT(*) FROM notes WHERE position_new_path IS NOT NULL;
```