Add --issue, --mr, -p, --preflight-only CLI flags and SyncOptions extensions with validation

## Background
Surgical sync is invoked via `lore sync --issue 123 --mr 456 -p myproject`. This bead adds the CLI flags, extends `SyncOptions` with surgical fields, and wires them together in `handle_sync_cmd` with full validation. This is the user-facing entry point for the entire surgical sync feature.

## Approach

### Step 1: Add flags to SyncArgs (src/cli/mod.rs, struct SyncArgs at line ~760)

Add after the existing `timings` field:

```rust
/// Surgically sync specific issues by IID (repeatable, must be positive)
#[arg(long, value_parser = clap::value_parser\!(u64).range(1..), action = clap::ArgAction::Append)]
pub issue: Vec<u64>,

/// Surgically sync specific merge requests by IID (repeatable, must be positive)
#[arg(long, value_parser = clap::value_parser\!(u64).range(1..), action = clap::ArgAction::Append)]
pub mr: Vec<u64>,

/// Scope to a single project (required when --issue or --mr is used, falls back to config.defaultProject)
#[arg(short = 'p', long)]
pub project: Option<String>,

/// Validate remote entities exist without any DB content writes. Runs preflight network fetch only.
#[arg(long, default_value_t = false)]
pub preflight_only: bool,
```

**Why u64 with range(1..)**: IIDs are always positive. Parse-time validation gives immediate, clear error messages.

### Step 2: Extend SyncOptions (src/cli/commands/sync.rs, struct SyncOptions at line ~20)

Add fields:

```rust
pub issue_iids: Vec<u64>,
pub mr_iids: Vec<u64>,
pub project: Option<String>,
pub preflight_only: bool,
```

Add helper:

```rust
impl SyncOptions {
    const MAX_SURGICAL_TARGETS: usize = 100;

    pub fn is_surgical(&self) -> bool {
        \!self.issue_iids.is_empty() || \!self.mr_iids.is_empty()
    }
}
```

### Step 3: Wire in handle_sync_cmd (src/main.rs, function handle_sync_cmd at line ~2070)

After existing SyncOptions construction (~line 2088):

1. **Dedup IIDs** before constructing options:
```rust
let mut issue_iids = args.issue;
let mut mr_iids = args.mr;
issue_iids.sort_unstable();
issue_iids.dedup();
mr_iids.sort_unstable();
mr_iids.dedup();
```

2. **Add new fields** to the SyncOptions construction.

3. **Validation** (after options creation):
- Hard cap: `issue_iids.len() + mr_iids.len() > MAX_SURGICAL_TARGETS` → error
- Project required: use `config.effective_project(options.project.as_deref())` or manual fallback to `config.default_project`. Error if still None.
- Incompatible flags: `--full` + surgical → error
- Embed leakage guard: `--no-docs` without `--no-embed` in surgical mode → error
- `--preflight-only` requires surgical mode → error

## Acceptance Criteria
- [ ] `lore sync --issue 123` parses correctly (issue_iids = [123])
- [ ] `lore sync --issue 123 --issue 456` produces deduplicated sorted vec
- [ ] `lore sync --mr 789` parses correctly
- [ ] `lore sync --issue 0` rejected at parse time by clap
- [ ] `lore sync --issue -1` rejected at parse time by clap
- [ ] `lore sync -p myproject --issue 1` sets project = Some("myproject")
- [ ] `lore sync --preflight-only --issue 1 -p proj` sets preflight_only = true
- [ ] `SyncOptions::is_surgical()` returns true when issue_iids or mr_iids is non-empty
- [ ] `SyncOptions::MAX_SURGICAL_TARGETS` is 100
- [ ] Validation: `--issue 1` without `-p` and no defaultProject → clear error
- [ ] Validation: `--issue 1` without `-p` but with defaultProject → uses defaultProject
- [ ] Validation: `--full --issue 1 -p proj` → clear incompatibility error
- [ ] Validation: `--no-docs --issue 1 -p proj` (without --no-embed) → clear embed leakage error
- [ ] Validation: `--no-docs --no-embed --issue 1 -p proj` → accepted
- [ ] Validation: `--preflight-only` without --issue/--mr → clear error
- [ ] Validation: >100 combined targets → clear hard cap error
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes
- [ ] Normal `lore sync` (without --issue/--mr) still works identically

## Files
- MODIFY: src/cli/mod.rs (add fields to SyncArgs, ~line 805)
- MODIFY: src/cli/commands/sync.rs (extend SyncOptions + is_surgical + MAX_SURGICAL_TARGETS)
- MODIFY: src/main.rs (wire fields + validation in handle_sync_cmd)

## TDD Anchor
RED: Add `is_surgical()` method to SyncOptions. Write a test `fn surgical_mode_detected()` asserting `SyncOptions { issue_iids: vec\![1], ..Default::default() }.is_surgical() == true`.
GREEN: Implement `is_surgical()`.
VERIFY: `cargo test surgical_mode_detected`

Additional tests:
- `surgical_no_docs_requires_no_embed_validation` — verify validation rejects --no-docs without --no-embed in surgical mode

## Edge Cases
- Clap `ArgAction::Append` allows `--issue 1 --issue 2` but NOT `--issue 1,2` (no value_delimiter). This is intentional per the plan.
- Duplicate IIDs like `--issue 123 --issue 123` must be handled by dedup, not rejected.
- The `effective_project` helper on Config may already exist — use it if available.

## Dependency Context
None — this is a leaf dependency. Can be done in parallel with bead bd-1sc6 and bd-159p.


Extend 'lore stats --check' for event table integrity and queue health

## Background
The existing stats --check command validates data integrity. Need to extend it for event tables (referential integrity) and dependent job queue health (stuck locks, retryable jobs). This provides operators and agents a way to detect data quality issues after sync.

## Approach
Extend src/cli/commands/stats.rs check mode:

**New checks:**

1. Event FK integrity:
```sql
-- Orphaned state events (issue_id points to non-existent issue)
SELECT COUNT(*) FROM resource_state_events rse
WHERE rse.issue_id IS NOT NULL
  AND NOT EXISTS (SELECT 1 FROM issues i WHERE i.id = rse.issue_id);
-- (repeat for merge_request_id, and for label + milestone event tables)
```

2. Queue health:
```sql
-- Pending jobs by type
SELECT job_type, COUNT(*) FROM pending_dependent_fetches GROUP BY job_type;
-- Stuck locks (locked_at older than 5 minutes)
SELECT COUNT(*) FROM pending_dependent_fetches WHERE locked_at IS NOT NULL AND locked_at < ?;
-- Retryable jobs (attempts > 0, not locked)
SELECT COUNT(*) FROM pending_dependent_fetches WHERE attempts > 0 AND locked_at IS NULL;
-- Max attempts (jobs that may be permanently failing)
SELECT job_type, MAX(attempts) FROM pending_dependent_fetches GROUP BY job_type;
```

3. Human output per check: PASS / WARN / FAIL with counts
```
Event FK integrity:     PASS (0 orphaned events)
Queue health:           WARN (3 stuck locks, 12 retryable jobs)
```

4. Robot JSON: structured health report
```json
{
  "event_integrity": {
    "status": "pass",
    "orphaned_state_events": 0,
    "orphaned_label_events": 0,
    "orphaned_milestone_events": 0
  },
  "queue_health": {
    "status": "warn",
    "pending_by_type": {"resource_events": 5, "mr_closes_issues": 2},
    "stuck_locks": 3,
    "retryable_jobs": 12,
    "max_attempts_by_type": {"resource_events": 5}
  }
}
```

## Acceptance Criteria
- [ ] Detects orphaned events (FK target missing)
- [ ] Detects stuck locks (locked_at older than threshold)
- [ ] Reports retryable job count and max attempts
- [ ] Human output shows PASS/WARN/FAIL per check
- [ ] Robot JSON matches structured schema
- [ ] Graceful when event/queue tables don't exist

## Files
- src/cli/commands/stats.rs (extend check mode)

## TDD Loop
RED: tests/stats_check_tests.rs:
- `test_stats_check_events_pass` - clean data, verify PASS
- `test_stats_check_events_orphaned` - delete an issue with events remaining, verify FAIL count
- `test_stats_check_queue_stuck_locks` - set old locked_at, verify WARN
- `test_stats_check_queue_retryable` - fail some jobs, verify retryable count

GREEN: Add the check queries and formatting

VERIFY: `cargo test stats_check -- --nocapture`

## Edge Cases
- FK with CASCADE should prevent orphaned events in normal operation — but manual DB edits or bugs could cause them
- Tables may not exist if migration 011 not applied — check table existence before querying
- Empty queue is PASS (not WARN for "no jobs found")
- Distinguish between "0 stuck locks" (good) and "queue table doesn't exist" (skip check)

