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


OBSERV: Add #[instrument] spans to ingestion stages

## Background
Tracing spans on each sync stage create the hierarchy that (1) makes log lines filterable by stage, (2) Phase 3's MetricsLayer reads to build StageTiming trees, and (3) gives meaningful context in -vv stderr output.

## Approach
Add #[instrument] attributes or manual spans to these functions:

### src/ingestion/orchestrator.rs
1. ingest_project_issues_with_progress() (line ~110):
```rust
#[instrument(skip_all, fields(stage = "ingest_issues", project = %project_path))]
pub async fn ingest_project_issues_with_progress(...) -> Result<IngestProjectResult> {
```

2. The MR equivalent (ingest_project_mrs_with_progress or similar):
```rust
#[instrument(skip_all, fields(stage = "ingest_mrs", project = %project_path))]
```

3. Inside the issue ingest function, add child spans for sub-stages:
```rust
let _fetch_span = tracing::info_span!("fetch_pages", project = %project_path).entered();
// ... fetch logic
drop(_fetch_span);

let _disc_span = tracing::info_span!("sync_discussions", project = %project_path).entered();
// ... discussion sync logic
drop(_disc_span);
```

4. drain_resource_events() (line ~566):
```rust
let _span = tracing::info_span!("fetch_resource_events", project = %project_path).entered();
```

### src/documents/regenerator.rs
5. regenerate_dirty_documents() (line ~24):
```rust
#[instrument(skip_all, fields(stage = "generate_docs"))]
pub fn regenerate_dirty_documents(conn: &Connection) -> Result<RegenerateResult> {
```

### src/embedding/pipeline.rs
6. embed_documents() (line ~36):
```rust
#[instrument(skip_all, fields(stage = "embed"))]
pub async fn embed_documents(...) -> Result<EmbedResult> {
```

### Important: field declarations for Phase 3
The #[instrument] fields should include empty recording fields that Phase 3 (bd-16m8) will populate:
```rust
#[instrument(skip_all, fields(
    stage = "ingest_issues",
    project = %project_path,
    items_processed = tracing::field::Empty,
    items_skipped = tracing::field::Empty,
    errors = tracing::field::Empty,
))]
```

This declares the fields on the span so MetricsLayer can capture them when span.record() is called later.

## Acceptance Criteria
- [ ] JSON log lines show nested span context: sync > ingest_issues > fetch_pages
- [ ] Each stage span has a "stage" field with the stage name
- [ ] Per-project spans include "project" field
- [ ] Spans are visible in -vv stderr output as bracketed context
- [ ] Empty recording fields declared for items_processed, items_skipped, errors
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/ingestion/orchestrator.rs (spans on ingest functions and sub-stages)
- src/documents/regenerator.rs (span on regenerate_dirty_documents)
- src/embedding/pipeline.rs (span on embed_documents)

## TDD Loop
RED:
  - test_span_context_in_json_logs: mock sync, capture JSON, verify span chain
  - test_nested_span_chain: verify parent-child: sync > ingest_issues > fetch_pages
  - test_span_elapsed_on_close: create span, sleep 10ms, verify elapsed >= 10
GREEN: Add #[instrument] and manual spans to all stage functions
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- #[instrument] on async fn: uses tracing::Instrument trait automatically. Works with tokio.
- skip_all is essential: without it, #[instrument] tries to Debug-format all parameters, which may not implement Debug or may be expensive.
- Manual span drop: for sub-stages within a single function, use explicit drop(_span) to end the span before the next sub-stage starts. Otherwise spans overlap.
- tracing::field::Empty: declares a field that can be recorded later. If never recorded, it appears as empty/missing in output (not zero).

Added #[instrument] spans to ingest_project_issues_with_progress, ingest_project_merge_requests_with_progress, drain_resource_events, regenerate_dirty_documents, embed_documents


robot-docs: Add Ollama dependency discovery to manifest

## Background

Add Ollama dependency discovery to robot-docs so agents know which commands need Ollama and which work without it.

## Codebase Context

- handle_robot_docs() in src/main.rs (line ~1646) returns RobotDocsData JSON
- RobotDocsData has fields: commands, exit_codes, workflows, aliases, clap_error_codes
- Currently 18 documented commands in the manifest
- Ollama required for: embed, search --mode=semantic, search --mode=hybrid
- Not required for: all Phase B temporal commands (timeline, file-history, trace), lexical search, count, ingest, stats, etc.
- No dependencies field exists yet in RobotDocsData

## Approach

Add dependencies field to RobotDocsData struct and populate in handle_robot_docs():

```json
{
  "ollama": {
    "required_by": ["embed", "search --mode=semantic", "search --mode=hybrid"],
    "not_required_by": ["issues", "mrs", "search --mode=lexical", "timeline", "file-history", "trace", "count", "ingest", "stats", "sync", "doctor", "health"],
    "install": {"macos": "brew install ollama", "linux": "curl -fsSL https://ollama.ai/install.sh | sh"},
    "setup": "ollama pull nomic-embed-text",
    "note": "Lexical search and all temporal features work without Ollama."
  }
}
```

## Acceptance Criteria

- [ ] `lore robot-docs | jq '.data.dependencies.ollama'` returns structured info
- [ ] required_by and not_required_by lists are complete and accurate
- [ ] Phase B commands listed in not_required_by
- [ ] Install instructions for macos and linux
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

- src/main.rs (update RobotDocsData struct + handle_robot_docs)

