lore related: semantic similarity discovery

## Background
Given any entity or free text, find semantically related entities using vector embeddings. No other GitLab tool does this — glab, GitLab Advanced Search, and even paid tiers are keyword-only. This finds conceptual connections humans miss.

## Current Infrastructure (Verified 2026-02-12)
- sqlite-vec extension loaded via sqlite3_vec_init in src/core/db.rs:84
- Embeddings stored in: embedding_metadata table (chunk info) + vec0 virtual table named `embeddings` (vectors)
- Migration 009 creates embedding infrastructure
- search_vector() at src/search/vector.rs:43 — works with sqlite-vec KNN queries
- OllamaClient::embed_batch() at src/embedding/ollama.rs:103 — batch embedding
- Model: nomic-embed-text, 768 dimensions, context_length=2048 tokens (~1500 bytes)
- 61K documents in DB, embedding coverage TBD

### sqlite-vec Distance Metric
The `embeddings` virtual table is `vec0(embedding float[768])`. sqlite-vec's MATCH query returns L2 (Euclidean) distance by default. Lower distance = more similar. The `search_vector()` function returns `VectorResult { document_id: i64, distance: f64 }`.

## Approach

### Entity Mode: lore related issues N
1. Look up document for issue N:
```sql
SELECT d.id, d.content_text
FROM documents d
JOIN issues i ON d.source_type = 'issue' AND d.source_id = i.id
WHERE i.iid = ?1 AND i.project_id = (SELECT id FROM projects WHERE ...)
```
NOTE: `documents.source_id` is the internal DB id from the source table (issues.id), NOT the GitLab IID. See migration 007 comment: `source_id INTEGER NOT NULL -- local DB id in the source table`.

2. Get its embedding: Look up via embedding_metadata which maps document_id -> rowid in the vec0 table:
```sql
SELECT em.rowid
FROM embedding_metadata em
WHERE em.document_id = ?1
LIMIT 1  -- use first chunk's embedding as representative
```
Then extract the embedding vector from the vec0 table to use as the KNN query.

Alternatively, embed the document's content_text on-the-fly via OllamaClient (simpler, more robust):
```rust
let embedding = client.embed_batch(&[&doc.content_text]).await?[0].clone();
```

3. Call search_vector(conn, &embedding, limit * 2) for KNN — multiply limit to have room after filtering self
4. Exclude self (filter out source document_id from results)
5. Hydrate results: join documents -> issues/mrs/discussions for title, url, labels, author
6. Compute shared_labels: parse `documents.label_names` (JSON array string) for both source and each result, intersect
7. Return ranked list

### Query Mode: lore related 'free text'
1. Embed query via OllamaClient::embed_batch(&[query_text])
2. Call search_vector(conn, &query_embedding, limit)
3. Hydrate and return (same as entity mode minus self-exclusion)

### Key Design Decision
This is intentionally SIMPLER than hybrid search. No FTS, no RRF. Pure vector similarity. The point is conceptual relatedness, not keyword matching.

### Distance to Similarity Score Conversion
sqlite-vec returns L2 (Euclidean) distance. Convert to 0-1 similarity:
```rust
/// Convert L2 distance to a 0-1 similarity score.
/// Uses inverse relationship: closer (lower distance) = higher similarity.
/// The +1 prevents division by zero and ensures score is in (0, 1].
fn distance_to_similarity(distance: f64) -> f64 {
    1.0 / (1.0 + distance)
}
```
For normalized embeddings (which nomic-embed-text produces), L2 distance ranges roughly 0-2. This formula maps:
- distance 0.0 -> similarity 1.0 (identical)
- distance 1.0 -> similarity 0.5
- distance 2.0 -> similarity 0.33

### Label Extraction for shared_labels
```rust
fn parse_label_names(label_names_json: &Option<String>) -> HashSet<String> {
    label_names_json
        .as_deref()
        .and_then(|s| serde_json::from_str::<Vec<String>>(s).ok())
        .unwrap_or_default()
        .into_iter()
        .collect()
}

let source_labels = parse_label_names(&source_doc.label_names);
let result_labels = parse_label_names(&result_doc.label_names);
let shared: Vec<String> = source_labels.intersection(&result_labels).cloned().collect();
```

## Function Signatures

```rust
// New: src/cli/commands/related.rs
pub struct RelatedArgs {
    pub entity_type: Option<String>,  // "issues" or "mrs"
    pub entity_iid: Option<i64>,
    pub query: Option<String>,        // free text mode
    pub project: Option<String>,
    pub limit: Option<usize>,
}

pub async fn run_related(
    config: &Config,
    args: RelatedArgs,
) -> Result<RelatedResponse>

// Reuse from src/search/vector.rs:43
pub fn search_vector(
    conn: &Connection,
    query_embedding: &[f32],
    limit: usize,
) -> Result<Vec<VectorResult>>
// VectorResult { document_id: i64, distance: f64 }

// Reuse from src/embedding/ollama.rs:103
pub async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>>
```

## Robot Mode Output Schema
```json
{
  "ok": true,
  "data": {
    "source": { "type": "issue", "iid": 3864, "title": "..." },
    "query": "switch throw time...",
    "results": [{
      "source_type": "issue",
      "iid": 3800,
      "title": "Rail Break Card",
      "url": "...",
      "similarity_score": 0.87,
      "shared_labels": ["customer:BNSF"],
      "shared_authors": [],
      "project_path": "vs/typescript-code"
    }]
  },
  "meta": { "elapsed_ms": 42, "mode": "entity", "embedding_dims": 768, "distance_metric": "l2" }
}
```

## Clap Registration
```rust
// In src/main.rs Commands enum, add:
Related {
    /// Entity type ("issues" or "mrs") or free text query
    query_or_type: String,
    /// Entity IID (when first arg is entity type)
    iid: Option<i64>,
    /// Maximum results
    #[arg(short = 'n', long, default_value = "10")]
    limit: usize,
    /// Scope to project (fuzzy match)
    #[arg(short, long)]
    project: Option<String>,
},
```

## TDD Loop
RED: Tests in src/cli/commands/related.rs:
- test_related_entity_excludes_self: insert doc + embedding for issue, query related, assert source doc not in results
- test_related_shared_labels: insert 2 docs with overlapping labels (JSON in label_names), assert shared_labels computed correctly
- test_related_empty_embeddings: no embeddings in DB, assert exit code 14 with helpful error
- test_related_query_mode: embed free text via mock, assert results returned
- test_related_similarity_score_range: all scores between 0.0 and 1.0
- test_distance_to_similarity: unit test the conversion function (0.0->1.0, 1.0->0.5, large->~0.0)

GREEN: Implement related command using search_vector + hydration

VERIFY:
```bash
cargo test related:: && cargo clippy --all-targets -- -D warnings
cargo run --release -- -J related issues 3864 -n 5 | jq '.data.results[0].similarity_score'
```

