Implement MR discussion ingestion module

## Background
MR discussion ingestion with critical atomicity guarantees. Parse notes BEFORE destructive DB operations to prevent data loss. Watermark ONLY advanced on full success.

## Approach
Create `src/ingestion/mr_discussions.rs` with:
1. `IngestMrDiscussionsResult` - Per-MR stats
2. `ingest_mr_discussions()` - Main function with atomicity guarantees
3. Upsert + sweep pattern for notes (not delete-all-then-insert)
4. Sync health telemetry for debugging failures

## Files
- `src/ingestion/mr_discussions.rs` - New module
- `tests/mr_discussion_ingestion_tests.rs` - Integration tests

## Acceptance Criteria
- [ ] `IngestMrDiscussionsResult` has: discussions_fetched, discussions_upserted, notes_upserted, notes_skipped_bad_timestamp, diffnotes_count, pagination_succeeded
- [ ] `ingest_mr_discussions()` returns `Result<IngestMrDiscussionsResult>`
- [ ] CRITICAL: Notes parsed BEFORE any DELETE operations
- [ ] CRITICAL: Watermark NOT advanced if `pagination_succeeded == false`
- [ ] CRITICAL: Watermark NOT advanced if any note parse fails
- [ ] Upsert + sweep pattern using `last_seen_at`
- [ ] Stale discussions/notes removed only on full success
- [ ] Selective raw payload storage (skip system notes without position)
- [ ] Sync health telemetry recorded on failure
- [ ] `does_not_advance_discussion_watermark_on_partial_failure` test passes
- [ ] `atomic_note_replacement_preserves_data_on_parse_failure` test passes

## TDD Loop
RED: `cargo test does_not_advance_watermark` -> test fails
GREEN: Add ingestion with atomicity guarantees
VERIFY: `cargo test mr_discussion_ingestion`

## Main Function
```rust
pub async fn ingest_mr_discussions(
    conn: &Connection,
    client: &GitLabClient,
    config: &Config,
    project_id: i64,
    gitlab_project_id: i64,
    mr_iid: i64,
    local_mr_id: i64,
    mr_updated_at: i64,
) -> Result<IngestMrDiscussionsResult>
```

## CRITICAL: Atomic Note Replacement
```rust
// Record sync start time for sweep
let run_seen_at = now_ms();

while let Some(discussion_result) = stream.next().await {
    let discussion = match discussion_result {
        Ok(d) => d,
        Err(e) => {
            result.pagination_succeeded = false;
            break; // Stop but don't advance watermark
        }
    };

    // CRITICAL: Parse BEFORE destructive operations
    let notes = match transform_notes_with_diff_position(&discussion, project_id) {
        Ok(notes) => notes,
        Err(e) => {
            warn!("Note transform failed; preserving existing notes");
            result.notes_skipped_bad_timestamp += discussion.notes.len();
            result.pagination_succeeded = false;
            continue; // Skip this discussion, don't delete existing
        }
    };

    // Only NOW start transaction (after parse succeeded)
    let tx = conn.unchecked_transaction()?;

    // Upsert discussion with run_seen_at
    // Upsert notes with run_seen_at (not delete-all)

    tx.commit()?;
}
```

## Stale Data Sweep (only on success)
```rust
if result.pagination_succeeded {
    // Sweep stale discussions
    conn.execute(
        "DELETE FROM discussions
         WHERE project_id = ? AND merge_request_id = ?
           AND last_seen_at < ?",
        params![project_id, local_mr_id, run_seen_at],
    )?;

    // Sweep stale notes
    conn.execute(
        "DELETE FROM notes
         WHERE discussion_id IN (
           SELECT id FROM discussions
           WHERE project_id = ? AND merge_request_id = ?
         )
         AND last_seen_at < ?",
        params![project_id, local_mr_id, run_seen_at],
    )?;
}
```

## Watermark Update (ONLY on success)
```rust
if result.pagination_succeeded {
    mark_discussions_synced(conn, local_mr_id, mr_updated_at)?;
    clear_sync_health_error(conn, local_mr_id)?;
} else {
    record_sync_health_error(conn, local_mr_id, "Pagination incomplete or parse failure")?;
    warn!("Watermark NOT advanced; will retry on next sync");
}
```

## Selective Payload Storage
```rust
// Only store payload for DiffNotes and non-system notes
let should_store_note_payload =
    !note.is_system() ||
    note.position_new_path().is_some() ||
    note.position_old_path().is_some();
```

## Integration Tests (CRITICAL)
```rust
#[tokio::test]
async fn does_not_advance_discussion_watermark_on_partial_failure() {
    // Setup: MR with updated_at > discussions_synced_for_updated_at
    // Mock: Page 1 returns OK, Page 2 returns 500
    // Assert: discussions_synced_for_updated_at unchanged
}

#[tokio::test]
async fn does_not_advance_discussion_watermark_on_note_parse_failure() {
    // Setup: Existing notes in DB
    // Mock: Discussion with note having invalid created_at
    // Assert: Original notes preserved, watermark unchanged
}

#[tokio::test]
async fn atomic_note_replacement_preserves_data_on_parse_failure() {
    // Setup: Discussion with 3 valid notes
    // Mock: Updated discussion where note 2 has bad timestamp
    // Assert: All 3 original notes still in DB
}
```