## TDD Loop

VERIFY: `lore robot-docs | jq '.data.dependencies.ollama.required_by'`

## Edge Cases

- Keep not_required_by up to date as new commands are added
- Phase B commands (timeline, file-history, trace) must be in not_required_by once they exist


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


OBSERV: Enhance sync-status to show recent runs with metrics

## Background
sync_status currently queries sync_runs but always gets zero rows (nothing writes to the table). After bd-23a4 wires up SyncRunRecorder, rows will exist. This bead enhances the display to show recent runs with metrics.

## Approach
### src/cli/commands/sync_status.rs

1. Change get_last_sync_run() (line ~66) to get_recent_sync_runs() returning last N:
```rust
fn get_recent_sync_runs(conn: &Connection, limit: usize) -> Result<Vec<SyncRunInfo>> {
    let mut stmt = conn.prepare(
        "SELECT id, started_at, finished_at, status, command, error,
                run_id, total_items_processed, total_errors, metrics_json
         FROM sync_runs
         ORDER BY started_at DESC
         LIMIT ?1",
    )?;
    // ... map rows to SyncRunInfo
}
```

2. Extend SyncRunInfo to include new fields:
```rust
pub struct SyncRunInfo {
    pub id: i64,
    pub started_at: i64,
    pub finished_at: Option<i64>,
    pub status: String,
    pub command: String,
    pub error: Option<String>,
    pub run_id: Option<String>,          // NEW
    pub total_items_processed: i64,       // NEW
    pub total_errors: i64,                // NEW
    pub stages: Option<Vec<StageTiming>>, // NEW: parsed from metrics_json
}
```

3. Parse metrics_json into Vec<StageTiming>:
```rust
let stages: Option<Vec<StageTiming>> = row.get::<_, Option<String>>(9)?
    .and_then(|json| serde_json::from_str(&json).ok());
```

4. Interactive output (new format):
```
Recent sync runs:
  Run a1b2c3 | 2026-02-04 14:32 | 45.2s | 235 items | 1 error
  Run d4e5f6 | 2026-02-03 14:30 | 38.1s | 220 items | 0 errors
  Run g7h8i9 | 2026-02-02 14:29 | 42.7s | 228 items | 0 errors
```

5. Robot JSON output: runs array with stages parsed from metrics_json:
```json
{
    "ok": true,
    "data": {
        "runs": [{ "run_id": "...", "stages": [...] }],
        "cursors": [...],
        "summary": {...}
    }
}
```

6. Add --run <run_id> flag to sync-status subcommand for single-run detail view (shows full stage breakdown).

## Acceptance Criteria
- [ ] lore sync-status shows last 10 runs (not just 1) with run_id, duration, items, errors
- [ ] lore --robot sync-status JSON includes runs array with stages parsed from metrics_json
- [ ] lore sync-status --run a1b2c3 shows single run detail with full stage breakdown
- [ ] When no runs exist, shows appropriate "No sync runs recorded" message
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/cli/commands/sync_status.rs (rewrite query, extend structs, update display)

## TDD Loop
RED:
  - test_sync_status_shows_runs: insert 3 sync_runs rows, call print function, assert all 3 shown
  - test_sync_status_json_includes_stages: insert row with metrics_json, verify robot JSON has stages
  - test_sync_status_empty: no rows, verify graceful message
GREEN: Rewrite get_last_sync_run -> get_recent_sync_runs, extend SyncRunInfo, update output
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- metrics_json is NULL (old rows or failed runs): stages field is null/empty in output
- metrics_json is malformed: serde_json::from_str fails silently (.ok()), stages is None
- Duration calculation: finished_at - started_at in ms. If finished_at is NULL (running), show "in progress"

Enhanced sync-status: shows last 10 runs with run_id, duration, items, errors, parsed stages; JSON includes full stages array


[CP1] Epic: Issue Ingestion

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

Success Criteria:
- gi ingest --type=issues fetches all issues (count matches GitLab UI)
- Labels extracted from issue payloads
- Issue discussions fetched per-issue
- Cursor-based sync is resumable
- Sync tracking records all runs
- Single-flight lock prevents concurrent runs

Reference: docs/prd/checkpoint-1.md


[CP1] GitLab types for issues, discussions, notes

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

## Types to Add

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

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

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

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

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

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


[CP1] Discussion ingestion module

Fetch and store discussions/notes for each issue.

## Module
src/ingestion/discussions.rs

## Key Structs

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

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

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

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

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

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


Implement project resolution for --project filter

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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 count command currently supports issues, mrs, discussions, notes, and events. This adds 'references' as a new entity type, showing cross-reference totals and breakdowns by reference_type and source_method.

## Codebase Context

- entity_references table (migration 011) with:
  - reference_type CHECK: `'closes' | 'mentioned' | 'related'`
  - source_method CHECK: `'api' | 'note_parse' | 'description_parse'` (**codebase values, NOT spec values**)
  - target_entity_id: NULL for unresolved cross-project refs
- Count command pattern in src/cli/commands/count.rs: run_count() returns CountResult, handle_count formats output
- events count already implemented as a special case: run_count_events() in main.rs (line ~829)
- count.rs has value_parser list for entity arg

## Approach

### 1. Add to CountArgs value_parser in `src/cli/mod.rs`:
```rust
#[arg(value_parser = ["issues", "mrs", "discussions", "notes", "events", "references"])]
pub entity: String,
```

