WHO: Overlap mode query (query_overlap)

## Background

Overlap mode answers "Who else has MRs/notes touching my files?" — helps identify potential reviewers, collaborators, or conflicting work at a path. Tracks author and reviewer roles separately for richer signal.

## Approach

### SQL: two static variants (prefix/exact) with reviewer + author UNION ALL

Both branches return: username, role, touch_count (COUNT DISTINCT m.id), last_seen_at, mr_refs (GROUP_CONCAT of project-qualified refs).

Key differences from Expert:
- No scoring formula — just touch_count ranking
- mr_refs collected for actionable output (group/project!iid format)
- Rust-side merge needed (can't fully aggregate in SQL due to HashSet dedup of mr_refs across branches)

### Reviewer branch includes:
- Self-review exclusion: `n.author_username != m.author_username`
- MR state filter: `m.state IN ('opened','merged')`
- Project-qualified refs: `GROUP_CONCAT(DISTINCT (p.path_with_namespace || '!' || m.iid))`

### Rust accumulator pattern:
```rust
struct OverlapAcc {
    username: String,
    author_touch_count: u32,
    review_touch_count: u32,
    touch_count: u32,
    last_seen_at: i64,
    mr_refs: HashSet<String>,  // O(1) dedup from the start
}
// Build HashMap<String, OverlapAcc> from rows
// Convert to Vec, sort, bound mr_refs
```

### Bounded mr_refs:
```rust
const MAX_MR_REFS_PER_USER: usize = 50;
let mr_refs_total = mr_refs.len() as u32;
let mr_refs_truncated = mr_refs.len() > MAX_MR_REFS_PER_USER;
```

### Deterministic sort: touch_count DESC, last_seen_at DESC, username ASC

### format_overlap_role():
```rust
fn format_overlap_role(user: &OverlapUser) -> &'static str {
    match (user.author_touch_count > 0, user.review_touch_count > 0) {
        (true, true) => "A+R", (true, false) => "A",
        (false, true) => "R", (false, false) => "-",
    }
}
```

### OverlapResult/OverlapUser structs include path_match ("exact"/"prefix"), truncated bool, per-user mr_refs_total + mr_refs_truncated

## Files

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

## TDD Loop

RED:
```
test_overlap_dual_roles — user is author of MR 1 and reviewer of MR 2 at same path; verify A+R role, both touch counts > 0, mr_refs contain "team/backend!"
test_overlap_multi_project_mr_refs — same iid 100 in two projects; verify both "team/backend!100" and "team/frontend!100" present
test_overlap_excludes_self_review_notes — author comments on own MR; review_touch_count must be 0
```

GREEN: Implement query_overlap with both SQL variants + accumulator
VERIFY: `cargo test -- overlap`

## Acceptance Criteria

- [ ] test_overlap_dual_roles passes (A+R role detection)
- [ ] test_overlap_multi_project_mr_refs passes (project-qualified refs unique)
- [ ] test_overlap_excludes_self_review_notes passes
- [ ] Default since window: 30d
- [ ] mr_refs sorted alphabetically for deterministic output
- [ ] touch_count uses coherent units (COUNT DISTINCT m.id on BOTH branches)

## Edge Cases

- Both branches count MRs (not DiffNotes) for coherent touch_count — mixing units produces misleading totals
- mr_refs from GROUP_CONCAT may contain duplicates across branches — HashSet handles dedup
- Project scoping on n.project_id (not m.project_id) for index alignment
- mr_refs sorted before output (HashSet iteration is nondeterministic)

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


Implement filter DSL parser state machine

## Background

The Issue List and MR List filter bars accept typed filter expressions (e.g., `state:opened author:@asmith label:"high priority" -milestone:v2.0`). The PRD Appendix B defines a full state machine: Inactive -> Active -> FieldSelect/FreeText -> ValueInput. The parser needs to handle field:value pairs, negation prefix (`-`), quoted values with spaces, bare text as free-text search, and inline error diagnostics when an unrecognized field name is typed. This is a substantial subsystem that the entity table filter bar widget (bd-18qs) depends on for its core functionality.

## Approach

Create a `filter_dsl.rs` module with:

1. **FilterToken enum** — `Field { name: String, value: String, negated: bool }` | `FreeText(String)` | `Error { position: usize, message: String }`
2. **`parse_filter(input: &str) -> Vec<FilterToken>`** — Tokenizer that handles:
   - `field:value` — recognized fields: state, author, assignee, label, milestone, since, project (issue); + reviewer, draft, target, source (MR)
   - `-field:value` — negation prefix strips the `-` and sets `negated: true`
   - `field:"quoted value"` — double-quoted values preserve spaces
   - bare words — collected as `FreeText` tokens
   - unrecognized field names — produce `Error` token with position and message
3. **FilterBarState** state machine:
   - `Inactive` — filter bar not focused
   - `Active(Typing)` — user typing, no suggestion yet
   - `Active(Suggesting)` — 200ms pause triggers field name suggestions
   - `FieldSelect` — dropdown showing recognized field names after `:`
   - `ValueInput` — context-dependent completions (e.g., state values: opened/closed/all)
4. **`apply_issue_filter(tokens: &[FilterToken]) -> IssueFilterParams`** — converts tokens to query parameters
5. **`apply_mr_filter(tokens: &[FilterToken]) -> MrFilterParams`** — MR variant with reviewer, draft, target/source fields

## Acceptance Criteria
- [ ] `parse_filter("state:opened")` returns one Field token with name="state", value="opened", negated=false
- [ ] `parse_filter("-label:bug")` returns one Field with negated=true
- [ ] `parse_filter('author:"Jane Doe"')` returns one Field with value="Jane Doe" (quotes stripped)
- [ ] `parse_filter("foo:bar")` where "foo" is not a recognized field returns Error token with position
- [ ] `parse_filter("state:opened some text")` returns Field + FreeText tokens
- [ ] `parse_filter("")` returns empty vec
- [ ] FilterBarState transitions match the Appendix B state machine diagram
- [ ] apply_issue_filter correctly maps all 7 issue fields (state, author, assignee, label, milestone, since, project)
- [ ] apply_mr_filter correctly maps additional MR fields (reviewer, draft, target, source)
- [ ] Inline error diagnostics include the character position of the unrecognized field

## Files
- CREATE: crates/lore-tui/src/widgets/filter_dsl.rs
- MODIFY: crates/lore-tui/src/widgets/mod.rs (add `pub mod filter_dsl;`)

## TDD Anchor
RED: Write `test_parse_simple_field_value` that asserts `parse_filter("state:opened")` returns `[Field { name: "state", value: "opened", negated: false }]`.
GREEN: Implement the tokenizer for the simplest case.
VERIFY: cargo test -p lore-tui parse_simple

Additional tests:
- test_parse_negation
- test_parse_quoted_value
- test_parse_unrecognized_field_produces_error
- test_parse_mixed_tokens
- test_parse_empty_input
- test_apply_issue_filter_maps_all_fields
- test_apply_mr_filter_maps_additional_fields
- test_filter_bar_state_transitions

## Edge Cases
- Unclosed quote (`author:"Jane`) — treat rest of input as the value, produce warning token
- Empty value (`state:`) — produce Error token, not a Field with empty value
- Multiple colons (`field:val:ue`) — first colon splits, rest is part of value
- Unicode in field values (`author:@rene`) — must handle multi-byte chars correctly
- Very long filter strings (>1000 chars) — must not allocate unbounded; truncate with error

## Dependency Context
- Depends on bd-18qs (entity table + filter bar widgets) which provides the TextInput widget and filter bar rendering. This bead provides the PARSER that bd-18qs's filter bar CALLS.
- Consumed by bd-3ei1 (Issue List) and bd-2kr0 (MR List) for converting user filter input into query parameters.


Implement vector search function

## Background
Vector search queries the sqlite-vec virtual table for nearest-neighbor documents. Because documents may have multiple chunks, the raw KNN results need deduplication by document_id (keeping the best/lowest distance per document). The function over-fetches 3x to ensure enough unique documents after dedup.

## Approach
Create `src/search/vector.rs`:

```rust
pub struct VectorResult {
    pub document_id: i64,
    pub distance: f64,  // Lower = closer match
}

/// Search documents using sqlite-vec KNN query.
/// Over-fetches 3x limit to handle chunk dedup.
pub fn search_vector(
    conn: &Connection,
    query_embedding: &[f32],  // 768-dim embedding of search query
    limit: usize,
) -> Result<Vec<VectorResult>>
```

**SQL (KNN query):**
```sql
SELECT rowid, distance
FROM embeddings
WHERE embedding MATCH ?
  AND k = ?
ORDER BY distance
```

**Algorithm:**
1. Convert query_embedding to raw LE bytes
2. Execute KNN with k = limit * 3 (over-fetch for dedup)
3. Decode each rowid via decode_rowid() -> (document_id, chunk_index)
4. Group by document_id, keep minimum distance (best chunk)
5. Sort by distance ascending
6. Take first `limit` results

## Acceptance Criteria
- [ ] Returns deduplicated document-level results (not chunk-level)
- [ ] Best chunk distance kept per document (lowest distance wins)
- [ ] KNN with k parameter (3x limit)
- [ ] Query embedding passed as raw LE bytes
- [ ] Results sorted by distance ascending (closest first)
- [ ] Returns at most `limit` results
- [ ] Empty embeddings table returns empty Vec
- [ ] `cargo build` succeeds

## Files
- `src/search/vector.rs` — new file
- `src/search/mod.rs` — add `pub use vector::{search_vector, VectorResult};`

## TDD Loop
RED: Integration tests need sqlite-vec + seeded embeddings:
- `test_vector_search_basic` — finds nearest document
- `test_vector_search_dedup` — multi-chunk doc returns once with best distance
- `test_vector_search_empty` — empty table returns empty
- `test_vector_search_limit` — respects limit parameter
GREEN: Implement search_vector
VERIFY: `cargo test vector`

## Edge Cases
- All chunks belong to same document: returns single result
- Query embedding wrong dimension: sqlite-vec may error — handle gracefully
- Over-fetch returns fewer than limit unique docs: return what we have
- Distance = 0.0: exact match (valid result)

Implemented search_vector with KNN query, 3x over-fetch, chunk dedup. 3 tests pass.


Implement core types (Msg, Screen, EntityKey, AppError, InputMode)

## Background
The core types form the message-passing backbone of the Elm Architecture. Every user action and async result flows through the Msg enum. Screen identifies navigation targets. EntityKey provides safe cross-project entity identity. AppError enables structured error display. InputMode controls key dispatch routing.

## Approach
Create crates/lore-tui/src/message.rs with:
- Msg enum (~40 variants): RawEvent, Tick, Resize, NavigateTo, GoBack, GoForward, GoHome, JumpBack, JumpForward, OpenCommandPalette, CloseCommandPalette, CommandPaletteInput, CommandPaletteSelect, IssueListLoaded{generation, rows}, IssueListFilterChanged, IssueListSortChanged, IssueSelected, MrListLoaded{generation, rows}, MrListFilterChanged, MrSelected, IssueDetailLoaded{generation, key, detail}, MrDetailLoaded{generation, key, detail}, DiscussionsLoaded{generation, discussions}, SearchQueryChanged, SearchRequestStarted{generation, query}, SearchExecuted{generation, results}, SearchResultSelected, SearchModeChanged, SearchCapabilitiesLoaded, TimelineLoaded, TimelineEntitySelected, WhoResultLoaded, WhoModeChanged, SyncStarted, SyncProgress, SyncProgressBatch, SyncLogLine, SyncBackpressureDrop, SyncCompleted, SyncCancelled, SyncFailed, SyncStreamStats, SearchDebounceArmed, SearchDebounceFired, DashboardLoaded, Error, ShowHelp, ShowCliEquivalent, OpenInBrowser, BlurTextInput, ScrollToTopCurrentScreen, Quit
- impl From<Event> for Msg (FrankenTUI requirement) — maps Resize, Tick, and wraps everything else in RawEvent
- Screen enum: Dashboard, IssueList, IssueDetail(EntityKey), MrList, MrDetail(EntityKey), Search, Timeline, Who, Sync, Stats, Doctor, Bootstrap
- Screen::label() -> &str and Screen::is_detail_or_entity() -> bool
- EntityKey { project_id: i64, iid: i64, kind: EntityKind } with EntityKey::issue() and EntityKey::mr() constructors
- EntityKind enum: Issue, MergeRequest
- AppError enum: DbBusy, DbCorruption(String), NetworkRateLimited{retry_after_secs}, NetworkUnavailable, AuthFailed, ParseError(String), Internal(String) with Display impl
- InputMode enum: Normal, Text, Palette, GoPrefix{started_at: Instant} with Default -> Normal

## Acceptance Criteria
- [ ] Msg enum compiles with all ~40 variants
- [ ] From<Event> impl converts Resize->Msg::Resize, Tick->Msg::Tick, other->Msg::RawEvent
- [ ] Screen enum has all 12 variants with label() and is_detail_or_entity() methods
- [ ] EntityKey::issue() and EntityKey::mr() constructors work correctly
- [ ] EntityKey derives Debug, Clone, PartialEq, Eq, Hash
- [ ] AppError Display shows user-friendly messages for each variant
- [ ] InputMode defaults to Normal

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

## TDD Anchor
RED: Write test_entity_key_equality that asserts EntityKey::issue(1, 42) == EntityKey::issue(1, 42) and EntityKey::issue(1, 42) != EntityKey::mr(1, 42).
GREEN: Implement EntityKey with derives.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_entity_key

## Edge Cases
- Generation fields (u64) in Msg variants are critical for stale result detection — must be present on all async result variants
- EntityKey equality must include both project_id AND iid AND kind — bare iid is unsafe with multi-project datasets
- AppError::NetworkRateLimited retry_after_secs is Option<u64> — GitLab may not provide Retry-After header


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

Add required dependencies for async pagination streams.

## Changes
Add to Cargo.toml:
- async-stream = "0.3"
- futures = "0.3"

## Why
The pagination methods use async generators which require async-stream crate.
futures crate provides StreamExt for consuming the streams.

## Done When
- cargo check passes with new deps
- No unused dependency warnings

Files: Cargo.toml


[CP1] Integration tests for label linkage

Integration tests verifying label linkage and stale removal.

## Tests (tests/label_linkage_tests.rs)

- clears_existing_labels_before_linking_new_set
- removes_stale_label_links_on_issue_update
- handles_issue_with_all_labels_removed
- preserves_labels_that_still_exist

## Test Scenario
1. Create issue with labels [A, B]
2. Verify issue_labels has links to A and B
3. Update issue with labels [B, C]
4. Verify A link removed, B preserved, C added

## Why This Matters
The clear-and-relink pattern ensures GitLab reality is reflected locally.
If we only INSERT, removed labels would persist incorrectly.

Files: tests/label_linkage_tests.rs
Done when: Stale label links correctly removed on resync


Add entity_references table to migration 010

## Background
The entity_references table is now part of migration 011 (combined with resource event tables and dependent fetch queue). This bead is satisfied by bd-hu3 since the entity_references table schema is included in the same migration.

## Approach
This bead's work is folded into bd-hu3 (Write migration 011). The entity_references table from Phase B spec §2.2 is included in migrations/011_resource_events.sql alongside the event tables and queue.

The entity_references schema includes:
- source/target entity type + id with reference_type and source_method
- Unresolved reference support (target_entity_id NULL with target_project_path + target_entity_iid)
- UNIQUE constraint using COALESCE for nullable columns
- Partial indexes for source, target (where not null), and unresolved refs

No separate migration file needed — this is in 011.

## Acceptance Criteria
- [ ] entity_references table exists in migration 011 (verified by bd-hu3)
- [ ] UNIQUE constraint handles NULL columns via COALESCE
- [ ] Indexes created: source composite, target composite (partial), unresolved (partial)
- [ ] reference_type CHECK includes 'closes', 'mentioned', 'related'
- [ ] source_method CHECK includes 'api_closes_issues', 'api_state_event', 'system_note_parse'

## Files
- migrations/011_resource_events.sql (part of bd-hu3)

## TDD Loop
Covered by bd-hu3's test_migration_011_entity_references_dedup test.

VERIFY: `cargo test migration_tests -- --nocapture`

## Edge Cases
- Same as bd-hu3's entity_references edge cases

Work folded into bd-hu3 (migration 011 includes entity_references table)


Implement timeline robot mode JSON output

## Background

Robot mode JSON for timeline follows the {ok, data, meta} envelope pattern. The JSON schema MUST match spec Section 3.5 exactly — this is the contract for AI agent consumers.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.5 (Robot Mode JSON).

## Codebase Context

- Robot mode pattern: all commands use {ok: true, data: {...}, meta: {...}} envelope
- Timestamps: internal ms epoch UTC -> output ISO 8601 via core::time::ms_to_iso()
- source_method values in DB: 'api', 'note_parse', 'description_parse' (NOT spec's api_closes_issues etc.)
- Serde rename: use #[serde(rename = "type")] for entity objects per spec

## Approach

Create `print_timeline_json()` in `src/cli/commands/timeline.rs`:

### Key JSON structure (spec Section 3.5):
- data.seed_entities: [{type, iid, project}] — note "type" not "entity_type", "project" not "project_path"
- data.expanded_entities: [{type, iid, project, depth, via: {from: {type,iid,project}, reference_type, source_method}}]
- data.unresolved_references: [{source: {type,iid,project}, target_project, target_type, target_iid, reference_type}]
- data.events: [{timestamp (ISO 8601), entity_type, entity_iid, project, event_type, summary, actor, url, is_seed, details}]
- meta: {search_mode: "lexical", expansion_depth, expand_mentions, total_entities, total_events, evidence_notes_included, unresolved_references, showing}

### Details object per event type:
- created: {labels: [...]}
- note_evidence: {note_id, snippet}
- state_changed: {state}
- label_added: {label}

### Rust JSON Structs

```rust
#[derive(Serialize)]
struct TimelineJson {
    ok: bool,
    data: TimelineDataJson,
    meta: TimelineMetaJson,
}

#[derive(Serialize)]
struct TimelineDataJson {
    query: String,
    event_count: usize,
    seed_entities: Vec<EntityJson>,
    expanded_entities: Vec<ExpandedEntityJson>,
    unresolved_references: Vec<UnresolvedRefJson>,
    events: Vec<EventJson>,
}

#[derive(Serialize)]
struct EntityJson {
    #[serde(rename = "type")]
    entity_type: String,
    iid: i64,
    project: String,
}

#[derive(Serialize)]
struct TimelineMetaJson {
    search_mode: String,        // always "lexical"
    expansion_depth: u32,
    expand_mentions: bool,
    total_entities: usize,
    total_events: usize,        // before limit
    evidence_notes_included: usize,
    unresolved_references: usize,
    showing: usize,             // after limit
}
```

### source_method values: use CODEBASE values (api/note_parse/description_parse), not spec values

## Acceptance Criteria

- [ ] Valid JSON to stdout
- [ ] {ok, data, meta} envelope
- [ ] ISO 8601 timestamps
- [ ] Entity objects use "type" and "project" keys per spec
- [ ] Nested "via" object on expanded entities per spec
- [ ] Events include url and details fields
- [ ] meta.total_events before limit; meta.showing after limit
- [ ] source_method uses codebase values
- [ ] `cargo check --all-targets` passes

## Files

- `src/cli/commands/timeline.rs` (add print_timeline_json + JSON structs)
- `src/cli/commands/mod.rs` (re-export)

## TDD Loop

Verify: `lore --robot timeline "test" | jq '.data.expanded_entities[0].via.from'`

## Edge Cases

- Empty results: events=[], meta.showing=0
- Null actor/url: serialize as null (not omitted)
- source_method: use actual DB values, not spec originals

Implemented print_timeline_json_with_meta() robot JSON output in src/cli/commands/timeline.rs with {ok,data,meta} envelope, ISO timestamps, entity/expanded/unresolved JSON structs, event details per type


NOTE-2B: SourceType enum extension for notes

## Background
The SourceType enum in src/documents/extractor.rs (line 15-19) needs a Note variant for the document pipeline to handle note-type documents.

## Approach
In src/documents/extractor.rs:
1. Add Note variant to SourceType enum (line 15-19, after Discussion):
   pub enum SourceType { Issue, MergeRequest, Discussion, Note }

2. Add match arm to as_str() (line 22-28): Self::Note => "note"

3. Add parse aliases (line 30-37): "note" | "notes" => Some(Self::Note)

4. Display impl (line 40-43) already delegates to as_str() — no change needed.

5. IMPORTANT: Also update seed_dirty() in src/cli/commands/generate_docs.rs (line 66-70) which has a match on SourceType that maps to table names. SourceType::Note should NOT be added to this match — notes are seeded differently (by querying the notes table, not by table name pattern). This is handled by NOTE-2E.

## Files
- MODIFY: src/documents/extractor.rs (SourceType enum at line 15, as_str at line 22, parse at line 30)

## TDD Anchor
RED: test_source_type_parse_note — assert SourceType::parse("note") == Some(SourceType::Note)
GREEN: Add Note variant and match arms.
VERIFY: cargo test source_type_parse_note -- --nocapture
Tests: test_source_type_note_as_str (assert as_str() == "note"), test_source_type_note_display (assert format!("{}", SourceType::Note) == "note"), test_source_type_parse_notes_alias (assert parse("notes") works)

## Acceptance Criteria
- [ ] SourceType::Note variant exists
- [ ] as_str() returns "note"
- [ ] parse() accepts "note", "notes" (case-insensitive via to_lowercase)
- [ ] Display trait works via as_str delegation
- [ ] No change to seed_dirty() match — that's a separate bead (NOTE-2E)
- [ ] All 4 tests pass, clippy clean
- [ ] CRITICAL: regenerate_one() in src/documents/regenerator.rs (line 86-91) has exhaustive match on SourceType — adding Note variant will cause a compile error until NOTE-2D adds the match arm. Either add a temporary todo!() or coordinate with NOTE-2D.

## Dependency Context
- Depends on NOTE-2A (bd-1oi7): migration 024 must exist so test DBs accept source_type='note' in documents/dirty_sources tables

## Edge Cases
- Exhaustive match: Adding the variant breaks regenerate_one() (line 86-91) and seed_dirty() (line 66-70) until downstream beads handle it. Agent should add temporary unreachable!() arms with comments referencing the downstream bead IDs.


[CP0] Config loading with Zod validation

## Background

Config loading is critical infrastructure - every CLI command needs the config. Uses Zod for schema validation with sensible defaults. Must handle missing files gracefully with typed errors.

Reference: docs/prd/checkpoint-0.md sections "Configuration Schema", "Config Resolution Order"

## Approach

**src/core/config.ts:**
```typescript
import { z } from 'zod';
import { readFileSync } from 'node:fs';
import { ConfigNotFoundError, ConfigValidationError } from './errors';
import { getConfigPath } from './paths';

export const ConfigSchema = z.object({
  gitlab: z.object({
    baseUrl: z.string().url(),
    tokenEnvVar: z.string().default('GITLAB_TOKEN'),
  }),
  projects: z.array(z.object({
    path: z.string().min(1),
  })).min(1),
  sync: z.object({
    backfillDays: z.number().int().positive().default(14),
    staleLockMinutes: z.number().int().positive().default(10),
    heartbeatIntervalSeconds: z.number().int().positive().default(30),
    cursorRewindSeconds: z.number().int().nonnegative().default(2),
    primaryConcurrency: z.number().int().positive().default(4),
    dependentConcurrency: z.number().int().positive().default(2),
  }).default({}),
  storage: z.object({
    dbPath: z.string().optional(),
    backupDir: z.string().optional(),
    compressRawPayloads: z.boolean().default(true),
  }).default({}),
  embedding: z.object({
    provider: z.literal('ollama').default('ollama'),
    model: z.string().default('nomic-embed-text'),
    baseUrl: z.string().url().default('http://localhost:11434'),
    concurrency: z.number().int().positive().default(4),
  }).default({}),
});

export type Config = z.infer<typeof ConfigSchema>;

export function loadConfig(cliOverride?: string): Config {
  const path = getConfigPath(cliOverride);
  // throws ConfigNotFoundError if missing
  // throws ConfigValidationError if invalid
}
```

## Acceptance Criteria

- [ ] `loadConfig()` returns validated Config object
- [ ] `loadConfig()` throws ConfigNotFoundError if file missing
- [ ] `loadConfig()` throws ConfigValidationError with Zod errors if invalid
- [ ] Empty optional fields get default values
- [ ] projects array must have at least 1 item
- [ ] gitlab.baseUrl must be valid URL
- [ ] All number fields must be positive integers
- [ ] tests/unit/config.test.ts passes (8 tests)

## Files

CREATE:
- src/core/config.ts
- tests/unit/config.test.ts
- tests/fixtures/mock-responses/valid-config.json
- tests/fixtures/mock-responses/invalid-config.json

## TDD Loop

RED:
```typescript
// tests/unit/config.test.ts
describe('Config', () => {
  it('loads config from file path')
  it('throws ConfigNotFoundError if file missing')
  it('throws ConfigValidationError if required fields missing')
  it('validates project paths are non-empty strings')
  it('applies default values for optional fields')
  it('loads from XDG path by default')
  it('respects GI_CONFIG_PATH override')
  it('respects --config flag override')
})
```

GREEN: Implement loadConfig() function

VERIFY: `npm run test -- tests/unit/config.test.ts`

## Edge Cases

- JSON parse error should wrap in ConfigValidationError
- Zod error messages should be human-readable
- File exists but empty → ConfigValidationError
- File has extra fields → should pass (Zod strips by default)


Interactive path selection for ambiguous matches (TTY picker)

When a partial file path matches multiple files, show an interactive numbered picker in TTY mode instead of a hard error. In robot mode, return candidates as structured JSON in the error envelope. Use dialoguer crate for selection UI. The path_resolver module already detects ambiguity via SuffixResult::Ambiguous and limits to 11 candidates.


WHO: Verification gate — check, clippy, fmt, EXPLAIN QUERY PLAN

## Background

Final verification gate before the who epic is considered complete. Confirms code quality, test coverage, and index utilization against real data.

## Approach

### Step 1: Compiler checks
```bash
cargo check --all-targets
cargo clippy --all-targets -- -D warnings
cargo fmt --check
cargo test
```

### Step 2: Manual smoke test (against real DB)
```bash
cargo run --release -- who src/features/global-search/
cargo run --release -- who @asmith
cargo run --release -- who @asmith --reviews
cargo run --release -- who --active
cargo run --release -- who --active --since 30d
cargo run --release -- who --overlap libs/shared-frontend/src/features/global-search/
cargo run --release -- who --path README.md
cargo run --release -- who --path Makefile
cargo run --release -- -J who src/features/global-search/  # robot mode
cargo run --release -- -J who @asmith                       # robot mode
cargo run --release -- who src/features/global-search/ -p typescript  # project scoped
```

### Step 3: EXPLAIN QUERY PLAN verification
```bash
# Expert: should use idx_notes_diffnote_path_created
sqlite3 ~/.local/share/lore/lore.db "
  EXPLAIN QUERY PLAN
  SELECT n.author_username, COUNT(*), MAX(n.created_at)
  FROM notes n
  WHERE n.note_type = 'DiffNote' AND n.is_system = 0
    AND n.position_new_path LIKE 'src/features/global-search/%' ESCAPE '\\'
    AND n.created_at >= 0
  GROUP BY n.author_username;"

# Active global: should use idx_discussions_unresolved_recent_global
sqlite3 ~/.local/share/lore/lore.db "
  EXPLAIN QUERY PLAN
  SELECT d.id, d.last_note_at FROM discussions d
  WHERE d.resolvable = 1 AND d.resolved = 0 AND d.last_note_at >= 0
  ORDER BY d.last_note_at DESC LIMIT 20;"

# Active scoped: should use idx_discussions_unresolved_recent
sqlite3 ~/.local/share/lore/lore.db "
  EXPLAIN QUERY PLAN
  SELECT d.id, d.last_note_at FROM discussions d
  WHERE d.resolvable = 1 AND d.resolved = 0 AND d.project_id = 1
    AND d.last_note_at >= 0
  ORDER BY d.last_note_at DESC LIMIT 20;"
```

## Files

No files modified — verification only.

## TDD Loop

This bead is the TDD VERIFY phase for the entire epic. No code written.
VERIFY: All commands in Steps 1-3 must succeed. Document results.

## Acceptance Criteria

- [ ] cargo check --all-targets: 0 errors
- [ ] cargo clippy --all-targets -- -D warnings: 0 warnings
- [ ] cargo fmt --check: no formatting changes needed
- [ ] cargo test: all tests pass (including 20+ who tests)
- [ ] Expert EXPLAIN shows idx_notes_diffnote_path_created
- [ ] Active global EXPLAIN shows idx_discussions_unresolved_recent_global
- [ ] Active scoped EXPLAIN shows idx_discussions_unresolved_recent
- [ ] All 5 modes produce reasonable output against real data
- [ ] Robot mode produces valid JSON for all modes

## Edge Cases

- DB path may differ from ~/.local/share/lore/lore.db — check config with `lore -J doctor` first to get actual db_path
- EXPLAIN QUERY PLAN output format varies by SQLite version — look for the index name in any output column, not an exact string match
- If the DB has not been synced recently, smoke tests may return empty results — run `lore sync` first if needed
- Project name "typescript" in the -p flag may not exist — use an actual project from `lore -J status` output
- The real DB may not have migration 017 yet — run `cargo run --release -- migrate` first if the who command fails with a missing index error
- clippy::pedantic + clippy::nursery are enabled — common issues: arrays vs vec![] for sorted collections, too_many_arguments on test helpers (use #[allow])


OBSERV: Add tracing-appender dependency to Cargo.toml

## Background
tracing-appender provides non-blocking, daily-rotating file writes for the tracing ecosystem. It's the canonical solution used by tokio-rs projects. We need it for the file logging layer (Phase 1) that writes JSON logs to ~/.local/share/lore/logs/.

## Approach
Add tracing-appender to [dependencies] in Cargo.toml (line ~54, after the existing tracing-subscriber entry):

```toml
tracing-appender = "0.2"
```

Also add the "json" feature to tracing-subscriber since the file layer and --log-format json both need it:

```toml
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
```

Current tracing deps (Cargo.toml lines 53-54):
  tracing = "0.1"
  tracing-subscriber = { version = "0.3", features = ["env-filter"] }

## Acceptance Criteria
- [ ] cargo check --all-targets succeeds with tracing-appender available
- [ ] tracing_appender::rolling::daily() is importable
- [ ] tracing-subscriber json feature is available (fmt::layer().json() compiles)
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- Cargo.toml (modify lines 53-54 region)

## TDD Loop
RED: Not applicable (dependency addition)
GREEN: Add deps, run cargo check
VERIFY: cargo check --all-targets && cargo clippy --all-targets -- -D warnings

## Edge Cases
- Ensure tracing-appender 0.2 is compatible with tracing-subscriber 0.3 (both from tokio-rs/tracing monorepo, always compatible)
- The "json" feature on tracing-subscriber pulls in serde_json, which is already a dependency

Added tracing-appender 0.2 and json feature to tracing-subscriber


NOTE-TEST: Test bead

type: task


[CP0] Core utilities - paths, time, errors, logger

## Background

Core utilities provide the foundation for all other modules. Path resolution enables XDG-compliant config/data locations. Time utilities ensure consistent timestamp handling (ms epoch for DB, ISO for API). Error classes provide typed exceptions for clean error handling. Logger provides structured logging to stderr.

Reference: docs/prd/checkpoint-0.md sections "Config + Data Locations", "Timestamp Convention", "Error Classes", "Logging Configuration"

## Approach

**src/core/paths.ts:**
- `getConfigPath(cliOverride?)`: resolution order is CLI flag → GI_CONFIG_PATH env → XDG default → local fallback
- `getDataDir()`: uses XDG_DATA_HOME or ~/.local/share/gi
- `getDbPath(configOverride?)`: returns data dir + data.db
- `getBackupDir(configOverride?)`: returns data dir + backups/

**src/core/time.ts:**
- `isoToMs(isoString)`: converts GitLab API ISO 8601 → ms epoch
- `msToIso(ms)`: converts ms epoch → ISO 8601
- `nowMs()`: returns Date.now() for DB storage

**src/core/errors.ts:**
Error hierarchy (all extend GiError base class with code and cause):
- ConfigNotFoundError, ConfigValidationError
- GitLabAuthError, GitLabNotFoundError, GitLabRateLimitError, GitLabNetworkError
- DatabaseLockError, MigrationError
- TokenNotSetError

**src/core/logger.ts:**
- pino logger to stderr (fd 2) with pino-pretty in dev
- Child loggers: dbLogger, gitlabLogger, configLogger
- LOG_LEVEL env var support (default: info)

## Acceptance Criteria

- [ ] `getConfigPath()` returns ~/.config/gi/config.json when no overrides
- [ ] `getConfigPath()` respects GI_CONFIG_PATH env var
- [ ] `getConfigPath("./custom.json")` returns "./custom.json"
- [ ] `isoToMs("2024-01-27T00:00:00.000Z")` returns 1706313600000
- [ ] `msToIso(1706313600000)` returns "2024-01-27T00:00:00.000Z"
- [ ] All error classes have correct code property
- [ ] Logger outputs to stderr (not stdout)
- [ ] tests/unit/paths.test.ts passes
- [ ] tests/unit/errors.test.ts passes

## Files

CREATE:
- src/core/paths.ts
- src/core/time.ts
- src/core/errors.ts
- src/core/logger.ts
- tests/unit/paths.test.ts
- tests/unit/errors.test.ts

## TDD Loop

RED: Write tests first
```typescript
// tests/unit/paths.test.ts
describe('getConfigPath', () => {
  it('uses XDG_CONFIG_HOME if set')
  it('falls back to ~/.config/gi if XDG not set')
  it('prefers --config flag over environment')
  it('prefers environment over XDG default')
  it('falls back to local gi.config.json in dev')
})
```

GREEN: Implement paths.ts, errors.ts, time.ts, logger.ts

VERIFY: `npm run test -- tests/unit/paths.test.ts tests/unit/errors.test.ts`

## Edge Cases

- XDG_CONFIG_HOME may not exist - don't create, just return path
- existsSync() check for local fallback - only return if file exists
- Time conversion must handle timezone edge cases - always use UTC
- Logger must work even if pino-pretty not installed (production)


[CP1] Discussion ingestion module

## Background

Discussion ingestion fetches all discussions and notes for a single issue. It is called as part of dependent sync - only for issues whose `updated_at` has advanced beyond `discussions_synced_for_updated_at`. After successful sync, it updates the watermark to prevent redundant refetches.

## Approach

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

### Key Structs

```rust
#[derive(Debug, Default)]
pub struct IngestDiscussionsResult {
    pub discussions_fetched: usize,
    pub discussions_upserted: usize,
    pub notes_upserted: usize,
    pub system_notes_count: usize,
}
```

### Main Function

```rust
pub async fn ingest_issue_discussions(
    conn: &Connection,
    client: &GitLabClient,
    config: &Config,
    project_id: i64,           // Local DB project ID
    gitlab_project_id: i64,    // GitLab project ID
    issue_iid: i64,
    local_issue_id: i64,
    issue_updated_at: i64,     // For watermark update
) -> Result<IngestDiscussionsResult>
```

### Logic

1. Stream discussions via `client.paginate_issue_discussions()`
2. For each discussion:
   - Begin transaction
   - Store raw payload (compressed based on config)
   - Transform to NormalizedDiscussion
   - Upsert discussion
   - Get local discussion ID
   - Transform notes via `transform_notes()`
   - For each note: store raw payload, upsert note
   - Track system_notes_count
   - Commit transaction
3. After all discussions processed: `mark_discussions_synced(conn, local_issue_id, issue_updated_at)`

### Helper Functions

```rust
fn upsert_discussion(conn, discussion, payload_id) -> Result<()>
fn get_local_discussion_id(conn, project_id, gitlab_id) -> Result<i64>
fn upsert_note(conn, discussion_id, note, payload_id) -> Result<()>
fn mark_discussions_synced(conn, issue_id, issue_updated_at) -> Result<()>
```

### Critical Invariant

`discussions_synced_for_updated_at` MUST be updated only AFTER all discussions are successfully synced. This watermark prevents redundant refetches on subsequent runs.

## Acceptance Criteria

- [ ] `ingest_issue_discussions` streams all discussions for an issue
- [ ] Each discussion wrapped in transaction for atomicity
- [ ] Raw payloads stored for discussions and notes
- [ ] `discussions_synced_for_updated_at` updated after successful sync
- [ ] System notes tracked in result.system_notes_count
- [ ] Notes linked to correct discussion via local discussion ID

## Files

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

## TDD Loop

RED:
```rust
// tests/discussion_watermark_tests.rs
#[tokio::test] async fn fetches_discussions_when_updated_at_advanced()
#[tokio::test] async fn updates_watermark_after_successful_discussion_sync()
#[tokio::test] async fn does_not_update_watermark_on_discussion_sync_failure()
#[tokio::test] async fn stores_raw_payload_for_each_discussion()
#[tokio::test] async fn stores_raw_payload_for_each_note()
```

GREEN: Implement ingest_issue_discussions with watermark logic

VERIFY: `cargo test discussion_watermark`

## Edge Cases

- Issue with 0 discussions - mark synced anyway (empty is valid)
- Discussion with 0 notes - should not happen per GitLab API (discussions always have >= 1 note)
- Network failure mid-sync - watermark NOT updated, next run retries
- individual_note=true discussions - have exactly 1 note


Create migration 007_documents.sql

## Background
Migration 007 creates the document storage layer that Gate A's entire search pipeline depends on. It introduces 5 tables: `documents` (the searchable unit), `document_labels` and `document_paths` (for filtered search), and two queue tables (`dirty_sources`, `pending_discussion_fetches`) that drive incremental document regeneration and discussion fetching in Gate C. This is the most-depended-on bead in the project (6 downstream beads block on it).

## Approach
Create `migrations/007_documents.sql` with the exact SQL from PRD Section 1.1. The schema is fully specified in the PRD — no design decisions remain.

Key implementation details:
- `documents` table has `UNIQUE(source_type, source_id)` constraint for upsert support
- `document_labels` and `document_paths` use `WITHOUT ROWID` for compact storage
- `dirty_sources` uses composite PK `(source_type, source_id)` with `ON CONFLICT` upsert semantics
- `pending_discussion_fetches` uses composite PK `(project_id, noteable_type, noteable_iid)`
- Both queue tables have `next_attempt_at` indexed for efficient backoff queries
- `labels_hash` and `paths_hash` on documents enable write optimization (skip unchanged labels/paths)

Register the migration in `src/core/db.rs` by adding entry 7 to the `MIGRATIONS` array.

## Acceptance Criteria
- [ ] `migrations/007_documents.sql` file exists with all 5 CREATE TABLE statements
- [ ] Migration applies cleanly on fresh DB (`cargo test migration_tests`)
- [ ] Migration applies cleanly after CP2 schema (migrations 001-006 already applied)
- [ ] All foreign keys enforced: `documents.project_id -> projects(id)`, `document_labels.document_id -> documents(id) ON DELETE CASCADE`, `document_paths.document_id -> documents(id) ON DELETE CASCADE`, `pending_discussion_fetches.project_id -> projects(id)`
- [ ] All indexes created: `idx_documents_project_updated`, `idx_documents_author`, `idx_documents_source`, `idx_documents_hash`, `idx_document_labels_label`, `idx_document_paths_path`, `idx_dirty_sources_next_attempt`, `idx_pending_discussions_next_attempt`
- [ ] `labels_hash TEXT NOT NULL DEFAULT ''` and `paths_hash TEXT NOT NULL DEFAULT ''` columns present on `documents`
- [ ] Schema version 7 recorded in `schema_version` table
- [ ] `cargo build` succeeds after registering migration in db.rs

## Files
- `migrations/007_documents.sql` — new file (copy exact SQL from PRD Section 1.1)
- `src/core/db.rs` — add migration 7 to `MIGRATIONS` array

## TDD Loop
RED: Add migration to db.rs, run `cargo test migration_tests` — fails because SQL file missing
GREEN: Create `migrations/007_documents.sql` with full schema
VERIFY: `cargo test migration_tests && cargo build`

## Edge Cases
- Migration must be idempotent-safe if applied twice (INSERT into schema_version will fail on second run — this is expected and handled by the migration runner's version check)
- `WITHOUT ROWID` tables (document_labels, document_paths) require explicit PK — already defined
- `CHECK` constraint on `documents.source_type` must match exactly: `'issue','merge_request','discussion'`
- `CHECK` constraint on `documents.truncated_reason` allows NULL or one of 4 specific values

Completed: migration 007_documents.sql with 5 tables (documents, document_labels, document_paths, dirty_sources, pending_discussion_fetches), 8 indexes, registered in db.rs, cargo build + migration tests pass


Implement run_generate_docs_for_sources scoped doc regeneration

## Background
Surgical sync MUST NOT drain the global dirty queue. It must regenerate documents ONLY for the entities it just ingested. The existing `run_generate_docs` processes ALL dirty_sources rows and deletes them after processing. Surgical mode needs a scoped variant that:
1. Filters dirty_sources by explicit `(source_type, source_id)` keys
2. Processes only those matching rows
3. Deletes only those specific dirty_sources rows
4. Returns the IDs of regenerated documents for scoped embedding

## Approach

**File: `src/cli/commands/generate_docs.rs`** (where `run_generate_docs` lives)

Read the existing `run_generate_docs` function carefully. Create a scoped variant:

```rust
/// Scoped doc regeneration: process ONLY the specified dirty source keys.
/// Used by surgical sync to avoid draining the global dirty queue.
/// Returns regenerated document IDs for use with scoped embedding.
pub fn run_generate_docs_for_sources(
    config: &Config,
    source_keys: &[(String, i64)],
) -> Result<GenerateDocsResult>
```

### Implementation

This should be a ~20-30 line variant of `run_generate_docs` that:

1. **SELECT** from dirty_sources WHERE `(source_type, source_id)` matches the provided keys (use parameterized IN clause or iterate)
2. **Process** each dirty source using the same doc template/rendering logic as the existing function
3. **Collect** the IDs of all documents regenerated/updated
4. **DELETE** from dirty_sources WHERE `(source_type, source_id)` matches — NOT a full drain
5. Return `GenerateDocsResult` with `regenerated_document_ids` populated

### Extend GenerateDocsResult

Add a field to the existing struct:

```rust
pub struct GenerateDocsResult {
    pub regenerated: usize,
    pub unchanged: usize,
    pub errored: usize,
    pub full_mode: bool,
    // NEW:
    pub regenerated_document_ids: Vec<i64>,
}
```

Ensure the existing `run_generate_docs` populates this field too (can be empty vec for standard mode if not needed).

### Key Safety Property

The function MUST NOT delete or process dirty_sources rows that are not in the provided `source_keys`. This is the core scoping invariant that prevents surgical sync from draining work queued by a concurrent normal sync.

## Acceptance Criteria
- [ ] `run_generate_docs_for_sources(config, source_keys)` exists
- [ ] Only processes dirty_sources matching the provided keys
- [ ] Only deletes dirty_sources rows matching the provided keys
- [ ] Returns `GenerateDocsResult` with `regenerated_document_ids` populated
- [ ] Pre-existing dirty_sources rows for OTHER entities are untouched
- [ ] `GenerateDocsResult` has `regenerated_document_ids: Vec<i64>` field
- [ ] Existing `run_generate_docs` still works (field added with default empty vec)
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes
- [ ] Existing tests pass

## Files
- MODIFY: src/cli/commands/generate_docs.rs (add scoped variant + extend result struct)

## TDD Anchor
RED: Write `scoped_doc_regen_only_processes_target_keys` test:
- Setup: in-memory DB, insert 2 different dirty_sources rows (entity A and entity B)
- Call `run_generate_docs_for_sources` with ONLY entity A key
- Assert: entity A docs regenerated, entity B dirty row still exists
GREEN: Implement the scoped variant.
VERIFY: `cargo test scoped_doc_regen`

## Edge Cases
- If `source_keys` is empty, function should return early with zero work.
- The dirty_sources table uses `(source_type, source_id)` as PK with UPSERT semantics. Filter must match both columns.
- SQLite does not support `IN` on tuple pairs. Use either: (a) iterate and process one key at a time, or (b) build a CTE/temp table, or (c) use `WHERE (source_type = ?1 AND source_id = ?2) OR (source_type = ?3 AND source_id = ?4)...` with dynamic params.
- The doc generation logic may need a DB connection — check how run_generate_docs gets its connection.

## Dependency Context
No strict blockers, but should be implemented after understanding the existing `run_generate_docs` flow. Used by **run_sync_surgical** (bead bd-TODO).


Write migration 011: resource event tables, entity_references, and dependent fetch queue

## Background
Phase B needs three new event tables and a generic dependent fetch queue to power temporal queries (timeline, file-history, trace). These tables store structured event data from GitLab Resource Events APIs, replacing fragile system note parsing for state/label/milestone changes.

Migration 010_chunk_config.sql already exists, so Phase B starts at migration 011.

## Approach
Create migrations/011_resource_events.sql with the exact schema from the Phase B spec (§1.2 + §2.2):

**Event tables:**
- resource_state_events: state changes (opened/closed/reopened/merged/locked) with source_merge_request_id for "closed by MR" linking
- resource_label_events: label add/remove with label_name
- resource_milestone_events: milestone add/remove with milestone_title + milestone_id

**Cross-reference table (Gate 2):**
- entity_references: source/target entity pairs with reference_type (closes/mentioned/related), source_method provenance, and unresolved reference support (target_entity_id NULL with target_project_path + target_entity_iid)

**Dependent fetch queue:**
- pending_dependent_fetches: generic job queue with job_type IN ('resource_events', 'mr_closes_issues', 'mr_diffs'), locked_at crash recovery, exponential backoff via attempts + next_retry_at

**All tables must have:**
- CHECK constraints for entity exclusivity (issue XOR merge_request) on event tables
- UNIQUE constraints (gitlab_id + project_id for events, composite for queue, multi-column for references)
- Partial indexes (WHERE issue_id IS NOT NULL, WHERE target_entity_id IS NULL, etc.)
- CASCADE deletes on project_id and entity FKs

Register in src/core/db.rs MIGRATIONS array:
```rust
("011", include_str!("../../migrations/011_resource_events.sql")),
```

End migration with:
```sql
INSERT INTO schema_version (version, applied_at, description)
VALUES (11, strftime('%s', 'now') * 1000, 'Resource events, entity references, and dependent fetch queue');
```

## Acceptance Criteria
- [ ] migrations/011_resource_events.sql exists with all 4 tables + indexes + constraints
- [ ] src/core/db.rs MIGRATIONS array includes ("011", include_str!(...))
- [ ] `cargo build` succeeds (migration SQL compiles into binary)
- [ ] `cargo test migration` passes (migration applies cleanly on fresh DB)
- [ ] All CHECK constraints enforced (issue XOR merge_request on event tables)
- [ ] All UNIQUE constraints present (prevents duplicate events/refs/jobs)
- [ ] entity_references UNIQUE handles NULL coalescing correctly
- [ ] pending_dependent_fetches job_type CHECK includes all three types

## Files
- migrations/011_resource_events.sql (new)
- src/core/db.rs (add to MIGRATIONS array, line ~46)

## TDD Loop
RED: Add test in tests/migration_tests.rs:
- `test_migration_011_creates_event_tables` - verify all 4 tables exist after migration
- `test_migration_011_entity_exclusivity_constraint` - verify CHECK rejects both NULL and both non-NULL for issue_id/merge_request_id
- `test_migration_011_event_dedup` - verify UNIQUE(gitlab_id, project_id) rejects duplicate events
- `test_migration_011_entity_references_dedup` - verify UNIQUE constraint with NULL coalescing
- `test_migration_011_queue_dedup` - verify UNIQUE(project_id, entity_type, entity_iid, job_type)

GREEN: Write the migration SQL + register in db.rs

VERIFY: `cargo test migration_tests -- --nocapture`

## Edge Cases
- entity_references UNIQUE uses COALESCE for NULLable columns — test with both resolved and unresolved refs
- pending_dependent_fetches job_type CHECK — ensure 'mr_diffs' is included (Gate 4 needs it)
- SQLite doesn't enforce CHECK on INSERT OR REPLACE — verify constraint behavior
- The entity exclusivity CHECK must allow exactly one of issue_id/merge_request_id to be non-NULL
- Verify CASCADE deletes work (delete project → all events/refs/jobs deleted)

Already completed in prior session, re-closing after accidental reopen


Add GitLab client MR pagination methods

## Background
GitLab client pagination for merge requests and discussions. Must support robust pagination with fallback chain because some GitLab instances/proxies strip headers.

## Approach
Add to existing `src/gitlab/client.rs`:
1. `MergeRequestPage` struct - Items + pagination metadata
2. `parse_link_header_next()` - RFC 8288 Link header parsing
3. `fetch_merge_requests_page()` - Single page fetch with metadata
4. `paginate_merge_requests()` - Async stream for all MRs
5. `paginate_mr_discussions()` - Async stream for MR discussions

## Files
- `src/gitlab/client.rs` - Add pagination methods

## Acceptance Criteria
- [ ] `MergeRequestPage` struct exists with `items`, `next_page`, `is_last_page`
- [ ] `parse_link_header_next()` extracts `rel="next"` URL from Link header
- [ ] Pagination fallback chain: Link header > x-next-page > full-page heuristic
- [ ] `paginate_merge_requests()` returns `Pin<Box<dyn Stream<Item = Result<GitLabMergeRequest>>>>`
- [ ] `paginate_mr_discussions()` returns `Pin<Box<dyn Stream<Item = Result<GitLabDiscussion>>>>`
- [ ] MR endpoint uses `scope=all&state=all` to include all MRs
- [ ] `cargo test client` passes

## TDD Loop
RED: `cargo test fetch_merge_requests` -> method not found
GREEN: Add pagination methods
VERIFY: `cargo test client`

## Struct Definitions
```rust
#[derive(Debug)]
pub struct MergeRequestPage {
    pub items: Vec<GitLabMergeRequest>,
    pub next_page: Option<u32>,
    pub is_last_page: bool,
}
```

## Link Header Parsing (RFC 8288)
```rust
/// Parse Link header to extract rel="next" URL.
fn parse_link_header_next(headers: &reqwest::header::HeaderMap) -> Option<String> {
    headers
        .get("link")
        .and_then(|v| v.to_str().ok())
        .and_then(|link_str| {
            // Format: <url>; rel="next", <url>; rel="last"
            for part in link_str.split(',') {
                let part = part.trim();
                if part.contains("rel=\"next\"") || part.contains("rel=next") {
                    if let Some(start) = part.find('<') {
                        if let Some(end) = part.find('>') {
                            return Some(part[start + 1..end].to_string());
                        }
                    }
                }
            }
            None
        })
}
```

## Pagination Fallback Chain
```rust
let next_page = match (link_next, x_next_page, items.len() as u32 == per_page) {
    (Some(_), _, _) => Some(page + 1),     // Link header present: continue
    (None, Some(np), _) => Some(np),       // x-next-page present: use it
    (None, None, true) => Some(page + 1),  // Full page, no headers: try next
    (None, None, false) => None,           // Partial page: we're done
};
```

## Fetch Single Page
```rust
pub async fn fetch_merge_requests_page(
    &self,
    gitlab_project_id: i64,
    updated_after: Option<i64>,
    cursor_rewind_seconds: u32,
    page: u32,
    per_page: u32,
) -> Result<MergeRequestPage> {
    let mut params = vec![
        ("scope", "all".to_string()),
        ("state", "all".to_string()),
        ("order_by", "updated_at".to_string()),
        ("sort", "asc".to_string()),
        ("per_page", per_page.to_string()),
        ("page", page.to_string()),
    ];
    // Apply cursor rewind for safety
    // ...
}
```

## Async Stream Pattern
```rust
pub fn paginate_merge_requests(
    &self,
    gitlab_project_id: i64,
    updated_after: Option<i64>,
    cursor_rewind_seconds: u32,
) -> Pin<Box<dyn Stream<Item = Result<GitLabMergeRequest>> + Send + '_>> {
    Box::pin(async_stream::try_stream! {
        let mut page = 1u32;
        let per_page = 100u32;
        loop {
            let page_result = self.fetch_merge_requests_page(...).await?;
            for mr in page_result.items {
                yield mr;
            }
            if page_result.is_last_page {
                break;
            }
            match page_result.next_page {
                Some(np) => page = np,
                None => break,
            }
        }
    })
}
```

## Edge Cases
- `scope=all` required to include all MRs (not just authored by current user)
- `state=all` required to include merged/closed (GitLab defaults may exclude)
- `locked` state cannot be filtered server-side (use local SQL filtering)
- Cursor rewind should clamp to 0 to avoid negative timestamps


Epic: Gate 3 - Decision Timeline (lore timeline)

## Background

Gate 3 is the first user-facing temporal feature: `lore timeline <query>`. It answers "What happened with X?" by finding matching entities via FTS5, expanding cross-references, collecting all temporal events, and rendering a chronological narrative.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Gate 3 (Sections 3.1-3.6).

## Prerequisites (All Complete)

- Gates 1-2 COMPLETE: resource_state_events, resource_label_events, resource_milestone_events, entity_references all populated
- FTS5 search index (CP3): working search infrastructure for keyword matching
- Migration 015 (commit SHAs, closes watermark) exists on disk (registered by bd-1oo)

## Architecture — 5-Stage Pipeline

```
1. SEED:    FTS5 keyword search -> matched document IDs (issues, MRs, notes)
2. HYDRATE: Map document IDs -> source entities + top matched notes as evidence
3. EXPAND:  BFS over entity_references (depth-limited, edge-type filtered)
4. COLLECT: Gather events from all tables for seed + expanded entities
5. RENDER:  Sort chronologically, format as human or robot output
```

No new tables required. All reads are from existing tables at query time.

## Children (Execution Order)

1. **bd-20e** — Define TimelineEvent model and TimelineEventType enum (types first)
2. **bd-32q** — Implement timeline seed phase: FTS5 keyword search to entity IDs
3. **bd-ypa** — Implement timeline expand phase: BFS cross-reference expansion
4. **bd-3as** — Implement timeline event collection and chronological interleaving
5. **bd-1nf** — Register lore timeline command with all flags (CLI wiring)
6. **bd-2f2** — Implement timeline human output renderer
7. **bd-dty** — Implement timeline robot mode JSON output

## Gate Completion Criteria

- [ ] `lore timeline <query>` returns chronologically ordered events
- [ ] Seed entities found via FTS5 keyword search (issues, MRs, and notes)
- [ ] State, label, and milestone events interleaved from resource event tables
- [ ] Entity creation and merge events included
- [ ] Evidence-bearing notes included as note_evidence events (top FTS5 matches, bounded default 10)
- [ ] Cross-reference expansion follows entity_references to configurable depth
- [ ] Default: follows closes + related edges; --expand-mentions adds mentioned
- [ ] --depth 0 disables expansion
- [ ] --since filters by event timestamp
- [ ] -p scopes to project
- [ ] Human output is colored and readable
- [ ] Robot mode returns structured JSON with expansion provenance
- [ ] Unresolved (external) references included in JSON output

Gate 3 complete: all 7 children closed. Timeline pipeline fully implemented with SEED->HYDRATE->EXPAND->COLLECT->RENDER stages, human+robot renderers, CLI wiring with 9 flags, robot-docs manifest entry


NOTE-0D: Capture immutable author_id in note upserts

## Background
Core use case is year-scale reviewer profiling. GitLab usernames are mutable — a user can change username mid-year, fragmenting author queries. GitLab note payloads include note.author.id (immutable integer). Capturing this provides a stable identity anchor for longitudinal analysis.

## Approach
1. The author_id column and index are added by migration 022 (NOTE-1E, bd-296a). This bead only handles the ingestion code changes.

2. Populate author_id during upsert: In both upsert_note_for_issue() (from NOTE-0A) and upsert_note() in mr_discussions.rs, add author_id to INSERT column list and ON CONFLICT DO UPDATE SET clause. The value comes from the GitLab API note.author.id field.

3. Check NormalizedNote in src/gitlab/transformers.rs — if it doesn't have an author_id field yet, add it there. The GitLab REST API returns notes with: { "author": { "id": 12345, "username": "jdefting", ... } }. Extract author.id in the transformer.

4. Semantic change detection: author_id changes do NOT trigger changed_semantics = true. It's an identity anchor, not content. Do not include author_id in the pre-read comparison fields.

## Files
- MODIFY: src/ingestion/discussions.rs (add author_id to upsert_note_for_issue INSERT and ON CONFLICT SET)
- MODIFY: src/ingestion/mr_discussions.rs (add author_id to upsert_note INSERT and ON CONFLICT SET, line 470)
- MODIFY: src/gitlab/transformers.rs (add author_id: Option<i64> to NormalizedNote if missing, extract from API note.author.id)

## TDD Anchor
RED: test_issue_note_upsert_captures_author_id — insert note with author_id=12345, assert stored correctly.
GREEN: Add author_id to INSERT/UPDATE clauses and transformer.
VERIFY: cargo test captures_author_id -- --nocapture
Tests: test_mr_note_upsert_captures_author_id, test_note_upsert_author_id_nullable (old API responses without author.id), test_note_author_id_survives_username_change

## Acceptance Criteria
- [ ] author_id populated in upsert_note_for_issue INSERT and ON CONFLICT SET
- [ ] author_id populated in MR upsert_note INSERT and ON CONFLICT SET
- [ ] NormalizedNote has author_id: Option<i64> field
- [ ] Transformer extracts author.id from GitLab API note payload
- [ ] author_id = None handled gracefully (older API responses)
- [ ] author_id change does NOT trigger changed_semantics
- [ ] All 4 tests pass

## Dependency Context
- Depends on NOTE-0A (bd-3bpk): modifies upsert functions created in NOTE-0A
- Depends on NOTE-1E (bd-296a): migration 022 adds the author_id column + index to the notes table. Column must exist before ingestion code can write to it.

## Edge Cases
- Old GitLab instances: author.id may be missing from API response — use None
- Self-hosted GitLab: some versions may not include author block — handle gracefully
- author_id is nullable INTEGER — no NOT NULL constraint


Add fetchMrFileChanges config flag

## Background

Config flag controlling whether MR diff fetching is enabled, following the fetchResourceEvents pattern.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 4.2.

## Codebase Context

- src/core/config.rs has SyncConfig with fetch_resource_events: bool (serde rename 'fetchResourceEvents', default true)
- Default impl exists for SyncConfig
- CLI sync options in src/cli/mod.rs have --no-events flag pattern
- Orchestrator checks config.sync.fetch_resource_events before enqueuing resource_events jobs

## Approach

### 1. Add to SyncConfig (`src/core/config.rs`):
```rust
#[serde(rename = "fetchMrFileChanges", default = "default_true")]
pub fetch_mr_file_changes: bool,
```

Update Default impl to include fetch_mr_file_changes: true.

### 2. CLI override (`src/cli/mod.rs`):
```rust
#[arg(long = "no-file-changes")]
pub no_file_changes: bool,
```

### 3. Apply in main.rs:
```rust
if args.no_file_changes { config.sync.fetch_mr_file_changes = false; }
```

### 4. Guard in orchestrator:
```rust
if config.sync.fetch_mr_file_changes { enqueue mr_diffs jobs }
```

## Acceptance Criteria

- [ ] fetchMrFileChanges in SyncConfig, default true
- [ ] Config without field defaults to true
- [ ] --no-file-changes disables diff fetching
- [ ] Orchestrator skips mr_diffs when false
- [ ] `cargo check --all-targets` passes

## Files

- `src/core/config.rs` (add field + Default)
- `src/cli/mod.rs` (add --no-file-changes)
- `src/main.rs` (apply override)
- `src/ingestion/orchestrator.rs` (guard enqueue)

## TDD Loop

RED:
- `test_config_default_fetch_mr_file_changes` - default is true
- `test_config_deserialize_false` - JSON with false

GREEN: Add field, default, serde attribute.

VERIFY: `cargo test --lib -- config`

## Edge Cases

- Config missing fetchMrFileChanges key entirely: serde default_true fills in true
- Config explicitly set to false: no mr_diffs jobs enqueued, mr_file_changes table empty
- --no-file-changes with --full sync: overrides config, no diffs fetched even on full resync
- sync.fetchMrFileChanges = false in config + no --no-file-changes flag: respects config (no override)

Added fetch_mr_file_changes to SyncConfig (default true, serde rename fetchMrFileChanges), --no-file-changes CLI flag in SyncArgs, override in main.rs. Orchestrator guard deferred to bd-2yo which implements the actual drain.


[CP1] Discussion and note transformers

Transform GitLab discussion/note payloads to normalized database schema.

## Module
src/gitlab/transformers/discussion.rs

## Structs

### NormalizedDiscussion
- gitlab_discussion_id: String
- project_id: i64
- issue_id: i64
- noteable_type: String ("Issue")
- individual_note: bool
- first_note_at, last_note_at: Option<i64>
- last_seen_at: i64
- resolvable, resolved: bool

### NormalizedNote
- gitlab_id: i64
- project_id: i64
- note_type: Option<String>
- is_system: bool
- author_username: String
- body: String
- created_at, updated_at, last_seen_at: i64
- position: i32 (array index in notes[])
- resolvable, resolved: bool
- resolved_by: Option<String>
- resolved_at: Option<i64>

## Functions

### transform_discussion(gitlab_discussion, local_project_id, local_issue_id) -> NormalizedDiscussion
- Compute first_note_at/last_note_at from notes array min/max created_at
- Compute resolvable (any note resolvable)
- Compute resolved (resolvable AND all resolvable notes resolved)

### transform_notes(gitlab_discussion, local_project_id) -> Vec<NormalizedNote>
- Enumerate notes to get position (array index)
- Set is_system from note.system
- Convert timestamps to ms epoch

Files: src/gitlab/transformers/discussion.rs
Tests: tests/discussion_transformer_tests.rs
Done when: Unit tests pass for discussion/note transformation with system note flagging


[CP1] gi show issue command

Show issue details with discussions.

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

## Clap Definition
Show {
    #[arg(value_parser = ["issue", "mr"])]
    entity: String,

    iid: i64,

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

## Output Format
Issue #1234: Authentication redesign
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

Project:  group/project-one
State:    opened
Author:   @johndoe
Created:  2024-01-15
Updated:  2024-03-20
Labels:   enhancement, auth
URL:      https://gitlab.example.com/group/project-one/-/issues/1234

Description:
  We need to redesign the authentication flow to support...

Discussions (5):

  @janedoe (2024-01-16):
    I agree we should move to JWT-based auth...

    @johndoe (2024-01-16):
      What about refresh token strategy?

  @bobsmith (2024-01-17):
    Have we considered OAuth2?

## Ambiguity Handling
If multiple projects have same iid, either:
- Prompt for --project flag
- Show error listing which projects have that iid

Files: src/cli/commands/show.rs
Done when: Issue detail view displays all fields including threaded discussions


Extract orchestrator per-entity logic and implement inline dependent helpers

## Background
Surgical sync runs dependent stages (discussions, resource events, MR closes_issues, MR diffs) INLINE per-entity, bypassing `pending_dependent_fetches` entirely. The existing orchestrator has drain loops that process queued jobs — each loop body contains per-entity fetch+write logic. This bead extracts that per-entity logic into standalone functions callable by both the orchestrator drain loops and surgical inline helpers.

## Approach

**File: `src/ingestion/orchestrator.rs`** and/or **`src/ingestion/surgical.rs`**

### Strategy

Read `src/ingestion/orchestrator.rs` carefully. Find the drain loop bodies for:
1. Resource events for issues
2. Resource events for MRs
3. MR closes_issues
4. MR diffs

**Option A (preferred):** If the per-entity logic in each drain loop body is separable from queue management (locked_at, attempts, delete-on-complete), extract it into a standalone function. The drain loop then calls the extracted function, and surgical helpers call it directly. Zero code duplication.

**Option B:** If tightly coupled to queue state, write standalone versions for surgical that call the same underlying GitLab API functions and DB write functions, bypassing queue management.

### Inline dependent helpers (in surgical.rs or surgical_dependents.rs)

```rust
/// Fetch and write resource events for a single issue (no queue).
pub(crate) async fn sync_issue_resource_events_direct(
    conn: &Connection, client: &GitLabClient, config: &Config,
    gitlab_project_id: i64, project_id: i64,
    issue_info: &IssueForDiscussionSync,
) -> Result<usize>

/// Fetch and write resource events for a single MR (no queue).
pub(crate) async fn sync_mr_resource_events_direct(
    conn: &Connection, client: &GitLabClient, config: &Config,
    gitlab_project_id: i64, project_id: i64,
    mr_info: &MrForDiscussionSync,
) -> Result<usize>

/// Fetch and write closes_issues for a single MR (no queue).
pub(crate) async fn sync_mr_closes_issues_direct(
    conn: &Connection, client: &GitLabClient, config: &Config,
    gitlab_project_id: i64, project_id: i64,
    mr_info: &MrForDiscussionSync,
) -> Result<()>

/// Fetch and write diffs for a single MR (no queue).
pub(crate) async fn sync_mr_diffs_direct(
    conn: &Connection, client: &GitLabClient, config: &Config,
    gitlab_project_id: i64, project_id: i64,
    mr_info: &MrForDiscussionSync,
) -> Result<()>
```

Each function:
1. Calls the GitLab API for the single entity
2. Writes results to DB
3. Returns count/result
4. Does NOT touch `pending_dependent_fetches` table

### Existing discussion sync

Issue discussions already have `ingest_issue_discussions` that takes a slice of `IssueForDiscussionSync`. MR discussions use a two-step process: `prefetch_mr_discussions` (network) then `write_prefetched_mr_discussions` (DB). These are already available and usable directly by surgical mode — no extraction needed.

## Acceptance Criteria
- [ ] `sync_issue_resource_events_direct` exists and fetches+writes resource events for one issue
- [ ] `sync_mr_resource_events_direct` exists for one MR
- [ ] `sync_mr_closes_issues_direct` exists for one MR
- [ ] `sync_mr_diffs_direct` exists for one MR
- [ ] None of these functions touch `pending_dependent_fetches`
- [ ] Existing orchestrator drain loops still work (if Option A, they call the extracted functions)
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes
- [ ] Existing tests pass

## Files
- MODIFY: src/ingestion/orchestrator.rs (extract per-entity logic if Option A)
- MODIFY: src/ingestion/surgical.rs (add inline dependent helpers)

## TDD Anchor
RED: Write `sync_issue_resource_events_direct_fetches_events` test using wiremock — mock the resource events API endpoint, call the function, verify events are written to DB.
GREEN: Implement the function.
VERIFY: `cargo test sync_issue_resource_events_direct`

## Edge Cases
- Resource events use three separate tables: `resource_state_events`, `resource_label_events`, `resource_milestone_events`. The helper must handle all three.
- `pending_dependent_fetches` has a UNIQUE constraint — verify the inline helpers never INSERT into this table.
- MR diffs may have large payloads. The per-entity timeout wrapping happens in the caller (run_sync_surgical), not in these helpers.
- Config flags `config.sync.fetch_resource_events`, `config.sync.fetch_mr_closes_issues`, `config.sync.fetch_mr_diffs` control whether each dependent type runs. These checks happen in the caller, not here.

## Dependency Context
Depends on **bd-3sez** (surgical.rs module): the helpers are added to or called from surgical.rs.
Understanding of orchestrator.rs drain loops is required — read the file before implementing.


Rewrite agent skills to mandate lore for all reads

## Background
Agent skills and AGENTS.md files currently allow agents to choose between glab and lore for read operations. Agents default to glab (familiar from training data) even though lore returns richer data. Need a clean, enforced boundary: lore=reads, glab=writes.

## Approach
1. Audit all config files for glab read patterns
2. Replace each with lore equivalent
3. Add explicit Read/Write Split section to AGENTS.md and CLAUDE.md

## Translation Table
| glab (remove)                      | lore (replace with)              |
|------------------------------------|----------------------------------|
| glab issue view N                  | lore -J issues N                 |
| glab issue list                    | lore -J issues -n 50             |
| glab issue list -l bug             | lore -J issues --label bug       |
| glab mr view N                     | lore -J mrs N                    |
| glab mr list                       | lore -J mrs                      |
| glab mr list -s opened             | lore -J mrs -s opened            |
| glab api '/projects/:id/issues'    | lore -J issues -p project        |

## Files to Audit

### Project-level
- /Users/tayloreernisse/projects/gitlore/AGENTS.md — primary project instructions

### Global Claude config
- ~/.claude/CLAUDE.md — global instructions (already has lore section, verify no glab reads)

### Skills directory
Scan all .md files under ~/.claude/skills/ for glab read patterns.
Likely candidates: any skill that references GitLab data retrieval.

### Rules directory
Scan all .md files under ~/.claude/rules/ for glab read patterns.

### Work-ghost templates
- ~/projects/work-ghost/tasks/*.md — task templates that reference glab reads

## Verification Commands
After all changes:
```bash
# Should return ZERO matches (no glab read commands remain)
rg 'glab issue view|glab issue list|glab mr view|glab mr list|glab api.*issues|glab api.*merge_requests' ~/.claude/ AGENTS.md --type md

# These should REMAIN (write operations stay with glab)
rg 'glab (issue|mr) (create|update|close|delete|approve|merge|note|rebase)' ~/.claude/ AGENTS.md --type md
```

## Read/Write Split Section to Add
Add to AGENTS.md and ~/.claude/CLAUDE.md:
```markdown
## Read/Write Split: lore vs glab

| Operation | Tool | Why |
|-----------|------|-----|
| List issues/MRs | lore | Richer: includes status, discussions, closing MRs |
| View issue/MR detail | lore | Pre-joined discussions, work-item status |
| Search across entities | lore | FTS5 + vector hybrid search |
| Expert/workload analysis | lore | who command — no glab equivalent |
| Timeline reconstruction | lore | Chronological narrative — no glab equivalent |
| Create/update/close | glab | Write operations |
| Approve/merge MR | glab | Write operations |
| CI/CD pipelines | glab | Not in lore scope |
```

## TDD Loop
This is a config-only task — no Rust code changes. Verification is via grep:

RED: Run verification commands above, expect matches (glab reads still present)
GREEN: Replace all glab read references with lore equivalents
VERIFY: Run verification commands, expect zero glab read matches

## Acceptance Criteria
- [ ] Zero glab read references in AGENTS.md
- [ ] Zero glab read references in ~/.claude/CLAUDE.md
- [ ] Zero glab read references in ~/.claude/skills/**/*.md
- [ ] Zero glab read references in ~/.claude/rules/**/*.md
- [ ] glab write references preserved (create, update, close, approve, merge, CI)
- [ ] Read/Write Split section added to AGENTS.md
- [ ] Read/Write Split section added to ~/.claude/CLAUDE.md
- [ ] Fresh agent session uses lore for reads without prompting (manual verification)

## Edge Cases
- Skills that use glab api for data NOT in lore (e.g., CI pipeline data, project settings) — these should remain
- glab MCP server references — evaluate case-by-case (keep for write operations)
- Shell aliases or env vars that invoke glab for reads — out of scope unless in config files
- Skills that use `glab issue list | jq` for ad-hoc queries — replace with `lore -J issues | jq`
- References to glab in documentation context (explaining what tools exist) vs operational context (telling agent to use glab) — only replace operational references

Agent skills rewritten: AGENTS.md and CLAUDE.md updated with read/write split mandating lore for reads, glab for writes


Epic: CP2 Gate E - CLI Complete

## Background
Gate E validates all CLI commands are functional and user-friendly. This is the final usability gate - even if all data is correct, users need good CLI UX to access it.

## Acceptance Criteria (Pass/Fail)

### List Command
- [ ] `gi list mrs` shows MR table with columns: iid, title, state, author, branches, updated
- [ ] `gi list mrs --state=opened` filters to only opened MRs
- [ ] `gi list mrs --state=merged` filters to only merged MRs
- [ ] `gi list mrs --state=closed` filters to only closed MRs
- [ ] `gi list mrs --state=locked` filters locally (not server-side filter)
- [ ] `gi list mrs --draft` shows only draft MRs
- [ ] `gi list mrs --no-draft` excludes draft MRs
- [ ] Draft MRs show `[DRAFT]` prefix in title column
- [ ] `gi list mrs --author=username` filters by author
- [ ] `gi list mrs --assignee=username` filters by assignee
- [ ] `gi list mrs --reviewer=username` filters by reviewer
- [ ] `gi list mrs --target-branch=main` filters by target branch
- [ ] `gi list mrs --source-branch=feature/x` filters by source branch
- [ ] `gi list mrs --label=bugfix` filters by label
- [ ] `gi list mrs --limit=N` limits output

### Show Command
- [ ] `gi show mr <iid>` displays full MR detail
- [ ] Show includes: title, description, state, draft status, author
- [ ] Show includes: assignees, reviewers, labels
- [ ] Show includes: source_branch, target_branch
- [ ] Show includes: detailed_merge_status (e.g., "mergeable")
- [ ] Show includes: merge_user and merged_at for merged MRs
- [ ] Show includes: discussions with author and date
- [ ] DiffNote shows file context: `[src/file.ts:45]`
- [ ] Multi-line DiffNote shows range: `[src/file.ts:45-48]`
- [ ] Resolved discussions show `[RESOLVED]` marker

### Count Command
- [ ] `gi count mrs` shows total count
- [ ] Count shows state breakdown: opened, merged, closed

### Sync Status
- [ ] `gi sync-status` shows MR cursor position
- [ ] Sync status shows last sync timestamp

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

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

echo "=== Gate E: CLI Complete ==="

# 1. Test list command (basic)
echo "Step 1: Basic list..."
gi list mrs --limit=5 || { echo "FAIL: list mrs failed"; exit 1; }

# 2. Test state filters
echo "Step 2: State filters..."
for state in opened merged closed; do
  echo "  Testing --state=$state"
  gi list mrs --state=$state --limit=3 || echo "  Warning: No $state MRs"
done

# 3. Test draft filters
echo "Step 3: Draft filters..."
gi list mrs --draft --limit=3 || echo "  Note: No draft MRs found"
gi list mrs --no-draft --limit=3 || echo "  Note: All MRs are drafts?"

# 4. Check [DRAFT] prefix
echo "Step 4: Check [DRAFT] prefix..."
DRAFT_IID=$(sqlite3 "$DB_PATH" "SELECT iid FROM merge_requests WHERE draft = 1 LIMIT 1;")
if [ -n "$DRAFT_IID" ]; then
  if gi list mrs --limit=100 | grep -q "\[DRAFT\]"; then
    echo "  PASS: [DRAFT] prefix found"
  else
    echo "  FAIL: Draft MR exists but no [DRAFT] prefix in output"
  fi
else
  echo "  Skip: No draft MRs to test"
fi

# 5. Test author/assignee/reviewer filters
echo "Step 5: User filters..."
AUTHOR=$(sqlite3 "$DB_PATH" "SELECT author_username FROM merge_requests LIMIT 1;")
if [ -n "$AUTHOR" ]; then
  echo "  Testing --author=$AUTHOR"
  gi list mrs --author="$AUTHOR" --limit=3
fi

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

# 6. Test branch filters
echo "Step 6: Branch filters..."
TARGET=$(sqlite3 "$DB_PATH" "SELECT target_branch FROM merge_requests LIMIT 1;")
if [ -n "$TARGET" ]; then
  echo "  Testing --target-branch=$TARGET"
  gi list mrs --target-branch="$TARGET" --limit=3
fi

# 7. Test show command
echo "Step 7: Show command..."
MR_IID=$(sqlite3 "$DB_PATH" "SELECT iid FROM merge_requests LIMIT 1;")
gi show mr "$MR_IID" || { echo "FAIL: show mr failed"; exit 1; }

# 8. Test show with DiffNote context
echo "Step 8: Show with DiffNote..."
DIFFNOTE_MR=$(sqlite3 "$DB_PATH" "
  SELECT DISTINCT m.iid
  FROM merge_requests m
  JOIN discussions d ON d.merge_request_id = m.id
  JOIN notes n ON n.discussion_id = d.id
  WHERE n.position_new_path IS NOT NULL
  LIMIT 1;
")
if [ -n "$DIFFNOTE_MR" ]; then
  echo "  Testing MR with DiffNotes: !$DIFFNOTE_MR"
  OUTPUT=$(gi show mr "$DIFFNOTE_MR")
  if echo "$OUTPUT" | grep -qE '\[[^]]+:[0-9]+\]'; then
    echo "  PASS: File context [path:line] found"
  else
    echo "  FAIL: DiffNote should show [path:line] context"
  fi
else
  echo "  Skip: No MRs with DiffNotes"
fi

# 9. Test count command
echo "Step 9: Count command..."
gi count mrs || { echo "FAIL: count mrs failed"; exit 1; }

# 10. Test sync-status
echo "Step 10: Sync status..."
gi sync-status || echo "  Note: sync-status may need implementation"

echo ""
echo "=== Gate E: PASSED ==="
```

## Test Commands (Quick Verification)
```bash
# List with all column types visible:
gi list mrs --limit=10

# Show a specific MR:
gi show mr 42

# Count with breakdown:
gi count mrs

# Complex filter:
gi list mrs --state=opened --reviewer=alice --target-branch=main --limit=5
```

## Expected Output Formats

### gi list mrs
```
Merge Requests (showing 5 of 1,234)

  !847  Refactor auth to use JWT tokens           merged   @johndoe   main <- feature/jwt   3d ago
  !846  Fix memory leak in websocket handler      opened   @janedoe   main <- fix/websocket 5d ago
  !845  [DRAFT] Add dark mode CSS variables       opened   @bobsmith  main <- ui/dark-mode  1w ago
  !844  Update dependencies to latest versions    closed   @alice     main <- chore/deps    2w ago
```

### gi show mr 847
```
Merge Request !847: Refactor auth to use JWT tokens
================================================================================

Project:        group/project-one
State:          merged
Draft:          No
Author:         @johndoe
Assignees:      @janedoe, @bobsmith
Reviewers:      @alice, @charlie
Labels:         enhancement, auth, reviewed
Source:         feature/jwt
Target:         main
Merge Status:   merged
Merged By:      @alice
Merged At:      2024-03-20 14:30:00

Description:
  Moving away from session cookies to JWT-based authentication...

Discussions (3):

  @janedoe (2024-03-16) [src/auth/jwt.ts:45]:
    Should we use a separate signing key for refresh tokens?

    @johndoe (2024-03-16):
      Good point. I'll add a separate key with rotation support.

  @alice (2024-03-18) [RESOLVED]:
    Looks good! Just one nit about the token expiry constant.
```

### gi count mrs
```
Merge Requests: 1,234
  opened: 89
  merged: 1,045
  closed: 100
```

## Dependencies
This gate requires:
- bd-3js (CLI commands implementation)
- All previous gates must pass first

## Edge Cases
- Ambiguous MR iid across projects: should prompt for `--project` or show error
- Very long titles: should truncate with `...` in list view
- Empty description: should show "No description" or empty section
- No discussions: should show "No discussions" message
- Unicode in titles/descriptions: should render correctly


Add embedding error variants to LoreError

## Background
Gate B introduces Ollama-dependent operations that need distinct error variants for clear diagnostics. Each error has a unique exit code, a descriptive message, and an actionable suggestion. These errors must integrate with the existing LoreError enum pattern (renamed from GiError in bd-3lc).

## Approach
Extend `src/core/error.rs` with 4 new variants per PRD Section 4.3.

**ErrorCode additions:**
```rust
pub enum ErrorCode {
    // ... existing (InternalError=1 through TransformError=13)
    OllamaUnavailable,      // exit code 14
    OllamaModelNotFound,    // exit code 15
    EmbeddingFailed,        // exit code 16
}
```

**LoreError additions:**
```rust
/// Ollama-specific connection failure. Use instead of Http for Ollama errors
/// because it includes base_url for actionable error messages.
#[error("Cannot connect to Ollama at {base_url}. Is it running?")]
OllamaUnavailable {
    base_url: String,
    #[source]
    source: Option<reqwest::Error>,
},

#[error("Ollama model '{model}' not found. Run: ollama pull {model}")]
OllamaModelNotFound { model: String },

#[error("Embedding failed for document {document_id}: {reason}")]
EmbeddingFailed { document_id: i64, reason: String },

#[error("No embeddings found. Run: lore embed")]
EmbeddingsNotBuilt,
```

**code() mapping:**
- OllamaUnavailable => ErrorCode::OllamaUnavailable
- OllamaModelNotFound => ErrorCode::OllamaModelNotFound
- EmbeddingFailed => ErrorCode::EmbeddingFailed
- EmbeddingsNotBuilt => ErrorCode::EmbeddingFailed (shares exit code 16)

**suggestion() mapping:**
- OllamaUnavailable => "Start Ollama: ollama serve"
- OllamaModelNotFound => "Pull the model: ollama pull nomic-embed-text"
- EmbeddingFailed => "Check Ollama logs or retry with 'lore embed --retry-failed'"
- EmbeddingsNotBuilt => "Generate embeddings first: lore embed"

## Acceptance Criteria
- [ ] All 4 error variants compile
- [ ] Exit codes: OllamaUnavailable=14, OllamaModelNotFound=15, EmbeddingFailed=16
- [ ] EmbeddingsNotBuilt shares exit code 16 (mapped to ErrorCode::EmbeddingFailed)
- [ ] OllamaUnavailable has `base_url: String` and `source: Option<reqwest::Error>`
- [ ] EmbeddingFailed has `document_id: i64` and `reason: String`
- [ ] Each variant has actionable .suggestion() text per PRD
- [ ] ErrorCode Display: OLLAMA_UNAVAILABLE, OLLAMA_MODEL_NOT_FOUND, EMBEDDING_FAILED
- [ ] Robot mode JSON includes code + suggestion for each variant
- [ ] `cargo build` succeeds

## Files
- `src/core/error.rs` — extend LoreError enum + ErrorCode enum + impl blocks

## TDD Loop
RED: Add variants, `cargo build` fails on missing match arms
GREEN: Add match arms in code(), exit_code(), suggestion(), to_robot_error(), Display
VERIFY: `cargo build && cargo test error`

## Edge Cases
- OllamaUnavailable with source=None: still valid (used when no HTTP error available)
- EmbeddingFailed with document_id=0: used for batch-level failures (not per-doc)
- EmbeddingsNotBuilt vs OllamaUnavailable: former means "never ran embed", latter means "Ollama down right now"

Completed: Added 4 LoreError variants (OllamaUnavailable, OllamaModelNotFound, EmbeddingFailed, EmbeddingsNotBuilt) and 3 ErrorCode variants with exit codes 14-16. cargo build succeeds.


Epic: Gate B - Hybrid MVP

## Background
Gate B adds semantic search capabilities via Ollama embeddings and sqlite-vec vector storage. It builds on Gate A's document layer, adding the embedding pipeline, vector search, RRF-based hybrid ranking, and graceful degradation when Ollama is unavailable. Gate B is independently shippable on top of Gate A.

## Gate B Deliverables
1. Ollama-powered embedding pipeline with sqlite-vec storage
2. Hybrid search (RRF-ranked vector + lexical) with rich filtering + graceful degradation

## Bead Dependencies (execution order, after Gate A)
1. **bd-mem** — Shared backoff utility (no deps)
2. **bd-1y8** — Chunk ID encoding (no deps)
3. **bd-3ez** — RRF ranking (no deps)
4. **bd-ljf** — Embedding error variants (blocked by bd-3lc)
5. **bd-2ac** — Migration 009 embeddings (blocked by bd-hrs)
6. **bd-335** — Ollama API client (blocked by bd-ljf)
7. **bd-am7** — Embedding pipeline (blocked by bd-335, bd-2ac, bd-1y8)
8. **bd-bjo** — Vector search (blocked by bd-2ac, bd-1y8)
9. **bd-2sx** — Embed CLI (blocked by bd-am7)
10. **bd-3eu** — Hybrid search (blocked by bd-3ez, bd-bjo, bd-1k1, bd-3q2)

## Acceptance Criteria
- [ ] `lore embed` builds embeddings for all documents via Ollama
- [ ] `lore embed --retry-failed` re-attempts failed embeddings
- [ ] `lore search --mode=hybrid "query"` uses both FTS + vector
- [ ] `lore search --mode=semantic "query"` uses vector only
- [ ] Graceful degradation: Ollama down -> FTS fallback with warning
- [ ] `lore search --explain` shows vector_rank, fts_rank, rrf_score
- [ ] sqlite-vec loaded before migration 009

All Gate B sub-beads complete: backoff, chunk IDs, RRF, error variants, migration 009, Ollama client, embedding pipeline, vector search, embed CLI, hybrid search


WHO: Active mode query (query_active)

## Background

Active mode answers "What discussions are actively in progress?" by finding unresolved resolvable discussions with recent activity. This is the most complex query due to the CTE structure and the dual SQL variant requirement.

## Approach

### Two static SQL variants (CRITICAL — not nullable-OR):
Active mode uses separate global vs project-scoped SQL strings because:
- With (?N IS NULL OR d.project_id = ?N), SQLite can't commit to either index at prepare time
- Global queries need idx_discussions_unresolved_recent_global (single-column last_note_at)
- Scoped queries need idx_discussions_unresolved_recent (project_id, last_note_at)
- Selected at runtime: `match project_id { None => sql_global, Some(pid) => sql_scoped }`

### CTE structure (4 stages):
```sql
WITH picked AS (
    -- Stage 1: Select limited discussions using the right index
    SELECT d.id, d.noteable_type, d.issue_id, d.merge_request_id,
           d.project_id, d.last_note_at
    FROM discussions d
    WHERE d.resolvable = 1 AND d.resolved = 0
      AND d.last_note_at >= ?1
    ORDER BY d.last_note_at DESC LIMIT ?2
),
note_counts AS (
    -- Stage 2: Count all non-system notes per discussion (ACTUAL note count)
    SELECT n.discussion_id, COUNT(*) AS note_count
    FROM notes n JOIN picked p ON p.id = n.discussion_id
    WHERE n.is_system = 0
    GROUP BY n.discussion_id
),
participants AS (
    -- Stage 3: Distinct usernames per discussion, then GROUP_CONCAT
    SELECT x.discussion_id, GROUP_CONCAT(x.author_username, X'1F') AS participants
    FROM (
        SELECT DISTINCT n.discussion_id, n.author_username
        FROM notes n JOIN picked p ON p.id = n.discussion_id
        WHERE n.is_system = 0 AND n.author_username IS NOT NULL
    ) x
    GROUP BY x.discussion_id
)
-- Stage 4: Join everything
SELECT p.id, p.noteable_type, COALESCE(i.iid, m.iid), COALESCE(i.title, m.title),
       proj.path_with_namespace, p.last_note_at,
       COALESCE(nc.note_count, 0), COALESCE(pa.participants, '')
FROM picked p
JOIN projects proj ON p.project_id = proj.id
LEFT JOIN issues i ON p.issue_id = i.id
LEFT JOIN merge_requests m ON p.merge_request_id = m.id
LEFT JOIN note_counts nc ON nc.discussion_id = p.id
LEFT JOIN participants pa ON pa.discussion_id = p.id
ORDER BY p.last_note_at DESC
```

### CRITICAL BUG PREVENTION: note_counts and participants MUST be separate CTEs.
A single CTE with `SELECT DISTINCT discussion_id, author_username` then `COUNT(*)` produces a PARTICIPANT count, not a NOTE count. A discussion with 5 notes from 2 people would show note_count: 2 instead of 5.

### Participants post-processing in Rust:
```rust
let mut participants: Vec<String> = csv.split('\x1F').map(String::from).collect();
participants.sort();  // deterministic — GROUP_CONCAT order is undefined
const MAX_PARTICIPANTS: usize = 50;
let participants_total = participants.len() as u32;
let participants_truncated = participants.len() > MAX_PARTICIPANTS;
```

### Total count also uses two variants (global/scoped), same match pattern.

### Unit separator X'1F' for GROUP_CONCAT (not comma — usernames could theoretically contain commas)

## Files

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

## TDD Loop

RED:
```
test_active_query — insert discussion + 2 notes by same user; verify:
  - total_unresolved_in_window = 1
  - discussions.len() = 1
  - participants = ["reviewer_b"]
  - note_count = 2 (NOT 1 — this was a real regression in iteration 4)
  - discussion_id > 0
test_active_participants_sorted — insert notes by zebra_user then alpha_user; verify sorted ["alpha_user", "zebra_user"]
```

GREEN: Implement query_active with both SQL variants and the shared map_row closure
VERIFY: `cargo test -- active`

## Acceptance Criteria

- [ ] test_active_query passes with note_count = 2 (not participant count)
- [ ] test_active_participants_sorted passes (alphabetical order)
- [ ] discussion_id included in output (stable entity ID for agents)
- [ ] Default since window: 7d
- [ ] Bounded participants: cap 50, with total + truncated metadata

## Edge Cases

- note_count vs participant_count: MUST be separate CTEs (see bug prevention above)
- GROUP_CONCAT order is undefined — sort participants in Rust after parsing
- SQLite doesn't support GROUP_CONCAT(DISTINCT col, separator) — use subquery with SELECT DISTINCT then GROUP_CONCAT
- Two SQL variants: prepare exactly ONE statement per invocation (don't prepare both)
- entity_type mapping: "MergeRequest" -> "MR", else "Issue"


Implement shared backoff utility

## Background
Both `dirty_sources` and `pending_discussion_fetches` tables use exponential backoff with `next_attempt_at` timestamps. Without a shared utility, each module would duplicate the backoff curve logic, risking drift. The shared backoff module ensures consistent retry behavior across all queue consumers in Gate C.

## Approach
Create `src/core/backoff.rs` per PRD Section 6.X.

**IMPORTANT — PRD-exact signature and implementation:**
```rust
use rand::Rng;

/// Compute next_attempt_at with exponential backoff and jitter.
///
/// Formula: now + min(3600000, 1000 * 2^attempt_count) * (0.9 to 1.1)
/// - Capped at 1 hour to prevent runaway delays
/// - ±10% jitter prevents synchronized retries after outages
///
/// Used by:
/// - `dirty_sources` retry scheduling (document regeneration failures)
/// - `pending_discussion_fetches` retry scheduling (API fetch failures)
///
/// Having one implementation prevents subtle divergence between queues
/// (e.g., different caps or jitter ranges).
pub fn compute_next_attempt_at(now: i64, attempt_count: i64) -> i64 {
    // Cap attempt_count to prevent overflow (2^30 > 1 hour anyway)
    let capped_attempts = attempt_count.min(30) as u32;
    let base_delay_ms = 1000_i64.saturating_mul(1 << capped_attempts);
    let capped_delay_ms = base_delay_ms.min(3_600_000); // 1 hour cap

    // Add ±10% jitter
    let jitter_factor = rand::thread_rng().gen_range(0.9..=1.1);
    let delay_with_jitter = (capped_delay_ms as f64 * jitter_factor) as i64;

    now + delay_with_jitter
}
```

**Key PRD details (must match exactly):**
- `attempt_count` parameter is `i64` (not `u32`) — matches SQLite integer type from DB columns
- Overflow prevention: `.min(30) as u32` caps before shift
- Base delay: `1000_i64.saturating_mul(1 << capped_attempts)` — uses `saturating_mul` for safety
- Cap: `3_600_000` (1 hour)
- Jitter: `gen_range(0.9..=1.1)` — inclusive range
- Return: `i64` (milliseconds epoch)

**Cargo.toml change:** Add `rand = "0.8"` to `[dependencies]`.

## Acceptance Criteria
- [ ] Single shared implementation used by both dirty_tracker and discussion_queue
- [ ] Signature: `pub fn compute_next_attempt_at(now: i64, attempt_count: i64) -> i64`
- [ ] attempt_count is i64 (matches SQLite column type), not u32
- [ ] Overflow prevention: `.min(30) as u32` before shift
- [ ] Base delay uses `1000_i64.saturating_mul(1 << capped_attempts)`
- [ ] Cap at 1 hour (3,600,000 ms)
- [ ] Jitter: `gen_range(0.9..=1.1)` inclusive range
- [ ] Exponential curve: 1s, 2s, 4s, 8s, ... up to 1h cap
- [ ] `cargo test backoff` passes

## Files
- `src/core/backoff.rs` — new file
- `src/core/mod.rs` — add `pub mod backoff;`
- `Cargo.toml` — add `rand = "0.8"`

## TDD Loop
RED: `src/core/backoff.rs` with `#[cfg(test)] mod tests`:
- `test_exponential_curve` — verify delays double each attempt (within jitter range)
- `test_cap_at_one_hour` — attempt 20+ still produces delay <= MAX_DELAY_MS * 1.1
- `test_jitter_range` — run 100 iterations, all delays within [0.9x, 1.1x] of base
- `test_first_retry_is_about_one_second` — attempt 1 produces ~1000ms delay
- `test_overflow_safety` — very large attempt_count doesn't panic
GREEN: Implement compute_next_attempt_at()
VERIFY: `cargo test backoff`

## Edge Cases
- `attempt_count` > 30: `.min(30)` caps, saturating_mul prevents overflow
- `attempt_count` = 0: not used in practice (callers pass `attempt_count + 1`)
- `attempt_count` = 1: delay is ~1 second (first retry)
- Negative attempt_count: `.min(30)` still works, shift of negative-as-u32 wraps but saturating_mul handles it

Completed: compute_next_attempt_at with exp backoff (1s base, 1h cap, +-10% jitter), i64 params matching SQLite, overflow-safe, 5 tests pass


Update ingest command for merge_requests type

## Background
CLI entry point for MR ingestion. Routes `--type=merge_requests` to the orchestrator. Must ensure `--full` resets both MR cursor AND discussion watermarks. This is the user-facing command that kicks off the entire MR sync pipeline.

## Approach
Update `src/cli/commands/ingest.rs` to handle `merge_requests` type:
1. Add `merge_requests` branch to the resource type match statement
2. Validate resource type early with helpful error message
3. Pass `full` flag through to orchestrator (it handles the watermark reset internally)

## Files
- `src/cli/commands/ingest.rs` - Add merge_requests branch to `run_ingest`

## Acceptance Criteria
- [ ] `gi ingest --type=merge_requests` runs MR ingestion successfully
- [ ] `gi ingest --type=merge_requests --full` resets cursor AND discussion watermarks
- [ ] `gi ingest --type=invalid` returns helpful error listing valid types
- [ ] Progress output shows MR counts, discussion counts, and skip counts
- [ ] Default type remains `issues` for backward compatibility
- [ ] `cargo test ingest_command` passes

## TDD Loop
RED: `gi ingest --type=merge_requests` -> "invalid type: merge_requests"
GREEN: Add merge_requests to match statement in run_ingest
VERIFY: `gi ingest --type=merge_requests --help` shows merge_requests as valid

## Function Signature
```rust
pub async fn run_ingest(
    config: &Config,
    args: &IngestArgs,
) -> Result<(), GiError>
```

## IngestArgs Reference (existing)
```rust
#[derive(Parser, Debug)]
pub struct IngestArgs {
    /// Resource type to ingest
    #[arg(long, short = 't', default_value = "issues")]
    pub r#type: String,

    /// Filter to specific project (by path or ID)
    #[arg(long, short = 'p')]
    pub project: Option<String>,

    /// Force run even if another ingest is in progress
    #[arg(long, short = 'f')]
    pub force: bool,

    /// Full sync - reset cursor and refetch all
    #[arg(long)]
    pub full: bool,
}
```

## Code Change
```rust
use crate::core::errors::GiError;
use crate::ingestion::orchestrator::Orchestrator;

pub async fn run_ingest(
    config: &Config,
    args: &IngestArgs,
) -> Result<(), GiError> {
    let resource_type = args.r#type.as_str();

    // Validate resource type early
    match resource_type {
        "issues" | "merge_requests" => {}
        _ => {
            return Err(GiError::InvalidArgument {
                name: "type".to_string(),
                value: resource_type.to_string(),
                expected: "issues or merge_requests".to_string(),
            });
        }
    }

    // Acquire single-flight lock (unless --force)
    if !args.force {
        acquire_ingest_lock(config, resource_type)?;
    }

    // Get projects to ingest (filtered if --project specified)
    let projects = get_projects_to_ingest(config, args.project.as_deref())?;

    for project in projects {
        println!("Ingesting {} for {}...", resource_type, project.path);

        let orchestrator = Orchestrator::new(
            &config,
            project.id,
            project.gitlab_id,
        )?;

        let result = orchestrator.run_ingestion(resource_type, args.full).await?;

        // Print results based on resource type
        match resource_type {
            "issues" => {
                println!("  {}: {} issues fetched, {} upserted",
                    project.path, result.issues_fetched, result.issues_upserted);
            }
            "merge_requests" => {
                println!("  {}: {} MRs fetched, {} new labels, {} assignees, {} reviewers",
                    project.path,
                    result.mrs_fetched,
                    result.labels_created,
                    result.assignees_linked,
                    result.reviewers_linked,
                );
                println!("  Discussions: {} synced, {} notes ({} DiffNotes)",
                    result.discussions_synced,
                    result.notes_synced,
                    result.diffnotes_count,
                );
                if result.mrs_skipped_discussion_sync > 0 {
                    println!("  Skipped discussion sync for {} unchanged MRs",
                        result.mrs_skipped_discussion_sync);
                }
                if result.failed_discussion_syncs > 0 {
                    eprintln!("  Warning: {} MRs failed discussion sync (will retry next run)",
                        result.failed_discussion_syncs);
                }
            }
            _ => unreachable!(),
        }
    }

    // Release lock
    if !args.force {
        release_ingest_lock(config, resource_type)?;
    }

    Ok(())
}
```

## Output Format
```
Ingesting merge_requests for group/project-one...
  group/project-one: 567 MRs fetched, 12 new labels, 89 assignees, 45 reviewers
  Discussions: 456 synced, 1,234 notes (89 DiffNotes)
  Skipped discussion sync for 444 unchanged MRs

Total: 567 MRs, 456 discussions, 1,234 notes
```

## Full Sync Behavior
When `--full` is passed:
1. MR cursor reset to NULL (handled by `ingest_merge_requests` with `full_sync: true`)
2. Discussion watermarks reset to NULL (handled by `reset_discussion_watermarks` called from ingestion)
3. All MRs re-fetched from GitLab API
4. All discussions re-fetched for every MR

## Error Types (from GiError enum)
```rust
// In src/core/errors.rs
pub enum GiError {
    InvalidArgument {
        name: String,
        value: String,
        expected: String,
    },
    LockError {
        resource: String,
        message: String,
    },
    // ... other variants
}
```

## Edge Cases
- Default type is `issues` for backward compatibility with CP1
- Project filter (`--project`) can limit to specific project by path or ID
- Force flag (`--force`) bypasses single-flight lock for debugging
- If no projects configured, return helpful error about running `gi project add` first
- Empty project (no MRs): completes successfully with "0 MRs fetched"