Implement activity feed: note and resource event queries

## Background
The activity feed (AC-5.4) aggregates events from multiple sources. This bead covers the first 4 sources: human comments (notes.is_system=0), state events, label events, and milestone events. Each source is a SQL subquery that returns a unified column set for UNION ALL assembly.

## Approach
Create `src/cli/commands/me/activity.rs`. Define individual query-building functions that return SQL strings or execute directly and return `Vec<MeActivityItem>`.

### Schema context

**notes** (migration 002): `id, discussion_id (FK→discussions), is_system (0/1), author_username, body, created_at (ms epoch)`

**discussions** (migration 002): `id, project_id, issue_id (nullable FK→issues), merge_request_id (nullable), noteable_type ('Issue'|'MergeRequest')`
- CHECK constraint: exactly one of issue_id/merge_request_id is non-NULL

**resource_state_events** (migration 011): `id, gitlab_id, project_id, issue_id (nullable), merge_request_id (nullable), state TEXT NOT NULL, actor_username TEXT, created_at (ms epoch)`
- CHECK constraint: exactly one of issue_id/merge_request_id is non-NULL

**resource_label_events** (migration 011): `id, gitlab_id, project_id, issue_id (nullable), merge_request_id (nullable), action TEXT ('add'|'remove'), label_name TEXT NOT NULL, actor_username TEXT, created_at (ms epoch)`
- CHECK: one of issue_id/merge_request_id non-NULL. Note: `label_name` is NOT NULL in migration 011 but was made NULLABLE in migration 012.

**resource_milestone_events** (migration 011): `id, gitlab_id, project_id, issue_id (nullable), merge_request_id (nullable), action TEXT ('add'|'remove'), milestone_title TEXT NOT NULL, actor_username TEXT, created_at (ms epoch)`
- CHECK: one of issue_id/merge_request_id non-NULL. Note: `milestone_title` is NOT NULL in 011, NULLABLE after 012.

**issue_assignees**: `issue_id, username` (PK both). Index: `idx_issue_assignees_username(username, issue_id)`.

**mr_reviewers**: `merge_request_id, username` (PK both).

### Unified output columns (all 4 sources must match)

```
timestamp (ms epoch) | event_type | entity_type | entity_iid | project_path | actor | is_own | summary | body_preview
```

### Source 1: Human comments (notes with is_system=0)
```sql
SELECT n.created_at AS timestamp,
       'note' AS event_type,
       CASE WHEN d.issue_id IS NOT NULL THEN 'issue' ELSE 'merge_request' END AS entity_type,
       COALESCE(i.iid, mr.iid) AS entity_iid,
       p.path_with_namespace AS project_path,
       n.author_username AS actor,
       (n.author_username = ?username) AS is_own,
       'commented' AS summary,
       SUBSTR(n.body, 1, 200) AS body_preview
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
LEFT JOIN issues i ON d.issue_id = i.id
LEFT JOIN merge_requests mr ON d.merge_request_id = mr.id
JOIN projects p ON p.id = COALESCE(i.project_id, mr.project_id)
WHERE n.is_system = 0
  AND n.created_at >= ?since_ms
  AND (my_items_filter)
  AND (project_scope_filter)
```

### Source 2: State events
```sql
SELECT rse.created_at AS timestamp,
       'status' AS event_type,
       CASE WHEN rse.issue_id IS NOT NULL THEN 'issue' ELSE 'merge_request' END AS entity_type,
       COALESCE(i.iid, mr.iid) AS entity_iid,
       p.path_with_namespace AS project_path,
       rse.actor_username AS actor,
       (rse.actor_username = ?username) AS is_own,
       rse.state || ' ' || CASE WHEN rse.issue_id IS NOT NULL THEN 'issue' ELSE 'MR' END AS summary,
       NULL AS body_preview
FROM resource_state_events rse
LEFT JOIN issues i ON rse.issue_id = i.id
LEFT JOIN merge_requests mr ON rse.merge_request_id = mr.id
JOIN projects p ON p.id = rse.project_id
WHERE rse.created_at >= ?since_ms
  AND (my_items_filter)
  AND (project_scope_filter)
```

### Source 3: Label events
```sql
SELECT rle.created_at AS timestamp,
       'label' AS event_type,
       CASE WHEN rle.issue_id IS NOT NULL THEN 'issue' ELSE 'merge_request' END AS entity_type,
       COALESCE(i.iid, mr.iid) AS entity_iid,
       p.path_with_namespace AS project_path,
       rle.actor_username AS actor,
       (rle.actor_username = ?username) AS is_own,
       rle.action || ' label ' || COALESCE(rle.label_name, '?') AS summary,
       NULL AS body_preview
FROM resource_label_events rle
LEFT JOIN issues i ON rle.issue_id = i.id
LEFT JOIN merge_requests mr ON rle.merge_request_id = mr.id
JOIN projects p ON p.id = rle.project_id
WHERE rle.created_at >= ?since_ms
  AND (my_items_filter)
  AND (project_scope_filter)
```