### 2. Add types and query in `src/cli/commands/count.rs`:

```rust
pub struct ReferenceCountResult {
    pub total: i64,
    pub by_type: HashMap<String, i64>,     // closes, mentioned, related
    pub by_method: HashMap<String, i64>,   // api, note_parse, description_parse
    pub unresolved: i64,
}
```

### 3. SQL:
```sql
SELECT
    COUNT(*) as total,
    COALESCE(SUM(CASE WHEN reference_type = 'closes' THEN 1 ELSE 0 END), 0) as closes,
    COALESCE(SUM(CASE WHEN reference_type = 'mentioned' THEN 1 ELSE 0 END), 0) as mentioned,
    COALESCE(SUM(CASE WHEN reference_type = 'related' THEN 1 ELSE 0 END), 0) as related,
    COALESCE(SUM(CASE WHEN source_method = 'api' THEN 1 ELSE 0 END), 0) as api,
    COALESCE(SUM(CASE WHEN source_method = 'note_parse' THEN 1 ELSE 0 END), 0) as note_parse,
    COALESCE(SUM(CASE WHEN source_method = 'description_parse' THEN 1 ELSE 0 END), 0) as desc_parse,
    COALESCE(SUM(CASE WHEN target_entity_id IS NULL THEN 1 ELSE 0 END), 0) as unresolved
FROM entity_references
```

### 4. Human output:
```
References: 1,234
  By type:
    closes:    456
    mentioned: 678
    related:   100
  By source:
    api:              234
    note_parse:       890
    description_parse: 110
  Unresolved: 45 (3.6%)
```

### 5. Robot JSON:
```json
{
  "ok": true,
  "data": {
    "entity": "references",
    "total": 1234,
    "by_type": { "closes": 456, "mentioned": 678, "related": 100 },
    "by_method": { "api": 234, "note_parse": 890, "description_parse": 110 },
    "unresolved": 45
  }
}
```

### 6. Wire in main.rs handle_count:
Add "references" branch, similar to the existing "events" special case.

## Acceptance Criteria

- [ ] `lore count references` works with human output
- [ ] `lore --robot count references` returns JSON
- [ ] by_type uses codebase values: closes, mentioned, related
- [ ] by_method uses codebase values: api, note_parse, description_parse (NOT spec values)
- [ ] Unresolved = WHERE target_entity_id IS NULL
- [ ] Zero references: all counts 0, not error
- [ ] entity_references table missing (old schema): graceful error with migration suggestion
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

- `src/cli/mod.rs` (add "references" to value_parser)
- `src/cli/commands/count.rs` (add count_references + ReferenceCountResult)
- `src/main.rs` (add "references" branch in handle_count)

## TDD Loop

RED: `test_count_references_query` with in-memory DB + migration 011 data

GREEN: Implement query, result type, output.

VERIFY: `cargo test --lib -- count && cargo check --all-targets`

## Edge Cases

- entity_references table doesn't exist (pre-migration-011): catch SQL error, suggest `lore migrate`
- All references unresolved: unresolved = total
- New source_method values in future: consider logging unknown values


[CP1] gi count issues/discussions/notes commands

## Background

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

## Approach

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

### Clap Definition

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

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

### Handler Function

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

### Queries by Entity

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

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

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

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

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

### Output Format

```
Issues: 3,801
```

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

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

## Acceptance Criteria

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

## Files

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

## TDD Loop

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

GREEN: Implement handler with queries

VERIFY: `cargo test count`

## Edge Cases

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

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


Implement timeline human output renderer

## Background

This bead implements the human-readable (non-robot) output renderer for `lore timeline`. It takes a collection of TimelineEvents and renders them as a colored, chronological timeline in the terminal.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.4 (Human Output Format).

## Codebase Context

- Colored output pattern: src/cli/commands/show.rs uses `colored` crate for terminal styling
- Existing formatters: `print_show_issue()`, `print_show_mr()`, `print_list_issues()`
- TimelineEvent model (bd-20e): timestamp, entity_type, entity_iid, project_path, event_type, summary, actor, url, is_seed
- TimelineEventType enum (bd-20e): Created, StateChanged, LabelAdded, LabelRemoved, MilestoneSet, MilestoneRemoved, Merged, NoteEvidence, CrossReferenced
- Expansion provenance: expanded entities have `via` info (from which seed, what edge type)
- Convention: all output functions take `&[TimelineEvent]` and metadata, not raw DB results

## Approach

Create `src/cli/commands/timeline.rs`:

```rust
use colored::Colorize;
use crate::core::timeline::{TimelineEvent, TimelineEventType, TimelineQueryResult};

pub fn print_timeline(result: &TimelineQueryResult) {
    // Header
    println\!();
    println\!("{}", format\!("Timeline: \"{}\" ({} events across {} entities)",
        result.query, result.events.len(), result.total_entities).bold());
    println\!("{}", "─".repeat(60));
    println\!();

    // Events
    for event in &result.events {
        print_timeline_event(event);
    }

    // Footer
    println\!();
    println\!("{}", "─".repeat(60));
    print_timeline_footer(result);
}

fn print_timeline_event(event: &TimelineEvent) {
    let date = format_date(event.timestamp);
    let tag = format_event_tag(&event.event_type);
    let entity = format_entity_ref(event.entity_type.as_str(), event.entity_iid);
    let actor = event.actor.as_deref().map(|a| format\!("@{a}")).unwrap_or_default();
    let expanded_marker = if event.is_seed { "" } else { "  [expanded]" };

    println\!("{date}  {tag:10}  {entity:6}  {summary:40}  {actor}{expanded_marker}",
        summary = &event.summary);

    // Extra lines for specific event types
    match &event.event_type {
        TimelineEventType::NoteEvidence { snippet, .. } => {
            // Show snippet indented, wrapped to ~70 chars
            for line in wrap_text(snippet, 70) {
                println\!("                             \"{line}\"");
            }
        }
        TimelineEventType::Created => {
            // Could show labels if available in details
        }
        _ => {}
    }
}
```