## Acceptance Criteria
- [ ] lore related issues N returns top-K semantically similar entities
- [ ] lore related mrs N works for merge requests
- [ ] lore related 'free text' works as concept search (requires Ollama)
- [ ] Results exclude the input entity itself
- [ ] similarity_score is 0-1 range (higher = more similar), converted from L2 distance
- [ ] Robot mode includes shared_labels (from documents.label_names JSON), shared_authors per result
- [ ] Human mode shows ranked list with titles, scores, common labels
- [ ] No embeddings in DB: exit code 14 with message "Run 'lore embed' first"
- [ ] Ollama unavailable (query mode only): exit code 14 with suggestion
- [ ] Performance: <1s for 61K documents
- [ ] Command registered in main.rs and robot-docs

## Edge Cases
- Entity has no embedding (added after last lore embed): embed its content_text on-the-fly via OllamaClient, or exit 14 if Ollama unavailable
- All results have very low similarity (<0.3): include warning "No strongly related entities found"
- Entity is a discussion (not issue/MR): should still work (documents table has discussion docs)
- Multiple documents per entity (discussion docs): use the entity-level document, not discussion subdocs
- Free text query very short (1-2 words): may produce noisy results, add warning
- Entity not found in DB: exit code 17 with suggestion to sync
- Ambiguous project: exit code 18 with suggestion to use -p flag
- documents.label_names may be NULL or invalid JSON — parse_label_names handles both gracefully

## Dependency Context
- **bd-1ksf (hybrid search)**: BLOCKER. Shares OllamaClient infrastructure. Also ensures async search.rs patterns are established. Related reuses the same vector search infrastructure.

## Files to Create/Modify
- NEW: src/cli/commands/related.rs
- src/cli/commands/mod.rs (add pub mod related; re-export)
- src/main.rs (register Related subcommand in Commands enum, add handle_related fn)
- Reuse: search_vector() from src/search/vector.rs, OllamaClient from src/embedding/ollama.rs

Implemented: entity mode (issues/mrs), query mode (free text), similarity scoring, shared labels, robot/human output


Extract cross-references from resource_state_events

## Background
resource_state_events includes source_merge_request (with iid) for 'closed by MR' events. After state events are stored (Gate 1), post-processing extracts these into entity_references for the cross-reference graph.

## Approach
Create src/core/references.rs (new module) or add to events_db.rs:

```rust
/// Extract cross-references from stored state events and insert into entity_references.
/// Looks for state events with source_merge_request_id IS NOT NULL (meaning "closed by MR").
///
/// Directionality: source = MR (that caused the close), target = issue (that was closed)
pub fn extract_refs_from_state_events(
    conn: &Connection,
    project_id: i64,
) -> Result<usize>  // returns count of new references inserted
```

SQL logic:
```sql
INSERT OR IGNORE INTO entity_references (
    source_entity_type, source_entity_id,
    target_entity_type, target_entity_id,
    reference_type, source_method, created_at
)
SELECT
    'merge_request',
    mr.id,
    'issue',
    rse.issue_id,
    'closes',
    'api_state_event',
    rse.created_at
FROM resource_state_events rse
JOIN merge_requests mr ON mr.project_id = rse.project_id AND mr.iid = rse.source_merge_request_id
WHERE rse.source_merge_request_id IS NOT NULL
  AND rse.issue_id IS NOT NULL
  AND rse.project_id = ?1;
```

Key: source_merge_request_id stores the MR iid, so we JOIN on merge_requests.iid to get the local DB id.

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

Call this after drain_dependent_queue in the sync pipeline (after all state events are stored).

## Acceptance Criteria
- [ ] State events with source_merge_request_id produce 'closes' references
- [ ] Source = MR (resolved by iid), target = issue
- [ ] source_method = 'api_state_event'
- [ ] INSERT OR IGNORE prevents duplicates with api_closes_issues data
- [ ] Returns count of newly inserted references
- [ ] No-op when no state events have source_merge_request_id

## Files
- src/core/references.rs (new)
- src/core/mod.rs (add `pub mod references;`)
- src/cli/commands/sync.rs (call after drain step)

## TDD Loop
RED: tests/references_tests.rs:
- `test_extract_refs_from_state_events_basic` - seed a "closed" state event with source_merge_request_id, verify entity_reference created
- `test_extract_refs_dedup_with_closes_issues` - insert ref from closes_issues API first, verify state event extraction doesn't duplicate
- `test_extract_refs_no_source_mr` - state events without source_merge_request_id produce no refs

Setup: create_test_db with migrations 001-011, seed project + issue + MR + state events.

GREEN: Implement extract_refs_from_state_events

VERIFY: `cargo test references -- --nocapture`

## Edge Cases
- source_merge_request_id may reference an MR not synced locally (cross-project close) — the JOIN will produce no match, which is correct behavior (ref simply not created)
- Multiple state events can reference the same MR for the same issue (reopen + re-close) — INSERT OR IGNORE handles dedup
- The merge_requests table might not have the MR yet if sync is still running — call this after all dependent fetches complete


Comprehensive robot-docs as agent bootstrap

## Background
Agents reach for glab because they already know it from training data. lore robot-docs exists but is not comprehensive enough to serve as a zero-training bootstrap. An agent encountering lore for the first time should be able to use any command correctly after reading robot-docs output alone.

## Current State (Verified 2026-02-12)
- `handle_robot_docs()` at src/main.rs:2069
- Called at no-args in robot mode (main.rs:165) and via Commands::RobotDocs { brief } (main.rs:229)
- Current output top-level keys: name, version, description, activation, commands, aliases, exit_codes, clap_error_codes, error_format, workflows
- Missing: response_schema per command, example_output per command, quick_start section, glab equivalence table
- --brief flag exists but returns shorter version of same structure
- main.rs is 2579 lines total

## Current robot-docs Output Structure
```json
{
  "name": "lore",
  "version": "0.6.1",
  "description": "...",
  "activation": { "flags": ["--robot", "-J"], "env": "LORE_ROBOT=1", "auto_detect": "non-TTY" },
  "commands": [{ "name": "...", "description": "...", "flags": [...], "example": "..." }],
  "aliases": { ... },
  "exit_codes": { ... },
  "clap_error_codes": { ... },
  "error_format": { ... },
  "workflows": { ... }
}
```

## Approach

### 1. Add quick_start section
Top-level key with glab-to-lore translation and lore-exclusive feature summary:
```json
"quick_start": {
  "glab_equivalents": [
    { "glab": "glab issue list", "lore": "lore -J issues -n 50", "note": "Richer: includes labels, status, closing MRs" },
    { "glab": "glab issue view 123", "lore": "lore -J issues 123", "note": "Includes discussions, work-item status" },
    { "glab": "glab mr list", "lore": "lore -J mrs", "note": "Includes draft status, reviewers" },
    { "glab": "glab mr view 456", "lore": "lore -J mrs 456", "note": "Includes discussions, file changes" },
    { "glab": "glab api '/projects/:id/issues'", "lore": "lore -J issues -p project", "note": "Fuzzy project matching" }
  ],
  "lore_exclusive": [
    "search: FTS5 + vector hybrid search across all entities",
    "who: Expert/workload/reviews analysis per file path or person",
    "timeline: Chronological event reconstruction across entities",
    "stats: Database statistics with document/note/discussion counts",
    "count: Entity counts with state breakdowns"
  ]
}
```

