Add SurgicalPreflightFailed error variant and foundation visibility changes

## Background
Surgical sync needs a dedicated error variant for preflight failures (e.g., IID not found on GitLab, project mismatch). The existing `GitLabNotFound` variant maps to exit code 6 and is too generic — it does not convey that the failure occurred during surgical preflight validation. A new `SurgicalPreflightFailed` variant in `LoreError` with a clear Display message and exit code 6 provides actionable feedback to both human and robot consumers.

Additionally, the `process_single_issue` function in `src/ingestion/issues.rs` and `process_single_mr` + `ProcessMrResult` in `src/ingestion/merge_requests.rs` are currently private. The surgical sync orchestrator (downstream bead bd-3sez) will need to call these from `src/core/surgical.rs`, so they must be raised to `pub(crate)` visibility. No config field is needed for this bead — the surgical sync feature is triggered purely by CLI flags (bead bd-1lja).

## Approach

### Step 1: Add ErrorCode variant (src/core/error.rs, line ~23)
Add `SurgicalPreflightFailed` to the `ErrorCode` enum (after `Ambiguous`). Wire it through three impls:
- `Display`: maps to `"SURGICAL_PREFLIGHT_FAILED"`
- `exit_code()`: maps to `6` (same category as GitLabNotFound — resource-level failure)

### Step 2: Add LoreError variant (src/core/error.rs, after EmbeddingsNotBuilt ~line 155)
```rust
#[error("Surgical preflight failed for {entity_type} !{iid} in {project}: {reason}")]
SurgicalPreflightFailed {
    entity_type: String,  // "issue" or "merge_request"
    iid: u64,
    project: String,
    reason: String,
},
```
Wire in `code()` → `ErrorCode::SurgicalPreflightFailed`, `suggestion()` → a helpful message about verifying the IID exists, `actions()` → `["lore issues -p <project>", "lore mrs -p <project>"]`.

### Step 3: Raise visibility (src/ingestion/issues.rs, src/ingestion/merge_requests.rs)
- `process_single_issue` at line 143: `fn` → `pub(crate) fn`
- `process_single_mr` at line 144: `fn` → `pub(crate) fn`
- `ProcessMrResult` at line 138: `struct` → `pub(crate) struct` (and its fields)

## Acceptance Criteria
- [ ] `ErrorCode::SurgicalPreflightFailed` exists with Display `"SURGICAL_PREFLIGHT_FAILED"` and exit code 6
- [ ] `LoreError::SurgicalPreflightFailed { entity_type, iid, project, reason }` exists
- [ ] `LoreError::SurgicalPreflightFailed { .. }.code()` returns `ErrorCode::SurgicalPreflightFailed`
- [ ] Display output includes entity_type, iid, project, and reason
- [ ] `suggestion()` returns a non-None helpful string
- [ ] `process_single_issue` is `pub(crate)`
- [ ] `process_single_mr` is `pub(crate)`
- [ ] `ProcessMrResult` and its fields are `pub(crate)`
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes
- [ ] All existing tests pass

## Files
- MODIFY: src/core/error.rs (add ErrorCode variant, LoreError variant, wire Display/exit_code/code/suggestion/actions)
- MODIFY: src/ingestion/issues.rs (pub(crate) on process_single_issue)
- MODIFY: src/ingestion/merge_requests.rs (pub(crate) on process_single_mr, ProcessMrResult + fields)

## TDD Anchor
RED: Write three tests in a new `#[cfg(test)] mod tests` block at the bottom of `src/core/error.rs`:

```rust
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn surgical_preflight_failed_display() {
        let err = LoreError::SurgicalPreflightFailed {
            entity_type: "issue".to_string(),
            iid: 42,
            project: "group/repo".to_string(),
            reason: "not found on GitLab".to_string(),
        };
        let msg = err.to_string();
        assert!(msg.contains("issue"), "missing entity_type: {msg}");
        assert!(msg.contains("42"), "missing iid: {msg}");
        assert!(msg.contains("group/repo"), "missing project: {msg}");
        assert!(msg.contains("not found on GitLab"), "missing reason: {msg}");
    }

    #[test]
    fn surgical_preflight_failed_error_code() {
        let code = ErrorCode::SurgicalPreflightFailed;
        assert_eq!(code.exit_code(), 6);
    }

    #[test]
    fn surgical_preflight_failed_code_mapping() {
        let err = LoreError::SurgicalPreflightFailed {
            entity_type: "merge_request".to_string(),
            iid: 99,
            project: "ns/proj".to_string(),
            reason: "404".to_string(),
        };
        assert_eq!(err.code(), ErrorCode::SurgicalPreflightFailed);
    }
}
```

GREEN: Add the variant and wire all impls.
VERIFY: `cargo test surgical_preflight_failed`

## Edge Cases
- Exit code 6 is shared with `GitLabNotFound` — this is intentional (same semantic category: resource not found). The `ErrorCode` Display string distinguishes them for robot consumers.
- The `entity_type` field uses strings ("issue", "merge_request") rather than an enum to avoid over-abstraction for two values.
- Visibility changes are `pub(crate)`, not `pub` — these are internal implementation details, not public API.

## Dependency Context
This is a leaf/foundation bead with no upstream dependencies. Downstream beads bd-1i4i (orchestrator) and bd-3sez (surgical.rs module) depend on this for both the error variant and the pub(crate) visibility of ingestion functions.

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


Epic: Gate 2 - Cross-Reference Extraction

## Background
Gate 2 builds the entity relationship graph that connects issues, MRs, and discussions. Without cross-references, temporal queries can only show events for individually-matched entities. With them, "lore timeline auth migration" can discover that MR !567 closed issue #234, which spawned follow-up issue #299 — even if #299 does not contain the words "auth migration."

Three data sources feed entity_references:
1. **Structured API (reliable):** GET /projects/:id/merge_requests/:iid/closes_issues
2. **State events (reliable):** resource_state_events.source_merge_request_id
3. **System note parsing (best-effort):** "mentioned in !456", "closed by !789" patterns

## Architecture
- **entity_references table:** Already created in migration 011 (bd-hu3/bd-czk). Stores source→target relationships with reference_type (closes/mentioned/related) and source_method provenance.
- **Directionality convention:** source = entity where reference was observed, target = entity being referenced. Consistent across all source_methods.
- **Unresolved references:** Cross-project refs stored with target_entity_id=NULL, target_project_path populated. Still valuable for timeline narratives.
- **closes_issues fetch:** Uses generic dependent fetch queue (job_type = mr_closes_issues). One API call per MR.
- **System note parsing:** Local post-processing after all dependent fetches complete. No API calls. English-only, best-effort.

## Children (Execution Order)
1. **bd-czk** [CLOSED] — entity_references schema (folded into migration 011)
2. **bd-8t4** [OPEN] — Extract cross-references from resource_state_events (source_merge_request_id)
3. **bd-3ia** [OPEN] — Fetch closes_issues API and populate entity_references
4. **bd-1ji** [OPEN] — Parse system notes for cross-reference patterns

## Gate Completion Criteria
- [ ] entity_references populated from closes_issues API for all synced MRs
- [ ] entity_references populated from state events where source_merge_request_id present
- [ ] System notes parsed for cross-reference patterns (English instances)
- [ ] Cross-project references stored as unresolved (target_entity_id=NULL)
- [ ] source_method tracks provenance of each reference
- [ ] Deduplication: same relationship from multiple sources stored once (UNIQUE constraint)
- [ ] Timeline JSON includes expansion provenance (via) for expanded entities
- [ ] Integration test: sync with all three extraction methods, verify entity_references populated

## Dependencies
- Depends on: Gate 1 (bd-2zl) — event tables and dependent fetch queue
- Downstream: Gate 3 (bd-ike) depends on entity_references for BFS expansion

All child beads completed: bd-8t4 (state event extraction), bd-3ia (closes_issues API), bd-1ji (system note parsing)


Implement scope context (global project filter)

## Background
The scope context provides a global project filter that flows through all query bridge functions. Users can pin to a specific project set or view all projects. The P keybinding opens a scope picker overlay. Scope is persisted in session state.

## Approach
Create crates/lore-tui/src/scope.rs:
- ScopeContext enum: AllProjects, Pinned(Vec<ProjectInfo>)
- ProjectInfo: id (i64), path (String)
- scope_filter_sql(scope: &ScopeContext) -> String: generates WHERE clause fragment
- All action.rs query functions accept &ScopeContext parameter
- Scope picker overlay: list of projects with checkbox selection
- P keybinding toggles scope picker from any screen

## Acceptance Criteria
- [ ] AllProjects scope returns unfiltered results
- [ ] Pinned scope filters to specific project IDs
- [ ] All query functions respect global scope
- [ ] P keybinding opens scope picker
- [ ] Scope persisted in session state
- [ ] Scope change triggers re-query of current screen

## Files
- CREATE: crates/lore-tui/src/scope.rs
- MODIFY: crates/lore-tui/src/action.rs (add scope parameter to all queries)

## TDD Anchor
RED: Write test_scope_filter_sql that creates Pinned scope with 2 projects, asserts generated SQL contains IN (1, 2).
GREEN: Implement scope_filter_sql.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_scope_filter

## Edge Cases
- Single-project datasets: scope picker not needed, but should still work
- Very many projects (>50): scope picker should be scrollable
- Scope change mid-pagination: reset cursor to first page

## Dependency Context
Uses AppState from "Implement AppState composition" task.
Uses session persistence from "Implement session persistence" task.


Add half_life_decay() pure function

## Background
The decay function is the mathematical core of the scoring model. It must be correct, tested first (TDD RED), and verified independently of any DB or SQL changes.

## Approach
Add to who.rs as a private function near the top of the module (before query_expert):

```rust
/// Exponential half-life decay: R = 2^(-t/h)
/// Returns 1.0 at elapsed=0, 0.5 at elapsed=half_life, 0.0 if half_life=0.
fn half_life_decay(elapsed_ms: i64, half_life_days: u32) -> f64 {
    let days = (elapsed_ms as f64 / 86_400_000.0).max(0.0);
    let hl = f64::from(half_life_days);
    if hl <= 0.0 { return 0.0; }
    2.0_f64.powf(-days / hl)
}
```

## TDD Loop

### RED (write first):
```rust
#[test]
fn test_half_life_decay_math() {
    let hl_180 = 180;
    // At t=0, full retention
    assert!((half_life_decay(0, hl_180) - 1.0).abs() < f64::EPSILON);
    // At t=half_life, exactly 0.5
    let one_hl_ms = 180 * 86_400_000_i64;
    assert!((half_life_decay(one_hl_ms, hl_180) - 0.5).abs() < 1e-10);
    // At t=2*half_life, exactly 0.25
    assert!((half_life_decay(2 * one_hl_ms, hl_180) - 0.25).abs() < 1e-10);
    // Negative elapsed clamped to 0 -> 1.0
    assert!((half_life_decay(-1000, hl_180) - 1.0).abs() < f64::EPSILON);
    // Zero half-life -> 0.0 (div-by-zero guard)
    assert!((half_life_decay(86_400_000, 0)).abs() < f64::EPSILON);
}

#[test]
fn test_score_monotonicity_by_age() {
    // For any half-life, older timestamps must never produce higher decay than newer ones.
    // Use deterministic LCG PRNG (no rand dependency).
    let mut seed: u64 = 42;
    let hl = 90_u32;
    for _ in 0..50 {
        seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
        let newer_ms = (seed % 100_000_000) as i64;       // 0-100M ms (~1.15 days max)
        seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
        let older_ms = newer_ms + (seed % 500_000_000) as i64; // always >= newer
        assert!(
            half_life_decay(older_ms, hl) <= half_life_decay(newer_ms, hl),
            "Monotonicity violated: decay({older_ms}) > decay({newer_ms})"
        );
    }
}
```

### GREEN: Add the half_life_decay function (3 lines of math).
### VERIFY: `cargo test -p lore -- test_half_life_decay_math test_score_monotonicity`