### Event Tag Colors:
| Tag | Color |
|-----|-------|
| CREATED | green |
| CLOSED | red |
| REOPENED | yellow |
| MERGED | cyan |
| LABEL | blue |
| MILESTONE | magenta |
| NOTE | white/dim |
| REF | dim |

### Date Format:
```
2024-03-15  CREATED   #234  Migrate to OAuth2                    @alice
```
Use `YYYY-MM-DD` for dates. Group consecutive same-day events visually.

Add `pub mod timeline;` to `src/cli/commands/mod.rs` and re-export `print_timeline`.

## Acceptance Criteria

- [ ] `print_timeline()` renders header with query, event count, entity count
- [ ] Events displayed chronologically with: date, tag, entity ref, summary, actor
- [ ] Expanded entities marked with [expanded] suffix
- [ ] NoteEvidence events show snippet text indented and quoted
- [ ] Tags colored by event type
- [ ] Footer shows seed entities and expansion info
- [ ] Module registered in src/cli/commands/mod.rs
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

- `src/cli/commands/timeline.rs` (NEW)
- `src/cli/commands/mod.rs` (add `pub mod timeline;` and re-export `print_timeline`)

## TDD Loop

No unit tests for terminal rendering. Verify visually:

```bash
cargo check --all-targets
# After full pipeline: lore timeline "some query"
```

## Edge Cases

- Empty result: print "No events found for query." and exit 0
- Very long summaries: truncate to 60 chars with "..."
- NoteEvidence snippets: wrap at 70 chars, cap at 4 lines
- Null actors (system events): show no @username
- Entity types: # for issues, \! for MRs (GitLab convention)

Implemented print_timeline() human renderer in src/cli/commands/timeline.rs with colored chronological output, event tags, entity refs, evidence note snippets, and footer summary


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

## Background

After Phase B implementation, update AGENTS.md and CLAUDE.md with temporal intelligence command documentation so agents can discover and use the new commands.

## Codebase Context

- AGENTS.md section "Gitlore Robot Mode" (line ~592) has Robot Mode Commands table
- ~/.claude/CLAUDE.md has matching "Gitlore (lore)" section with command reference
- New Phase B commands: timeline, file-history, trace
- New count entity: references
- sync gains --no-file-changes flag (bd-jec)
- Config gains fetchMrFileChanges (bd-jec) and fetchResourceEvents (already exists)

## Approach

Add "Temporal Intelligence Commands" section after existing Robot Mode Commands in both files:

```bash
# Timeline - chronological event history
lore --robot timeline "authentication" --since 30d
lore --robot timeline "deployment" --depth 2 --expand-mentions

# File History - which MRs touched a file
lore --robot file-history src/auth/oauth.rs --discussions

# Trace - file -> MR -> issue -> discussion chain
lore --robot trace src/auth/oauth.rs --discussions

# Count references - cross-reference statistics
lore --robot count references

# Sync with file changes
lore --robot sync --no-file-changes  # skip MR diff fetching
```

Also document config flags:
```json
{
  "sync": {
    "fetchResourceEvents": true,
    "fetchMrFileChanges": true
  }
}
```

## Acceptance Criteria

- [ ] AGENTS.md has Temporal Intelligence Commands section
- [ ] ~/.claude/CLAUDE.md has matching section
- [ ] All examples are valid, runnable commands
- [ ] Config flags documented (fetchResourceEvents, fetchMrFileChanges)
- [ ] --no-events and --no-file-changes CLI flags documented
- [ ] sync-related changes documented
- [ ] Mentions resource events requirement for timeline queries

## Files

- AGENTS.md (add temporal intelligence section)
- ~/.claude/CLAUDE.md (add matching section)

## Edge Cases

- Both files must stay in sync
- Examples must use --robot flag consistently
- Config flag names use camelCase in JSON, snake_case in Rust


Add GitLab Resource Event serde types

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

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

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

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

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

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

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

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

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

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

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

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

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

GREEN: Add the type definitions to types.rs

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

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

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


Implement discussion document extraction

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

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

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

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

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

-- Parent labels (via issue_labels or mr_labels junction)

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

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

--- Thread ---

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

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

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

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

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

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

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

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


[CP1] gi list issues command

List issues from the database.

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

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

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

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

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

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

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

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

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


OBSERV: Add log file diagnostics to lore doctor

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

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

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

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

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

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

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

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

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

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

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

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

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

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


[CP1] Database migration 002_issues.sql

## Background

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

## Approach

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

### Full Migration SQL

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

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

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

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

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

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

CREATE INDEX idx_issue_labels_label ON issue_labels(label_id);

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

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

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

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

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

## Acceptance Criteria

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

## Files

- migrations/002_issues.sql (create)

## TDD Loop

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

GREEN: Create migration file with all SQL

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

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

