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


Implement common widgets (status bar, breadcrumb, loading, error toast, help overlay)

## Background
Common widgets appear across all screens: the status bar shows context-sensitive key hints and sync status, the breadcrumb shows navigation depth, the loading spinner indicates background work, the error toast shows transient errors with auto-dismiss, and the help overlay (?) shows available keybindings.

## Approach
Create crates/lore-tui/src/view/common/mod.rs and individual widget files:

view/common/mod.rs:
- render_breadcrumb(frame, area, nav: &NavigationStack, theme: &Theme): renders "Dashboard > Issues > #42" trail
- render_status_bar(frame, area, registry: &CommandRegistry, screen: &Screen, mode: &InputMode, theme: &Theme): renders bottom bar with key hints and sync indicator
- render_loading(frame, area, load_state: &LoadState, theme: &Theme): renders centered spinner for LoadingInitial, or subtle refresh indicator for Refreshing
- render_error_toast(frame, area, msg: &str, theme: &Theme): renders floating toast at bottom-right with error message
- render_help_overlay(frame, area, registry: &CommandRegistry, screen: &Screen, theme: &Theme): renders centered modal with keybinding list from registry

Create crates/lore-tui/src/view/mod.rs:
- render_screen(frame, app: &LoreApp): top-level dispatch — renders breadcrumb + screen content + status bar + optional overlays (help, error toast, command palette)

## Acceptance Criteria
- [ ] Breadcrumb renders all stack entries with " > " separator
- [ ] Status bar shows contextual hints from CommandRegistry
- [ ] Loading spinner animates via tick subscription
- [ ] Error toast auto-positions at bottom-right of screen
- [ ] Help overlay shows all commands for current screen from registry
- [ ] render_screen routes to correct per-screen view function
- [ ] Overlays (help, error, palette) render on top of screen content

## Files
- CREATE: crates/lore-tui/src/view/mod.rs
- CREATE: crates/lore-tui/src/view/common/mod.rs

## TDD Anchor
RED: Write test_breadcrumbs_format that creates a NavigationStack with Dashboard > IssueList, calls breadcrumbs(), asserts ["Dashboard", "Issues"].
GREEN: Implement breadcrumbs() in NavigationStack (already in nav task) and render_breadcrumb.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_breadcrumbs

## Edge Cases
- Breadcrumb must truncate from the left if stack is too deep for terminal width
- Status bar must handle narrow terminals (<60 cols) gracefully — show abbreviated hints
- Error toast must handle very long messages with truncation
- Help overlay must scroll if there are more commands than terminal height

## Dependency Context
Uses NavigationStack from "Implement NavigationStack" task.
Uses CommandRegistry from "Implement CommandRegistry" task.
Uses LoadState from "Implement AppState composition" task.
Uses Theme from "Implement theme configuration" task.


WHO: Reviews mode query (query_reviews)

## Background

Reviews mode answers "What review patterns does person X have?" by analyzing the **prefix** convention in DiffNote bodies (e.g., **suggestion**: ..., **question**: ..., **nit**: ...). Only counts DiffNotes on MRs the user did NOT author (m.author_username != ?1).

## Approach

### Three queries:
1. **Total DiffNotes**: COUNT(*) of DiffNotes by user on others' MRs
2. **Distinct MRs reviewed**: COUNT(DISTINCT m.id)
3. **Category extraction**: SQL-level prefix parsing + Rust normalization

### Category extraction SQL:
```sql
SELECT
    SUBSTR(ltrim(n.body), 3, INSTR(SUBSTR(ltrim(n.body), 3), '**') - 1) AS raw_prefix,
    COUNT(*) AS cnt
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
JOIN merge_requests m ON d.merge_request_id = m.id
WHERE n.author_username = ?1
  AND n.note_type = 'DiffNote' AND n.is_system = 0
  AND m.author_username != ?1
  AND ltrim(n.body) LIKE '**%**%'   -- only bodies with **prefix** pattern
  AND n.created_at >= ?2
  AND (?3 IS NULL OR n.project_id = ?3)
GROUP BY raw_prefix ORDER BY cnt DESC
```

Key: `ltrim(n.body)` tolerates leading whitespace before **prefix** (common in practice).

### normalize_review_prefix() in Rust:
```rust
fn normalize_review_prefix(raw: &str) -> String {
    let s = raw.trim().trim_end_matches(':').trim().to_lowercase();
    // Strip parentheticals like "(non-blocking)"
    let s = if let Some(idx) = s.find('(') { s[..idx].trim().to_string() } else { s };
    // Merge nit/nitpick variants
    match s.as_str() {
        "nitpick" | "nit" => "nit".to_string(),
        other => other.to_string(),
    }
}
```

### HashMap merge for normalized categories, then sort by count DESC

### ReviewsResult struct:
```rust
pub struct ReviewsResult {
    pub username: String,
    pub total_diffnotes: u32,
    pub categorized_count: u32,
    pub mrs_reviewed: u32,
    pub categories: Vec<ReviewCategory>,
}
pub struct ReviewCategory { pub name: String, pub count: u32, pub percentage: f64 }
```

No LIMIT needed — categories are naturally bounded (few distinct prefixes).

## Files

- `src/cli/commands/who.rs`

## TDD Loop

RED:
```
test_reviews_query — insert 3 DiffNotes (2 with **prefix**, 1 without); verify total=3, categorized=2, categories.len()=2
test_normalize_review_prefix — "suggestion" "Suggestion:" "suggestion (non-blocking):" "Nitpick:" "nit (non-blocking):" "question" "TODO:"
```

GREEN: Implement query_reviews + normalize_review_prefix
VERIFY: `cargo test -- reviews`