### 2. Add response_schema per command
For each command in the commands array, add a `response_schema` field showing the JSON shape:
```json
{
  "name": "issues",
  "response_schema": {
    "ok": "boolean",
    "data": { "type": "array|object", "fields": ["iid", "title", "state", "..."] },
    "meta": { "elapsed_ms": "integer" }
  }
}
```
Commands with multiple output shapes (list vs detail) need both documented.

### 3. Add example_output per command
Realistic truncated JSON for each command. Keep each example under 500 bytes.

### 4. Token budget enforcement
- --brief mode: ONLY quick_start + command names + invocation syntax. Target <4000 tokens (~16000 bytes).
- Full mode: everything. Target <12000 tokens (~48000 bytes).
- Measure with: `cargo run --release -- --robot robot-docs --brief | wc -c`

## TDD Loop
RED: Tests in src/main.rs or new src/cli/commands/robot_docs.rs:
- test_robot_docs_has_quick_start: parse output JSON, assert quick_start.glab_equivalents array has >= 5 entries
- test_robot_docs_brief_size: --brief output < 16000 bytes
- test_robot_docs_full_size: full output < 48000 bytes
- test_robot_docs_has_response_schemas: every command entry has response_schema key
- test_robot_docs_commands_complete: assert all registered commands appear (issues, mrs, search, who, timeline, count, stats, sync, embed, doctor, health, ingest, generate-docs, show)

GREEN: Add quick_start, response_schema, example_output to robot-docs output

VERIFY:
```bash
cargo test robot_docs && cargo clippy --all-targets -- -D warnings
cargo run --release -- --robot robot-docs | jq '.quick_start.glab_equivalents | length'
# Should return >= 5
cargo run --release -- --robot robot-docs --brief | wc -c
# Should be < 16000
```

## Acceptance Criteria
- [ ] robot-docs JSON has quick_start.glab_equivalents array with >= 5 entries
- [ ] robot-docs JSON has quick_start.lore_exclusive array
- [ ] Every command entry has response_schema showing the JSON shape
- [ ] Every command entry has example_output with realistic truncated data
- [ ] --brief output is under 16000 bytes (~4000 tokens)
- [ ] Full output is under 48000 bytes (~12000 tokens)
- [ ] An agent reading ONLY robot-docs can correctly invoke any lore command
- [ ] cargo test passes with new robot_docs tests

## Edge Cases
- Commands with multiple output shapes (e.g., issues list vs issues detail via iid) need both schemas documented
- --fields flag changes output shape -- document the effect in the response_schema
- robot-docs output must be stable across versions (agents may cache it)
- Version field should match Cargo.toml version

## Files to Modify
- src/main.rs fn handle_robot_docs() (~line 2069) — add quick_start section, response_schema, example_output
- Consider extracting to src/cli/commands/robot_docs.rs if the function exceeds 200 lines

Robot-docs enhanced with quick_start (glab equivalents, lore exclusives, read/write split) and example_output for issues/mrs/search/who


[CP1] gi sync-status enhancement

Enhance sync-status from CP0 stub to show issue cursors.

## Changes to src/cli/commands/sync_status.rs

Update the existing stub to show:
- Last run timestamp and duration
- Cursor positions per project (issues resource_type)
- Entity counts (issues, discussions, notes)

## Output Format
Last sync: 2026-01-25 10:30:00 (succeeded, 45s)

Cursors:
  group/project-one
    issues: 2026-01-25T10:25:00Z (gitlab_id: 12345678)

Counts:
  Issues: 1,234
  Discussions: 5,678
  Notes: 23,456 (4,567 system)

Files: src/cli/commands/sync_status.rs
Done when: Shows cursor positions and counts after ingestion


Render MR sections (authored + reviewing) in human mode

## Background
Render the two MR sections in `lore me` human mode: authored MRs and reviewing MRs. Both share layout primitives but differ in section-specific fields.

## Approach
Implement in `src/cli/commands/me/render_human.rs`:
```rust
pub fn render_authored_mrs_section(mrs: &[MeMrAuthored], single_project: bool) -> String
pub fn render_reviewing_mrs_section(mrs: &[MeMrReviewing], single_project: bool) -> String
```

Shared row layout:
- Line 1: attention icon + `!iid` + title + section-specific fields + relative time.
- Line 2: dimmed `project_path` unless `single_project == true`.

Section-specific fields:
- Authored: optional `detailed_merge_status` and optional `DRAFT` indicator.
- Reviewing: `@author_username` and optional `DRAFT` indicator.

Styling:
- `Theme::mr_ref()` for `!iid`
- `Theme::username()` for `@author`
- `Theme::state_draft()` for `DRAFT`
- `Theme::dim()` for recency and project path
- `section_divider()` for headers

Timestamp conversion rule:
- Convert `updated_at_iso` via `crate::core::time::iso_to_ms` before `format_relative_time`.
- On parse failure, render `updated_at_iso` text fallback; do not panic.

Attention rule:
- `NotReady` renders no attention icon; `DRAFT` remains visible.

## Acceptance Criteria
- [ ] Headers are `Authored MRs (N)` and `Reviewing MRs (N)` via `section_divider`
- [ ] `!iid` uses MR ref styling
- [ ] `DRAFT` appears only when `draft=true`
- [ ] Authored rows show `detailed_merge_status` only when `Some`
- [ ] Reviewing rows show `@author_username`
- [ ] Attention icon rendering matches state/tier rules
- [ ] `NotReady` shows no icon but keeps draft indicator
- [ ] Project path hidden when `single_project=true`
- [ ] Empty authored section -> `No authored MRs`
- [ ] Empty reviewing section -> `No MRs to review`
- [ ] Invalid `updated_at_iso` does not panic and renders fallback text

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

## TDD Anchor
RED:
- `test_authored_mr_shows_merge_status`
- `test_authored_mr_shows_draft_indicator`
- `test_authored_mr_hides_draft_when_false`
- `test_reviewing_mr_shows_author_username`
- `test_reviewing_mr_empty_section`
- `test_authored_mr_omits_status_when_none`
- `test_mr_section_invalid_iso_fallback`

GREEN:
- Implement both section renderers with shared helper + safe timestamp conversion.

VERIFY:
- `cargo test mr_section`

## Edge Cases
- Same MR can appear in both sections (author and reviewer); render independently.
- `detailed_merge_status` may be absent; omit cleanly.
- Sorting remains handler-owned; renderer does no resorting.

## Dependency Context
Consumes `MeMrAuthored`/`MeMrReviewing` from `bd-3bwh`, icon/summary context from `bd-1vxq`, and is called by `bd-1vv8`.

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

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


Implement 'lore trace' command with human and robot output

## Background

The trace command is Gate 5's capstone CLI. It answers 'Why was this code introduced?' by building file -> MR -> issue -> discussion chains.

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

## Codebase Context

- CLI pattern: same as file-history (Commands enum, handler in main.rs)
- trace.rs (bd-2n4): run_trace() returns TraceResult with chains
- Path parsing: support 'src/foo.rs:45' syntax (line number for future Tier 2)
- merge_requests.merged_at exists (migration 006) — use COALESCE(merged_at, updated_at) for ordering

## Approach