## Edge Cases
- HTTP error mid-pagination: preserve existing data, log error, no watermark advance
- Invalid note timestamp: skip discussion, preserve existing notes
- System notes without position: don't store raw payload (saves space)
- Empty discussion: still upsert discussion record, no notes

Implemented MR discussion ingestion module with full atomicity guarantees:
- IngestMrDiscussionsResult with all required fields
- parse-before-destructive pattern (transform notes before DB ops)
- Upsert + sweep pattern with last_seen_at timestamps
- Watermark advanced ONLY on full pagination success
- Selective payload storage (skip system notes without position)
- Sync health telemetry for failure debugging
- All 163 tests passing


Create migration 008_fts5.sql

## Background
FTS5 (Full-Text Search 5) provides the lexical search backbone for Gate A. The virtual table + triggers keep the FTS index in sync with the documents table automatically. This migration must be applied AFTER migration 007 (documents table exists). The trigger design handles NULL titles via COALESCE and only rebuilds the FTS entry when searchable text actually changes (not metadata-only updates).

## Approach
Create `migrations/008_fts5.sql` with the exact SQL from PRD Section 1.2:

1. **Virtual table:** `documents_fts` using FTS5 with porter stemmer, prefix indexes (2,3,4), external content backed by `documents` table
2. **Insert trigger:** `documents_ai` — inserts into FTS on document insert, uses COALESCE(title, '') for NULL safety
3. **Delete trigger:** `documents_ad` — removes from FTS on document delete using the FTS5 delete command syntax
4. **Update trigger:** `documents_au` — only fires when `title` or `content_text` changes (WHEN clause), performs delete-then-insert to update FTS

Register migration 8 in `src/core/db.rs` MIGRATIONS array.

**Critical detail:** The COALESCE is required because FTS5 external-content tables require exact value matching for delete operations. If NULL was inserted, the delete trigger couldn't match it (NULL != NULL in SQL).

## Acceptance Criteria
- [ ] `migrations/008_fts5.sql` file exists
- [ ] `documents_fts` virtual table created with `tokenize='porter unicode61'` and `prefix='2 3 4'`
- [ ] `content='documents'` and `content_rowid='id'` set (external content mode)
- [ ] Insert trigger `documents_ai` fires on document insert with COALESCE(title, '')
- [ ] Delete trigger `documents_ad` fires on document delete using FTS5 delete command
- [ ] Update trigger `documents_au` only fires when `old.title IS NOT new.title OR old.content_text != new.content_text`
- [ ] Prefix search works: query `auth*` matches "authentication"
- [ ] After bulk insert of N documents, `SELECT count(*) FROM documents_fts` returns N
- [ ] Schema version 8 recorded in schema_version table
- [ ] `cargo test migration_tests` passes

## Files
- `migrations/008_fts5.sql` — new file (copy exact SQL from PRD Section 1.2)
- `src/core/db.rs` — add migration 8 to MIGRATIONS array

## TDD Loop
RED: Register migration in db.rs, `cargo test migration_tests` fails (SQL file missing)
GREEN: Create `008_fts5.sql` with all triggers
VERIFY: `cargo test migration_tests && cargo build`

## Edge Cases
- Metadata-only updates (e.g., changing `updated_at` or `labels_hash`) must NOT trigger FTS rebuild — the WHEN clause prevents this
- NULL titles must use COALESCE to empty string in both insert and delete triggers
- The update trigger does delete+insert (not FTS5 'delete' + regular insert atomically) — this is the correct FTS5 pattern for content changes

Completed: migration 008_fts5.sql with FTS5 virtual table, 3 sync triggers (insert/delete/update with COALESCE NULL safety), prefix search, registered in db.rs, cargo build + tests pass


[CP1] gi count issues/discussions/notes commands

Count entities in the database.

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

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

    #[arg(long, value_parser = ["issue", "mr"])]
    r#type: Option<String>,
}

## Commands
- gi count issues → 'Issues: N'
- gi count discussions → 'Discussions: N'
- gi count discussions --type=issue → 'Issue Discussions: N'
- gi count notes → 'Notes: N (excluding M system)'
- gi count notes --type=issue → 'Issue Notes: N (excluding M system)'

## Implementation
- Simple COUNT(*) queries
- For notes, also count WHERE is_system = 1 for system note count
- Filter by noteable_type when --type specified

Files: src/cli/commands/count.rs
Done when: Counts match expected values from GitLab


Implement issue document extraction

## Background
Issue documents are the simplest document type — a structured header + description text. The extractor queries the existing issues and issue_labels tables (populated by ingestion) and assembles a DocumentData struct. This is one of three entity-specific extractors (issue, MR, discussion) that feed the document regeneration pipeline.

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

```rust
/// Extract a searchable document from an issue.
/// Returns None if the issue has been deleted from the DB.
pub fn extract_issue_document(conn: &Connection, issue_id: i64) -> Result<Option<DocumentData>>
```

**SQL queries (from PRD Section 2.2):**
```sql
-- Main entity
SELECT i.id, i.iid, i.title, i.description, i.state, i.author_username,
       i.created_at, i.updated_at, i.web_url,
       p.path_with_namespace, p.id AS project_id
FROM issues i
JOIN projects p ON p.id = i.project_id
WHERE i.id = ?

-- Labels
SELECT l.name FROM issue_labels il
JOIN labels l ON l.id = il.label_id
WHERE il.issue_id = ?
ORDER BY l.name
```