## Acceptance Criteria

- [ ] test_reviews_query passes (total=3, categorized=2)
- [ ] test_normalize_review_prefix passes (nit/nitpick merge, parenthetical strip)
- [ ] Only counts DiffNotes on MRs user did NOT author
- [ ] Default since window: 6m

## Edge Cases

- Self-authored MRs excluded (m.author_username != ?1) — user's notes on own MRs are not "reviews"
- ltrim() handles leading whitespace before **prefix**
- Empty raw_prefix after normalization filtered out (!normalized.is_empty())
- Percentage calculated from categorized_count (not total_diffnotes)

Implemented by agent team: migration 017, CLI skeleton, all 5 query modes, human+robot output, 20 tests. All quality gates pass.


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


Add migration for dual-path and reviewer participation indexes

## Background
The restructured expert SQL (bd-1hoq) uses UNION ALL + dedup to match both old_path and new_path columns. Without indexes on old_path columns, these branches would force table scans. The reviewer_participation CTE joins notes -> discussions and needs index coverage on discussion_id.

## Approach
Create migration file migrations/021_scoring_indexes.sql. Add entry to MIGRATIONS array at db.rs:11-68 (currently 20 entries ending with 020). LATEST_SCHEMA_VERSION at db.rs:9 auto-increments via MIGRATIONS.len().

### Migration SQL:
```sql
-- Support old_path leg of matched_notes CTE
CREATE INDEX IF NOT EXISTS idx_notes_old_path_author
  ON notes(position_old_path, author_username, created_at)
  WHERE note_type = 'DiffNote' AND is_system = 0 AND position_old_path IS NOT NULL;

-- Support old_path leg of matched_file_changes CTE
CREATE INDEX IF NOT EXISTS idx_mfc_old_path_project_mr
  ON mr_file_changes(old_path, project_id, merge_request_id)
  WHERE old_path IS NOT NULL;

-- Ensure new_path index parity for matched_file_changes CTE
CREATE INDEX IF NOT EXISTS idx_mfc_new_path_project_mr
  ON mr_file_changes(new_path, project_id, merge_request_id);

-- Support reviewer_participation CTE: notes -> discussions join
CREATE INDEX IF NOT EXISTS idx_notes_diffnote_discussion_author
  ON notes(discussion_id, author_username, created_at)
  WHERE note_type = 'DiffNote' AND is_system = 0;
```

### MIGRATIONS array addition (db.rs, after line ~68):
```rust
("021", include_str!("../../migrations/021_scoring_indexes.sql")),
```

## Acceptance Criteria
- [ ] migrations/021_scoring_indexes.sql exists with 4 CREATE INDEX statements
- [ ] MIGRATIONS array has 21 entries
- [ ] LATEST_SCHEMA_VERSION == 21
- [ ] cargo test (migration tests pass on both fresh and migrated :memory: DBs)
- [ ] Existing indexes unaffected (017_who_indexes.sql untouched)

## Files
- migrations/021_scoring_indexes.sql (new file)
- src/core/db.rs (line ~68: add to MIGRATIONS array)

## Edge Cases
- Use CREATE INDEX IF NOT EXISTS (idempotent)
- Partial indexes with WHERE clauses keep size minimal
- position_old_path and old_path can be NULL (handled by WHERE clause in partial indexes)