### 1. TraceArgs (`src/cli/mod.rs`):
```rust
#[derive(Parser)]
pub struct TraceArgs {
    pub path: String,                      // supports :line suffix
    #[arg(short = 'p', long)] pub project: Option<String>,
    #[arg(long)] pub discussions: bool,
    #[arg(long = "no-follow-renames")] pub no_follow_renames: bool,
    #[arg(short = 'n', long = "limit", default_value = "20")] pub limit: usize,
}
```

### 2. Path parsing:
```rust
fn parse_trace_path(input: &str) -> (String, Option<u32>) {
    if let Some((path, line)) = input.rsplit_once(':') {
        if let Ok(n) = line.parse::<u32>() { return (path.to_string(), Some(n)); }
    }
    (input.to_string(), None)
}
```
If line present: warn 'Line-level tracing requires Tier 2. Showing file-level results.'

### 3. Human output shows chains with MR -> issue -> discussion context

### 4. Robot JSON:
```json
{"ok": true, "data": {"path": "...", "resolved_paths": [...], "trace_chains": [...]}, "meta": {"tier": "api_only", "line_requested": null}}
```

## Acceptance Criteria

- [ ] `lore trace src/foo.rs` with human output
- [ ] `lore --robot trace src/foo.rs` with JSON
- [ ] :line suffix parses and emits Tier 2 warning
- [ ] -p, --discussions, --no-follow-renames, -n all work
- [ ] Rename-aware via resolve_rename_chain
- [ ] meta.tier = 'api_only'
- [ ] Added to VALID_COMMANDS and robot-docs
- [ ] `cargo check --all-targets` passes

## Files

- `src/cli/mod.rs` (TraceArgs + Commands::Trace)
- `src/cli/commands/trace.rs` (NEW)
- `src/cli/commands/mod.rs` (re-export)
- `src/main.rs` (handler + VALID_COMMANDS + robot-docs)

## TDD Loop

RED:
- `test_parse_trace_path_simple` - "src/foo.rs" -> (path, None)
- `test_parse_trace_path_with_line` - "src/foo.rs:42" -> (path, Some(42))
- `test_parse_trace_path_windows` - "C:/foo.rs" -> (path, None) — don't misparse drive letter

GREEN: Implement CLI wiring and handlers.

VERIFY: `cargo check --all-targets`

## Edge Cases

- Windows paths: don't misparse C: as line number
- No MR data: friendly message with suggestion to sync
- Very deep rename chain: bounded by resolve_rename_chain


lore explain: auto-generate issue/MR narrative

## Background
Given an issue or MR, auto-generate a structured narrative of what happened: who was involved, what decisions were made, what changed, and what is unresolved. Template-based v1 (no LLM dependency), deterministic and reproducible.

## Current Infrastructure (Verified 2026-02-12)
- show.rs: IssueDetail (line 69) and MrDetail (line 14) — entity detail with discussions
- timeline.rs: 5-stage pipeline SHIPPED — chronological event reconstruction
- notes table: 282K rows with body, author, created_at, is_system, discussion_id
- discussions table: links notes to parent entity (noteable_type, noteable_id), has resolved flag
- resource_state_events table: state changes with created_at, user_username (src/core/events_db.rs)
- resource_label_events table: label add/remove with created_at, user_username
- entity_references table (src/core/references.rs): cross-references between entities (closing MRs, related issues). Column names: `source_entity_type`, `source_entity_id`, `target_entity_type`, `target_entity_id`, `target_project_path`, `target_entity_iid`, `reference_type`, `source_method`

## Approach
New command: `lore explain issues N` / `lore explain mrs N`

### Data Assembly (reuse existing internals as library calls)
1. Entity detail: reuse show.rs query logic for IssueDetail/MrDetail
2. Timeline events: reuse timeline pipeline with entity-scoped seed
3. Discussion notes:
```sql
SELECT n.id, n.body, n.author_username, n.created_at
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE d.noteable_type = ? AND d.noteable_id = ?
  AND n.is_system = 0
ORDER BY n.created_at
```
4. Cross-references:
```sql
SELECT target_entity_type, target_entity_id, target_project_path,
       target_entity_iid, reference_type, source_method
FROM entity_references
WHERE (source_entity_type = ?1 AND source_entity_id = ?2)
UNION ALL
SELECT source_entity_type, source_entity_id, NULL,
       NULL, reference_type, source_method
FROM entity_references
WHERE (target_entity_type = ?1 AND target_entity_id = ?2)
```

### Key Decisions Heuristic
Notes from assignees/author that follow state or label changes within 1 hour:
```rust
struct StateOrLabelEvent {
    created_at: i64,  // ms epoch
    user: String,
    description: String,  // e.g. "state: opened -> closed" or "label: +bug"
}

fn extract_key_decisions(
    state_events: &[ResourceStateEvent],
    label_events: &[ResourceLabelEvent],
    notes: &[Note],
) -> Vec<KeyDecision> {
    // Merge both event types into a unified chronological list
    let mut events: Vec<StateOrLabelEvent> = Vec::new();
    for e in state_events {
        events.push(StateOrLabelEvent {
            created_at: e.created_at,
            user: e.user_username.clone(),
            description: format!("state: {} -> {}", e.from_state.as_deref().unwrap_or("?"), e.to_state),
        });
    }
    for e in label_events {
        let action = if e.action == "add" { "+" } else { "-" };
        events.push(StateOrLabelEvent {
            created_at: e.created_at,
            user: e.user_username.clone(),
            description: format!("label: {}{}", action, e.label_name.as_deref().unwrap_or("?")),
        });
    }
    events.sort_by_key(|e| e.created_at);

    let mut decisions = Vec::new();
    let one_hour_ms: i64 = 60 * 60 * 1000;

    for event in &events {
        // Find notes by same actor within 60 min after the event
        for note in notes {
            if note.author_username == event.user
                && note.created_at >= event.created_at
                && note.created_at <= event.created_at + one_hour_ms
            {
                decisions.push(KeyDecision {
                    timestamp: event.created_at,
                    actor: event.user.clone(),
                    action: event.description.clone(),
                    context_note: truncate(&note.body, 500),
                });
                break;  // one note per event
            }
        }
    }
    decisions.truncate(10);  // Cap at 10 key decisions
    decisions
}
```

### Narrative Sections
1. **Header**: title, author, opened date, state, assignees, labels, status_name
2. **Description excerpt**: first 500 chars of description (or full if shorter)
3. **Key decisions**: notes correlated with state/label changes (heuristic above)
4. **Activity summary**: counts of state changes, label changes, notes, time range
5. **Open threads**: discussions WHERE resolved = false
6. **Related entities**: closing MRs (with state), related issues from entity_references
7. **Timeline excerpt**: first 20 events from timeline pipeline