cargo test migration_002
```

## Edge Cases

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

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


[CP1] Unit tests for transformers

Comprehensive unit tests for issue and discussion transformers.

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

- transforms_gitlab_issue_to_normalized_schema
- extracts_labels_from_issue_payload
- handles_missing_optional_fields_gracefully
- converts_iso_timestamps_to_ms_epoch
- sets_last_seen_at_to_current_time

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

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

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

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


Epic: Gate A - Lexical MVP

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

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

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

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

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


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

## Background

The trace query builds a chain from file path -> MRs -> issues -> discussions, combining data from mr_file_changes (Gate 4), entity_references (Gate 2), and the existing discussions/notes tables. This is the backend for the trace CLI command.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 5.4 (Query Flow Tier 1).

## Codebase Context

- entity_references table (migration 011): source_entity_type, source_entity_id, target_entity_type, target_entity_id, reference_type, source_method
- mr_file_changes table (migration 016, bd-1oo): merge_request_id, project_id, old_path, new_path, change_type
- discussions table: issue_id, merge_request_id
- notes table: discussion_id, author_username, body, created_at, is_system, position_new_path (for DiffNotes)
- merge_requests table: iid, title, state, author_username, web_url, merged_at, updated_at
- issues table: iid, title, state, web_url
- resolve_rename_chain() from bd-1yx (src/core/file_history.rs) provides multi-path matching
- reference_type values: 'closes', 'mentioned', 'related'

## Approach

Create `src/core/trace.rs`:

```rust
use rusqlite::Connection;
use crate::core::file_history::resolve_rename_chain;
use crate::core::error::Result;

#[derive(Debug, Clone, Serialize)]
pub struct TraceChain {
    pub merge_request: TraceMr,
    pub issues: Vec<TraceIssue>,
    pub discussions: Vec<TraceDiscussion>,
}

#[derive(Debug, Clone, Serialize)]
pub struct TraceMr {
    pub iid: i64,
    pub title: String,
    pub state: String,
    pub author_username: String,
    pub web_url: Option<String>,
    pub merged_at: Option<i64>,
    pub merge_commit_sha: Option<String>,
    pub file_change_type: String,
}

#[derive(Debug, Clone, Serialize)]
pub struct TraceIssue {
    pub iid: i64,
    pub title: String,
    pub state: String,
    pub web_url: Option<String>,
    pub reference_type: String,   // "closes", "mentioned", "related"
}

#[derive(Debug, Clone, Serialize)]
pub struct TraceDiscussion {
    pub author_username: String,
    pub body_snippet: String,     // truncated to 500 chars
    pub created_at: i64,
    pub is_diff_note: bool,       // true if position_new_path matched
}

#[derive(Debug, Clone, Serialize)]
pub struct TraceResult {
    pub path: String,
    pub resolved_paths: Vec<String>,
    pub chains: Vec<TraceChain>,
}

pub fn run_trace(
    conn: &Connection,
    project_id: i64,
    path: &str,
    follow_renames: bool,
    include_discussions: bool,
    limit: usize,
) -> Result<TraceResult> {
    // 1. Resolve rename chain (unless !follow_renames)
    let paths = if follow_renames {
        resolve_rename_chain(conn, project_id, path, 10)?
    } else {
        vec![path.to_string()]
    };

    // 2. Find MRs via mr_file_changes for all resolved paths
    //    Dynamic IN-clause for path set
    // 3. For each MR, find linked issues via entity_references
    // 4. If include_discussions, fetch DiffNote discussions on traced file
    // 5. Order chains by COALESCE(merged_at, updated_at) DESC, apply limit
}
```

### SQL for step 2 (find MRs):

Build dynamic IN-clause placeholders for the resolved path set:
```sql
SELECT DISTINCT mr.id, mr.iid, mr.title, mr.state, mr.author_username,
       mr.web_url, mr.merged_at, mr.updated_at, mr.merge_commit_sha,
       mfc.change_type
FROM mr_file_changes mfc
JOIN merge_requests mr ON mr.id = mfc.merge_request_id
WHERE mfc.project_id = ?1
  AND (mfc.new_path IN (...placeholders...) OR mfc.old_path IN (...placeholders...))
ORDER BY COALESCE(mr.merged_at, mr.updated_at) DESC
LIMIT ?N
```

### SQL for step 3 (linked issues):
```sql
SELECT i.iid, i.title, i.state, i.web_url, er.reference_type
FROM entity_references er
JOIN issues i ON i.id = er.target_entity_id
WHERE er.source_entity_type = 'merge_request'
  AND er.source_entity_id = ?1
  AND er.target_entity_type = 'issue'
```

### SQL for step 4 (DiffNote discussions):
```sql
SELECT n.author_username, n.body, n.created_at, n.position_new_path
FROM notes n
JOIN discussions d ON d.id = n.discussion_id
WHERE d.merge_request_id = ?1
  AND n.position_new_path IN (...placeholders...)
  AND n.is_system = 0