[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


Implement GraphQL client with partial-error handling

## Background
GitLab's GraphQL endpoint (/api/graphql) uses different auth than REST (Bearer token, not PRIVATE-TOKEN). We need a minimal GraphQL client that handles the GitLab-specific error codes and partial-data responses per GraphQL spec. The client returns a GraphqlQueryResult struct that propagates partial-error metadata end-to-end.

## Approach
Create a new file src/gitlab/graphql.rs with GraphqlClient (uses reqwest). Add httpdate crate for Retry-After HTTP-date parsing. Wire into the module tree. Factory on GitLabClient keeps token encapsulated.

## Files
- src/gitlab/graphql.rs (NEW) — GraphqlClient struct, GraphqlQueryResult, ansi256_from_rgb
- src/gitlab/mod.rs (add pub mod graphql;)
- src/gitlab/client.rs (add graphql_client() factory method)
- Cargo.toml (add httpdate dependency)

## Implementation

GraphqlClient struct:
  Fields: http (reqwest::Client with 30s timeout), base_url (String), token (String)
  Constructor: new(base_url, token) — trims trailing slash from base_url

query() method:
  - POST to {base_url}/api/graphql
  - Headers: Authorization: Bearer {token}, Content-Type: application/json
  - Body: {"query": "...", "variables": {...}}
  - Returns Result<GraphqlQueryResult>

GraphqlQueryResult struct (pub):
  data: serde_json::Value
  had_partial_errors: bool
  first_partial_error: Option<String>

HTTP status mapping:
  401 | 403 -> LoreError::GitLabAuthFailed
  404 -> LoreError::GitLabNotFound { resource: "GraphQL endpoint" }
  429 -> LoreError::GitLabRateLimited { retry_after } (parse Retry-After: try u64 first, then httpdate::parse_http_date, fallback 60)
  Other non-success -> LoreError::Other

GraphQL-level error handling:
  errors array present + data absent/null -> Err(LoreError::Other("GraphQL error: {first_msg}"))
  errors array present + data present -> Ok(GraphqlQueryResult { data, had_partial_errors: true, first_partial_error: Some(first_msg) })
  No errors + data present -> Ok(GraphqlQueryResult { data, had_partial_errors: false, first_partial_error: None })
  No errors + no data -> Err(LoreError::Other("missing 'data' field"))

ansi256_from_rgb(r, g, b) -> u8:
  Maps RGB to nearest ANSI 256-color index using 6x6x6 cube (indices 16-231).
  MUST be placed BEFORE #[cfg(test)] module (clippy::items_after_test_module).

Factory in src/gitlab/client.rs:
  pub fn graphql_client(&self) -> crate::gitlab::graphql::GraphqlClient {
      crate::gitlab::graphql::GraphqlClient::new(&self.base_url, &self.token)
  }

## Acceptance Criteria
- [ ] query() sends POST with Bearer auth header
- [ ] Success: returns GraphqlQueryResult { data, had_partial_errors: false }
- [ ] Errors-only (no data): returns Err with first error message
- [ ] Partial data + errors: returns Ok with had_partial_errors: true
- [ ] 401 -> GitLabAuthFailed
- [ ] 403 -> GitLabAuthFailed
- [ ] 404 -> GitLabNotFound
- [ ] 429 -> GitLabRateLimited (parses Retry-After delta-seconds and HTTP-date, fallback 60)
- [ ] ansi256_from_rgb: (0,0,0)->16, (255,255,255)->231
- [ ] cargo check --all-targets passes

## TDD Loop
RED: test_graphql_query_success, test_graphql_query_with_errors_no_data, test_graphql_auth_uses_bearer, test_graphql_401_maps_to_auth_failed, test_graphql_403_maps_to_auth_failed, test_graphql_404_maps_to_not_found, test_graphql_partial_data_with_errors_returns_data, test_retry_after_http_date_format, test_retry_after_invalid_falls_back_to_60, test_ansi256_from_rgb
  Tests use wiremock or similar mock HTTP server
GREEN: Implement GraphqlClient, add httpdate to Cargo.toml
VERIFY: cargo test graphql && cargo test ansi256

## Edge Cases
- Use r##"..."## in tests containing "#1f75cb" hex colors (# breaks r#"..."#)
- LoreError::GitLabRateLimited uses u64 not Option<u64> — use .unwrap_or(60)
- httpdate::parse_http_date returns SystemTime — compute duration_since(now) for delta
- GraphqlQueryResult is NOT Clone — tests must check fields individually

Implemented by agent swarm — all quality gates pass (595 tests, 0 failures)


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


FrankenTUI integration proof + terminal compat smoke test

## Background
This is the critical validation that FrankenTUI works with our setup. A minimal Model trait implementation must compile, render a frame, and handle basic input. Terminal compatibility must be verified in iTerm2 and tmux. This proves the toolchain gate before investing in the full implementation.

## Approach
In crates/lore-tui/src/app.rs, implement a minimal LoreApp that:
- implements ftui_runtime::program::Model with type Message = Msg
- init() returns Cmd::none()
- update() handles Msg::Quit to return None (exit) and ignores everything else
- view() renders a simple "lore TUI" text centered on screen
- subscriptions() returns empty vec

Add a smoke test binary or integration test that:
- Creates a TerminalSession with ftui test harness
- Verifies Model::view() produces non-empty output
- Verifies resize events are handled without panic
- Tests render in both fullscreen and inline(12) modes

Terminal compat: manually verify ftui demo-showcase renders correctly in iTerm2 and tmux (document results in test notes).

## Acceptance Criteria
- [ ] LoreApp implements Model trait with Msg as message type
- [ ] App::fullscreen(lore_app).run() compiles (even if not runnable in CI without a TTY)
- [ ] App::inline(lore_app, 12).run() compiles
- [ ] Panic hook installed: terminal restored on crash (crossterm disable_raw_mode + LeaveAlternateScreen)
- [ ] Crash report written to ~/.local/share/lore/crash-{timestamp}.log with redacted sensitive data
- [ ] Crash file retention: max 20 files, oldest deleted
- [ ] ftui demo-showcase renders correctly in iTerm2 (documented)
- [ ] ftui demo-showcase renders correctly in tmux (documented)
- [ ] Binary size increase < 5MB over current lore binary

## Files
- CREATE: crates/lore-tui/src/app.rs (minimal Model impl)
- MODIFY: crates/lore-tui/src/lib.rs (add install_panic_hook_for_tui, crash report logic)
- CREATE: crates/lore-tui/src/crash_context.rs (ring buffer stub for crash diagnostics)

## TDD Anchor
RED: Write test_app_model_compiles that creates LoreApp and calls init(), verifying it returns without error.
GREEN: Implement minimal LoreApp struct with Model trait.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_app_model

## Edge Cases
- CI environments have no TTY — tests must use ftui test harness, not actual terminal
- tmux may not support all ANSI features — FrankenTUI's BOCPD resize coalescing must be verified
- Panic hook must handle double-panic gracefully (don't panic inside the panic hook)
- Crash context ring buffer must be lock-free readable from panic hook (signal safety)

## Dependency Context
Uses crate scaffold (Cargo.toml, rust-toolchain.toml) from "Create lore-tui crate scaffold" task.
Uses Msg enum and Screen type from "Implement core types" task.


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


Audit and fill data gaps: lore detail view vs glab

## Background
For lore to be the definitive read path, its single-entity detail view must return everything glab returns PLUS lore-exclusive enrichments.

## Current Issue Detail Output (lore -J issues N)
Fields returned: assignees, author_username, closing_merge_requests, created_at, description, discussions, due_date, id, iid, labels, milestone, project_path, state, status_color, status_icon_name, status_name, status_synced_at, title, updated_at, web_url

## Gap Analysis (Verified 2026-02-12)

### Raw Payload Audit
Issue raw_payloads store exactly 15 fields: assignees, author, closed_at, created_at, description, due_date, id, iid, labels, milestone, project_id, state, title, updated_at, web_url.

Fields NOT in raw payloads (require ingestion pipeline update to capture from GitLab API):
- closed_by, confidential, upvotes, downvotes, weight, issue_type, time_stats, health_status, references

### Phase 1 — Computed fields (NO schema change, NO ingestion change)
These can be derived from existing data:
1. `references_full`: format!("{path_with_namespace}#{iid}") — project_path already in show.rs:IssueDetail
2. `user_notes_count`: SELECT COUNT(*) FROM notes n JOIN discussions d ON n.discussion_id = d.id WHERE d.noteable_type = 'Issue' AND d.noteable_id = ? AND n.is_system = 0
3. `merge_requests_count`: COUNT from closing_merge_requests vec already loaded in show.rs (just .len())

### Phase 2 — Extract from existing raw payloads (schema change, NO ingestion change)
`closed_at` IS in raw_payloads for closed issues. Can be backfilled:
1. Add `closed_at TEXT` column to issues table (migration 023)
2. Backfill: UPDATE issues SET closed_at = json_extract((SELECT payload FROM raw_payloads WHERE id = issues.raw_payload_id), '$.closed_at') WHERE state = 'closed'
3. Capture during ingestion going forward

### Phase 3 — Requires ingestion pipeline update (schema change + API capture)
These fields are in the GitLab Issues API response but NOT captured by lore's ingestion:
1. `closed_by` (object with username) — add closed_by_username TEXT to issues
2. `confidential` (boolean) — add confidential INTEGER DEFAULT 0 to issues
3. Both require updating src/ingestion/ to extract these fields during sync

### Phase 4 — Same audit for MR detail view
MR detail (src/cli/commands/show.rs MrDetail struct lines 14-33) already includes: closed_at, merged_at, draft, source/target branch, reviewers. Missing: approvers_count, pipeline_status.

## Implementation: show.rs Modifications

### IssueDetail struct (src/cli/commands/show.rs:69-91)
Add fields:
```rust
pub references_full: String,        // Phase 1: computed
pub user_notes_count: i64,          // Phase 1: computed
pub merge_requests_count: usize,    // Phase 1: computed (closing_merge_requests.len())
pub closed_at: Option<String>,      // Phase 2: from DB after migration
pub confidential: bool,             // Phase 3: from DB after ingestion update
```

### SQL for computed fields
```sql
-- user_notes_count
SELECT COUNT(*) FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE d.noteable_type = 'Issue' AND d.noteable_id = ?1 AND n.is_system = 0

-- references_full (in Rust)
format!("{}#{}", project_path, iid)

-- merge_requests_count (in Rust)
closing_merge_requests.len()
```

## Migration 023 (after bd-2l3s takes 022)
```sql
-- migrations/023_issue_detail_fields.sql
ALTER TABLE issues ADD COLUMN closed_at TEXT;
ALTER TABLE issues ADD COLUMN confidential INTEGER NOT NULL DEFAULT 0;

-- Backfill closed_at from raw_payloads
UPDATE issues SET closed_at = (
    SELECT json_extract(rp.payload, '$.closed_at')
    FROM raw_payloads rp
    WHERE rp.id = issues.raw_payload_id
) WHERE state = 'closed' AND raw_payload_id IS NOT NULL;

INSERT INTO schema_version (version, applied_at, description)
VALUES (23, strftime('%s', 'now') * 1000, 'Issue detail fields: closed_at, confidential');
```

NOTE: raw_payload_id column on issues — verify this exists. If issues don't have a direct FK to raw_payloads, the backfill SQL needs adjustment (may need to join through another path).

## TDD Loop
RED: Tests in src/cli/commands/show.rs:
- test_show_issue_has_references_full: insert issue with known project_path, assert JSON output contains "project/path#123"
- test_show_issue_has_notes_count: insert issue + 3 user notes + 1 system note, assert user_notes_count = 3
- test_show_issue_closed_has_closed_at: insert closed issue with closed_at in raw_payload, run migration, verify closed_at appears

GREEN: Add computed fields to IssueDetail, add migration 023 for closed_at + confidential columns

VERIFY:
```bash
cargo test show:: && cargo clippy --all-targets -- -D warnings
cargo run --release -- -J issues 3864 | jq '{references_full, user_notes_count, merge_requests_count}'
```

## Acceptance Criteria
- [ ] lore -J issues N includes references_full (string, e.g., "vs/typescript-code#3864")
- [ ] lore -J issues N includes user_notes_count (integer, excludes system notes)
- [ ] lore -J issues N includes merge_requests_count (integer)
- [ ] lore -J issues N includes closed_at (ISO string for closed issues, null for open)
- [ ] lore -J issues N includes confidential (boolean, after Phase 3)
- [ ] --fields minimal preset updated to include references_full
- [ ] Migration 023 adds closed_at and confidential columns to issues table
- [ ] Backfill SQL populates closed_at from existing raw_payloads
- [ ] cargo test passes with new show:: tests

## Edge Cases
- Issue with zero notes: user_notes_count = 0 (not null)
- Issue with no closing MRs: merge_requests_count = 0
- Open issue: closed_at = null (serialized as JSON null, not omitted)
- confidential before Phase 3: default false (safe default)
- MR detail: different computed fields (approvers_count, pipeline_status if available)
- Raw payload missing for very old issues (raw_payload_id = NULL): closed_at stays NULL
- raw_payload_id column: verify it exists on the issues table before writing backfill SQL

## Files to Modify
- src/cli/commands/show.rs (IssueDetail struct + query logic)
- src/core/db.rs (migration 023: wire into MIGRATIONS array)
- NEW: migrations/023_issue_detail_fields.sql
- src/ingestion/ (Phase 3: capture closed_by, confidential during sync — specify exact file after reviewing ingestion pipeline)

Data gaps filled: references_full, user_notes_count, merge_requests_count, closed_at, confidential via migration 023


[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


Migration 021: Add status columns to issues table

## Background
GitLab issues have work item status (To do, In progress, Done, Won't do, Duplicate) only available via GraphQL. We need 5 nullable columns on the issues table to store this data after enrichment. The status_synced_at column tracks when enrichment last wrote/cleared each row (ms epoch UTC).

## Approach
Create a new SQL migration file and register it in the MIGRATIONS array. SQLite ALTER TABLE ADD COLUMN is non-destructive — existing rows get NULL defaults. Add a compound index for --status filter performance.

## Files
- migrations/021_work_item_status.sql (NEW)
- src/core/db.rs (add entry to MIGRATIONS array)

## Implementation

migrations/021_work_item_status.sql:
  ALTER TABLE issues ADD COLUMN status_name TEXT;
  ALTER TABLE issues ADD COLUMN status_category TEXT;
  ALTER TABLE issues ADD COLUMN status_color TEXT;
  ALTER TABLE issues ADD COLUMN status_icon_name TEXT;
  ALTER TABLE issues ADD COLUMN status_synced_at INTEGER;
  CREATE INDEX IF NOT EXISTS idx_issues_project_status_name ON issues(project_id, status_name);

In src/core/db.rs, add as last entry in MIGRATIONS array:
  ("021", include_str!("../../migrations/021_work_item_status.sql")),
LATEST_SCHEMA_VERSION is computed as MIGRATIONS.len() as i32 — auto-becomes 21.

## Acceptance Criteria
- [ ] Migration file exists at migrations/021_work_item_status.sql
- [ ] MIGRATIONS array has 21 entries ending with ("021", ...)
- [ ] In-memory DB: PRAGMA table_info(issues) includes all 5 new columns
- [ ] In-memory DB: PRAGMA index_list(issues) includes idx_issues_project_status_name
- [ ] Existing rows have NULL for all 5 new columns
- [ ] cargo check --all-targets passes

## TDD Loop
RED: test_migration_021_adds_columns, test_migration_021_adds_index
  Pattern: create_connection(Path::new(":memory:")) + run_migrations(&conn), then PRAGMA queries
GREEN: Create SQL file + register in MIGRATIONS
VERIFY: cargo test test_migration_021

## Edge Cases
- Migration has 5 columns (including status_synced_at INTEGER), not 4
- Test project insert uses gitlab_project_id, path_with_namespace, web_url (no name/last_seen_at)


Implement DbManager (read pool + dedicated writer)

## Background
The TUI needs concurrent database access: multiple read queries running in parallel (screen loads, search, filter) while a single writer handles sync updates. DbManager provides closure-based access (with_reader/with_writer) to prevent lock-poison panics and accidental long-held guards. All connections use std::sync::Mutex (not tokio). SQLite WAL mode enables concurrent readers alongside a single writer.

## Approach
Create crates/lore-tui/src/db_manager.rs with:
- DbManager struct: readers (Vec of Mutex<Connection>), writer (Mutex<Connection>), reader_index (AtomicUsize for round-robin)
- DbManager::new(db_path: &Path, reader_count: usize) -> Result<Self, LoreError>: opens reader_count read-only connections + 1 read-write connection, enables WAL mode, sets busy_timeout, enables foreign_keys
- with_reader<T>(f: impl FnOnce(&Connection) -> Result<T, LoreError>) -> Result<T, LoreError>: round-robin selects a reader, locks it, runs closure
- with_writer<T>(f: impl FnOnce(&Connection) -> Result<T, LoreError>) -> Result<T, LoreError>: locks writer, runs closure
- ReaderLease: for long-running queries that need an interrupt handle. lease_reader() -> (ReaderLease, InterruptHandle). Leased connections are NOT returned to the pool while leased.
- with_read_snapshot<T>(conn: &Connection, f: impl FnOnce(&Transaction) -> Result<T, LoreError>) -> Result<T, LoreError>: wraps queries in a deferred read transaction for snapshot consistency
- DEFAULT_READER_COUNT = 3

## Acceptance Criteria
- [ ] DbManager::new() creates 3 readers + 1 writer, all with WAL mode enabled
- [ ] with_reader() round-robins across readers
- [ ] with_writer() provides exclusive write access
- [ ] Closure-based API prevents Connection escaping the closure scope
- [ ] lease_reader() returns a ReaderLease with InterruptHandle for cancellable queries
- [ ] with_read_snapshot() wraps queries in deferred transaction
- [ ] All connections set busy_timeout(5000) and pragma foreign_keys=ON
- [ ] Test: concurrent with_reader calls from multiple threads don't panic

## Files
- CREATE: crates/lore-tui/src/db_manager.rs

## TDD Anchor
RED: Write test_dbmanager_round_robin that creates DbManager with 3 readers on an in-memory DB, calls with_reader 6 times, and verifies each reader is used twice (by tracking via a shared atomic counter or similar).
GREEN: Implement DbManager with round-robin reader selection.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_dbmanager

Additional tests:
- test_dbmanager_writer_isolation: writer and reader connections are separate
- test_read_snapshot_consistency: with_read_snapshot provides repeatable reads
- test_lease_reader_interrupt: leased reader can be interrupted via InterruptHandle

## Edge Cases
- In-memory DBs can't share connections across threads — tests must use file-based temp DBs or configure shared cache
- Mutex poisoning on panic: use .lock().expect() with clear message, or consider parking_lot::Mutex which doesn't poison
- Reader round-robin uses AtomicUsize with Relaxed ordering — sufficient since we're just load-balancing, not synchronizing
- WAL mode must be set on every connection, not just the first one


Implement MR List (state + action + view)

## Background
The MR List mirrors the Issue List pattern with MR-specific columns (target branch, source branch, draft status, reviewer). Same keyset pagination and filter bar DSL.

## Approach
State (state/mr_list.rs):
- MrListState: same structure as IssueListState but with MrFilter and MrListRow
- MrFilter: state, author, reviewer, target_branch, source_branch, label, draft (bool), free_text, project_id
- MrListRow: project_path, iid, title, state, author, reviewer, target_branch, labels, updated_at, draft
- MrCursor: updated_at, iid for keyset pagination

Action (action.rs):
- fetch_mrs(conn, filter, cursor, page_size, clock) -> Result<MrListPage, LoreError>: keyset query against merge_requests table. Uses idx_mrs_list_default index.

View (view/mr_list.rs):
- render_mr_list(frame, state, area, theme): FilterBar + EntityTable with MR columns
- Columns: IID, Title (flex), State, Author, Target, Labels, Updated, Draft indicator
- Draft MRs shown with muted style and [WIP] tag

## Acceptance Criteria
- [ ] Keyset pagination works for MR list (same pattern as issues)
- [ ] MR-specific filter fields: draft, reviewer, target_branch, source_branch
- [ ] Draft MRs visually distinguished with [WIP] indicator
- [ ] State filter supports: opened, merged, closed, locked, all
- [ ] Columns: IID, Title, State, Author, Target Branch, Labels, Updated
- [ ] Enter navigates to MrDetail, Esc returns with state preserved

## Files
- MODIFY: crates/lore-tui/src/state/mr_list.rs (expand from stub)
- MODIFY: crates/lore-tui/src/action.rs (add fetch_mrs)
- CREATE: crates/lore-tui/src/view/mr_list.rs

## TDD Anchor
RED: Write test_fetch_mrs_draft_filter in action.rs that inserts 5 MRs (3 draft, 2 not), calls fetch_mrs with draft=true filter, asserts 3 results.
GREEN: Implement fetch_mrs with draft filter.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_fetch_mrs

## Edge Cases
- MR state "locked" is rare but must be handled in filter and display
- Very long branch names: truncate with ellipsis
- MRs with no reviewer: show "-" in reviewer column

## Dependency Context
Uses EntityTable and FilterBar from "Implement entity table + filter bar widgets" task.
Uses same keyset pagination pattern from "Implement Issue List" task.
Uses MrListState from "Implement AppState composition" task.


Per-note search: search individual comments at note granularity

## Background
Most knowledge in a GitLab project is buried in discussion threads. Current lore search operates at document level (one doc per issue/MR/discussion). An agent searching for "we decided to use Redis" only finds the parent issue, not the specific comment where that decision was stated.

## Current State (Verified 2026-02-12)
- documents table (migration 007): source_type, source_id, project_id, author_username, label_names, content_text, content_hash, etc. NO source_note_id column.
- source_type values: 'issue', 'merge_request', 'discussion' — discussion docs concatenate all notes into one text blob
- notes table: 282K rows with individual note body, author, timestamps, is_system flag
- discussions table: links notes to their parent entity (noteable_type, noteable_id)
- FTS5 index (documents_fts): operates on coarse document-level text
- Document generation: src/documents/extractor.rs extracts issue/MR/discussion documents
- Document regeneration: src/documents/regenerator.rs handles dirty document refresh
- PRD exists: docs/prd-per-note-search.md with 5 feedback iterations

## Approach

### Schema (Migration 022)
This bead owns migration 022. bd-2g50 (data gaps) ships after this and uses migration 023.

```sql
-- migrations/022_note_documents.sql
ALTER TABLE documents ADD COLUMN source_note_id INTEGER REFERENCES notes(id);
CREATE INDEX idx_documents_source_note ON documents(source_note_id) WHERE source_note_id IS NOT NULL;
```
- source_note_id = NULL for existing entity-level documents (backwards compatible)
- source_note_id = notes.id for new note-level documents

Wire into src/core/db.rs MIGRATIONS array as entry ("022", include_str!("../../migrations/022_note_documents.sql")). LATEST_SCHEMA_VERSION auto-updates since it's `MIGRATIONS.len() as i32`.

### Document Generation (src/documents/extractor.rs)
Add a new extraction function alongside existing `extract_issue_document()` (line 85), `extract_mr_document()` (line 186), `extract_discussion_document()` (line 302):

```rust
pub fn extract_note_documents(
    conn: &Connection,
    project_id: i64,
) -> Result<Vec<DocumentData>> {
    // SELECT n.id, n.body, n.author_username, n.created_at, n.updated_at,
    //        d.noteable_type, d.noteable_id
    // FROM notes n
    // JOIN discussions d ON n.discussion_id = d.id
    // WHERE n.is_system = 0
    //   AND LENGTH(n.body) >= 50
    //   AND d.project_id = ?1
    //   AND n.id NOT IN (SELECT source_note_id FROM documents WHERE source_note_id IS NOT NULL)

    // For each qualifying note:
    // - source_type = 'note'
    // - source_id = note.id (the note's local DB id)
    // - source_note_id = note.id
    // - title = format!("Re: {}", parent_entity_title)
    // - author_username = note.author_username
    // - content_text = note.body
    // - content_hash = sha256(note.body) for deduplication
}
```

Minimum note length (50 chars) filters out "+1", "LGTM", emoji-only notes. is_system=0 filters automated state change notes.

NOTE: The documents table CHECK constraint for source_type needs updating — currently enforces `CHECK (source_type IN ('issue','merge_request','discussion'))`. Migration 022 must also:
```sql
-- Drop and recreate the CHECK constraint is not supported in SQLite ALTER TABLE.
-- Instead, the check is only on INSERT, so we need to handle this:
-- Option A: Don't add 'note' to CHECK — just insert with source_type='note' and let
--           SQLite ignore the CHECK on ALTER (it won't — CHECK is enforced).
-- Option B: Use source_type='discussion' for note docs (semantically wrong).
-- Option C: Recreate the table (heavy migration).
-- RECOMMENDED: Use a new migration that drops the CHECK constraint entirely.
--   SQLite doesn't support ALTER TABLE ... DROP CONSTRAINT, so:
--   CREATE TABLE documents_new (... without CHECK ...);
--   INSERT INTO documents_new SELECT * FROM documents;
--   DROP TABLE documents;
--   ALTER TABLE documents_new RENAME TO documents;
--   Recreate indexes and triggers.
-- This is the only correct approach. The CHECK constraint is in migration 007.
```

### Search Integration
Add --granularity flag to search command:

```rust
// In SearchCliFilters or SearchFilters (src/search/filters.rs:15)
pub granularity: Option<Granularity>,  // note | entity (default)

// In FTS query construction (src/search/fts.rs)
// When granularity = note:
//   AND d.source_note_id IS NOT NULL
// When granularity = entity (or default):
//   AND d.source_note_id IS NULL (existing behavior)
```

### Robot Mode Output (note granularity)
```json
{
  "source_type": "note",
  "title": "Re: Switch Health Card",
  "parent_type": "issue",
  "parent_iid": 3864,
  "parent_title": "Switch Health Card (Throw Times)",
  "note_author": "teernisse",
  "note_created_at": "2026-02-01T...",
  "discussion_id": "abc123",
  "snippet": "...decided to use once-per-day ingestion from BNSF...",
  "score": 0.87
}
```

Join path for note metadata:
```sql
SELECT d.source_note_id, n.author_username, n.created_at,
       disc.gitlab_discussion_id,
       CASE disc.noteable_type
         WHEN 'Issue' THEN 'issue'
         WHEN 'MergeRequest' THEN 'merge_request'
       END as parent_type,
       disc.noteable_id
FROM documents d
JOIN notes n ON d.source_note_id = n.id
JOIN discussions disc ON n.discussion_id = disc.id
WHERE d.source_note_id IS NOT NULL AND d.id IN (...)
```

## TDD Loop
RED: Tests in src/documents/extractor.rs (or new test file):
- test_note_document_generation: insert issue + discussion + 3 notes (one 10 chars, one 60 chars, one 200 chars), run extract_note_documents, assert 2 note-level documents created (>= 50 chars only)
- test_note_document_skips_system_notes: insert system note (is_system=1) with 100-char body, assert no document generated
- test_note_document_content_hash_dedup: insert note, generate doc, re-run, assert no duplicate created
- test_note_document_parent_title: assert generated doc title starts with "Re: "

Tests in src/cli/commands/search.rs:
- test_search_granularity_note_filter: with note docs in DB, --granularity note returns only note results
- test_search_granularity_entity_default: default behavior unchanged, does NOT return note docs

GREEN: Add migration, update extractor, add --granularity flag to search

VERIFY:
```bash
cargo test note_document && cargo test search_granularity
cargo clippy --all-targets -- -D warnings
cargo run --release -- -J search 'ingestion' --granularity note | jq '.data.results[0].parent_iid'
```

## Acceptance Criteria
- [ ] Migration 022 adds source_note_id to documents table (nullable, indexed, FK to notes)
- [ ] Migration 022 handles the source_type CHECK constraint (allows 'note' as valid value)
- [ ] extract_note_documents creates note-level docs for notes >= 50 chars, non-system
- [ ] Content hash deduplication prevents duplicate note documents
- [ ] lore search --granularity note returns note-level results with parent context
- [ ] lore search (no flag) returns entity-level results only (backwards compatible)
- [ ] Robot mode includes parent_type, parent_iid, parent_title, note_author, note_created_at
- [ ] Performance: note-level FTS search across expanded index completes in <200ms
- [ ] Embedding pipeline handles note-level documents (embed individually, same as entity docs)
- [ ] lore stats shows note document count separately from entity document count

## Edge Cases
- Note with only markdown formatting (no text after stripping): skip (LENGTH(body) >= 50 handles most)
- Note body is a quote of another note (duplicated text): deduplicate via content_hash
- Very long note (>32KB): apply same truncation as entity documents (src/documents/truncation.rs)
- Discussion with 100+ notes: each becomes its own document (correct behavior)
- Deleted notes (if tracked): should not generate documents
- Notes on confidential issues: inherit visibility (future concern, not blocking)
- source_type CHECK constraint: migration MUST handle this — SQLite enforces CHECK on INSERT, so inserting source_type='note' will fail without updating the constraint

## Files to Modify
- NEW: migrations/022_note_documents.sql (schema change + CHECK constraint update)
- src/core/db.rs (wire migration 022 into MIGRATIONS array)
- src/documents/extractor.rs (add extract_note_documents function)
- src/documents/mod.rs (export new function)
- src/search/fts.rs (add granularity filter to FTS queries)
- src/search/filters.rs (add granularity to SearchFilters at line 15)
- src/cli/commands/search.rs (--granularity flag, note metadata in SearchResultDisplay)
- src/cli/commands/stats.rs (show note document count)

Replaced by granular beads broken out from docs/prd-per-note-search.md


WHO: Mode resolution, path helpers, run_who entry point

## Background

Core scaffolding that all 5 query modes depend on. Defines the mode discrimination logic, path normalization, path-to-SQL translation (with project-scoped DB probes), time resolution, and the run_who() entry point that dispatches to query functions.

## Approach

### WhoMode enum
```rust
enum WhoMode<'a> {
    Expert { path: String },       // owns String (normalization produces new strings)
    Workload { username: &'a str },  // borrows from args
    Reviews { username: &'a str },
    Active,
    Overlap { path: String },
}
```

### resolve_mode() discrimination rules:
1. --path flag always wins -> Expert
2. --active -> Active
3. --overlap -> Overlap
4. positional target with --reviews -> Reviews
5. positional target containing '/' -> Expert (username never contains /)
6. positional target without '/' -> Workload (strip @ prefix)
7. No args -> error with usage examples

### normalize_repo_path(): strips ./, leading /, collapses //, converts \ to / (Windows paste, only when no / present), trims whitespace

### PathQuery + build_path_query(conn, path, project_id):
- Struct: `{ value: String, is_prefix: bool }`
- Trailing / forces directory prefix
- Root path (no /) without trailing / -> exact match (handles Makefile, LICENSE via --path)
- Last segment contains . -> heuristic: file (exact)
- **Two-way DB probe** (project-scoped): when heuristics are ambiguous, probe DB:
  - Probe 1: exact path exists? `SELECT 1 FROM notes WHERE note_type='DiffNote' AND is_system=0 AND position_new_path = ?1 AND (?2 IS NULL OR project_id = ?2) LIMIT 1`
  - Probe 2 (only if exact miss, not forced-dir): prefix exists?
  - Decision: forced_dir -> prefix; exact_exists -> exact; prefix_exists -> prefix; else heuristic
- **CRITICAL**: escape_like() is ONLY called for prefix (LIKE) matches. For exact matches (=), use raw path — LIKE metacharacters (_, %) are not special in = comparisons.

### Result types: WhoRun, WhoResolvedInput (since_mode tri-state: "default"/"explicit"/"none"), WhoResult enum, all 5 mode-specific result structs (see plan Step 2 "Result Types")

### run_who() entry: resolve project -> resolve mode -> resolve since -> dispatch to query_* -> return WhoRun

### since_mode semantics:
- Expert/Reviews/Active/Overlap: default window applies if --since absent -> "default"
- Workload: no default window; --since absent -> "none"
- Any mode with explicit --since -> "explicit"

## Files

- `src/cli/commands/who.rs` — all code in this file

## TDD Loop

RED:
```
test_is_file_path_discrimination — resolve_mode for paths/usernames/@/--reviews/--path
test_build_path_query — directory/file/root/dotted/underscore/dotless
test_build_path_query_exact_does_not_escape — _ in exact path stays raw
test_path_flag_dotless_root_file_is_exact — Makefile/Dockerfile via --path
test_build_path_query_dotless_subdir_file_uses_db_probe — src/Dockerfile with/without DB data
test_build_path_query_probe_is_project_scoped — data in proj 1, query proj 2
test_escape_like — normal/underscore/percent/backslash
test_normalize_repo_path — ./ / \\ // whitespace identity
test_lookup_project_path — basic round-trip
```

GREEN: Implement all functions. Query functions can be stubs (todo!()) for now.
VERIFY: `cargo test -- who`

## Acceptance Criteria

- [ ] resolve_mode correctly discriminates all 7 cases (see tests)
- [ ] build_path_query returns exact for files, prefix for dirs
- [ ] build_path_query DB probe is project-scoped (cross-project isolation)
- [ ] escape_like escapes %, _, \ correctly
- [ ] normalize_repo_path handles ./, /, \\, //, whitespace
- [ ] WhoResolvedInput.since_mode is "none" for Workload without --since

## Edge Cases

- Dotless files in subdirectories (src/Dockerfile, infra/Makefile) — DB probe catches these, heuristic alone would misclassify as directory
- Windows path paste (src\foo\bar.rs) — convert \ to / only when no / present
- LIKE metacharacters in filenames (README_with_underscore.md) — must NOT be escaped for exact match
- Root files without / (README.md, LICENSE, Makefile) — must use --path flag, positional would treat as username


Implement Clock trait (SystemClock + FakeClock)

## Background
All relative-time rendering (e.g., "3h ago" labels) must use an injected Clock, not wall-clock time. This ensures deterministic snapshot tests and consistent timestamps within a single frame. FakeClock lets tests control time precisely.

## Approach
Create crates/lore-tui/src/clock.rs with:
- Clock trait: fn now(&self) -> chrono::DateTime<chrono::Utc>
- SystemClock: impl Clock using chrono::Utc::now()
- FakeClock: wraps Arc<Mutex<DateTime<Utc>>>, impl Clock returning the frozen value. Methods: new(fixed_time), advance(duration), set(time)
- Both cloneable (SystemClock is Copy, FakeClock shares Arc)

## Acceptance Criteria
- [ ] Clock trait with now() method
- [ ] SystemClock returns real wall-clock time
- [ ] FakeClock returns frozen time, advance() moves it forward
- [ ] FakeClock is Clone (shared Arc)
- [ ] Tests pass: frozen clock returns same time on repeated calls
- [ ] Tests pass: advance() moves time forward by exact duration

## Files
- CREATE: crates/lore-tui/src/clock.rs

## TDD Anchor
RED: Write test_fake_clock_frozen that creates FakeClock at a fixed time, calls now() twice, asserts both return the same value.
GREEN: Implement FakeClock with Arc<Mutex<DateTime>>.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_fake_clock

## Edge Cases
- FakeClock must be Send+Sync for use across Cmd::task threads
- advance() must handle chrono overflow gracefully (use checked_add)


[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