## Robot Mode Output Schema
```json
{
  "ok": true,
  "data": {
    "entity": {
      "type": "issue", "iid": 3864, "title": "...", "state": "opened",
      "author": "teernisse", "assignees": ["teernisse"],
      "labels": ["customer:BNSF"], "created_at": "...", "updated_at": "...",
      "url": "...", "status_name": "In progress"
    },
    "description_excerpt": "First 500 chars of description...",
    "key_decisions": [{
      "timestamp": "2026-01-15T...",
      "actor": "teernisse",
      "action": "state: opened -> in_progress",
      "context_note": "Starting work on the BNSF throw time integration..."
    }],
    "activity": {
      "state_changes": 3, "label_changes": 5, "notes": 42,
      "first_event": "2026-01-10T...", "last_event": "2026-02-12T..."
    },
    "open_threads": [{
      "discussion_id": "abc123",
      "started_by": "cseiber",
      "started_at": "2026-02-01T...",
      "note_count": 5,
      "last_note_at": "2026-02-10T..."
    }],
    "related": {
      "closing_mrs": [{ "iid": 200, "title": "...", "state": "merged" }],
      "related_issues": [{ "iid": 3800, "title": "Rail Break Card", "relation": "related" }]
    },
    "timeline_excerpt": [{ "timestamp": "...", "event_type": "...", "actor": "...", "summary": "..." }]
  },
  "meta": { "elapsed_ms": 350 }
}
```

## Clap Registration
```rust
// In src/main.rs Commands enum, add:
Explain {
    /// Entity type: "issues" or "mrs"
    entity_type: String,
    /// Entity IID
    iid: i64,
    /// Scope to project (fuzzy match)
    #[arg(short, long)]
    project: Option<String>,
},
```

## TDD Loop
RED: Tests in src/cli/commands/explain.rs:
- test_explain_issue_basic: insert issue + notes + state events, run explain, assert all sections present (entity, description_excerpt, key_decisions, activity, open_threads, related, timeline_excerpt)
- test_explain_key_decision_heuristic: insert state change event + note by same author within 30 min, assert note appears in key_decisions
- test_explain_key_decision_ignores_unrelated_notes: insert note by different author, assert it does NOT appear in key_decisions
- test_explain_open_threads: insert 2 discussions (1 resolved, 1 unresolved), assert only unresolved in open_threads
- test_explain_no_notes: issue with zero notes produces header + description + empty sections
- test_explain_mr: insert MR with merged_at, assert entity includes type="merge_request"
- test_explain_activity_counts: insert 3 state events + 2 label events + 10 notes, assert counts match

GREEN: Implement explain command with section assembly

VERIFY:
```bash
cargo test explain:: && cargo clippy --all-targets -- -D warnings
cargo run --release -- -J explain issues 3864 | jq '.data | keys'
# Should include: entity, description_excerpt, key_decisions, activity, open_threads, related, timeline_excerpt
```

## Acceptance Criteria
- [ ] lore explain issues N produces structured output for any synced issue
- [ ] lore explain mrs N produces structured output for any synced MR
- [ ] Robot mode returns all 7 sections
- [ ] Human mode renders readable narrative with headers and indentation
- [ ] Key decisions heuristic: captures notes within 60 min of state/label changes by same actor
- [ ] Works fully offline (no API calls, no LLM)
- [ ] Performance: <500ms for issue with 50 notes
- [ ] Command registered in main.rs and robot-docs
- [ ] key_decisions capped at 10, timeline_excerpt capped at 20 events

## Edge Cases
- Issue with empty description: description_excerpt = "(no description)"
- Issue with 500+ notes: timeline_excerpt capped at 20, key_decisions capped at 10
- Issue not found in local DB: exit code 17 with suggestion to sync
- Ambiguous project: exit code 18 with suggestion to use -p flag
- MR with no review activity: activity section shows zeros
- Cross-project references: show as unresolved with project path hint
- Notes that are pure code blocks: include in key_decisions if correlated with events (they may contain implementation decisions)
- ResourceStateEvent/ResourceLabelEvent field names: check src/core/events_db.rs for exact struct definitions before implementing

## Dependency Context
- **bd-2g50 (data gaps)**: BLOCKER. Provides `closed_at` field on IssueDetail for the header section. Without it, explain can still show state="closed" but won't have the exact close timestamp.

## Files to Create/Modify
- NEW: src/cli/commands/explain.rs
- src/cli/commands/mod.rs (add pub mod explain; re-export)
- src/main.rs (register Explain subcommand in Commands enum, add handle_explain fn)
- Reuse: show.rs queries, timeline pipeline, notes/discussions/resource_events queries from src/core/events_db.rs


NOTE-2G: Parent metadata change propagation to note documents

## Background
Note documents inherit labels and title from parent issue/MR. When parent metadata changes, note documents become stale. The existing pipeline already marks discussion documents dirty on parent changes — note documents need the same treatment.

## Approach
Find where ingestion detects parent entity changes and marks discussion documents dirty. The dirty marking for discussions happens in:
- src/ingestion/discussions.rs line 127: mark_dirty_tx(&tx, SourceType::Discussion, local_discussion_id)
- src/ingestion/mr_discussions.rs line 162 and 362: mark_dirty_tx(&tx, SourceType::Discussion, local_discussion_id)

These fire when a discussion is upserted (which happens when parent entity is re-ingested). For note documents, we need to additionally mark all non-system notes of that discussion as dirty:

After each mark_dirty_tx for Discussion, add:
   // Mark child note documents dirty (they inherit parent metadata)
   let note_ids: Vec<i64> = tx.prepare("SELECT id FROM notes WHERE discussion_id = ? AND is_system = 0")?
       .query_map([local_discussion_id], |r| r.get(0))?
       .collect::<std::result::Result<Vec<_>, _>>()?;
   for note_id in note_ids {
       dirty_tracker::mark_dirty_tx(&tx, SourceType::Note, note_id)?;
   }

Alternative (more efficient, set-based):
   INSERT INTO dirty_sources (source_type, source_id, queued_at)
   SELECT 'note', n.id, ?1
   FROM notes n
   WHERE n.discussion_id = ?2 AND n.is_system = 0
   ON CONFLICT(source_type, source_id) DO UPDATE SET queued_at = excluded.queued_at, attempt_count = 0

Use the set-based approach for better performance with large discussions.

## Files
- MODIFY: src/ingestion/discussions.rs (add note dirty marking after line 127)
- MODIFY: src/ingestion/mr_discussions.rs (add note dirty marking after lines 162 and 362)

## TDD Anchor
RED: test_parent_title_change_marks_notes_dirty — change issue title, re-ingest discussions, assert note documents appear in dirty_sources.
GREEN: Add set-based INSERT INTO dirty_sources after discussion dirty marking.
VERIFY: cargo test parent_title_change_marks_notes -- --nocapture
Tests: test_parent_label_change_marks_notes_dirty (modify issue labels, re-ingest, check dirty queue)

## Acceptance Criteria
- [ ] Discussion upsert for issue marks child non-system note documents dirty
- [ ] Discussion upsert for MR marks child non-system note documents dirty (both call sites)
- [ ] Only non-system notes marked dirty (is_system = 0 filter)
- [ ] Set-based SQL (not per-note loop) for performance
- [ ] Both tests pass

## Dependency Context
- Depends on NOTE-2D (bd-2ezb): dirty tracking infrastructure for notes must exist (dirty_sources accepts source_type='note', regenerator handles it)

## Edge Cases
- Discussion with 0 non-system notes: set-based INSERT is a no-op
- Discussion with 100+ notes: set-based approach handles efficiently in one SQL statement
- Concurrent discussion ingestion: ON CONFLICT DO UPDATE handles race safely


Implement project scope resolution for me command