Extended IntegrityResult with orphan_state/label/milestone_events and queue_stuck_locks/queue_max_attempts. Added FK integrity queries for all 3 event tables and queue health checks. Updated human output with PASS/WARN/FAIL indicators and robot JSON.


[CP1] gi sync-status enhancement

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

Output:
- Last run timestamp and duration
- Cursor positions per project (issues resource_type)
- Entity counts (issues, discussions, notes)

Files: src/cli/commands/sync-status.ts (update existing)
Done when: Shows cursor positions and counts after ingestion


Vertical slice integration test + SLO verification

## Background
The vertical slice gate validates that core screens work together end-to-end with real data flows and meet performance SLOs. This is a manual + automated verification pass.

## Approach
Create integration tests in crates/lore-tui/tests/:
- test_full_nav_flow: Dashboard -> press i -> IssueList loads -> press Enter -> IssueDetail loads -> press Esc -> back to IssueList with cursor preserved -> press H -> Dashboard
- test_filter_requery: IssueList -> type filter -> verify re-query triggers and results update
- test_stale_result_guard: rapidly navigate between screens, verify no stale data displayed
- Performance benchmarks: run M-tier fixture, measure p95 nav latency, assert < 75ms
- Stuck-input check: fuzz InputMode transitions, assert always recoverable via Esc or Ctrl+C
- Cancel latency: start sync, cancel, measure time to acknowledgment, assert < 2s

## Acceptance Criteria
- [ ] Full nav flow test passes without panic
- [ ] Filter re-query test shows updated results
- [ ] No stale data displayed during rapid navigation
- [ ] p95 nav latency < 75ms on M-tier fixtures
- [ ] Zero stuck-input states across 1000 random key sequences
- [ ] Sync cancel acknowledged p95 < 2s
- [ ] All state preserved correctly on back-navigation

## Files
- CREATE: crates/lore-tui/tests/vertical_slice.rs

## TDD Anchor
RED: Write test_dashboard_to_issue_detail_roundtrip that navigates Dashboard -> IssueList -> IssueDetail -> Esc -> IssueList, asserts cursor position preserved.
GREEN: Ensure all navigation and state preservation is wired up.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml vertical_slice

## Edge Cases
- Tests need FakeClock and synthetic DB fixtures (not real GitLab)
- ftui test harness required for rendering tests without TTY
- Performance benchmarks may vary by machine — use relative thresholds

## Dependency Context
Requires all Phase 2 screens: Dashboard, Issue List, Issue Detail, MR List, MR Detail.
Requires NavigationStack, TaskSupervisor, DbManager from Phase 1.


[CP1] gi ingest --type=issues command

CLI command to orchestrate issue ingestion.

Implementation:
1. Acquire app lock with heartbeat
2. Create sync_run record (status='running')
3. For each configured project:
   - Call ingestIssues()
   - For each ingested issue, call ingestIssueDiscussions()
   - Show progress (spinner or progress bar)
4. Update sync_run (status='succeeded', metrics_json)
5. Release lock

Flags:
- --type=issues (required)
- --project=PATH (optional, filter to single project)
- --force (override stale lock)

Output: Progress bar, then summary with counts

Files: src/cli/commands/ingest.ts
Tests: tests/integration/sync-runs.test.ts
Done when: Full issue + discussion ingestion works end-to-end


lore brief: situational awareness for topic/module/person

## Background
Composable capstone command. An agent says "I am about to work on auth" and gets everything in one call: open issues, active MRs, experts, recent activity, unresolved threads, related context. Replaces 5 separate lore calls with 1.

## Input Modes
1. Topic: `lore brief 'authentication'` — FTS search to find relevant entities, aggregate
2. Path: `lore brief --path src/auth/` — who expert internals for path expertise
3. Person: `lore brief --person teernisse` — who workload internals
4. Entity: `lore brief issues 3864` — single entity focus with cross-references

## Section Assembly Architecture

### Reuse existing run_* functions (ship faster, recommended for v1)
Each section calls existing CLI command functions and converts their output.

IMPORTANT: All existing run_* functions take `&Config`, NOT `&Connection`. The Config contains the db_path and each function opens its own connection internally.

```rust
// In src/cli/commands/brief.rs

use crate::cli::commands::list::{run_list_issues, run_list_mrs, ListFilters, MrListFilters};
use crate::cli::commands::who::{run_who, WhoArgs, WhoMode};
use crate::core::config::Config;

pub async fn run_brief(config: &Config, args: BriefArgs) -> Result<BriefResponse> {
    let mut sections_computed = Vec::new();

    // 1. open_issues: reuse list.rs
    // Signature: pub fn run_list_issues(config: &Config, filters: ListFilters) -> Result<ListResult>
    // Located at src/cli/commands/list.rs:268
    let open_issues = run_list_issues(config, ListFilters {
        state: Some("opened".into()),
        limit: Some(5),
        project: args.project.clone(),
        // ... scope by topic/path/person based on mode
        ..Default::default()
    })?;
    sections_computed.push("open_issues");

    // 2. active_mrs: reuse list.rs
    // Signature: pub fn run_list_mrs(config: &Config, filters: MrListFilters) -> Result<MrListResult>
    // Located at src/cli/commands/list.rs:476
    let active_mrs = run_list_mrs(config, MrListFilters {
        state: Some("opened".into()),
        limit: Some(5),
        project: args.project.clone(),
        ..Default::default()
    })?;
    sections_computed.push("active_mrs");

    // 3. experts: reuse who.rs
    // Signature: pub fn run_who(config: &Config, args: &WhoArgs) -> Result<WhoRun>
    // Located at src/cli/commands/who.rs:276
    let experts = run_who(config, &WhoArgs {
        mode: WhoMode::Expert,
        path: args.path.clone(),
        limit: Some(3),
        ..Default::default()
    })?;
    sections_computed.push("experts");

    // 4. recent_activity: reuse timeline internals
    // The timeline pipeline is 5-stage (SEED->HYDRATE->EXPAND->COLLECT->RENDER)
    // Types in src/core/timeline.rs, seed in src/core/timeline_seed.rs
    // ...etc
}
```

NOTE: ListFilters and MrListFilters may not implement Default. Check before using `..Default::default()`. If they don't, derive it or construct all fields explicitly.

### Concrete Function References (src/cli/commands/)
| Module | Function | Signature | Line |
|--------|----------|-----------|------|
| list.rs | run_list_issues | `(config: &Config, filters: ListFilters) -> Result<ListResult>` | 268 |
| list.rs | run_list_mrs | `(config: &Config, filters: MrListFilters) -> Result<MrListResult>` | 476 |
| who.rs | run_who | `(config: &Config, args: &WhoArgs) -> Result<WhoRun>` | 276 |
| search.rs | run_search | `(config: &Config, query: &str, cli_filters: SearchCliFilters, fts_mode: FtsQueryMode, requested_mode: &str, explain: bool) -> Result<SearchResponse>` | 61 |

NOTE: run_search is currently synchronous (pub fn, not pub async fn). If bd-1ksf ships first, it becomes async. Brief should handle both cases — call `.await` if async, direct call if sync.

### Section Details
| Section | Source | Limit | Fallback |
|---------|--------|-------|----------|
| open_issues | list.rs with state=opened | 5 | empty array |
| active_mrs | list.rs with state=opened | 5 | empty array |
| experts | who.rs Expert mode | 3 | empty array (no path data) |
| recent_activity | timeline pipeline | 10 events | empty array |
| unresolved_threads | SQL: discussions WHERE resolved=false | 5 | empty array |
| related | search_vector() via bd-8con | 5 | omit section (no embeddings) |
| warnings | computed from dates/state | all | empty array |

### Warning Generation
```rust
fn compute_warnings(issues: &[IssueRow]) -> Vec<String> {
    let now = chrono::Utc::now();
    issues.iter().filter_map(|i| {
        let updated = parse_timestamp(i.updated_at)?;
        let days_stale = (now - updated).num_days();
        if days_stale > 30 {
            Some(format!("Issue #{} has no activity for {} days", i.iid, days_stale))
        } else { None }
    }).chain(
        issues.iter().filter(|i| i.assignees.is_empty())
            .map(|i| format!("Issue #{} is unassigned", i.iid))
    ).collect()
}
```