### Source 4: Milestone events
```sql
SELECT rme.created_at AS timestamp,
       'milestone' AS event_type,
       CASE WHEN rme.issue_id IS NOT NULL THEN 'issue' ELSE 'merge_request' END AS entity_type,
       COALESCE(i.iid, mr.iid) AS entity_iid,
       p.path_with_namespace AS project_path,
       rme.actor_username AS actor,
       (rme.actor_username = ?username) AS is_own,
       rme.action || ' milestone ' || COALESCE(rme.milestone_title, '?') AS summary,
       NULL AS body_preview
FROM resource_milestone_events rme
LEFT JOIN issues i ON rme.issue_id = i.id
LEFT JOIN merge_requests mr ON rme.merge_request_id = mr.id
JOIN projects p ON p.id = rme.project_id
WHERE rme.created_at >= ?since_ms
  AND (my_items_filter)
  AND (project_scope_filter)
```

### "My items" filter (AC-3.6 — current associations)
```sql
(
  (d.issue_id IS NOT NULL AND EXISTS (
    SELECT 1 FROM issue_assignees ia WHERE ia.issue_id = d.issue_id AND ia.username = ?username
  ))
  OR
  (d.merge_request_id IS NOT NULL AND (
    EXISTS (SELECT 1 FROM merge_requests m2 WHERE m2.id = d.merge_request_id AND m2.author_username = ?username)
    OR EXISTS (SELECT 1 FROM mr_reviewers mrr WHERE mrr.merge_request_id = d.merge_request_id AND mrr.username = ?username)
  ))
)
```
For resource event tables, use `rse.issue_id`/`rse.merge_request_id` instead of `d.issue_id`/`d.merge_request_id`.

### ProjectScope filter
Use the `ProjectScope` enum from bd-a7ba:
- `All` → no filter
- `Single(project_id)` → `AND p.id = ?project_id`

### Function signature
```rust
pub fn build_notes_activity_sql(scope: &ProjectScope) -> String
pub fn build_state_events_sql(scope: &ProjectScope) -> String
pub fn build_label_events_sql(scope: &ProjectScope) -> String
pub fn build_milestone_events_sql(scope: &ProjectScope) -> String
```

Or a single function that returns all 4 SQL fragments for UNION ALL assembly.

## Acceptance Criteria
- [ ] Notes query returns human comments (is_system=0) on my items within --since window
- [ ] State events query returns state changes on my items within --since window
- [ ] Label events query returns label changes on my items within --since window
- [ ] Milestone events query returns milestone changes on my items within --since window
- [ ] All 4 queries return identical column shape: timestamp, event_type, entity_type, entity_iid, project_path, actor, is_own, summary, body_preview
- [ ] body_preview: SUBSTR(body, 1, 200) for notes (AC-7.6), NULL for events
- [ ] is_own flag: `(actor_username = ?username)` returns 0/1 (AC-5.4)
- [ ] "My items" uses CURRENT association only (AC-3.6): assignee, MR author, or MR reviewer
- [ ] Includes activity on items regardless of open/closed state (AC-5.4)
- [ ] Respects ProjectScope filtering
- [ ] label_name and milestone_title handled as potentially NULL (COALESCE with '?')

## Files
- CREATE: src/cli/commands/me/activity.rs
- MODIFY: src/cli/commands/me/mod.rs (add `pub mod activity;`)

## TDD Anchor
RED: Write `test_note_activity_returns_human_comments` using in-memory DB. Insert: project, issue, issue_assignee(username=me), discussion(issue_id=issue, noteable_type='Issue'), note(is_system=0, created_at within since window). Execute notes query. Assert 1 result with event_type="note".

GREEN: Implement the notes query.
VERIFY: `cargo test note_activity`

Additional tests:
- test_note_activity_excludes_system_notes (insert is_system=1 note, assert 0 results)
- test_note_activity_respects_since_window (insert note before since_ms, assert 0 results)
- test_state_event_appears_in_activity (insert resource_state_event with issue_id set)
- test_label_event_appears_in_activity (insert resource_label_event)
- test_milestone_event_appears_in_activity (insert resource_milestone_event)
- test_activity_only_for_currently_associated_items (insert note on unassigned issue, assert 0)
- test_is_own_flag_correct (two notes: one by me, one by other; check is_own on each)

## Edge Cases
- resource event tables use CHECK constraint: exactly one of issue_id/merge_request_id is non-NULL — LEFT JOINs handle this
- `label_name` is NULLABLE after migration 012 — use `COALESCE(rle.label_name, '?')` in summary
- `milestone_title` is NULLABLE after migration 012 — same COALESCE
- `actor_username` on resource events can be NULL (rare: API returns null actor) — handle with COALESCE in actor column
- The "my items" filter joins differently for notes (through discussions) vs resource events (direct issue_id/merge_request_id columns)
- Activity on closed items SHOULD appear — no `state = 'opened'` filter

## Dependency Context
Uses `MeActivityItem` struct from bd-3bwh. Uses `ProjectScope` enum from bd-a7ba.
Consumed by bd-2tl5 (UNION ALL assembly) and bd-2nl3 (assignment detection adds a 5th source).


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.


Phase 1: Build HTTP adapter layer (src/http.rs)

## What
Create src/http.rs (~100 LOC) — an adapter layer wrapping asupersync HttpClient with ergonomic methods matching gitlore usage patterns.

## Why
Asupersync HttpClient is lower-level than reqwest:
- Headers: Vec<(String, String)> not typed HeaderMap/HeaderValue
- Body: Vec<u8> not a builder with .json()
- Status: raw u16 not StatusCode enum
- Response: body already buffered, no async .json().await
- No per-request timeout

Without an adapter, every call site becomes 5-6 lines of boilerplate. The adapter also isolates gitlore from asupersync pre-1.0 HTTP API changes.