**Document format:**
```
[[Issue]] #234: Authentication redesign
Project: group/project-one
URL: https://gitlab.example.com/group/project-one/-/issues/234
Labels: ["bug", "auth"]
State: opened
Author: @johndoe

--- Description ---

We need to modernize our authentication system...
```

**Implementation steps:**
1. Query issue row — if not found, return Ok(None)
2. Query labels via junction table
3. Format header with [[Issue]] prefix
4. Compute content_hash via compute_content_hash()
5. Compute labels_hash via compute_list_hash()
6. paths is always empty for issues (paths are only for DiffNote discussions)
7. Return DocumentData with all fields populated

## Acceptance Criteria
- [ ] Deleted issue (not in DB) returns Ok(None)
- [ ] Issue with no description: content_text has header only (no "--- Description ---" section)
- [ ] Issue with no labels: Labels line shows "[]"
- [ ] Issue with labels: Labels line shows sorted JSON array
- [ ] content_hash is SHA-256 of the full content_text
- [ ] labels_hash is SHA-256 of sorted label names joined by newline
- [ ] paths_hash is empty string hash (issues have no paths)
- [ ] project_id comes from the JOIN with projects table
- [ ] `cargo test extract_issue` passes

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

## TDD Loop
RED: Test in `#[cfg(test)] mod tests`:
- `test_issue_document_format` — verify header format matches PRD template
- `test_issue_not_found` — returns Ok(None) for nonexistent issue_id
- `test_issue_no_description` — no description section when description is NULL
- `test_issue_labels_sorted` — labels appear in alphabetical order
- `test_issue_hash_deterministic` — same issue produces same content_hash
GREEN: Implement extract_issue_document with SQL queries
VERIFY: `cargo test extract_issue`

## Edge Cases
- Issue with NULL description: skip "--- Description ---" section entirely
- Issue with empty string description: include section but with empty body
- Issue with very long description: no truncation here (hard cap applied by caller)
- Labels with special characters (quotes, commas): JSON array handles escaping

Implemented extract_issue_document() with SQL queries, PRD-compliant format, and 7 tests


robot-docs: Add Ollama dependency discovery to manifest

An agent reading robot-docs has no way to discover that semantic search requires Ollama, what model to pull, or that lexical search works without it. The error messages in embed/search are actionable enough for self-correction, but proactive discovery would be better.

Proposed: add a 'dependencies' section to the robot-docs JSON output listing optional deps with install commands and setup instructions. Example:

{"ollama": {"required_for": ["embed", "search --mode semantic|hybrid"], "install": "brew install ollama || curl -fsSL https://ollama.com/install.sh | sh", "setup": "ollama serve && ollama pull nomic-embed-text", "optional": true, "note": "Lexical search works without Ollama"}}

Existing coverage (sufficient for now):
- README.md mentions Ollama as a dependency
- doctor command checks Ollama health at runtime
- error.rs has actionable suggestions: 'ollama serve', 'ollama pull nomic-embed-text'
- sync gracefully degrades when Ollama is unavailable

Gap: none of this surfaces in robot-docs, the agent self-discovery entry point.


Create migration 009_embeddings.sql

## Background
Migration 009 creates the embedding storage layer for Gate B. It introduces a sqlite-vec vec0 virtual table for vector search and an embedding_metadata table for tracking provenance per chunk. Unlike migrations 007-008, this migration REQUIRES sqlite-vec to be loaded before it can be applied. The migration runner in db.rs must load the sqlite-vec extension first.

## Approach
Create `migrations/009_embeddings.sql` per PRD Section 1.3.

**Tables:**
1. `embeddings` — vec0 virtual table with `embedding float[768]`
2. `embedding_metadata` — tracks per-chunk provenance with composite PK (document_id, chunk_index)
3. Orphan cleanup trigger: `documents_embeddings_ad` — deletes ALL chunk embeddings when a document is deleted using range deletion `[doc_id * 1000, (doc_id + 1) * 1000)`

**Critical: sqlite-vec loading:**
The migration runner in `src/core/db.rs` must load sqlite-vec BEFORE applying any migrations. This means adding extension loading to the `create_connection()` or `run_migrations()` function. sqlite-vec is loaded via:
```rust
conn.load_extension_enable()?;
conn.load_extension("vec0", None)?;  // or platform-specific path
conn.load_extension_disable()?;
```

Register migration 9 in `src/core/db.rs` MIGRATIONS array.

## Acceptance Criteria
- [ ] `migrations/009_embeddings.sql` file exists
- [ ] `embeddings` vec0 virtual table created with `embedding float[768]`
- [ ] `embedding_metadata` table has composite PK (document_id, chunk_index)
- [ ] `embedding_metadata.document_id` has FK to documents(id) ON DELETE CASCADE
- [ ] Error tracking fields: last_error, attempt_count, last_attempt_at
- [ ] Orphan cleanup trigger: deletes embeddings WHERE rowid in [doc_id*1000, (doc_id+1)*1000)
- [ ] Index on embedding_metadata(last_error) WHERE last_error IS NOT NULL
- [ ] Index on embedding_metadata(document_id)
- [ ] Schema version 9 recorded
- [ ] Migration runner loads sqlite-vec before applying migrations
- [ ] `cargo build` succeeds

## Files
- `migrations/009_embeddings.sql` — new file (copy exact SQL from PRD Section 1.3)
- `src/core/db.rs` — add migration 9 to MIGRATIONS array; add sqlite-vec extension loading