## Robot Mode Output Schema
```json
{
  "ok": true,
  "data": {
    "mode": "topic",
    "query": "authentication",
    "summary": "3 open issues, 2 active MRs, top expert: teernisse",
    "open_issues": [{ "iid": 123, "title": "...", "state": "opened", "assignees": [...], "updated_at": "...", "labels": [...] }],
    "active_mrs": [{ "iid": 456, "title": "...", "state": "opened", "author": "...", "draft": false, "updated_at": "..." }],
    "experts": [{ "username": "teernisse", "score": 42, "last_activity": "..." }],
    "recent_activity": [{ "timestamp": "...", "event_type": "state_change", "entity_ref": "issues#123", "summary": "...", "actor": "..." }],
    "unresolved_threads": [{ "discussion_id": "abc", "entity_ref": "issues#123", "started_by": "...", "note_count": 5, "last_note_at": "..." }],
    "related": [{ "iid": 789, "title": "...", "similarity_score": 0.85 }],
    "warnings": ["Issue #3800 has no activity for 45 days"]
  },
  "meta": { "elapsed_ms": 1200, "sections_computed": ["open_issues", "active_mrs", "experts", "recent_activity"] }
}
```

## Clap Registration
```rust
// In src/main.rs Commands enum, add:
Brief {
    /// Free-text topic, entity type, or omit for project-wide brief
    query: Option<String>,
    /// Focus on a file path (who expert mode)
    #[arg(long)]
    path: Option<String>,
    /// Focus on a person (who workload mode)
    #[arg(long)]
    person: Option<String>,
    /// Scope to project (fuzzy match)
    #[arg(short, long)]
    project: Option<String>,
    /// Maximum items per section
    #[arg(long, default_value = "5")]
    section_limit: usize,
},
```

## TDD Loop
RED: Tests in src/cli/commands/brief.rs:
- test_brief_topic_returns_all_sections: insert test data, search 'auth', assert all section keys present in response
- test_brief_path_uses_who_expert: brief --path src/auth/, assert experts section populated
- test_brief_person_uses_who_workload: brief --person user, assert open_issues filtered to user's assignments
- test_brief_warnings_stale_issue: insert issue with updated_at > 30 days ago, assert warning generated
- test_brief_token_budget: robot mode output for topic query is under 12000 bytes (~3000 tokens)
- test_brief_no_embeddings_graceful: related section omitted (not errored) when no embeddings exist
- test_brief_empty_topic: zero matches returns valid JSON with empty arrays + "No data found" summary

GREEN: Implement brief with section assembly, calling existing run_* functions

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

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

## Edge Cases
- Topic with zero matches: return empty sections + "No data found for this topic" summary
- Path that nobody has touched: experts empty, related may still have results
- Person not found in DB: exit code 17 with suggestion
- All sections empty: still return valid JSON with empty arrays
- Very broad topic ("the"): may return too many results — each section respects its limit cap
- ListFilters/MrListFilters may not derive Default — construct all fields explicitly if needed

## Dependencies
- Hybrid search (bd-1ksf) for topic relevance ranking
- lore who (already shipped) for expertise
- lore related (bd-8con) for semantic connections (BLOCKER — related section is core to the feature)
- Timeline pipeline (already shipped) for recent activity

## Dependency Context
- **bd-1ksf (hybrid search)**: Provides `search_hybrid()` which brief uses for topic mode to find relevant entities. Without it, topic mode falls back to FTS-only via `search_fts()`.
- **bd-8con (related)**: Provides `run_related()` which brief calls to populate the `related` section with semantically similar entities. This is a blocking dependency — the related section is a core differentiator.

## Files to Create/Modify
- NEW: src/cli/commands/brief.rs
- src/cli/commands/mod.rs (add pub mod brief; re-export)
- src/main.rs (register Brief subcommand in Commands enum, add handle_brief fn)
- Reuse: list.rs, who.rs, timeline.rs, search.rs, show.rs internals


Register 'lore timeline' command with all flags

## Background

This bead wires the `lore timeline` command into the CLI — adding the subcommand to the Commands enum, defining all flags, registering in VALID_COMMANDS, and dispatching to the timeline handler. The actual query logic and rendering are in separate beads.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.1 (Command Design).

## Codebase Context

- Commands enum in `src/cli/mod.rs` (line ~86): uses #[derive(Subcommand)] with nested Args structs
- VALID_COMMANDS in `src/main.rs` (line ~448): &[&str] array for fuzzy command matching
- Handler dispatch in `src/main.rs` match on Commands:: variants
- robot-docs manifest in `src/main.rs`: registers commands for `lore robot-docs` output
- Existing pattern: `Sync(SyncArgs)`, `Search(SearchArgs)`, etc.
- No timeline module exists yet — this bead creates the CLI entry point only

## Approach

### 1. TimelineArgs struct (`src/cli/mod.rs`):

```rust
/// Show a chronological timeline of events matching a query
#[derive(Parser, Debug)]
pub struct TimelineArgs {
    /// Search query (keywords to find in issues, MRs, and discussions)
    pub query: String,

    /// Scope to a specific project (fuzzy match)
    #[arg(short = 'p', long)]
    pub project: Option<String>,

    /// Only show events after this date (e.g. "6m", "2w", "2024-01-01")
    #[arg(long)]
    pub since: Option<String>,

    /// Cross-reference expansion depth (0 = no expansion)
    #[arg(long, default_value = "1")]
    pub depth: usize,

    /// Also follow 'mentioned' edges during expansion (high fan-out)
    #[arg(long = "expand-mentions")]
    pub expand_mentions: bool,

    /// Maximum number of events to display
    #[arg(short = 'n', long = "limit", default_value = "100")]
    pub limit: usize,
}
```

### 2. Commands enum variant:

```rust
/// Show a chronological timeline of events matching a query
#[command(name = "timeline")]
Timeline(TimelineArgs),
```

### 3. Handler in `src/main.rs`:

```rust
Commands::Timeline(args) => {
    // Placeholder: will be filled by bd-2f2 (human) and bd-dty (robot)
    // For now: resolve project, call timeline query, dispatch to renderer
}
```

### 4. VALID_COMMANDS: add `"timeline"` to the array

### 5. robot-docs: add timeline command description to manifest

## Acceptance Criteria

- [ ] `TimelineArgs` struct with all 6 flags: query, project, since, depth, expand-mentions, limit
- [ ] Commands::Timeline variant registered in Commands enum
- [ ] Handler stub in src/main.rs dispatches to timeline logic
- [ ] `"timeline"` added to VALID_COMMANDS array
- [ ] robot-docs manifest includes timeline command description
- [ ] `lore timeline --help` shows correct help text
- [ ] `lore timeline` without query shows error (query is required positional)
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

- `src/cli/mod.rs` (TimelineArgs struct + Commands::Timeline variant)
- `src/main.rs` (handler dispatch + VALID_COMMANDS + robot-docs entry)

## TDD Loop

No unit tests for CLI wiring. Verify with:

```bash
cargo check --all-targets
cargo run -- timeline --help
```

## Edge Cases

- --since parsing: reuse existing date parsing from ListFilters (src/cli/mod.rs handles "7d", "2w", "YYYY-MM-DD")
- --depth 0: valid, means no cross-reference expansion
- --expand-mentions: off by default because mentioned edges have high fan-out

Wired lore timeline command: TimelineArgs with 9 flags, Commands::Timeline variant, handle_timeline handler, VALID_COMMANDS entry, robot-docs manifest with temporal_intelligence workflow


[CP1] GitLab types for issues, discussions, notes

## Background

GitLab types define the Rust structs for deserializing GitLab API responses. These types are the foundation for all ingestion work - issues, discussions, and notes must be correctly typed for serde to parse them.

## Approach

Add types to `src/gitlab/types.rs` with serde derives:

### GitLabIssue

```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabIssue {
    pub id: i64,                      // GitLab global ID
    pub iid: i64,                     // Project-scoped issue number
    pub project_id: i64,
    pub title: String,
    pub description: Option<String>,
    pub state: String,                // "opened" | "closed"
    pub created_at: String,           // ISO 8601
    pub updated_at: String,           // ISO 8601
    pub closed_at: Option<String>,
    pub author: GitLabAuthor,
    pub labels: Vec<String>,          // Array of label names (CP1 canonical)
    pub web_url: String,
}
```

NOTE: `labels_details` intentionally NOT modeled - varies across GitLab versions.

### GitLabAuthor

```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabAuthor {
    pub id: i64,
    pub username: String,
    pub name: String,
}
```

### GitLabDiscussion

```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabDiscussion {
    pub id: String,                   // String ID like "6a9c1750b37d..."
    pub individual_note: bool,        // true = standalone comment
    pub notes: Vec<GitLabNote>,
}
```

### GitLabNote