## API Design

### Client struct
```rust
pub struct Client {
    inner: HttpClient,
    timeout: Duration,
}
```

### Methods
- Client::with_timeout(Duration) -> Self — constructor with connection pool config
- client.get(url, headers) -> Result<Response>
- client.get_with_query(url, params, headers) -> Result<Response>
- client.post_json(url, headers, body: &T) -> Result<Response>
- (private) client.execute(method, url, headers, body) -> Result<Response>

### Response struct
```rust
pub struct Response {
    pub status: u16,
    pub reason: String,
    pub headers: Vec<(String, String)>,
    body: Vec<u8>,  // private, accessed via methods
}
```

### Response methods
- response.is_success() -> bool (200..300)
- response.json::<T>() -> Result<T>
- response.text() -> Result<String>
- response.header(name) -> Option<&str> (case-insensitive)
- response.headers_all(name) -> Vec<&str> (multi-value, for Link header pagination)

### Timeout behavior
Every request wrapped with asupersync::time::timeout(self.timeout, ...). Maps to:
- Timeout -> LoreError::GitLabNetworkError { kind: Timeout }
- Transport error -> classified via classify_transport_error(&e) -> NetworkErrorKind

### Response body size guard
MAX_RESPONSE_BODY_BYTES = 64 MiB. Safety net for runaways. Normal responses are <1 MiB.

### URL query helper
append_query_params(url, params) handles:
- URLs with existing ?query -> appends with &
- URLs with #fragment -> inserts query before fragment
- Empty params -> returns URL unchanged
- Repeated keys -> preserved (GitLab uses repeated labels[])

### Connection pool config
- max_connections_per_host: 6
- max_total_connections: 100
- idle_timeout: 90s

## Default timeouts by consumer
- GitLab REST/GraphQL: 30s
- Ollama: configurable (default 60s)
- Ollama health check: 5s

## Files Changed
- src/http.rs (NEW, ~100 LOC)
- src/main.rs or src/lib.rs (add pub mod http)

## Testing
- Unit tests for append_query_params edge cases (existing query, fragments, empty params, repeated keys)
- Unit tests for Response::header case-insensitivity
- cargo check --all-targets
- cargo clippy --all-targets -- -D warnings

## Depends On
- Phase 0d (error types: NetworkErrorKind must exist)
- Decision Gate (must verify asupersync compiles first)

asupersync migration complete — 5 commits pushed to asupersync-migration branch


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


Render activity feed with event badges in human mode

## Background
Human-mode activity output needs fast scanning: event badge, actor, entity reference, summary, time, optional preview, and optional project path. Badge color semantics must match AC-6.4 while covering all event types emitted by me activity queries (`note`, `status`, `label`, `milestone`, `assign`, `unassign`, `review_request`).

## Approach
Implement activity rendering in `src/cli/commands/me/render_human.rs`.

### 1. Badge renderer
Add helper:
```rust
fn render_event_badge(event_type: &str) -> String
```

Mapping:
- `note` -> cyan
- `status` -> amber
- `label` -> purple
- `assign` / `unassign` / `review_request` -> green
- `milestone` -> magenta
- fallback -> dim bracket label

Mode behavior:
- Color-capable mode: background pill style (fg/bg contrast).
- ASCII/no-color mode: bracketed text label using foreground color only (`[note]`, `[status]`, etc).

### 2. Row renderer
For each activity item:
- line 1: badge + `@actor` + entity ref (`#iid`/`!iid`) + summary + optional `(you)` + relative timestamp
- line 2: quoted `body_preview` (only for `note`, newline-collapsed)
- line 3: `project_path` dimmed unless single-project scope

### 3. Own-action styling
If `is_own`:
- append `(you)`
- dim the full first line to reduce visual priority

## Acceptance Criteria
- [ ] Badge colors: note=cyan, status=amber, label=purple, assign/unassign/review_request=green, milestone=magenta
- [ ] ASCII fallback renders bracket labels with colored foreground text
- [ ] `@actor` rendered with username color
- [ ] Issue refs (`#iid`) and MR refs (`!iid`) use existing ref palette
- [ ] Own actions include `(you)` and dimmed emphasis
- [ ] `body_preview` only appears for `note` events
- [ ] `body_preview` newlines are replaced with spaces
- [ ] Project path line suppressed for single-project scope
- [ ] Section header is `Activity (N)` via `section_divider`
- [ ] Empty state renders `No recent activity`

## Files
- MODIFY: `src/cli/commands/me/render_human.rs`

## TDD Anchor
RED:
- `test_event_badge_color_mapping_includes_unassign`
- `test_activity_own_action_includes_you_suffix`
- `test_activity_preview_only_for_note`
- `test_activity_project_path_suppressed_single_project`
- `test_activity_ascii_badges`

GREEN:
- Implement badge + row rendering with event coverage parity.

VERIFY:
- `cargo test me_render_activity`

## Edge Cases
- Unknown event types should still render safely with a neutral badge.
- `actor` may be empty/system fallback; renderer must not panic.
- `review_request` labels are long; spacing should remain readable without fixed-width assumptions.

## Dependency Context
Consumes `MeActivityItem` from `bd-3bwh` and event types produced by `bd-b3r3` + `bd-2nl3`.
Integrated by `bd-1vv8` handler.

Dependencies:
  -> bd-1vxq (blocks) - Render summary header and attention legend