ORDER BY n.created_at ASC
```

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

## Acceptance Criteria

- [ ] run_trace() returns chains ordered by COALESCE(merged_at, updated_at) DESC
- [ ] Rename-aware: uses all paths from resolve_rename_chain
- [ ] Issues linked via entity_references (closes, mentioned, related)
- [ ] DiffNote discussions correctly filtered to traced file paths via position_new_path
- [ ] Discussion body_snippet truncated to 500 chars
- [ ] Empty result (file not in any MR) returns TraceResult with empty chains
- [ ] Limit applies to number of chains (MRs), not total discussions
- [ ] Module registered in src/core/mod.rs as `pub mod trace;`
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

- `src/core/trace.rs` (NEW)
- `src/core/mod.rs` (add `pub mod trace;`)

## TDD Loop

RED:
- `test_trace_empty_file` — unknown file returns empty chains
- `test_trace_finds_mr` — file in mr_file_changes returns chain with correct MR
- `test_trace_follows_renames` — renamed file finds historical MRs
- `test_trace_links_issues` — MR with entity_references shows linked issues
- `test_trace_limits_chains` — limit=1 returns at most 1 chain
- `test_trace_no_follow_renames` — follow_renames=false only matches literal path

Tests need in-memory DB with migrations applied through 016 + test fixtures for mr_file_changes, entity_references, discussions, notes.

GREEN: Implement SQL queries and chain assembly.

VERIFY: `cargo test --lib -- trace`

## Edge Cases

- MR with no linked issues: chain has empty issues vec
- Same issue linked from multiple MRs: appears in each chain independently
- DiffNote on old_path (before rename): captured via resolved path set
- include_discussions=false: skip DiffNote query for performance
- Null merged_at: falls back to updated_at for ordering
- Dynamic IN-clause: use rusqlite::params_from_iter for parameterized queries


[CP1] Issue ingestion module

Fetch and store issues with cursor-based incremental sync.

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

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

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


OBSERV Epic: Phase 2 - Spans + Correlation IDs

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

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

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

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

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


Write integration tests

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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


[CP1] Epic: Issue Ingestion

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

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

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

Reference: docs/prd/checkpoint-1.md


OBSERV: Replace subscriber init with dual-layer setup

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


Implement lore embed CLI command

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

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

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

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

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

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

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

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

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

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

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

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

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


[CP1] gi ingest --type=issues command

CLI command to orchestrate issue ingestion.

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

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

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

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

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

## Output Format
Ingesting issues...

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

Fetching discussions (312 issues with updates)...

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

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

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

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


[CP1] Epic: Issue Ingestion

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

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

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

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

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


Add work item status via GraphQL enrichment

## Background

GitLab 18.2+ added native work item status (To do, In progress, Done, Won't do, Duplicate) but it is only available via GraphQL, not the REST API. This enriches synced issues with status information by making a supplementary GraphQL call after the REST ingestion.

**Spec reference:** Not in Phase B spec. This is an enhancement discovered during Phase B planning.

## Codebase Context — Migration Numbering

- LATEST_SCHEMA_VERSION = 14 (MIGRATIONS array in db.rs includes 001-014)
- Migration 015 exists on disk (commit SHAs) but is NOT registered in db.rs yet — bd-1oo fixes this
- bd-1oo creates migration 016 (mr_file_changes)
- **This bead uses migration 017** for issue status columns
- bd-343o (linked issues) would use migration 018 — coordinate numbering
- No GraphQL client exists yet — this would be the first GraphQL usage in the codebase

## Other Codebase Context

- src/core/config.rs SyncConfig has: fetch_resource_events, and will get fetch_mr_file_changes (bd-jec)
- src/gitlab/client.rs: all API methods use reqwest + fetch_all_pages() + coalesce_not_found()
- src/cli/commands/show.rs: print_show_issue() displays issue detail with colored sections
- src/ingestion/orchestrator.rs: ingest_project_issues() returns IngestProjectResult with progress callbacks

## Approach

### Phase 1: GraphQL Client (src/gitlab/graphql.rs NEW)

Minimal GraphQL client — single function, not a full framework:
```rust
pub async fn graphql_query(
    base_url: &str,
    token: &str,
    query: &str,
    variables: serde_json::Value,
) -> Result<serde_json::Value> {
    // POST to {base_url}/api/graphql
    // Content-Type: application/json
    // Headers: PRIVATE-TOKEN: {token}
    // Body: {"query": "...", "variables": {...}}
    // Parse response, check for errors array
}
```

Add `pub mod graphql;` to `src/gitlab/mod.rs`.

### Phase 2: Status Types (src/gitlab/types.rs)

```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkItemStatus {
    pub name: String,           // "To do", "In progress", "Done", etc.
    pub category: String,       // "todo", "in_progress", "done"
    pub color: Option<String>,
    pub icon_name: Option<String>,
}
```

### Phase 3: Batch Fetch Query

```graphql
query IssueStatuses($projectPath: ID!, $iids: [String!]) {
  project(fullPath: $projectPath) {
    issues(iids: $iids) {
      nodes {
        iid
        state
        workItemType { name }
        widgets {
          ... on WorkItemWidgetStatus {
            status { name category color iconName }
          }
        }
      }
    }
  }
}
```

Batch in groups of 50 IIDs to avoid query complexity limits.

### Phase 4: Migration 017 (migrations/017_issue_status.sql)

```sql
ALTER TABLE issues ADD COLUMN status_name TEXT;
ALTER TABLE issues ADD COLUMN status_category TEXT;