```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabNote {
    pub id: i64,
    #[serde(rename = "type")]
    pub note_type: Option<String>,    // "DiscussionNote" | "DiffNote" | null
    pub body: String,
    pub author: GitLabAuthor,
    pub created_at: String,           // ISO 8601
    pub updated_at: String,           // ISO 8601
    pub system: bool,                 // true for system-generated notes
    #[serde(default)]
    pub resolvable: bool,
    #[serde(default)]
    pub resolved: bool,
    pub resolved_by: Option<GitLabAuthor>,
    pub resolved_at: Option<String>,
    pub position: Option<GitLabNotePosition>,
}
```

### GitLabNotePosition

```rust
#[derive(Debug, Clone, Deserialize)]
pub struct GitLabNotePosition {
    pub old_path: Option<String>,
    pub new_path: Option<String>,
    pub old_line: Option<i32>,
    pub new_line: Option<i32>,
}
```

## Acceptance Criteria

- [ ] GitLabIssue deserializes from API response JSON
- [ ] GitLabAuthor embedded correctly in issue and note
- [ ] GitLabDiscussion with notes array deserializes
- [ ] GitLabNote handles null note_type (use Option<String>)
- [ ] GitLabNote uses #[serde(rename = "type")] for reserved keyword
- [ ] resolvable/resolved default to false via #[serde(default)]
- [ ] All timestamp fields are String (ISO 8601 parsed elsewhere)

## Files

- src/gitlab/types.rs (edit - add types)

## TDD Loop

RED:
```rust
// tests/gitlab_types_tests.rs
#[test] fn deserializes_gitlab_issue_from_json()
#[test] fn deserializes_gitlab_discussion_from_json()
#[test] fn handles_null_note_type()
#[test] fn handles_missing_resolvable_field()
#[test] fn deserializes_labels_as_string_array()
```

GREEN: Add type definitions with serde attributes

VERIFY: `cargo test gitlab_types`

## Edge Cases

- note_type can be null, "DiscussionNote", or "DiffNote"
- labels array can be empty
- description can be null
- resolved_by/resolved_at can be null
- position is only present for DiffNotes

Implemented GitLabAuthor, GitLabIssue, GitLabDiscussion, GitLabNote, GitLabNotePosition types with 10 passing tests


Epic: Surgical Per-IID Sync


OBSERV: Add -v/--verbose and --log-format CLI flags

## Background
Users and agents need CLI-controlled verbosity without knowing RUST_LOG syntax. The -v flag convention (cargo, curl, ssh) is universally understood. --log-format json enables lore sync 2>&1 | jq workflows without reading log files.

## Approach
Add two new global flags to the Cli struct in src/cli/mod.rs (insert after the quiet field at line ~37):

```rust
/// Increase log verbosity (-v, -vv, -vvv)
#[arg(short = 'v', long = "verbose", action = clap::ArgAction::Count, global = true)]
pub verbose: u8,

/// Log format for stderr output: text (default) or json
#[arg(long = "log-format", global = true, value_parser = ["text", "json"], default_value = "text")]
pub log_format: String,
```

The existing Cli struct (src/cli/mod.rs:13-42) has these global flags: config, robot, json, color, quiet. The new flags follow the same pattern.

Note: clap::ArgAction::Count allows -v, -vv, -vvv as a single flag with increasing count (0, 1, 2, 3).

## Acceptance Criteria
- [ ] lore -v sync parses without error (verbose=1)
- [ ] lore -vv sync parses (verbose=2)
- [ ] lore -vvv sync parses (verbose=3)
- [ ] lore --log-format json sync parses (log_format="json")
- [ ] lore --log-format text sync parses (default)
- [ ] lore --log-format xml sync errors (invalid value)
- [ ] Existing commands unaffected (verbose defaults to 0, log_format to "text")
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/cli/mod.rs (modify Cli struct, lines 13-42)

## TDD Loop
RED: Write test that parses Cli with -v flag and asserts verbose=1
GREEN: Add the two fields to Cli struct
VERIFY: cargo test -p lore && cargo clippy --all-targets -- -D warnings

## Edge Cases
- -v and -q together: both parse fine; conflict resolution happens in subscriber setup (bd-2rr), not here
- -v flag must be global=true so it works before and after subcommands: lore -v sync AND lore sync -v
- --log-format is a string, not enum, to keep Cli struct simple

Added -v/--verbose (count) and --log-format (text|json) global CLI flags


OBSERV: Define StageTiming struct in src/core/metrics.rs

## Background
StageTiming is the materialized view of span timing data. It's the data structure that flows through robot JSON output, sync_runs.metrics_json, and the human-readable timing summary. Defined in a new file because it's genuinely new functionality that doesn't fit existing modules.

## Approach
Create src/core/metrics.rs:

```rust
use serde::Serialize;

fn is_zero(v: &usize) -> bool { *v == 0 }

#[derive(Debug, Clone, Serialize)]
pub struct StageTiming {
    pub name: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub project: Option<String>,
    pub elapsed_ms: u64,
    pub items_processed: usize,
    #[serde(skip_serializing_if = "is_zero")]
    pub items_skipped: usize,
    #[serde(skip_serializing_if = "is_zero")]
    pub errors: usize,
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub sub_stages: Vec<StageTiming>,
}
```

Register module in src/core/mod.rs (line ~11, add):
```rust
pub mod metrics;
```

The is_zero helper is a private function used by serde's skip_serializing_if. It must take &usize (reference) and return bool.

## Acceptance Criteria
- [ ] StageTiming serializes to JSON matching PRD Section 4.6.2 example
- [ ] items_skipped omitted when 0
- [ ] errors omitted when 0
- [ ] sub_stages omitted when empty vec
- [ ] project omitted when None
- [ ] name, elapsed_ms, items_processed always present
- [ ] Struct is Debug + Clone + Serialize
- [ ] cargo clippy --all-targets -- -D warnings passes

## Files
- src/core/metrics.rs (new file)
- src/core/mod.rs (register module, add line after existing pub mod declarations)

## TDD Loop
RED:
  - test_stage_timing_serialization: create StageTiming with sub_stages, serialize, assert JSON structure
  - test_stage_timing_zero_fields_omitted: errors=0, items_skipped=0, assert no "errors" or "items_skipped" keys
  - test_stage_timing_empty_sub_stages: sub_stages=vec![], assert no "sub_stages" key
GREEN: Create metrics.rs with StageTiming struct and is_zero helper
VERIFY: cargo test && cargo clippy --all-targets -- -D warnings

## Edge Cases
- is_zero must be a function, not a closure (serde skip_serializing_if requires a function path)
- Vec::is_empty is a method on Vec, and serde accepts "Vec::is_empty" as a path for skip_serializing_if
- Recursive StageTiming (sub_stages contains StageTiming): serde handles this naturally, no special handling needed

Created src/core/metrics.rs with StageTiming struct, serde skip_serializing_if for zero/empty fields, 5 tests


NOTE-2A: Schema migration for note documents (migration 024)

## Background
The documents and dirty_sources tables have CHECK constraints limiting source_type to ('issue', 'merge_request', 'discussion'). Need to add 'note' as valid source_type. SQLite doesn't support ALTER CONSTRAINT, so use the table-rebuild pattern. Uses migration slot 024 (022 = query indexes, 023 = issue_detail_fields already exists).

## Approach
Create migrations/024_note_documents.sql:

1. Rebuild dirty_sources: CREATE dirty_sources_new with CHECK adding 'note', INSERT SELECT, DROP old, RENAME.
2. Rebuild documents (complex — must preserve FTS consistency):
   - Save junction table data (_doc_labels_backup, _doc_paths_backup)
   - Drop FTS triggers (documents_ai, documents_ad, documents_au — defined in migration 008_fts5.sql)
   - Drop junction tables (document_labels, document_paths — defined in migration 007_documents.sql)
   - Create documents_new with updated CHECK adding 'note'
   - INSERT INTO documents_new SELECT * FROM documents (preserves rowids for FTS)
   - Drop documents, rename new
   - Recreate all indexes (idx_documents_project_updated, idx_documents_author, idx_documents_source, idx_documents_content_hash — see migration 007_documents.sql for definitions)
   - Recreate junction tables + restore data from backups
   - Recreate FTS triggers (see migration 008_fts5.sql for trigger SQL)
   - INSERT INTO documents_fts(documents_fts) VALUES('rebuild')
3. Defense-in-depth triggers:
   - notes_ad_cleanup: AFTER DELETE ON notes WHEN old.is_system = 0 → delete doc + dirty_sources for source_type='note', source_id=old.id
   - notes_au_system_cleanup: AFTER UPDATE OF is_system ON notes WHEN NEW.is_system = 1 AND OLD.is_system = 0 → delete doc + dirty_sources
4. Drop temp backup tables

Register as ("024", include_str!("../../migrations/024_note_documents.sql")) in MIGRATIONS array in src/core/db.rs. Position AFTER the "023" entry.