## Background
The `lore me` command needs to resolve which project(s) to query. There are three modes: single project (--project), all projects (--all), or default (use default_project if set, else all). The existing `Config.effective_project()` method already handles CLI flag > default_project fallback, and `resolve_project()` in `src/core/project.rs` handles fuzzy matching.

## Approach
Create in `src/cli/commands/me/mod.rs` (or a submodule):
```rust
pub enum ProjectScope {
    Single(i64),  // internal DB project.id (NOT gitlab_project_id)
    All,
}

pub fn resolve_project_scope(
    project_flag: Option<&str>,
    all_flag: bool,
    config: &Config,
    conn: &Connection,
) -> Result<ProjectScope> {
    // AC-8.4: mutual exclusivity
    if project_flag.is_some() && all_flag {
        return Err(LoreError::UsageError(
            "Cannot use --project and --all together.".to_string()
        ));
    }
    // AC-8.2: --all overrides everything
    if all_flag {
        return Ok(ProjectScope::All);
    }
    // Use effective_project: CLI flag > config.default_project
    let effective = project_flag.or(config.default_project.as_deref());
    match effective {
        Some(p) => Ok(ProjectScope::Single(resolve_project(conn, p)?)),
        None => Ok(ProjectScope::All),  // AC-8.1: no default → all
    }
}
```

Imports:
```rust
use crate::Config;
use crate::core::error::{LoreError, Result};
use crate::core::project::resolve_project;
use rusqlite::Connection;
```

Note: `resolve_project()` returns the internal DB `id` (not `gitlab_project_id`). Match strategy: exact → case-insensitive → suffix → substring. Returns `LoreError::Ambiguous` (exit 18) on multiple matches, `LoreError::Other` on no match.

## Acceptance Criteria
- [ ] `--project` and `--all` both passed → `LoreError::UsageError` with exit code 2 (AC-8.4)
- [ ] `--project "repo"` → resolves via `resolve_project()` → `Single(id)` (AC-8.3)
- [ ] `--all` → `All` (AC-8.2)
- [ ] No flags + `config.default_project` set → resolve it → `Single(id)` (AC-8.1)
- [ ] No flags + no default_project → `All` (AC-8.1)
- [ ] Unknown --project → `LoreError::Other` (from resolve_project)
- [ ] Ambiguous --project → `LoreError::Ambiguous` exit 18 (from resolve_project)

## Files
- MODIFY: src/cli/commands/me/mod.rs (ProjectScope enum + resolve function)

## TDD Anchor
RED: Write `test_project_and_all_mutually_exclusive` in an in-memory DB test. Pass both flags, assert error matches `LoreError::UsageError`.
GREEN: Implement resolve_project_scope.
VERIFY: `cargo test project_scope`

Additional tests:
- test_project_flag_resolves (insert project, pass --project with matching path)
- test_all_flag_returns_all
- test_default_project_used (no CLI flag, config has default_project)
- test_no_default_no_flags_returns_all

## Edge Cases
- `resolve_project` returns internal DB `id`, not `gitlab_project_id` — use the returned id for WHERE clauses on `project_id`
- default_project value in config might not exist in DB (deleted/unsynced) — resolve_project will error

## Dependency Context
Uses `LoreError::UsageError` variant from bd-1f1f.
Uses `resolve_project` from `src/core/project.rs` (existing, no bead needed).
Uses `Config.default_project` field from `src/core/config.rs`.


Implement embedding pipeline with chunking

## Background
The embedding pipeline takes documents, chunks them (paragraph-boundary splitting with overlap), sends chunks to Ollama for embedding via async HTTP, and stores vectors in sqlite-vec + metadata. It uses keyset pagination, concurrent HTTP requests via FuturesUnordered, per-batch transactions, and dimension validation.

## Approach
Create \`src/embedding/pipeline.rs\` per PRD Section 4.4. **The pipeline is async.**

**Constants (per PRD):**
```rust
const BATCH_SIZE: usize = 32;           // texts per Ollama API call
const DB_PAGE_SIZE: usize = 500;        // keyset pagination page size
const EXPECTED_DIMS: usize = 768;       // nomic-embed-text dimensions
const CHUNK_MAX_CHARS: usize = 32_000;  // max chars per chunk
const CHUNK_OVERLAP_CHARS: usize = 500; // overlap between chunks
```

**Core async function:**
```rust
pub async fn embed_documents(
    conn: &Connection,
    client: &OllamaClient,
    selection: EmbedSelection,
    concurrency: usize,              // max in-flight HTTP requests
    progress_callback: Option<Box<dyn Fn(usize, usize)>>,
) -> Result<EmbedResult>
```

**EmbedSelection:** Pending | RetryFailed
**EmbedResult:** { embedded, failed, skipped }

**Algorithm (per PRD):**
1. count_pending_documents(conn, selection) for progress total
2. Keyset pagination loop: find_pending_documents(conn, DB_PAGE_SIZE, last_id, selection)
3. For each page:
   a. Begin transaction
   b. For each doc: clear_document_embeddings(&tx, doc.id), split_into_chunks(&doc.content)
   c. Build ChunkWork items with doc_hash + chunk_hash
   d. Commit clearing transaction
4. Batch ChunkWork texts into Ollama calls (BATCH_SIZE=32)
5. Use **FuturesUnordered** for concurrent HTTP, cap at \`concurrency\`
6. collect_writes() in per-batch transactions: validate dims (768), store LE bytes, write metadata
7. On error: record_embedding_error per chunk (not abort)
8. Advance keyset cursor

**ChunkWork struct:**
```rust
struct ChunkWork {
    doc_id: i64,
    chunk_index: usize,
    doc_hash: String,    // SHA-256 of FULL document (staleness detection)
    chunk_hash: String,  // SHA-256 of THIS chunk (provenance)
    text: String,
}
```

**Splitting:** split_into_chunks(content) -> Vec<(usize, String)>
- Documents <= CHUNK_MAX_CHARS: single chunk (index 0)
- Longer: split at paragraph boundaries (\\n\\n), fallback to sentence/word, with CHUNK_OVERLAP_CHARS overlap

**Storage:** embeddings as raw LE bytes, rowid = encode_rowid(doc_id, chunk_idx)
**Staleness detection:** uses document_hash (not chunk_hash) because it's document-level

Also create \`src/embedding/change_detector.rs\` (referenced in PRD module structure):
```rust
pub fn detect_embedding_changes(conn: &Connection) -> Result<Vec<PendingDocument>>;
```

## Acceptance Criteria
- [ ] Pipeline is async (uses FuturesUnordered for concurrent HTTP)
- [ ] concurrency parameter caps in-flight HTTP requests
- [ ] progress_callback reports (processed, total)
- [ ] New documents embedded, changed re-embedded, unchanged skipped
- [ ] clear_document_embeddings before re-embedding (range delete vec0 + metadata)
- [ ] Chunking at paragraph boundaries with 500-char overlap
- [ ] Short documents (<32k chars) produce exactly 1 chunk
- [ ] Embeddings stored as raw LE bytes in vec0
- [ ] Rowids encoded via encode_rowid(doc_id, chunk_index)
- [ ] Dimension validation: 768 floats per embedding (mismatch -> record error, not store)
- [ ] Per-batch transactions for writes
- [ ] Errors recorded in embedding_metadata per chunk (last_error, attempt_count)
- [ ] Keyset pagination (d.id > last_id, not OFFSET)
- [ ] Pending detection uses document_hash (not chunk_hash)
- [ ] \`cargo build\` succeeds

## Files
- \`src/embedding/pipeline.rs\` — new file (async)
- \`src/embedding/change_detector.rs\` — new file
- \`src/embedding/mod.rs\` — add \`pub mod pipeline; pub mod change_detector;\` + re-exports

## TDD Loop
RED: Unit tests for chunking:
- \`test_short_document_single_chunk\` — <32k produces [(0, full_content)]
- \`test_long_document_multiple_chunks\` — >32k splits at paragraph boundaries
- \`test_chunk_overlap\` — adjacent chunks share 500-char overlap
- \`test_no_paragraph_boundary\` — falls back to char boundary
Integration tests need Ollama or mock.
GREEN: Implement split_into_chunks, embed_documents (async)
VERIFY: \`cargo test pipeline\`

## Edge Cases
- Empty document content_text: skip (don't embed)
- No paragraph boundaries: split at CHUNK_MAX_CHARS with overlap
- Ollama error for one batch: record error per chunk, continue with next batch
- Dimension mismatch (model returns 512 instead of 768): record error, don't store corrupt data
- Document deleted between pagination and embedding: skip gracefully

Implemented embedding pipeline: chunking at paragraph boundaries with 500-char overlap, change detector (keyset pagination, hash-based staleness), async embed via Ollama with batch processing, dimension validation, per-chunk error recording, LE byte vector storage. 7 chunking tests pass. 289 total tests.


OBSERV: Create migration 014 for sync_runs enrichment

## Background
The sync_runs table (created in migration 001) has columns id, started_at, heartbeat_at, finished_at, status, command, error, metrics_json but NOTHING writes to it. This migration adds columns for the observability correlation ID and aggregate counts, enabling queryable sync history.

## Approach
Create migrations/014_sync_runs_enrichment.sql:

```sql
-- Migration 014: sync_runs enrichment for observability
-- Adds correlation ID and aggregate counts for queryable sync history