Dependents:
  <- bd-1vv8 (blocks) - Implement me command handler: wire queries to renderers


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.

## Current Behavior

`build_path_query()` in `src/core/path_resolver.rs` returns `Err(LoreError::Ambiguous(String))` when suffix probe finds 2+ distinct paths (after `try_resolve_rename_ambiguity` also fails). The error message is a formatted string with candidates listed as indented lines. All callers propagate this as a hard error — users must re-run with the full path or `-p` flag.

## Desired Behavior

### TTY Mode (Interactive)
When stdout is a TTY and robot mode is OFF:
1. Intercept `LoreError::AmbiguousPath` in the handler before it propagates
2. Show a `dialoguer::Select` picker with the candidate paths
3. Retry with the selected full path and continue normally

Example UX:
```
Multiple files match 'utils.rs':
> src/auth/utils.rs
  src/api/utils.rs
  lib/shared/utils.rs
```

### Robot Mode (JSON)
When robot mode is ON:
1. Return structured candidates in the error JSON envelope (stderr)
2. The error code remains `AMBIGUOUS` (exit 18)
3. Add a `candidates` array to the error object

Example robot output (stderr):
```json
{
  "error": {
    "code": "AMBIGUOUS",
    "message": "'utils.rs' matches multiple paths. Use the full path or -p to scope.",
    "suggestion": "Pass the full path, or use -p to scope to a specific project.",
    "candidates": [
      "src/auth/utils.rs",
      "src/api/utils.rs",
      "lib/shared/utils.rs"
    ]
  }
}
```

### Non-TTY, Non-Robot
Same as current behavior (formatted error message to stderr). The only internal change is the error now originates from `AmbiguousPath { message, candidates }` instead of `Ambiguous(String)`, but the `Display` impl uses the `message` field directly, so formatted output is identical.

## Architecture: New `AmbiguousPath` Variant (REQUIRED)

Add a new error variant to `LoreError` in `src/core/error.rs`:

```rust
#[error("{message}")]
AmbiguousPath {
    message: String,
    candidates: Vec<String>,
}
```

**Note:** Use `#[error("{message}")]` (NOT `"Ambiguous path: {message}"`) because `message` already contains the full formatted text like "'utils.rs' matches multiple paths...". Wrapping it would produce redundant output.

**Why not modify `Ambiguous(String)`?** There are 9 construction sites for `Ambiguous(String)` across the codebase (project.rs ×2, show/issue.rs, show/mr.rs, explain.rs ×2, drift.rs, related.rs, timeline/types.rs) used for project/entity ambiguity — NOT path ambiguity. Changing the variant shape would require updating all of them (out of scope). A dedicated variant avoids this entirely.

### Error wiring for `AmbiguousPath`:

**`code()` match arm** (error.rs ~line 208):
```rust
Self::AmbiguousPath { .. } => ErrorCode::Ambiguous,
```
Maps to the same error code (exit 18) as the existing `Ambiguous(String)`.

**`suggestion()` match arm** (error.rs ~line 257):
```rust
Self::AmbiguousPath { .. } => Some(
    "Pass the full path, or use -p to scope to a specific project.",
),
```
This is PATH-focused, unlike the existing `Ambiguous` suggestion which is PROJECT-focused.

**`actions()` match arm** (error.rs ~line 306):
```rust
Self::AmbiguousPath { .. } => vec![],
```
No machine-actionable recovery commands — the `candidates` array in `RobotError` serves this role instead.

**`is_permanent_api_error()`**: No change needed (returns false for this variant by default).

### Construction site change in `path_resolver.rs`:

At `src/core/path_resolver.rs:168`, change:
```rust
// Before:
Err(LoreError::Ambiguous(format!(
    "'{trimmed}' matches multiple paths. Use the full path or -p to scope:\n{list}"
)))

// After:
Err(LoreError::AmbiguousPath {
    message: format!(
        "'{trimmed}' matches multiple paths. Use the full path or -p to scope:\n{list}"
    ),
    candidates,
})
```

The `candidates` variable is already in scope from `SuffixResult::Ambiguous(candidates)` at line 152.

### RobotError modification:
Add a `candidates` field to `RobotError` in `src/core/error.rs`:

```rust
#[derive(Debug, Serialize)]
pub struct RobotError {
    pub code: String,
    pub message: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub suggestion: Option<String>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub actions: Vec<String>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub candidates: Vec<String>,
}
```

In `to_robot_error()`, populate `candidates` from `AmbiguousPath`:
```rust
let candidates = match self {
    Self::AmbiguousPath { candidates, .. } => candidates.clone(),
    _ => vec![],
};
RobotError {
    code: self.code().to_string(),
    message: self.to_string(),
    suggestion: self.suggestion().map(String::from),
    actions,
    candidates,
}
```

**Prior art:** `RobotErrorSuggestionData` in `src/app/handlers.rs:1177` already extends the error envelope with `valid_values: Option<Vec<String>>`. The `candidates` field follows the same pattern.

### Picker Integration Point

The picker MUST live in the CLI/handler layer, NOT in `core/path_resolver.rs`. The core module should never import dialoguer or know about TTY state.

**Pattern**: Each handler that calls `build_path_query` (or calls a function that does) catches `LoreError::AmbiguousPath` and either:
- Shows the picker (TTY, non-robot) and retries with the selected full path
- Lets the error propagate for robot/non-TTY serialization

### TTY Detection