## Files
- CREATE: migrations/024_note_documents.sql
- MODIFY: src/core/db.rs (add ("024", include_str!(...)) to MIGRATIONS array, after line 75)

## TDD Anchor
RED: test_migration_024_allows_note_source_type — INSERT with source_type='note' should succeed in both documents and dirty_sources.
GREEN: Implement the table rebuild migration.
VERIFY: cargo test migration_024 -- --nocapture
Tests: test_migration_024_preserves_existing_data, test_migration_024_fts_triggers_intact, test_migration_024_row_counts_preserved, test_migration_024_integrity_checks_pass, test_migration_024_fts_rebuild_consistent, test_migration_024_note_delete_trigger_cleans_document, test_migration_024_note_system_flip_trigger_cleans_document, test_migration_024_system_note_delete_trigger_does_not_fire

## Acceptance Criteria
- [ ] INSERT source_type='note' succeeds in documents and dirty_sources
- [ ] All existing data preserved through table rebuild (row counts match before/after)
- [ ] FTS triggers fire correctly after rebuild (insert a doc, verify FTS entry exists)
- [ ] documents_fts row count == documents row count after rebuild
- [ ] PRAGMA foreign_key_check returns no violations
- [ ] notes_ad_cleanup trigger fires on note deletion (deletes document + dirty_sources)
- [ ] notes_au_system_cleanup trigger fires when is_system flips 0→1
- [ ] System note deletion does NOT trigger notes_ad_cleanup (is_system = 1 guard)
- [ ] All 9 tests pass

## Edge Cases
- Rowid preservation: INSERT INTO documents_new SELECT * preserves id column = rowid for FTS consistency
- CRITICAL: Must save/restore junction table data (ON DELETE CASCADE on document_labels/document_paths would delete them when documents table is dropped)
- The FTS rebuild at end is a safety net for any rowid drift
- Empty database: migration is a no-op (all SELECTs return 0 rows, tables rebuilt with new CHECK)


Register migration 015 in db.rs and create migration 016 for mr_file_changes

## Background

This bead creates the `mr_file_changes` table that stores which files each MR touched, enabling Gate 4 (file-history) and Gate 5 (trace). It maps MRs to the file paths they modify.

**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 4.1 (Schema).

## Codebase Context — CRITICAL Migration Numbering

- **LATEST_SCHEMA_VERSION = 14** (MIGRATIONS array in db.rs includes 001-014)
- **Migration 015 exists on disk** (`migrations/015_commit_shas_and_closes_watermark.sql`) but is **NOT registered** in `src/core/db.rs` MIGRATIONS array
- `merge_commit_sha` and `squash_commit_sha` are already on merge_requests (added by 015 SQL) and already used in `src/ingestion/merge_requests.rs`
- `closes_issues_synced_for_updated_at` also added by 015 and used in orchestrator.rs
- **This bead must FIRST register migration 015 in db.rs**, then create migration 016 for mr_file_changes
- pending_dependent_fetches already has `job_type='mr_diffs'` in CHECK constraint (migration 011)
- Schema version auto-computes: `LATEST_SCHEMA_VERSION = MIGRATIONS.len() as i32`

## Approach

### Step 1: Register existing migration 015 in db.rs

Add to MIGRATIONS array in `src/core/db.rs` (after the "014" entry):

```rust
(
    "015",
    include_str!("../../migrations/015_commit_shas_and_closes_watermark.sql"),
),
```

This makes LATEST_SCHEMA_VERSION = 15.

### Step 2: Create migration 016 for mr_file_changes

Create `migrations/016_mr_file_changes.sql`:

```sql
-- Migration 016: MR file changes table
-- Powers file-history and trace commands (Gates 4-5)

CREATE TABLE mr_file_changes (
    id INTEGER PRIMARY KEY,
    merge_request_id INTEGER NOT NULL REFERENCES merge_requests(id) ON DELETE CASCADE,
    project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
    old_path TEXT,
    new_path TEXT NOT NULL,
    change_type TEXT NOT NULL CHECK (change_type IN ('added', 'modified', 'renamed', 'deleted')),
    UNIQUE(merge_request_id, new_path)
);

CREATE INDEX idx_mfc_project_path ON mr_file_changes(project_id, new_path);
CREATE INDEX idx_mfc_project_old_path ON mr_file_changes(project_id, old_path) WHERE old_path IS NOT NULL;
CREATE INDEX idx_mfc_mr ON mr_file_changes(merge_request_id);
CREATE INDEX idx_mfc_renamed ON mr_file_changes(project_id, change_type) WHERE change_type = 'renamed';

INSERT INTO schema_version (version, applied_at, description)
VALUES (16, strftime('%s', 'now') * 1000, 'MR file changes table');
```

### Step 3: Register migration 016 in db.rs

```rust
(
    "016",
    include_str!("../../migrations/016_mr_file_changes.sql"),
),
```

LATEST_SCHEMA_VERSION will auto-compute to 16.

## Acceptance Criteria

- [ ] Migration 015 registered in MIGRATIONS array in src/core/db.rs
- [ ] Migration file exists at `migrations/016_mr_file_changes.sql`
- [ ] `mr_file_changes` table has columns: id, merge_request_id, project_id, old_path, new_path, change_type
- [ ] UNIQUE constraint on (merge_request_id, new_path)
- [ ] CHECK constraint on change_type: added, modified, renamed, deleted
- [ ] 4 indexes: project+new_path, project+old_path (partial), mr_id, project+renamed (partial)
- [ ] Migration 016 registered in MIGRATIONS array
- [ ] LATEST_SCHEMA_VERSION auto-computes to 16
- [ ] `lore migrate` applies both 015 and 016 successfully on a v14 database
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes

## Files

- `src/core/db.rs` (register migrations 015 AND 016 in MIGRATIONS array)
- `migrations/016_mr_file_changes.sql` (NEW)

## TDD Loop

RED: `lore migrate` on v14 database says "already up to date" (015 not registered)

GREEN: Register 015 in db.rs, create 016 file, register 016 in db.rs. `lore migrate` applies both.

VERIFY:
```bash
cargo check --all-targets
lore --robot migrate
sqlite3 ~/.local/share/lore/lore.db '.schema mr_file_changes'
sqlite3 ~/.local/share/lore/lore.db "SELECT version FROM schema_version ORDER BY version DESC LIMIT 1"
```

## Edge Cases

- Databases already at v15 via manual migration: 015 will be skipped, only 016 applied
- old_path is NULL for added files, populated for renamed/deleted
- No lines_added/lines_removed columns (spec does not require them; removed to match spec exactly)
- Partial indexes only index relevant rows for rename chain BFS performance

Completed: registered migration 015 in db.rs MIGRATIONS array, created migration 016 (mr_file_changes table with 4 indexes, CHECK constraint, UNIQUE constraint), registered 016 in db.rs. LATEST_SCHEMA_VERSION auto-computes to 16. cargo check, clippy, and fmt all pass.


NOTE-1D: robot-docs integration for notes command

## Background
Add the notes command to the robot-docs manifest so agents can discover it. Also forward-prep SearchArgs --type to accept "note"/"notes" (duplicates work in NOTE-2F but is safe to do early).

## Approach
1. Robot-docs manifest is in src/main.rs, function handle_robot_docs() starting at line 2087. The commands JSON is built at line 2090 with serde_json::json!. Add a "notes" entry following the pattern of "issues" (line 2107 area) and "mrs" entries:

   "notes": {
       "description": "List notes from discussions with rich filtering",
       "flags": ["--limit/-n <N>", "--author/-a <username>", "--note-type <type>", "--contains <text>", "--for-issue <iid>", "--for-mr <iid>", "-p/--project <path>", "--since <period>", "--until <period>", "--path <filepath>", "--resolution <any|unresolved|resolved>", "--sort <created|updated>", "--asc", "--include-system", "--note-id <id>", "--gitlab-note-id <id>", "--discussion-id <id>", "--format <table|json|jsonl|csv>", "--fields <list|minimal>", "--open"],
       "robot_flags": ["--format json", "--fields minimal"],
       "example": "lore --robot notes --author jdefting --since 1y --format json --fields minimal",
       "response_schema": {
           "ok": "bool",
           "data": {"notes": "[NoteListRowJson]", "total_count": "int", "showing": "int"},
           "meta": {"elapsed_ms": "int"}
       }
   }

2. Update SearchArgs.source_type value_parser in src/cli/mod.rs (line 560) to include "note":
   value_parser = ["issue", "mr", "discussion", "note"]
   (This is also done in NOTE-2F but is safe to do in either order — value_parser is additive)

3. Add "notes" to the command list in handle_robot_docs (line 662 area where command names are listed).

