Implement document regenerator

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


Implement DB upsert functions for resource events

## Background
Need to store fetched resource events into the three event tables created by migration 011. The existing DB pattern uses rusqlite prepared statements with named parameters. Timestamps from GitLab are ISO 8601 strings that need conversion to ms epoch UTC (matching the existing time.rs parse_datetime_to_ms function).

## Approach
Create src/core/events_db.rs (new module) with three upsert functions:

```rust
use rusqlite::Connection;
use super::error::Result;

/// Upsert state events for an entity.
/// Uses INSERT OR REPLACE keyed on UNIQUE(gitlab_id, project_id).
pub fn upsert_state_events(
    conn: &Connection,
    project_id: i64,          // local DB project id
    entity_type: &str,        // "issue" | "merge_request"
    entity_local_id: i64,     // local DB id of the issue/MR
    events: &[GitLabStateEvent],
) -> Result<usize>

/// Upsert label events for an entity.
pub fn upsert_label_events(
    conn: &Connection,
    project_id: i64,
    entity_type: &str,
    entity_local_id: i64,
    events: &[GitLabLabelEvent],
) -> Result<usize>

/// Upsert milestone events for an entity.
pub fn upsert_milestone_events(
    conn: &Connection,
    project_id: i64,
    entity_type: &str,
    entity_local_id: i64,
    events: &[GitLabMilestoneEvent],
) -> Result<usize>
```

Each function:
1. Prepares INSERT OR REPLACE statement
2. For each event, maps GitLab types to DB columns:
   - `actor_gitlab_id` = event.user.map(|u| u.id)
   - `actor_username` = event.user.map(|u| u.username.clone())
   - `created_at` = parse_datetime_to_ms(&event.created_at)?
   - Set issue_id or merge_request_id based on entity_type
3. Returns count of upserted rows
4. Wraps in a savepoint for atomicity per entity

Register module in src/core/mod.rs:
```rust
pub mod events_db;
```