Use `std::io::IsTerminal` from Rust std (already used in the codebase at `src/cli/mod.rs:94` and `src/cli/render.rs:252`). Do NOT add `atty` crate.

```rust
use std::io::IsTerminal;
let is_tty = std::io::stdout().is_terminal();
```

### Shared Helper for file-history / trace

A shared helper function in `src/cli/` encapsulates the "resolve path with optional picker" logic for the two call sites that directly invoke `build_path_query`:

```rust
/// Resolve a user-supplied path, showing an interactive picker on ambiguity (TTY only).
/// In robot mode or non-TTY, returns the AmbiguousPath error unchanged.
pub fn resolve_path_interactive(
    conn: &Connection,
    path: &str,
    project_id: Option<i64>,
    robot_mode: bool,
) -> Result<PathQuery> {
    match build_path_query(conn, path, project_id) {
        Err(LoreError::AmbiguousPath { candidates, .. })
            if !robot_mode && std::io::stdout().is_terminal() =>
        {
            let selection = dialoguer::Select::new()
                .with_prompt(format!("Multiple files match '{path}'"))
                .items(&candidates)
                .default(0)
                .interact()
                .map_err(|e| LoreError::Other(format!("Selection cancelled: {e}")))?;
            build_path_query(conn, &candidates[selection], project_id)
        }
        other => other,
    }
}
```

**Note:** This helper covers `handle_file_history` and `handle_trace` only. The `who` command uses a different interception pattern (see below).

## Affected Call Sites (4 total)

### Direct `build_path_query` calls — use `resolve_path_interactive`:

1. **`src/app/handlers.rs:1302`** — `handle_file_history` (has `robot_mode` param)
   - Replace `build_path_query(&conn_tmp, &normalized, project_id_tmp)?` with `resolve_path_interactive(&conn_tmp, &normalized, project_id_tmp, robot_mode)?`

2. **`src/app/handlers.rs:1359`** — `handle_trace` (has `robot_mode` param)
   - Replace `build_path_query(&conn, &normalized, project_id)?` with `resolve_path_interactive(&conn, &normalized, project_id, robot_mode)?`

### `build_path_query` calls inside `run_who` — intercept at handler level:

3. **`src/cli/commands/who/expert.rs:37`** — `query_expert` calls `build_path_query`
4. **`src/cli/commands/who/overlap.rs:19`** — `query_overlap` calls `build_path_query`

These are called indirectly by `run_who()` which dispatches via `resolve_mode(args)`. The interception happens at `handle_who` (src/app/robot_docs.rs, function starts at line 622):

**Architecture context:** `run_who` internally calls `resolve_mode(args)` to extract the path from `WhoArgs`. `resolve_mode` checks `args.path` FIRST (highest priority), then `args.overlap`, then `args.target`. This means the handler can override the path by setting `args.path` and retrying.

```rust
// In handle_who (src/app/robot_docs.rs:622, run_who call at line 632)
fn handle_who(
    config_override: Option<&str>,
    mut args: WhoArgs,  // already `mut` in current code
    robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> {
    let start = std::time::Instant::now();
    let config = Config::load(config_override)?;
    if args.project.is_none() {
        args.project = config.default_project.clone();
    }

    let run = match run_who(&config, &args) {
        Err(LoreError::AmbiguousPath { candidates, .. })
            if !robot_mode && std::io::stdout().is_terminal() =>
        {
            let selection = dialoguer::Select::new()
                .with_prompt("Multiple files match the path")
                .items(&candidates)
                .default(0)
                .interact()
                .map_err(|e| LoreError::Other(format!("Selection cancelled: {e}")))?;
            // Set args.path to override — resolve_mode checks args.path first
            args.path = Some(candidates[selection].clone());
            run_who(&config, &args)?
        }
        other => other?,
    };

    let elapsed_ms = start.elapsed().as_millis() as u64;
    if robot_mode {
        print_who_json(&run, &args, elapsed_ms);
    } else {
        print_who_human(&run.result, run.resolved_input.project_path.as_deref());
    }
    Ok(())
}
```

**Why `args.path = Some(...)` works:** `resolve_mode()` (at `src/cli/commands/who/mod.rs:59`) checks `args.path` first. Setting it to `Some(full_path)` forces Expert mode with the resolved path, regardless of whether the original input came from `--overlap`, `target`, or `--path`.

**Key constraints:**
- `query_expert`/`query_overlap` must NOT receive robot_mode or dialoguer dependencies
- The handler is the right interception boundary — it already knows about robot_mode and TTY
- `WhoArgs` is already `mut` in `handle_who`'s signature

## Scope Boundary

This bead covers ONLY path ambiguity from `build_path_query` / `suffix_probe`. It does NOT cover:
- Project ambiguity (`resolve_project` in `src/core/project.rs`) — uses `Ambiguous(String)`
- Issue/MR ambiguity (`show/issue.rs`, `show/mr.rs`) — uses `Ambiguous(String)`
- Entity resolution ambiguity (`explain.rs`, `drift.rs`, `related.rs`, `timeline/types.rs`) — uses `Ambiguous(String)`

Those remain on `Ambiguous(String)` and can be separate beads if interactive resolution is desired.

## Testing