## Acceptance Criteria
- [ ] test_half_life_decay_math passes (4 boundary cases + div-by-zero guard)
- [ ] test_score_monotonicity_by_age passes (50 random pairs, deterministic seed)
- [ ] Function is `fn` not `pub fn` (module-private)
- [ ] No DB dependency — pure function

## Files
- src/cli/commands/who.rs (function near top, tests in test module)

## Edge Cases
- Negative elapsed_ms: clamped to 0 via .max(0.0) -> returns 1.0
- half_life_days = 0: returns 0.0, not NaN/Inf
- Very large elapsed (10 years): returns very small positive f64, never negative

Implemented by time-decay swarm: 3 agents, 12 tasks, 621 tests passing, all quality gates green


Epic: CP2 Gate C - Dependent Discussion Sync

## Background
Gate C validates the dependent discussion sync with DiffNote position capture. This is critical for code review context preservation - without DiffNote positions, we lose the file/line context for review comments.

## Acceptance Criteria (Pass/Fail)
- [ ] Discussions fetched for MRs with updated_at > discussions_synced_for_updated_at
- [ ] `SELECT COUNT(*) FROM discussions WHERE merge_request_id IS NOT NULL` > 0
- [ ] DiffNotes have `position_new_path` populated (file path)
- [ ] DiffNotes have `position_new_line` populated (line number)
- [ ] DiffNotes have `position_type` populated (text/image/file)
- [ ] DiffNotes have SHA triplet: `position_base_sha`, `position_start_sha`, `position_head_sha`
- [ ] Multi-line DiffNotes have `position_line_range_start` and `position_line_range_end`
- [ ] Unchanged MRs skip discussion refetch (watermark comparison works)
- [ ] Watermark NOT advanced on HTTP error mid-pagination
- [ ] Watermark NOT advanced on note timestamp parse failure
- [ ] `gi show mr <iid>` displays DiffNote with file context `[path:line]`

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

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

echo "=== Gate C: Dependent Discussion Sync ==="

# 1. Check discussion count for MRs
echo "Step 1: Check MR discussion count..."
MR_DISC_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM discussions WHERE merge_request_id IS NOT NULL;")
echo "  MR discussions: $MR_DISC_COUNT"
[ "$MR_DISC_COUNT" -gt 0 ] || { echo "FAIL: No MR discussions found"; exit 1; }