ALTER TABLE sync_runs ADD COLUMN run_id TEXT;
ALTER TABLE sync_runs ADD COLUMN total_items_processed INTEGER DEFAULT 0;
ALTER TABLE sync_runs ADD COLUMN total_errors INTEGER DEFAULT 0;

-- Index for correlation queries (find run by run_id from logs)
CREATE INDEX IF NOT EXISTS idx_sync_runs_run_id ON sync_runs(run_id);
```

Migration naming convention: check migrations/ directory. Current latest is 013_resource_event_watermarks.sql. Next is 014.

Note: SQLite ALTER TABLE ADD COLUMN is always safe -- it sets NULL for existing rows. DEFAULT 0 applies to new INSERTs only.

## Acceptance Criteria
- [ ] Migration 014 applies cleanly on a fresh DB (all migrations 001-014)
- [ ] Migration 014 applies cleanly on existing DB with 001-013 already applied
- [ ] sync_runs table has run_id TEXT column
- [ ] sync_runs table has total_items_processed INTEGER DEFAULT 0 column
- [ ] sync_runs table has total_errors INTEGER DEFAULT 0 column
- [ ] idx_sync_runs_run_id index exists
- [ ] Existing sync_runs rows (if any) have NULL run_id, 0 for counts
- [ ] cargo clippy --all-targets -- -D warnings passes (no code changes, but verify migration is picked up)

## Files
- migrations/014_sync_runs_enrichment.sql (new file)

## TDD Loop
RED:
  - test_migration_014_applies: apply all migrations on fresh in-memory DB, query sync_runs schema
  - test_migration_014_idempotent: CREATE INDEX IF NOT EXISTS makes re-run safe; ALTER TABLE ADD COLUMN is NOT idempotent in SQLite (will error). Consider: skip this test or use IF NOT EXISTS workaround
GREEN: Create migration file
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- ALTER TABLE ADD COLUMN in SQLite: NOT idempotent. Running migration twice will error "duplicate column name." The migration system should prevent re-runs, but IF NOT EXISTS is not available for ALTER TABLE in SQLite. Rely on migration tracking.
- Migration numbering conflict: if another PR adds 014 first, renumber to 015. Check before merging.
- metrics_json already exists (from migration 001): we don't touch it. The new columns supplement it with queryable aggregates.

Created migration 014 adding run_id TEXT, total_items_processed INTEGER, total_errors INTEGER to sync_runs, with idx_sync_runs_run_id index


Extend SyncRunRecorder with surgical mode lifecycle methods

## Background
The existing `SyncRunRecorder` in `src/core/sync_run.rs` manages sync run lifecycle with three methods: `start()` (creates row, returns Self), `succeed(self, ...)` (consumes self, sets succeeded), and `fail(self, ...)` (consumes self, sets failed). Both `succeed()` and `fail()` take ownership of `self` — this is intentional to prevent double-finalization.

Surgical sync needs additional lifecycle methods to:
1. Set surgical-specific metadata (mode, phase, IIDs JSON) after `start()`
2. Record per-entity results (increment counters, store entity-level outcomes)
3. Cancel a run (distinct from failure — user-initiated or timeout)
4. Update phase progression during the surgical pipeline

These methods operate on the columns added by migration 027 (bead bd-tiux).

## Approach

### Step 1: Add `set_surgical_metadata` method

Called once after `start()` to set the surgical mode columns:

```rust
pub fn set_surgical_metadata(
    &self,
    conn: &Connection,
    mode: &str,
    phase: &str,
    iids_json: &str,
) -> Result<()> {
    conn.execute(
        "UPDATE sync_runs SET mode = ?1, phase = ?2, surgical_iids_json = ?3 WHERE id = ?4",
        rusqlite::params![mode, phase, iids_json, self.row_id],
    )?;
    Ok(())
}
```

Takes `&self` (not `self`) because the recorder continues to be used after metadata is set.

### Step 2: Add `update_phase` method

Called as the surgical pipeline progresses through phases:

```rust
pub fn update_phase(&self, conn: &Connection, phase: &str) -> Result<()> {
    conn.execute(
        "UPDATE sync_runs SET phase = ?1, heartbeat_at = ?2 WHERE id = ?3",
        rusqlite::params![phase, now_ms(), self.row_id],
    )?;
    Ok(())
}
```

### Step 3: Add `record_entity_result` method

Called after each entity (issue or MR) is processed to increment counters:

```rust
pub fn record_entity_result(
    &self,
    conn: &Connection,
    entity_type: &str,
    stage: &str,
) -> Result<()> {
    let column = match (entity_type, stage) {
        ("issue", "fetched") => "issues_fetched",
        ("issue", "ingested") => "issues_ingested",
        ("mr", "fetched") => "mrs_fetched",
        ("mr", "ingested") => "mrs_ingested",
        ("issue" | "mr", "skipped_stale") => "skipped_stale",
        ("doc", "regenerated") => "docs_regenerated",
        ("doc", "embedded") => "docs_embedded",
        (_, "warning") => "warnings_count",
        _ => return Ok(()),  // Unknown combinations are silently ignored
    };
    conn.execute(
        &format!("UPDATE sync_runs SET {column} = {column} + 1 WHERE id = ?1"),
        rusqlite::params![self.row_id],
    )?;
    Ok(())
}
```

Note: The column name comes from a hardcoded match, NOT from user input — no SQL injection risk.

### Step 4: Add `cancel` method

Consumes self (like succeed/fail) to finalize the run as cancelled:

```rust
pub fn cancel(self, conn: &Connection, reason: &str) -> Result<()> {
    let now = now_ms();
    conn.execute(
        "UPDATE sync_runs SET finished_at = ?1, cancelled_at = ?2, status = 'cancelled', error = ?3 WHERE id = ?4",
        rusqlite::params![now, now, reason, self.row_id],
    )?;
    Ok(())
}
```

Takes `self` (ownership) like `succeed()` and `fail()` — prevents further use after cancellation.

### Step 5: Expose `row_id` getter

The orchestrator (bd-1i4i) may need the row_id for logging/tracing:

```rust
pub fn row_id(&self) -> i64 {
    self.row_id
}
```

## Acceptance Criteria
- [ ] `set_surgical_metadata(&self, conn, mode, phase, iids_json)` writes mode/phase/surgical_iids_json columns
- [ ] `update_phase(&self, conn, phase)` updates phase and heartbeat_at
- [ ] `record_entity_result(&self, conn, entity_type, stage)` increments the correct counter column
- [ ] `record_entity_result` silently ignores unknown entity_type/stage combinations
- [ ] `cancel(self, conn, reason)` consumes self, sets status='cancelled', finished_at, cancelled_at, error
- [ ] `row_id()` returns the internal row_id
- [ ] `succeed(self, ...)` still works after `set_surgical_metadata` + `record_entity_result` calls
- [ ] `fail(self, ...)` still works after `set_surgical_metadata` + `update_phase` calls
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes
- [ ] All existing sync_run tests continue to pass

## Files
- MODIFY: src/core/sync_run.rs (add methods to SyncRunRecorder impl block)
- MODIFY: src/core/sync_run_tests.rs (add new tests)

## TDD Anchor
RED: Write tests in `src/core/sync_run_tests.rs`:

```rust
#[test]
fn surgical_lifecycle_start_metadata_succeed() {
    let conn = setup_test_db();
    let recorder = SyncRunRecorder::start(&conn, "sync", "surg001").unwrap();
    let row_id = recorder.row_id();

    recorder.set_surgical_metadata(
        &conn, "surgical", "preflight", r#"{"issues":[7,8],"mrs":[101]}"#,
    ).unwrap();

    recorder.update_phase(&conn, "ingest").unwrap();
    recorder.record_entity_result(&conn, "issue", "fetched").unwrap();
    recorder.record_entity_result(&conn, "issue", "fetched").unwrap();
    recorder.record_entity_result(&conn, "issue", "ingested").unwrap();
    recorder.record_entity_result(&conn, "mr", "fetched").unwrap();
    recorder.record_entity_result(&conn, "mr", "ingested").unwrap();

    recorder.succeed(&conn, &[], 3, 0).unwrap();

    let (mode, phase, iids, issues_fetched, mrs_fetched, issues_ingested, mrs_ingested, status): (
        String, String, String, i64, i64, i64, i64, String,
    ) = conn.query_row(
        "SELECT mode, phase, surgical_iids_json, issues_fetched, mrs_fetched, issues_ingested, mrs_ingested, status
         FROM sync_runs WHERE id = ?1",
        [row_id],
        |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?, r.get(6)?, r.get(7)?)),
    ).unwrap();

    assert_eq!(mode, "surgical");
    assert_eq!(phase, "ingest");  // Last phase set before succeed
    assert!(iids.contains("101"));
    assert_eq!(issues_fetched, 2);
    assert_eq!(mrs_fetched, 1);
    assert_eq!(issues_ingested, 1);
    assert_eq!(mrs_ingested, 1);
    assert_eq!(status, "succeeded");
}