### Unit Tests
- Add test that `LoreError::AmbiguousPath` carries candidates vec correctly
- Add test that `LoreError::AmbiguousPath` maps to `ErrorCode::Ambiguous` (exit 18)
- Add test that `RobotError` serialization includes `candidates` array when non-empty
- Add test that `RobotError` serialization omits `candidates` when empty (skip_serializing_if)
- Existing ambiguity tests in path_resolver_tests.rs remain unchanged (they test core logic, not UI)
- Update the path_resolver test at who_tests.rs:2087 that matches on `SuffixResult::Ambiguous(paths)` — it should still work since the core SuffixResult enum is unchanged, only the LoreError construction differs

### Integration Tests
- Test robot mode: verify JSON error includes `candidates` array with correct paths
- Test non-TTY: verify error message still contains candidate paths as text
- Test that the helper retries with the correct path after simulated selection

### Manual Testing
- TTY picker: run `lore who utils.rs` (or similar ambiguous path) in a terminal
- Verify arrow-key selection works
- Verify Ctrl+C cancels cleanly (returns non-zero exit, no panic)
- Verify robot mode: `lore --robot who utils.rs` returns structured candidates

## Dependencies
- `dialoguer = "0.12"` — already in Cargo.toml (line 25), already used in init flow (src/app/handlers.rs:873)
- `std::io::IsTerminal` — stdlib, already used in src/cli/mod.rs:94 and src/cli/render.rs:252
- No new crate dependencies needed

## Acceptance Criteria
1. TTY + non-robot: `dialoguer::Select` picker appears, user selects, command completes normally
2. Robot mode: error JSON includes `candidates: [...]` array
3. Non-TTY + non-robot: formatted error with candidate list (current behavior preserved)
4. All existing path_resolver tests pass unchanged
5. Picker shows at most 11 candidates (existing limit from suffix_probe LIMIT 11)
6. Ctrl+C during picker exits cleanly (non-zero exit, no panic)
7. Helper function is shared across file_history and trace call sites (no duplication)
8. The who command intercepts at the handler level via `args.path` mutation, not inside query_expert/query_overlap
9. New `AmbiguousPath` variant does NOT affect any existing `Ambiguous(String)` call sites (9 sites confirmed)
10. `RobotError.candidates` is empty-vec-omitted for all non-path-ambiguity errors
11. `AmbiguousPath` suggestion is path-focused ("Pass the full path..."), not project-focused like `Ambiguous`

## Polish Audit (2026-03-13)

### Issues Found & Fixed

1. **Architectural contradiction (CRITICAL)**: Original Option A proposed changing `LoreError::Ambiguous(String)` to carry structured data, but excluded all other Ambiguous construction sites from scope. Fixed by recommending new `AmbiguousPath` variant instead.

2. **Wrong call site location**: `handle_who` is in `src/app/robot_docs.rs:622`, not `src/app/handlers.rs`. The who build_path_query calls are inside `query_expert`/`query_overlap` which lack robot_mode. Fixed by specifying handler-level interception.

3. **TTY detection unspecified**: Codebase uses `std::io::IsTerminal` (Rust std), not `atty`. Added explicit specification.

4. **RobotError modification under-specified**: Added concrete field addition and `to_robot_error()` population code.

5. **`try_resolve_rename_ambiguity` not mentioned**: The AmbiguousPath error only fires after rename resolution also fails. Added to "Current Behavior" for clarity.

6. **Referenced prior art**: `RobotErrorSuggestionData` in handlers.rs already extends error envelope with `valid_values`. Candidates field follows same pattern.


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

Currently `regenerate_dirty_documents()` in `src/documents/regenerator.rs` processes ALL entries in the `dirty_sources` table. The surgical sync pipeline needs a scoped variant that only regenerates documents for specific `(source_type, source_id)` pairs — the ones produced by the surgical ingest step.

The dirty_sources table schema: `(source_type TEXT, source_id INTEGER)` primary key, where source_type is one of `'issue'`, `'merge_request'`, `'discussion'`, `'note'`. After `ingest_issue_by_iid` or `ingest_mr_by_iid` calls `mark_dirty()`, these rows exist in dirty_sources with the matching keys.

The existing `regenerate_one(conn, source_type, source_id, cache)` private function does the actual work for a single source. The scoped function can call it directly for each provided key, without going through `get_dirty_sources()` which pulls from the full table.

Key requirement: the function must return the `document_id` values of regenerated documents so the scoped embedding step (bd-1elx) can process only those documents.

## Approach

Add `regenerate_dirty_documents_for_sources()` to `src/documents/regenerator.rs`:

```rust
pub struct RegenerateForSourcesResult {
    pub regenerated: usize,
    pub unchanged: usize,
    pub errored: usize,
    pub document_ids: Vec<i64>,  // IDs of regenerated docs for scoped embedding
}

pub fn regenerate_dirty_documents_for_sources(
    conn: &Connection,
    source_keys: &[(SourceType, i64)],
) -> Result<RegenerateForSourcesResult>
```

Implementation:
1. Create a `ParentMetadataCache` (same as bulk path).
2. Iterate over provided `source_keys`.
3. For each key, call `regenerate_one(conn, source_type, source_id, &mut cache)`.
4. On success (changed=true): call `clear_dirty()`, query `documents` table for the document_id by `(source_type, source_id)`, push to `document_ids` vec.
5. On success (changed=false): call `clear_dirty()`, still query for document_id (content unchanged but may need re-embedding if model changed).
6. On error: call `record_dirty_error()`, increment errored count.

Also export from `src/documents/mod.rs`: `pub use regenerator::{RegenerateForSourcesResult, regenerate_dirty_documents_for_sources};`