## Files
- MODIFY: src/main.rs (add notes to robot-docs commands JSON at line 2090 area, add to command list at line 662)
- MODIFY: src/cli/mod.rs (add "note" to SearchArgs source_type value_parser at line 560)

## TDD Anchor
Smoke test: cargo run -- --robot robot-docs | jq '.data.commands.notes' should return the notes command entry.
VERIFY: cargo test -- --nocapture (no dedicated test needed — robot-docs is a static JSON generator)

## Acceptance Criteria
- [ ] lore robot-docs output includes notes command with all flags
- [ ] notes command has response_schema, example, and robot_flags
- [ ] SearchArgs accepts --type note
- [ ] All existing tests still pass

## Dependency Context
- Depends on NOTE-1A (bd-20p9), NOTE-1B (bd-3iod), NOTE-1C (bd-25hb): command must be fully wired before documenting (the manifest should describe actual working behavior)

## Edge Cases
- robot-docs --brief mode: notes command should still appear in brief output
- Value parser order doesn't matter — "note" can be added at any position in the array


Implement responsive layout system (LORE_BREAKPOINTS + Responsive)

## Background

Every TUI view needs to adapt its layout to terminal width. The PRD defines a project-wide breakpoint system using FrankenTUI native `Responsive<T>` with 5 tiers: Xs (<60), Sm (60-89), Md (90-119), Lg (120-159), Xl (160+). This is cross-cutting infrastructure — Dashboard uses it for 1/2/3-column layout, Issue/MR lists use it for column visibility, Search/Who use it for split-pane toggling. Without this, every view would reinvent breakpoint logic.

## Approach

Define a single `layout.rs` module in the TUI crate with:

1. **`LORE_BREAKPOINTS` constant** — `Breakpoints::new(60, 90, 120)` (Xl defaults to Lg + 40 = 160)
2. **`classify_width(width: u16) -> Breakpoint`** — wrapper around `LORE_BREAKPOINTS.classify_width(area.width)`
3. **Helper functions** using `Responsive<T>`:
   - `dashboard_columns(bp: Breakpoint) -> u16` — 1 (Xs/Sm), 2 (Md), 3 (Lg/Xl)
   - `show_preview_pane(bp: Breakpoint) -> bool` — false (Xs/Sm), true (Md+)
   - `table_columns(bp: Breakpoint, screen: Screen) -> Vec<ColumnDef>` — returns visible column set per breakpoint per screen

Reference FrankenTUI types:
```rust
use ftui::layout::{Breakpoints, Breakpoint, Responsive, Flex, Constraint};
pub const LORE_BREAKPOINTS: Breakpoints = Breakpoints::new(60, 90, 120);
```

The `Responsive<T>` wrapper provides breakpoint-aware values with inheritance — `.new(base)` sets Xs, `.at(bp, val)` sets overrides, `.resolve_cloned(bp)` walks downward.

## Acceptance Criteria
- [ ] `LORE_BREAKPOINTS` constant defined with sm=60, md=90, lg=120
- [ ] `classify_width()` returns correct Breakpoint for widths: 40->Xs, 60->Sm, 90->Md, 120->Lg, 160->Xl
- [ ] `dashboard_columns()` returns 1 for Xs/Sm, 2 for Md, 3 for Lg/Xl
- [ ] `show_preview_pane()` returns false for Xs/Sm, true for Md+
- [ ] All helpers use `Responsive<T>` (not bare match), so inheritance is automatic
- [ ] Module is `pub` and importable by all view modules

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

## TDD Anchor
RED: Write `test_classify_width_boundaries` that asserts classify_width(59)=Xs, classify_width(60)=Sm, classify_width(89)=Sm, classify_width(90)=Md, classify_width(119)=Md, classify_width(120)=Lg, classify_width(159)=Lg, classify_width(160)=Xl.
GREEN: Implement LORE_BREAKPOINTS and classify_width().
VERIFY: cargo test -p lore-tui classify_width

Additional tests:
- test_dashboard_columns_per_breakpoint
- test_show_preview_pane_per_breakpoint
- test_responsive_inheritance (Sm inherits from Xs when no Sm override set)

## Edge Cases
- Terminal width of 0 or 1 must not panic — classify to Xs
- Very wide terminals (>300 cols) should still work — classify to Xl
- All Responsive<T> values must have an Xs base so resolve never fails

## Dependency Context
- Depends on bd-3ddw (crate scaffold) which creates the crates/lore-tui/ workspace
- Consumed by all view beads (bd-35g5 Dashboard, bd-3ei1 Issue List, bd-2kr0 MR List, bd-1zow Search, bd-u7se Who, etc.) for layout decisions


WHO: Epic — People Intelligence Commands

## Background

The current beads roadmap focuses on Gate 4/5 (file-history, code-trace) — archaeology queries requiring mr_file_changes data that does not exist yet. Meanwhile, the DB has rich people/activity data (280K notes, 210K discussions, 33K DiffNotes with file positions, 53 active participants) that can answer collaboration questions immediately with zero new tables or API calls.

## Scope

This epic builds `lore who` — a pure SQL query layer answering 5 questions:
1. **Expert**: "Who should I talk to about this feature/file?" (DiffNote path analysis)
2. **Workload**: "What is person X working on?" (open issues, authored/reviewing MRs, unresolved discussions)
3. **Reviews**: "What review patterns does person X have?" (DiffNote **prefix** category extraction)
4. **Active**: "What discussions are actively in progress?" (unresolved resolvable discussions)
5. **Overlap**: "Who else has MRs/notes touching my files?" (path-based activity overlap)

## Plan Reference

Full implementation plan with 8 iterations of review: `docs/who-command-design.md`

## Children (Execution Order)

1. **bd-34rr** — Migration 017: 5 composite indexes for query performance
2. **bd-2rk9** — CLI skeleton: WhoArgs, Commands::Who, dispatch, stub file
3. **bd-2ldg** — Mode resolution, path helpers, run_who entry point, all result types
4. **bd-zqpf** — Expert mode query (CTE + MR-breadth scoring)
5. **bd-s3rc** — Workload mode query (4 SELECT queries)
6. **bd-m7k1** — Active mode query (CTE + global/scoped SQL variants)
7. **bd-b51e** — Overlap mode query (dual role tracking + accumulator)
8. **bd-2711** — Reviews mode query (prefix extraction + normalization)
9. **bd-1rdi** — Human terminal output for all 5 modes
10. **bd-3mj2** — Robot JSON output for all 5 modes
11. **bd-tfh3** — Comprehensive test suite (20+ tests)
12. **bd-zibc** — VALID_COMMANDS + robot-docs manifest
13. **bd-g0d5** — Verification gate (check, clippy, fmt, EXPLAIN QUERY PLAN)

## Design Principles (from plan)

- All SQL fully static — no format!() for query text, LIMIT bound as ?N
- prepare_cached() everywhere for statement caching
- (?N IS NULL OR ...) nullable binding except Active mode (two SQL variants for index selection)
- Self-review exclusion on all DiffNote-based branches
- Deterministic output: sorted GROUP_CONCAT, sorted HashSet-derived vectors, stable tie-breakers
- Truncation transparency: LIMIT+1 pattern with truncated bool
- Bounded payloads: capped arrays with *_total + *_truncated metadata
- Robot-first reproducibility: input + resolved_input with since_mode tri-state

## Files

| File | Action | Description |
|---|---|---|
| `src/cli/commands/who.rs` | CREATE | All 5 query modes + human/robot output |
| `src/cli/commands/mod.rs` | MODIFY | Add `pub mod who` + re-exports |
| `src/cli/mod.rs` | MODIFY | Add `WhoArgs` struct + `Commands::Who` variant |
| `src/main.rs` | MODIFY | Add dispatch arm + `handle_who` fn + VALID_COMMANDS + robot-docs |
| `src/core/db.rs` | MODIFY | Add migration 017: composite indexes for who query paths |

## TDD Loop

Each child bead has its own RED/GREEN/VERIFY cycle. The epic TDD strategy:
- RED: Tests in bd-tfh3 (written alongside query beads)
- GREEN: Query implementations in bd-zqpf, bd-s3rc, bd-m7k1, bd-b51e, bd-2711
- VERIFY: bd-g0d5 runs `cargo test` + `cargo clippy` + EXPLAIN QUERY PLAN

## Acceptance Criteria

- [ ] `lore who src/path/` shows ranked experts with scores
- [ ] `lore who @username` shows workload across all projects
- [ ] `lore who @username --reviews` shows categorized review patterns
- [ ] `lore who --active` shows unresolved discussions
- [ ] `lore who --overlap src/path/` shows other contributors
- [ ] `lore who --path README.md` handles root files
- [ ] `lore -J who ...` produces valid JSON with input + resolved_input
- [ ] All indexes verified via EXPLAIN QUERY PLAN
- [ ] cargo check + clippy + fmt + test all pass