## TDD Loop
RED: Register migration in db.rs, `cargo test migration_tests` fails
GREEN: Create SQL file + add extension loading
VERIFY: `cargo test migration_tests && cargo build`

## Edge Cases
- sqlite-vec not installed: migration fails with clear error (not a silent skip)
- Migration applied without sqlite-vec loaded: `CREATE VIRTUAL TABLE` fails with "no such module: vec0"
- Documents deleted before embeddings: trigger fires but vec0 DELETE on empty range is safe
- vec0 doesn't support FK cascades: that's why we need the explicit trigger

Completed: migration 009_embeddings.sql with vec0 table, embedding_metadata with composite PK, orphan cleanup trigger, registered in db.rs


[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
- Issue discussions fetched per-issue
- Cursor-based sync is resumable
- Sync tracking records all runs
- Single-flight lock prevents concurrent runs

Reference: docs/prd/checkpoint-1.md


[CP1] GitLab types for issues, discussions, notes

Add Rust types to src/gitlab/types.rs for GitLab API responses.

## Types to Add

### GitLabIssue
- id: i64 (GitLab global ID)
- iid: i64 (project-scoped issue number)
- project_id: i64
- title: String
- description: Option<String>
- state: String ("opened" | "closed")
- created_at, updated_at: String (ISO 8601)
- closed_at: Option<String>
- author: GitLabAuthor
- labels: Vec<String> (array of label names - CP1 canonical)
- web_url: String
NOTE: labels_details intentionally NOT modeled - varies across GitLab versions

### GitLabAuthor
- id: i64
- username: String
- name: String

### GitLabDiscussion
- id: String (like "6a9c1750b37d...")
- individual_note: bool
- notes: Vec<GitLabNote>

### GitLabNote
- id: i64
- note_type: Option<String> ("DiscussionNote" | "DiffNote" | null)
- body: String
- author: GitLabAuthor
- created_at, updated_at: String (ISO 8601)
- system: bool
- resolvable: bool (default false)
- resolved: bool (default false)
- resolved_by: Option<GitLabAuthor>
- resolved_at: Option<String>
- position: Option<GitLabNotePosition>

### GitLabNotePosition
- old_path, new_path: Option<String>
- old_line, new_line: Option<i32>

Files: src/gitlab/types.rs
Tests: Test deserialization with fixtures
Done when: Types compile and deserialize sample API responses correctly


[CP1] Discussion ingestion module

Fetch and store discussions/notes for each issue.

## Module
src/ingestion/discussions.rs

## Key Structs

### IngestDiscussionsResult
- discussions_fetched: usize
- discussions_upserted: usize
- notes_upserted: usize
- system_notes_count: usize

## Main Function
pub async fn ingest_issue_discussions(
    conn, client, config,
    project_id, gitlab_project_id,
    issue_iid, local_issue_id, issue_updated_at
) -> Result<IngestDiscussionsResult>

## Logic
1. Paginate through all discussions for given issue
2. For each discussion:
   - Begin transaction
   - Store raw payload (compressed)
   - Transform and upsert discussion record with correct issue FK
   - Get local discussion ID
   - Transform notes from discussion
   - For each note:
     - Store raw payload
     - Upsert note with discussion_id FK
     - Count system notes
   - Commit transaction
3. After all discussions synced: mark_discussions_synced(conn, local_issue_id, issue_updated_at)
   - UPDATE issues SET discussions_synced_for_updated_at = ? WHERE id = ?

## Invariant
A rerun MUST NOT refetch discussions for issues whose updated_at has not advanced, even with cursor rewind. The discussions_synced_for_updated_at watermark ensures this.

## Helper Functions
- upsert_discussion(conn, discussion, payload_id)
- get_local_discussion_id(conn, project_id, gitlab_id) -> i64
- upsert_note(conn, discussion_id, note, payload_id)
- mark_discussions_synced(conn, issue_id, issue_updated_at)

Files: src/ingestion/discussions.rs
Tests: tests/discussion_watermark_tests.rs
Done when: Discussions and notes populated with correct FKs, watermark prevents redundant refetch


Implement project resolution for --project filter

## Background
The --project filter on search (and other commands) accepts a string that must be resolved to a project_id. Users may type the full path, a partial path, or just the project name. The resolution logic provides cascading match with helpful error messages when ambiguous. This improves ergonomics for multi-project installations.

## Approach
Implement project resolution function (location TBD — likely `src/core/project.rs` or inline in search filters):

```rust
/// Resolve a project string to a project_id using cascading match:
/// 1. Exact match on path_with_namespace
/// 2. Case-insensitive exact match
/// 3. Suffix match (only if unambiguous)
/// 4. Error with available projects list
pub fn resolve_project(conn: &Connection, project_str: &str) -> Result<i64>
```

**SQL queries:**
```sql
-- Step 1: exact match
SELECT id FROM projects WHERE path_with_namespace = ?

-- Step 2: case-insensitive
SELECT id FROM projects WHERE LOWER(path_with_namespace) = LOWER(?)

-- Step 3: suffix match
SELECT id, path_with_namespace FROM projects
WHERE path_with_namespace LIKE '%/' || ?
   OR path_with_namespace = ?

-- Step 4: list all for error message
SELECT path_with_namespace FROM projects ORDER BY path_with_namespace
```

**Error format:**
```
Error: Project 'auth-service' not found.

Available projects:
  backend/auth-service
  frontend/auth-service-ui
  infra/auth-proxy

Hint: Use the full path, e.g., --project=backend/auth-service
```

Uses `LoreError::Ambiguous` variant for multiple suffix matches.

## Acceptance Criteria
- [ ] Exact match: "group/project" resolves correctly
- [ ] Case-insensitive: "Group/Project" resolves to "group/project"
- [ ] Suffix match: "project-name" resolves when only one "*/project-name" exists
- [ ] Ambiguous suffix: error lists matching projects with hint
- [ ] No match: error lists all available projects with hint
- [ ] Empty projects table: clear error message
- [ ] `cargo test project_resolution` passes

## Files
- `src/core/project.rs` — new file (or add to existing module)
- `src/core/mod.rs` — add `pub mod project;`

## TDD Loop
RED: Tests:
- `test_exact_match` — full path resolves
- `test_case_insensitive` — mixed case resolves
- `test_suffix_unambiguous` — short name resolves when unique
- `test_suffix_ambiguous` — error with list when multiple match
- `test_no_match` — error with available projects
GREEN: Implement resolve_project
VERIFY: `cargo test project_resolution`

## Edge Cases
- Project path containing special LIKE characters (%, _): unlikely but escape for safety
- Single project in DB: suffix always unambiguous
- Project path with multiple slashes: "org/group/project" — suffix match on "project" works

Implemented resolve_project() with cascading match (exact, CI, suffix) + 6 tests


## 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 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


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


[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


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.


[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


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


[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


[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


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


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;
```


Implement document types and extractor module

## Background
The document types module is the foundational data layer for all document operations. It defines SourceType (the enum used everywhere to identify entity types), DocumentData (the struct passed between extractors, regenerator, and storage), and hash functions (for change detection that skips unchanged documents). This bead has 7 downstream dependents — it's the most-depended-on implementation bead.

## Approach
Create `src/documents/` module with `mod.rs` and `extractor.rs`.

**src/documents/mod.rs:**
```rust
mod extractor;
mod regenerator;   // placeholder: pub mod added by later bead
mod truncation;    // placeholder: pub mod added by later bead

pub use extractor::{
    extract_discussion_document, extract_issue_document, extract_mr_document,
    DocumentData, SourceType, compute_content_hash, compute_list_hash,
};
pub use regenerator::regenerate_dirty_documents;
pub use truncation::{truncate_content, TruncationResult};
```

**src/documents/extractor.rs — types only (this bead):**
- `SourceType` enum with `Issue`, `MergeRequest`, `Discussion` variants
- `SourceType::as_str()`, `SourceType::parse()` (accepts aliases), `Display` impl
- `DocumentData` struct with all fields per PRD Section 2.2
- `compute_content_hash(content: &str) -> String` using SHA-256
- `compute_list_hash(items: &[String]) -> String` using sorted join + SHA-256

**Dependencies:** Add `sha2 = "0.10"` to Cargo.toml if not already present.

Note: The `extract_*_document()` functions are stub signatures in this bead. Their implementations are in separate beads (bd-247, bd-1yz, bd-2fp).

**src/lib.rs:** Add `pub mod documents;`

## Acceptance Criteria
- [ ] `SourceType::parse("issue")` returns `Some(Issue)`
- [ ] `SourceType::parse("mr")` returns `Some(MergeRequest)` (alias)
- [ ] `SourceType::parse("mrs")` returns `Some(MergeRequest)` (alias)
- [ ] `SourceType::parse("merge_request")` returns `Some(MergeRequest)`
- [ ] `SourceType::parse("discussion")` returns `Some(Discussion)`
- [ ] `SourceType::parse("invalid")` returns `None`
- [ ] `SourceType::as_str()` returns "issue", "merge_request", "discussion"
- [ ] `compute_content_hash("hello")` returns deterministic SHA-256 hex string
- [ ] `compute_list_hash(["b", "a"])` == `compute_list_hash(["a", "b"])` (sorted)
- [ ] `DocumentData` struct compiles with all fields per PRD
- [ ] `cargo build` succeeds
- [ ] `cargo test documents` passes

## Files
- `src/documents/mod.rs` — new file (module root with pub use)
- `src/documents/extractor.rs` — new file (types + hash functions)
- `src/documents/regenerator.rs` — new file (placeholder, empty module or todo!())
- `src/documents/truncation.rs` — new file (placeholder, empty module or todo!())
- `src/lib.rs` — add `pub mod documents;`
- `Cargo.toml` — add `sha2 = "0.10"` if not present

## TDD Loop
RED: Tests in `extractor.rs` `#[cfg(test)] mod tests`:
- `test_source_type_parse_aliases` — all parse variants
- `test_source_type_as_str` — roundtrip as_str
- `test_content_hash_deterministic` — same input = same hash
- `test_list_hash_order_independent` — sorted before hashing
- `test_list_hash_empty` — empty vec produces consistent hash
GREEN: Implement types and hash functions
VERIFY: `cargo test documents`

## Edge Cases
- Empty string content hash: must produce valid SHA-256 (not panic)
- Empty labels list: compute_list_hash returns hash of empty string
- SourceType Display shows snake_case (same as as_str)

Completed: SourceType enum with parse/as_str/Display, DocumentData struct, compute_content_hash, compute_list_hash, 8 tests pass


[CP0] gi init command - interactive setup wizard

## Background

The init command is the user's first interaction with gi. It must guide them through setup, validate everything works before writing config, and leave the system in a ready-to-use state. Poor UX here will frustrate new users.

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

## Approach

**src/cli/commands/init.ts:**

Interactive flow (using inquirer):
1. Check if config exists at target path
   - If exists and no --force: prompt "Config exists. Overwrite? [y/N]"
   - If --non-interactive and config exists: exit 2
2. Prompt for GitLab base URL (validate URL format)
3. Prompt for token env var name (default: GITLAB_TOKEN)
4. Check token is set in environment
   - If not set: exit 1 with "Export GITLAB_TOKEN first"
5. Test auth with GET /api/v4/user
   - If 401: exit 1 with "Authentication failed"
   - Show "Authenticated as @username (Display Name)"
6. Prompt for project paths (comma-separated or add one at a time)
7. Validate each project with GET /api/v4/projects/:encoded_path
   - If 404: exit 1 with "Project not found: group/project"
   - Show "✓ group/project (Project Name)"
8. Write config.json to target path
9. Initialize database with migrations
10. Insert validated projects into projects table
11. Show "Setup complete! Run 'gi doctor' to verify."

**Flags:**
- `--config <path>`: Write config to specific path
- `--force`: Skip overwrite confirmation
- `--non-interactive`: Fail if prompts would be shown (for CI/scripting)

## Acceptance Criteria

- [ ] Creates config.json with valid structure
- [ ] Validates GitLab URL is reachable before writing config
- [ ] Validates token with GET /api/v4/user before writing config
- [ ] Validates each project path exists in GitLab before writing config
- [ ] Fails with exit 1 if token not set in environment
- [ ] Fails with exit 1 if GitLab auth fails
- [ ] Fails with exit 1 if any project not found
- [ ] Prompts before overwriting existing config (unless --force)
- [ ] --force skips overwrite confirmation
- [ ] --non-interactive fails if prompts would be shown
- [ ] Creates data directory and applies DB migrations
- [ ] Inserts validated projects into projects table
- [ ] tests/integration/init.test.ts passes (11 tests)

## Files

CREATE:
- src/cli/commands/init.ts
- tests/integration/init.test.ts

## TDD Loop

RED:
```typescript
// tests/integration/init.test.ts
describe('gi init', () => {
  it('creates config file with valid structure')
  it('validates GitLab URL format')
  it('validates GitLab connection before writing config')
  it('validates each project path exists in GitLab')
  it('fails if token not set')
  it('fails if GitLab auth fails')
  it('fails if any project path not found')
  it('prompts before overwriting existing config')
  it('respects --force to skip confirmation')
  it('generates config with sensible defaults')
  it('creates data directory if missing')
})
```

GREEN: Implement init.ts

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

## Edge Cases

- User cancels at any prompt: exit 2 (user cancelled)
- Network error during validation: show specific error, exit 1
- Token has wrong scopes (no read_api): auth succeeds but project fetch fails
- Project path with special characters must be URL-encoded
- Config directory might not exist - create with mkdirSync recursive
- --non-interactive with missing env var should fail immediately


Implement dirty source tracking module

## Background
Dirty source tracking drives incremental document regeneration. When entities are upserted during ingestion, they're marked dirty. The regenerator drains this queue. The key constraint: mark_dirty_tx() takes &Transaction to enforce atomic marking inside the entity upsert transaction. Uses ON CONFLICT DO UPDATE (not INSERT OR IGNORE) to reset backoff on re-queue.

## Approach
Create \`src/ingestion/dirty_tracker.rs\` per PRD Section 6.1.

```rust
const DIRTY_SOURCES_BATCH_SIZE: usize = 500;

/// Mark dirty INSIDE existing transaction. Takes &Transaction, NOT &Connection.
/// ON CONFLICT resets ALL backoff/error state (not INSERT OR IGNORE).
/// This ensures fresh updates are immediately eligible, not stuck behind stale backoff.
pub fn mark_dirty_tx(
    tx: &rusqlite::Transaction<'_>,
    source_type: SourceType,
    source_id: i64,
) -> Result<()>;

/// Convenience wrapper for non-transactional contexts.
pub fn mark_dirty(conn: &Connection, source_type: SourceType, source_id: i64) -> Result<()>;

/// Get dirty sources ready for processing.
/// WHERE next_attempt_at IS NULL OR next_attempt_at <= now
/// ORDER BY attempt_count ASC, queued_at ASC  (failed items deprioritized)
/// LIMIT 500
pub fn get_dirty_sources(conn: &Connection) -> Result<Vec<(SourceType, i64)>>;

/// Clear dirty entry after successful processing.
pub fn clear_dirty(conn: &Connection, source_type: SourceType, source_id: i64) -> Result<()>;
```

**PRD-specific details:**
- get_dirty_sources ORDER BY: \`attempt_count ASC, queued_at ASC\` (failed items processed AFTER fresh items)
- mark_dirty_tx ON CONFLICT resets: queued_at, attempt_count=0, last_attempt_at=NULL, last_error=NULL, next_attempt_at=NULL
- SourceType parsed from string in query results via match on \"issue\"/\"merge_request\"/\"discussion\"
- Invalid source_type in DB -> rusqlite::Error::FromSqlConversionFailure

**Error recording is in regenerator.rs (bd-1u1)**, not dirty_tracker. The dirty_tracker only marks, gets, and clears.

## Acceptance Criteria
- [ ] mark_dirty_tx takes &Transaction<'_>, NOT &Connection
- [ ] ON CONFLICT DO UPDATE resets: attempt_count=0, next_attempt_at=NULL, last_error=NULL, last_attempt_at=NULL
- [ ] Uses ON CONFLICT DO UPDATE, NOT INSERT OR IGNORE (PRD explains why)
- [ ] get_dirty_sources WHERE next_attempt_at IS NULL OR <= now
- [ ] get_dirty_sources ORDER BY attempt_count ASC, queued_at ASC
- [ ] get_dirty_sources LIMIT 500
- [ ] get_dirty_sources returns Vec<(SourceType, i64)>
- [ ] clear_dirty DELETEs entry
- [ ] Queue drains completely when called in loop
- [ ] \`cargo test dirty_tracker\` passes

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

## TDD Loop
RED: Tests:
- \`test_mark_dirty_tx_inserts\` — entry appears in dirty_sources
- \`test_requeue_resets_backoff\` — mark, simulate error state, re-mark -> attempt_count=0, next_attempt_at=NULL
- \`test_get_respects_backoff\` — entry with future next_attempt_at not returned
- \`test_get_orders_by_attempt_count\` — fresh items before failed items
- \`test_batch_size_500\` — insert 600, get returns 500
- \`test_clear_removes\` — entry gone after clear
- \`test_drain_loop\` — insert 1200, loop 3 times = empty
GREEN: Implement all functions
VERIFY: \`cargo test dirty_tracker\`

## Edge Cases
- Empty queue: get returns empty Vec
- Invalid source_type string in DB: FromSqlConversionFailure error
- Concurrent mark + get: ON CONFLICT handles race condition

Implemented dirty_tracker with mark_dirty_tx, get_dirty_sources, clear_dirty, record_dirty_error + 8 tests


## Background

Test fixtures provide mocked GitLab API responses for unit and integration tests. They enable testing without a live GitLab instance and ensure consistent test data across runs.

## Approach

### Fixture Files

Create JSON fixtures that match GitLab API response shapes:

```
tests/fixtures/
├── gitlab_issue.json           # Single issue
├── gitlab_issues_page.json     # Array of issues (pagination test)
├── gitlab_discussion.json      # Single discussion with notes
└── gitlab_discussions_page.json # Array of discussions
```

### gitlab_issue.json

```json
{
  "id": 12345,
  "iid": 42,
  "project_id": 100,
  "title": "Test issue title",
  "description": "Test issue description",
  "state": "opened",
  "created_at": "2024-01-15T10:00:00.000Z",
  "updated_at": "2024-01-20T15:30:00.000Z",
  "closed_at": null,
  "author": {
    "id": 1,
    "username": "testuser",
    "name": "Test User"
  },
  "labels": ["bug", "priority::high"],
  "web_url": "https://gitlab.example.com/group/project/-/issues/42"
}
```

### gitlab_discussion.json

```json
{
  "id": "6a9c1750b37d513a43987b574953fceb50b03ce7",
  "individual_note": false,
  "notes": [
    {
      "id": 1001,
      "type": "DiscussionNote",
      "body": "First comment in thread",
      "author": { "id": 1, "username": "testuser", "name": "Test User" },
      "created_at": "2024-01-16T09:00:00.000Z",
      "updated_at": "2024-01-16T09:00:00.000Z",
      "system": false,
      "resolvable": true,
      "resolved": false,
      "resolved_by": null,
      "resolved_at": null,
      "position": null
    },
    {
      "id": 1002,
      "type": "DiscussionNote",
      "body": "Reply to first comment",
      "author": { "id": 2, "username": "reviewer", "name": "Reviewer" },
      "created_at": "2024-01-16T10:00:00.000Z",
      "updated_at": "2024-01-16T10:00:00.000Z",
      "system": false,
      "resolvable": true,
      "resolved": false,
      "resolved_by": null,
      "resolved_at": null,
      "position": null
    }
  ]
}
```

### Helper Module

```rust
// tests/fixtures/mod.rs

pub fn load_fixture<T: DeserializeOwned>(name: &str) -> T {
    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests/fixtures")
        .join(name);
    let content = std::fs::read_to_string(&path)
        .expect(&format!("Failed to read fixture: {}", name));
    serde_json::from_str(&content)
        .expect(&format!("Failed to parse fixture: {}", name))
}

pub fn gitlab_issue() -> GitLabIssue {
    load_fixture("gitlab_issue.json")
}

pub fn gitlab_issues_page() -> Vec<GitLabIssue> {
    load_fixture("gitlab_issues_page.json")
}

pub fn gitlab_discussion() -> GitLabDiscussion {
    load_fixture("gitlab_discussion.json")
}
```

## Acceptance Criteria

- [ ] gitlab_issue.json deserializes to GitLabIssue correctly
- [ ] gitlab_issues_page.json contains 3+ issues for pagination tests
- [ ] gitlab_discussion.json contains multi-note thread
- [ ] gitlab_discussions_page.json contains mix of individual_note true/false
- [ ] At least one fixture includes system: true note
- [ ] Helper functions load fixtures without panic

## Files

- tests/fixtures/gitlab_issue.json (create)
- tests/fixtures/gitlab_issues_page.json (create)
- tests/fixtures/gitlab_discussion.json (create)
- tests/fixtures/gitlab_discussions_page.json (create)
- tests/fixtures/mod.rs (create)

## TDD Loop

RED:
```rust
#[test] fn fixture_gitlab_issue_deserializes()
#[test] fn fixture_gitlab_discussion_deserializes()
#[test] fn fixture_has_system_note()
```

GREEN: Create JSON fixtures and helper module

VERIFY: `cargo test fixture`

## Edge Cases

- Include issue with empty labels array
- Include issue with null description
- Include system note (system: true)
- Include individual_note: true discussion (standalone comment)
- Timestamps must be valid ISO 8601

Created 4 JSON fixture files (issue, issues_page, discussion, discussions_page) with helper tests - 6 tests passing


Epic: CP2 Gate A - MRs Only

## Background
Gate A validates core MR ingestion works before adding complexity. Proves the cursor-based sync, pagination, and basic CLI work. This is the foundation - if Gate A fails, nothing else matters.

## Acceptance Criteria (Pass/Fail)
- [ ] `gi ingest --type=merge_requests` completes without error
- [ ] `SELECT COUNT(*) FROM merge_requests` > 0
- [ ] `gi list mrs --limit=5` shows 5 MRs with iid, title, state, author
- [ ] `gi count mrs` shows total count matching DB query
- [ ] MR with `state=locked` can be stored (if exists in test data)
- [ ] Draft MR shows `draft=1` in DB and `[DRAFT]` in list output
- [ ] `work_in_progress=true` MR shows `draft=1` (fallback works)
- [ ] `head_sha` populated for MRs with commits
- [ ] `references_short` and `references_full` populated
- [ ] Re-run ingest shows "0 new MRs" or minimal refetch (cursor working)
- [ ] Cursor saved at page boundary, not item boundary

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

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

echo "=== Gate A: MRs Only ==="

# 1. Clear any existing MR data for clean test
echo "Step 1: Reset MR cursor for clean test..."
sqlite3 "$DB_PATH" "DELETE FROM sync_cursors WHERE resource_type = 'merge_requests';"

# 2. Run MR ingestion
echo "Step 2: Ingest MRs..."
gi ingest --type=merge_requests

# 3. Verify MRs exist
echo "Step 3: Verify MR count..."
MR_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM merge_requests;")
echo "  MR count: $MR_COUNT"
[ "$MR_COUNT" -gt 0 ] || { echo "FAIL: No MRs ingested"; exit 1; }

# 4. Verify list command
echo "Step 4: Test list command..."
gi list mrs --limit=5

# 5. Verify count command
echo "Step 5: Test count command..."
gi count mrs

# 6. Verify draft handling
echo "Step 6: Check draft MRs..."
DRAFT_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM merge_requests WHERE draft = 1;")
echo "  Draft MR count: $DRAFT_COUNT"

# 7. Verify head_sha population
echo "Step 7: Check head_sha..."
SHA_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM merge_requests WHERE head_sha IS NOT NULL;")
echo "  MRs with head_sha: $SHA_COUNT"

# 8. Verify references
echo "Step 8: Check references..."
REF_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM merge_requests WHERE references_short IS NOT NULL;")
echo "  MRs with references: $REF_COUNT"

# 9. Verify cursor saved
echo "Step 9: Check cursor..."
CURSOR=$(sqlite3 "$DB_PATH" "SELECT updated_at, gitlab_id FROM sync_cursors WHERE resource_type = 'merge_requests';")
echo "  Cursor: $CURSOR"
[ -n "$CURSOR" ] || { echo "FAIL: Cursor not saved"; exit 1; }

# 10. Re-run and verify minimal refetch
echo "Step 10: Re-run ingest (should be minimal)..."
gi ingest --type=merge_requests
# Output should show minimal or zero new MRs

echo ""
echo "=== Gate A: PASSED ==="
```

## Test Commands (Quick Verification)
```bash
# Run these in order:
gi ingest --type=merge_requests
gi list mrs --limit=10
gi count mrs

# Verify in DB:
sqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 "
  SELECT
    COUNT(*) as total,
    SUM(CASE WHEN draft = 1 THEN 1 ELSE 0 END) as drafts,
    SUM(CASE WHEN head_sha IS NOT NULL THEN 1 ELSE 0 END) as with_sha,
    SUM(CASE WHEN references_short IS NOT NULL THEN 1 ELSE 0 END) as with_refs
  FROM merge_requests;
"

# Re-run (should be no-op):
gi ingest --type=merge_requests
```

## Dependencies
This gate requires these beads to be complete:
- bd-3ir (Database migration)
- bd-5ta (GitLab MR types)
- bd-34o (MR transformer)
- bd-iba (GitLab client pagination)
- bd-ser (MR ingestion module)

## Edge Cases
- `locked` state is transitional (merge in progress); may not exist in test data
- Some older GitLab instances may not return `head_sha` for all MRs
- `work_in_progress` is deprecated but should still work as fallback
- Very large projects (10k+ MRs) may take significant time on first sync


Count entities in the database.

Commands:
- gi count issues → 'Issues: N'
- gi count discussions --type=issue → 'Issue Discussions: N'
- gi count notes --type=issue → 'Issue Notes: N (excluding M system)'

Files: src/cli/commands/count.ts
Done when: Counts match expected values from GitLab