INSERT INTO schema_version (version, applied_at, description)
VALUES (17, strftime('%s', 'now') * 1000, 'Issue work item status columns');
```

Register in db.rs MIGRATIONS array.

### Phase 5: Enrichment Step (src/ingestion/orchestrator.rs)

After REST issue ingestion, call GraphQL to fetch statuses for all synced issues.

### Phase 6: Display (src/cli/commands/show.rs)

Add status line to print_show_issue():
```
Status: In progress
```

### Phase 7: Graceful Degradation

- If GraphQL endpoint returns 404 or 403: skip silently (older GitLab)
- If work item status widget not present: skip
- Never fail the sync pipeline due to GraphQL errors

## Acceptance Criteria

- [ ] GraphQL client can POST queries and handle errors
- [ ] Status fetched in batches of 50 IIDs
- [ ] Migration 017 adds status_name and status_category columns to issues table
- [ ] `lore show issue 123` shows status in human output (when available)
- [ ] `lore --robot show issue 123` includes status_name, status_category in JSON
- [ ] Graceful degradation: older GitLab versions do not cause errors
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

- src/gitlab/graphql.rs (NEW — minimal GraphQL client)
- src/gitlab/mod.rs (add pub mod graphql)
- src/gitlab/types.rs (add WorkItemStatus struct)
- migrations/017_issue_status.sql (NEW)
- src/core/db.rs (add migration 017 to MIGRATIONS array)
- src/ingestion/orchestrator.rs (call enrich_issue_statuses after issue sync)
- src/cli/commands/show.rs (display status in issue output)

## TDD Loop

RED:
- test_graphql_query_success — mock server returns valid GraphQL response
- test_graphql_query_error — mock server returns errors array -> Result::Err
- test_work_item_status_deserialize — parse GraphQL response into WorkItemStatus
- test_enrichment_graceful_degradation — 403 response -> Ok(0) not Err

GREEN: Implement GraphQL client, enrichment step, migration.

VERIFY: cargo test --lib -- graphql

## Edge Cases

- GitLab < 18.2: GraphQL endpoint exists but work item status widget missing -> skip
- GraphQL rate limiting: respect Retry-After header
- Issue with no status widget: status_name = NULL in DB
- Token with only read_api scope: GraphQL may require different scopes — test and document
- Must run after bd-1oo (migration numbering: 015 registered, 016 created, then 017)


Fetch MR diffs API and populate mr_file_changes

## Background

This bead fetches MR diff metadata from the GitLab API and populates the mr_file_changes table created by migration 016. It extracts only file-level metadata (paths, change type) and discards actual diff content.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 4.3 (Ingestion).

## Codebase Context

- pending_dependent_fetches already has `job_type='mr_diffs'` in CHECK constraint (migration 011)
- dependent_queue.rs has: enqueue_job(), claim_jobs(), complete_job(), fail_job() with exponential backoff
- Orchestrator pattern: enqueue after entity ingestion, drain after primary ingestion completes
- GitLab client uses fetch_all_pages() for pagination
- Existing drain patterns in orchestrator.rs: drain_resource_events() and drain_mr_closes_issues() — follow same pattern
- config.sync.fetch_mr_file_changes flag guards enqueue (see bd-jec)
- mr_file_changes table created by migration 016 (bd-1oo) — NOT 015 (015 is commit SHAs)
- merge_commit_sha and squash_commit_sha already captured during MR ingestion (src/ingestion/merge_requests.rs lines 184, 205-206, 230-231) — no work needed for those fields

## Approach

### 1. API Client — add to `src/gitlab/client.rs`:

```rust
pub async fn fetch_mr_diffs(
    &self,
    project_id: i64,
    mr_iid: i64,
) -> Result<Vec<GitLabMrDiff>> {
    let path = format\!("/projects/{project_id}/merge_requests/{mr_iid}/diffs");
    self.fetch_all_pages(&path, &[("per_page", "100")]).await
        .or_else(|e| coalesce_not_found(e, Vec::new()))
}
```

### 2. Types — add to `src/gitlab/types.rs`:

```rust
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct GitLabMrDiff {
    pub old_path: String,
    pub new_path: String,
    pub new_file: bool,
    pub renamed_file: bool,
    pub deleted_file: bool,
    // Ignore: diff, a_mode, b_mode, generated_file (not stored)
}
```

Add `GitLabMrDiff` to `src/gitlab/mod.rs` re-exports.

### 3. Change Type Derivation (in new file):

```rust
fn derive_change_type(diff: &GitLabMrDiff) -> &'static str {
    if diff.new_file { "added" }
    else if diff.renamed_file { "renamed" }
    else if diff.deleted_file { "deleted" }
    else { "modified" }
}
```

### 4. DB Storage — new `src/ingestion/mr_diffs.rs`:

```rust
pub fn upsert_mr_file_changes(
    conn: &Connection,
    mr_local_id: i64,
    project_id: i64,
    diffs: &[GitLabMrDiff],
) -> Result<usize> {
    // DELETE FROM mr_file_changes WHERE merge_request_id = ?
    // INSERT each diff row with derived change_type
    // DELETE+INSERT is simpler than UPSERT for array replacement
}
```

Add `pub mod mr_diffs;` to `src/ingestion/mod.rs`.

### 5. Queue Integration — in orchestrator.rs:

```rust
// After MR upsert, if config.sync.fetch_mr_file_changes:
enqueue_job(conn, project_id, "merge_request", mr_iid, mr_local_id, "mr_diffs")?;
```

Add `drain_mr_diffs()` following the drain_mr_closes_issues() pattern. Call it after drain_mr_closes_issues() in the sync pipeline.

## Acceptance Criteria

- [ ] `fetch_mr_diffs()` calls GET /projects/:id/merge_requests/:iid/diffs with pagination
- [ ] GitLabMrDiff type added to src/gitlab/types.rs and re-exported from src/gitlab/mod.rs
- [ ] Change type derived: new_file->added, renamed_file->renamed, deleted_file->deleted, else->modified
- [ ] mr_file_changes rows have correct old_path, new_path, change_type
- [ ] Old rows deleted before insert (clean replacement per MR)
- [ ] Jobs only enqueued when config.sync.fetch_mr_file_changes is true
- [ ] 404/403 API errors handled gracefully (empty result, not failure)
- [ ] drain_mr_diffs() added to orchestrator.rs sync pipeline
- [ ] `pub mod mr_diffs;` added to src/ingestion/mod.rs
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

- `src/gitlab/client.rs` (add fetch_mr_diffs method)
- `src/gitlab/types.rs` (add GitLabMrDiff struct)
- `src/gitlab/mod.rs` (re-export GitLabMrDiff)
- `src/ingestion/mr_diffs.rs` (NEW — upsert_mr_file_changes + derive_change_type)
- `src/ingestion/mod.rs` (add pub mod mr_diffs)
- `src/ingestion/orchestrator.rs` (enqueue mr_diffs jobs + drain_mr_diffs)

## TDD Loop

RED:
- `test_derive_change_type_added` - new_file=true -> "added"
- `test_derive_change_type_renamed` - renamed_file=true -> "renamed"
- `test_derive_change_type_deleted` - deleted_file=true -> "deleted"
- `test_derive_change_type_modified` - all false -> "modified"
- `test_upsert_replaces_existing` - second upsert replaces first

GREEN: Implement API client, type derivation, DB ops, orchestrator wiring.

VERIFY: `cargo test --lib -- mr_diffs`

## Edge Cases

- MR with 500+ files: paginate properly via fetch_all_pages
- Binary files: handled as modified (renamed_file/new_file/deleted_file all false)
- File renamed AND modified: renamed_file=true takes precedence
- Draft MRs: still fetch diffs
- Deleted MR: 404 -> empty vec via coalesce_not_found()
- merge_commit_sha/squash_commit_sha: already handled in merge_requests.rs ingestion — NOT part of this bead


[CP1] Issue transformer with label extraction

Transform GitLab issue payloads to normalized database schema.

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

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

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


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

## Background

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

## Approach

Add these dependencies to Cargo.toml:

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

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

## Acceptance Criteria

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

## Files

- Cargo.toml (edit)

## TDD Loop

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

## Edge Cases

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

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


Epic: Gate 1 - Resource Events Ingestion

## Background
Gate 1 transforms gitlore from a snapshot engine into a temporal data store by ingesting structured event data from GitLab Resource Events APIs (state, label, milestone changes). This is the foundation — Gates 2-5 all depend on the event tables and dependent fetch queue that Gate 1 establishes.

Currently, when an issue is closed or a label changes, gitlore overwrites the current state. The transition is lost. Gate 1 captures these transitions as discrete events with timestamps, actors, and provenance, enabling temporal queries like "when did this issue become critical?" and "who closed this MR?"

## Architecture
- **Three new tables:** resource_state_events, resource_label_events, resource_milestone_events (migration 011, already shipped as bd-hu3)
- **Generic dependent fetch queue:** pending_dependent_fetches table replaces per-type queue tables. Supports job_types: resource_events, mr_closes_issues, mr_diffs. Used by Gates 1, 2, and 4.
- **Opt-in via config:** sync.fetchResourceEvents (default true). --no-events CLI flag to skip.
- **Incremental:** Only changed entities enqueued. --full re-enqueues all.
- **Crash recovery:** locked_at column with 5-minute stale lock reclaim.

## Children (Execution Order)
1. **bd-hu3** [CLOSED] — Migration 011: event tables + entity_references + dependent fetch queue
2. **bd-2e8** [CLOSED] — fetchResourceEvents config flag
3. **bd-2fm** [CLOSED] — GitLab Resource Event serde types
4. **bd-sqw** [CLOSED] — Resource Events API endpoints in GitLab client
5. **bd-1uc** [CLOSED] — DB upsert functions for resource events
6. **bd-tir** [CLOSED] — Generic dependent fetch queue (enqueue + drain)
7. **bd-1ep** [CLOSED] — Wire resource event fetching into sync pipeline
8. **bd-3sh** [CLOSED] — lore count events command
9. **bd-1m8** [CLOSED] — lore stats --check for event integrity + queue health

## Gate Completion Criteria
- [ ] All 9 children closed
- [ ] `lore sync` fetches resource events for changed entities
- [ ] `lore sync --no-events` skips event fetching
- [ ] Event fetch failures queued for retry with exponential backoff
- [ ] Stale locks auto-reclaimed on next sync run
- [ ] `lore count events` shows counts by type (state/label/milestone)
- [ ] `lore stats --check` validates referential integrity + queue health
- [ ] Robot mode JSON for all new commands
- [ ] Integration test: full sync cycle with events enabled

## Dependencies
- None (Gate 1 is the foundation)
- Downstream: Gate 2 (bd-1se) depends on event tables and dependent fetch queue

Already implemented: migration 011 exists, events_db.rs has upsert functions, client.rs has fetch_*_state_events, orchestrator.rs has drain_resource_events. Full Gate 1 functionality is live.


[CP1] GitLab client pagination methods

Add async stream methods for paginated GitLab API calls.

## Methods to Add to GitLabClient

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

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

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

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

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