## Acceptance Criteria

- [ ] `regenerate_dirty_documents_for_sources` only processes the provided source_keys, not all dirty_sources
- [ ] Returns `document_ids` for all successfully processed documents (both regenerated and unchanged)
- [ ] Clears dirty_sources entries for successfully processed sources
- [ ] Records errors for failed sources without aborting the batch
- [ ] Exported from `src/documents/mod.rs`
- [ ] Existing `regenerate_dirty_documents` is unchanged (no regression)

## Files

- `src/documents/regenerator.rs` (add new function + result struct)
- `src/documents/mod.rs` (export new function + struct)

## TDD Anchor

Tests in `src/documents/regenerator_tests.rs` (add to existing test file):

```rust
#[test]
fn test_scoped_regen_only_processes_specified_sources() {
    let conn = setup_test_db();
    // Insert 2 issues with dirty markers
    insert_test_issue(&conn, 1, "Issue A");
    insert_test_issue(&conn, 2, "Issue B");
    mark_dirty(&conn, SourceType::Issue, 1).unwrap();
    mark_dirty(&conn, SourceType::Issue, 2).unwrap();

    // Regenerate only issue 1
    let result = regenerate_dirty_documents_for_sources(
        &conn,
        &[(SourceType::Issue, 1)],
    ).unwrap();

    assert!(result.regenerated >= 1 || result.unchanged >= 1);
    // Issue 1 dirty cleared, issue 2 still dirty
    let remaining = get_dirty_sources(&conn).unwrap();
    assert_eq!(remaining.len(), 1);
    assert_eq!(remaining[0], (SourceType::Issue, 2));
}

#[test]
fn test_scoped_regen_returns_document_ids() {
    let conn = setup_test_db();
    insert_test_issue(&conn, 1, "Issue A");
    mark_dirty(&conn, SourceType::Issue, 1).unwrap();

    let result = regenerate_dirty_documents_for_sources(
        &conn,
        &[(SourceType::Issue, 1)],
    ).unwrap();

    assert!(!result.document_ids.is_empty());
    // Verify document_id exists in documents table
    let exists: bool = conn.query_row(
        "SELECT EXISTS(SELECT 1 FROM documents WHERE id = ?1)",
        [result.document_ids[0]], |r| r.get(0),
    ).unwrap();
    assert!(exists);
}

#[test]
fn test_scoped_regen_handles_missing_source() {
    let conn = setup_test_db();
    // Source key not in dirty_sources, regenerate_one will fail or return None
    let result = regenerate_dirty_documents_for_sources(
        &conn,
        &[(SourceType::Issue, 9999)],
    ).unwrap();
    // Should handle gracefully: either errored=1 or unchanged with no doc_id
    assert_eq!(result.document_ids.len(), 0);
}
```

## Edge Cases

- Source key exists in dirty_sources but the underlying entity was deleted: `regenerate_one` returns `None` from the extractor, calls `delete_document`, returns `Ok(true)`. No document_id to return.
- Source key not in dirty_sources at all (already cleared by concurrent process): `regenerate_one` still works (it reads from the entity tables, not dirty_sources). But `clear_dirty` is a no-op DELETE.
- Same source_key appears twice in the input slice: second call is idempotent (dirty already cleared, doc already up to date).
- `unchanged` documents: content_hash matches, but we still need the document_id for embedding (model version may have changed). Include in `document_ids`.
- Error in one source must not abort processing of remaining sources.

## Dependency Context

- **No blockers**: Uses only existing functions (`regenerate_one`, `clear_dirty`, `record_dirty_error`) which are all private to the regenerator module. New function lives in same module.
- **Blocks bd-1i4i**: Orchestration function calls this after surgical ingest to get document_ids for scoped embedding.
- **Feeds bd-1elx**: `document_ids` output is the input for `run_embed_for_document_ids`.

Completed: regenerate_dirty_documents_for_sources with scoped dirty tracking, 3 tests


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


lore brief: core section assembly, robot mode, CLI wiring

## Summary
Implement the core `lore brief` command: section assembly logic, BriefResponse type, robot mode JSON output, and CLI wiring in main.rs. This is the business logic that composes existing `run_*` functions into a unified situational awareness response.

## Files to Create/Modify

- **NEW:** `src/cli/commands/brief/mod.rs` — `run_brief()`, `BriefResponse`, section assembly, robot JSON
- **MODIFY:** `src/cli/commands/mod.rs` — add `pub mod brief;`
- **MODIFY:** `src/main.rs` — register `Brief` subcommand in `Commands` enum, add dispatch

## BriefResponse Type

```rust
#[derive(Debug, Serialize)]
pub struct BriefResponse {
    pub mode: String,                          // "topic", "path", "person", "entity"
    pub query: String,                         // the input query/path/person
    pub summary: String,                       // auto-generated one-liner from section counts
    pub open_issues: Vec<BriefIssue>,
    pub active_mrs: Vec<BriefMr>,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub experts: Vec<BriefExpert>,
    pub recent_activity: Vec<BriefActivityEvent>,
    pub unresolved_threads: Vec<BriefThread>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub related: Option<Vec<BriefRelated>>,    // None when no embeddings
    pub warnings: Vec<String>,
}
```

Define lightweight Brief-prefixed structs that map from the existing run_* return types. Keep fields minimal for the robot mode 12KB token budget.

## Section Assembly (run_brief)