## Acceptance Criteria
- [ ] All three upsert functions compile and handle all event fields
- [ ] Upserts are idempotent (re-inserting same event doesn't duplicate)
- [ ] Timestamps converted to ms epoch UTC via parse_datetime_to_ms
- [ ] actor_gitlab_id and actor_username populated from event.user (handles None)
- [ ] entity_type correctly maps to issue_id/merge_request_id (other is NULL)
- [ ] source_merge_request_id populated for state events (iid from source_merge_request)
- [ ] source_commit populated for state events
- [ ] label_name populated for label events
- [ ] milestone_title and milestone_id populated for milestone events
- [ ] Returns upserted count

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

## TDD Loop
RED: tests/events_db_tests.rs (new):
- `test_upsert_state_events_basic` - insert 3 events, verify count and data
- `test_upsert_state_events_idempotent` - insert same events twice, verify no duplicates
- `test_upsert_label_events_with_actor` - verify actor fields populated
- `test_upsert_milestone_events_null_user` - verify user: null doesn't crash
- `test_upsert_state_events_entity_exclusivity` - verify only one of issue_id/merge_request_id set

Setup: create_test_db() helper that applies migrations 001-011, inserts a test project + issue + MR.

GREEN: Implement the three functions

VERIFY: `cargo test events_db -- --nocapture`

## Edge Cases
- parse_datetime_to_ms must handle GitLab's format: "2024-03-15T10:30:00.000Z" and "2024-03-15T10:30:00.000+00:00"
- INSERT OR REPLACE will fire CASCADE deletes if there are FK references to these rows — currently no other table references event rows, so this is safe
- entity_type must be validated ("issue" or "merge_request") — panic or error on invalid
- source_merge_request field contains an MR ref object, not an ID — extract .iid for DB column

Implemented upsert_state_events, upsert_label_events, upsert_milestone_events, count_events in src/core/events_db.rs. Uses savepoints for atomicity, LoreError::Database via ? operator for clean error handling.


[CP0] Final validation - tests, lint, typecheck

## Background

Final validation ensures everything works together before marking CP0 complete. This is the integration gate - all unit tests, integration tests, lint, and type checking must pass. Manual smoke tests verify the full user experience.

Reference: docs/prd/checkpoint-0.md sections "Definition of Done", "Manual Smoke Tests"

## Approach

**Automated checks:**
```bash
# All tests pass
npm run test

# TypeScript strict mode
npm run build  # or: npx tsc --noEmit

# ESLint with no errors
npm run lint
```

**Manual smoke tests (from PRD table):**

| Command | Expected | Pass Criteria |
|---------|----------|---------------|
| `gi --help` | Command list | Shows all commands |
| `gi version` | Version number | Shows installed version |
| `gi init` | Interactive prompts | Creates valid config |
| `gi init` (config exists) | Confirmation prompt | Warns before overwriting |
| `gi init --force` | No prompt | Overwrites without asking |
| `gi auth-test` | `Authenticated as @username` | Shows GitLab username |
| `GITLAB_TOKEN=invalid gi auth-test` | Error message | Non-zero exit, clear error |
| `gi doctor` | Status table | All required checks pass |
| `gi doctor --json` | JSON object | Valid JSON, `success: true` |
| `gi backup` | Backup path | Creates timestamped backup |
| `gi sync-status` | No runs message | Stub output works |

**Definition of Done gate items:**
- [ ] `gi init` writes config to XDG path and validates projects against GitLab
- [ ] `gi auth-test` succeeds with real PAT
- [ ] `gi doctor` reports DB ok + GitLab ok
- [ ] DB migrations apply; WAL + FK enabled; busy_timeout + synchronous set
- [ ] App lock mechanism works (concurrent runs blocked)
- [ ] All unit tests pass
- [ ] All integration tests pass (mocked)
- [ ] ESLint passes with no errors
- [ ] TypeScript compiles with strict mode

## Acceptance Criteria

- [ ] `npm run test` exits 0 (all tests pass)
- [ ] `npm run build` exits 0 (TypeScript compiles)
- [ ] `npm run lint` exits 0 (no ESLint errors)
- [ ] All 11 manual smoke tests pass
- [ ] All 9 Definition of Done gate items verified

## Files

No new files created. This bead verifies existing work.

## TDD Loop

This IS the final verification step:

```bash
# Automated
npm run test
npm run build
npm run lint

# Manual (requires GITLAB_TOKEN set with valid token)
gi --help
gi version
gi init  # go through setup
gi auth-test
gi doctor
gi doctor --json | jq .success  # should output true
gi backup
gi sync-status
gi reset --confirm
gi init  # re-setup
```

## Edge Cases

- Test coverage should be reasonable (aim for 80%+ on core modules)
- Integration tests may flake on CI - check MSW setup
- Manual tests require real GitLab token - document in README
- ESLint may warn vs error - only errors block
- TypeScript noImplicitAny catches missed types


Update robot-docs manifest with Phase B commands

## Background
The `lore robot-docs` command outputs a machine-readable JSON manifest for agent self-discovery. New Phase B commands (timeline, file-history, trace, count references) must be registered so agents can discover and use them.

## Approach
In src/cli/commands/robot_docs.rs, add entries to the commands array for:

1. `timeline` - entity decision timeline
   - args: entity type + IID, optional --project
   - flags: --since, --until, --type (filter event types)
   - output: chronological event list with actor/action/details

2. `file-history` - file decision history
   - args: file path
   - flags: --since, --project
   - output: MR/issue events touching the file

3. `trace` - code trace from file to decisions
   - args: file path or pattern
   - flags: --depth, --since, --project
   - output: connected graph of files → MRs → issues → cross-refs

4. `count references` - cross-reference graph stats
   - existing count command extended with new entity type

Each entry follows the existing manifest structure with command name, description, args, flags, example, and output schema.

## Acceptance Criteria
- [ ] `lore robot-docs` JSON includes timeline command with args/flags/example
- [ ] `lore robot-docs` JSON includes file-history command
- [ ] `lore robot-docs` JSON includes trace command
- [ ] `lore robot-docs` JSON includes references in count entity list
- [ ] All new entries follow existing manifest schema
- [ ] `lore robot-docs | jq '.commands[] | select(.name=="timeline")'` returns valid entry

## Files
- src/cli/commands/robot_docs.rs (add 4 command entries)

## TDD Loop
RED: tests/robot_docs_test.rs:
- `test_robot_docs_includes_timeline` - parse JSON, assert timeline command exists
- `test_robot_docs_includes_file_history` - assert file-history command exists
- `test_robot_docs_includes_trace` - assert trace command exists
- `test_robot_docs_count_includes_references` - assert count entity list includes references

GREEN: Add command entries to the manifest builder
REFACTOR: Extract command entry builder if pattern is repetitive

VERIFY: `cargo test robot_docs -- --nocapture`

## Edge Cases
- Manifest must remain valid JSON (no trailing commas, proper escaping)
- Command examples should use realistic flag combinations
- Flag descriptions must match actual CLI behavior


Implement lore sync CLI command

## Background
The sync command is the unified orchestrator for the full pipeline: ingest -> generate-docs -> embed. It replaces the need to run three separate commands. It acquires a lock, runs each stage sequentially, and reports combined results. Individual stages can be skipped via flags (--no-embed, --no-docs). The command is designed for cron/scheduled execution. Individual commands (`lore generate-docs`, `lore embed`) still exist for manual recovery and debugging.

## Approach
Create `src/cli/commands/sync.rs` per PRD Section 6.4.

**IMPORTANT: run_sync is async** (embed_documents and search_hybrid are async).

**Key types (PRD-exact):**
```rust
#[derive(Debug, Serialize)]
pub struct SyncResult {
    pub issues_updated: usize,
    pub mrs_updated: usize,
    pub discussions_fetched: usize,
    pub documents_regenerated: usize,
    pub documents_embedded: usize,
}

#[derive(Debug, Default)]
pub struct SyncOptions {
    pub full: bool,       // Reset cursors, fetch everything
    pub force: bool,      // Override stale lock
    pub no_embed: bool,   // Skip embedding step
    pub no_docs: bool,    // Skip document regeneration
}
```

**Core function (async, PRD-exact):**
```rust
pub async fn run_sync(config: &Config, options: SyncOptions) -> Result<SyncResult>
```

**Pipeline (sequential steps per PRD):**
1. Acquire app lock with heartbeat (via existing `src/core/lock.rs`)
2. Ingest delta: fetch issues + MRs via cursor-based sync (calls existing ingestion orchestrator)
   - Each upserted entity marked dirty via `mark_dirty_tx(&tx)` inside ingestion transaction
3. Process `pending_discussion_fetches` queue (bounded)
   - Discussion sweep uses CTE to capture stale IDs, then cascading deletes
4. Regenerate documents from `dirty_sources` queue (unless --no-docs)
5. Embed documents with changed content_hash (unless --no-embed; skipped gracefully if Ollama unavailable)
6. Release lock, record sync_run

**NOTE (PRD):** Rolling backfill window removed — the existing cursor + watermark design handles old issues with resumed activity. GitLab updates `updated_at` when new comments are added, so the cursor naturally picks up old issues that receive new activity.

**CLI args (PRD-exact):**
```rust
#[derive(Args)]
pub struct SyncArgs {
    /// Reset cursors, fetch everything
    #[arg(long)]
    full: bool,
    /// Override stale lock
    #[arg(long)]
    force: bool,
    /// Skip embedding step
    #[arg(long)]
    no_embed: bool,
    /// Skip document regeneration
    #[arg(long)]
    no_docs: bool,
}
```

**Human output:**
```
Sync complete:
  Issues updated:             42
  MRs updated:                18
  Discussions fetched:        56
  Documents regenerated:      38
  Documents embedded:         38
  Elapsed: 2m 15s
```

**JSON output:**
```json
{"ok": true, "data": {...}, "meta": {"elapsed_ms": 135000}}
```

## Acceptance Criteria
- [ ] Function is `async fn run_sync`
- [ ] Takes `SyncOptions` struct (not separate params)
- [ ] Returns `SyncResult` with flat fields (not nested sub-structs)
- [ ] Full pipeline orchestrated: ingest -> discussion queue -> docs -> embed
- [ ] --full resets cursors (passes through to ingest)
- [ ] --force overrides stale sync lock
- [ ] --no-embed skips embedding stage (Ollama not needed)
- [ ] --no-docs skips document regeneration stage
- [ ] Discussion queue processing bounded per run
- [ ] Dirty sources marked inside ingestion transactions (via mark_dirty_tx)
- [ ] Progress reporting: stage names + elapsed time
- [ ] Lock acquired with heartbeat at start, released at end (even on error)
- [ ] Embedding skipped gracefully if Ollama unavailable (warning, not error)
- [ ] JSON summary in robot mode
- [ ] Human-readable summary with elapsed time
- [ ] `cargo build` succeeds

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

## TDD Loop
RED: Integration test requiring full pipeline
GREEN: Implement run_sync orchestration (async)
VERIFY: `cargo build && cargo test sync`

## Edge Cases
- Ollama unavailable + --no-embed not set: sync should NOT fail — embed stage logs warning, returns 0 embedded
- Lock already held: error unless --force (and lock is stale)
- No dirty sources after ingest: regeneration stage returns 0 (not error)
- --full with large dataset: keyset pagination prevents OFFSET degradation

Sync CLI: async run_sync orchestrator with 4-stage pipeline (ingest issues, ingest MRs, generate-docs, embed), SyncOptions/SyncResult, --full/--force/--no-embed/--no-docs flags, graceful Ollama degradation, human+JSON output, clean build, all tests pass


Implement chunk ID encoding module

## Background
sqlite-vec uses a single integer rowid for embeddings. To store multiple chunks per document, we encode (document_id, chunk_index) into a single rowid using a multiplier. This module is shared between the embedding pipeline (encode on write) and vector search (decode on read). The encoding scheme supports up to 1000 chunks per document.

## Approach
Create `src/embedding/chunk_ids.rs`:

```rust
/// Multiplier for encoding (document_id, chunk_index) into a single rowid.
/// Supports up to 1000 chunks per document (32M chars at 32k/chunk).
pub const CHUNK_ROWID_MULTIPLIER: i64 = 1000;

/// Encode (document_id, chunk_index) into a sqlite-vec rowid.
///
/// rowid = document_id * CHUNK_ROWID_MULTIPLIER + chunk_index
pub fn encode_rowid(document_id: i64, chunk_index: i64) -> i64 {
    document_id * CHUNK_ROWID_MULTIPLIER + chunk_index
}

/// Decode a sqlite-vec rowid back into (document_id, chunk_index).
pub fn decode_rowid(rowid: i64) -> (i64, i64) {
    let document_id = rowid / CHUNK_ROWID_MULTIPLIER;
    let chunk_index = rowid % CHUNK_ROWID_MULTIPLIER;
    (document_id, chunk_index)
}
```

Also create the parent module `src/embedding/mod.rs`:
```rust
pub mod chunk_ids;
// Later beads add: pub mod ollama; pub mod pipeline;
```

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

## Acceptance Criteria
- [ ] `encode_rowid(42, 0)` == 42000
- [ ] `encode_rowid(42, 5)` == 42005
- [ ] `decode_rowid(42005)` == (42, 5)
- [ ] Roundtrip: decode(encode(doc_id, chunk_idx)) == (doc_id, chunk_idx) for all valid inputs
- [ ] CHUNK_ROWID_MULTIPLIER is 1000
- [ ] `cargo test chunk_ids` passes

## Files
- `src/embedding/chunk_ids.rs` — new file
- `src/embedding/mod.rs` — new file (module root)
- `src/lib.rs` — add `pub mod embedding;`

## TDD Loop
RED: Tests in `#[cfg(test)] mod tests`:
- `test_encode_single_chunk` — encode(1, 0) == 1000
- `test_encode_multi_chunk` — encode(1, 5) == 1005
- `test_decode_roundtrip` — property test over range of doc_ids and chunk_indices
- `test_decode_zero_chunk` — decode(42000) == (42, 0)
- `test_multiplier_value` — assert CHUNK_ROWID_MULTIPLIER == 1000
GREEN: Implement encode_rowid, decode_rowid
VERIFY: `cargo test chunk_ids`

## Edge Cases
- chunk_index >= 1000: not expected (documents that large would be pathological), but no runtime panic — just incorrect decode. The embedding pipeline caps chunks well below this.
- document_id = 0: valid (encode returns chunk_index directly)

Completed: chunk_ids module with encode_rowid/decode_rowid, CHUNK_ROWID_MULTIPLIER=1000, 6 tests pass


[CP1] GitLab types for issues, discussions, notes

Add TypeScript interfaces for GitLab API responses.

Types to add to src/gitlab/types.ts:
- GitLabIssue: id, iid, project_id, title, description, state, timestamps, author, labels[], labels_details?, web_url
- GitLabDiscussion: id (string), individual_note, notes[]
- GitLabNote: id, type, body, author, timestamps, system, resolvable, resolved, resolved_by, resolved_at, position?

Files: src/gitlab/types.ts
Done when: Types compile and match GitLab API documentation


Implement rename chain resolution for file-history

## Background
File renames need to be resolved as bounded chains to find all historical paths. When a user queries for src/auth/oauth.rs, the system should also find MRs that touched src/auth/handler.rs if it was renamed. This is a core algorithm for both file-history and trace commands.

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

```rust
/// Result of rename chain resolution.
pub struct RenameChain {
    /// All paths the file has been known by, ordered chronologically.
    pub paths: Vec<String>,
    /// The original query path.
    pub query_path: String,
}

/// Resolve the rename chain for a file path.
/// Starts with the query path and follows renames in both directions
/// up to max_hops (default 10).
pub fn resolve_rename_chain(
    conn: &Connection,
    query_path: &str,
    max_hops: usize,       // default 10, configurable
) -> Result<RenameChain>
```

Algorithm:
```
1. path_set = HashSet::from([query_path])
2. for hop in 0..max_hops:
3.   new_paths = query:
        SELECT old_path, new_path FROM mr_file_changes
        WHERE change_type = 'renamed'
          AND (new_path IN path_set OR old_path IN path_set)
4.   for (old, new) in new_paths:
5.     add the "other side" (not already in path_set) to path_set
6.   if no new paths discovered: break
7. return ordered path set (attempt chronological ordering by MR date)
```

The ordering uses MR merge/creation dates to present paths chronologically:
```sql
SELECT DISTINCT mfc.old_path, mfc.new_path, COALESCE(mr.merged_at, mr.created_at) as date
FROM mr_file_changes mfc
JOIN merge_requests mr ON mr.id = mfc.merge_request_id
WHERE mfc.change_type = 'renamed'
  AND (mfc.new_path IN (...) OR mfc.old_path IN (...))
ORDER BY date;
```

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

## Acceptance Criteria
- [ ] Single rename resolved: query "b.rs" finds "a.rs" (renamed a→b)
- [ ] Multi-hop: a→b→c chain resolved from any entry point
- [ ] Cycle detection: a→b→a doesn't infinite loop
- [ ] 10-hop cap enforced
- [ ] Paths returned in chronological order
- [ ] --no-follow-renames returns single-element chain (just query path)

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

## TDD Loop
RED: tests/file_history_tests.rs:
- `test_rename_chain_single_hop` - a.rs renamed to b.rs, query b.rs → [a.rs, b.rs]
- `test_rename_chain_multi_hop` - a→b→c, query c → [a, b, c]
- `test_rename_chain_reverse_direction` - query a.rs also discovers b.rs
- `test_rename_chain_cycle_detection` - a→b, b→a in different MRs → terminates
- `test_rename_chain_hop_cap` - 15-hop chain with cap 10 → truncated at 10
- `test_rename_chain_no_renames` - file never renamed → single-element chain

Setup: create_test_db with migrations 001-012, seed mr_file_changes with renamed entries.

GREEN: Implement resolve_rename_chain

VERIFY: `cargo test file_history -- --nocapture`

## Edge Cases
- Same file renamed back and forth (a→b in MR !1, b→a in MR !2): cycle detection handles this
- File renamed in two different projects: the query is project-scoped, so only same-project renames matter
- old_path in mr_file_changes is only populated for change_type='renamed' — the WHERE clause already filters this
- Path matching is exact (case-sensitive on Linux, should be consistent)


Implement MR document extraction

## Background
MR documents are similar to issue documents but include source/target branch information in the header. The extractor queries merge_requests and mr_labels tables. Like issue extraction, it produces a DocumentData struct for the regeneration pipeline.

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

```rust
/// Extract a searchable document from a merge request.
/// Returns None if the MR has been deleted from the DB.
pub fn extract_mr_document(conn: &Connection, mr_id: i64) -> Result<Option<DocumentData>>
```

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

-- Labels
SELECT l.name FROM mr_labels ml
JOIN labels l ON l.id = ml.label_id
WHERE ml.merge_request_id = ?
ORDER BY l.name
```

**Document format:**
```
[[MergeRequest]] !456: Implement JWT authentication
Project: group/project-one
URL: https://gitlab.example.com/group/project-one/-/merge_requests/456
Labels: ["feature", "auth"]
State: opened
Author: @johndoe
Source: feature/jwt-auth -> main

--- Description ---

This MR implements JWT-based authentication...
```

**Key difference from issues:** The `Source:` line with `source_branch -> target_branch`.

## Acceptance Criteria
- [ ] Deleted MR returns Ok(None)
- [ ] MR document has `[[MergeRequest]]` prefix with `!` before iid (not `#`)
- [ ] Source line shows `source_branch -> target_branch`
- [ ] Labels sorted alphabetically in JSON array
- [ ] content_hash computed from full content_text
- [ ] labels_hash computed from sorted labels
- [ ] paths is empty (MR-level docs don't have DiffNote paths; those are on discussion docs)
- [ ] `cargo test extract_mr` passes

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

## TDD Loop
RED: Tests in `#[cfg(test)] mod tests`:
- `test_mr_document_format` — verify header matches PRD template with Source line
- `test_mr_not_found` — returns Ok(None)
- `test_mr_no_description` — header only
- `test_mr_branch_info` — Source line correct
GREEN: Implement extract_mr_document with SQL queries
VERIFY: `cargo test extract_mr`

## Edge Cases
- MR with NULL description: skip "--- Description ---" section
- MR with NULL source_branch or target_branch: omit Source line (shouldn't happen in practice)
- Draft MRs: state field captures this, no special handling needed

Implemented extract_mr_document() with Source line, PRD format, and 5 tests


[CP1] Issue ingestion module

## Background

The issue ingestion module fetches and stores issues with cursor-based incremental sync. It is the primary data ingestion component, establishing the pattern reused for MR ingestion in CP2. The module handles tuple-cursor semantics, raw payload storage, label extraction, and tracking which issues need discussion sync.

## Approach

### Module: src/ingestion/issues.rs

### Key Structs

```rust
#[derive(Debug, Default)]
pub struct IngestIssuesResult {
    pub fetched: usize,
    pub upserted: usize,
    pub labels_created: usize,
    pub issues_needing_discussion_sync: Vec<IssueForDiscussionSync>,
}

#[derive(Debug, Clone)]
pub struct IssueForDiscussionSync {
    pub local_issue_id: i64,
    pub iid: i64,
    pub updated_at: i64,  // ms epoch
}
```

### Main Function

```rust
pub async fn ingest_issues(
    conn: &Connection,
    client: &GitLabClient,
    config: &Config,
    project_id: i64,           // Local DB project ID
    gitlab_project_id: i64,
) -> Result<IngestIssuesResult>
```

### Logic (Step by Step)

1. **Get current cursor** from sync_cursors table:
```sql
SELECT updated_at_cursor, tie_breaker_id
FROM sync_cursors
WHERE project_id = ? AND resource_type = 'issues'
```

2. **Call pagination method** with cursor rewind:
```rust
let issues_stream = client.paginate_issues(
    gitlab_project_id,
    cursor.updated_at_cursor,
    config.sync.cursor_rewind_seconds,
);
```

3. **Apply local filtering** for tuple cursor semantics:
```rust
// Skip if issue.updated_at < cursor_updated_at
// Skip if issue.updated_at == cursor_updated_at AND issue.gitlab_id <= cursor_gitlab_id
fn passes_cursor_filter(issue: &GitLabIssue, cursor: &SyncCursor) -> bool {
    if issue.updated_at < cursor.updated_at_cursor {
        return false;
    }
    if issue.updated_at == cursor.updated_at_cursor
        && issue.gitlab_id <= cursor.tie_breaker_id {
        return false;
    }
    true
}
```

4. **For each issue passing filter**:
```rust
// Begin transaction (unchecked_transaction for rusqlite)
let tx = conn.unchecked_transaction()?;

// Store raw payload (compressed based on config)
let payload_id = store_raw_payload(&tx, &issue_json, config.storage.compress_raw_payloads)?;

// Transform and upsert issue
let issue_row = transform_issue(&issue)?;
upsert_issue(&tx, &issue_row, project_id, payload_id)?;
let local_issue_id = get_local_issue_id(&tx, project_id, issue.iid)?;

// Clear existing label links (stale removal!)
tx.execute("DELETE FROM issue_labels WHERE issue_id = ?", [local_issue_id])?;

// Extract and upsert labels
for label_name in &issue_row.label_names {
    let label_id = upsert_label(&tx, project_id, label_name)?;
    link_issue_label(&tx, local_issue_id, label_id)?;
}

tx.commit()?;
```

5. **Incremental cursor update** every 100 issues:
```rust
if batch_count % 100 == 0 {
    update_sync_cursor(conn, project_id, "issues", last_updated_at, last_gitlab_id)?;
}
```

6. **Final cursor update** after all issues processed

7. **Determine issues needing discussion sync**:
```sql
SELECT id, iid, updated_at
FROM issues
WHERE project_id = ?
  AND updated_at > COALESCE(discussions_synced_for_updated_at, 0)
```

### Helper Functions

```rust
fn store_raw_payload(conn, json: &Value, compress: bool) -> Result<i64>
fn upsert_issue(conn, issue: &IssueRow, project_id: i64, payload_id: i64) -> Result<()>
fn get_local_issue_id(conn, project_id: i64, iid: i64) -> Result<i64>
fn upsert_label(conn, project_id: i64, name: &str) -> Result<i64>
fn link_issue_label(conn, issue_id: i64, label_id: i64) -> Result<()>
fn update_sync_cursor(conn, project_id: i64, resource: &str, updated_at: i64, gitlab_id: i64) -> Result<()>
```

### Critical Invariant

Stale label links MUST be removed on resync. The "DELETE then INSERT" pattern ensures GitLab reality is reflected locally. If an issue had labels [A, B] and now has [A, C], the B link must be removed.

## Acceptance Criteria

- [ ] `ingest_issues` returns IngestIssuesResult with all counts
- [ ] Cursor fetched from sync_cursors at start
- [ ] Cursor rewind applied before API call
- [ ] Local filtering skips already-processed issues
- [ ] Each issue wrapped in transaction for atomicity
- [ ] Raw payload stored with correct compression
- [ ] Issue upserted (INSERT OR REPLACE pattern)
- [ ] Existing label links deleted before new links inserted
- [ ] Labels upserted (INSERT OR IGNORE by project+name)
- [ ] Cursor updated every 100 issues (crash recovery)
- [ ] Final cursor update after all issues
- [ ] issues_needing_discussion_sync populated correctly

## Files

- src/ingestion/mod.rs (add `pub mod issues;`)
- src/ingestion/issues.rs (create)

## TDD Loop

RED:
```rust
// tests/issue_ingestion_tests.rs
#[tokio::test] async fn ingests_issues_from_stream()
#[tokio::test] async fn applies_cursor_filter_correctly()
#[tokio::test] async fn updates_cursor_every_100_issues()
#[tokio::test] async fn stores_raw_payload_for_each_issue()
#[tokio::test] async fn upserts_issues_correctly()

// tests/label_linkage_tests.rs
#[tokio::test] async fn extracts_and_stores_labels()
#[tokio::test] async fn removes_stale_label_links_on_resync()
#[tokio::test] async fn handles_empty_labels_array()

// tests/discussion_eligibility_tests.rs
#[tokio::test] async fn identifies_issues_needing_discussion_sync()
#[tokio::test] async fn skips_issues_with_current_watermark()
```

GREEN: Implement ingest_issues with all helper functions

VERIFY: `cargo test issue_ingestion && cargo test label_linkage && cargo test discussion_eligibility`

## Edge Cases

- Empty issues stream - return result with all zeros
- Cursor at epoch 0 - fetch all issues (no filtering)
- Issue with no labels - empty Vec, no label links created
- Issue with 50+ labels - all should be linked
- Crash mid-batch - cursor at last 100-boundary, some issues re-fetched
- Label already exists - upsert via INSERT OR IGNORE
- Same issue fetched twice (due to rewind) - upsert handles it


Define TimelineEvent model and TimelineEventType enum

## Background
The timeline needs a unified event model that spans multiple source tables (resource events, issues, MRs, notes). This is a read-time virtual event stream — not a separate stored table. The spec (§3.3) defines the exact shapes.

## Approach
Create src/core/timeline.rs with the core types:

```rust
use serde::Serialize;

/// A single event in a timeline query result.
#[derive(Debug, Clone, Serialize)]
pub struct TimelineEvent {
    pub timestamp: i64,              // ms epoch UTC
    pub entity_type: String,         // "issue" | "merge_request"
    pub entity_iid: i64,
    pub project_path: String,
    pub event_type: TimelineEventType,
    pub summary: String,             // human-readable one-liner
    pub actor: Option<String>,       // username
    pub url: Option<String>,
    pub is_seed: bool,               // matched by keyword (vs. expanded via reference)
}

/// Types of events in the timeline.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TimelineEventType {
    Created,
    StateChanged { state: String },
    LabelAdded { label: String },
    LabelRemoved { label: String },
    MilestoneSet { milestone: String },
    MilestoneRemoved { milestone: String },
    Merged,
    NoteEvidence {
        note_id: i64,
        snippet: String,              // first ~200 chars
        discussion_id: Option<i64>,
    },
    CrossReferenced { target: String },
}

impl TimelineEvent {
    /// Create a summary string for human output.
    pub fn format_summary(&self) -> String { ... }
}

impl Ord for TimelineEvent {
    /// Sort chronologically by timestamp, then by entity_iid for stable tiebreak.
    fn cmp(&self, other: &Self) -> Ordering {
        self.timestamp.cmp(&other.timestamp)
            .then_with(|| self.entity_iid.cmp(&other.entity_iid))
    }
}
impl PartialOrd for TimelineEvent { ... }
impl Eq for TimelineEvent {}
impl PartialEq for TimelineEvent { ... }

/// Result of a timeline query.
pub struct TimelineResult {
    pub query: String,
    pub events: Vec<TimelineEvent>,
    pub seed_entities: Vec<EntityRef>,
    pub expanded_entities: Vec<ExpandedEntityRef>,
    pub unresolved_references: Vec<UnresolvedRef>,
}

#[derive(Debug, Clone, Serialize)]
pub struct EntityRef {
    pub entity_type: String,
    pub iid: i64,
    pub project: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct ExpandedEntityRef {
    pub entity_type: String,
    pub iid: i64,
    pub project: String,
    pub depth: usize,
    pub via: ExpansionProvenance,
}

#[derive(Debug, Clone, Serialize)]
pub struct ExpansionProvenance {
    pub from: EntityRef,
    pub reference_type: String,
    pub source_method: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct UnresolvedRef {
    pub source: EntityRef,
    pub target_project: String,
    pub target_type: String,
    pub target_iid: i64,
    pub reference_type: String,
}
```

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

## Acceptance Criteria
- [ ] All types compile with Serialize derive
- [ ] TimelineEventType serde serializes with tagged union (type field)
- [ ] TimelineEvent Ord sorts chronologically with stable tiebreak
- [ ] format_summary produces readable strings for each event type
- [ ] All helper types (EntityRef, ExpandedEntityRef, UnresolvedRef) defined

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

## TDD Loop
RED: tests/timeline_types_tests.rs:
- `test_timeline_event_sort_chronological` - events at different timestamps sort correctly
- `test_timeline_event_sort_stable_tiebreak` - same timestamp, different iids, stable order
- `test_timeline_event_type_serialize` - verify JSON tag serialization for each variant
- `test_format_summary_created` - verify human string for Created
- `test_format_summary_state_changed` - verify "closed" / "reopened" etc.
- `test_format_summary_note_evidence` - verify snippet truncation

GREEN: Define all types and implement traits

VERIFY: `cargo test timeline_types -- --nocapture`

## Edge Cases
- TimelineEventType::NoteEvidence snippet should be truncated at ~200 chars with "..." suffix — handle multi-byte UTF-8 correctly (don't split in middle of char)
- serde tag="type" may conflict if any variant has a field named "type" — none do, so safe
- Ord comparison on timestamps should handle equal timestamps gracefully — iid tiebreak ensures stability


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

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

## Approach
Add a 'dependencies' section to the robot-docs JSON output in the command that generates it. The section lists optional external dependencies with install commands, setup instructions, and which commands require them.

In the robot-docs handler (likely src/cli/commands/robot_docs.rs or wherever the robot-docs JSON is built):

```rust
"dependencies": {
    "ollama": {
        "required_for": ["embed", "search --mode semantic", "search --mode hybrid"],
        "not_required_for": ["search --mode lexical", "list", "show", "count", "timeline", "file-history", "trace"],
        "install": {
            "macos": "brew install ollama",
            "linux": "curl -fsSL https://ollama.com/install.sh | sh",
            "check": "ollama --version"
        },
        "setup": ["ollama serve", "ollama pull nomic-embed-text"],
        "optional": true,
        "note": "Lexical search, temporal queries, and all non-embedding features work without Ollama. Only semantic/hybrid search and the embed command require it."
    }
}
```

Locate the existing robot-docs generation code and add this section to the top-level JSON object.

## Acceptance Criteria
- [ ] `lore robot-docs` JSON includes a "dependencies" section
- [ ] Ollama listed with required_for commands, install commands, and setup steps
- [ ] optional: true flag present
- [ ] Note clarifying what works without Ollama
- [ ] Existing robot-docs fields unchanged

## Files
- src/cli/commands/robot_docs.rs (or wherever robot-docs JSON is generated)

## TDD Loop
RED: tests/robot_docs_tests.rs:
- `test_robot_docs_includes_dependencies` - parse JSON, verify "dependencies" key exists
- `test_robot_docs_ollama_dependency` - verify ollama entry has required_for, install, setup

GREEN: Add the dependencies section to robot-docs output

VERIFY: `cargo test robot_docs -- --nocapture`

## Edge Cases
- Keep the dependencies section extensible (HashMap or Vec) for future deps
- Don't change the top-level structure of existing robot-docs — add alongside existing fields


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


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


Add fetchResourceEvents config flag to SyncConfig

## Background
Event fetching should be opt-in (default true) so users who don't need temporal queries skip 3 extra API calls per entity. This follows the existing SyncConfig pattern with serde defaults and camelCase JSON aliases.

## Approach
Add to SyncConfig in src/core/config.rs:
```rust
#[serde(rename = "fetchResourceEvents", default = "default_true")]
pub fetch_resource_events: bool,
```

Add default function (if not already present):
```rust
fn default_true() -> bool { true }
```

Update Default impl for SyncConfig to include `fetch_resource_events: true`.

Add --no-events flag to sync command in src/cli/mod.rs (SyncArgs):
```rust
/// Skip resource event fetching (overrides config)
#[arg(long = "no-events", help_heading = "Sync Options")]
pub no_events: bool,
```

In the sync command handler (src/cli/commands/sync.rs), override config when flag is set:
```rust
if args.no_events {
    config.sync.fetch_resource_events = false;
}
```

## Acceptance Criteria
- [ ] SyncConfig deserializes `fetchResourceEvents: false` from JSON config
- [ ] SyncConfig defaults to `fetch_resource_events: true` when field absent
- [ ] `--no-events` flag parses correctly in CLI
- [ ] `--no-events` overrides config to false
- [ ] `cargo test` passes with no regressions

## Files
- src/core/config.rs (add field to SyncConfig + default fn + Default impl)
- src/cli/mod.rs (add --no-events to SyncArgs)
- src/cli/commands/sync.rs (override config when flag set)

## TDD Loop
RED: tests/config_tests.rs (or inline in config.rs):
- `test_sync_config_fetch_resource_events_default_true` - omit field from JSON, verify default
- `test_sync_config_fetch_resource_events_explicit_false` - set field false, verify parsed
- `test_sync_config_no_events_flag` - verify CLI arg parsing

GREEN: Add the field, default fn, Default impl update, CLI flag, and override logic

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

## Edge Cases
- Ensure serde rename matches camelCase convention used by all other SyncConfig fields
- The default_true fn may already exist for other fields — check before adding duplicate
- The --no-events flag must NOT be confused with --no-X negation flags already in CLI (check mod.rs for conflicts)

Completed: Added fetch_resource_events bool to SyncConfig with serde rename, default_true, --no-events CLI flag, and config override in sync handler


Add 'lore count references' command

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

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

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

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

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

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

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

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

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

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


## Background

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

## Approach

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

### Clap Definition

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

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

### Handler Function

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

### Queries by Entity

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

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

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

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

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

### Output Format

```
Issues: 3,801
```

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

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

## Acceptance Criteria

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

## Files

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

## TDD Loop

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

GREEN: Implement handler with queries

VERIFY: `cargo test count`

## Edge Cases

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

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


Implement timeline human output renderer

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

GREEN: Implement the human output renderer

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

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


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

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

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

```markdown
### Temporal Intelligence Commands

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

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

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

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

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

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

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

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

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

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


Add GitLab Resource Event serde types

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

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

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

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

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

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

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

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

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

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

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

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

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

GREEN: Add the type definitions to types.rs

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

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

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


Implement discussion document extraction

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

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

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

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

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

-- Parent labels (via issue_labels or mr_labels junction)

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

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

--- Thread ---

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

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

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

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

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

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

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

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


[CP1] gi list issues command

List issues from the database.

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

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

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

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

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

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

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

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

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


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