#[test]
fn surgical_lifecycle_cancel() {
    let conn = setup_test_db();
    let recorder = SyncRunRecorder::start(&conn, "sync", "cancel01").unwrap();
    let row_id = recorder.row_id();

    recorder.set_surgical_metadata(&conn, "surgical", "preflight", "{}").unwrap();
    recorder.cancel(&conn, "User requested cancellation").unwrap();

    let (status, error, cancelled_at, finished_at): (String, Option<String>, Option<i64>, Option<i64>) = conn.query_row(
        "SELECT status, error, cancelled_at, finished_at FROM sync_runs WHERE id = ?1",
        [row_id],
        |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
    ).unwrap();

    assert_eq!(status, "cancelled");
    assert_eq!(error.as_deref(), Some("User requested cancellation"));
    assert!(cancelled_at.is_some());
    assert!(finished_at.is_some());
}

#[test]
fn record_entity_result_ignores_unknown() {
    let conn = setup_test_db();
    let recorder = SyncRunRecorder::start(&conn, "sync", "unk001").unwrap();
    // Should not panic or error on unknown combinations
    recorder.record_entity_result(&conn, "widget", "exploded").unwrap();
}

#[test]
fn record_entity_result_json_counters() {
    let conn = setup_test_db();
    let recorder = SyncRunRecorder::start(&conn, "sync", "cnt001").unwrap();
    let row_id = recorder.row_id();

    recorder.record_entity_result(&conn, "doc", "regenerated").unwrap();
    recorder.record_entity_result(&conn, "doc", "regenerated").unwrap();
    recorder.record_entity_result(&conn, "doc", "embedded").unwrap();
    recorder.record_entity_result(&conn, "issue", "skipped_stale").unwrap();

    let (docs_regen, docs_embed, skipped): (i64, i64, i64) = conn.query_row(
        "SELECT docs_regenerated, docs_embedded, skipped_stale FROM sync_runs WHERE id = ?1",
        [row_id],
        |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
    ).unwrap();

    assert_eq!(docs_regen, 2);
    assert_eq!(docs_embed, 1);
    assert_eq!(skipped, 1);
}
```

GREEN: Add all methods to `SyncRunRecorder`.
VERIFY: `cargo test surgical_lifecycle && cargo test record_entity_result`

## Edge Cases
- `succeed()` and `fail()` consume `self` — the compiler enforces that no methods are called after finalization. `cancel()` also consumes self for the same reason.
- `set_surgical_metadata`, `update_phase`, and `record_entity_result` take `&self` — they can be called multiple times before finalization.
- The `record_entity_result` match uses a hardcoded column name derived from known string constants, not user input. The `format!` is safe because the column name is always one of the hardcoded strings.
- `record_entity_result` silently returns Ok(()) for unknown entity_type/stage combos rather than erroring — this avoids breaking the pipeline for non-critical telemetry.
- Phase is NOT overwritten by `succeed()`/`fail()`/`cancel()` — the last phase set via `update_phase()` is preserved as the "phase at completion" for observability.

## Dependency Context
Depends on bd-tiux (migration 027) for the surgical columns to exist. Downstream beads bd-1i4i (orchestrator) and bd-3jqx (integration tests) use these methods.

Completed: all implementation work done, code reviewed, tests passing


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.