`run_brief` is `pub async fn`. Sync callees (`run_list_issues`, `run_list_mrs`, `run_who`) are called directly. Async callees (`run_related`, `run_timeline`) use `.await`. No `spawn_blocking` needed.

### Topic mode scoping (search-first, IID filter)

```rust
let search_iids: Vec<i64> = if mode == "topic" {
    let search_result = run_search(config, query, ...).await?;
    search_result.results.iter().map(|r| r.iid).collect()
} else {
    vec![]
};
// Then pass iids to ListFilters { iids: if search_iids.is_empty() { None } else { Some(&search_iids) }, ... }
```

### Verified function signatures (2026-03-13)

| Module | Function | Signature |
|--------|----------|-----------|
| list/issues.rs:133 | `run_list_issues` | `fn(config: &Config, filters: ListFilters<'a>) -> Result<ListResult>` |
| list/mrs.rs:120 | `run_list_mrs` | `fn(config: &Config, filters: MrListFilters<'a>) -> Result<MrListResult>` |
| who/mod.rs:115 | `run_who` | `fn(config: &Config, args: &WhoArgs) -> Result<WhoRun>` |
| search.rs:69 | `run_search` | `async fn(config: &Config, query: &str, cli_filters: SearchCliFilters, fts_mode: FtsQueryMode, requested_mode: &str, explain: bool) -> Result<SearchResponse>` |
| related.rs:92 | `run_related` | `async fn(config: &Config, query_or_type: &str, iid: Option<i64>, limit: usize, project: Option<&str>) -> Result<RelatedResponse>` |
| timeline.rs:71 | `run_timeline` | `async fn(config: &Config, params: &TimelineParams) -> Result<TimelineResult>` |

### CRITICAL: ListFilters/MrListFilters have lifetimes, no Default

All fields must be constructed explicitly. See the epic bead (bd-1n5q) for full field listings.

### CRITICAL: WhoArgs is a clap struct

To trigger expert mode: set `path = Some(...)`. To trigger workload mode: set `target = Some(username)`. There is no `mode` field — mode resolved internally by `resolve_mode()`. Full field list in epic bead.

### Section details

| Section | Source | Limit | Fallback |
|---------|--------|-------|----------|
| open_issues | `run_list_issues` with state=opened, iids filter for topic mode | section_limit | empty vec |
| active_mrs | `run_list_mrs` with state=opened, iids filter for topic mode | section_limit | empty vec |
| experts | `run_who` Expert mode (path mode only) | 3 | empty vec or omit |
| recent_activity | `run_timeline` | section_limit | empty vec |
| unresolved_threads | Direct SQL: discussions WHERE resolved=false | section_limit | empty vec |
| related | `run_related` | section_limit | None (no embeddings) |
| warnings | computed from issue dates/state | all | empty vec |

### Warning generation
Detect: stale issues (>30d no activity), unassigned issues. See epic bead for `compute_warnings` implementation.

### Mode-specific behavior

- **Topic** (`query` is set, no `--path`/`--person`): Search-first IID filter for issues/MRs. No experts section unless a path can be inferred.
- **Path** (`--path`): Experts section prominent. Issues/MRs scoped by file path.
- **Person** (`--person`): Issues filtered by assignee, MRs by author. No experts section.
- **Entity** (`query` matches `issues N` or `mrs N`): Single entity focus with cross-references.

## Clap Registration

```rust
Brief {
    /// Free-text topic, entity type, or omit for project-wide brief
    query: Option<String>,
    #[arg(long)]
    path: Option<String>,
    #[arg(long)]
    person: Option<String>,
    #[arg(short, long)]
    project: Option<String>,
    #[arg(long, default_value = "5")]
    section_limit: usize,
},
```

## Robot Mode Output

Standard envelope: `{"ok": true, "data": BriefResponse, "meta": {"elapsed_ms": N, "sections_computed": [...]}}`. See epic bead for full schema.

## TDD

RED:
- `test_brief_topic_returns_all_sections`: insert test data, brief with topic query, assert all section keys present
- `test_brief_path_uses_who_expert`: brief --path, assert experts populated
- `test_brief_person_filters_by_assignee`: brief --person, assert issues filtered to assignee
- `test_brief_warnings_stale_issue`: insert stale issue, assert warning generated
- `test_brief_token_budget`: robot JSON under 12000 bytes
- `test_brief_no_embeddings_graceful`: related is None when no embeddings
- `test_brief_empty_topic`: zero matches returns valid response with empty sections

GREEN: Implement run_brief and wire into main.rs.

VERIFY:
```bash
cargo test brief:: && cargo clippy --all-targets -- -D warnings && cargo fmt --check
cargo run --release -- -J brief 'throw time' | jq '.data | keys'
cargo run --release -- -J brief 'throw time' | wc -c
```

## Acceptance Criteria
- [ ] `lore brief TOPIC` returns all sections for free-text topic
- [ ] `lore brief --path PATH` returns path-focused briefing with experts
- [ ] `lore brief --person USERNAME` returns person-focused briefing
- [ ] `lore brief issues N` returns entity-focused briefing
- [ ] Robot mode output under 12000 bytes
- [ ] Each section degrades gracefully if its data source is unavailable
- [ ] summary field is auto-generated one-liner from section counts
- [ ] warnings detect: stale issues (>30d), unassigned
- [ ] Performance: <2s total
- [ ] Command registered in main.rs and robot-docs


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.