# 2. Check note count
echo "Step 2: Check note count..."
NOTE_COUNT=$(sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM notes n
  JOIN discussions d ON d.id = n.discussion_id
  WHERE d.merge_request_id IS NOT NULL;
")
echo "  MR notes: $NOTE_COUNT"

# 3. Check DiffNote position data
echo "Step 3: Check DiffNote positions..."
DIFFNOTE_COUNT=$(sqlite3 "$DB_PATH" "SELECT COUNT(*) FROM notes WHERE position_new_path IS NOT NULL;")
echo "  DiffNotes with position: $DIFFNOTE_COUNT"

# 4. Sample DiffNote data
echo "Step 4: Sample DiffNote data..."
sqlite3 "$DB_PATH" "
  SELECT
    n.gitlab_id,
    n.position_new_path,
    n.position_new_line,
    n.position_type,
    SUBSTR(n.position_head_sha, 1, 7) as head_sha
  FROM notes n
  WHERE n.position_new_path IS NOT NULL
  LIMIT 5;
"

# 5. Check multi-line DiffNotes
echo "Step 5: Check multi-line DiffNotes..."
MULTILINE_COUNT=$(sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM notes
  WHERE position_line_range_start IS NOT NULL
    AND position_line_range_end IS NOT NULL
    AND position_line_range_start != position_line_range_end;
")
echo "  Multi-line DiffNotes: $MULTILINE_COUNT"

# 6. Check watermarks set
echo "Step 6: Check watermarks..."
WATERMARKED=$(sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM merge_requests
  WHERE discussions_synced_for_updated_at IS NOT NULL;
")
echo "  MRs with watermark set: $WATERMARKED"

# 7. Check last_seen_at for sweep pattern
echo "Step 7: Check last_seen_at (sweep pattern)..."
sqlite3 "$DB_PATH" "
  SELECT
    MIN(last_seen_at) as oldest,
    MAX(last_seen_at) as newest
  FROM discussions
  WHERE merge_request_id IS NOT NULL;
"

# 8. Test show command with DiffNote
echo "Step 8: Find MR with DiffNotes for show test..."
MR_IID=$(sqlite3 "$DB_PATH" "
  SELECT DISTINCT m.iid
  FROM merge_requests m
  JOIN discussions d ON d.merge_request_id = m.id
  JOIN notes n ON n.discussion_id = d.id
  WHERE n.position_new_path IS NOT NULL
  LIMIT 1;
")
if [ -n "$MR_IID" ]; then
  echo "  Testing: gi show mr $MR_IID"
  gi show mr "$MR_IID" | head -50
fi

# 9. Re-run and verify skip count
echo "Step 9: Re-run ingest (should skip unchanged MRs)..."
gi ingest --type=merge_requests
# Should report "Skipped discussion sync for N unchanged MRs"

echo ""
echo "=== Gate C: PASSED ==="
```

## Atomicity Test (Manual - Kill Test)
```bash
# This tests that partial failure preserves data

# 1. Get an MR with discussions
MR_ID=$(sqlite3 "$DB_PATH" "
  SELECT m.id FROM merge_requests m
  JOIN discussions d ON d.merge_request_id = m.id
  LIMIT 1;
")

# 2. Note current note count
BEFORE=$(sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM notes n
  JOIN discussions d ON d.id = n.discussion_id
  WHERE d.merge_request_id = $MR_ID;
")
echo "Notes before: $BEFORE"

# 3. Note watermark
WATERMARK_BEFORE=$(sqlite3 "$DB_PATH" "
  SELECT discussions_synced_for_updated_at FROM merge_requests WHERE id = $MR_ID;
")
echo "Watermark before: $WATERMARK_BEFORE"

# 4. Force full sync and kill mid-run
gi ingest --type=merge_requests --full &
PID=$!
sleep 3 && kill -9 $PID 2>/dev/null || true
wait $PID 2>/dev/null || true

# 5. Verify notes preserved (should be same or more, never less)
AFTER=$(sqlite3 "$DB_PATH" "
  SELECT COUNT(*) FROM notes n
  JOIN discussions d ON d.id = n.discussion_id
  WHERE d.merge_request_id = $MR_ID;
")
echo "Notes after kill: $AFTER"
[ "$AFTER" -ge "$BEFORE" ] || echo "WARNING: Notes decreased - atomicity may be broken"

# 6. Note watermark should NOT have advanced if killed mid-pagination
WATERMARK_AFTER=$(sqlite3 "$DB_PATH" "
  SELECT discussions_synced_for_updated_at FROM merge_requests WHERE id = $MR_ID;
")
echo "Watermark after: $WATERMARK_AFTER"
```

## Test Commands (Quick Verification)
```bash
# Check DiffNote data:
sqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 "
  SELECT
    (SELECT COUNT(*) FROM discussions WHERE merge_request_id IS NOT NULL) as mr_discussions,
    (SELECT COUNT(*) FROM notes WHERE position_new_path IS NOT NULL) as diffnotes,
    (SELECT COUNT(*) FROM merge_requests WHERE discussions_synced_for_updated_at IS NOT NULL) as watermarked;
"

# Find MR with DiffNotes and show it:
gi show mr $(sqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 "
  SELECT DISTINCT m.iid FROM merge_requests m
  JOIN discussions d ON d.merge_request_id = m.id
  JOIN notes n ON n.discussion_id = d.id
  WHERE n.position_new_path IS NOT NULL LIMIT 1;
")
```

## Dependencies
This gate requires:
- bd-3j6 (Discussion transformer with DiffNote position extraction)
- bd-20h (MR discussion ingestion with atomicity guarantees)
- bd-iba (Client pagination for MR discussions)
- Gates A and B must pass first

## Edge Cases
- MRs without discussions: should sync successfully, just with 0 discussions
- Discussions without DiffNotes: regular comments have NULL position fields
- Deleted discussions in GitLab: sweep pattern should remove them locally
- Invalid note timestamps: should NOT advance watermark, should log warning


[CP1] Integration tests for pagination

Integration tests for GitLab pagination with wiremock.

## Tests (tests/pagination_tests.rs)

### Page Navigation
- fetches_all_pages_when_multiple_exist
- respects_per_page_parameter
- follows_x_next_page_header_until_empty
- falls_back_to_empty_page_stop_if_headers_missing

### Cursor Behavior
- applies_cursor_rewind_for_tuple_semantics
- clamps_negative_rewind_to_zero

## Test Setup
- Use wiremock::MockServer
- Set up handlers for /api/v4/projects/:id/issues
- Return x-next-page headers
- Verify request params (updated_after, per_page)

Files: tests/pagination_tests.rs
Done when: All pagination tests pass with mocked server


Epic: lore me — Personal Work Dashboard

## Background
`lore me` is the self-service command that answers: "what needs my attention right now?" for the configured GitLab user. It must work without local per-item state and without manual triage steps by deriving attention directly from synced GitLab data.

This epic exists to deliver a complete personal dashboard in both human and robot mode with deterministic behavior across section filtering, project scoping, sorting, and error handling.

The command is intentionally separate from `lore who`:
- `lore who` = inspect other people or team-level activity.
- `lore me` = high-signal personal dashboard with attention scoring.

## Approach
Implement the command in vertical slices, then wire them together in a single handler:

1. Input and identity resolution:
- Add optional `gitlab.username` config field.
- Resolve username with strict precedence: `--user` > `config.gitlab.username` > usage error (exit 2).
- Add `me` command args and section flags (`--issues`, `--mrs`, `--activity`) with combinable behavior.

2. Scope and filtering:
- Resolve project scope from `--project`, `--all`, and `config.default_project`.
- Treat `--project` + `--all` as a usage error.
- Parse `--since` (default `30d`) for activity feed cutoff only.

3. Data model and queries:
- Introduce typed dashboard structs (`MeDashboard`, per-section item structs, summary struct).
- Implement open issues / authored MRs / reviewing MRs queries using current-association semantics.
- Compute attention state from non-system notes with deterministic priority ordering.
- Build activity feed from 5 sources (notes, state events, label events, milestone events, assignment system-note patterns) and merge via `UNION ALL` sorted by timestamp desc.

4. Rendering:
- Human mode: summary + legend + section renderers with existing theme/icons behavior and single-project path suppression.
- Robot mode: `{ok,data,meta}` envelope with stable field names and `--fields minimal` support.

5. Integration and errors:
- Wire a single `handle_me(...)` flow in CLI dispatch.
- Enforce required errors and exit codes (no username, no synced data, invalid flag combos, invalid since).
- Keep empty-result dashboards non-error with explicit zero counts.

## Acceptance Criteria
- [ ] `lore me` command is available with flags: `--issues`, `--mrs`, `--activity`, `--since`, `--project`, `--all`, `--user`, `--fields`.
- [ ] Username precedence is exact: CLI flag > config field > exit 2 usage error.
- [ ] `--project` and `--all` together return usage error (exit 2).
- [ ] Default scope uses `config.default_project` when present; otherwise all synced projects.
- [ ] Work item sections include only open items and current association definitions:
  - Issues assigned to me.
  - MRs authored by me.
  - MRs where I am a reviewer.
- [ ] Activity feed includes the 5 required sources and remains restricted to currently associated items.
- [ ] Attention state is computed from non-system notes with stable priority order and no local cursor/high-water state.
- [ ] Work items are sorted by attention priority then updated time descending.
- [ ] Activity is sorted newest-first and includes own-action marker (`is_own` / `(you)`).
- [ ] Human output includes summary counts, attention legend, event badges, and single-project path suppression.
- [ ] Robot output uses `{ok,data,meta}` and supports `--fields minimal` preset for me-specific lists.
- [ ] Empty data renders valid zero-state dashboard (not an error).
- [ ] No synced data returns exit 17 with actionable suggestion.
- [ ] All me-related logic is covered by tests and passes project quality gates.

## Files
Confirmed existing integration points:
- MODIFY: `src/main.rs` (CLI args + dispatch)
- MODIFY: `src/cli/commands/mod.rs` (module exports)
- MODIFY: `src/core/config.rs` (`gitlab.username`)
- MODIFY: `src/core/error.rs` (usage-path mapping)
- MODIFY: `src/cli/robot.rs` (me field preset + field filtering integration)

Expected new command surface:
- CREATE: `src/cli/commands/me/mod.rs`

Likely supporting files (if split by concern instead of single-file module):
- CREATE: `src/cli/commands/me/types.rs`
- CREATE: `src/cli/commands/me/queries.rs`
- CREATE: `src/cli/commands/me/render_human.rs`
- CREATE: `src/cli/commands/me/render_robot.rs`

## TDD Anchor
RED:
- Add tests for username resolution precedence and missing-username error.
- Add tests for scope resolution (`--project`, `--all`, default project, conflict).
- Add query tests for each section and attention-state computation edge ordering.
- Add activity-feed tests validating all 5 sources and timestamp sort order.
- Add renderer tests for zero-state, icon tier fallback, and project-path suppression.
- Add robot tests for envelope shape and `--fields minimal` behavior.

GREEN:
- Implement minimal behavior to satisfy one failing test class at a time in this order:
  1) identity/scope,
  2) data structs,
  3) section queries,
  4) attention,
  5) activity feed,
  6) human renderer,
  7) robot renderer,
  8) handler wiring + errors.

VERIFY:
- `cargo check --all-targets`
- `cargo clippy --all-targets -- -D warnings`
- `cargo fmt --check`
- `cargo test`

## Edge Cases
- User has synced projects but zero matching issues/MRs/activity: must render valid dashboard with zeros.
- `--since` affects only activity feed; it must not filter issue/MR sections.
- Stale vs not-started distinction: items with zero non-system notes are `not_started`, not `stale`.
- MR `not_ready` applies only when `draft=1` and reviewer count is zero.
- Activity should still include closed items when they are currently associated with the user.

## Dependency Context
This epic is implemented through the following child beads; each one is required because it provides a concrete subsystem consumed by the handler:

- `bd-qpk3`: adds `gitlab.username` config source consumed by username resolution.
- `bd-1f1f`: username resolver and usage-error path consumed by command entrypoint.
- `bd-utt4`: CLI argument struct + subcommand registration consumed by dispatch.
- `bd-a7ba`: project scope resolver consumed by section query filters.
- `bd-1vai`: `AttentionState` enum + ordering consumed by sorting and serialization.
- `bd-3bwh`: dashboard data structs consumed by queries and renderers.
- `bd-joja`: open-issues query feeding issues section.
- `bd-1obt`: authored-MR query feeding authored section.
- `bd-1fgr`: reviewing-MR query feeding reviewing section.
- `bd-1xuf`: attention SQL enrichment feeding all work-item sections.
- `bd-b3r3`: core activity-source queries (notes/state/label/milestone).
- `bd-2nl3`: assignment/review-request extraction from system notes (5th activity source).
- `bd-2tl5`: `UNION ALL` activity assembly consumed by dashboard activity section.
- `bd-1vxq`: summary + attention legend renderer used at top of human output.
- `bd-2fuw`: open-issues human renderer.
- `bd-9cob`: authored/reviewing MRs human renderer.
- `bd-e48d`: activity human renderer with event badges.
- `bd-2ilv`: robot serializer/envelope for me dashboard.
- `bd-3jiq`: me-specific `--fields minimal` projection behavior.
- `bd-1vv8`: integration handler that orchestrates all previous slices.
- `bd-32aw`: final error-path hardening and exit-code compliance.

All 21 child beads implemented by lore-me agent swarm. lore me command fully operational.


Implement document regenerator

## Background
The document regenerator drains the dirty_sources queue, regenerating documents for each entry. It uses per-item transactions for crash safety, a triple-hash fast path to skip unchanged documents entirely (no writes at all), and a bounded batch loop that drains completely. Error recording includes backoff computation.

## Approach
Create `src/documents/regenerator.rs` per PRD Section 6.3.

**Core function:**
```rust
pub fn regenerate_dirty_documents(conn: &Connection) -> Result<RegenerateResult>
```

**RegenerateResult:** { regenerated, unchanged, errored }

**Algorithm (per PRD):**
1. Loop: get_dirty_sources(conn) -> Vec<(SourceType, i64)>
2. If empty, break (queue fully drained)
3. For each (source_type, source_id):
   a. Begin transaction
   b. Call regenerate_one_tx(&tx, source_type, source_id) -> Result<bool>
   c. If Ok(changed): clear_dirty_tx, commit, count regenerated or unchanged
   d. If Err: record_dirty_error_tx (with backoff), commit, count errored

**regenerate_one_tx (per PRD):**
1. Extract document via extract_{type}_document(conn, source_id)
2. If None (deleted): delete_document, return Ok(true)
3. If Some(doc): call get_existing_hash() to check current state
4. **If ALL THREE hashes match: return Ok(false) — skip ALL writes** (fast path)
5. Otherwise: upsert_document with conditional label/path relinking
6. Return Ok(content changed)

**Helper functions (PRD-exact):**

`get_existing_hash` — uses `optional()` to distinguish missing rows from DB errors:
```rust
fn get_existing_hash(
    conn: &Connection,
    source_type: SourceType,
    source_id: i64,
) -> Result<Option<String>> {
    use rusqlite::OptionalExtension;
    let hash: Option<String> = stmt
        .query_row(params, |row| row.get(0))
        .optional()?;  // IMPORTANT: Not .ok() — .ok() would hide real DB errors
    Ok(hash)
}
```

`get_document_id` — resolve document ID after upsert:
```rust
fn get_document_id(conn: &Connection, source_type: SourceType, source_id: i64) -> Result<i64>
```

`upsert_document` — checks existing triple hash before writing:
```rust
fn upsert_document(conn: &Connection, doc: &DocumentData) -> Result<()> {
    // 1. Query existing (id, content_hash, labels_hash, paths_hash) via OptionalExtension
    // 2. Triple-hash fast path: all match -> return Ok(())
    // 3. Upsert document row (ON CONFLICT DO UPDATE)
    // 4. Get doc_id (from existing or query after insert)
    // 5. Only delete+reinsert labels if labels_hash changed
    // 6. Only delete+reinsert paths if paths_hash changed
}
```

**Key PRD detail — triple-hash fast path:**
```rust
if old_content_hash == &doc.content_hash
    && old_labels_hash == &doc.labels_hash
    && old_paths_hash == &doc.paths_hash
{ return Ok(()); }  // Skip ALL writes — prevents WAL churn
```

**Error recording with backoff:**
record_dirty_error_tx reads current attempt_count from DB, computes next_attempt_at via shared backoff utility:
```rust
let next_attempt_at = crate::core::backoff::compute_next_attempt_at(now, attempt_count + 1);
```

**All internal functions use _tx suffix** (take &Transaction) for atomicity.

## Acceptance Criteria
- [ ] Queue fully drained (bounded batch loop until empty)
- [ ] Per-item transactions (crash loses at most 1 doc)
- [ ] Triple-hash fast path: ALL THREE hashes match -> skip ALL writes (return Ok(false))
- [ ] Content change: upsert document, update labels/paths
- [ ] Labels-only change: relabels but skips path writes (paths_hash unchanged)
- [ ] Deleted entity: delete document (cascade handles FTS/labels/paths/embeddings)
- [ ] get_existing_hash uses `.optional()` (not `.ok()`) to preserve DB errors
- [ ] get_document_id resolves document ID after upsert
- [ ] Error recording: increment attempt_count, compute next_attempt_at via backoff
- [ ] FTS triggers fire on insert/update/delete (verified by trigger, not regenerator)
- [ ] RegenerateResult counts accurate (regenerated, unchanged, errored)
- [ ] Errors do not abort batch (log, increment, continue)
- [ ] `cargo test regenerator` passes

## Files
- `src/documents/regenerator.rs` — new file
- `src/documents/mod.rs` — add `pub use regenerator::regenerate_dirty_documents;`

## TDD Loop
RED: Tests requiring DB:
- `test_creates_new_document` — dirty source -> document created
- `test_skips_unchanged_triple_hash` — all 3 hashes match -> unchanged count incremented, no DB writes
- `test_updates_changed_content` — content_hash mismatch -> updated
- `test_updates_changed_labels_only` — content same but labels_hash different -> updated
- `test_updates_changed_paths_only` — content same but paths_hash different -> updated
- `test_deletes_missing_source` — source deleted -> document deleted
- `test_drains_queue` — queue empty after regeneration
- `test_error_records_backoff` — error -> attempt_count incremented, next_attempt_at set
- `test_get_existing_hash_not_found` — returns Ok(None) for missing document
GREEN: Implement regenerate_dirty_documents + all helpers
VERIFY: `cargo test regenerator`

## Edge Cases
- Empty queue: return immediately with all-zero counts
- Extractor error for one item: record_dirty_error_tx, commit, continue
- Triple-hash prevents WAL churn on incremental syncs (most entities unchanged)
- Labels change but content does not: labels_hash mismatch triggers upsert with label relinking
- get_existing_hash on missing document: returns Ok(None) via .optional() (not DB error)
- get_existing_hash on corrupt DB: propagates real DB error (not masked by .ok())

Implemented document regenerator with triple-hash fast path, queue draining, fail-soft error handling + 5 tests


Implement DB upsert functions for resource events

## Background
Need to store fetched resource events into the three event tables created by migration 011. The existing DB pattern uses rusqlite prepared statements with named parameters. Timestamps from GitLab are ISO 8601 strings that need conversion to ms epoch UTC (matching the existing time.rs parse_datetime_to_ms function).

## Approach
Create src/core/events_db.rs (new module) with three upsert functions:

```rust
use rusqlite::Connection;
use super::error::Result;

/// Upsert state events for an entity.
/// Uses INSERT OR REPLACE keyed on UNIQUE(gitlab_id, project_id).
pub fn upsert_state_events(
    conn: &Connection,
    project_id: i64,          // local DB project id
    entity_type: &str,        // "issue" | "merge_request"
    entity_local_id: i64,     // local DB id of the issue/MR
    events: &[GitLabStateEvent],
) -> Result<usize>

/// Upsert label events for an entity.
pub fn upsert_label_events(
    conn: &Connection,
    project_id: i64,
    entity_type: &str,
    entity_local_id: i64,
    events: &[GitLabLabelEvent],
) -> Result<usize>

/// Upsert milestone events for an entity.
pub fn upsert_milestone_events(
    conn: &Connection,
    project_id: i64,
    entity_type: &str,
    entity_local_id: i64,
    events: &[GitLabMilestoneEvent],
) -> Result<usize>
```

Each function:
1. Prepares INSERT OR REPLACE statement
2. For each event, maps GitLab types to DB columns:
   - `actor_gitlab_id` = event.user.map(|u| u.id)
   - `actor_username` = event.user.map(|u| u.username.clone())
   - `created_at` = parse_datetime_to_ms(&event.created_at)?
   - Set issue_id or merge_request_id based on entity_type
3. Returns count of upserted rows
4. Wraps in a savepoint for atomicity per entity

Register module in src/core/mod.rs:
```rust
pub mod events_db;
```

## Acceptance Criteria
- [ ] All three upsert functions compile and handle all event fields
- [ ] Upserts are idempotent (re-inserting same event doesn't duplicate)
- [ ] Timestamps converted to ms epoch UTC via parse_datetime_to_ms
- [ ] actor_gitlab_id and actor_username populated from event.user (handles None)
- [ ] entity_type correctly maps to issue_id/merge_request_id (other is NULL)
- [ ] source_merge_request_id populated for state events (iid from source_merge_request)
- [ ] source_commit populated for state events
- [ ] label_name populated for label events
- [ ] milestone_title and milestone_id populated for milestone events
- [ ] Returns upserted count

## Files
- src/core/events_db.rs (new)
- src/core/mod.rs (add `pub mod events_db;`)

## TDD Loop
RED: tests/events_db_tests.rs (new):
- `test_upsert_state_events_basic` - insert 3 events, verify count and data
- `test_upsert_state_events_idempotent` - insert same events twice, verify no duplicates
- `test_upsert_label_events_with_actor` - verify actor fields populated
- `test_upsert_milestone_events_null_user` - verify user: null doesn't crash
- `test_upsert_state_events_entity_exclusivity` - verify only one of issue_id/merge_request_id set

Setup: create_test_db() helper that applies migrations 001-011, inserts a test project + issue + MR.

GREEN: Implement the three functions

VERIFY: `cargo test events_db -- --nocapture`

## Edge Cases
- parse_datetime_to_ms must handle GitLab's format: "2024-03-15T10:30:00.000Z" and "2024-03-15T10:30:00.000+00:00"
- INSERT OR REPLACE will fire CASCADE deletes if there are FK references to these rows — currently no other table references event rows, so this is safe
- entity_type must be validated ("issue" or "merge_request") — panic or error on invalid
- source_merge_request field contains an MR ref object, not an ID — extract .iid for DB column

Implemented upsert_state_events, upsert_label_events, upsert_milestone_events, count_events in src/core/events_db.rs. Uses savepoints for atomicity, LoreError::Database via ? operator for clean error handling.


[CP0] Final validation - tests, lint, typecheck

## Background

Final validation ensures everything works together before marking CP0 complete. This is the integration gate - all unit tests, integration tests, lint, and type checking must pass. Manual smoke tests verify the full user experience.

Reference: docs/prd/checkpoint-0.md sections "Definition of Done", "Manual Smoke Tests"

## Approach

**Automated checks:**
```bash
# All tests pass
npm run test

# TypeScript strict mode
npm run build  # or: npx tsc --noEmit

# ESLint with no errors
npm run lint
```

**Manual smoke tests (from PRD table):**

| Command | Expected | Pass Criteria |
|---------|----------|---------------|
| `gi --help` | Command list | Shows all commands |
| `gi version` | Version number | Shows installed version |
| `gi init` | Interactive prompts | Creates valid config |
| `gi init` (config exists) | Confirmation prompt | Warns before overwriting |
| `gi init --force` | No prompt | Overwrites without asking |
| `gi auth-test` | `Authenticated as @username` | Shows GitLab username |
| `GITLAB_TOKEN=invalid gi auth-test` | Error message | Non-zero exit, clear error |
| `gi doctor` | Status table | All required checks pass |
| `gi doctor --json` | JSON object | Valid JSON, `success: true` |
| `gi backup` | Backup path | Creates timestamped backup |
| `gi sync-status` | No runs message | Stub output works |

**Definition of Done gate items:**
- [ ] `gi init` writes config to XDG path and validates projects against GitLab
- [ ] `gi auth-test` succeeds with real PAT
- [ ] `gi doctor` reports DB ok + GitLab ok
- [ ] DB migrations apply; WAL + FK enabled; busy_timeout + synchronous set
- [ ] App lock mechanism works (concurrent runs blocked)
- [ ] All unit tests pass
- [ ] All integration tests pass (mocked)
- [ ] ESLint passes with no errors
- [ ] TypeScript compiles with strict mode

## Acceptance Criteria

- [ ] `npm run test` exits 0 (all tests pass)
- [ ] `npm run build` exits 0 (TypeScript compiles)
- [ ] `npm run lint` exits 0 (no ESLint errors)
- [ ] All 11 manual smoke tests pass
- [ ] All 9 Definition of Done gate items verified

## Files

No new files created. This bead verifies existing work.

## TDD Loop

This IS the final verification step:

```bash
# Automated
npm run test
npm run build
npm run lint

# Manual (requires GITLAB_TOKEN set with valid token)
gi --help
gi version
gi init  # go through setup
gi auth-test
gi doctor
gi doctor --json | jq .success  # should output true
gi backup
gi sync-status
gi reset --confirm
gi init  # re-setup
```

## Edge Cases

- Test coverage should be reasonable (aim for 80%+ on core modules)
- Integration tests may flake on CI - check MSW setup
- Manual tests require real GitLab token - document in README
- ESLint may warn vs error - only errors block
- TypeScript noImplicitAny catches missed types


Update robot-docs manifest with Phase B commands

## Background

The robot-docs manifest is the agent self-discovery mechanism. It must include all Phase B commands so agents can discover temporal intelligence features.

## Codebase Context

- handle_robot_docs() in src/main.rs (line ~1646) returns JSON with commands, exit_codes, workflows, aliases, clap_error_codes
- Currently 18 commands documented in the manifest
- VALID_COMMANDS array in src/main.rs (line ~448): ["issues", "mrs", "search", "sync", "ingest", "count", "status", "auth", "doctor", "version", "init", "stats", "generate-docs", "embed", "migrate", "health", "robot-docs", "completions"]
- Phase B adds 3 new commands: timeline, file-history, trace
- count gains new entity: "references" (bd-2ez)
- Existing workflows: first_setup, daily_sync, search, pre_flight

## Approach

### 1. Add commands to handle_robot_docs() JSON:

```json
"timeline": {
    "description": "Chronological timeline of events matching a keyword query",
    "flags": ["<QUERY>", "-p <project>", "--since <duration>", "--depth <n>", "--expand-mentions", "-n <limit>"],
    "example": "lore --robot timeline 'authentication' --since 30d"
},
"file-history": {
    "description": "Which MRs touched a file, with rename chain resolution",
    "flags": ["<PATH>", "-p <project>", "--discussions", "--no-follow-renames", "--merged", "-n <limit>"],
    "example": "lore --robot file-history src/auth/oauth.rs"
},
"trace": {
    "description": "Trace file -> MR -> issue -> discussions decision chain",
    "flags": ["<PATH>", "-p <project>", "--discussions", "--no-follow-renames", "-n <limit>"],
    "example": "lore --robot trace src/auth/oauth.rs"
}
```

### 2. Update count command to mention "references" entity

### 3. Add temporal_intelligence workflow:
```json
"temporal_intelligence": {
    "description": "Query temporal data about project history",
    "steps": [
        "lore sync (ensure events fetched with fetchResourceEvents=true)",
        "lore timeline '<keyword>' for chronological event history",
        "lore file-history <path> for file-level MR history",
        "lore trace <path> for file -> MR -> issue -> discussion chain"
    ]
}
```

### 4. Add timeline, file-history, trace to VALID_COMMANDS array

## Acceptance Criteria

- [ ] robot-docs includes timeline, file-history, trace commands
- [ ] count references documented
- [ ] temporal_intelligence workflow present
- [ ] VALID_COMMANDS includes all 3 new commands
- [ ] Examples are valid, runnable commands
- [ ] cargo check --all-targets passes
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files

- src/main.rs (update handle_robot_docs + VALID_COMMANDS array)

## TDD Loop

VERIFY: lore robot-docs | jq '.data.commands.timeline'
VERIFY: lore robot-docs | jq '.data.workflows.temporal_intelligence'

Robot-docs already includes all 26 commands (timeline, file-history, trace, drift, cron, token, etc.)


Add WorkItemStatus type and SyncConfig toggle

## Background
The GraphQL status response returns name, category, color, and iconName fields. We need a Rust struct that deserializes this directly. Category is stored as raw Option<String> (not an enum) because GitLab 18.5+ supports custom statuses with arbitrary category values. We also need a config toggle so users can disable status enrichment.

## Approach
Add WorkItemStatus to the existing types module. Add fetch_work_item_status to the existing SyncConfig with default_true() helper. Also add WorkItemStatus to pub use re-exports in src/gitlab/mod.rs.

## Files
- src/gitlab/types.rs (add struct after GitLabMergeRequest, before #[cfg(test)])
- src/core/config.rs (add field to SyncConfig struct + Default impl)
- src/gitlab/mod.rs (add WorkItemStatus to pub use)

## Implementation

In src/gitlab/types.rs (needs Serialize, Deserialize derives already in scope):
  #[derive(Debug, Clone, Serialize, Deserialize)]
  pub struct WorkItemStatus {
      pub name: String,
      pub category: Option<String>,
      pub color: Option<String>,
      #[serde(rename = "iconName")]
      pub icon_name: Option<String>,
  }

In src/core/config.rs SyncConfig struct (after fetch_mr_file_changes):
  #[serde(rename = "fetchWorkItemStatus", default = "default_true")]
  pub fetch_work_item_status: bool,

In impl Default for SyncConfig (after fetch_mr_file_changes: true):
  fetch_work_item_status: true,

## Acceptance Criteria
- [ ] WorkItemStatus deserializes: {"name":"In progress","category":"IN_PROGRESS","color":"#1f75cb","iconName":"status-in-progress"}
- [ ] Optional fields: {"name":"To do"} -> category/color/icon_name are None
- [ ] Unknown category: {"name":"Custom","category":"SOME_FUTURE_VALUE"} -> Ok
- [ ] Null category: {"name":"In progress","category":null} -> None
- [ ] SyncConfig::default().fetch_work_item_status == true
- [ ] JSON without fetchWorkItemStatus key -> defaults true
- [ ] cargo check --all-targets passes

## TDD Loop
RED: test_work_item_status_deserialize, test_work_item_status_optional_fields, test_work_item_status_unknown_category, test_work_item_status_null_category, test_config_fetch_work_item_status_default_true, test_config_deserialize_without_key
GREEN: Add struct + config field
VERIFY: cargo test test_work_item_status && cargo test test_config

## Edge Cases
- serde rename "iconName" -> icon_name (camelCase in GraphQL)
- Category is Option<String>, NOT an enum
- Config key is camelCase "fetchWorkItemStatus" matching existing convention

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


Implement AppState composition + LoadState + ScreenIntent

## Background
AppState is the top-level state composition — each field corresponds to one screen. State is preserved when navigating away (never cleared on pop). LoadState enables stale-while-revalidate: screens show last data during refresh with a spinner. ScreenIntent is the pure return type from state handlers — they never launch async tasks directly.

## Approach
Create crates/lore-tui/src/state/mod.rs:
- AppState struct: dashboard (DashboardState), issue_list (IssueListState), issue_detail (IssueDetailState), mr_list (MrListState), mr_detail (MrDetailState), search (SearchState), timeline (TimelineState), who (WhoState), sync (SyncState), command_palette (CommandPaletteState), global_scope (ScopeContext), load_state (ScreenLoadStateMap), error_toast (Option<String>), show_help (bool), terminal_size ((u16, u16))
- LoadState enum: Idle, LoadingInitial, Refreshing, Error(String)
- ScreenLoadStateMap: wraps HashMap<Screen, LoadState>, get()/set()/any_loading()
- AppState methods: set_loading(), set_error(), clear_error(), has_text_focus(), blur_text_focus(), delegate_text_event(), interpret_screen_key(), handle_screen_msg()
- ScreenIntent enum: None, Navigate(Screen), RequeryNeeded(Screen)
- handle_screen_msg() matches Msg variants and returns ScreenIntent (NEVER Cmd::task)

Create stub per-screen state files (just Default-derivable structs):
- state/dashboard.rs, issue_list.rs, issue_detail.rs, mr_list.rs, mr_detail.rs, search.rs, timeline.rs, who.rs, sync.rs, command_palette.rs

## Acceptance Criteria
- [ ] AppState derives Default and compiles with all screen state fields
- [ ] LoadState has Idle, LoadingInitial, Refreshing, Error variants
- [ ] ScreenLoadStateMap::get() returns Idle for untracked screens
- [ ] ScreenLoadStateMap::any_loading() returns true when any screen is loading
- [ ] has_text_focus() checks all filter/query focused flags
- [ ] blur_text_focus() resets all focus flags
- [ ] handle_screen_msg() returns ScreenIntent, never Cmd::task
- [ ] ScreenIntent::RequeryNeeded signals that LoreApp should dispatch supervised query

## Files
- CREATE: crates/lore-tui/src/state/mod.rs
- CREATE: crates/lore-tui/src/state/dashboard.rs (stub)
- CREATE: crates/lore-tui/src/state/issue_list.rs (stub)
- CREATE: crates/lore-tui/src/state/issue_detail.rs (stub)
- CREATE: crates/lore-tui/src/state/mr_list.rs (stub)
- CREATE: crates/lore-tui/src/state/mr_detail.rs (stub)
- CREATE: crates/lore-tui/src/state/search.rs (stub)
- CREATE: crates/lore-tui/src/state/timeline.rs (stub)
- CREATE: crates/lore-tui/src/state/who.rs (stub)
- CREATE: crates/lore-tui/src/state/sync.rs (stub)
- CREATE: crates/lore-tui/src/state/command_palette.rs (stub)

## TDD Anchor
RED: Write test_load_state_default_idle that creates ScreenLoadStateMap, asserts get(&Screen::Dashboard) returns Idle.
GREEN: Implement ScreenLoadStateMap with HashMap defaulting to Idle.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_load_state

## Edge Cases
- LoadState::set() removes Idle entries from the map to prevent unbounded growth
- Screen::IssueDetail(key) comparison for HashMap: requires Screen to impl Hash+Eq or use ScreenKind discriminant
- has_text_focus() must be kept in sync as new screens add text inputs


Define AttentionState enum with sort ordering

## Background
The attention state model (AC-4) classifies each work item into one of 5 states based on comment activity. These states have a defined sort priority (AC-9.2). The enum is used across queries, rendering, and JSON serialization. NotReady is MR-only and has no attention icon (just draft indicator).

## Approach
Create `src/cli/commands/me/types.rs`:
```rust
use serde::Serialize;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AttentionState {
    NeedsAttention,    // others commented after me (or I haven't engaged)
    NotStarted,        // zero non-system notes from anyone
    AwaitingResponse,  // my latest >= others' latest
    Stale,             // had activity, but latest note > 30 days old
    NotReady,          // MR-only: draft=1 AND no reviewers
}

impl AttentionState {
    /// Sort priority — lower = higher urgency (shows first in dashboard)
    #[must_use]
    pub const fn sort_priority(self) -> u8 {
        match self {
            Self::NeedsAttention => 0,
            Self::NotStarted => 1,
            Self::AwaitingResponse => 2,
            Self::Stale => 3,
            Self::NotReady => 4,
        }
    }
}

impl Ord for AttentionState {
    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
        self.sort_priority().cmp(&other.sort_priority())
    }
}

impl PartialOrd for AttentionState {
    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
        Some(self.cmp(other))
    }
}

impl std::fmt::Display for AttentionState {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::NeedsAttention => write!(f, "needs_attention"),
            Self::NotStarted => write!(f, "not_started"),
            Self::AwaitingResponse => write!(f, "awaiting_response"),
            Self::Stale => write!(f, "stale"),
            Self::NotReady => write!(f, "not_ready"),
        }
    }
}

impl AttentionState {
    pub fn from_sql_str(s: &str) -> Self {
        match s {
            "needs_attention" => Self::NeedsAttention,
            "not_started" => Self::NotStarted,
            "awaiting_response" => Self::AwaitingResponse,
            "stale" => Self::Stale,
            "not_ready" => Self::NotReady,
            _ => Self::NotStarted,  // fallback
        }
    }
}
```

## Acceptance Criteria
- [ ] 5 variants: NeedsAttention, NotStarted, AwaitingResponse, Stale, NotReady
- [ ] Serializes to snake_case: "needs_attention", "not_started", etc. (AC-7.3)
- [ ] Implements Ord: NeedsAttention < NotStarted < AwaitingResponse < Stale < NotReady (AC-9.2)
- [ ] sort_priority() is const fn returning u8
- [ ] Display shows snake_case name
- [ ] from_sql_str() parses SQL CASE output strings back to enum
- [ ] Derives: Debug, Clone, Copy, PartialEq, Eq, Serialize

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

## TDD Anchor
RED: Write `test_attention_state_sort_order`:
```rust
let mut states = [AttentionState::NotReady, AttentionState::Stale,
                  AttentionState::NeedsAttention, AttentionState::AwaitingResponse,
                  AttentionState::NotStarted];
states.sort();
assert_eq!(states[0], AttentionState::NeedsAttention);
assert_eq!(states[4], AttentionState::NotReady);
```
GREEN: Implement the enum with Ord.
VERIFY: `cargo test attention_state`

Additional tests:
- test_attention_state_serializes_snake_case (serde_json::to_string → "\"needs_attention\"")
- test_attention_state_from_sql_str (round-trip each variant)
- test_attention_state_display

## Edge Cases
- Using array `states.sort()` (not `vec![]`) avoids clippy pedantic lint
- `from_sql_str` has a fallback to NotStarted for unknown strings — safe default
- NotReady is MR-only but the enum doesn't enforce this — enforcement at query level (bd-1xuf)

## Dependency Context
No upstream dependencies beyond the me/ module existing (bd-utt4).
Consumed by all query beads and rendering beads.


Write decay and scoring example-based tests (TDD)

## Background
All implementation beads (bd-1soz through bd-11mg) include their own inline TDD tests. This bead is the integration verification: run the full test suite and confirm everything works together with no regressions.

## Approach
Run cargo test and verify:
1. All NEW tests pass (31 tests across implementation beads)
2. All EXISTING tests pass unchanged (existing who tests, config tests, etc.)
3. No test interference (--test-threads=1 mode)
4. All tests in who.rs test module compile and run cleanly

Test count by bead:
- bd-1soz: 2 (test_half_life_decay_math, test_score_monotonicity_by_age)
- bd-2w1p: 3 (test_config_validation_rejects_zero_half_life, _absurd_half_life, _nan_multiplier)
- bd-18dn: 2 (test_path_normalization_handles_dot_and_double_slash, _preserves_prefix_semantics)
- bd-1hoq: 1 (test_expert_sql_returns_expected_signal_rows)
- bd-1h3f: 2 (test_old_path_probe_exact_and_prefix, test_suffix_probe_uses_old_path_sources)
- bd-13q8: 13 (decay integration + invariant tests)
- bd-11mg: 8 (CLI flag tests: explain_score, as_of, excluded_usernames, etc.)
Total: 2+3+2+1+2+13+8 = 31 new tests

This is NOT a code-writing bead — it is a verification checkpoint.

## Acceptance Criteria
- [ ] cargo test -p lore passes (all tests green)
- [ ] cargo test -p lore -- --test-threads=1 passes (no test interference)
- [ ] No existing test assertions were changed (only callsite signatures updated in bd-13q8 and ScoringConfig literals in bd-1b50)
- [ ] Total test count: existing + 31 new = all pass

## TDD Loop
N/A — this bead verifies, does not write code.
VERIFY: cargo test -p lore

## Files
None modified — read-only verification.


Implement me command handler: wire queries to renderers

## Background
This is the integration bead for `lore me`: resolve inputs, fetch data, enrich attention, build dashboard DTO, and dispatch to human or robot rendering. It should mirror the command-handler style used by `handle_who`, but with me-specific section filtering and output shaping.

## Approach
Implement `handle_me(config_override, args, robot_mode)` in `src/cli/commands/me/mod.rs` with deterministic flow:

1. **Bootstrap**
- load config
- resolve username (`bd-1f1f`)
- open DB connection
- validate synced data exists (`SELECT COUNT(*) FROM projects` > 0)

2. **Scope + temporal inputs**
- resolve `ProjectScope` (`bd-a7ba`)
- parse `--since` via `parse_since(&args.since)` into `since_ms`
- compute `since_iso` from `since_ms` using existing ms->ISO helper

3. **Section selection**
- no section flags => all sections
- `--issues`, `--mrs`, `--activity` combinable
- `--since` affects activity query only

4. **Fetch + enrich**
- fetch selected sections (`bd-joja`, `bd-1obt`, `bd-1fgr`, `bd-2tl5`)
- run attention enrichment on work-item collections (`bd-1xuf`)
- sort each work-item list by attention then `updated_at_iso` desc

5. **Assemble dashboard DTO**
- compute summary counts from full selected data
- include `username`, `since_iso`, `summary`, and all four arrays (empty when not selected)

6. **Render/emit**
- robot: call me robot renderer with `elapsed_ms` and optional `args.fields`
- human: render summary first, then selected sections in order: issues, authored MRs, reviewing MRs, activity

## Acceptance Criteria
- [ ] No section flags => full dashboard
- [ ] `--issues` => only issues section
- [ ] `--mrs` => both MR sections (authored + reviewing)
- [ ] `--activity` => only activity section
- [ ] Section flags combine without conflict (`--issues --mrs` excludes activity)
- [ ] `--since` is parsed once and only used by activity query
- [ ] Invalid `--since` returns usage error (exit 2 path)
- [ ] Work-item sorting is attention-priority then recency within state
- [ ] Summary `needs_attention_count` aggregates across issues + authored + reviewing lists
- [ ] Empty result sets still produce valid dashboard with zero counts
- [ ] Robot output path receives `args.fields` for `--fields minimal` behavior
- [ ] Human output suppresses project path lines when scope is single-project

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

## TDD Anchor
RED:
- `test_handler_section_filter_matrix`
- `test_handler_since_only_filters_activity`
- `test_handler_passes_fields_to_robot_renderer`
- `test_handler_empty_results_zero_summary`
- `test_handler_sort_order_attention_then_updated`

GREEN:
- Implement handler flow end-to-end with minimal branching.

VERIFY:
- `cargo test handle_me`
- `cargo test me_handler`

## Edge Cases
- If only one section is selected, non-selected arrays should still serialize as `[]` in robot output.
- `since_iso` must reflect parsed cutoff timestamp, not current time.
- `single_project` detection should come from resolved scope, not from item count.

## Dependency Context
This bead consumes all me slice outputs and is blocked by:
- `bd-3jiq`, `bd-e48d`, `bd-9cob`, `bd-2fuw`, `bd-2tl5`, `bd-1xuf`.
Its output/error behavior is finalized by `bd-32aw`.

Dependencies:
  -> bd-3jiq (blocks) - Implement --fields minimal preset for me robot mode
  -> bd-e48d (blocks) - Render activity feed with event badges in human mode
  -> bd-9cob (blocks) - Render MR sections (authored + reviewing) in human mode
  -> bd-2fuw (blocks) - Render open issues section in human mode
  -> bd-2tl5 (blocks) - Implement activity feed UNION ALL assembly
  -> bd-1xuf (blocks) - Implement attention state SQL computation

Dependents:
  <- bd-32aw (blocks) - Implement error handling paths for me command


Render summary header and attention legend

## Background
The dashboard starts with a summary header and attention legend (AC-5.5, AC-6.1, AC-6.2). Attention icons go in the existing `Icons` struct in `src/cli/render.rs` (user decision). The `not_ready` state uses no attention icon — just the draft indicator.

## Approach

### 1. Add attention icon methods to Icons struct
In `src/cli/render.rs`, add to the `Icons` impl block (around line 91-231):
```rust
pub fn attention_needs(&self) -> &'static str {
    match self.mode {
        GlyphMode::Nerd => "\u{f0f3}",     // nf-fa-bell
        GlyphMode::Unicode => "\u{25c6}",   // ◆ (black diamond)
        GlyphMode::Ascii => "[!]",
    }
}
pub fn attention_not_started(&self) -> &'static str {
    match self.mode {
        GlyphMode::Nerd => "\u{f005}",     // nf-fa-star
        GlyphMode::Unicode => "\u{2605}",   // ★ (black star)
        GlyphMode::Ascii => "[*]",
    }
}
pub fn attention_awaiting(&self) -> &'static str {
    match self.mode {
        GlyphMode::Nerd => "\u{f017}",     // nf-fa-clock_o
        GlyphMode::Unicode => "\u{25f7}",   // ◷ (white circle with upper right quadrant)
        GlyphMode::Ascii => "[~]",
    }
}
pub fn attention_stale(&self) -> &'static str {
    match self.mode {
        GlyphMode::Nerd => "\u{f54c}",     // nf-mdi-skull
        GlyphMode::Unicode => "\u{2620}",   // ☠ (skull and crossbones)
        GlyphMode::Ascii => "[x]",
    }
}
```

### 2. Summary header renderer
Create `src/cli/commands/me/render_human.rs`:
```rust
use crate::cli::render::{section_divider, format_relative_time, Theme, Icons, LoreRenderer};
use super::types::{MeDashboard, AttentionState};

pub fn render_summary(dashboard: &MeDashboard, single_project: bool) -> String {
    let mut out = String::new();
    // Header
    let title = if single_project {
        format!("My Dashboard")
    } else {
        format!("My Dashboard ({} projects)", dashboard.summary.project_count)
    };
    out.push_str(&section_divider(&title));
    // Counts line
    out.push_str(&format!("\n  {} issues · {} authored MRs · {} reviewing MRs",
        dashboard.summary.open_issues,
        dashboard.summary.authored_mrs,
        dashboard.summary.reviewing_mrs));
    if dashboard.summary.needs_attention_count > 0 {
        out.push_str(&format!(" · {} need attention",
            Theme::warning().render(dashboard.summary.needs_attention_count)));
    }
    // Legend
    let icons = LoreRenderer::icons();
    out.push_str("\n\n  ");
    out.push_str(&format!("{} Needs attention  ", Theme::warning().render(icons.attention_needs())));
    out.push_str(&format!("{} Not started  ", Theme::info().render(icons.attention_not_started())));
    out.push_str(&format!("{} Awaiting response  ", Theme::dim().render(icons.attention_awaiting())));
    out.push_str(&format!("{} Stale", Theme::dim().render(icons.attention_stale())));
    out
}
```

### 3. Attention icon helper
Shared function for rendering an attention icon by state:
```rust
pub fn attention_icon(state: &AttentionState) -> String {
    let icons = LoreRenderer::icons();
    match state {
        AttentionState::NeedsAttention => Theme::warning().render(icons.attention_needs()),
        AttentionState::NotStarted => Theme::info().render(icons.attention_not_started()),
        AttentionState::AwaitingResponse => Theme::dim().render(icons.attention_awaiting()),
        AttentionState::Stale => Theme::dim().render(icons.attention_stale()),
        AttentionState::NotReady => String::new(),  // no icon — draft indicator suffices
    }
}
```

## Acceptance Criteria
- [ ] Four new methods on Icons: attention_needs, attention_not_started, attention_awaiting, attention_stale
- [ ] Nerd Font icons: bell, star, clock, skull (AC-6.2)
- [ ] Unicode fallback: diamond, star, clock, skull+crossbones (AC-6.2)
- [ ] ASCII fallback: [!], [*], [~], [x] (AC-6.2)
- [ ] Summary header shows project count, issue count, authored MRs, reviewing MRs (AC-5.5)
- [ ] needs_attention count highlighted in amber/warning (AC-5.5)
- [ ] Attention legend shows all 4 icons with labels and correct colors
- [ ] NotReady renders no icon (empty string) — draft indicator only
- [ ] Uses existing `section_divider()` for header
- [ ] Uses existing `Theme::warning()`, `Theme::info()`, `Theme::dim()` for colors

## Files
- MODIFY: src/cli/render.rs (add 4 attention icon methods to Icons impl)
- CREATE: src/cli/commands/me/render_human.rs (render_summary + attention_icon helper)
- MODIFY: src/cli/commands/me/mod.rs (add `pub mod render_human;`)

## TDD Anchor
RED: Write `test_attention_icon_needs_attention_not_empty` that calls `attention_icon(&AttentionState::NeedsAttention)` and asserts result is non-empty.
GREEN: Implement the icon methods and helper.
VERIFY: `cargo test attention_icon`

Additional tests:
- test_attention_icon_not_ready_is_empty
- test_summary_contains_counts (create dashboard with known counts, verify output)

## Edge Cases
- Icons struct accessed via `LoreRenderer::icons()` — must be initialized first (in tests, call `LoreRenderer::init()`)
- The render functions return String (not print directly) — enables testing
- Pad attention icons to consistent width? Nerd icons are typically 1-2 chars, ASCII is 3 chars — may need padding for alignment

## Dependency Context
Uses `MeDashboard`, `MeSummary`, `AttentionState` from bd-3bwh.
Modifies `Icons` struct in `src/cli/render.rs` (existing file).
Pattern: `section_divider()` at render.rs:600, `Theme` at render.rs:291.


Design Actionable Insights panel (heuristic queries TBD)

## Background
The PRD specifies an Actionable Insights panel on the Dashboard that surfaces heuristic signals: stale P1 issues, blocked MRs awaiting review, velocity spikes/dips, and assignee workload imbalance. This requires heuristic query functions that do NOT currently exist in the lore codebase.

Since the TUI work is purely UI built on existing code, the Actionable Insights panel is deferred to a later phase when the heuristic queries are implemented. This bead tracks the design and eventual implementation.

## Approach
When ready to implement:
1. Define InsightKind enum: StaleHighPriority, BlockedMR, VelocityAnomaly, WorkloadImbalance
2. Define Insight struct: kind, severity (Info/Warning/Critical), title, description, entity_refs (Vec<EntityKey>)
3. Implement heuristic query functions in lore core (NOT in TUI crate)
4. Wire insights into DashboardState as Optional<Vec<Insight>>
5. Render as a scrollable panel with severity-colored icons

## Acceptance Criteria
- [ ] InsightKind and Insight types defined
- [ ] At least 2 heuristic queries implemented (stale P1, blocked MR)
- [ ] Dashboard renders insights panel when data available
- [ ] Insights panel is scrollable with j/k
- [ ] Enter on insight navigates to related entity
- [ ] Empty insights shows "No insights" or hides panel entirely

## Status
BLOCKED: Requires heuristic query functions that don't exist yet. This is NOT a TUI-only task — it requires backend query work first.

## Files
- CREATE: src/core/insights.rs (heuristic query functions — in main crate, not TUI)
- MODIFY: crates/lore-tui/src/state/dashboard.rs (add insights field)
- MODIFY: crates/lore-tui/src/view/dashboard.rs (add insights panel)

## Edge Cases
- Insights depend on data freshness: stale DB = stale insights. Show "last updated" timestamp.
- Heuristic thresholds should be configurable (e.g., "stale" = P1 untouched for 7 days)
- Large number of insights: cap at 20, show "N more..." link


Implement lore sync CLI command

## Background
The sync command is the unified orchestrator for the full pipeline: ingest -> generate-docs -> embed. It replaces the need to run three separate commands. It acquires a lock, runs each stage sequentially, and reports combined results. Individual stages can be skipped via flags (--no-embed, --no-docs). The command is designed for cron/scheduled execution. Individual commands (`lore generate-docs`, `lore embed`) still exist for manual recovery and debugging.

## Approach
Create `src/cli/commands/sync.rs` per PRD Section 6.4.

**IMPORTANT: run_sync is async** (embed_documents and search_hybrid are async).

**Key types (PRD-exact):**
```rust
#[derive(Debug, Serialize)]
pub struct SyncResult {
    pub issues_updated: usize,
    pub mrs_updated: usize,
    pub discussions_fetched: usize,
    pub documents_regenerated: usize,
    pub documents_embedded: usize,
}

#[derive(Debug, Default)]
pub struct SyncOptions {
    pub full: bool,       // Reset cursors, fetch everything
    pub force: bool,      // Override stale lock
    pub no_embed: bool,   // Skip embedding step
    pub no_docs: bool,    // Skip document regeneration
}
```

**Core function (async, PRD-exact):**
```rust
pub async fn run_sync(config: &Config, options: SyncOptions) -> Result<SyncResult>
```

**Pipeline (sequential steps per PRD):**
1. Acquire app lock with heartbeat (via existing `src/core/lock.rs`)
2. Ingest delta: fetch issues + MRs via cursor-based sync (calls existing ingestion orchestrator)
   - Each upserted entity marked dirty via `mark_dirty_tx(&tx)` inside ingestion transaction
3. Process `pending_discussion_fetches` queue (bounded)
   - Discussion sweep uses CTE to capture stale IDs, then cascading deletes
4. Regenerate documents from `dirty_sources` queue (unless --no-docs)
5. Embed documents with changed content_hash (unless --no-embed; skipped gracefully if Ollama unavailable)
6. Release lock, record sync_run

**NOTE (PRD):** Rolling backfill window removed — the existing cursor + watermark design handles old issues with resumed activity. GitLab updates `updated_at` when new comments are added, so the cursor naturally picks up old issues that receive new activity.

**CLI args (PRD-exact):**
```rust
#[derive(Args)]
pub struct SyncArgs {
    /// Reset cursors, fetch everything
    #[arg(long)]
    full: bool,
    /// Override stale lock
    #[arg(long)]
    force: bool,
    /// Skip embedding step
    #[arg(long)]
    no_embed: bool,
    /// Skip document regeneration
    #[arg(long)]
    no_docs: bool,
}
```

**Human output:**
```
Sync complete:
  Issues updated:             42
  MRs updated:                18
  Discussions fetched:        56
  Documents regenerated:      38
  Documents embedded:         38
  Elapsed: 2m 15s
```

**JSON output:**
```json
{"ok": true, "data": {...}, "meta": {"elapsed_ms": 135000}}
```

## Acceptance Criteria
- [ ] Function is `async fn run_sync`
- [ ] Takes `SyncOptions` struct (not separate params)
- [ ] Returns `SyncResult` with flat fields (not nested sub-structs)
- [ ] Full pipeline orchestrated: ingest -> discussion queue -> docs -> embed
- [ ] --full resets cursors (passes through to ingest)
- [ ] --force overrides stale sync lock
- [ ] --no-embed skips embedding stage (Ollama not needed)
- [ ] --no-docs skips document regeneration stage
- [ ] Discussion queue processing bounded per run
- [ ] Dirty sources marked inside ingestion transactions (via mark_dirty_tx)
- [ ] Progress reporting: stage names + elapsed time
- [ ] Lock acquired with heartbeat at start, released at end (even on error)
- [ ] Embedding skipped gracefully if Ollama unavailable (warning, not error)
- [ ] JSON summary in robot mode
- [ ] Human-readable summary with elapsed time
- [ ] `cargo build` succeeds

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

## TDD Loop
RED: Integration test requiring full pipeline
GREEN: Implement run_sync orchestration (async)
VERIFY: `cargo build && cargo test sync`

## Edge Cases
- Ollama unavailable + --no-embed not set: sync should NOT fail — embed stage logs warning, returns 0 embedded
- Lock already held: error unless --force (and lock is stale)
- No dirty sources after ingest: regeneration stage returns 0 (not error)
- --full with large dataset: keyset pagination prevents OFFSET degradation

Sync CLI: async run_sync orchestrator with 4-stage pipeline (ingest issues, ingest MRs, generate-docs, embed), SyncOptions/SyncResult, --full/--force/--no-embed/--no-docs flags, graceful Ollama degradation, human+JSON output, clean build, all tests pass


Implement attention state SQL computation

## Background
The attention state model (AC-4) is the core intelligence of the dashboard. It classifies each work item based on comment activity: who commented last, and when. The computation uses CTEs and a CASE expression with 5 branches, evaluated in priority order.

## Approach
Create `src/cli/commands/me/attention.rs`:
```rust
use rusqlite::Connection;
use crate::core::error::Result;
use super::types::{AttentionState, MeIssue, MeMrAuthored, MeMrReviewing};

pub fn enrich_attention_states(
    conn: &Connection,
    username: &str,
    issues: &mut [MeIssue],
    authored_mrs: &mut [MeMrAuthored],
    reviewing_mrs: &mut [MeMrReviewing],
) -> Result<()>
```

This runs AFTER the raw items are fetched. For each work item type, execute a single SQL query that computes the attention state for all items at once, then update the `attention_state` field on each struct.

### Schema context for the SQL

**notes** (`src/core/db.rs` migration 002): `id, gitlab_id, discussion_id (FK→discussions), project_id, note_type, is_system (INTEGER 0/1), author_username, body, created_at (ms epoch), updated_at, ...`

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

**mr_reviewers** (migration 006): `merge_request_id (FK), username TEXT, PRIMARY KEY(merge_request_id, username)`

**merge_requests** (migration 006): `draft INTEGER NOT NULL DEFAULT 0` (0/1 boolean)

### Attention SQL pattern

For issues, the query computes per-issue attention state:
```sql
WITH my_latest AS (
    SELECT d.issue_id,
           MAX(n.created_at) AS ts
    FROM notes n
    JOIN discussions d ON n.discussion_id = d.id
    WHERE n.is_system = 0
      AND n.author_username = ?username
      AND d.issue_id IS NOT NULL
    GROUP BY d.issue_id
),
others_latest AS (
    SELECT d.issue_id,
           MAX(n.created_at) AS ts
    FROM notes n
    JOIN discussions d ON n.discussion_id = d.id
    WHERE n.is_system = 0
      AND n.author_username != ?username
      AND d.issue_id IS NOT NULL
    GROUP BY d.issue_id
),
any_latest AS (
    SELECT d.issue_id,
           MAX(n.created_at) AS ts
    FROM notes n
    JOIN discussions d ON n.discussion_id = d.id
    WHERE n.is_system = 0
      AND d.issue_id IS NOT NULL
    GROUP BY d.issue_id
)
SELECT i.id,
    CASE
        -- 2. needs_attention: others commented AND (I haven't OR others' latest > mine)
        WHEN ol.ts IS NOT NULL AND (ml.ts IS NULL OR ol.ts > ml.ts) THEN 'needs_attention'
        -- 3. stale: any notes exist AND latest > 30 days old
        WHEN al.ts IS NOT NULL AND al.ts < (?now_ms - 30 * 86400 * 1000) THEN 'stale'
        -- 4. awaiting_response: my latest >= others' latest
        WHEN ml.ts IS NOT NULL AND (ol.ts IS NULL OR ml.ts >= ol.ts) THEN 'awaiting_response'
        -- 5. not_started: zero non-system notes
        ELSE 'not_started'
    END AS attention
FROM issues i
LEFT JOIN my_latest ml ON ml.issue_id = i.id
LEFT JOIN others_latest ol ON ol.issue_id = i.id
LEFT JOIN any_latest al ON al.issue_id = i.id
WHERE i.id IN (...)
```

For MRs, prepend a `not_ready` check before the CASE:
```sql
WHEN mr.draft = 1 AND NOT EXISTS (
    SELECT 1 FROM mr_reviewers mrr WHERE mrr.merge_request_id = mr.id
) THEN 'not_ready'
```

### Implementation strategy

1. Collect all internal DB `id` values from the fetched issue/MR vecs
2. Run the CTE query with those IDs as parameters (use `rusqlite::params_from_iter` or IN-list)
3. Build a `HashMap<i64, AttentionState>` from results
4. Iterate over the mutable slices, setting `attention_state` from the map
5. Default to `NotStarted` for any ID not found (shouldn't happen but safe fallback)

Use `AttentionState::from_sql_str(s)` (from bd-1vai) to convert the SQL CASE string to the enum.

Note: The fetched structs (`MeIssue`, `MeMrAuthored`, `MeMrReviewing`) will need an internal `id: i64` field (the DB primary key) for this lookup. This field should NOT serialize to JSON (use `#[serde(skip)]`). Ensure bd-3bwh includes this.

## Acceptance Criteria
- [ ] not_ready: MR with draft=1 AND zero mr_reviewers → NotReady (AC-4.4.1)
- [ ] not_ready: MR with draft=1 BUT has reviewers → falls through to other states
- [ ] needs_attention: others commented, I haven't → NeedsAttention (AC-4.4.2)
- [ ] needs_attention: others' latest > my latest → NeedsAttention
- [ ] stale: notes exist but latest is >30 days old → Stale (AC-4.4.3)
- [ ] stale: zero notes → NOT stale (falls to not_started)
- [ ] not_started: zero non-system notes from anyone → NotStarted (AC-4.4.4)
- [ ] awaiting_response: my latest >= others' latest → AwaitingResponse (AC-4.4.5)
- [ ] awaiting_response: only my notes, no others → AwaitingResponse
- [ ] Applied to issues, authored MRs, and reviewing MRs (AC-4.5)
- [ ] Only considers non-system notes (is_system = 0) (AC-4.2)
- [ ] Uses AttentionState::from_sql_str() for enum conversion

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

## TDD Anchor
RED: Write `test_needs_attention_when_others_commented_after_me` using in-memory DB (`create_connection(Path::new(":memory:"))` + `run_migrations(&conn)`). Insert a project, issue, issue_assignee, discussion (noteable_type='Issue', issue_id set), two notes: mine at created_at=100000, theirs at created_at=200000. Call `enrich_attention_states`. Assert issue's attention_state = NeedsAttention.

GREEN: Implement the SQL CTE query.
VERIFY: `cargo test attention`

Additional tests:
- test_needs_attention_others_commented_i_havent
- test_awaiting_response_i_commented_last
- test_awaiting_response_only_my_notes
- test_not_started_zero_notes
- test_stale_old_notes (set any_latest.ts to > 30 days ago using a fixed "now" param)
- test_stale_not_applied_to_zero_notes
- test_not_ready_draft_no_reviewers (insert MR with draft=1, no mr_reviewers rows)
- test_not_ready_draft_with_reviewers_falls_through
- test_system_notes_excluded (insert is_system=1 note, verify not counted)

## Edge Cases
- notes join through discussions: `notes.discussion_id → discussions.id`, then `discussions.issue_id / discussions.merge_request_id`
- "30 days" stale threshold: use current time (ms) minus `30 * 86_400 * 1_000` ms. Pass `now_ms` as a parameter for testability (don't call `SystemTime::now()` inside SQL).
- An item with ONLY system notes → zero non-system notes → `not_started`
- NULL handling: LEFT JOINs mean `ml.ts`, `ol.ts`, `al.ts` can all be NULL — the CASE handles this with IS NOT NULL / IS NULL checks
- The `id IN (...)` list may be empty (no issues fetched) — skip the query entirely for empty slices

## Dependency Context
Uses `AttentionState` enum and its `from_sql_str()` from bd-1vai (same `types.rs` file).
Uses `MeIssue`, `MeMrAuthored`, `MeMrReviewing` structs from bd-3bwh — these need an internal `id: i64` field with `#[serde(skip)]`.
Runs after the fetch queries from bd-joja (issues), bd-1obt (authored MRs), bd-1fgr (reviewing MRs).
Called by the handler in bd-1vv8.


Write invariant tests for ranking system

## Background
Invariant tests catch subtle ranking regressions that example-based tests miss. These test properties that must hold for ANY input, not specific values.

## Approach

### test_score_monotonicity_by_age:
Generate 50 random (age_ms, half_life_days) pairs using a simple LCG PRNG (deterministic seed for reproducibility). Assert decay(older) <= decay(newer) for all pairs where older > newer. Tests the pure half_life_decay() function only.

### test_row_order_independence:
Insert the same 5 signals in two orderings (forward and reverse). Run query_expert on both -> assert identical username ordering and identical scores (f64 bit-equal). Use a deterministic dataset with varied timestamps.

### test_reviewer_split_is_exhaustive:
Set up 3 reviewers on the same MR:
1. Reviewer with substantive DiffNotes (>= 20 chars) -> must appear in participated ONLY
2. Reviewer with no DiffNotes -> must appear in assigned-only ONLY
3. Reviewer with trivial note (< 20 chars) -> must appear in assigned-only ONLY
Use --explain-score to verify each reviewer's components: participated reviewer has reviewer_participated > 0 and reviewer_assigned == 0; others have reviewer_assigned > 0 and reviewer_participated == 0.

### test_deterministic_accumulation_order:
Insert signals for one user with 15 MRs at varied timestamps. Run query_expert 100 times in a loop. Assert all 100 runs produce the exact same f64 score (use == not approx, to verify bit-identical results from sorted accumulation).

## Acceptance Criteria
- [ ] All 4 tests pass
- [ ] No flakiness across 10 consecutive cargo test runs
- [ ] test_score_monotonicity covers at least 50 random pairs
- [ ] test_deterministic_accumulation runs at least 100 iterations

## Files
- src/cli/commands/who.rs (test module)

## Edge Cases
- LCG PRNG for monotonicity test: use fixed seed, not rand crate (avoid dependency)
- Bit-identical f64: use assert_eq!(a, b) not approx — the deterministic ordering guarantees this
- Row order test: must insert in genuinely different orders, not just shuffled within same transaction

Tests distributed to implementation beads: monotonicity->bd-1soz, row_order+split+deterministic->bd-13q8


Implement chunk ID encoding module

## Background
sqlite-vec uses a single integer rowid for embeddings. To store multiple chunks per document, we encode (document_id, chunk_index) into a single rowid using a multiplier. This module is shared between the embedding pipeline (encode on write) and vector search (decode on read). The encoding scheme supports up to 1000 chunks per document.

## Approach
Create `src/embedding/chunk_ids.rs`:

```rust
/// Multiplier for encoding (document_id, chunk_index) into a single rowid.
/// Supports up to 1000 chunks per document (32M chars at 32k/chunk).
pub const CHUNK_ROWID_MULTIPLIER: i64 = 1000;

/// Encode (document_id, chunk_index) into a sqlite-vec rowid.
///
/// rowid = document_id * CHUNK_ROWID_MULTIPLIER + chunk_index
pub fn encode_rowid(document_id: i64, chunk_index: i64) -> i64 {
    document_id * CHUNK_ROWID_MULTIPLIER + chunk_index
}

/// Decode a sqlite-vec rowid back into (document_id, chunk_index).
pub fn decode_rowid(rowid: i64) -> (i64, i64) {
    let document_id = rowid / CHUNK_ROWID_MULTIPLIER;
    let chunk_index = rowid % CHUNK_ROWID_MULTIPLIER;
    (document_id, chunk_index)
}
```

Also create the parent module `src/embedding/mod.rs`:
```rust
pub mod chunk_ids;
// Later beads add: pub mod ollama; pub mod pipeline;
```

Update `src/lib.rs`: add `pub mod embedding;`

## Acceptance Criteria
- [ ] `encode_rowid(42, 0)` == 42000
- [ ] `encode_rowid(42, 5)` == 42005
- [ ] `decode_rowid(42005)` == (42, 5)
- [ ] Roundtrip: decode(encode(doc_id, chunk_idx)) == (doc_id, chunk_idx) for all valid inputs
- [ ] CHUNK_ROWID_MULTIPLIER is 1000
- [ ] `cargo test chunk_ids` passes

## Files
- `src/embedding/chunk_ids.rs` — new file
- `src/embedding/mod.rs` — new file (module root)
- `src/lib.rs` — add `pub mod embedding;`

## TDD Loop
RED: Tests in `#[cfg(test)] mod tests`:
- `test_encode_single_chunk` — encode(1, 0) == 1000
- `test_encode_multi_chunk` — encode(1, 5) == 1005
- `test_decode_roundtrip` — property test over range of doc_ids and chunk_indices
- `test_decode_zero_chunk` — decode(42000) == (42, 0)
- `test_multiplier_value` — assert CHUNK_ROWID_MULTIPLIER == 1000
GREEN: Implement encode_rowid, decode_rowid
VERIFY: `cargo test chunk_ids`

## Edge Cases
- chunk_index >= 1000: not expected (documents that large would be pathological), but no runtime panic — just incorrect decode. The embedding pipeline caps chunks well below this.
- document_id = 0: valid (encode returns chunk_index directly)

Completed: chunk_ids module with encode_rowid/decode_rowid, CHUNK_ROWID_MULTIPLIER=1000, 6 tests pass


Phase 4c: Cancellation integration tests (asupersync-native)

## What
Add asupersync-native integration tests for cancellation and region semantics. These test the PRIMARY MOTIVATION for this migration — structured cancellation.

## Why
Wiremock tests validate protocol/serialization but cannot test cancellation. The deterministic lab runtime is one of the primary motivations for this migration.

## Tests to Write

### 1. Ctrl+C during fan-out
Simulate cancellation mid-batch in orchestrator. Verify:
- All in-flight tasks are drained
- No task leaks
- No obligation leaks

### 2. Region quiescence
After a region completes (normal or cancelled), verify no background tasks remain running.

### 3. Transaction integrity under cancellation
Cancel during an ingestion batch that has fetched data but not yet written to DB. Verify:
- No partial data is committed
- DB is in a consistent state
- Next sync picks up where it left off

## Test Infrastructure
Use asupersync deterministic lab runtime for reproducible cancellation timing.

## Files Changed
- tests/cancellation_tests.rs (NEW, ~150-200 LOC)

## Testing
- cargo test -- cancellation (all new tests must pass)

## Depends On
- Phase 3f-Step1 (region wrapping must be in place)

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


[CP1] GitLab types for issues, discussions, notes

Add TypeScript interfaces for GitLab API responses.

Types to add to src/gitlab/types.ts:
- GitLabIssue: id, iid, project_id, title, description, state, timestamps, author, labels[], labels_details?, web_url
- GitLabDiscussion: id (string), individual_note, notes[]
- GitLabNote: id, type, body, author, timestamps, system, resolvable, resolved, resolved_by, resolved_at, position?

Files: src/gitlab/types.ts
Done when: Types compile and match GitLab API documentation


Implement rename chain resolution for file-history

## Background

Rename chain resolution is the core algorithm for Gate 4. When querying history of src/auth.rs, it finds MRs that touched the file when it was previously named src/authentication.rs. This is reused by Gate 5 (trace) as well.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 4.6 (Rename Handling).

## Codebase Context

- mr_file_changes table (migration 016, bd-1oo): merge_request_id, project_id, old_path, new_path, change_type
- change_type='renamed' rows have both old_path and new_path populated
- Partial index `idx_mfc_renamed` on (project_id, change_type) WHERE change_type='renamed' optimizes BFS queries
- Also `idx_mfc_project_path` on (project_id, new_path) and `idx_mfc_project_old_path` partial index
- No timeline/trace/file_history modules exist yet in src/core/

## Approach

Create `src/core/file_history.rs`:

```rust
use std::collections::HashSet;
use std::collections::VecDeque;
use rusqlite::Connection;
use crate::core::error::Result;

/// Resolves a file path through its rename history.
/// Returns all equivalent paths (original + renames) for use in queries.
/// BFS in both directions: forward (old_path -> new_path) and backward (new_path -> old_path).
pub fn resolve_rename_chain(
    conn: &Connection,
    project_id: i64,
    path: &str,
    max_hops: usize,       // default 10 from CLI
) -> Result<Vec<String>> {
    let mut visited: HashSet<String> = HashSet::new();
    let mut queue: VecDeque<String> = VecDeque::new();

    visited.insert(path.to_string());
    queue.push_back(path.to_string());

    let forward_sql = "SELECT mfc.new_path FROM mr_file_changes mfc \
        WHERE mfc.project_id = ?1 AND mfc.old_path = ?2 AND mfc.change_type = 'renamed'";
    let backward_sql = "SELECT mfc.old_path FROM mr_file_changes mfc \
        WHERE mfc.project_id = ?1 AND mfc.new_path = ?2 AND mfc.change_type = 'renamed'";

    while let Some(current) = queue.pop_front() {
        if visited.len() > max_hops + 1 { break; }

        // Forward: current was the old name -> discover new names
        let mut stmt = conn.prepare(forward_sql)?;
        let forward: Vec<String> = stmt.query_map(
            rusqlite::params\![project_id, current],
            |row| row.get(0),
        )?.filter_map(|r| r.ok()).collect();

        // Backward: current was the new name -> discover old names
        let mut stmt = conn.prepare(backward_sql)?;
        let backward: Vec<String> = stmt.query_map(
            rusqlite::params\![project_id, current],
            |row| row.get(0),
        )?.filter_map(|r| r.ok()).collect();

        for discovered in forward.into_iter().chain(backward) {
            if visited.insert(discovered.clone()) {
                queue.push_back(discovered);
            }
        }
    }

    Ok(visited.into_iter().collect())
}
```

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

## Acceptance Criteria

- [ ] `resolve_rename_chain()` follows renames in both directions (forward + backward)
- [ ] Cycles detected via HashSet (same path never visited twice)
- [ ] Bounded at max_hops (default 10)
- [ ] No renames found: returns vec with just the original path
- [ ] max_hops=0: returns just original path without querying DB
- [ ] Module registered in src/core/mod.rs as `pub mod file_history;`
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

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

## TDD Loop

RED:
- `test_rename_chain_no_renames` — returns just original path
- `test_rename_chain_forward` — a.rs -> b.rs -> c.rs: starting from a.rs finds all three
- `test_rename_chain_backward` — starting from c.rs finds a.rs and b.rs
- `test_rename_chain_cycle_detection` — a->b->a terminates without infinite loop
- `test_rename_chain_max_hops_zero` — returns just original path
- `test_rename_chain_max_hops_bounded` — chain longer than max is truncated

Tests need in-memory DB with migrations applied through 016 + mr_file_changes test data with change_type='renamed'.

GREEN: Implement BFS with visited set.

VERIFY: `cargo test --lib -- file_history`

## Edge Cases

- File never renamed: single-element vec
- Circular rename (a->b->a): visited set prevents infinite loop
- max_hops=0: return just original path, no queries executed
- Case sensitivity: paths are case-sensitive (Linux default, matches GitLab behavior)
- Multiple renames from same old_path: BFS discovers all branches

Implemented resolve_rename_chain() BFS in src/core/file_history.rs with 8 tests covering: no renames, forward chain, backward chain, cycle detection, max_hops=0, max_hops bounded, branching renames, project isolation. All 765 tests pass, clippy+fmt clean.


Implement MR document extraction

## Background
MR documents are similar to issue documents but include source/target branch information in the header. The extractor queries merge_requests and mr_labels tables. Like issue extraction, it produces a DocumentData struct for the regeneration pipeline.

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

```rust
/// Extract a searchable document from a merge request.
/// Returns None if the MR has been deleted from the DB.
pub fn extract_mr_document(conn: &Connection, mr_id: i64) -> Result<Option<DocumentData>>
```

**SQL queries (from PRD Section 2.2):**
```sql
-- Main entity
SELECT m.id, m.iid, m.title, m.description, m.state, m.author_username,
       m.source_branch, m.target_branch,
       m.created_at, m.updated_at, m.web_url,
       p.path_with_namespace, p.id AS project_id
FROM merge_requests m
JOIN projects p ON p.id = m.project_id
WHERE m.id = ?

-- Labels
SELECT l.name FROM mr_labels ml
JOIN labels l ON l.id = ml.label_id
WHERE ml.merge_request_id = ?
ORDER BY l.name
```

**Document format:**
```
[[MergeRequest]] !456: Implement JWT authentication
Project: group/project-one
URL: https://gitlab.example.com/group/project-one/-/merge_requests/456
Labels: ["feature", "auth"]
State: opened
Author: @johndoe
Source: feature/jwt-auth -> main

--- Description ---

This MR implements JWT-based authentication...
```

**Key difference from issues:** The `Source:` line with `source_branch -> target_branch`.

## Acceptance Criteria
- [ ] Deleted MR returns Ok(None)
- [ ] MR document has `[[MergeRequest]]` prefix with `!` before iid (not `#`)
- [ ] Source line shows `source_branch -> target_branch`
- [ ] Labels sorted alphabetically in JSON array
- [ ] content_hash computed from full content_text
- [ ] labels_hash computed from sorted labels
- [ ] paths is empty (MR-level docs don't have DiffNote paths; those are on discussion docs)
- [ ] `cargo test extract_mr` passes

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

## TDD Loop
RED: Tests in `#[cfg(test)] mod tests`:
- `test_mr_document_format` — verify header matches PRD template with Source line
- `test_mr_not_found` — returns Ok(None)
- `test_mr_no_description` — header only
- `test_mr_branch_info` — Source line correct
GREEN: Implement extract_mr_document with SQL queries
VERIFY: `cargo test extract_mr`

## Edge Cases
- MR with NULL description: skip "--- Description ---" section
- MR with NULL source_branch or target_branch: omit Source line (shouldn't happen in practice)
- Draft MRs: state field captures this, no special handling needed

Implemented extract_mr_document() with Source line, PRD format, and 5 tests


OBSERV: Enrich robot JSON meta with run_id and stages

## Background
Robot JSON currently has a flat meta.elapsed_ms. This enriches it with run_id and a stages array, making every lore --robot sync output a complete performance profile.

## Approach
The robot JSON output is built in src/cli/commands/sync.rs. The current SyncResult (line 15-25) is serialized into the data field. The meta field is built alongside it.

1. Find or create the SyncMeta struct (likely near SyncResult). Add fields:
```rust
#[derive(Debug, Serialize)]
struct SyncMeta {
    run_id: String,
    elapsed_ms: u64,
    stages: Vec<StageTiming>,
}
```

2. After run_sync() completes, extract timings from MetricsLayer:
```rust
let stages = metrics_handle.extract_timings();
let meta = SyncMeta {
    run_id: run_id.to_string(),
    elapsed_ms: start.elapsed().as_millis() as u64,
    stages,
};
```

3. Build the JSON envelope:
```rust
let output = serde_json::json!({
    "ok": true,
    "data": result,
    "meta": meta,
});
```

The metrics_handle (Arc<MetricsLayer>) must be passed from main.rs to the command handler. This requires adding a parameter to handle_sync_cmd() and run_sync(), or using a global. Prefer parameter passing.

Same pattern for standalone ingest: add stages to IngestMeta.

## Acceptance Criteria
- [ ] lore --robot sync output includes meta.run_id (string, 8 hex chars)
- [ ] lore --robot sync output includes meta.stages (array of StageTiming)
- [ ] meta.elapsed_ms still present (total wall clock time)
- [ ] Each stage has name, elapsed_ms, items_processed at minimum
- [ ] Top-level stages have sub_stages when applicable
- [ ] lore --robot ingest also includes run_id and stages
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/cli/commands/sync.rs (add SyncMeta struct, wire extract_timings)
- src/cli/commands/ingest.rs (same for standalone ingest)
- src/main.rs (pass metrics_handle to command handlers)

## TDD Loop
RED: test_sync_meta_includes_stages (run robot-mode sync, parse JSON, assert meta.stages is array)
GREEN: Add SyncMeta, extract timings, include in JSON output
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- Empty stages: if sync runs with --no-docs --no-embed, some stages won't exist. stages array is shorter, not padded.
- extract_timings() called before root span closes: returns incomplete tree. Must call AFTER run_sync returns (span is dropped on function exit).
- metrics_handle clone: MetricsLayer uses Arc internally, clone is cheap (reference count increment).

Wired MetricsLayer into subscriber stack (all 4 branches), added run_id to SyncResult, enriched SyncMeta with run_id + stages Vec<StageTiming>, updated print_sync_json to accept MetricsLayer and extract timings