## Edge Cases

- This epic has zero new tables — all queries are pure SQL over existing schema + migration 017 indexes
- Gate 4/5 beads are NOT dependencies — who command works independently with current data
- If DB has <1000 notes, queries will work but results will be sparse — this is expected for fresh installations
- format_relative_time() is duplicated from list.rs intentionally (private fn, small blast radius > refactoring shared module)
- lookup_project_path() is local to who.rs — single invocation per run, does not warrant shared utility

All 13 child beads implemented: migration 017 (5 composite indexes), CLI skeleton with WhoArgs/dispatch/robot-docs, 5 query modes (expert/workload/active/overlap/reviews), human terminal + robot JSON output, 20 tests. All quality gates pass: cargo check, clippy (pedantic+nursery), fmt, test.


[CP1] Discussion and note transformers

## Background

Discussion and note transformers convert GitLab API discussion responses into our normalized schema. They compute derived fields like `first_note_at`, `last_note_at`, resolvable/resolved status, and note positions. These are pure functions with no I/O.

## Approach

Create transformer module with:

### Structs

```rust
// src/gitlab/transformers/discussion.rs

pub struct NormalizedDiscussion {
    pub gitlab_discussion_id: String,
    pub project_id: i64,
    pub issue_id: i64,
    pub noteable_type: String,        // "Issue"
    pub individual_note: bool,
    pub first_note_at: Option<i64>,   // min(note.created_at) in ms epoch
    pub last_note_at: Option<i64>,    // max(note.created_at) in ms epoch
    pub last_seen_at: i64,
    pub resolvable: bool,             // any note is resolvable
    pub resolved: bool,               // all resolvable notes are resolved
}

pub struct NormalizedNote {
    pub gitlab_id: i64,
    pub project_id: i64,
    pub note_type: Option<String>,    // "DiscussionNote" | "DiffNote" | null
    pub is_system: bool,              // from note.system
    pub author_username: String,
    pub body: String,
    pub created_at: i64,              // ms epoch
    pub updated_at: i64,              // ms epoch
    pub last_seen_at: i64,
    pub position: i32,                // 0-indexed array position
    pub resolvable: bool,
    pub resolved: bool,
    pub resolved_by: Option<String>,
    pub resolved_at: Option<i64>,
}
```

### Functions

```rust
pub fn transform_discussion(
    gitlab_discussion: &GitLabDiscussion,
    local_project_id: i64,
    local_issue_id: i64,
) -> NormalizedDiscussion

pub fn transform_notes(
    gitlab_discussion: &GitLabDiscussion,
    local_project_id: i64,
) -> Vec<NormalizedNote>
```

## Acceptance Criteria

- [ ] `NormalizedDiscussion` struct with all fields
- [ ] `NormalizedNote` struct with all fields
- [ ] `transform_discussion` computes first_note_at/last_note_at from notes array
- [ ] `transform_discussion` computes resolvable (any note is resolvable)
- [ ] `transform_discussion` computes resolved (all resolvable notes resolved)
- [ ] `transform_notes` preserves array order via position field (0-indexed)
- [ ] `transform_notes` maps system flag to is_system
- [ ] Unit tests cover all computed fields

## Files

- src/gitlab/transformers/mod.rs (add `pub mod discussion;`)
- src/gitlab/transformers/discussion.rs (create)

## TDD Loop

RED:
```rust
// tests/discussion_transformer_tests.rs
#[test] fn transforms_discussion_payload_to_normalized_schema()
#[test] fn extracts_notes_array_from_discussion()
#[test] fn sets_individual_note_flag_correctly()
#[test] fn flags_system_notes_with_is_system_true()
#[test] fn preserves_note_order_via_position_field()
#[test] fn computes_first_note_at_and_last_note_at_correctly()
#[test] fn computes_resolvable_and_resolved_status()
```

GREEN: Implement transform_discussion and transform_notes

VERIFY: `cargo test discussion_transformer`

## Edge Cases

- Discussion with single note - first_note_at == last_note_at
- All notes are system notes - still compute timestamps
- No notes resolvable - resolvable=false, resolved=false
- Mix of resolved/unresolved notes - resolved=false until all done

Implemented NormalizedDiscussion, NormalizedNote, transform_discussion, transform_notes with 9 passing unit tests


Implement NavigationStack (back/forward/jump list)

## Background
Navigation uses a stack with global shortcuts, supporting back/forward (browser-like) and jump list (vim-like Ctrl+O/Ctrl+I). State is preserved when navigating away — screens are never cleared on pop. The jump list only records "significant" hops (detail views, cross-references).

## Approach
Create crates/lore-tui/src/navigation.rs:
- NavigationStack struct: back_stack (Vec<Screen>), current (Screen), forward_stack (Vec<Screen>), jump_list (Vec<Screen>), jump_index (usize), browse_snapshots (HashMap<ScreenKind, BrowseSnapshot>)
- new() -> Self: initializes with Dashboard as current
- current() -> &Screen
- is_at(&Screen) -> bool
- push(Screen): pushes current to back_stack, clears forward_stack, sets new current, records detail hops in jump_list
- pop() -> Option<Screen>: pops from back_stack, pushes current to forward_stack
- go_forward() -> Option<Screen>: pops from forward_stack, pushes current to back_stack
- jump_back() -> Option<&Screen>: moves backward in jump list (Ctrl+O)
- jump_forward() -> Option<&Screen>: moves forward in jump list (Ctrl+I)
- reset_to(Screen): clears all stacks, sets new current (H=Home)
- breadcrumbs() -> Vec<&str>: returns labels for breadcrumb display
- depth() -> usize: back_stack.len() + 1
- BrowseSnapshot struct: per-screen pagination cursor snapshot for stable ordering during concurrent writes

## Acceptance Criteria
- [ ] push() adds to back_stack and clears forward_stack
- [ ] pop() moves current to forward_stack and restores previous
- [ ] go_forward() restores from forward_stack
- [ ] jump_back/forward navigates only through detail views
- [ ] reset_to() clears all history
- [ ] breadcrumbs() returns ordered screen labels
- [ ] pop() returns None at root (can't pop past Dashboard)
- [ ] push() only records is_detail_or_entity() screens in jump_list

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

## TDD Anchor
RED: Write test_push_pop_preserves_order that pushes Dashboard->IssueList->IssueDetail, pops twice, verifies correct order.
GREEN: Implement push/pop with back_stack.
VERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml test_push_pop

Additional tests:
- test_forward_stack_cleared_on_new_push
- test_jump_list_skips_list_screens
- test_reset_clears_all_history
- test_pop_at_root_returns_none
- test_breadcrumbs_reflect_stack

## Edge Cases
- Stack depth has no explicit limit — deeply nested cross-reference chains are supported
- Forward stack must be cleared on any new push (browser behavior)
- Jump list must truncate forward entries when recording a new jump (vim behavior)

## Dependency Context
Uses Screen enum and Screen::is_detail_or_entity() from "Implement core types" task.


[CP1] Database migration 002_issues.sql

Create migration file with tables for issues, labels, issue_labels, discussions, and notes.

## Tables

### issues
- id INTEGER PRIMARY KEY
- gitlab_id INTEGER UNIQUE NOT NULL
- project_id INTEGER NOT NULL REFERENCES projects(id)
- iid INTEGER NOT NULL
- title TEXT, description TEXT, state TEXT
- author_username TEXT
- created_at, updated_at, last_seen_at INTEGER (ms epoch UTC)
- discussions_synced_for_updated_at INTEGER (watermark for dependent sync)
- web_url TEXT
- raw_payload_id INTEGER REFERENCES raw_payloads(id)

### labels (name-only for CP1)
- id INTEGER PRIMARY KEY
- gitlab_id INTEGER (optional, for future Labels API)
- project_id INTEGER NOT NULL REFERENCES projects(id)
- name TEXT NOT NULL
- color TEXT, description TEXT (nullable, deferred)
- UNIQUE(project_id, name)

### issue_labels (junction)
- issue_id, label_id with CASCADE DELETE
- Clear existing links before INSERT to handle removed labels

### discussions
- gitlab_discussion_id TEXT (string ID from API)
- project_id, issue_id/merge_request_id FKs
- noteable_type TEXT ('Issue' | 'MergeRequest')
- individual_note INTEGER, first_note_at, last_note_at, last_seen_at
- resolvable, resolved flags
- CHECK constraint for Issue vs MR exclusivity

### notes
- gitlab_id INTEGER UNIQUE NOT NULL
- discussion_id, project_id FKs
- note_type, is_system, author_username, body
- timestamps, position (array order)
- resolution fields, DiffNote position fields

## Indexes
- idx_issues_project_updated, idx_issues_author, idx_issues_discussions_sync
- uq_issues_project_iid, uq_labels_project_name
- idx_issue_labels_label
- uq_discussions_project_discussion_id, idx_discussions_issue/mr/last_note
- idx_notes_discussion/author/system

Files: migrations/002_issues.sql
Done when: Migration applies cleanly on top of 001_initial.sql, schema_version = 2


WHO: Human terminal output for all 5 modes

## Background

Terminal output for humans. Each mode gets a dedicated print function with consistent styling: bold headers, cyan usernames/refs, dim metadata, table alignment.

## Approach

### Dispatch:
```rust
pub fn print_who_human(result: &WhoResult, project_path: Option<&str>) {
    match result {
        WhoResult::Expert(r) => print_expert_human(r, project_path),
        WhoResult::Workload(r) => print_workload_human(r),
        WhoResult::Reviews(r) => print_reviews_human(r),
        WhoResult::Active(r) => print_active_human(r, project_path),
        WhoResult::Overlap(r) => print_overlap_human(r, project_path),
    }
}
```

### Shared helpers:
- **print_scope_hint()**: dim "(aggregated across all projects; use -p to scope)" when project_path is None. Called by Expert, Active, Overlap.
- **format_relative_time(ms_epoch)**: "just now" / "N min ago" / "N hours ago" / "N days ago" / "N weeks ago" / "N months ago" — DUPLICATE from list.rs (private there, keep blast radius small)
- **truncate_str(s, max)**: Unicode-aware, appends "..." if truncated

### Mode formats:
- **Expert**: table with Username(16) / Score(6) / Reviewed(MRs)(12) / Notes(6) / Authored(MRs)(12) / Last Seen. Path match hint line. "-" for zero counts.
- **Workload**: 4 sections (Assigned Issues, Authored MRs, Reviewing MRs, Unresolved Discussions). Canonical refs in cyan. Draft indicator. Per-section truncation.
- **Reviews**: DiffNote summary line + category table (Category(16) / Count(6) / %(6)). Uncategorized count note.
- **Active**: Discussion list with entity ref, note count, participants (comma-joined @usernames), project path. Discussion count in header.
- **Overlap**: table with Username(16) / Role(6) / MRs(7) / Last Seen(12) / MR Refs (first 5, +N overflow). Path match hint.

### All modes: truncation dim hints, empty-state messages, console::style formatting.

## Files

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

## TDD Loop

No unit tests for print functions (they write to stdout). Verification is manual smoke test.
VERIFY: `cargo check --all-targets` then manual: `cargo run --release -- who src/features/global-search/`

## Acceptance Criteria

- [ ] cargo check passes (all print functions compile)
- [ ] Each mode produces readable, aligned terminal output
- [ ] Scope hint shown when project not specified (Expert, Active, Overlap)
- [ ] Truncation hints shown when results exceed limit
- [ ] Empty-state messages for zero results

## Edge Cases

- format_relative_time handles negative diff ("in the future")
- truncate_str is Unicode-aware (.chars().count(), not .len())
- Workload shows empty message only when ALL 4 sections are empty

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


[CP1] gi show issue command

Show issue details with discussions.

Flags:
- --project=PATH (required if iid is ambiguous across projects)

Output:
- Title, project, state, author, dates, labels, URL
- Description text
- All discussions with notes (formatted thread view)

Handle ambiguity: If multiple projects have same iid, prompt for --project or show error.

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


[CP1] Integration tests for issue ingestion

Full integration tests for issue ingestion module.

## Tests (tests/issue_ingestion_tests.rs)

- inserts_issues_into_database
- creates_labels_from_issue_payloads
- links_issues_to_labels_via_junction_table
- removes_stale_label_links_on_resync
- stores_raw_payload_for_each_issue
- stores_raw_payload_for_each_discussion
- updates_cursor_incrementally_per_page
- resumes_from_cursor_on_subsequent_runs
- handles_issues_with_no_labels
- upserts_existing_issues_on_refetch
- skips_discussion_refetch_for_unchanged_issues

## Test Setup
- tempfile::TempDir for isolated database
- wiremock::MockServer for GitLab API
- Mock handlers returning fixture data

Files: tests/issue_ingestion_tests.rs
Done when: All integration tests pass with mocked GitLab


Add SurgicalPreflightFailed error variant and foundation visibility changes

## Background
Surgical sync needs several foundational changes across existing modules before the core surgical.rs module can be implemented. These are small, independent visibility and type changes that unblock downstream work.

1. **SurgicalPreflightFailed error variant** — a typed error for when preflight fetch discovers invalid IIDs. Without this, surgical mode would use generic `LoreError::Other` strings, losing machine semantics for robot mode exit codes and structured JSON output.

2. **Visibility changes** — `process_single_issue` (or its equivalent per-entity ingest function) in `src/ingestion/issues.rs` and `process_single_mr` + `ProcessMrResult` in `src/ingestion/merge_requests.rs` must be `pub(crate)` so the surgical module can call them directly.

3. **Config field** — `surgical_entity_timeout_seconds` on the sync config section, for per-entity timeout during dependent stages.

## Approach

### Error variant (src/core/error.rs)

Add to the `LoreError` enum:

```rust
/// Surgical sync preflight failed — one or more IIDs could not be fetched.
SurgicalPreflightFailed {
    run_id: String,
    total: usize,
    failures: Vec<crate::ingestion::surgical::EntityFailure>,
},
```

**IMPORTANT**: This variant references `EntityFailure` from `surgical.rs` which does not exist yet. To avoid a circular dependency, define `EntityFailure` in `src/core/error.rs` (or a shared types location) rather than importing from `ingestion::surgical`. Alternatively, use a simpler representation:

```rust
SurgicalPreflightFailed {
    run_id: String,
    total: usize,
    failure_summary: String, // pre-formatted summary
    failure_count: usize,
},
```

The Display impl should produce: `"Surgical preflight failed for {failure_count} of {total} IIDs (run {run_id}): {failure_summary}"`

Map to exit code 6 (resource not found) since the primary failure mode is 404s.

Add the corresponding `ErrorCode` variant if needed (check existing `ErrorCode` enum pattern).

### Visibility changes (src/ingestion/issues.rs, src/ingestion/merge_requests.rs)

Search for the per-entity issue/MR processing functions. The plan references `process_single_issue` and `process_single_mr` but these may have different actual names. Look for functions that:
- Take a `&Connection`, `&Config`, `project_id: i64`, and a single `GitLabIssue`/`GitLabMergeRequest`
- Perform the UPSERT into the issues/merge_requests table
- Handle labels, assignees, milestones

Change their visibility from `fn` to `pub(crate) fn`. Also change `ProcessMrResult` (or equivalent return type) to `pub(crate)`.

### Config field (src/core/config.rs)

Find the `SyncConfig` struct (or equivalent sync section in `Config`). Add:

```rust
#[serde(rename = "surgicalEntityTimeoutSeconds")]
pub surgical_entity_timeout_seconds: Option<u64>,
```

Default: `None` (callers default to 30 when None).

## Acceptance Criteria
- [ ] `LoreError::SurgicalPreflightFailed` variant exists with Display impl
- [ ] Exit code mapping for SurgicalPreflightFailed returns 6
- [ ] Per-entity issue ingest function is `pub(crate)`
- [ ] Per-entity MR ingest function is `pub(crate)`
- [ ] `ProcessMrResult` (or equivalent) is `pub(crate)`
- [ ] `surgical_entity_timeout_seconds: Option<u64>` field exists on sync config
- [ ] `cargo check --all-targets` passes
- [ ] `cargo clippy --all-targets -- -D warnings` passes
- [ ] `cargo fmt --check` passes
- [ ] Existing tests still pass

## Files
- MODIFY: src/core/error.rs (add SurgicalPreflightFailed variant + Display)
- MODIFY: src/ingestion/issues.rs (pub(crate) visibility on per-entity fn)
- MODIFY: src/ingestion/merge_requests.rs (pub(crate) on per-entity fn + result type)
- MODIFY: src/core/config.rs (add surgical_entity_timeout_seconds)

## TDD Anchor
This bead is primarily visibility/type changes. No new test needed — verify existing tests still pass:
`cargo test`

## Edge Cases
- The exact function names for per-entity processing may differ from plan. Search for UPSERT logic in issues.rs and merge_requests.rs to find them.
- SurgicalPreflightFailed references EntityFailure which wont exist yet. Use a simpler representation (String summary + count) to avoid circular dependency, or forward-declare the type.
- ProcessMrResult might be used internally in ways that make pub(crate) insufficient. Check all usages.

## Dependency Context
None — this is a leaf dependency with no blockers.


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


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'