From 873d2c0ab81155c902f2be2dc41c0467d60f62ba Mon Sep 17 00:00:00 2001 From: Taylor Eernisse Date: Thu, 5 Feb 2026 14:13:34 -0500 Subject: [PATCH] fix(beads): Align bead descriptions with Phase B spec Reconciled 9 beads (bd-20e, bd-dty, bd-2f2, bd-3as, bd-ypa, bd-32q, bd-1nf, bd-2ez, bd-343o) against docs/phase-b-temporal-intelligence.md. Key fixes: - bd-20e: Add url field, align StateChanged to {state} per spec 3.3, fix NoteEvidence fields (note_id, snippet, discussion_id), simplify Merged to unit variant, align CrossReferenced to {target} - bd-dty: Restructure expanded_entities JSON to use nested "via" object per spec 3.5, add url/details fields to events, use "project" key - bd-3as: Align event collection with updated TimelineEventType variants - bd-ypa: Add via_from/via_reference_type/via_source_method provenance - bd-32q, bd-1nf, bd-2f2: Add spec section references throughout - bd-2ez: Document source_method value discrepancy (spec vs codebase) - bd-343o: Add spec context for how it extends Gate 2 Co-Authored-By: Claude Opus 4.6 --- .beads/issues.jsonl | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index e272393..d1160ab 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -29,7 +29,7 @@ {"id":"bd-1m8","title":"Extend 'lore stats --check' for event table integrity and queue health","description":"## Background\nThe 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.\n\n## Approach\nExtend src/cli/commands/stats.rs check mode:\n\n**New checks:**\n\n1. Event FK integrity:\n```sql\n-- Orphaned state events (issue_id points to non-existent issue)\nSELECT COUNT(*) FROM resource_state_events rse\nWHERE rse.issue_id IS NOT NULL\n AND NOT EXISTS (SELECT 1 FROM issues i WHERE i.id = rse.issue_id);\n-- (repeat for merge_request_id, and for label + milestone event tables)\n```\n\n2. Queue health:\n```sql\n-- Pending jobs by type\nSELECT job_type, COUNT(*) FROM pending_dependent_fetches GROUP BY job_type;\n-- Stuck locks (locked_at older than 5 minutes)\nSELECT COUNT(*) FROM pending_dependent_fetches WHERE locked_at IS NOT NULL AND locked_at < ?;\n-- Retryable jobs (attempts > 0, not locked)\nSELECT COUNT(*) FROM pending_dependent_fetches WHERE attempts > 0 AND locked_at IS NULL;\n-- Max attempts (jobs that may be permanently failing)\nSELECT job_type, MAX(attempts) FROM pending_dependent_fetches GROUP BY job_type;\n```\n\n3. Human output per check: PASS / WARN / FAIL with counts\n```\nEvent FK integrity: PASS (0 orphaned events)\nQueue health: WARN (3 stuck locks, 12 retryable jobs)\n```\n\n4. Robot JSON: structured health report\n```json\n{\n \"event_integrity\": {\n \"status\": \"pass\",\n \"orphaned_state_events\": 0,\n \"orphaned_label_events\": 0,\n \"orphaned_milestone_events\": 0\n },\n \"queue_health\": {\n \"status\": \"warn\",\n \"pending_by_type\": {\"resource_events\": 5, \"mr_closes_issues\": 2},\n \"stuck_locks\": 3,\n \"retryable_jobs\": 12,\n \"max_attempts_by_type\": {\"resource_events\": 5}\n }\n}\n```\n\n## Acceptance Criteria\n- [ ] Detects orphaned events (FK target missing)\n- [ ] Detects stuck locks (locked_at older than threshold)\n- [ ] Reports retryable job count and max attempts\n- [ ] Human output shows PASS/WARN/FAIL per check\n- [ ] Robot JSON matches structured schema\n- [ ] Graceful when event/queue tables don't exist\n\n## Files\n- src/cli/commands/stats.rs (extend check mode)\n\n## TDD Loop\nRED: tests/stats_check_tests.rs:\n- `test_stats_check_events_pass` - clean data, verify PASS\n- `test_stats_check_events_orphaned` - delete an issue with events remaining, verify FAIL count\n- `test_stats_check_queue_stuck_locks` - set old locked_at, verify WARN\n- `test_stats_check_queue_retryable` - fail some jobs, verify retryable count\n\nGREEN: Add the check queries and formatting\n\nVERIFY: `cargo test stats_check -- --nocapture`\n\n## Edge Cases\n- FK with CASCADE should prevent orphaned events in normal operation — but manual DB edits or bugs could cause them\n- Tables may not exist if migration 011 not applied — check table existence before querying\n- Empty queue is PASS (not WARN for \"no jobs found\")\n- Distinguish between \"0 stuck locks\" (good) and \"queue table doesn't exist\" (skip check)","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-02T21:31:57.422916Z","created_by":"tayloreernisse","updated_at":"2026-02-03T16:23:13.409909Z","closed_at":"2026-02-03T16:23:13.409717Z","close_reason":"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.","compaction_level":0,"original_size":0,"labels":["cli","gate-1","phase-b"],"dependencies":[{"issue_id":"bd-1m8","depends_on_id":"bd-2zl","type":"parent-child","created_at":"2026-02-02T21:31:57.424103Z","created_by":"tayloreernisse"},{"issue_id":"bd-1m8","depends_on_id":"bd-hu3","type":"blocks","created_at":"2026-02-02T21:32:06.350605Z","created_by":"tayloreernisse"},{"issue_id":"bd-1m8","depends_on_id":"bd-tir","type":"blocks","created_at":"2026-02-02T21:32:06.391042Z","created_by":"tayloreernisse"}]} {"id":"bd-1mf","title":"[CP1] gi sync-status enhancement","description":"Enhance sync-status from CP0 stub to show issue cursors.\n\nOutput:\n- Last run timestamp and duration\n- Cursor positions per project (issues resource_type)\n- Entity counts (issues, discussions, notes)\n\nFiles: src/cli/commands/sync-status.ts (update existing)\nDone when: Shows cursor positions and counts after ingestion","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T15:20:36.449088Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.157235Z","deleted_at":"2026-01-25T15:21:35.157232Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-1n5","title":"[CP1] gi ingest --type=issues command","description":"CLI command to orchestrate issue ingestion.\n\nImplementation:\n1. Acquire app lock with heartbeat\n2. Create sync_run record (status='running')\n3. For each configured project:\n - Call ingestIssues()\n - For each ingested issue, call ingestIssueDiscussions()\n - Show progress (spinner or progress bar)\n4. Update sync_run (status='succeeded', metrics_json)\n5. Release lock\n\nFlags:\n- --type=issues (required)\n- --project=PATH (optional, filter to single project)\n- --force (override stale lock)\n\nOutput: Progress bar, then summary with counts\n\nFiles: src/cli/commands/ingest.ts\nTests: tests/integration/sync-runs.test.ts\nDone when: Full issue + discussion ingestion works end-to-end","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:20:05.114751Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.153598Z","deleted_at":"2026-01-25T15:21:35.153595Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0} -{"id":"bd-1nf","title":"Register 'lore timeline' command with all flags","description":"## Background\n\nThis is the CLI wiring bead for the timeline command. It registers the command with clap, parses all flags, calls the pipeline functions, and delegates to human/robot output renderers. This is the integration point that ties all Gate 3 components together.\n\n## Approach\n\n### 1. Add Timeline subcommand in `src/cli/mod.rs`\n\nAdd to the `Commands` enum:\n```rust\n/// Show a chronological timeline of events matching a keyword query\nTimeline(TimelineArgs),\n```\n\nAdd the args struct:\n```rust\n#[derive(Parser)]\npub struct TimelineArgs {\n /// Keyword search query\n pub query: String,\n\n /// Filter by project path\n #[arg(short = 'p', long, help_heading = \"Filters\")]\n pub project: Option,\n\n /// Filter by time (7d, 2w, 1m, or YYYY-MM-DD)\n #[arg(long, help_heading = \"Filters\")]\n pub since: Option,\n\n /// Cross-reference expansion depth (0=seed only, 1=default, 2=deep)\n #[arg(long, default_value = \"1\", help_heading = \"Output\")]\n pub depth: u32,\n\n /// Include 'mentioned' edges in expansion (slower, noisier)\n #[arg(long, help_heading = \"Output\", overrides_with = \"no_expand_mentions\")]\n pub expand_mentions: bool,\n\n #[arg(long = \"no-expand-mentions\", hide = true, overrides_with = \"expand_mentions\")]\n pub no_expand_mentions: bool,\n\n /// Maximum events to return\n #[arg(short = 'n', long = \"limit\", default_value = \"100\", help_heading = \"Output\")]\n pub limit: usize,\n}\n```\n\n### 2. Add handler in `src/main.rs`\n\n```rust\nSome(Commands::Timeline(args)) => handle_timeline(cli.config.as_deref(), args, robot_mode),\n```\n\nThe handler:\n1. Loads config, resolves project if -p given\n2. Parses --since using `core::time::parse_since()`\n3. Calls `seed_timeline()` -> `expand_timeline()` -> `collect_events()` -> sort + limit\n4. Delegates to `print_timeline()` or `print_timeline_json()`\n\n### 3. Add to VALID_COMMANDS in fuzzy matching\n\nIn `suggest_similar_command()`, add \"timeline\" to `VALID_COMMANDS`.\n\n### 4. Add to robot-docs manifest\n\nIn `handle_robot_docs()`, add the timeline command entry.\n\n## Acceptance Criteria\n\n- [ ] `lore timeline \"authentication\"` works with human output\n- [ ] `lore --robot timeline \"authentication\"` works with JSON output\n- [ ] `-p group/repo` filters to single project\n- [ ] `--since 7d` filters to recent events\n- [ ] `--depth 0` skips expansion phase\n- [ ] `--depth 2` expands two hops\n- [ ] `--expand-mentions` includes mentioned edges\n- [ ] `-n 50` limits output to 50 events\n- [ ] `lore --robot timeline` with missing query prints MISSING_REQUIRED error\n- [ ] Timeline command appears in `lore robot-docs` output\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/cli/mod.rs` (add TimelineArgs struct + Commands::Timeline variant)\n- `src/main.rs` (add handle_timeline function + match arm + VALID_COMMANDS + robot-docs entry)\n\n## TDD Loop\n\nRED: No unit tests for CLI wiring -- this is integration-level. Verify with:\n- `cargo check --all-targets` (compiles)\n- Manual: `lore timeline \"test\"` with a synced database\n\nGREEN: Wire up the clap struct, add the match arm, call through to pipeline.\n\nVERIFY: `cargo check --all-targets && cargo clippy --all-targets -- -D warnings`\n\n## Edge Cases\n\n- Missing query argument: clap handles this with MISSING_REQUIRED error (exit 2)\n- Ambiguous project: resolve_project returns exit 18 (Ambiguous match)\n- Invalid --since format: `parse_since()` returns descriptive LoreError\n- --depth > 5: cap silently at 5 to prevent explosion\n- Empty results: print \"No events found matching 'query'\" (human) or empty events array (robot)","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-02T21:33:28.422082Z","created_by":"tayloreernisse","updated_at":"2026-02-05T18:50:53.635211Z","compaction_level":0,"original_size":0,"labels":["cli","gate-3","phase-b"],"dependencies":[{"issue_id":"bd-1nf","depends_on_id":"bd-2f2","type":"blocks","created_at":"2026-02-02T21:33:37.746192Z","created_by":"tayloreernisse"},{"issue_id":"bd-1nf","depends_on_id":"bd-dty","type":"blocks","created_at":"2026-02-02T21:33:37.788079Z","created_by":"tayloreernisse"},{"issue_id":"bd-1nf","depends_on_id":"bd-ike","type":"parent-child","created_at":"2026-02-02T21:33:28.423399Z","created_by":"tayloreernisse"}]} +{"id":"bd-1nf","title":"Register 'lore timeline' command with all flags","description":"## Background\n\nThis is the CLI wiring bead for the timeline command. It registers the command with clap, parses all flags, calls the pipeline functions, and delegates to human/robot output renderers. This is the integration point that ties all Gate 3 components together.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.1 (Command Design).\n\n## Approach\n\n### 1. Add Timeline subcommand in `src/cli/mod.rs`\n\nAdd to the `Commands` enum:\n```rust\n/// Show a chronological timeline of events matching a keyword query\nTimeline(TimelineArgs),\n```\n\nAdd the args struct (flags from spec Section 3.1):\n```rust\n#[derive(Parser)]\npub struct TimelineArgs {\n /// Keyword search query\n pub query: String,\n\n /// Filter by project path\n #[arg(short = 'p', long, help_heading = \"Filters\")]\n pub project: Option,\n\n /// Filter by time (7d, 2w, 1m, 6m, or YYYY-MM-DD) [spec: --since]\n #[arg(long, help_heading = \"Filters\")]\n pub since: Option,\n\n /// Cross-reference expansion depth (0=seed only, 1=default, 2=deep) [spec: --depth]\n #[arg(long, default_value = \"1\", help_heading = \"Output\")]\n pub depth: u32,\n\n /// Include 'mentioned' edges in expansion (off by default, noisier) [spec: --expand-mentions]\n #[arg(long, help_heading = \"Output\")]\n pub expand_mentions: bool,\n\n /// Maximum events to return [spec: -n]\n #[arg(short = 'n', long = \"limit\", default_value = \"100\", help_heading = \"Output\")]\n pub limit: usize,\n}\n```\n\n### 2. Add handler in `src/main.rs`\n\n```rust\nSome(Commands::Timeline(args)) => handle_timeline(cli.config.as_deref(), args, robot_mode),\n```\n\nThe handler:\n1. Loads config, resolves project if -p given\n2. Parses --since using `core::time::parse_since()`\n3. Cap --depth at 5 silently (spec doesn't specify max, but prevent runaway)\n4. Calls pipeline: `seed_timeline()` -> `expand_timeline()` -> `collect_events()` -> sort + limit\n5. Delegates to `print_timeline()` or `print_timeline_json()`\n\n### 3. Add to VALID_COMMANDS in fuzzy matching\n\nIn `suggest_similar_command()`, add \"timeline\" to `VALID_COMMANDS`.\n\n### 4. Add to robot-docs manifest\n\nIn `handle_robot_docs()`, add the timeline command entry with all flags.\n\n## Acceptance Criteria\n\n- [ ] `lore timeline \"authentication\"` works with human output\n- [ ] `lore --robot timeline \"authentication\"` works with JSON output\n- [ ] `-p group/repo` filters to single project (spec Section 3.1)\n- [ ] `--since 6m` filters to recent events (spec Section 3.1: supports 6m, YYYY-MM-DD)\n- [ ] `--depth 0` disables expansion (spec Section 3.1)\n- [ ] `--depth 1` is the default (spec Section 3.1)\n- [ ] `--depth 2` expands two hops (spec Section 3.1)\n- [ ] `--expand-mentions` includes mentioned edges (spec Section 3.1)\n- [ ] `-n 50` limits output to 50 events (spec Section 3.1)\n- [ ] `lore --robot timeline` with missing query prints MISSING_REQUIRED error\n- [ ] Timeline command appears in `lore robot-docs` output\n- [ ] Timeline command in VALID_COMMANDS for fuzzy matching\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/cli/mod.rs` (add TimelineArgs struct + Commands::Timeline variant)\n- `src/main.rs` (add handle_timeline function + match arm + VALID_COMMANDS + robot-docs entry)\n\n## TDD Loop\n\nRED: No unit tests for CLI wiring -- this is integration-level. Verify with:\n- `cargo check --all-targets` (compiles)\n- Manual: `lore timeline \"test\"` with a synced database\n\nGREEN: Wire up the clap struct, add the match arm, call through to pipeline.\n\nVERIFY: `cargo check --all-targets && cargo clippy --all-targets -- -D warnings`\n\n## Edge Cases\n\n- Missing query argument: clap handles this with MISSING_REQUIRED error (exit 2)\n- Ambiguous project: resolve_project returns exit 18 (Ambiguous match)\n- Invalid --since format: `parse_since()` returns descriptive LoreError\n- --depth > 5: cap silently at 5 to prevent explosion\n- Empty results: print \"No events found matching 'query'\" (human) or empty events array (robot)","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-02T21:33:28.422082Z","created_by":"tayloreernisse","updated_at":"2026-02-05T19:12:17.937610Z","compaction_level":0,"original_size":0,"labels":["cli","gate-3","phase-b"],"dependencies":[{"issue_id":"bd-1nf","depends_on_id":"bd-2f2","type":"blocks","created_at":"2026-02-02T21:33:37.746192Z","created_by":"tayloreernisse"},{"issue_id":"bd-1nf","depends_on_id":"bd-dty","type":"blocks","created_at":"2026-02-02T21:33:37.788079Z","created_by":"tayloreernisse"},{"issue_id":"bd-1nf","depends_on_id":"bd-ike","type":"parent-child","created_at":"2026-02-02T21:33:28.423399Z","created_by":"tayloreernisse"}]} {"id":"bd-1np","title":"[CP1] GitLab types for issues, discussions, notes","description":"## Background\n\nGitLab 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.\n\n## Approach\n\nAdd types to `src/gitlab/types.rs` with serde derives:\n\n### GitLabIssue\n\n```rust\n#[derive(Debug, Clone, Deserialize)]\npub struct GitLabIssue {\n pub id: i64, // GitLab global ID\n pub iid: i64, // Project-scoped issue number\n pub project_id: i64,\n pub title: String,\n pub description: Option,\n pub state: String, // \"opened\" | \"closed\"\n pub created_at: String, // ISO 8601\n pub updated_at: String, // ISO 8601\n pub closed_at: Option,\n pub author: GitLabAuthor,\n pub labels: Vec, // Array of label names (CP1 canonical)\n pub web_url: String,\n}\n```\n\nNOTE: `labels_details` intentionally NOT modeled - varies across GitLab versions.\n\n### GitLabAuthor\n\n```rust\n#[derive(Debug, Clone, Deserialize)]\npub struct GitLabAuthor {\n pub id: i64,\n pub username: String,\n pub name: String,\n}\n```\n\n### GitLabDiscussion\n\n```rust\n#[derive(Debug, Clone, Deserialize)]\npub struct GitLabDiscussion {\n pub id: String, // String ID like \"6a9c1750b37d...\"\n pub individual_note: bool, // true = standalone comment\n pub notes: Vec,\n}\n```\n\n### GitLabNote\n\n```rust\n#[derive(Debug, Clone, Deserialize)]\npub struct GitLabNote {\n pub id: i64,\n #[serde(rename = \"type\")]\n pub note_type: Option, // \"DiscussionNote\" | \"DiffNote\" | null\n pub body: String,\n pub author: GitLabAuthor,\n pub created_at: String, // ISO 8601\n pub updated_at: String, // ISO 8601\n pub system: bool, // true for system-generated notes\n #[serde(default)]\n pub resolvable: bool,\n #[serde(default)]\n pub resolved: bool,\n pub resolved_by: Option,\n pub resolved_at: Option,\n pub position: Option,\n}\n```\n\n### GitLabNotePosition\n\n```rust\n#[derive(Debug, Clone, Deserialize)]\npub struct GitLabNotePosition {\n pub old_path: Option,\n pub new_path: Option,\n pub old_line: Option,\n pub new_line: Option,\n}\n```\n\n## Acceptance Criteria\n\n- [ ] GitLabIssue deserializes from API response JSON\n- [ ] GitLabAuthor embedded correctly in issue and note\n- [ ] GitLabDiscussion with notes array deserializes\n- [ ] GitLabNote handles null note_type (use Option)\n- [ ] GitLabNote uses #[serde(rename = \"type\")] for reserved keyword\n- [ ] resolvable/resolved default to false via #[serde(default)]\n- [ ] All timestamp fields are String (ISO 8601 parsed elsewhere)\n\n## Files\n\n- src/gitlab/types.rs (edit - add types)\n\n## TDD Loop\n\nRED:\n```rust\n// tests/gitlab_types_tests.rs\n#[test] fn deserializes_gitlab_issue_from_json()\n#[test] fn deserializes_gitlab_discussion_from_json()\n#[test] fn handles_null_note_type()\n#[test] fn handles_missing_resolvable_field()\n#[test] fn deserializes_labels_as_string_array()\n```\n\nGREEN: Add type definitions with serde attributes\n\nVERIFY: `cargo test gitlab_types`\n\n## Edge Cases\n\n- note_type can be null, \"DiscussionNote\", or \"DiffNote\"\n- labels array can be empty\n- description can be null\n- resolved_by/resolved_at can be null\n- position is only present for DiffNotes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-25T17:02:38.150472Z","created_by":"tayloreernisse","updated_at":"2026-01-25T22:17:08.842965Z","closed_at":"2026-01-25T22:17:08.842895Z","close_reason":"Implemented GitLabAuthor, GitLabIssue, GitLabDiscussion, GitLabNote, GitLabNotePosition types with 10 passing tests","compaction_level":0,"original_size":0} {"id":"bd-1o1","title":"OBSERV: Add -v/--verbose and --log-format CLI flags","description":"## Background\nUsers 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.\n\n## Approach\nAdd two new global flags to the Cli struct in src/cli/mod.rs (insert after the quiet field at line ~37):\n\n```rust\n/// Increase log verbosity (-v, -vv, -vvv)\n#[arg(short = 'v', long = \"verbose\", action = clap::ArgAction::Count, global = true)]\npub verbose: u8,\n\n/// Log format for stderr output: text (default) or json\n#[arg(long = \"log-format\", global = true, value_parser = [\"text\", \"json\"], default_value = \"text\")]\npub log_format: String,\n```\n\nThe 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.\n\nNote: clap::ArgAction::Count allows -v, -vv, -vvv as a single flag with increasing count (0, 1, 2, 3).\n\n## Acceptance Criteria\n- [ ] lore -v sync parses without error (verbose=1)\n- [ ] lore -vv sync parses (verbose=2)\n- [ ] lore -vvv sync parses (verbose=3)\n- [ ] lore --log-format json sync parses (log_format=\"json\")\n- [ ] lore --log-format text sync parses (default)\n- [ ] lore --log-format xml sync errors (invalid value)\n- [ ] Existing commands unaffected (verbose defaults to 0, log_format to \"text\")\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/cli/mod.rs (modify Cli struct, lines 13-42)\n\n## TDD Loop\nRED: Write test that parses Cli with -v flag and asserts verbose=1\nGREEN: Add the two fields to Cli struct\nVERIFY: cargo test -p lore && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- -v and -q together: both parse fine; conflict resolution happens in subscriber setup (bd-2rr), not here\n- -v flag must be global=true so it works before and after subcommands: lore -v sync AND lore sync -v\n- --log-format is a string, not enum, to keep Cli struct simple","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-04T15:53:55.421339Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:10:22.585947Z","closed_at":"2026-02-04T17:10:22.585905Z","close_reason":"Added -v/--verbose (count) and --log-format (text|json) global CLI flags","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-1o1","depends_on_id":"bd-2nx","type":"parent-child","created_at":"2026-02-04T15:53:55.422103Z","created_by":"tayloreernisse"}]} {"id":"bd-1o4h","title":"OBSERV: Define StageTiming struct in src/core/metrics.rs","description":"## Background\nStageTiming 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.\n\n## Approach\nCreate src/core/metrics.rs:\n\n```rust\nuse serde::Serialize;\n\nfn is_zero(v: &usize) -> bool { *v == 0 }\n\n#[derive(Debug, Clone, Serialize)]\npub struct StageTiming {\n pub name: String,\n #[serde(skip_serializing_if = \"Option::is_none\")]\n pub project: Option,\n pub elapsed_ms: u64,\n pub items_processed: usize,\n #[serde(skip_serializing_if = \"is_zero\")]\n pub items_skipped: usize,\n #[serde(skip_serializing_if = \"is_zero\")]\n pub errors: usize,\n #[serde(skip_serializing_if = \"Vec::is_empty\")]\n pub sub_stages: Vec,\n}\n```\n\nRegister module in src/core/mod.rs (line ~11, add):\n```rust\npub mod metrics;\n```\n\nThe is_zero helper is a private function used by serde's skip_serializing_if. It must take &usize (reference) and return bool.\n\n## Acceptance Criteria\n- [ ] StageTiming serializes to JSON matching PRD Section 4.6.2 example\n- [ ] items_skipped omitted when 0\n- [ ] errors omitted when 0\n- [ ] sub_stages omitted when empty vec\n- [ ] project omitted when None\n- [ ] name, elapsed_ms, items_processed always present\n- [ ] Struct is Debug + Clone + Serialize\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/core/metrics.rs (new file)\n- src/core/mod.rs (register module, add line after existing pub mod declarations)\n\n## TDD Loop\nRED:\n - test_stage_timing_serialization: create StageTiming with sub_stages, serialize, assert JSON structure\n - test_stage_timing_zero_fields_omitted: errors=0, items_skipped=0, assert no \"errors\" or \"items_skipped\" keys\n - test_stage_timing_empty_sub_stages: sub_stages=vec![], assert no \"sub_stages\" key\nGREEN: Create metrics.rs with StageTiming struct and is_zero helper\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- is_zero must be a function, not a closure (serde skip_serializing_if requires a function path)\n- Vec::is_empty is a method on Vec, and serde accepts \"Vec::is_empty\" as a path for skip_serializing_if\n- Recursive StageTiming (sub_stages contains StageTiming): serde handles this naturally, no special handling needed","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T15:54:31.907234Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:21:40.915842Z","closed_at":"2026-02-04T17:21:40.915794Z","close_reason":"Created src/core/metrics.rs with StageTiming struct, serde skip_serializing_if for zero/empty fields, 5 tests","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-1o4h","depends_on_id":"bd-3er","type":"parent-child","created_at":"2026-02-04T15:54:31.910015Z","created_by":"tayloreernisse"}]} @@ -53,7 +53,7 @@ {"id":"bd-1zj6","title":"OBSERV: Enrich robot JSON meta with run_id and stages","description":"## Background\nRobot JSON currently has a flat meta.elapsed_ms. This enriches it with run_id and a stages array, making every lore --robot sync output a complete performance profile.\n\n## Approach\nThe robot JSON output is built in src/cli/commands/sync.rs. The current SyncResult (line 15-25) is serialized into the data field. The meta field is built alongside it.\n\n1. Find or create the SyncMeta struct (likely near SyncResult). Add fields:\n```rust\n#[derive(Debug, Serialize)]\nstruct SyncMeta {\n run_id: String,\n elapsed_ms: u64,\n stages: Vec,\n}\n```\n\n2. After run_sync() completes, extract timings from MetricsLayer:\n```rust\nlet stages = metrics_handle.extract_timings();\nlet meta = SyncMeta {\n run_id: run_id.to_string(),\n elapsed_ms: start.elapsed().as_millis() as u64,\n stages,\n};\n```\n\n3. Build the JSON envelope:\n```rust\nlet output = serde_json::json!({\n \"ok\": true,\n \"data\": result,\n \"meta\": meta,\n});\n```\n\nThe metrics_handle (Arc) must be passed from main.rs to the command handler. This requires adding a parameter to handle_sync_cmd() and run_sync(), or using a global. Prefer parameter passing.\n\nSame pattern for standalone ingest: add stages to IngestMeta.\n\n## Acceptance Criteria\n- [ ] lore --robot sync output includes meta.run_id (string, 8 hex chars)\n- [ ] lore --robot sync output includes meta.stages (array of StageTiming)\n- [ ] meta.elapsed_ms still present (total wall clock time)\n- [ ] Each stage has name, elapsed_ms, items_processed at minimum\n- [ ] Top-level stages have sub_stages when applicable\n- [ ] lore --robot ingest also includes run_id and stages\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/cli/commands/sync.rs (add SyncMeta struct, wire extract_timings)\n- src/cli/commands/ingest.rs (same for standalone ingest)\n- src/main.rs (pass metrics_handle to command handlers)\n\n## TDD Loop\nRED: test_sync_meta_includes_stages (run robot-mode sync, parse JSON, assert meta.stages is array)\nGREEN: Add SyncMeta, extract timings, include in JSON output\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- Empty stages: if sync runs with --no-docs --no-embed, some stages won't exist. stages array is shorter, not padded.\n- extract_timings() called before root span closes: returns incomplete tree. Must call AFTER run_sync returns (span is dropped on function exit).\n- metrics_handle clone: MetricsLayer uses Arc internally, clone is cheap (reference count increment).","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T15:54:32.062410Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:31:11.073580Z","closed_at":"2026-02-04T17:31:11.073534Z","close_reason":"Wired MetricsLayer into subscriber stack (all 4 branches), added run_id to SyncResult, enriched SyncMeta with run_id + stages Vec, updated print_sync_json to accept MetricsLayer and extract timings","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-1zj6","depends_on_id":"bd-34ek","type":"blocks","created_at":"2026-02-04T15:55:20.085372Z","created_by":"tayloreernisse"},{"issue_id":"bd-1zj6","depends_on_id":"bd-3er","type":"parent-child","created_at":"2026-02-04T15:54:32.063354Z","created_by":"tayloreernisse"}]} {"id":"bd-1zwv","title":"Display assignees, due_date, and milestone in lore issues output","description":"## Background\nThe `lore issues ` command displays issue details but omits key metadata that exists in the database: assignees, due dates, and milestones. Users need this information to understand issue context without opening GitLab.\n\n**System fit**: This data is already ingested during issue sync (migration 005) but the show command never queries it.\n\n## Approach\n\nAll changes in `src/cli/commands/show.rs`:\n\n### 1. Update IssueRow struct (line ~119)\nAdd fields to internal row struct:\n```rust\nstruct IssueRow {\n // ... existing 10 fields ...\n due_date: Option, // NEW\n milestone_title: Option, // NEW\n}\n```\n\n### 2. Update find_issue() SQL (line ~137)\nExtend SELECT:\n```sql\nSELECT i.id, i.iid, i.title, i.description, i.state, i.author_username,\n i.created_at, i.updated_at, i.web_url, p.path_with_namespace,\n i.due_date, i.milestone_title -- ADD THESE\nFROM issues i ...\n```\n\nUpdate row mapping to extract columns 10 and 11.\n\n### 3. Add get_issue_assignees() (after get_issue_labels ~line 189)\n```rust\nfn get_issue_assignees(conn: &Connection, issue_id: i64) -> Result> {\n let mut stmt = conn.prepare(\n \"SELECT username FROM issue_assignees WHERE issue_id = ? ORDER BY username\"\n )?;\n let assignees = stmt\n .query_map([issue_id], |row| row.get(0))?\n .collect::, _>>()?;\n Ok(assignees)\n}\n```\n\n### 4. Update IssueDetail struct (line ~59)\n```rust\npub struct IssueDetail {\n // ... existing 12 fields ...\n pub assignees: Vec, // NEW\n pub due_date: Option, // NEW\n pub milestone: Option, // NEW\n}\n```\n\n### 5. Update IssueDetailJson struct (line ~770)\nAdd same 3 fields with identical types.\n\n### 6. Update run_show_issue() (line ~89)\n```rust\nlet assignees = get_issue_assignees(&conn, issue.id)?;\n// In return struct:\nassignees,\ndue_date: issue.due_date,\nmilestone: issue.milestone_title,\n```\n\n### 7. Update print_show_issue() (line ~533, after Author line ~548)\n```rust\nif !issue.assignees.is_empty() {\n println!(\"Assignee{}: {}\",\n if issue.assignees.len() > 1 { \"s\" } else { \"\" },\n issue.assignees.iter().map(|a| format!(\"@{}\", a)).collect::>().join(\", \"));\n}\nif let Some(due) = &issue.due_date {\n println!(\"Due: {}\", due);\n}\nif let Some(ms) = &issue.milestone {\n println!(\"Milestone: {}\", ms);\n}\n```\n\n### 8. Update From<&IssueDetail> for IssueDetailJson (line ~799)\n```rust\nassignees: issue.assignees.clone(),\ndue_date: issue.due_date.clone(),\nmilestone: issue.milestone.clone(),\n```\n\n## Acceptance Criteria\n- [ ] `cargo test test_get_issue_assignees` passes (3 tests)\n- [ ] `lore issues ` shows Assignees line when assignees exist\n- [ ] `lore issues ` shows Due line when due_date set\n- [ ] `lore issues ` shows Milestone line when milestone set\n- [ ] `lore -J issues ` includes assignees/due_date/milestone in JSON\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n- `src/cli/commands/show.rs` - ALL changes\n\n## TDD Loop\n\n**RED** - Add tests to `src/cli/commands/show.rs` `#[cfg(test)] mod tests`:\n\n```rust\nuse crate::core::db::{create_connection, run_migrations};\nuse std::path::Path;\n\nfn setup_test_db() -> Connection {\n let conn = create_connection(Path::new(\":memory:\")).unwrap();\n run_migrations(&conn).unwrap();\n conn\n}\n\n#[test]\nfn test_get_issue_assignees_empty() {\n let conn = setup_test_db();\n // seed project + issue with no assignees\n let result = get_issue_assignees(&conn, 1).unwrap();\n assert!(result.is_empty());\n}\n\n#[test]\nfn test_get_issue_assignees_multiple_sorted() {\n let conn = setup_test_db();\n // seed with alice, bob\n let result = get_issue_assignees(&conn, 1).unwrap();\n assert_eq!(result, vec![\"alice\", \"bob\"]); // alphabetical\n}\n\n#[test]\nfn test_get_issue_assignees_single() {\n let conn = setup_test_db();\n // seed with charlie only\n let result = get_issue_assignees(&conn, 1).unwrap();\n assert_eq!(result, vec![\"charlie\"]);\n}\n```\n\n**GREEN** - Implement get_issue_assignees() and struct updates\n\n**VERIFY**: `cargo test test_get_issue_assignees && cargo clippy --all-targets -- -D warnings`\n\n## Edge Cases\n- Empty assignees list -> don't print Assignees line\n- NULL due_date -> don't print Due line \n- NULL milestone_title -> don't print Milestone line\n- Single vs multiple assignees -> \"Assignee\" vs \"Assignees\" grammar","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-02-05T15:16:00.105830Z","created_by":"tayloreernisse","updated_at":"2026-02-05T15:26:08.147202Z","closed_at":"2026-02-05T15:26:08.147154Z","close_reason":"Implemented: assignees, due_date, milestone now display in lore issues . All 7 new tests pass.","compaction_level":0,"original_size":0,"labels":["ISSUE"]} {"id":"bd-208","title":"[CP1] Issue ingestion module","description":"## Background\n\nThe issue ingestion module fetches and stores issues with cursor-based incremental sync. It is the primary data ingestion component, establishing the pattern reused for MR ingestion in CP2. The module handles tuple-cursor semantics, raw payload storage, label extraction, and tracking which issues need discussion sync.\n\n## Approach\n\n### Module: src/ingestion/issues.rs\n\n### Key Structs\n\n```rust\n#[derive(Debug, Default)]\npub struct IngestIssuesResult {\n pub fetched: usize,\n pub upserted: usize,\n pub labels_created: usize,\n pub issues_needing_discussion_sync: Vec,\n}\n\n#[derive(Debug, Clone)]\npub struct IssueForDiscussionSync {\n pub local_issue_id: i64,\n pub iid: i64,\n pub updated_at: i64, // ms epoch\n}\n```\n\n### Main Function\n\n```rust\npub async fn ingest_issues(\n conn: &Connection,\n client: &GitLabClient,\n config: &Config,\n project_id: i64, // Local DB project ID\n gitlab_project_id: i64,\n) -> Result\n```\n\n### Logic (Step by Step)\n\n1. **Get current cursor** from sync_cursors table:\n```sql\nSELECT updated_at_cursor, tie_breaker_id\nFROM sync_cursors\nWHERE project_id = ? AND resource_type = 'issues'\n```\n\n2. **Call pagination method** with cursor rewind:\n```rust\nlet issues_stream = client.paginate_issues(\n gitlab_project_id,\n cursor.updated_at_cursor,\n config.sync.cursor_rewind_seconds,\n);\n```\n\n3. **Apply local filtering** for tuple cursor semantics:\n```rust\n// Skip if issue.updated_at < cursor_updated_at\n// Skip if issue.updated_at == cursor_updated_at AND issue.gitlab_id <= cursor_gitlab_id\nfn passes_cursor_filter(issue: &GitLabIssue, cursor: &SyncCursor) -> bool {\n if issue.updated_at < cursor.updated_at_cursor {\n return false;\n }\n if issue.updated_at == cursor.updated_at_cursor \n && issue.gitlab_id <= cursor.tie_breaker_id {\n return false;\n }\n true\n}\n```\n\n4. **For each issue passing filter**:\n```rust\n// Begin transaction (unchecked_transaction for rusqlite)\nlet tx = conn.unchecked_transaction()?;\n\n// Store raw payload (compressed based on config)\nlet payload_id = store_raw_payload(&tx, &issue_json, config.storage.compress_raw_payloads)?;\n\n// Transform and upsert issue\nlet issue_row = transform_issue(&issue)?;\nupsert_issue(&tx, &issue_row, project_id, payload_id)?;\nlet local_issue_id = get_local_issue_id(&tx, project_id, issue.iid)?;\n\n// Clear existing label links (stale removal!)\ntx.execute(\"DELETE FROM issue_labels WHERE issue_id = ?\", [local_issue_id])?;\n\n// Extract and upsert labels\nfor label_name in &issue_row.label_names {\n let label_id = upsert_label(&tx, project_id, label_name)?;\n link_issue_label(&tx, local_issue_id, label_id)?;\n}\n\ntx.commit()?;\n```\n\n5. **Incremental cursor update** every 100 issues:\n```rust\nif batch_count % 100 == 0 {\n update_sync_cursor(conn, project_id, \"issues\", last_updated_at, last_gitlab_id)?;\n}\n```\n\n6. **Final cursor update** after all issues processed\n\n7. **Determine issues needing discussion sync**:\n```sql\nSELECT id, iid, updated_at\nFROM issues\nWHERE project_id = ?\n AND updated_at > COALESCE(discussions_synced_for_updated_at, 0)\n```\n\n### Helper Functions\n\n```rust\nfn store_raw_payload(conn, json: &Value, compress: bool) -> Result\nfn upsert_issue(conn, issue: &IssueRow, project_id: i64, payload_id: i64) -> Result<()>\nfn get_local_issue_id(conn, project_id: i64, iid: i64) -> Result\nfn upsert_label(conn, project_id: i64, name: &str) -> Result\nfn link_issue_label(conn, issue_id: i64, label_id: i64) -> Result<()>\nfn update_sync_cursor(conn, project_id: i64, resource: &str, updated_at: i64, gitlab_id: i64) -> Result<()>\n```\n\n### Critical Invariant\n\nStale label links MUST be removed on resync. The \"DELETE then INSERT\" pattern ensures GitLab reality is reflected locally. If an issue had labels [A, B] and now has [A, C], the B link must be removed.\n\n## Acceptance Criteria\n\n- [ ] `ingest_issues` returns IngestIssuesResult with all counts\n- [ ] Cursor fetched from sync_cursors at start\n- [ ] Cursor rewind applied before API call\n- [ ] Local filtering skips already-processed issues\n- [ ] Each issue wrapped in transaction for atomicity\n- [ ] Raw payload stored with correct compression\n- [ ] Issue upserted (INSERT OR REPLACE pattern)\n- [ ] Existing label links deleted before new links inserted\n- [ ] Labels upserted (INSERT OR IGNORE by project+name)\n- [ ] Cursor updated every 100 issues (crash recovery)\n- [ ] Final cursor update after all issues\n- [ ] issues_needing_discussion_sync populated correctly\n\n## Files\n\n- src/ingestion/mod.rs (add `pub mod issues;`)\n- src/ingestion/issues.rs (create)\n\n## TDD Loop\n\nRED:\n```rust\n// tests/issue_ingestion_tests.rs\n#[tokio::test] async fn ingests_issues_from_stream()\n#[tokio::test] async fn applies_cursor_filter_correctly()\n#[tokio::test] async fn updates_cursor_every_100_issues()\n#[tokio::test] async fn stores_raw_payload_for_each_issue()\n#[tokio::test] async fn upserts_issues_correctly()\n\n// tests/label_linkage_tests.rs\n#[tokio::test] async fn extracts_and_stores_labels()\n#[tokio::test] async fn removes_stale_label_links_on_resync()\n#[tokio::test] async fn handles_empty_labels_array()\n\n// tests/discussion_eligibility_tests.rs\n#[tokio::test] async fn identifies_issues_needing_discussion_sync()\n#[tokio::test] async fn skips_issues_with_current_watermark()\n```\n\nGREEN: Implement ingest_issues with all helper functions\n\nVERIFY: `cargo test issue_ingestion && cargo test label_linkage && cargo test discussion_eligibility`\n\n## Edge Cases\n\n- Empty issues stream - return result with all zeros\n- Cursor at epoch 0 - fetch all issues (no filtering)\n- Issue with no labels - empty Vec, no label links created\n- Issue with 50+ labels - all should be linked\n- Crash mid-batch - cursor at last 100-boundary, some issues re-fetched\n- Label already exists - upsert via INSERT OR IGNORE\n- Same issue fetched twice (due to rewind) - upsert handles it","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-25T17:02:38.245404Z","created_by":"tayloreernisse","updated_at":"2026-01-25T22:52:38.003964Z","closed_at":"2026-01-25T22:52:38.003868Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-208","depends_on_id":"bd-2iq","type":"blocks","created_at":"2026-01-25T17:04:05.425224Z","created_by":"tayloreernisse"},{"issue_id":"bd-208","depends_on_id":"bd-3nd","type":"blocks","created_at":"2026-01-25T17:04:05.450341Z","created_by":"tayloreernisse"},{"issue_id":"bd-208","depends_on_id":"bd-xhz","type":"blocks","created_at":"2026-01-25T17:04:05.473203Z","created_by":"tayloreernisse"}]} -{"id":"bd-20e","title":"Define TimelineEvent model and TimelineEventType enum","description":"## Background\n\nThe TimelineEvent model is the foundational data type for Gate 3's timeline feature. All pipeline stages (seed, expand, collect, interleave) produce or consume TimelineEvents. This must be defined first because every downstream bead (bd-32q, bd-ypa, bd-3as, bd-dty, bd-2f2) depends on these types.\n\n## Approach\n\nCreate `src/core/timeline.rs` with the following types:\n\n```rust\n#[derive(Debug, Clone, Serialize)]\npub struct TimelineEvent {\n pub timestamp: i64, // ms epoch UTC\n pub entity_type: &'static str, // \"issue\" | \"merge_request\"\n pub entity_id: i64, // local DB id\n pub entity_iid: i64,\n pub project_path: String,\n pub event_type: TimelineEventType,\n pub summary: String, // one-line human description\n pub actor: Option, // username or None for system\n pub is_seed: bool, // true if from seed phase, false if expanded\n}\n\n#[derive(Debug, Clone, Serialize)]\n#[serde(tag = \"kind\", rename_all = \"snake_case\")]\npub enum TimelineEventType {\n Created,\n StateChanged { from: Option, to: String },\n LabelAdded { label: String },\n LabelRemoved { label: String },\n MilestoneSet { milestone: String },\n MilestoneRemoved { milestone: String },\n Merged { merge_commit: Option },\n NoteEvidence { note_body_snippet: String, discussion_id: i64 },\n CrossReferenced { reference_type: String, target_entity_type: String, target_iid: i64 },\n}\n\n#[derive(Debug, Clone)]\npub struct EntityRef {\n pub entity_type: &'static str,\n pub entity_id: i64,\n pub entity_iid: i64,\n pub project_path: String,\n}\n\n#[derive(Debug, Clone, Serialize)]\npub struct ExpandedEntityRef {\n pub entity_ref: EntityRef,\n pub provenance_seed: EntityRef, // which seed led here\n pub edge_type: String, // \"closes\", \"mentioned\", \"related\"\n pub depth: u32,\n}\n\n#[derive(Debug, Clone, Serialize)]\npub struct UnresolvedRef {\n pub target_project_path: Option,\n pub target_iid: i64,\n pub reference_type: String,\n}\n\n#[derive(Debug, Clone, Serialize)]\npub struct TimelineResult {\n pub query: String,\n pub events: Vec,\n pub seed_entities: Vec,\n pub expanded_entities: Vec,\n pub unresolved_references: Vec,\n}\n```\n\nImplement `Ord` on `TimelineEvent` for chronological sort: primary key `timestamp`, tiebreak by `entity_id` then `event_type` discriminant (use a `sort_key()` method returning `(i64, i64, u8)`).\n\nRegister in `src/core/mod.rs`: `pub mod timeline;`\n\n## Acceptance Criteria\n\n- [ ] `src/core/timeline.rs` compiles with no warnings\n- [ ] `TimelineEventType` has exactly 9 variants as listed above\n- [ ] `TimelineEvent` derives `Serialize` for downstream JSON output\n- [ ] `EntityRef` is `Clone + Debug` (needed in BFS expand phase)\n- [ ] `TimelineResult` contains all 5 fields (query, events, seed_entities, expanded_entities, unresolved_references)\n- [ ] `Ord` impl on `TimelineEvent` sorts by (timestamp, entity_id, event_type discriminant)\n- [ ] Module registered in `src/core/mod.rs`\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/core/timeline.rs` (NEW)\n- `src/core/mod.rs` (add `pub mod timeline;`)\n\n## TDD Loop\n\nRED: Create `src/core/timeline.rs` with `#[cfg(test)] mod tests` containing:\n- `test_timeline_event_sort_by_timestamp` - events sort chronologically\n- `test_timeline_event_sort_tiebreak` - same-timestamp events sort stably by entity_id then event_type\n- `test_timeline_event_type_serializes_tagged` - serde JSON output uses `kind` tag\n\nGREEN: Implement the types and Ord trait.\n\nVERIFY: `cargo test --lib -- timeline`\n\n## Edge Cases\n\n- Ensure Ord is consistent: a.cmp(b) must never panic for any valid TimelineEvent\n- NoteEvidence snippet should be truncated to 200 chars in the type (enforced at construction, not in the type itself)\n- EntityRef's entity_type uses &'static str not String to avoid allocations in hot BFS loop","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-02T21:33:08.569126Z","created_by":"tayloreernisse","updated_at":"2026-02-05T18:50:08.069355Z","compaction_level":0,"original_size":0,"labels":["gate-3","phase-b","types"],"dependencies":[{"issue_id":"bd-20e","depends_on_id":"bd-ike","type":"parent-child","created_at":"2026-02-02T21:33:08.573079Z","created_by":"tayloreernisse"}]} +{"id":"bd-20e","title":"Define TimelineEvent model and TimelineEventType enum","description":"## Background\n\nThe TimelineEvent model is the foundational data type for Gate 3's timeline feature. All pipeline stages (seed, expand, collect, interleave) produce or consume TimelineEvents. This must be defined first because every downstream bead (bd-32q, bd-ypa, bd-3as, bd-dty, bd-2f2) depends on these types.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.3 (Event Model).\n\n## Approach\n\nCreate `src/core/timeline.rs` with the following types:\n\n```rust\n/// The core timeline event. All pipeline stages produce or consume these.\n/// Spec ref: Section 3.3 \"Event Model\"\n#[derive(Debug, Clone, Serialize)]\npub struct TimelineEvent {\n pub timestamp: i64, // ms epoch UTC\n pub entity_type: &'static str, // \"issue\" | \"merge_request\"\n pub entity_id: i64, // local DB id (internal, not in JSON output)\n pub entity_iid: i64,\n pub project_path: String,\n pub event_type: TimelineEventType,\n pub summary: String, // human-readable one-liner\n pub actor: Option, // username or None for system\n pub url: Option, // web URL for the event source (spec requirement)\n pub is_seed: bool, // true if from seed phase, false if expanded\n}\n\n/// Per spec Section 3.3. Serde tagged enum for JSON output.\n/// Note: internal struct uses &'static str for entity_type (perf optimization),\n/// but JSON output serializes as String. entity_id is internal-only (not in JSON).\n#[derive(Debug, Clone, Serialize)]\n#[serde(tag = \"kind\", rename_all = \"snake_case\")]\npub enum TimelineEventType {\n Created,\n StateChanged { state: String }, // spec: just the target state\n LabelAdded { label: String },\n LabelRemoved { label: String },\n MilestoneSet { milestone: String },\n MilestoneRemoved { milestone: String },\n Merged, // spec: unit variant\n NoteEvidence {\n note_id: i64, // spec: required\n snippet: String, // first ~200 chars of matching note body\n discussion_id: Option, // spec: optional (some notes lack discussions)\n },\n CrossReferenced { target: String }, // spec: compact target ref like \"\\!567\" or \"#234\"\n}\n\n/// Internal entity reference used across pipeline stages.\n#[derive(Debug, Clone)]\npub struct EntityRef {\n pub entity_type: &'static str,\n pub entity_id: i64,\n pub entity_iid: i64,\n pub project_path: String,\n}\n\n/// An entity discovered via BFS expansion.\n/// Spec ref: Section 3.5 \"expanded_entities\" JSON structure.\n#[derive(Debug, Clone, Serialize)]\npub struct ExpandedEntityRef {\n pub entity_ref: EntityRef,\n pub depth: u32,\n /// Provenance: which seed entity + edge type led to discovery.\n /// JSON output uses nested \"via\" object per spec Section 3.5:\n /// { \"from\": { \"type\", \"iid\", \"project\" }, \"reference_type\", \"source_method\" }\n pub via_from: EntityRef, // the entity that referenced this one\n pub via_reference_type: String, // \"closes\", \"mentioned\", \"related\"\n pub via_source_method: String, // \"api\", \"note_parse\", etc.\n}\n\n/// Reference that points to an unsynced external entity.\n/// Spec ref: Section 3.5 \"unresolved_references\" JSON structure.\n#[derive(Debug, Clone, Serialize)]\npub struct UnresolvedRef {\n pub source: EntityRef, // entity containing the reference\n pub target_project: Option, // e.g. \"group/other-repo\"\n pub target_type: String, // \"issue\" | \"merge_request\"\n pub target_iid: i64,\n pub reference_type: String, // \"closes\", \"mentioned\", \"related\"\n}\n\n/// Complete result from the timeline pipeline.\n#[derive(Debug, Clone, Serialize)]\npub struct TimelineResult {\n pub query: String,\n pub events: Vec,\n pub seed_entities: Vec,\n pub expanded_entities: Vec,\n pub unresolved_references: Vec,\n}\n```\n\nImplement `Ord` on `TimelineEvent` for chronological sort: primary key `timestamp`, tiebreak by `entity_id` then `event_type` discriminant (use a `sort_key()` method returning `(i64, i64, u8)`).\n\nRegister in `src/core/mod.rs`: `pub mod timeline;`\n\n## Acceptance Criteria\n\n- [ ] `src/core/timeline.rs` compiles with no warnings\n- [ ] `TimelineEvent` has `url: Option` field (spec Section 3.3)\n- [ ] `TimelineEventType` has exactly 9 variants matching spec Section 3.3\n- [ ] `StateChanged` uses `{ state: String }` (not from/to -- spec Section 3.3)\n- [ ] `Merged` is a unit variant (no inner fields -- spec Section 3.3)\n- [ ] `NoteEvidence` has `note_id: i64`, `snippet: String`, `discussion_id: Option` (spec Section 3.3)\n- [ ] `CrossReferenced` has `{ target: String }` (compact ref like \"\\!567\" -- spec Section 3.3)\n- [ ] `ExpandedEntityRef` has `via_from`, `via_reference_type`, `via_source_method` fields (spec Section 3.5)\n- [ ] `UnresolvedRef` has `source` entity, `target_project`, `target_type`, `target_iid`, `reference_type` (spec Section 3.5)\n- [ ] `TimelineEvent` derives `Serialize` for downstream JSON output\n- [ ] `EntityRef` is `Clone + Debug` (needed in BFS expand phase)\n- [ ] `TimelineResult` contains all 5 fields (query, events, seed_entities, expanded_entities, unresolved_references)\n- [ ] `Ord` impl on `TimelineEvent` sorts by (timestamp, entity_id, event_type discriminant)\n- [ ] Module registered in `src/core/mod.rs`\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/core/timeline.rs` (NEW)\n- `src/core/mod.rs` (add `pub mod timeline;`)\n\n## TDD Loop\n\nRED: Create `src/core/timeline.rs` with `#[cfg(test)] mod tests` containing:\n- `test_timeline_event_sort_by_timestamp` - events sort chronologically\n- `test_timeline_event_sort_tiebreak` - same-timestamp events sort stably by entity_id then event_type\n- `test_timeline_event_type_serializes_tagged` - serde JSON output uses `kind` tag\n- `test_state_changed_serializes_state_only` - `{\"kind\":\"state_changed\",\"state\":\"closed\"}`\n- `test_note_evidence_has_note_id` - note_id present in serialized output\n\nGREEN: Implement the types and Ord trait.\n\nVERIFY: `cargo test --lib -- timeline`\n\n## Edge Cases\n\n- Ensure Ord is consistent: a.cmp(b) must never panic for any valid TimelineEvent\n- NoteEvidence snippet should be truncated to 200 chars at construction, not in the type itself\n- EntityRef's entity_type uses &'static str not String to avoid allocations in hot BFS loop\n- url field: constructed from project_path + entity_type + iid pattern; None for entities without web_url","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-02T21:33:08.569126Z","created_by":"tayloreernisse","updated_at":"2026-02-05T19:09:03.146239Z","compaction_level":0,"original_size":0,"labels":["gate-3","phase-b","types"],"dependencies":[{"issue_id":"bd-20e","depends_on_id":"bd-ike","type":"parent-child","created_at":"2026-02-02T21:33:08.573079Z","created_by":"tayloreernisse"}]} {"id":"bd-20h","title":"Implement MR discussion ingestion module","description":"## Background\nMR discussion ingestion with critical atomicity guarantees. Parse notes BEFORE destructive DB operations to prevent data loss. Watermark ONLY advanced on full success.\n\n## Approach\nCreate `src/ingestion/mr_discussions.rs` with:\n1. `IngestMrDiscussionsResult` - Per-MR stats\n2. `ingest_mr_discussions()` - Main function with atomicity guarantees\n3. Upsert + sweep pattern for notes (not delete-all-then-insert)\n4. Sync health telemetry for debugging failures\n\n## Files\n- `src/ingestion/mr_discussions.rs` - New module\n- `tests/mr_discussion_ingestion_tests.rs` - Integration tests\n\n## Acceptance Criteria\n- [ ] `IngestMrDiscussionsResult` has: discussions_fetched, discussions_upserted, notes_upserted, notes_skipped_bad_timestamp, diffnotes_count, pagination_succeeded\n- [ ] `ingest_mr_discussions()` returns `Result`\n- [ ] CRITICAL: Notes parsed BEFORE any DELETE operations\n- [ ] CRITICAL: Watermark NOT advanced if `pagination_succeeded == false`\n- [ ] CRITICAL: Watermark NOT advanced if any note parse fails\n- [ ] Upsert + sweep pattern using `last_seen_at`\n- [ ] Stale discussions/notes removed only on full success\n- [ ] Selective raw payload storage (skip system notes without position)\n- [ ] Sync health telemetry recorded on failure\n- [ ] `does_not_advance_discussion_watermark_on_partial_failure` test passes\n- [ ] `atomic_note_replacement_preserves_data_on_parse_failure` test passes\n\n## TDD Loop\nRED: `cargo test does_not_advance_watermark` -> test fails\nGREEN: Add ingestion with atomicity guarantees\nVERIFY: `cargo test mr_discussion_ingestion`\n\n## Main Function\n```rust\npub async fn ingest_mr_discussions(\n conn: &Connection,\n client: &GitLabClient,\n config: &Config,\n project_id: i64,\n gitlab_project_id: i64,\n mr_iid: i64,\n local_mr_id: i64,\n mr_updated_at: i64,\n) -> Result\n```\n\n## CRITICAL: Atomic Note Replacement\n```rust\n// Record sync start time for sweep\nlet run_seen_at = now_ms();\n\nwhile let Some(discussion_result) = stream.next().await {\n let discussion = match discussion_result {\n Ok(d) => d,\n Err(e) => {\n result.pagination_succeeded = false;\n break; // Stop but don't advance watermark\n }\n };\n \n // CRITICAL: Parse BEFORE destructive operations\n let notes = match transform_notes_with_diff_position(&discussion, project_id) {\n Ok(notes) => notes,\n Err(e) => {\n warn!(\"Note transform failed; preserving existing notes\");\n result.notes_skipped_bad_timestamp += discussion.notes.len();\n result.pagination_succeeded = false;\n continue; // Skip this discussion, don't delete existing\n }\n };\n \n // Only NOW start transaction (after parse succeeded)\n let tx = conn.unchecked_transaction()?;\n \n // Upsert discussion with run_seen_at\n // Upsert notes with run_seen_at (not delete-all)\n \n tx.commit()?;\n}\n```\n\n## Stale Data Sweep (only on success)\n```rust\nif result.pagination_succeeded {\n // Sweep stale discussions\n conn.execute(\n \"DELETE FROM discussions\n WHERE project_id = ? AND merge_request_id = ?\n AND last_seen_at < ?\",\n params![project_id, local_mr_id, run_seen_at],\n )?;\n \n // Sweep stale notes\n conn.execute(\n \"DELETE FROM notes\n WHERE discussion_id IN (\n SELECT id FROM discussions\n WHERE project_id = ? AND merge_request_id = ?\n )\n AND last_seen_at < ?\",\n params![project_id, local_mr_id, run_seen_at],\n )?;\n}\n```\n\n## Watermark Update (ONLY on success)\n```rust\nif result.pagination_succeeded {\n mark_discussions_synced(conn, local_mr_id, mr_updated_at)?;\n clear_sync_health_error(conn, local_mr_id)?;\n} else {\n record_sync_health_error(conn, local_mr_id, \"Pagination incomplete or parse failure\")?;\n warn!(\"Watermark NOT advanced; will retry on next sync\");\n}\n```\n\n## Selective Payload Storage\n```rust\n// Only store payload for DiffNotes and non-system notes\nlet should_store_note_payload =\n !note.is_system() ||\n note.position_new_path().is_some() ||\n note.position_old_path().is_some();\n```\n\n## Integration Tests (CRITICAL)\n```rust\n#[tokio::test]\nasync fn does_not_advance_discussion_watermark_on_partial_failure() {\n // Setup: MR with updated_at > discussions_synced_for_updated_at\n // Mock: Page 1 returns OK, Page 2 returns 500\n // Assert: discussions_synced_for_updated_at unchanged\n}\n\n#[tokio::test]\nasync fn does_not_advance_discussion_watermark_on_note_parse_failure() {\n // Setup: Existing notes in DB\n // Mock: Discussion with note having invalid created_at\n // Assert: Original notes preserved, watermark unchanged\n}\n\n#[tokio::test]\nasync fn atomic_note_replacement_preserves_data_on_parse_failure() {\n // Setup: Discussion with 3 valid notes\n // Mock: Updated discussion where note 2 has bad timestamp\n // Assert: All 3 original notes still in DB\n}\n```\n\n## Edge Cases\n- HTTP error mid-pagination: preserve existing data, log error, no watermark advance\n- Invalid note timestamp: skip discussion, preserve existing notes\n- System notes without position: don't store raw payload (saves space)\n- Empty discussion: still upsert discussion record, no notes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-26T22:06:42.335714Z","created_by":"tayloreernisse","updated_at":"2026-01-27T00:22:43.207057Z","closed_at":"2026-01-27T00:22:43.206996Z","close_reason":"Implemented MR discussion ingestion module with full atomicity guarantees:\n- IngestMrDiscussionsResult with all required fields\n- parse-before-destructive pattern (transform notes before DB ops)\n- Upsert + sweep pattern with last_seen_at timestamps\n- Watermark advanced ONLY on full pagination success\n- Selective payload storage (skip system notes without position)\n- Sync health telemetry for failure debugging\n- All 163 tests passing","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-20h","depends_on_id":"bd-3ir","type":"blocks","created_at":"2026-01-26T22:08:54.649094Z","created_by":"tayloreernisse"},{"issue_id":"bd-20h","depends_on_id":"bd-3j6","type":"blocks","created_at":"2026-01-26T22:08:54.686066Z","created_by":"tayloreernisse"},{"issue_id":"bd-20h","depends_on_id":"bd-iba","type":"blocks","created_at":"2026-01-26T22:08:54.722746Z","created_by":"tayloreernisse"}]} {"id":"bd-221","title":"Create migration 008_fts5.sql","description":"## Background\nFTS5 (Full-Text Search 5) provides the lexical search backbone for Gate A. The virtual table + triggers keep the FTS index in sync with the documents table automatically. This migration must be applied AFTER migration 007 (documents table exists). The trigger design handles NULL titles via COALESCE and only rebuilds the FTS entry when searchable text actually changes (not metadata-only updates).\n\n## Approach\nCreate `migrations/008_fts5.sql` with the exact SQL from PRD Section 1.2:\n\n1. **Virtual table:** `documents_fts` using FTS5 with porter stemmer, prefix indexes (2,3,4), external content backed by `documents` table\n2. **Insert trigger:** `documents_ai` — inserts into FTS on document insert, uses COALESCE(title, '') for NULL safety\n3. **Delete trigger:** `documents_ad` — removes from FTS on document delete using the FTS5 delete command syntax\n4. **Update trigger:** `documents_au` — only fires when `title` or `content_text` changes (WHEN clause), performs delete-then-insert to update FTS\n\nRegister migration 8 in `src/core/db.rs` MIGRATIONS array.\n\n**Critical detail:** The COALESCE is required because FTS5 external-content tables require exact value matching for delete operations. If NULL was inserted, the delete trigger couldn't match it (NULL != NULL in SQL).\n\n## Acceptance Criteria\n- [ ] `migrations/008_fts5.sql` file exists\n- [ ] `documents_fts` virtual table created with `tokenize='porter unicode61'` and `prefix='2 3 4'`\n- [ ] `content='documents'` and `content_rowid='id'` set (external content mode)\n- [ ] Insert trigger `documents_ai` fires on document insert with COALESCE(title, '')\n- [ ] Delete trigger `documents_ad` fires on document delete using FTS5 delete command\n- [ ] Update trigger `documents_au` only fires when `old.title IS NOT new.title OR old.content_text != new.content_text`\n- [ ] Prefix search works: query `auth*` matches \"authentication\"\n- [ ] After bulk insert of N documents, `SELECT count(*) FROM documents_fts` returns N\n- [ ] Schema version 8 recorded in schema_version table\n- [ ] `cargo test migration_tests` passes\n\n## Files\n- `migrations/008_fts5.sql` — new file (copy exact SQL from PRD Section 1.2)\n- `src/core/db.rs` — add migration 8 to MIGRATIONS array\n\n## TDD Loop\nRED: Register migration in db.rs, `cargo test migration_tests` fails (SQL file missing)\nGREEN: Create `008_fts5.sql` with all triggers\nVERIFY: `cargo test migration_tests && cargo build`\n\n## Edge Cases\n- Metadata-only updates (e.g., changing `updated_at` or `labels_hash`) must NOT trigger FTS rebuild — the WHEN clause prevents this\n- NULL titles must use COALESCE to empty string in both insert and delete triggers\n- The update trigger does delete+insert (not FTS5 'delete' + regular insert atomically) — this is the correct FTS5 pattern for content changes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:25:25.763146Z","created_by":"tayloreernisse","updated_at":"2026-01-30T16:56:13.131830Z","closed_at":"2026-01-30T16:56:13.131771Z","close_reason":"Completed: migration 008_fts5.sql with FTS5 virtual table, 3 sync triggers (insert/delete/update with COALESCE NULL safety), prefix search, registered in db.rs, cargo build + tests pass","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-221","depends_on_id":"bd-hrs","type":"blocks","created_at":"2026-01-30T15:29:15.574576Z","created_by":"tayloreernisse"}]} {"id":"bd-227","title":"[CP1] gi count issues/discussions/notes commands","description":"Count entities in the database.\n\n## Module\nsrc/cli/commands/count.rs\n\n## Clap Definition\nCount {\n #[arg(value_parser = [\"issues\", \"mrs\", \"discussions\", \"notes\"])]\n entity: String,\n \n #[arg(long, value_parser = [\"issue\", \"mr\"])]\n r#type: Option,\n}\n\n## Commands\n- gi count issues → 'Issues: N'\n- gi count discussions → 'Discussions: N'\n- gi count discussions --type=issue → 'Issue Discussions: N'\n- gi count notes → 'Notes: N (excluding M system)'\n- gi count notes --type=issue → 'Issue Notes: N (excluding M system)'\n\n## Implementation\n- Simple COUNT(*) queries\n- For notes, also count WHERE is_system = 1 for system note count\n- Filter by noteable_type when --type specified\n\nFiles: src/cli/commands/count.rs\nDone when: Counts match expected values from GitLab","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T16:58:25.648805Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.920135Z","deleted_at":"2026-01-25T17:02:01.920129Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0} @@ -69,9 +69,9 @@ {"id":"bd-2cu","title":"[CP1] Discussion ingestion module","description":"Fetch and store discussions/notes for each issue.\n\n## Module\nsrc/ingestion/discussions.rs\n\n## Key Structs\n\n### IngestDiscussionsResult\n- discussions_fetched: usize\n- discussions_upserted: usize\n- notes_upserted: usize\n- system_notes_count: usize\n\n## Main Function\npub async fn ingest_issue_discussions(\n conn, client, config,\n project_id, gitlab_project_id,\n issue_iid, local_issue_id, issue_updated_at\n) -> Result\n\n## Logic\n1. Paginate through all discussions for given issue\n2. For each discussion:\n - Begin transaction\n - Store raw payload (compressed)\n - Transform and upsert discussion record with correct issue FK\n - Get local discussion ID\n - Transform notes from discussion\n - For each note:\n - Store raw payload\n - Upsert note with discussion_id FK\n - Count system notes\n - Commit transaction\n3. After all discussions synced: mark_discussions_synced(conn, local_issue_id, issue_updated_at)\n - UPDATE issues SET discussions_synced_for_updated_at = ? WHERE id = ?\n\n## Invariant\nA rerun MUST NOT refetch discussions for issues whose updated_at has not advanced, even with cursor rewind. The discussions_synced_for_updated_at watermark ensures this.\n\n## Helper Functions\n- upsert_discussion(conn, discussion, payload_id)\n- get_local_discussion_id(conn, project_id, gitlab_id) -> i64\n- upsert_note(conn, discussion_id, note, payload_id)\n- mark_discussions_synced(conn, issue_id, issue_updated_at)\n\nFiles: src/ingestion/discussions.rs\nTests: tests/discussion_watermark_tests.rs\nDone when: Discussions and notes populated with correct FKs, watermark prevents redundant refetch","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T16:57:36.703237Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.827880Z","deleted_at":"2026-01-25T17:02:01.827876Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-2dk","title":"Implement project resolution for --project filter","description":"## Background\nThe --project filter on search (and other commands) accepts a string that must be resolved to a project_id. Users may type the full path, a partial path, or just the project name. The resolution logic provides cascading match with helpful error messages when ambiguous. This improves ergonomics for multi-project installations.\n\n## Approach\nImplement project resolution function (location TBD — likely `src/core/project.rs` or inline in search filters):\n\n```rust\n/// Resolve a project string to a project_id using cascading match:\n/// 1. Exact match on path_with_namespace\n/// 2. Case-insensitive exact match\n/// 3. Suffix match (only if unambiguous)\n/// 4. Error with available projects list\npub fn resolve_project(conn: &Connection, project_str: &str) -> Result\n```\n\n**SQL queries:**\n```sql\n-- Step 1: exact match\nSELECT id FROM projects WHERE path_with_namespace = ?\n\n-- Step 2: case-insensitive\nSELECT id FROM projects WHERE LOWER(path_with_namespace) = LOWER(?)\n\n-- Step 3: suffix match\nSELECT id, path_with_namespace FROM projects\nWHERE path_with_namespace LIKE '%/' || ?\n OR path_with_namespace = ?\n\n-- Step 4: list all for error message\nSELECT path_with_namespace FROM projects ORDER BY path_with_namespace\n```\n\n**Error format:**\n```\nError: Project 'auth-service' not found.\n\nAvailable projects:\n backend/auth-service\n frontend/auth-service-ui\n infra/auth-proxy\n\nHint: Use the full path, e.g., --project=backend/auth-service\n```\n\nUses `LoreError::Ambiguous` variant for multiple suffix matches.\n\n## Acceptance Criteria\n- [ ] Exact match: \"group/project\" resolves correctly\n- [ ] Case-insensitive: \"Group/Project\" resolves to \"group/project\"\n- [ ] Suffix match: \"project-name\" resolves when only one \"*/project-name\" exists\n- [ ] Ambiguous suffix: error lists matching projects with hint\n- [ ] No match: error lists all available projects with hint\n- [ ] Empty projects table: clear error message\n- [ ] `cargo test project_resolution` passes\n\n## Files\n- `src/core/project.rs` — new file (or add to existing module)\n- `src/core/mod.rs` — add `pub mod project;`\n\n## TDD Loop\nRED: Tests:\n- `test_exact_match` — full path resolves\n- `test_case_insensitive` — mixed case resolves\n- `test_suffix_unambiguous` — short name resolves when unique\n- `test_suffix_ambiguous` — error with list when multiple match\n- `test_no_match` — error with available projects\nGREEN: Implement resolve_project\nVERIFY: `cargo test project_resolution`\n\n## Edge Cases\n- Project path containing special LIKE characters (%, _): unlikely but escape for safety\n- Single project in DB: suffix always unambiguous\n- Project path with multiple slashes: \"org/group/project\" — suffix match on \"project\" works","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-30T15:26:13.076571Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:39:17.197735Z","closed_at":"2026-01-30T17:39:17.197552Z","close_reason":"Implemented resolve_project() with cascading match (exact, CI, suffix) + 6 tests","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2dk","depends_on_id":"bd-3q2","type":"blocks","created_at":"2026-01-30T15:29:24.446650Z","created_by":"tayloreernisse"}]} {"id":"bd-2e8","title":"Add fetchResourceEvents config flag to SyncConfig","description":"## Background\nEvent fetching should be opt-in (default true) so users who don't need temporal queries skip 3 extra API calls per entity. This follows the existing SyncConfig pattern with serde defaults and camelCase JSON aliases.\n\n## Approach\nAdd to SyncConfig in src/core/config.rs:\n```rust\n#[serde(rename = \"fetchResourceEvents\", default = \"default_true\")]\npub fetch_resource_events: bool,\n```\n\nAdd default function (if not already present):\n```rust\nfn default_true() -> bool { true }\n```\n\nUpdate Default impl for SyncConfig to include `fetch_resource_events: true`.\n\nAdd --no-events flag to sync command in src/cli/mod.rs (SyncArgs):\n```rust\n/// Skip resource event fetching (overrides config)\n#[arg(long = \"no-events\", help_heading = \"Sync Options\")]\npub no_events: bool,\n```\n\nIn the sync command handler (src/cli/commands/sync.rs), override config when flag is set:\n```rust\nif args.no_events {\n config.sync.fetch_resource_events = false;\n}\n```\n\n## Acceptance Criteria\n- [ ] SyncConfig deserializes `fetchResourceEvents: false` from JSON config\n- [ ] SyncConfig defaults to `fetch_resource_events: true` when field absent\n- [ ] `--no-events` flag parses correctly in CLI\n- [ ] `--no-events` overrides config to false\n- [ ] `cargo test` passes with no regressions\n\n## Files\n- src/core/config.rs (add field to SyncConfig + default fn + Default impl)\n- src/cli/mod.rs (add --no-events to SyncArgs)\n- src/cli/commands/sync.rs (override config when flag set)\n\n## TDD Loop\nRED: tests/config_tests.rs (or inline in config.rs):\n- `test_sync_config_fetch_resource_events_default_true` - omit field from JSON, verify default\n- `test_sync_config_fetch_resource_events_explicit_false` - set field false, verify parsed\n- `test_sync_config_no_events_flag` - verify CLI arg parsing\n\nGREEN: Add the field, default fn, Default impl update, CLI flag, and override logic\n\nVERIFY: `cargo test config -- --nocapture && cargo build`\n\n## Edge Cases\n- Ensure serde rename matches camelCase convention used by all other SyncConfig fields\n- The default_true fn may already exist for other fields — check before adding duplicate\n- The --no-events flag must NOT be confused with --no-X negation flags already in CLI (check mod.rs for conflicts)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:31:24.006037Z","created_by":"tayloreernisse","updated_at":"2026-02-03T16:10:20.311986Z","closed_at":"2026-02-03T16:10:20.311939Z","close_reason":"Completed: Added fetch_resource_events bool to SyncConfig with serde rename, default_true, --no-events CLI flag, and config override in sync handler","compaction_level":0,"original_size":0,"labels":["config","gate-1","phase-b"],"dependencies":[{"issue_id":"bd-2e8","depends_on_id":"bd-2zl","type":"parent-child","created_at":"2026-02-02T21:31:24.010608Z","created_by":"tayloreernisse"}]} -{"id":"bd-2ez","title":"Add 'lore count references' command","description":"## Background\n\nThe count command currently supports issues, mrs, discussions, notes, and events. This bead adds 'references' as a new entity type, showing total cross-references and breakdowns by reference_type and source_method. This helps users understand the density and quality of their cross-reference data.\n\n## Approach\n\n### 1. Add to CountArgs value_parser in \\`src/cli/mod.rs\\`\n\n```rust\n#[arg(value_parser = [\"issues\", \"mrs\", \"discussions\", \"notes\", \"events\", \"references\"])]\npub entity: String,\n```\n\n### 2. Add count_references function in \\`src/cli/commands/count.rs\\`\n\n```rust\nfn count_references(conn: &Connection) -> Result {\n // This doesn't fit the existing CountResult shape well.\n // Create a new ReferenceCountResult or extend CountResult.\n}\n\npub struct ReferenceCountResult {\n pub total: i64,\n pub by_type: ReferenceTypeBreakdown,\n pub by_method: ReferenceMethodBreakdown,\n pub unresolved: i64,\n}\n\npub struct ReferenceTypeBreakdown {\n pub closes: i64,\n pub mentioned: i64,\n pub related: i64,\n}\n\npub struct ReferenceMethodBreakdown {\n pub api: i64,\n pub note_parse: i64,\n pub description_parse: i64,\n}\n```\n\n### 3. SQL Query\n\n```sql\nSELECT\n COUNT(*) as total,\n COALESCE(SUM(CASE WHEN reference_type = 'closes' THEN 1 ELSE 0 END), 0) as closes,\n COALESCE(SUM(CASE WHEN reference_type = 'mentioned' THEN 1 ELSE 0 END), 0) as mentioned,\n COALESCE(SUM(CASE WHEN reference_type = 'related' THEN 1 ELSE 0 END), 0) as related,\n COALESCE(SUM(CASE WHEN source_method = 'api' THEN 1 ELSE 0 END), 0) as api,\n COALESCE(SUM(CASE WHEN source_method = 'note_parse' THEN 1 ELSE 0 END), 0) as note_parse,\n COALESCE(SUM(CASE WHEN source_method = 'description_parse' THEN 1 ELSE 0 END), 0) as desc_parse,\n COALESCE(SUM(CASE WHEN target_entity_id IS NULL THEN 1 ELSE 0 END), 0) as unresolved\nFROM entity_references\n```\n\n### 4. Human Output\n\n```\nReferences: 1,234\n By type:\n closes: 456\n mentioned: 678\n related: 100\n By source:\n api: 234\n note_parse: 890\n description_parse: 110\n Unresolved: 45 (3.6%)\n```\n\n### 5. Robot JSON\n\n```json\n{\n \"ok\": true,\n \"data\": {\n \"entity\": \"references\",\n \"total\": 1234,\n \"by_type\": { \"closes\": 456, \"mentioned\": 678, \"related\": 100 },\n \"by_method\": { \"api\": 234, \"note_parse\": 890, \"description_parse\": 110 },\n \"unresolved\": 45\n }\n}\n```\n\n### 6. Wire in run_count and handle_count\n\nAdd a new branch in \\`run_count()\\` match:\n```rust\n\"references\" => count_references(&conn),\n```\n\nSince ReferenceCountResult has a different shape than CountResult, either:\n- Use an enum \\`CountOutput { Standard(CountResult), References(ReferenceCountResult) }\\`\n- Or make count_references return its own type and handle it separately in handle_count\n\n## Acceptance Criteria\n\n- [ ] \\`lore count references\\` works with human output\n- [ ] \\`lore --robot count references\\` works with JSON output\n- [ ] by_type breakdown sums to total\n- [ ] by_method breakdown sums to total\n- [ ] Unresolved count matches \\`WHERE target_entity_id IS NULL\\`\n- [ ] Zero references: all counts are 0 (not error)\n- [ ] \\`cargo check --all-targets\\` passes\n- [ ] \\`cargo clippy --all-targets -- -D warnings\\` passes\n\n## Files\n\n- \\`src/cli/mod.rs\\` (add \"references\" to CountArgs value_parser)\n- \\`src/cli/commands/count.rs\\` (add count_references + output functions + ReferenceCountResult)\n- \\`src/main.rs\\` (handle the references branch in handle_count)\n\n## TDD Loop\n\nRED: Add test in count.rs:\n- \\`test_count_references_query\\` - verify SQL returns correct structure (needs in-memory DB with migration 011)\n\nGREEN: Implement the query, result type, and output functions.\n\nVERIFY: \\`cargo test --lib -- count && cargo check --all-targets\\`\n\n## Edge Cases\n\n- entity_references table doesn't exist (old schema): handle with migration check or graceful SQL error\n- All references unresolved: unresolved count = total\n- New source_method values added in future: \"other\" bucket or explicit error","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-02T22:42:43.780303Z","created_by":"tayloreernisse","updated_at":"2026-02-05T18:56:37.554571Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2ez","depends_on_id":"bd-1se","type":"parent-child","created_at":"2026-02-02T22:43:40.652558Z","created_by":"tayloreernisse"},{"issue_id":"bd-2ez","depends_on_id":"bd-hu3","type":"blocks","created_at":"2026-02-02T22:43:33.877742Z","created_by":"tayloreernisse"}]} +{"id":"bd-2ez","title":"Add 'lore count references' command","description":"## Background\n\nThe count command currently supports issues, mrs, discussions, notes, and events. This bead adds 'references' as a new entity type, showing total cross-references and breakdowns by reference_type and source_method. This helps users understand the density and quality of their cross-reference data.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 2.5 AC: \"source_method column tracks provenance of each reference.\"\n\n**IMPORTANT: source_method value discrepancy.** The spec (Section 2.2) defines source_method values as `'api_closes_issues' | 'api_state_event' | 'system_note_parse'`. However, the implemented migration 011 uses simpler values: `'api' | 'note_parse' | 'description_parse'` (see `migrations/011_resource_events.sql` line 86). The CHECK constraint is already applied. **Use the codebase values**, not the spec values.\n\n## Approach\n\n### 1. Add to CountArgs value_parser in `src/cli/mod.rs`\n\n```rust\n#[arg(value_parser = [\"issues\", \"mrs\", \"discussions\", \"notes\", \"events\", \"references\"])]\npub entity: String,\n```\n\n### 2. Add count_references function in `src/cli/commands/count.rs`\n\n```rust\npub struct ReferenceCountResult {\n pub total: i64,\n pub by_type: ReferenceTypeBreakdown,\n pub by_method: ReferenceMethodBreakdown,\n pub unresolved: i64,\n}\n\npub struct ReferenceTypeBreakdown {\n pub closes: i64,\n pub mentioned: i64,\n pub related: i64,\n}\n\npub struct ReferenceMethodBreakdown {\n pub api: i64,\n pub note_parse: i64,\n pub description_parse: i64,\n}\n```\n\n### 3. SQL Query\n\n```sql\nSELECT\n COUNT(*) as total,\n COALESCE(SUM(CASE WHEN reference_type = 'closes' THEN 1 ELSE 0 END), 0) as closes,\n COALESCE(SUM(CASE WHEN reference_type = 'mentioned' THEN 1 ELSE 0 END), 0) as mentioned,\n COALESCE(SUM(CASE WHEN reference_type = 'related' THEN 1 ELSE 0 END), 0) as related,\n COALESCE(SUM(CASE WHEN source_method = 'api' THEN 1 ELSE 0 END), 0) as api,\n COALESCE(SUM(CASE WHEN source_method = 'note_parse' THEN 1 ELSE 0 END), 0) as note_parse,\n COALESCE(SUM(CASE WHEN source_method = 'description_parse' THEN 1 ELSE 0 END), 0) as desc_parse,\n COALESCE(SUM(CASE WHEN target_entity_id IS NULL THEN 1 ELSE 0 END), 0) as unresolved\nFROM entity_references\n```\n\n### 4. Human Output\n\n```\nReferences: 1,234\n By type:\n closes: 456\n mentioned: 678\n related: 100\n By source:\n api: 234\n note_parse: 890\n description_parse: 110\n Unresolved: 45 (3.6%)\n```\n\n### 5. Robot JSON\n\n```json\n{\n \"ok\": true,\n \"data\": {\n \"entity\": \"references\",\n \"total\": 1234,\n \"by_type\": { \"closes\": 456, \"mentioned\": 678, \"related\": 100 },\n \"by_method\": { \"api\": 234, \"note_parse\": 890, \"description_parse\": 110 },\n \"unresolved\": 45\n }\n}\n```\n\n### 6. Wire in run_count and handle_count\n\nAdd a new branch in `run_count()` match:\n```rust\n\"references\" => count_references(&conn),\n```\n\nSince ReferenceCountResult has a different shape than CountResult, either:\n- Use an enum `CountOutput { Standard(CountResult), References(ReferenceCountResult) }`\n- Or make count_references return its own type and handle it separately in handle_count\n\n## Acceptance Criteria\n\n- [ ] `lore count references` works with human output\n- [ ] `lore --robot count references` works with JSON output\n- [ ] by_type breakdown matches reference_type column values: closes, mentioned, related\n- [ ] by_method breakdown matches source_method column values: api, note_parse, description_parse (codebase values, NOT spec values)\n- [ ] by_type breakdown sums to total\n- [ ] by_method breakdown sums to total\n- [ ] Unresolved count matches `WHERE target_entity_id IS NULL`\n- [ ] Zero references: all counts are 0 (not error)\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/cli/mod.rs` (add \"references\" to CountArgs value_parser)\n- `src/cli/commands/count.rs` (add count_references + output functions + ReferenceCountResult)\n- `src/main.rs` (handle the references branch in handle_count)\n\n## TDD Loop\n\nRED: Add test in count.rs:\n- `test_count_references_query` - verify SQL returns correct structure (needs in-memory DB with migration 011)\n\nGREEN: Implement the query, result type, and output functions.\n\nVERIFY: `cargo test --lib -- count && cargo check --all-targets`\n\n## Edge Cases\n\n- entity_references table doesn't exist (old schema): handle with migration check or graceful SQL error\n- All references unresolved: unresolved count = total\n- New source_method values added in future: consider \"other\" bucket or explicit error","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-02T22:42:43.780303Z","created_by":"tayloreernisse","updated_at":"2026-02-05T19:12:46.312791Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2ez","depends_on_id":"bd-1se","type":"parent-child","created_at":"2026-02-02T22:43:40.652558Z","created_by":"tayloreernisse"},{"issue_id":"bd-2ez","depends_on_id":"bd-hu3","type":"blocks","created_at":"2026-02-02T22:43:33.877742Z","created_by":"tayloreernisse"}]} {"id":"bd-2f0","title":"[CP1] gi count issues/discussions/notes commands","description":"## Background\n\nThe `gi count` command provides quick counts of entities in the local database. It supports counting issues, MRs, discussions, and notes, with optional filtering by noteable type. This enables quick validation that sync is working correctly.\n\n## Approach\n\n### Module: src/cli/commands/count.rs\n\n### Clap Definition\n\n```rust\n#[derive(Args)]\npub struct CountArgs {\n /// Entity type to count\n #[arg(value_parser = [\"issues\", \"mrs\", \"discussions\", \"notes\"])]\n pub entity: String,\n\n /// Filter by noteable type (for discussions/notes)\n #[arg(long, value_parser = [\"issue\", \"mr\"])]\n pub r#type: Option,\n}\n```\n\n### Handler Function\n\n```rust\npub async fn handle_count(args: CountArgs, conn: &Connection) -> Result<()>\n```\n\n### Queries by Entity\n\n**issues:**\n```sql\nSELECT COUNT(*) FROM issues\n```\nOutput: `Issues: 3,801`\n\n**discussions:**\n```sql\n-- Without type filter\nSELECT COUNT(*) FROM discussions\n\n-- With --type=issue\nSELECT COUNT(*) FROM discussions WHERE noteable_type = 'Issue'\n```\nOutput: `Issue Discussions: 1,234`\n\n**notes:**\n```sql\n-- Total and system count\nSELECT COUNT(*), SUM(is_system) FROM notes\n\n-- With --type=issue (join through discussions)\nSELECT COUNT(*), SUM(n.is_system)\nFROM notes n\nJOIN discussions d ON n.discussion_id = d.id\nWHERE d.noteable_type = 'Issue'\n```\nOutput: `Issue Notes: 5,678 (excluding 1,234 system)`\n\n### Output Format\n\n```\nIssues: 3,801\n```\n\n```\nIssue Discussions: 1,234\n```\n\n```\nIssue Notes: 5,678 (excluding 1,234 system)\n```\n\n## Acceptance Criteria\n\n- [ ] `gi count issues` shows total issue count\n- [ ] `gi count discussions` shows total discussion count\n- [ ] `gi count discussions --type=issue` filters to issue discussions\n- [ ] `gi count notes` shows total note count with system note exclusion\n- [ ] `gi count notes --type=issue` filters to issue notes\n- [ ] Numbers formatted with thousands separators (1,234)\n\n## Files\n\n- src/cli/commands/mod.rs (add `pub mod count;`)\n- src/cli/commands/count.rs (create)\n- src/cli/mod.rs (add Count variant to Commands enum)\n\n## TDD Loop\n\nRED:\n```rust\n#[tokio::test] async fn count_issues_returns_total()\n#[tokio::test] async fn count_discussions_with_type_filter()\n#[tokio::test] async fn count_notes_excludes_system_notes()\n```\n\nGREEN: Implement handler with queries\n\nVERIFY: `cargo test count`\n\n## Edge Cases\n\n- Zero entities - show \"Issues: 0\"\n- --type flag invalid for issues/mrs - ignore or error\n- All notes are system notes - show \"Notes: 0 (excluding 1,234 system)\"","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-25T17:02:38.360495Z","created_by":"tayloreernisse","updated_at":"2026-01-25T23:01:37.084627Z","closed_at":"2026-01-25T23:01:37.084568Z","close_reason":"Implemented gi count command with issues/discussions/notes support, format_number helper, and system note exclusion","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2f0","depends_on_id":"bd-208","type":"blocks","created_at":"2026-01-25T17:04:05.677181Z","created_by":"tayloreernisse"}]} -{"id":"bd-2f2","title":"Implement timeline human output renderer","description":"## Background\n\nThe human output renderer for timeline produces a vertically-oriented, colored timeline in the terminal. This follows the existing pattern from show.rs where human output uses console::style() for coloring and comfy-table or manual formatting for layout.\n\n## Approach\n\nCreate `print_timeline()` function in `src/cli/commands/timeline.rs` (or a dedicated `timeline_output.rs`).\n\n### Output Format\n\n```\nTimeline: \"authentication\" (47 events, 12 entities)\n\n2024-01-15 CREATED issue #123 \"Add OAuth2 support\" @alice\n2024-01-16 LABEL+ issue #123 + priority::high @bob\n2024-01-18 CREATED mr !456 \"Implement OAuth2 flow\" @alice\n2024-01-19 NOTE mr !456 \"We should use PKCE here because...\" @carol\n [expanded via closes -> issue #123]\n2024-01-22 STATE mr !456 merged @dave\n2024-01-22 STATE issue #123 closed @dave\n```\n\n### Color Scheme\n\n| Event Type | Color | Tag |\n|------------|-------|-----|\n| Created | green | CREATED |\n| StateChanged | yellow (close), green (open/merge) | STATE |\n| LabelAdded | cyan | LABEL+ |\n| LabelRemoved | dim cyan | LABEL- |\n| MilestoneSet | magenta | MILE+ |\n| MilestoneRemoved | dim magenta | MILE- |\n| Merged | bright green bold | MERGED |\n| NoteEvidence | white | NOTE |\n| CrossReferenced | blue | XREF |\n\n### Evidence Notes\n\nEvidence notes (NoteEvidence) show the snippet as an indented block quote:\n```\n2024-01-19 NOTE mr !456 \"We should use PKCE here because...\" @carol\n```\n\n### Expanded Entity Markers\n\nEvents from expanded (non-seed) entities show provenance:\n```\n [expanded via closes -> issue #123]\n```\n\n### Implementation Pattern\n\nFollow the existing `print_show_issue()` pattern from `src/cli/commands/show.rs`:\n- Use `console::style()` for colors\n- Use fixed-width columns for alignment\n- Truncate long titles to terminal width\n- Use `core::time::ms_to_iso()` for date formatting (show date only, not time, for readability)\n\n## Acceptance Criteria\n\n- [ ] Events display in chronological order with date, tag, entity ref, summary, actor\n- [ ] Each event type has the correct color per the color scheme\n- [ ] Evidence note snippets are displayed as quoted text\n- [ ] Expanded entity events show provenance annotation\n- [ ] Long titles truncate to fit terminal width (use `console::Term::size()`)\n- [ ] Empty results print \"No events found matching ''\"\n- [ ] Function signature: `pub fn print_timeline(result: &TimelineResult)`\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/cli/commands/timeline.rs` (NEW -- or add to an existing output file)\n- `src/cli/commands/mod.rs` (re-export print_timeline)\n\n## TDD Loop\n\nRED: No unit tests for display formatting -- this is visual output. Verify by:\n- Running `lore timeline \"test\"` against a synced database\n- Checking that colors render correctly in a TTY\n- Checking that output is readable in a non-TTY (no ANSI codes when piped)\n\nGREEN: Implement the column formatting and color logic.\n\nVERIFY: `cargo check --all-targets && cargo clippy --all-targets -- -D warnings`\n\n## Edge Cases\n\n- Terminal width < 80: truncate title column more aggressively\n- Events with no actor: show empty space (not \"None\" or \"unknown\")\n- Very long evidence snippets: already truncated to 200 chars at TimelineEvent level\n- Unicode in titles/actors: use `console::measure_text_width()` for correct column alignment","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-02T21:33:28.326026Z","created_by":"tayloreernisse","updated_at":"2026-02-05T18:51:17.099593Z","compaction_level":0,"original_size":0,"labels":["cli","gate-3","phase-b"],"dependencies":[{"issue_id":"bd-2f2","depends_on_id":"bd-3as","type":"blocks","created_at":"2026-02-02T21:33:37.659719Z","created_by":"tayloreernisse"},{"issue_id":"bd-2f2","depends_on_id":"bd-ike","type":"parent-child","created_at":"2026-02-02T21:33:28.329132Z","created_by":"tayloreernisse"}]} +{"id":"bd-2f2","title":"Implement timeline human output renderer","description":"## Background\n\nThe human output renderer for timeline produces a vertically-oriented, colored timeline in the terminal. This follows the existing pattern from show.rs where human output uses console::style() for coloring.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.4 (Human Output Format).\n\n## Approach\n\nCreate `print_timeline()` function in `src/cli/commands/timeline.rs`.\n\n### Output Format (from spec Section 3.4)\n\n```\nlore timeline \"auth migration\"\n\nTimeline: \"auth migration\" (12 events across 4 entities)\n───────────────────────────────────────────────────────\n\n2024-03-15 CREATED #234 Migrate to OAuth2 @alice\n Labels: ~auth, ~breaking-change\n2024-03-18 CREATED !567 feat: add OAuth2 provider @bob\n References: #234\n2024-03-20 NOTE #234 \"Should we support SAML too? I think @charlie\n we should stick with OAuth2 for now...\"\n2024-03-22 LABEL !567 added ~security-review @alice\n2024-03-24 NOTE !567 [src/auth/oauth.rs:45] @dave\n \"Consider refresh token rotation to\n prevent session fixation attacks\"\n2024-03-25 MERGED !567 feat: add OAuth2 provider @alice\n2024-03-26 CLOSED #234 closed by !567 @alice\n2024-03-28 CREATED #299 OAuth2 login fails for SSO users @dave [expanded]\n (via !567, closes)\n\n───────────────────────────────────────────────────────\nSeed entities: #234, !567 | Expanded: #299 (depth 1, via !567)\n```\n\nKey formatting rules from spec:\n- Entity refs use compact notation: `#234` for issues, `!567` for MRs (not `issue #234`)\n- Expanded entities show `[expanded]` inline with provenance `(via !567, closes)` below\n- Evidence notes show quoted text with author\n- Footer shows seed vs expanded entity summary\n\n### Color Scheme\n\n| Event Type | Color | Tag |\n|------------|-------|-----|\n| Created | green | CREATED |\n| StateChanged (close) | yellow | CLOSED |\n| StateChanged (reopen) | green | REOPENED |\n| StateChanged (lock) | dim | LOCKED |\n| LabelAdded | cyan | LABEL |\n| LabelRemoved | dim cyan | LABEL |\n| MilestoneSet | magenta | MILESTONE |\n| MilestoneRemoved | dim magenta | MILESTONE |\n| Merged | bright green bold | MERGED |\n| NoteEvidence | white | NOTE |\n| CrossReferenced | blue | XREF |\n\n### Evidence Notes\n\nEvidence notes (NoteEvidence) show the snippet as quoted text per spec Section 3.4:\n```\n2024-03-20 NOTE #234 \"Should we support SAML too?...\" @charlie\n```\n\n### Implementation Pattern\n\nFollow the existing `print_show_issue()` pattern from `src/cli/commands/show.rs`:\n- Use `console::style()` for colors\n- Use fixed-width columns for alignment\n- Truncate long titles to terminal width\n- Use `core::time::ms_to_iso()` for date formatting (date only, not time, for readability)\n\n## Acceptance Criteria\n\n- [ ] Events display in chronological order with date, tag, entity ref, summary, actor\n- [ ] Entity refs use compact notation: `#iid` for issues, `!iid` for MRs (spec Section 3.4)\n- [ ] Each event type has the correct color per the color scheme\n- [ ] Evidence note snippets are displayed as quoted text\n- [ ] Expanded entity events show `[expanded]` marker with `(via !iid, edge_type)` provenance (spec)\n- [ ] Header line shows event count and entity count\n- [ ] Footer shows seed vs expanded entity summary (spec Section 3.4)\n- [ ] Long titles truncate to fit terminal width (use `console::Term::size()`)\n- [ ] Empty results print \"No events found matching ''\"\n- [ ] Function signature: `pub fn print_timeline(result: &TimelineResult)`\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/cli/commands/timeline.rs` (NEW)\n- `src/cli/commands/mod.rs` (re-export print_timeline)\n\n## TDD Loop\n\nRED: No unit tests for display formatting -- this is visual output. Verify by:\n- Running `lore timeline \"test\"` against a synced database\n- Checking that colors render correctly in a TTY\n- Checking that output is readable in a non-TTY (no ANSI codes when piped)\n\nGREEN: Implement the column formatting and color logic.\n\nVERIFY: `cargo check --all-targets && cargo clippy --all-targets -- -D warnings`\n\n## Edge Cases\n\n- Terminal width < 80: truncate title column more aggressively\n- Events with no actor: show empty space (not \"None\" or \"unknown\")\n- Very long evidence snippets: already truncated to 200 chars at TimelineEvent level\n- Unicode in titles/actors: use `console::measure_text_width()` for correct column alignment\n- StateChanged event: use state value as tag (CLOSED, REOPENED, MERGED, LOCKED)","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-02T21:33:28.326026Z","created_by":"tayloreernisse","updated_at":"2026-02-05T19:10:11.521068Z","compaction_level":0,"original_size":0,"labels":["cli","gate-3","phase-b"],"dependencies":[{"issue_id":"bd-2f2","depends_on_id":"bd-3as","type":"blocks","created_at":"2026-02-02T21:33:37.659719Z","created_by":"tayloreernisse"},{"issue_id":"bd-2f2","depends_on_id":"bd-ike","type":"parent-child","created_at":"2026-02-02T21:33:28.329132Z","created_by":"tayloreernisse"}]} {"id":"bd-2fc","title":"Update AGENTS.md and CLAUDE.md with Phase B commands","description":"## Background\n\nAfter all Phase B commands are implemented, AGENTS.md and the global CLAUDE.md need to document the new temporal intelligence commands so agents can discover and use them without reading robot-docs.\n\n## Approach\n\n### 1. Update AGENTS.md\n\nAdd a new section after the existing Robot Mode Commands section:\n\n```markdown\n### Temporal Intelligence Commands\n\n\\`\\`\\`bash\n# Timeline: chronological event narrative for a keyword\nlore --robot timeline \"authentication\" --since 30d\nlore --robot timeline \"deployment\" --depth 2 --expand-mentions\n\n# File History: which MRs touched a file, with rename tracking\nlore --robot file-history src/auth/oauth.rs --discussions\nlore --robot file-history src/old_name.rs # follows renames automatically\n\n# Trace: decision chain from file -> MR -> issue -> discussions\nlore --robot trace src/auth/oauth.rs --discussions\nlore --robot trace src/auth/oauth.rs:45 # line hint (Tier 2 warning)\n\n# Count cross-references\nlore --robot count references\n\\`\\`\\`\n```\n\n### 2. Update CLAUDE.md (global)\n\nAdd the same commands to the Gitlore section in ~/.claude/CLAUDE.md, under the existing Commands section.\n\n## Acceptance Criteria\n\n- [ ] AGENTS.md has \"Temporal Intelligence Commands\" section\n- [ ] CLAUDE.md has matching section in the Gitlore block\n- [ ] All command examples are valid and runnable\n- [ ] No stale/outdated references to old command names\n- [ ] Examples cover all flags: --since, --depth, --expand-mentions, --discussions, -n, -p\n\n## Files\n\n- `AGENTS.md` (add section)\n- `~/.claude/CLAUDE.md` (add section in Gitlore block)\n\n## TDD Loop\n\nN/A - documentation only. Verify by reading the files after update.\n\n## Edge Cases\n\n- Don't duplicate the full robot-docs manifest; keep it concise\n- Reference robot-docs for the authoritative flag list\n- Mention that timeline requires synced resource events (--no-events disables them)","status":"open","priority":4,"issue_type":"task","created_at":"2026-02-02T22:43:22.090741Z","created_by":"tayloreernisse","updated_at":"2026-02-05T18:53:32.480490Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2fc","depends_on_id":"bd-1ht","type":"parent-child","created_at":"2026-02-02T22:43:40.829848Z","created_by":"tayloreernisse"},{"issue_id":"bd-2fc","depends_on_id":"bd-1v8","type":"blocks","created_at":"2026-02-02T22:43:34.047898Z","created_by":"tayloreernisse"}]} {"id":"bd-2fm","title":"Add GitLab Resource Event serde types","description":"## Background\nNeed Rust types for deserializing GitLab Resource Events API responses. These map directly to the API JSON shape from three endpoints: resource_state_events, resource_label_events, resource_milestone_events.\n\nExisting pattern: types.rs uses #[derive(Debug, Clone, Deserialize)] with Option for nullable fields. GitLabAuthor is already defined (id, username, name). Tests in tests/gitlab_types_tests.rs use serde_json::from_str with sample payloads.\n\n## Approach\nAdd to src/gitlab/types.rs (after existing types):\n\n```rust\n/// Reference to an MR in state event's source_merge_request field\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct GitLabMergeRequestRef {\n pub iid: i64,\n pub title: Option,\n pub web_url: Option,\n}\n\n/// Reference to a label in label event's label field\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct GitLabLabelRef {\n pub id: i64,\n pub name: String,\n pub color: Option,\n pub description: Option,\n}\n\n/// Reference to a milestone in milestone event's milestone field\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct GitLabMilestoneRef {\n pub id: i64,\n pub iid: i64,\n pub title: String,\n}\n\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct GitLabStateEvent {\n pub id: i64,\n pub user: Option,\n pub created_at: String,\n pub resource_type: String, // \"Issue\" | \"MergeRequest\"\n pub resource_id: i64,\n pub state: String, // \"opened\" | \"closed\" | \"reopened\" | \"merged\" | \"locked\"\n pub source_commit: Option,\n pub source_merge_request: Option,\n}\n\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct GitLabLabelEvent {\n pub id: i64,\n pub user: Option,\n pub created_at: String,\n pub resource_type: String,\n pub resource_id: i64,\n pub label: GitLabLabelRef,\n pub action: String, // \"add\" | \"remove\"\n}\n\n#[derive(Debug, Clone, Deserialize, Serialize)]\npub struct GitLabMilestoneEvent {\n pub id: i64,\n pub user: Option,\n pub created_at: String,\n pub resource_type: String,\n pub resource_id: i64,\n pub milestone: GitLabMilestoneRef,\n pub action: String, // \"add\" | \"remove\"\n}\n```\n\nAlso export from src/gitlab/mod.rs if needed.\n\n## Acceptance Criteria\n- [ ] All 6 types (3 events + 3 refs) compile\n- [ ] GitLabStateEvent deserializes from real GitLab API JSON (with and without source_merge_request)\n- [ ] GitLabLabelEvent deserializes with nested label object\n- [ ] GitLabMilestoneEvent deserializes with nested milestone object\n- [ ] All Optional fields handle null/missing correctly\n- [ ] Types exported from lore::gitlab::types\n\n## Files\n- src/gitlab/types.rs (add 6 new types)\n- tests/gitlab_types_tests.rs (add deserialization tests)\n\n## TDD Loop\nRED: Add to tests/gitlab_types_tests.rs:\n- `test_deserialize_state_event_closed_by_mr` - JSON with source_merge_request present\n- `test_deserialize_state_event_simple` - JSON with source_merge_request null, user null\n- `test_deserialize_label_event_add` - label add with full label object\n- `test_deserialize_label_event_remove` - label remove\n- `test_deserialize_milestone_event` - milestone add with nested milestone\nImport new types: `use lore::gitlab::types::{GitLabStateEvent, GitLabLabelEvent, GitLabMilestoneEvent, GitLabMergeRequestRef, GitLabLabelRef, GitLabMilestoneRef};`\n\nGREEN: Add the type definitions to types.rs\n\nVERIFY: `cargo test gitlab_types_tests -- --nocapture`\n\n## Edge Cases\n- GitLab sometimes returns user: null for system-generated events (e.g., auto-close on merge) — user must be Option\n- source_merge_request can be null even when state is \"closed\" (manually closed, not by MR)\n- label.color may be null for labels created via API without color\n- The resource_type field uses PascalCase (\"MergeRequest\" not \"merge_request\") — don't confuse with DB entity_type","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:31:24.081234Z","created_by":"tayloreernisse","updated_at":"2026-02-03T16:10:20.253407Z","closed_at":"2026-02-03T16:10:20.253344Z","close_reason":"Completed: Added 6 new types (GitLabMergeRequestRef, GitLabLabelRef, GitLabMilestoneRef, GitLabStateEvent, GitLabLabelEvent, GitLabMilestoneEvent) to types.rs with exports and 8 passing tests","compaction_level":0,"original_size":0,"labels":["gate-1","phase-b","types"],"dependencies":[{"issue_id":"bd-2fm","depends_on_id":"bd-2zl","type":"parent-child","created_at":"2026-02-02T21:31:24.085809Z","created_by":"tayloreernisse"}]} {"id":"bd-2fp","title":"Implement discussion document extraction","description":"## Background\nDiscussion documents are the most complex extraction — they involve querying discussions + notes + parent entity (issue or MR) + parent labels + DiffNote file paths. The output includes a threaded conversation format with author/date prefixes per note. System notes (bot-generated) are excluded. DiffNote paths are extracted for the --path search filter.\n\n## Approach\nImplement `extract_discussion_document()` in `src/documents/extractor.rs`:\n\n```rust\n/// Extract a searchable document from a discussion thread.\n/// Returns None if the discussion or its parent has been deleted.\npub fn extract_discussion_document(conn: &Connection, discussion_id: i64) -> Result>\n```\n\n**SQL queries (from PRD Section 2.2):**\n```sql\n-- Discussion metadata\nSELECT d.id, d.noteable_type, d.issue_id, d.merge_request_id,\n p.path_with_namespace, p.id AS project_id\nFROM discussions d\nJOIN projects p ON p.id = d.project_id\nWHERE d.id = ?\n\n-- Parent entity (conditional on noteable_type)\n-- If Issue: SELECT i.iid, i.title, i.web_url FROM issues i WHERE i.id = ?\n-- If MR: SELECT m.iid, m.title, m.web_url FROM merge_requests m WHERE m.id = ?\n\n-- Parent labels (via issue_labels or mr_labels junction)\n\n-- Non-system notes in thread order\nSELECT n.author_username, n.body, n.created_at, n.gitlab_id,\n n.note_type, n.position_old_path, n.position_new_path\nFROM notes n\nWHERE n.discussion_id = ? AND n.is_system = 0\nORDER BY n.created_at ASC, n.id ASC\n```\n\n**Document format:**\n```\n[[Discussion]] Issue #234: Authentication redesign\nProject: group/project-one\nURL: https://gitlab.example.com/group/project-one/-/issues/234#note_12345\nLabels: [\"bug\", \"auth\"]\nFiles: [\"src/auth/login.ts\"]\n\n--- Thread ---\n\n@johndoe (2024-03-15):\nI think we should move to JWT-based auth...\n\n@janedoe (2024-03-15):\nAgreed. What about refresh token strategy?\n```\n\n**Implementation steps:**\n1. Query discussion row — if not found, return Ok(None)\n2. Determine parent type (Issue or MR) from noteable_type\n3. Query parent entity for iid, title, web_url — if not found, return Ok(None)\n4. Query parent labels via appropriate junction table\n5. Query non-system notes ordered by created_at ASC, id ASC\n6. Extract DiffNote paths: collect position_old_path and position_new_path, dedup\n7. Construct URL: `{parent_web_url}#note_{first_note_gitlab_id}`\n8. Format header with [[Discussion]] prefix\n9. Format thread body: `@author (YYYY-MM-DD):\\nbody\\n\\n` per note\n10. Apply discussion truncation via `truncate_discussion()` if needed\n11. Author = first non-system note's author_username\n12. Compute hashes, return DocumentData\n\n## Acceptance Criteria\n- [ ] System notes (is_system=1) excluded from content\n- [ ] DiffNote paths extracted from position_old_path and position_new_path\n- [ ] Paths deduplicated and sorted\n- [ ] URL constructed as `parent_web_url#note_GITLAB_ID`\n- [ ] Header uses parent entity type: \"Issue #N\" or \"MR !N\"\n- [ ] Parent title included in header\n- [ ] Labels come from PARENT entity (not the discussion itself)\n- [ ] First non-system note author used as document author\n- [ ] Thread formatted with `@author (date):` per note\n- [ ] Truncation applied for long threads via truncate_discussion()\n- [ ] `cargo test extract_discussion` passes\n\n## Files\n- `src/documents/extractor.rs` — implement `extract_discussion_document()`\n\n## TDD Loop\nRED: Tests in `#[cfg(test)] mod tests`:\n- `test_discussion_document_format` — verify header + thread format\n- `test_discussion_not_found` — returns Ok(None)\n- `test_discussion_parent_deleted` — returns Ok(None) when parent issue/MR missing\n- `test_discussion_system_notes_excluded` — system notes not in content\n- `test_discussion_diffnote_paths` — old_path + new_path extracted and deduped\n- `test_discussion_url_construction` — URL has #note_GITLAB_ID anchor\n- `test_discussion_uses_parent_labels` — labels from parent entity, not discussion\nGREEN: Implement extract_discussion_document\nVERIFY: `cargo test extract_discussion`\n\n## Edge Cases\n- Discussion with all system notes: no non-system notes -> return empty thread (or skip document entirely?)\n- Discussion with NULL parent (orphaned): return Ok(None)\n- DiffNote with same old_path and new_path: dedup produces single entry\n- Notes with NULL body: skip or use empty string\n- Discussion on MR: header shows \"MR !N\" (not \"MergeRequest !N\")","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-30T15:25:45.549099Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:34:43.597398Z","closed_at":"2026-01-30T17:34:43.597339Z","close_reason":"Implemented extract_discussion_document() with parent entity lookup, DiffNote paths, system note exclusion, URL construction + 9 tests","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2fp","depends_on_id":"bd-18t","type":"blocks","created_at":"2026-01-30T15:29:15.914098Z","created_by":"tayloreernisse"},{"issue_id":"bd-2fp","depends_on_id":"bd-36p","type":"blocks","created_at":"2026-01-30T15:29:15.847680Z","created_by":"tayloreernisse"},{"issue_id":"bd-2fp","depends_on_id":"bd-hrs","type":"blocks","created_at":"2026-01-30T15:29:15.880008Z","created_by":"tayloreernisse"}]} @@ -101,9 +101,9 @@ {"id":"bd-31m","title":"[CP1] Test fixtures for mocked GitLab responses","description":"Create mock response files for integration tests.\n\nFixtures to create:\n- gitlab-issue.json (single issue with labels)\n- gitlab-issues-page.json (paginated list)\n- gitlab-discussion.json (single discussion with notes)\n- gitlab-discussions-page.json (paginated list)\n\nInclude edge cases:\n- Issue with labels_details\n- Issue with no labels\n- Discussion with individual_note=true\n- System notes with system=true\n\nFiles: tests/fixtures/mock-responses/gitlab-issue*.json, gitlab-discussion*.json\nDone when: MSW handlers can use fixtures for deterministic tests","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T15:20:43.781288Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.155480Z","deleted_at":"2026-01-25T15:21:35.155478Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-327","title":"[CP0] Project scaffold","description":"## Background\n\nThis is the foundational scaffold for the GitLab Inbox CLI tool. Every subsequent bead depends on having the correct project structure, TypeScript configuration, and tooling in place. The configuration choices here (ESM modules, strict TypeScript, Vitest for testing) set constraints for all future code.\n\n## Approach\n\nCreate a Node.js 20+ ESM project with TypeScript strict mode. Use flat ESLint config (v9+) with TypeScript plugin. Configure Vitest with coverage. Create the directory structure matching the PRD exactly.\n\n**package.json essentials:**\n- `\"type\": \"module\"` for ESM\n- `\"bin\": { \"gi\": \"./dist/cli/index.js\" }` for CLI entry point\n- Runtime deps: better-sqlite3, sqlite-vec, commander, zod, pino, pino-pretty, ora, chalk, cli-table3, inquirer\n- Dev deps: typescript, @types/better-sqlite3, @types/node, vitest, msw, eslint, @typescript-eslint/*\n\n**tsconfig.json:**\n- `target: ES2022`, `module: Node16`, `moduleResolution: Node16`\n- `strict: true`, `noImplicitAny: true`, `strictNullChecks: true`\n- `outDir: ./dist`, `rootDir: ./src`\n\n**vitest.config.ts:**\n- Exclude `tests/live/**` unless `GITLAB_LIVE_TESTS=1`\n- Coverage with v8 provider\n\n## Acceptance Criteria\n\n- [ ] `npm install` completes without errors\n- [ ] `npm run build` compiles TypeScript to dist/\n- [ ] `npm run test` runs vitest (0 tests is fine at this stage)\n- [ ] `npm run lint` runs ESLint with no config errors\n- [ ] All directories exist: src/cli/commands/, src/core/, src/gitlab/, src/types/, tests/unit/, tests/integration/, tests/live/, tests/fixtures/mock-responses/, migrations/\n\n## Files\n\nCREATE:\n- package.json\n- tsconfig.json\n- vitest.config.ts\n- eslint.config.js\n- .gitignore\n- src/cli/index.ts (empty placeholder with shebang)\n- src/cli/commands/.gitkeep\n- src/core/.gitkeep\n- src/gitlab/.gitkeep\n- src/types/index.ts (empty)\n- tests/unit/.gitkeep\n- tests/integration/.gitkeep\n- tests/live/.gitkeep\n- tests/fixtures/mock-responses/.gitkeep\n- migrations/.gitkeep\n\n## TDD Loop\n\nN/A - scaffold only. Verify with:\n\n```bash\nnpm install\nnpm run build\nnpm run lint\nnpm run test\n```\n\n## Edge Cases\n\n- Node.js version < 20 will fail on ESM features - add `engines` field\n- better-sqlite3 requires native compilation - may need python/build-essential\n- sqlite-vec installation can fail on some platforms - document fallback","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-24T16:09:47.955044Z","created_by":"tayloreernisse","updated_at":"2026-01-25T02:51:25.347932Z","closed_at":"2026-01-25T02:51:25.347799Z","compaction_level":0,"original_size":0} {"id":"bd-32mc","title":"OBSERV: Implement log retention cleanup at startup","description":"## Background\nLog files accumulate at ~1-10 MB/day. Without cleanup, they grow unbounded. Retention runs BEFORE subscriber init so deleted file handles aren't held open by the appender.\n\n## Approach\nAdd a cleanup function, called from main.rs before the subscriber is initialized (before current line 44):\n\n```rust\n/// Delete log files older than retention_days.\n/// Matches files named lore.YYYY-MM-DD.log in the log directory.\npub fn cleanup_old_logs(log_dir: &Path, retention_days: u32) -> std::io::Result {\n if retention_days == 0 {\n return Ok(0); // 0 means file logging disabled, don't delete\n }\n let cutoff = SystemTime::now() - Duration::from_secs(u64::from(retention_days) * 86400);\n let mut deleted = 0;\n\n for entry in std::fs::read_dir(log_dir)? {\n let entry = entry?;\n let name = entry.file_name();\n let name_str = name.to_string_lossy();\n\n // Only match lore.YYYY-MM-DD.log pattern\n if !name_str.starts_with(\"lore.\") || !name_str.ends_with(\".log\") {\n continue;\n }\n\n if let Ok(metadata) = entry.metadata() {\n if let Ok(modified) = metadata.modified() {\n if modified < cutoff {\n std::fs::remove_file(entry.path())?;\n deleted += 1;\n }\n }\n }\n }\n Ok(deleted)\n}\n```\n\nPlace this function in src/core/paths.rs (next to get_log_dir) or a new src/core/log_retention.rs. Prefer paths.rs since it's small and related.\n\nCall from main.rs:\n```rust\nlet log_dir = get_log_dir(config.logging.log_dir.as_deref());\nlet _ = cleanup_old_logs(&log_dir, config.logging.retention_days);\n// THEN init subscriber\n```\n\nNote: Config must be loaded before cleanup runs. Current main.rs parses Cli at line 60, but config loading happens inside command handlers. This means we need to either:\n A) Load config early in main() before subscriber init (preferred)\n B) Defer cleanup to after config load\n\nSince the subscriber must also know log_dir, approach A is natural: load config -> cleanup -> init subscriber -> dispatch command.\n\n## Acceptance Criteria\n- [ ] Files matching lore.*.log older than retention_days are deleted\n- [ ] Files matching lore.*.log within retention_days are preserved\n- [ ] Non-matching files (e.g., other.txt) are never deleted\n- [ ] retention_days=0 skips cleanup entirely (no files deleted)\n- [ ] Errors on individual files don't prevent cleanup of remaining files\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/core/paths.rs (add cleanup_old_logs function)\n- src/main.rs (call cleanup before subscriber init)\n\n## TDD Loop\nRED:\n - test_log_retention_cleanup: create tempdir with lore.2026-01-01.log through lore.2026-02-04.log, run with retention_days=7, assert old deleted, recent preserved\n - test_log_retention_ignores_non_log_files: create other.txt alongside old log files, assert other.txt untouched\n - test_log_retention_zero_days: retention_days=0, assert nothing deleted\nGREEN: Implement cleanup_old_logs\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- SystemTime::now() precision varies by OS; use file modified time, not name parsing (simpler and more reliable)\n- read_dir on non-existent directory: get_log_dir creates it first, so this shouldn't happen. But handle gracefully.\n- Permissions error on individual file: log a warning, continue with remaining files (don't propagate)\n- Race condition: another process creates a file during cleanup. Not a concern -- we only delete old files.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-04T15:53:55.627901Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:15:04.452086Z","closed_at":"2026-02-04T17:15:04.452039Z","close_reason":"Implemented cleanup_old_logs() with date-pattern matching and retention_days config, runs at startup before subscriber init","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-32mc","depends_on_id":"bd-17n","type":"blocks","created_at":"2026-02-04T15:55:19.523048Z","created_by":"tayloreernisse"},{"issue_id":"bd-32mc","depends_on_id":"bd-1k4","type":"blocks","created_at":"2026-02-04T15:55:19.583155Z","created_by":"tayloreernisse"},{"issue_id":"bd-32mc","depends_on_id":"bd-2nx","type":"parent-child","created_at":"2026-02-04T15:53:55.628795Z","created_by":"tayloreernisse"}]} -{"id":"bd-32q","title":"Implement timeline seed phase: FTS5 keyword search to entity IDs","description":"## Background\n\nThe seed phase is step 1 of the timeline pipeline: it converts a user's keyword query into a set of entity IDs (issues and MRs). It reuses the existing FTS5 index (documents_fts table from migration 008) and the document-to-entity mapping in the documents table. It also collects top evidence notes -- FTS5 snippet matches from discussion documents that become NoteEvidence timeline events.\n\n## Approach\n\nCreate `src/core/timeline_seed.rs` with:\n\n```rust\nuse crate::core::timeline::{EntityRef, TimelineEvent, TimelineEventType};\nuse rusqlite::Connection;\n\npub struct SeedResult {\n pub seed_entities: Vec,\n pub evidence_notes: Vec, // NoteEvidence events\n}\n\npub fn seed_timeline(\n conn: &Connection,\n query: &str,\n project_id: Option,\n since_ms: Option,\n limit: usize, // max seed entities (default 50)\n) -> Result {\n // 1. FTS5 query on documents_fts (reuse safe query builder from search::fts)\n // 2. Map document rows to source entities via documents.source_type + source_id\n // 3. Deduplicate entities (same issue/MR from multiple docs)\n // 4. Collect top 10 discussion-source documents as evidence candidates\n // 5. For evidence: fetch note body, truncate to 200 chars, create NoteEvidence events\n // 6. Apply --since filter: WHERE documents.created_at >= since_ms\n // 7. Apply -p filter: WHERE documents.project_id = project_id\n}\n```\n\nSQL for step 1-2:\n```sql\nSELECT DISTINCT d.source_type, d.source_id, d.project_id,\n CASE d.source_type\n WHEN 'issue' THEN (SELECT iid FROM issues WHERE id = d.source_id)\n WHEN 'merge_request' THEN (SELECT iid FROM merge_requests WHERE id = d.source_id)\n END AS iid,\n CASE d.source_type\n WHEN 'issue' THEN (SELECT p.path_with_namespace FROM issues i JOIN projects p ON i.project_id = p.id WHERE i.id = d.source_id)\n WHEN 'merge_request' THEN (SELECT p.path_with_namespace FROM merge_requests m JOIN projects p ON m.project_id = p.id WHERE m.id = d.source_id)\n END AS project_path\nFROM documents_fts fts\nJOIN documents d ON d.id = fts.rowid\nWHERE documents_fts MATCH ?1\n AND (?2 IS NULL OR d.project_id = ?2)\n AND (?3 IS NULL OR d.created_at >= ?3)\nORDER BY rank\nLIMIT ?4\n```\n\nFor evidence (step 4):\n```sql\nSELECT d.source_id, n.body, n.created_at, n.author_username, d.source_type,\n disc.id as discussion_id\nFROM documents_fts fts\nJOIN documents d ON d.id = fts.rowid\nJOIN discussions disc ON disc.id = d.source_id AND d.source_type = 'discussion'\nJOIN notes n ON n.discussion_id = disc.id AND n.is_system = 0\nWHERE documents_fts MATCH ?1\nORDER BY rank\nLIMIT 10\n```\n\nRegister in `src/core/mod.rs`: `pub mod timeline_seed;`\n\n## Acceptance Criteria\n\n- [ ] `seed_timeline()` returns entities from FTS5 search\n- [ ] Entities are deduplicated (same entity from multiple document hits appears once)\n- [ ] Evidence notes are capped at 10\n- [ ] Evidence note body snippets are truncated to 200 chars\n- [ ] `--since` filter works (only entities created after since_ms)\n- [ ] `-p` filter works (only entities from specified project)\n- [ ] Returns empty result (not error) for zero-match queries\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/core/timeline_seed.rs` (NEW)\n- `src/core/mod.rs` (add `pub mod timeline_seed;`)\n\n## TDD Loop\n\nRED: Create `src/core/timeline_seed.rs` with `#[cfg(test)] mod tests`:\n- `test_seed_deduplicates_entities` - same issue from two docs -> one EntityRef\n- `test_seed_empty_query_returns_empty` - no panic on zero matches\n- `test_seed_evidence_capped_at_10` - never more than 10 evidence notes\n- `test_seed_respects_since_filter` - old docs excluded\n\nGREEN: Implement the FTS5 queries and deduplication logic.\n\nVERIFY: `cargo test --lib -- timeline_seed`\n\n## Edge Cases\n\n- FTS5 MATCH can fail with invalid syntax. Use the safe query builder from `search::fts::build_safe_fts_query()` to sanitize input.\n- Discussion documents may link to notes that have been deleted (orphan). Handle with LEFT JOIN and skip NULL note bodies.\n- Some documents have source_type='discussion' but the discussion may be for an issue or MR -- need to resolve the parent entity via disc.issue_id or disc.merge_request_id.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-02T21:33:08.615908Z","created_by":"tayloreernisse","updated_at":"2026-02-05T18:50:31.577848Z","compaction_level":0,"original_size":0,"labels":["gate-3","phase-b","query"],"dependencies":[{"issue_id":"bd-32q","depends_on_id":"bd-20e","type":"blocks","created_at":"2026-02-02T21:33:37.368005Z","created_by":"tayloreernisse"},{"issue_id":"bd-32q","depends_on_id":"bd-ike","type":"parent-child","created_at":"2026-02-02T21:33:08.617483Z","created_by":"tayloreernisse"}]} +{"id":"bd-32q","title":"Implement timeline seed phase: FTS5 keyword search to entity IDs","description":"## Background\n\nThe seed phase is steps 1-2 of the timeline pipeline (spec Section 3.2): SEED + HYDRATE. It converts a user's keyword query into a set of entity IDs (issues and MRs) via FTS5 search, and collects evidence note candidates. It reuses the existing FTS5 index (documents_fts table from migration 008) and the document-to-entity mapping in the documents table.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.2 steps 1-2.\n\nNote: The spec describes SEED and HYDRATE as separate steps. This bead combines them into a single `seed_timeline()` function since they're tightly coupled (HYDRATE just maps document IDs to entities).\n\n## Approach\n\nCreate `src/core/timeline_seed.rs` with:\n\n```rust\nuse crate::core::timeline::{EntityRef, TimelineEvent, TimelineEventType};\nuse rusqlite::Connection;\n\npub struct SeedResult {\n pub seed_entities: Vec,\n pub evidence_notes: Vec, // NoteEvidence events (spec: \"evidence candidates\")\n}\n\npub fn seed_timeline(\n conn: &Connection,\n query: &str,\n project_id: Option,\n since_ms: Option,\n limit: usize, // max seed entities (default 50)\n) -> Result {\n // Spec step 1 (SEED): FTS5 keyword search -> matched document IDs\n // Spec step 2 (HYDRATE):\n // - Map document IDs -> source entities (issues, MRs)\n // - Collect top matched notes as evidence candidates (bounded, default top 10)\n // \"These are the actual decision-bearing comments that answer why\"\n}\n```\n\n### SQL for SEED + HYDRATE (steps 1-2):\n```sql\nSELECT DISTINCT d.source_type, d.source_id, d.project_id,\n CASE d.source_type\n WHEN 'issue' THEN (SELECT iid FROM issues WHERE id = d.source_id)\n WHEN 'merge_request' THEN (SELECT iid FROM merge_requests WHERE id = d.source_id)\n END AS iid,\n CASE d.source_type\n WHEN 'issue' THEN (SELECT p.path_with_namespace FROM issues i JOIN projects p ON i.project_id = p.id WHERE i.id = d.source_id)\n WHEN 'merge_request' THEN (SELECT p.path_with_namespace FROM merge_requests m JOIN projects p ON m.project_id = p.id WHERE m.id = d.source_id)\n END AS project_path\nFROM documents_fts fts\nJOIN documents d ON d.id = fts.rowid\nWHERE documents_fts MATCH ?1\n AND (?2 IS NULL OR d.project_id = ?2)\n AND (?3 IS NULL OR d.created_at >= ?3)\nORDER BY rank\nLIMIT ?4\n```\n\n### SQL for evidence notes (spec: \"top matched notes as evidence candidates\"):\n```sql\nSELECT d.source_id, n.id as note_id, n.body, n.created_at, n.author_username,\n disc.id as discussion_id,\n CASE\n WHEN disc.issue_id IS NOT NULL THEN 'issue'\n WHEN disc.merge_request_id IS NOT NULL THEN 'merge_request'\n END AS parent_entity_type,\n COALESCE(disc.issue_id, disc.merge_request_id) AS parent_entity_id\nFROM documents_fts fts\nJOIN documents d ON d.id = fts.rowid\nJOIN discussions disc ON disc.id = d.source_id AND d.source_type = 'discussion'\nJOIN notes n ON n.discussion_id = disc.id AND n.is_system = 0\nWHERE documents_fts MATCH ?1\nORDER BY rank\nLIMIT 10\n```\n\nEvidence notes become `TimelineEvent` with:\n- `event_type: TimelineEventType::NoteEvidence { note_id, snippet, discussion_id }`\n- `snippet`: first 200 chars of note body (per spec Section 3.3)\n- `note_id`: from notes.id (per spec Section 3.3)\n- `discussion_id`: from discussions.id (per spec Section 3.3, Optional)\n\nRegister in `src/core/mod.rs`: `pub mod timeline_seed;`\n\n## Acceptance Criteria\n\n- [ ] `seed_timeline()` returns entities from FTS5 search\n- [ ] Entities are deduplicated (same entity from multiple document hits appears once)\n- [ ] Evidence notes capped at 10 (spec: \"bounded, default top 10\")\n- [ ] Evidence note body snippets truncated to 200 chars (spec Section 3.3)\n- [ ] Evidence notes include note_id field (spec Section 3.3)\n- [ ] `--since` filter works (only entities created after since_ms)\n- [ ] `-p` filter works (only entities from specified project)\n- [ ] Returns empty result (not error) for zero-match queries\n- [ ] Uses safe FTS query builder from `search::fts::build_safe_fts_query()`\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/core/timeline_seed.rs` (NEW)\n- `src/core/mod.rs` (add `pub mod timeline_seed;`)\n\n## TDD Loop\n\nRED: Create `src/core/timeline_seed.rs` with `#[cfg(test)] mod tests`:\n- `test_seed_deduplicates_entities` - same issue from two docs -> one EntityRef\n- `test_seed_empty_query_returns_empty` - no panic on zero matches\n- `test_seed_evidence_capped_at_10` - never more than 10 evidence notes\n- `test_seed_evidence_has_note_id` - evidence NoteEvidence events include note_id\n- `test_seed_evidence_snippet_truncated` - snippets max 200 chars\n- `test_seed_respects_since_filter` - old docs excluded\n\nGREEN: Implement the FTS5 queries and deduplication logic.\n\nVERIFY: `cargo test --lib -- timeline_seed`\n\n## Edge Cases\n\n- FTS5 MATCH can fail with invalid syntax. Use the safe query builder from `search::fts::build_safe_fts_query()` to sanitize input.\n- Discussion documents may link to notes that have been deleted (orphan). Handle with LEFT JOIN and skip NULL note bodies.\n- Some documents have source_type='discussion' but the discussion may be for an issue or MR -- resolve the parent entity via disc.issue_id or disc.merge_request_id.\n- Evidence note with very long body: truncate to 200 chars, ensure no mid-codepoint truncation for UTF-8.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-02T21:33:08.615908Z","created_by":"tayloreernisse","updated_at":"2026-02-05T19:11:53.278838Z","compaction_level":0,"original_size":0,"labels":["gate-3","phase-b","query"],"dependencies":[{"issue_id":"bd-32q","depends_on_id":"bd-20e","type":"blocks","created_at":"2026-02-02T21:33:37.368005Z","created_by":"tayloreernisse"},{"issue_id":"bd-32q","depends_on_id":"bd-ike","type":"parent-child","created_at":"2026-02-02T21:33:08.617483Z","created_by":"tayloreernisse"}]} {"id":"bd-335","title":"Implement Ollama API client","description":"## Background\nThe Ollama API client provides the HTTP interface to the local Ollama embedding server. It handles health checks (is Ollama running? does the model exist?), batch embedding requests (up to 32 texts per call), and error translation to LoreError variants. This is the lowest-level embedding component — the pipeline (bd-am7) builds on top of it.\n\n## Approach\nCreate \\`src/embedding/ollama.rs\\` per PRD Section 4.2. **Uses async reqwest (not blocking).**\n\n```rust\nuse reqwest::Client; // NOTE: async Client, not reqwest::blocking\nuse serde::{Deserialize, Serialize};\nuse crate::core::error::{LoreError, Result};\n\npub struct OllamaConfig {\n pub base_url: String, // default \\\"http://localhost:11434\\\"\n pub model: String, // default \\\"nomic-embed-text\\\"\n pub timeout_secs: u64, // default 60\n}\n\nimpl Default for OllamaConfig { /* PRD defaults */ }\n\npub struct OllamaClient {\n client: Client, // async reqwest::Client\n config: OllamaConfig,\n}\n\n#[derive(Serialize)]\nstruct EmbedRequest { model: String, input: Vec }\n\n#[derive(Deserialize)]\nstruct EmbedResponse { model: String, embeddings: Vec> }\n\n#[derive(Deserialize)]\nstruct TagsResponse { models: Vec }\n\n#[derive(Deserialize)]\nstruct ModelInfo { name: String }\n\nimpl OllamaClient {\n pub fn new(config: OllamaConfig) -> Self;\n\n /// Async health check: GET /api/tags\n /// Model matched via starts_with (\\\"nomic-embed-text\\\" matches \\\"nomic-embed-text:latest\\\")\n pub async fn health_check(&self) -> Result<()>;\n\n /// Async batch embedding: POST /api/embed\n /// Input: Vec of texts, Response: Vec> of 768-dim embeddings\n pub async fn embed_batch(&self, texts: Vec) -> Result>>;\n}\n\n/// Quick health check without full client (async).\npub async fn check_ollama_health(base_url: &str) -> bool;\n```\n\n**Error mapping (per PRD):**\n- Connection refused/timeout -> LoreError::OllamaUnavailable { base_url, source: Some(e) }\n- Model not in /api/tags -> LoreError::OllamaModelNotFound { model }\n- Non-200 from /api/embed -> LoreError::EmbeddingFailed { document_id: 0, reason: format!(\\\"HTTP {}: {}\\\", status, body) }\n\n**Key PRD detail:** Model matching uses \\`starts_with\\` (not exact match) so \\\"nomic-embed-text\\\" matches \\\"nomic-embed-text:latest\\\".\n\n## Acceptance Criteria\n- [ ] Uses async reqwest::Client (not blocking)\n- [ ] health_check() is async, detects server availability and model presence\n- [ ] Model matched via starts_with (handles \\\":latest\\\" suffix)\n- [ ] embed_batch() is async, sends POST /api/embed\n- [ ] Batch size up to 32 texts\n- [ ] Returns Vec> with 768 dimensions each\n- [ ] OllamaUnavailable error includes base_url and source error\n- [ ] OllamaModelNotFound error includes model name\n- [ ] Non-200 response mapped to EmbeddingFailed with status + body\n- [ ] Timeout: 60 seconds default (configurable via OllamaConfig)\n- [ ] \\`cargo build\\` succeeds\n\n## Files\n- \\`src/embedding/ollama.rs\\` — new file\n- \\`src/embedding/mod.rs\\` — add \\`pub mod ollama;\\` and re-exports\n\n## TDD Loop\nRED: Tests (unit tests with mock, integration needs Ollama):\n- \\`test_config_defaults\\` — verify default base_url, model, timeout\n- \\`test_health_check_model_starts_with\\` — \\\"nomic-embed-text\\\" matches \\\"nomic-embed-text:latest\\\"\n- \\`test_embed_batch_parse\\` — mock response parsed correctly\n- \\`test_connection_error_maps_to_ollama_unavailable\\`\nGREEN: Implement OllamaClient\nVERIFY: \\`cargo test ollama\\`\n\n## Edge Cases\n- Ollama returns model name with version tag (\\\"nomic-embed-text:latest\\\"): starts_with handles this\n- Empty texts array: send empty batch, Ollama returns empty embeddings\n- Ollama returns wrong number of embeddings (2 texts, 1 embedding): caller (pipeline) validates\n- Non-JSON response: reqwest deserialization error -> wrap appropriately","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:26:34.025099Z","created_by":"tayloreernisse","updated_at":"2026-01-30T16:58:17.546852Z","closed_at":"2026-01-30T16:58:17.546794Z","close_reason":"Completed: OllamaClient with async health_check (starts_with model matching), embed_batch, error mapping to LoreError variants, check_ollama_health helper, 4 tests pass","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-335","depends_on_id":"bd-ljf","type":"blocks","created_at":"2026-01-30T15:29:24.627951Z","created_by":"tayloreernisse"}]} -{"id":"bd-343o","title":"Fetch and store GitLab linked issues (Related to)","description":"## Background\n\nGitLab's 'Linked items' (Related issues) section provides bidirectional issue linking that's distinct from 'closes' references and 'mentioned' references. This data is only available via the issue links API endpoint and must be fetched separately.\n\n## Approach\n\n### Phase 1: API Client\n\nAdd to \\`src/gitlab/client.rs\\`:\n```rust\npub async fn fetch_issue_links(\n &self,\n project_id: i64,\n issue_iid: i64,\n) -> Result> {\n // GET /projects/:id/issues/:iid/links\n // Returns array of linked issues with link_type\n}\n```\n\n### Phase 2: Types\n\nAdd to \\`src/gitlab/types.rs\\`:\n```rust\n#[derive(Debug, Deserialize)]\npub struct GitLabIssueLink {\n pub id: i64, // GitLab issue ID (not IID)\n pub iid: i64,\n pub title: String,\n pub state: String,\n pub web_url: String,\n pub link_type: String, // \"relates_to\", \"blocks\", \"is_blocked_by\"\n pub link_created_at: Option,\n pub link_updated_at: Option,\n // References the project via web_url parsing or separate field\n}\n```\n\n### Phase 3: Ingestion\n\nCreate \\`src/ingestion/issue_links.rs\\`:\n```rust\npub async fn fetch_and_store_issue_links(\n config: &Config,\n conn: &Connection,\n project_id: i64,\n issue_local_id: i64,\n issue_iid: i64,\n) -> Result {\n // 1. Fetch links from API\n // 2. For each link, resolve target issue to local DB id\n // 3. Insert into entity_references with reference_type = 'related'\n // and source_method = 'api'\n // 4. link_type 'blocks'/'is_blocked_by' -> still reference_type = 'related'\n // (blocking semantics not modeled in entity_references yet)\n}\n```\n\n### Phase 4: Queue Integration\n\nAdd 'issue_links' job_type to pending_dependent_fetches:\n- Enqueue after issue ingestion\n- Drain during dependent fetch phase\n\nNote: This requires updating the CHECK constraint on pending_dependent_fetches.job_type to include 'issue_links'.\n\n### Phase 5: Display\n\nIn \\`lore issues 123\\` show output, add a \"Related Issues\" section:\n```\nRelated Issues:\n #456 \"Fix login timeout\" (opened) - relates_to\n #789 \"Auth redesign\" (closed) - blocks\n```\n\n## Acceptance Criteria\n\n- [ ] API client fetches issue links with pagination\n- [ ] Each link stored as entity_reference with type='related', method='api'\n- [ ] Bidirectional: if issue A links to B, both A->B and B->A references created\n- [ ] Link type preserved in a new column or metadata (relates_to, blocks, is_blocked_by)\n- [ ] \\`lore issues 123\\` shows related issues section\n- [ ] \\`lore --robot issues 123\\` includes related_issues in JSON\n- [ ] Graceful handling: issues with no links -> empty section\n- [ ] \\`cargo check --all-targets\\` passes\n- [ ] \\`cargo clippy --all-targets -- -D warnings\\` passes\n\n## Files\n\n- \\`src/gitlab/client.rs\\` (add fetch_issue_links method)\n- \\`src/gitlab/types.rs\\` (add GitLabIssueLink struct)\n- \\`src/ingestion/issue_links.rs\\` (NEW)\n- \\`src/ingestion/mod.rs\\` (add pub mod issue_links)\n- \\`src/ingestion/orchestrator.rs\\` (enqueue issue_links jobs)\n- \\`migrations/???_issue_links_job_type.sql\\` (update CHECK constraint -- or use a new migration that recreates the constraint)\n- \\`src/cli/commands/show.rs\\` (display related issues)\n\n## TDD Loop\n\nRED: Create tests:\n- \\`test_issue_link_deserialization\\` - parse GitLab API response\n- \\`test_store_issue_links_creates_references\\` - inserts into entity_references\n- \\`test_bidirectional_links\\` - A->B also creates B->A reference\n\nGREEN: Implement API client, ingestion, and display.\n\nVERIFY: \\`cargo test --lib -- issue_links\\`\n\n## Edge Cases\n\n- Cross-project links: target issue may not be in local DB. Store as unresolved reference (target_entity_id = NULL, target_project_path + target_entity_iid populated).\n- Self-links: issue linked to itself. Skip or store -- either is valid.\n- Duplicate links: UNIQUE constraint on entity_references prevents duplicates\n- CHECK constraint on pending_dependent_fetches.job_type needs migration to add 'issue_links'. Alternative: just ALTER the table if SQLite supports it, or recreate the table.\n- SQLite limitation: can't ALTER CHECK constraints. Need to either DROP and recreate the table or use a workaround. Simplest: create a new migration that drops the CHECK by recreating the table with the expanded constraint. This is safe because pending_dependent_fetches is a transient queue.","status":"open","priority":2,"issue_type":"feature","created_at":"2026-02-05T15:14:25.202900Z","created_by":"tayloreernisse","updated_at":"2026-02-05T18:57:23.904771Z","compaction_level":0,"original_size":0,"labels":["ISSUE"]} +{"id":"bd-343o","title":"Fetch and store GitLab linked issues (Related to)","description":"## Background\n\nGitLab's 'Linked items' (Related issues) section provides bidirectional issue linking that's distinct from 'closes' references and 'mentioned' references. This data is only available via the issue links API endpoint and must be fetched separately.\n\n**Spec context:** `docs/phase-b-temporal-intelligence.md` Section 2.3 mentions the `entity_references` table can store 'related' references. This bead adds a new ingestion source (issue links API) that wasn't explicitly in the spec but naturally extends Gate 2's cross-reference extraction.\n\n**source_method note:** The codebase uses `source_method = 'api'` (see migration 011, line 86 CHECK constraint). The spec defines more granular values (`'api_closes_issues'`), but the code has already shipped with the simpler taxonomy.\n\n## Approach\n\n### Phase 1: API Client\n\nAdd to `src/gitlab/client.rs`:\n```rust\npub async fn fetch_issue_links(\n &self,\n project_id: i64,\n issue_iid: i64,\n) -> Result> {\n // GET /projects/:id/issues/:iid/links\n // Returns array of linked issues with link_type\n}\n```\n\n### Phase 2: Types\n\nAdd to `src/gitlab/types.rs`:\n```rust\n#[derive(Debug, Deserialize)]\npub struct GitLabIssueLink {\n pub id: i64, // GitLab issue ID (not IID)\n pub iid: i64,\n pub title: String,\n pub state: String,\n pub web_url: String,\n pub link_type: String, // \"relates_to\", \"blocks\", \"is_blocked_by\"\n pub link_created_at: Option,\n pub link_updated_at: Option,\n}\n```\n\n### Phase 3: Ingestion\n\nCreate `src/ingestion/issue_links.rs`:\n```rust\npub async fn fetch_and_store_issue_links(\n config: &Config,\n conn: &Connection,\n project_id: i64,\n issue_local_id: i64,\n issue_iid: i64,\n) -> Result {\n // 1. Fetch links from API\n // 2. For each link, resolve target issue to local DB id\n // 3. Insert into entity_references with:\n // reference_type = 'related'\n // source_method = 'api' (matches codebase CHECK constraint)\n // 4. link_type 'blocks'/'is_blocked_by' -> still reference_type = 'related'\n // (blocking semantics not modeled in entity_references yet)\n}\n```\n\n### Phase 4: Queue Integration\n\nAdd 'issue_links' job_type to pending_dependent_fetches:\n- Enqueue after issue ingestion\n- Drain during dependent fetch phase\n\nNote: This requires updating the CHECK constraint on pending_dependent_fetches.job_type to include 'issue_links'. SQLite can't ALTER CHECK constraints, so this needs a migration that recreates the table.\n\n### Phase 5: Display\n\nIn `lore show issue 123` output, add a \"Related Issues\" section:\n```\nRelated Issues:\n #456 \"Fix login timeout\" (opened) - relates_to\n #789 \"Auth redesign\" (closed) - blocks\n```\n\n## Acceptance Criteria\n\n- [ ] API client fetches issue links with pagination\n- [ ] Each link stored as entity_reference with reference_type='related', source_method='api'\n- [ ] Bidirectional: if issue A links to B, both A->B and B->A references created\n- [ ] Link type preserved (relates_to, blocks, is_blocked_by) in a metadata column or secondary table\n- [ ] `lore show issue 123` shows related issues section\n- [ ] `lore --robot show issue 123` includes related_issues in JSON\n- [ ] Cross-project links stored as unresolved (target_entity_id NULL, target_project_path populated)\n- [ ] Graceful handling: issues with no links -> empty section (not error)\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/gitlab/client.rs` (add fetch_issue_links method)\n- `src/gitlab/types.rs` (add GitLabIssueLink struct)\n- `src/ingestion/issue_links.rs` (NEW)\n- `src/ingestion/mod.rs` (add pub mod issue_links)\n- `src/ingestion/orchestrator.rs` (enqueue issue_links jobs)\n- `migrations/???_issue_links_job_type.sql` (recreate pending_dependent_fetches with expanded CHECK)\n- `src/cli/commands/show.rs` (display related issues)\n\n## TDD Loop\n\nRED: Create tests:\n- `test_issue_link_deserialization` - parse GitLab API response\n- `test_store_issue_links_creates_references` - inserts into entity_references\n- `test_bidirectional_links` - A->B also creates B->A reference\n\nGREEN: Implement API client, ingestion, and display.\n\nVERIFY: `cargo test --lib -- issue_links`\n\n## Edge Cases\n\n- Cross-project links: target issue may not be in local DB. Store as unresolved reference (target_entity_id = NULL, target_project_path + target_entity_iid populated).\n- Self-links: issue linked to itself. Skip.\n- Duplicate links: UNIQUE constraint on entity_references prevents duplicates.\n- CHECK constraint on pending_dependent_fetches.job_type needs migration to add 'issue_links'. SQLite limitation: can't ALTER CHECK constraints. Create a new migration that recreates the table with the expanded constraint. This is safe because pending_dependent_fetches is a transient queue.","status":"open","priority":2,"issue_type":"feature","created_at":"2026-02-05T15:14:25.202900Z","created_by":"tayloreernisse","updated_at":"2026-02-05T19:13:13.910828Z","compaction_level":0,"original_size":0,"labels":["ISSUE"]} {"id":"bd-34ek","title":"OBSERV: Implement MetricsLayer custom tracing subscriber layer","description":"## Background\nMetricsLayer is a custom tracing subscriber layer that records span timing and structured fields, then materializes them into Vec. This avoids threading a mutable collector through every function signature -- spans are the single source of truth.\n\n## Approach\nAdd to src/core/metrics.rs (same file as StageTiming):\n\n```rust\nuse std::collections::HashMap;\nuse std::sync::{Arc, Mutex};\nuse std::time::Instant;\nuse tracing::span::{Attributes, Id, Record};\nuse tracing::Subscriber;\nuse tracing_subscriber::layer::{Context, Layer};\nuse tracing_subscriber::registry::LookupSpan;\n\n#[derive(Debug)]\nstruct SpanData {\n name: String,\n parent_id: Option,\n start: Instant,\n fields: HashMap,\n}\n\n#[derive(Debug, Clone)]\npub struct MetricsLayer {\n spans: Arc>>,\n completed: Arc>>,\n}\n\nimpl MetricsLayer {\n pub fn new() -> Self {\n Self {\n spans: Arc::new(Mutex::new(HashMap::new())),\n completed: Arc::new(Mutex::new(Vec::new())),\n }\n }\n\n /// Extract timing tree for a completed run.\n /// Call this after the root span closes.\n pub fn extract_timings(&self) -> Vec {\n let completed = self.completed.lock().unwrap();\n // Build tree: find root entries (no parent), attach children\n // ... tree construction logic\n }\n}\n\nimpl Layer for MetricsLayer\nwhere\n S: Subscriber + for<'a> LookupSpan<'a>,\n{\n fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) {\n let parent_id = ctx.span(id).and_then(|s| s.parent().map(|p| p.id()));\n let mut fields = HashMap::new();\n // Visit attrs to capture initial field values\n let mut visitor = FieldVisitor(&mut fields);\n attrs.record(&mut visitor);\n\n self.spans.lock().unwrap().insert(id.into_u64(), SpanData {\n name: attrs.metadata().name().to_string(),\n parent_id,\n start: Instant::now(),\n fields,\n });\n }\n\n fn on_record(&self, id: &Id, values: &Record<'_>, _ctx: Context<'_, S>) {\n // Capture recorded fields (items_processed, items_skipped, errors)\n if let Some(data) = self.spans.lock().unwrap().get_mut(&id.into_u64()) {\n let mut visitor = FieldVisitor(&mut data.fields);\n values.record(&mut visitor);\n }\n }\n\n fn on_close(&self, id: Id, _ctx: Context<'_, S>) {\n if let Some(data) = self.spans.lock().unwrap().remove(&id.into_u64()) {\n let elapsed = data.start.elapsed();\n let timing = StageTiming {\n name: data.name,\n project: data.fields.get(\"project\").and_then(|v| v.as_str()).map(String::from),\n elapsed_ms: elapsed.as_millis() as u64,\n items_processed: data.fields.get(\"items_processed\").and_then(|v| v.as_u64()).unwrap_or(0) as usize,\n items_skipped: data.fields.get(\"items_skipped\").and_then(|v| v.as_u64()).unwrap_or(0) as usize,\n errors: data.fields.get(\"errors\").and_then(|v| v.as_u64()).unwrap_or(0) as usize,\n sub_stages: vec![], // Will be populated during extract_timings tree construction\n };\n self.completed.lock().unwrap().push((id.into_u64(), timing));\n }\n }\n}\n```\n\nNeed a FieldVisitor struct implementing tracing::field::Visit to capture field values.\n\nRegister in subscriber stack (src/main.rs), alongside stderr and file layers:\n```rust\nlet metrics_layer = MetricsLayer::new();\nlet metrics_handle = metrics_layer.clone(); // Clone Arc for later extraction\n\nregistry()\n .with(stderr_layer.with_filter(stderr_filter))\n .with(file_layer.with_filter(file_filter))\n .with(metrics_layer) // No filter -- captures all spans\n .init();\n```\n\nPass metrics_handle to command handlers so they can call extract_timings() after the pipeline completes.\n\n## Acceptance Criteria\n- [ ] MetricsLayer captures span enter/close timing\n- [ ] on_record captures items_processed, items_skipped, errors fields\n- [ ] extract_timings() returns correctly nested Vec tree\n- [ ] Parallel spans (multiple projects) both appear as sub_stages of parent\n- [ ] Thread-safe: Arc> allows concurrent span operations\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/core/metrics.rs (add MetricsLayer, FieldVisitor, tree construction)\n- src/main.rs (register MetricsLayer in subscriber stack)\n\n## TDD Loop\nRED:\n - test_metrics_layer_single_span: enter/exit one span, extract, assert one StageTiming\n - test_metrics_layer_nested_spans: parent + child, assert child in parent.sub_stages\n - test_metrics_layer_parallel_spans: two sibling spans, assert both in parent.sub_stages\n - test_metrics_layer_field_recording: record items_processed=42, assert captured\nGREEN: Implement MetricsLayer with on_new_span, on_record, on_close, extract_timings\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- Span ID reuse: tracing may reuse span IDs after close. Using remove on close prevents stale data.\n- Lock contention: Mutex per operation. For high-span-count scenarios, consider parking_lot::Mutex. But lore's span count is low (<100 per run), so std::sync::Mutex is fine.\n- extract_timings tree construction: iterate completed Vec, build parent->children map, then recursively construct StageTiming tree. Root entries have parent_id matching the root span or None.\n- MetricsLayer has no filter: it sees ALL spans. To avoid noise from dependency spans, check if span name starts with known stage names, or rely on the \"stage\" field being present.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-04T15:54:31.960669Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:25:25.523811Z","closed_at":"2026-02-04T17:25:25.523730Z","close_reason":"Implemented MetricsLayer custom tracing subscriber layer with span timing capture, rate-limit/retry event detection, tree extraction, and 12 unit tests","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-34ek","depends_on_id":"bd-1o4h","type":"blocks","created_at":"2026-02-04T15:55:19.851554Z","created_by":"tayloreernisse"},{"issue_id":"bd-34ek","depends_on_id":"bd-24j1","type":"blocks","created_at":"2026-02-04T15:55:19.905554Z","created_by":"tayloreernisse"},{"issue_id":"bd-34ek","depends_on_id":"bd-3er","type":"parent-child","created_at":"2026-02-04T15:54:31.961646Z","created_by":"tayloreernisse"}]} {"id":"bd-34o","title":"Implement MR transformer","description":"## Background\nTransforms GitLab MR API responses into normalized schema for database storage. Handles deprecated field fallbacks and extracts metadata (labels, assignees, reviewers).\n\n## Approach\nCreate new transformer module following existing issue transformer pattern:\n- `NormalizedMergeRequest` - Database-ready struct\n- `MergeRequestWithMetadata` - MR + extracted labels/assignees/reviewers\n- `transform_merge_request()` - Main transformation function\n- `extract_labels()` - Label extraction helper\n\n## Files\n- `src/gitlab/transformers/merge_request.rs` - New transformer module\n- `src/gitlab/transformers/mod.rs` - Export new module\n- `tests/mr_transformer_tests.rs` - Unit tests\n\n## Acceptance Criteria\n- [ ] `NormalizedMergeRequest` struct exists with all DB columns\n- [ ] `MergeRequestWithMetadata` contains MR + label_names + assignee_usernames + reviewer_usernames\n- [ ] `transform_merge_request()` returns `Result`\n- [ ] `draft` computed as `gitlab_mr.draft || gitlab_mr.work_in_progress`\n- [ ] `detailed_merge_status` prefers `detailed_merge_status` over `merge_status_legacy`\n- [ ] `merge_user_username` prefers `merge_user` over `merged_by`\n- [ ] `head_sha` extracted from `sha` field\n- [ ] `references_short` and `references_full` extracted from `references` Option\n- [ ] Timestamps parsed with `iso_to_ms()`, errors returned (not zeroed)\n- [ ] `last_seen_at` set to `now_ms()`\n- [ ] `cargo test mr_transformer` passes\n\n## TDD Loop\nRED: `cargo test mr_transformer` -> module not found\nGREEN: Add transformer with all fields\nVERIFY: `cargo test mr_transformer`\n\n## Struct Definitions\n```rust\n#[derive(Debug, Clone)]\npub struct NormalizedMergeRequest {\n pub gitlab_id: i64,\n pub project_id: i64,\n pub iid: i64,\n pub title: String,\n pub description: Option,\n pub state: String,\n pub draft: bool,\n pub author_username: String,\n pub source_branch: String,\n pub target_branch: String,\n pub head_sha: Option,\n pub references_short: Option,\n pub references_full: Option,\n pub detailed_merge_status: Option,\n pub merge_user_username: Option,\n pub created_at: i64,\n pub updated_at: i64,\n pub merged_at: Option,\n pub closed_at: Option,\n pub last_seen_at: i64,\n pub web_url: String,\n}\n\n#[derive(Debug, Clone)]\npub struct MergeRequestWithMetadata {\n pub merge_request: NormalizedMergeRequest,\n pub label_names: Vec,\n pub assignee_usernames: Vec,\n pub reviewer_usernames: Vec,\n}\n```\n\n## Function Signature\n```rust\npub fn transform_merge_request(\n gitlab_mr: &GitLabMergeRequest,\n local_project_id: i64,\n) -> Result\n```\n\n## Key Logic\n```rust\n// Draft: prefer draft, fallback to work_in_progress\nlet is_draft = gitlab_mr.draft || gitlab_mr.work_in_progress;\n\n// Merge status: prefer detailed_merge_status\nlet detailed_merge_status = gitlab_mr.detailed_merge_status\n .clone()\n .or_else(|| gitlab_mr.merge_status_legacy.clone());\n\n// Merge user: prefer merge_user\nlet merge_user_username = gitlab_mr.merge_user\n .as_ref()\n .map(|u| u.username.clone())\n .or_else(|| gitlab_mr.merged_by.as_ref().map(|u| u.username.clone()));\n\n// References extraction\nlet (references_short, references_full) = gitlab_mr.references\n .as_ref()\n .map(|r| (Some(r.short.clone()), Some(r.full.clone())))\n .unwrap_or((None, None));\n\n// Head SHA\nlet head_sha = gitlab_mr.sha.clone();\n```\n\n## Edge Cases\n- Invalid timestamps should return `Err`, not zero values\n- Empty labels/assignees/reviewers should return empty Vecs, not None\n- `state` must pass through as-is (including \"locked\")","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-26T22:06:40.849049Z","created_by":"tayloreernisse","updated_at":"2026-01-27T00:11:48.501301Z","closed_at":"2026-01-27T00:11:48.501241Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-34o","depends_on_id":"bd-3ir","type":"blocks","created_at":"2026-01-26T22:08:54.023616Z","created_by":"tayloreernisse"},{"issue_id":"bd-34o","depends_on_id":"bd-5ta","type":"blocks","created_at":"2026-01-26T22:08:54.059646Z","created_by":"tayloreernisse"}]} {"id":"bd-35o","title":"Create golden query test suite","description":"## Background\nGolden query tests verify end-to-end search quality with known-good expected results. They use a seeded SQLite DB with deterministic fixture data and fixed embedding vectors (no Ollama dependency). Each test query must return at least one expected URL in the top 10 results. These tests catch search regressions (ranking changes, filter bugs, missing results).\n\n## Approach\nCreate test infrastructure:\n\n**1. tests/fixtures/golden_queries.json:**\n```json\n[\n {\n \"query\": \"authentication login\",\n \"mode\": \"lexical\",\n \"filters\": {},\n \"expected_urls\": [\"https://gitlab.example.com/group/project/-/issues/234\"],\n \"min_results\": 1,\n \"max_rank\": 10\n },\n {\n \"query\": \"jwt token refresh\",\n \"mode\": \"hybrid\",\n \"filters\": {\"type\": \"merge_request\"},\n \"expected_urls\": [\"https://gitlab.example.com/group/project/-/merge_requests/456\"],\n \"min_results\": 1,\n \"max_rank\": 10\n }\n]\n```\n\n**2. Test harness (tests/golden_query_tests.rs):**\n- Load golden_queries.json\n- Create in-memory DB, apply all migrations\n- Seed with deterministic fixture documents (issues, MRs, discussions)\n- For hybrid/semantic queries: seed with fixed embedding vectors (768-dim, manually constructed for known similarity)\n- For each query: run search, verify expected URL in top N results\n\n**Fixture data design:**\n- 10-20 documents covering different source types\n- Known content that matches expected queries\n- Fixed embeddings: construct vectors where similar documents have small cosine distance\n- No randomness — fully deterministic\n\n## Acceptance Criteria\n- [ ] Golden queries file exists with at least 5 test queries\n- [ ] Test harness loads queries and validates each\n- [ ] All golden queries pass: expected URL in top 10\n- [ ] No external dependencies (no Ollama, no GitLab)\n- [ ] Deterministic fixture data (fixed embeddings, fixed content)\n- [ ] `cargo test --test golden_query_tests` passes in CI\n\n## Files\n- `tests/fixtures/golden_queries.json` — new file\n- `tests/golden_query_tests.rs` — new file (or tests/golden_queries.rs)\n\n## TDD Loop\nRED: Create golden_queries.json with expected results, harness fails (no fixture data)\nGREEN: Seed fixture data that satisfies expected results\nVERIFY: `cargo test --test golden_query_tests`\n\n## Edge Cases\n- Query matches multiple expected URLs: all must be present\n- Lexical queries: FTS ranking determines position, not vector\n- Hybrid queries: RRF combines both signals — fixed vectors must be designed to produce expected ranking\n- Empty result for a golden query: test failure with clear message showing actual results","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-30T15:27:21.788493Z","created_by":"tayloreernisse","updated_at":"2026-01-30T18:12:47.085563Z","closed_at":"2026-01-30T18:12:47.085363Z","close_reason":"Golden query test suite: 7 golden queries in fixture, 8 seeded documents, 2 test functions (all_pass + fixture_valid), deterministic in-memory DB, no external deps. 312 total tests pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-35o","depends_on_id":"bd-2no","type":"blocks","created_at":"2026-01-30T15:29:35.641568Z","created_by":"tayloreernisse"}]} @@ -115,7 +115,7 @@ {"id":"bd-38q","title":"Implement dirty source tracking module","description":"## Background\nDirty source tracking drives incremental document regeneration. When entities are upserted during ingestion, they're marked dirty. The regenerator drains this queue. The key constraint: mark_dirty_tx() takes &Transaction to enforce atomic marking inside the entity upsert transaction. Uses ON CONFLICT DO UPDATE (not INSERT OR IGNORE) to reset backoff on re-queue.\n\n## Approach\nCreate \\`src/ingestion/dirty_tracker.rs\\` per PRD Section 6.1.\n\n```rust\nconst DIRTY_SOURCES_BATCH_SIZE: usize = 500;\n\n/// Mark dirty INSIDE existing transaction. Takes &Transaction, NOT &Connection.\n/// ON CONFLICT resets ALL backoff/error state (not INSERT OR IGNORE).\n/// This ensures fresh updates are immediately eligible, not stuck behind stale backoff.\npub fn mark_dirty_tx(\n tx: &rusqlite::Transaction<'_>,\n source_type: SourceType,\n source_id: i64,\n) -> Result<()>;\n\n/// Convenience wrapper for non-transactional contexts.\npub fn mark_dirty(conn: &Connection, source_type: SourceType, source_id: i64) -> Result<()>;\n\n/// Get dirty sources ready for processing.\n/// WHERE next_attempt_at IS NULL OR next_attempt_at <= now\n/// ORDER BY attempt_count ASC, queued_at ASC (failed items deprioritized)\n/// LIMIT 500\npub fn get_dirty_sources(conn: &Connection) -> Result>;\n\n/// Clear dirty entry after successful processing.\npub fn clear_dirty(conn: &Connection, source_type: SourceType, source_id: i64) -> Result<()>;\n```\n\n**PRD-specific details:**\n- get_dirty_sources ORDER BY: \\`attempt_count ASC, queued_at ASC\\` (failed items processed AFTER fresh items)\n- mark_dirty_tx ON CONFLICT resets: queued_at, attempt_count=0, last_attempt_at=NULL, last_error=NULL, next_attempt_at=NULL\n- SourceType parsed from string in query results via match on \\\"issue\\\"/\\\"merge_request\\\"/\\\"discussion\\\"\n- Invalid source_type in DB -> rusqlite::Error::FromSqlConversionFailure\n\n**Error recording is in regenerator.rs (bd-1u1)**, not dirty_tracker. The dirty_tracker only marks, gets, and clears.\n\n## Acceptance Criteria\n- [ ] mark_dirty_tx takes &Transaction<'_>, NOT &Connection\n- [ ] ON CONFLICT DO UPDATE resets: attempt_count=0, next_attempt_at=NULL, last_error=NULL, last_attempt_at=NULL\n- [ ] Uses ON CONFLICT DO UPDATE, NOT INSERT OR IGNORE (PRD explains why)\n- [ ] get_dirty_sources WHERE next_attempt_at IS NULL OR <= now\n- [ ] get_dirty_sources ORDER BY attempt_count ASC, queued_at ASC\n- [ ] get_dirty_sources LIMIT 500\n- [ ] get_dirty_sources returns Vec<(SourceType, i64)>\n- [ ] clear_dirty DELETEs entry\n- [ ] Queue drains completely when called in loop\n- [ ] \\`cargo test dirty_tracker\\` passes\n\n## Files\n- \\`src/ingestion/dirty_tracker.rs\\` — new file\n- \\`src/ingestion/mod.rs\\` — add \\`pub mod dirty_tracker;\\`\n\n## TDD Loop\nRED: Tests:\n- \\`test_mark_dirty_tx_inserts\\` — entry appears in dirty_sources\n- \\`test_requeue_resets_backoff\\` — mark, simulate error state, re-mark -> attempt_count=0, next_attempt_at=NULL\n- \\`test_get_respects_backoff\\` — entry with future next_attempt_at not returned\n- \\`test_get_orders_by_attempt_count\\` — fresh items before failed items\n- \\`test_batch_size_500\\` — insert 600, get returns 500\n- \\`test_clear_removes\\` — entry gone after clear\n- \\`test_drain_loop\\` — insert 1200, loop 3 times = empty\nGREEN: Implement all functions\nVERIFY: \\`cargo test dirty_tracker\\`\n\n## Edge Cases\n- Empty queue: get returns empty Vec\n- Invalid source_type string in DB: FromSqlConversionFailure error\n- Concurrent mark + get: ON CONFLICT handles race condition","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:27:09.434845Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:31:35.455315Z","closed_at":"2026-01-30T17:31:35.455127Z","close_reason":"Implemented dirty_tracker with mark_dirty_tx, get_dirty_sources, clear_dirty, record_dirty_error + 8 tests","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-38q","depends_on_id":"bd-36p","type":"blocks","created_at":"2026-01-30T15:29:34.914038Z","created_by":"tayloreernisse"},{"issue_id":"bd-38q","depends_on_id":"bd-hrs","type":"blocks","created_at":"2026-01-30T15:29:34.961390Z","created_by":"tayloreernisse"},{"issue_id":"bd-38q","depends_on_id":"bd-mem","type":"blocks","created_at":"2026-01-30T15:29:34.995197Z","created_by":"tayloreernisse"}]} {"id":"bd-39w","title":"[CP1] Test fixtures for mocked GitLab responses","description":"## Background\n\nTest fixtures provide mocked GitLab API responses for unit and integration tests. They enable testing without a live GitLab instance and ensure consistent test data across runs.\n\n## Approach\n\n### Fixture Files\n\nCreate JSON fixtures that match GitLab API response shapes:\n\n```\ntests/fixtures/\n├── gitlab_issue.json # Single issue\n├── gitlab_issues_page.json # Array of issues (pagination test)\n├── gitlab_discussion.json # Single discussion with notes\n└── gitlab_discussions_page.json # Array of discussions\n```\n\n### gitlab_issue.json\n\n```json\n{\n \"id\": 12345,\n \"iid\": 42,\n \"project_id\": 100,\n \"title\": \"Test issue title\",\n \"description\": \"Test issue description\",\n \"state\": \"opened\",\n \"created_at\": \"2024-01-15T10:00:00.000Z\",\n \"updated_at\": \"2024-01-20T15:30:00.000Z\",\n \"closed_at\": null,\n \"author\": {\n \"id\": 1,\n \"username\": \"testuser\",\n \"name\": \"Test User\"\n },\n \"labels\": [\"bug\", \"priority::high\"],\n \"web_url\": \"https://gitlab.example.com/group/project/-/issues/42\"\n}\n```\n\n### gitlab_discussion.json\n\n```json\n{\n \"id\": \"6a9c1750b37d513a43987b574953fceb50b03ce7\",\n \"individual_note\": false,\n \"notes\": [\n {\n \"id\": 1001,\n \"type\": \"DiscussionNote\",\n \"body\": \"First comment in thread\",\n \"author\": { \"id\": 1, \"username\": \"testuser\", \"name\": \"Test User\" },\n \"created_at\": \"2024-01-16T09:00:00.000Z\",\n \"updated_at\": \"2024-01-16T09:00:00.000Z\",\n \"system\": false,\n \"resolvable\": true,\n \"resolved\": false,\n \"resolved_by\": null,\n \"resolved_at\": null,\n \"position\": null\n },\n {\n \"id\": 1002,\n \"type\": \"DiscussionNote\",\n \"body\": \"Reply to first comment\",\n \"author\": { \"id\": 2, \"username\": \"reviewer\", \"name\": \"Reviewer\" },\n \"created_at\": \"2024-01-16T10:00:00.000Z\",\n \"updated_at\": \"2024-01-16T10:00:00.000Z\",\n \"system\": false,\n \"resolvable\": true,\n \"resolved\": false,\n \"resolved_by\": null,\n \"resolved_at\": null,\n \"position\": null\n }\n ]\n}\n```\n\n### Helper Module\n\n```rust\n// tests/fixtures/mod.rs\n\npub fn load_fixture(name: &str) -> T {\n let path = PathBuf::from(env!(\"CARGO_MANIFEST_DIR\"))\n .join(\"tests/fixtures\")\n .join(name);\n let content = std::fs::read_to_string(&path)\n .expect(&format!(\"Failed to read fixture: {}\", name));\n serde_json::from_str(&content)\n .expect(&format!(\"Failed to parse fixture: {}\", name))\n}\n\npub fn gitlab_issue() -> GitLabIssue {\n load_fixture(\"gitlab_issue.json\")\n}\n\npub fn gitlab_issues_page() -> Vec {\n load_fixture(\"gitlab_issues_page.json\")\n}\n\npub fn gitlab_discussion() -> GitLabDiscussion {\n load_fixture(\"gitlab_discussion.json\")\n}\n```\n\n## Acceptance Criteria\n\n- [ ] gitlab_issue.json deserializes to GitLabIssue correctly\n- [ ] gitlab_issues_page.json contains 3+ issues for pagination tests\n- [ ] gitlab_discussion.json contains multi-note thread\n- [ ] gitlab_discussions_page.json contains mix of individual_note true/false\n- [ ] At least one fixture includes system: true note\n- [ ] Helper functions load fixtures without panic\n\n## Files\n\n- tests/fixtures/gitlab_issue.json (create)\n- tests/fixtures/gitlab_issues_page.json (create)\n- tests/fixtures/gitlab_discussion.json (create)\n- tests/fixtures/gitlab_discussions_page.json (create)\n- tests/fixtures/mod.rs (create)\n\n## TDD Loop\n\nRED:\n```rust\n#[test] fn fixture_gitlab_issue_deserializes()\n#[test] fn fixture_gitlab_discussion_deserializes()\n#[test] fn fixture_has_system_note()\n```\n\nGREEN: Create JSON fixtures and helper module\n\nVERIFY: `cargo test fixture`\n\n## Edge Cases\n\n- Include issue with empty labels array\n- Include issue with null description\n- Include system note (system: true)\n- Include individual_note: true discussion (standalone comment)\n- Timestamps must be valid ISO 8601","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-25T17:02:38.433752Z","created_by":"tayloreernisse","updated_at":"2026-01-25T22:48:08.415195Z","closed_at":"2026-01-25T22:48:08.415132Z","close_reason":"Created 4 JSON fixture files (issue, issues_page, discussion, discussions_page) with helper tests - 6 tests passing","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-39w","depends_on_id":"bd-1np","type":"blocks","created_at":"2026-01-25T17:04:05.770848Z","created_by":"tayloreernisse"}]} {"id":"bd-3ae","title":"Epic: CP2 Gate A - MRs Only","description":"## Background\nGate A validates core MR ingestion works before adding complexity. Proves the cursor-based sync, pagination, and basic CLI work. This is the foundation - if Gate A fails, nothing else matters.\n\n## Acceptance Criteria (Pass/Fail)\n- [ ] `gi ingest --type=merge_requests` completes without error\n- [ ] `SELECT COUNT(*) FROM merge_requests` > 0\n- [ ] `gi list mrs --limit=5` shows 5 MRs with iid, title, state, author\n- [ ] `gi count mrs` shows total count matching DB query\n- [ ] MR with `state=locked` can be stored (if exists in test data)\n- [ ] Draft MR shows `draft=1` in DB and `[DRAFT]` in list output\n- [ ] `work_in_progress=true` MR shows `draft=1` (fallback works)\n- [ ] `head_sha` populated for MRs with commits\n- [ ] `references_short` and `references_full` populated\n- [ ] Re-run ingest shows \"0 new MRs\" or minimal refetch (cursor working)\n- [ ] Cursor saved at page boundary, not item boundary\n\n## Validation Script\n```bash\n#!/bin/bash\nset -e\n\nDB_PATH=\"${XDG_DATA_HOME:-$HOME/.local/share}/gitlab-inbox/db.sqlite3\"\n\necho \"=== Gate A: MRs Only ===\"\n\n# 1. Clear any existing MR data for clean test\necho \"Step 1: Reset MR cursor for clean test...\"\nsqlite3 \"$DB_PATH\" \"DELETE FROM sync_cursors WHERE resource_type = 'merge_requests';\"\n\n# 2. Run MR ingestion\necho \"Step 2: Ingest MRs...\"\ngi ingest --type=merge_requests\n\n# 3. Verify MRs exist\necho \"Step 3: Verify MR count...\"\nMR_COUNT=$(sqlite3 \"$DB_PATH\" \"SELECT COUNT(*) FROM merge_requests;\")\necho \" MR count: $MR_COUNT\"\n[ \"$MR_COUNT\" -gt 0 ] || { echo \"FAIL: No MRs ingested\"; exit 1; }\n\n# 4. Verify list command\necho \"Step 4: Test list command...\"\ngi list mrs --limit=5\n\n# 5. Verify count command\necho \"Step 5: Test count command...\"\ngi count mrs\n\n# 6. Verify draft handling\necho \"Step 6: Check draft MRs...\"\nDRAFT_COUNT=$(sqlite3 \"$DB_PATH\" \"SELECT COUNT(*) FROM merge_requests WHERE draft = 1;\")\necho \" Draft MR count: $DRAFT_COUNT\"\n\n# 7. Verify head_sha population\necho \"Step 7: Check head_sha...\"\nSHA_COUNT=$(sqlite3 \"$DB_PATH\" \"SELECT COUNT(*) FROM merge_requests WHERE head_sha IS NOT NULL;\")\necho \" MRs with head_sha: $SHA_COUNT\"\n\n# 8. Verify references\necho \"Step 8: Check references...\"\nREF_COUNT=$(sqlite3 \"$DB_PATH\" \"SELECT COUNT(*) FROM merge_requests WHERE references_short IS NOT NULL;\")\necho \" MRs with references: $REF_COUNT\"\n\n# 9. Verify cursor saved\necho \"Step 9: Check cursor...\"\nCURSOR=$(sqlite3 \"$DB_PATH\" \"SELECT updated_at, gitlab_id FROM sync_cursors WHERE resource_type = 'merge_requests';\")\necho \" Cursor: $CURSOR\"\n[ -n \"$CURSOR\" ] || { echo \"FAIL: Cursor not saved\"; exit 1; }\n\n# 10. Re-run and verify minimal refetch\necho \"Step 10: Re-run ingest (should be minimal)...\"\ngi ingest --type=merge_requests\n# Output should show minimal or zero new MRs\n\necho \"\"\necho \"=== Gate A: PASSED ===\"\n```\n\n## Test Commands (Quick Verification)\n```bash\n# Run these in order:\ngi ingest --type=merge_requests\ngi list mrs --limit=10\ngi count mrs\n\n# Verify in DB:\nsqlite3 ~/.local/share/gitlab-inbox/db.sqlite3 \"\n SELECT \n COUNT(*) as total,\n SUM(CASE WHEN draft = 1 THEN 1 ELSE 0 END) as drafts,\n SUM(CASE WHEN head_sha IS NOT NULL THEN 1 ELSE 0 END) as with_sha,\n SUM(CASE WHEN references_short IS NOT NULL THEN 1 ELSE 0 END) as with_refs\n FROM merge_requests;\n\"\n\n# Re-run (should be no-op):\ngi ingest --type=merge_requests\n```\n\n## Dependencies\nThis gate requires these beads to be complete:\n- bd-3ir (Database migration)\n- bd-5ta (GitLab MR types)\n- bd-34o (MR transformer)\n- bd-iba (GitLab client pagination)\n- bd-ser (MR ingestion module)\n\n## Edge Cases\n- `locked` state is transitional (merge in progress); may not exist in test data\n- Some older GitLab instances may not return `head_sha` for all MRs\n- `work_in_progress` is deprecated but should still work as fallback\n- Very large projects (10k+ MRs) may take significant time on first sync","status":"closed","priority":3,"issue_type":"task","created_at":"2026-01-26T22:06:00.966522Z","created_by":"tayloreernisse","updated_at":"2026-01-27T00:48:21.057298Z","closed_at":"2026-01-27T00:48:21.057225Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3ae","depends_on_id":"bd-iba","type":"blocks","created_at":"2026-01-26T22:08:55.576626Z","created_by":"tayloreernisse"},{"issue_id":"bd-3ae","depends_on_id":"bd-ser","type":"blocks","created_at":"2026-01-26T22:08:55.446814Z","created_by":"tayloreernisse"}]} -{"id":"bd-3as","title":"Implement timeline event collection and chronological interleaving","description":"## Background\n\nThe event collection phase is steps 4-5 of the timeline pipeline. It takes the seed + expanded entity sets and collects all their events from the database, then interleaves them chronologically. This produces the final ordered event list that the human/robot renderers consume.\n\n## Approach\n\nCreate \\`src/core/timeline_collect.rs\\`:\n\n```rust\nuse rusqlite::Connection;\nuse crate::core::timeline::{TimelineEvent, TimelineEventType, EntityRef, ExpandedEntityRef};\n\npub fn collect_events(\n conn: &Connection,\n seed_entities: &[EntityRef],\n expanded_entities: &[ExpandedEntityRef],\n evidence_notes: &[TimelineEvent], // from seed phase\n limit: usize, // -n flag (default 100)\n) -> Result> {\n let mut events: Vec = Vec::new();\n\n // For each entity (seed + expanded):\n let all_entities = /* combine seeds and expanded */;\n\n for entity in all_entities {\n let is_seed = /* check if entity is in seeds */;\n\n // 1. Entity creation event\n events.push(creation_event(conn, &entity, is_seed)?);\n\n // 2. State change events\n events.extend(state_events(conn, &entity, is_seed)?);\n\n // 3. Label change events\n events.extend(label_events(conn, &entity, is_seed)?);\n\n // 4. Milestone change events\n events.extend(milestone_events(conn, &entity, is_seed)?);\n\n // 5. Merge event (for MRs only)\n if entity.entity_type == \"merge_request\" {\n events.extend(merge_event(conn, &entity, is_seed)?);\n }\n }\n\n // 6. Add evidence notes from seed phase\n events.extend(evidence_notes.iter().cloned());\n\n // 7. Sort chronologically with stable tiebreak\n events.sort();\n\n // 8. Apply limit\n events.truncate(limit);\n\n Ok(events)\n}\n```\n\n### SQL Queries\n\n**Creation event:**\n```sql\n-- For issues:\nSELECT created_at, author_username, title FROM issues WHERE id = ?1\n-- For MRs:\nSELECT created_at, author_username, title FROM merge_requests WHERE id = ?1\n```\n\n**State events:**\n```sql\nSELECT state, actor_username, created_at\nFROM resource_state_events\nWHERE issue_id = ?1 OR merge_request_id = ?1\nORDER BY created_at ASC\n```\n\n**Label events:**\n```sql\nSELECT action, label_name, actor_username, created_at\nFROM resource_label_events\nWHERE issue_id = ?1 OR merge_request_id = ?1\nORDER BY created_at ASC\n```\n\n**Milestone events:**\n```sql\nSELECT action, milestone_title, actor_username, created_at\nFROM resource_milestone_events\nWHERE issue_id = ?1 OR merge_request_id = ?1\nORDER BY created_at ASC\n```\n\n**Merge event:**\n```sql\nSELECT rse.created_at, rse.actor_username, mr.merge_commit_sha\nFROM resource_state_events rse\nJOIN merge_requests mr ON mr.id = rse.merge_request_id\nWHERE rse.merge_request_id = ?1 AND rse.state = 'merged'\n```\n\nRegister in \\`src/core/mod.rs\\`: \\`pub mod timeline_collect;\\`\n\n## Acceptance Criteria\n\n- [ ] Collects all 6 event types: created, state_changed, label_added/removed, milestone_set/removed, merged, note_evidence\n- [ ] Events marked with is_seed=true for seed entities, false for expanded\n- [ ] Chronological sort with stable tiebreak (timestamp, entity_id, event_type)\n- [ ] Limit applied AFTER sorting (last N events truncated, not random)\n- [ ] Evidence notes from seed phase included in final list\n- [ ] MR merge events only collected for merge_request entities\n- [ ] Empty entity set returns empty events list\n- [ ] Module registered in \\`src/core/mod.rs\\`\n- [ ] \\`cargo check --all-targets\\` passes\n- [ ] \\`cargo clippy --all-targets -- -D warnings\\` passes\n\n## Files\n\n- \\`src/core/timeline_collect.rs\\` (NEW)\n- \\`src/core/mod.rs\\` (add \\`pub mod timeline_collect;\\`)\n\n## TDD Loop\n\nRED: Create tests:\n- \\`test_collect_creation_event\\` - entity produces Created event at created_at timestamp\n- \\`test_collect_state_events\\` - state changes produce StateChanged events\n- \\`test_collect_label_events\\` - label add/remove produce LabelAdded/LabelRemoved events\n- \\`test_collect_chronological_sort\\` - events from different entities interleave correctly\n- \\`test_collect_respects_limit\\` - limit=5 returns at most 5 events\n- \\`test_collect_marks_seed_flag\\` - seed entities have is_seed=true\n\nTests need in-memory DB with migrations 001-011 and test data in issues, merge_requests, resource_*_events tables.\n\nGREEN: Implement the SQL queries and event assembly.\n\nVERIFY: \\`cargo test --lib -- timeline_collect\\`\n\n## Edge Cases\n\n- Entity with no events (just created): returns only the Created event\n- Entity with 1000+ events: collect all, then apply limit at the end\n- State event with NULL actor: actor field is None in TimelineEvent\n- Label/milestone events may have same timestamp: tiebreak by event_type discriminant\n- MR with state='merged' but no merge_commit_sha (legacy data): merge_commit is None","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-02T21:33:08.703942Z","created_by":"tayloreernisse","updated_at":"2026-02-05T18:55:55.245222Z","compaction_level":0,"original_size":0,"labels":["gate-3","phase-b","query"],"dependencies":[{"issue_id":"bd-3as","depends_on_id":"bd-1ep","type":"blocks","created_at":"2026-02-02T21:33:37.618171Z","created_by":"tayloreernisse"},{"issue_id":"bd-3as","depends_on_id":"bd-ike","type":"parent-child","created_at":"2026-02-02T21:33:08.705605Z","created_by":"tayloreernisse"},{"issue_id":"bd-3as","depends_on_id":"bd-ypa","type":"blocks","created_at":"2026-02-02T21:33:37.575585Z","created_by":"tayloreernisse"}]} +{"id":"bd-3as","title":"Implement timeline event collection and chronological interleaving","description":"## Background\n\nThe event collection phase is steps 4-5 of the timeline pipeline (spec Section 3.2). It takes the seed + expanded entity sets and collects all their events from the database, then interleaves them chronologically. This produces the final ordered event list that the human/robot renderers consume.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.2 steps 4-5, Section 3.3 (Event Model).\n\n## Approach\n\nCreate `src/core/timeline_collect.rs`:\n\n```rust\nuse rusqlite::Connection;\nuse crate::core::timeline::{TimelineEvent, TimelineEventType, EntityRef, ExpandedEntityRef};\n\npub fn collect_events(\n conn: &Connection,\n seed_entities: &[EntityRef],\n expanded_entities: &[ExpandedEntityRef],\n evidence_notes: &[TimelineEvent], // from seed phase\n limit: usize, // -n flag (default 100)\n) -> Result> {\n let mut events: Vec = Vec::new();\n\n let all_entities = /* combine seeds and expanded */;\n\n for entity in all_entities {\n let is_seed = /* check if entity is in seeds */;\n\n // Spec Section 3.2 step 4: COLLECT EVENTS\n // 1. Entity creation (created_at from issues/merge_requests)\n events.push(creation_event(conn, &entity, is_seed)?);\n\n // 2. State changes (resource_state_events)\n events.extend(state_events(conn, &entity, is_seed)?);\n\n // 3. Label changes (resource_label_events)\n events.extend(label_events(conn, &entity, is_seed)?);\n\n // 4. Milestone changes (resource_milestone_events)\n events.extend(milestone_events(conn, &entity, is_seed)?);\n\n // 5. Merge events (merged_at from merge_requests, for MRs only)\n if entity.entity_type == \"merge_request\" {\n events.extend(merge_event(conn, &entity, is_seed)?);\n }\n }\n\n // 6. Evidence notes from seed phase (spec: \"Evidence-bearing notes\")\n events.extend(evidence_notes.iter().cloned());\n\n // Spec Section 3.2 step 5: INTERLEAVE (chronological sort)\n events.sort();\n\n // Apply limit\n events.truncate(limit);\n\n Ok(events)\n}\n```\n\n### SQL Queries\n\n**Creation event** -> `TimelineEventType::Created`:\n```sql\n-- For issues:\nSELECT created_at, author_username, title, web_url FROM issues WHERE id = ?1\n-- For MRs:\nSELECT created_at, author_username, title, web_url FROM merge_requests WHERE id = ?1\n```\n\n**State events** -> `TimelineEventType::StateChanged { state }`:\nNote: spec Section 3.3 uses `StateChanged { state: String }` (just the target state).\n```sql\nSELECT state, actor_username, created_at\nFROM resource_state_events\nWHERE issue_id = ?1 OR merge_request_id = ?1\nORDER BY created_at ASC\n```\n\n**Label events** -> `TimelineEventType::LabelAdded { label }` / `LabelRemoved { label }`:\n```sql\nSELECT action, label_name, actor_username, created_at\nFROM resource_label_events\nWHERE issue_id = ?1 OR merge_request_id = ?1\nORDER BY created_at ASC\n```\n\n**Milestone events** -> `TimelineEventType::MilestoneSet { milestone }` / `MilestoneRemoved { milestone }`:\n```sql\nSELECT action, milestone_title, actor_username, created_at\nFROM resource_milestone_events\nWHERE issue_id = ?1 OR merge_request_id = ?1\nORDER BY created_at ASC\n```\n\n**Merge event** -> `TimelineEventType::Merged` (unit variant per spec):\n```sql\nSELECT rse.created_at, rse.actor_username\nFROM resource_state_events rse\nWHERE rse.merge_request_id = ?1 AND rse.state = 'merged'\n```\n\n### URL Construction\n\nEach event needs a `url` field (spec Section 3.3). Pattern:\n- Issues: `{gitlab_url}/{project_path}/-/issues/{iid}`\n- MRs: `{gitlab_url}/{project_path}/-/merge_requests/{iid}`\n- Evidence notes: `{issue_or_mr_url}#note_{note_id}` (when note_id is available)\n\nUse `web_url` from the entity's DB row when available; fall back to construction.\n\nRegister in `src/core/mod.rs`: `pub mod timeline_collect;`\n\n## Acceptance Criteria\n\n- [ ] Collects all 6 event types per spec Section 3.2 step 4: created, state_changed, label_added/removed, milestone_set/removed, merged, note_evidence\n- [ ] StateChanged uses `{ state: String }` per spec (not from/to)\n- [ ] Merged is a unit variant per spec (no inner fields)\n- [ ] Each event has `url: Option` populated from entity web_url\n- [ ] Events marked with is_seed=true for seed entities, false for expanded\n- [ ] Chronological sort with stable tiebreak (timestamp, entity_id, event_type)\n- [ ] Limit applied AFTER sorting (last N events truncated, not random)\n- [ ] Evidence notes from seed phase included in final list\n- [ ] MR merge events only collected for merge_request entities\n- [ ] Empty entity set returns empty events list\n- [ ] Module registered in `src/core/mod.rs`\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/core/timeline_collect.rs` (NEW)\n- `src/core/mod.rs` (add `pub mod timeline_collect;`)\n\n## TDD Loop\n\nRED: Create tests:\n- `test_collect_creation_event` - entity produces Created event at created_at timestamp\n- `test_collect_state_events` - state changes produce StateChanged { state } events\n- `test_collect_label_events` - label add/remove produce LabelAdded/LabelRemoved events\n- `test_collect_chronological_sort` - events from different entities interleave correctly\n- `test_collect_respects_limit` - limit=5 returns at most 5 events\n- `test_collect_marks_seed_flag` - seed entities have is_seed=true\n- `test_collect_includes_url` - events have url populated from entity web_url\n\nTests need in-memory DB with migrations 001-011 and test data in issues, merge_requests, resource_*_events tables.\n\nGREEN: Implement the SQL queries and event assembly.\n\nVERIFY: `cargo test --lib -- timeline_collect`\n\n## Edge Cases\n\n- Entity with no events (just created): returns only the Created event\n- Entity with 1000+ events: collect all, then apply limit at the end\n- State event with NULL actor: actor field is None in TimelineEvent\n- Label/milestone events may have same timestamp: tiebreak by event_type discriminant\n- MR with state='merged' but no merge_commit_sha (legacy data): Merged variant is unit, no issue\n- URL construction: handle entities without web_url (old data) by setting url to None","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-02T21:33:08.703942Z","created_by":"tayloreernisse","updated_at":"2026-02-05T19:10:47.601301Z","compaction_level":0,"original_size":0,"labels":["gate-3","phase-b","query"],"dependencies":[{"issue_id":"bd-3as","depends_on_id":"bd-1ep","type":"blocks","created_at":"2026-02-02T21:33:37.618171Z","created_by":"tayloreernisse"},{"issue_id":"bd-3as","depends_on_id":"bd-ike","type":"parent-child","created_at":"2026-02-02T21:33:08.705605Z","created_by":"tayloreernisse"},{"issue_id":"bd-3as","depends_on_id":"bd-ypa","type":"blocks","created_at":"2026-02-02T21:33:37.575585Z","created_by":"tayloreernisse"}]} {"id":"bd-3bo","title":"[CP1] gi count issues/discussions/notes commands","description":"Count entities in the database.\n\nCommands:\n- gi count issues → 'Issues: N'\n- gi count discussions --type=issue → 'Issue Discussions: N'\n- gi count notes --type=issue → 'Issue Notes: N (excluding M system)'\n\nFiles: src/cli/commands/count.ts\nDone when: Counts match expected values from GitLab","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T15:20:16.190875Z","created_by":"tayloreernisse","updated_at":"2026-01-25T15:21:35.156293Z","deleted_at":"2026-01-25T15:21:35.156290Z","deleted_by":"tayloreernisse","delete_reason":"delete","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-3er","title":"OBSERV Epic: Phase 3 - Performance Metrics Collection","description":"StageTiming struct, custom MetricsLayer tracing subscriber layer, span-to-metrics extraction, robot JSON enrichment with meta.stages, human-readable timing summary.\n\nDepends on: Phase 2 (spans must exist to extract timing from)\nUnblocks: Phase 4 (sync history needs Vec to store)\n\nFiles: src/core/metrics.rs (new), src/cli/commands/sync.rs, src/cli/commands/ingest.rs, src/main.rs\n\nAcceptance criteria (PRD Section 6.3):\n- lore --robot sync includes meta.run_id and meta.stages array\n- Each stage has name, elapsed_ms, items_processed\n- Top-level stages have sub_stages arrays\n- Interactive sync prints timing summary table\n- Zero-value fields omitted from JSON","status":"closed","priority":2,"issue_type":"epic","created_at":"2026-02-04T15:53:27.415566Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:32:56.743477Z","closed_at":"2026-02-04T17:32:56.743430Z","close_reason":"All Phase 3 tasks complete: StageTiming struct, MetricsLayer, span field recording, robot JSON enrichment with stages, and human-readable timing summary","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-3er","depends_on_id":"bd-2ni","type":"blocks","created_at":"2026-02-04T15:55:19.101775Z","created_by":"tayloreernisse"}]} {"id":"bd-3eu","title":"Implement hybrid search with adaptive recall","description":"## Background\nHybrid search is the top-level search orchestrator that combines FTS5 lexical results with sqlite-vec semantic results via RRF ranking. It supports three modes (Lexical, Semantic, Hybrid) and implements adaptive recall (wider initial fetch when filters are applied) and graceful degradation (falls back to FTS when Ollama is unavailable). All modes use RRF for consistent --explain output.\n\n## Approach\nCreate `src/search/hybrid.rs` per PRD Section 5.3.\n\n**Key types:**\n```rust\n#[derive(Debug, Clone, Copy, PartialEq, Eq)]\npub enum SearchMode {\n Hybrid, // Vector + FTS with RRF\n Lexical, // FTS only\n Semantic, // Vector only\n}\n\nimpl SearchMode {\n pub fn from_str(s: &str) -> Option {\n match s.to_lowercase().as_str() {\n \"hybrid\" => Some(Self::Hybrid),\n \"lexical\" | \"fts\" => Some(Self::Lexical),\n \"semantic\" | \"vector\" => Some(Self::Semantic),\n _ => None,\n }\n }\n\n pub fn as_str(&self) -> &'static str {\n match self {\n Self::Hybrid => \"hybrid\",\n Self::Lexical => \"lexical\",\n Self::Semantic => \"semantic\",\n }\n }\n}\n\npub struct HybridResult {\n pub document_id: i64,\n pub score: f64, // Normalized RRF score (0-1)\n pub vector_rank: Option,\n pub fts_rank: Option,\n pub rrf_score: f64, // Raw RRF score\n}\n```\n\n**Core function (ASYNC, PRD-exact signature):**\n```rust\npub async fn search_hybrid(\n conn: &Connection,\n client: Option<&OllamaClient>, // None if Ollama unavailable\n ollama_base_url: Option<&str>, // For actionable error messages\n query: &str,\n mode: SearchMode,\n filters: &SearchFilters,\n fts_mode: FtsQueryMode,\n) -> Result<(Vec, Vec)>\n```\n\n**IMPORTANT — client is `Option<&OllamaClient>`:** This enables graceful degradation. When Ollama is unavailable, the caller passes `None` and hybrid mode falls back to FTS-only with a warning. The `ollama_base_url` is separate so error messages can include it even when client is None.\n\n**Adaptive recall constants (PRD Section 5.3):**\n```rust\nconst BASE_RECALL_MIN: usize = 50;\nconst FILTERED_RECALL_MIN: usize = 200;\nconst RECALL_CAP: usize = 1500;\n```\n\n**Recall formula:**\n```rust\nlet requested = filters.clamp_limit();\nlet top_k = if filters.has_any_filter() {\n (requested * 50).max(FILTERED_RECALL_MIN).min(RECALL_CAP)\n} else {\n (requested * 10).max(BASE_RECALL_MIN).min(RECALL_CAP)\n};\n```\n\n**Mode behavior:**\n- **Lexical:** FTS only -> rank_rrf with empty vector list (single-list RRF)\n- **Semantic:** Vector only -> requires client (error if None) -> rank_rrf with empty FTS list\n- **Hybrid:** Both FTS + vector -> rank_rrf with both lists\n- **Hybrid with client=None:** Graceful degradation to Lexical with warning, NOT error\n\n**Graceful degradation logic:**\n```rust\nSearchMode::Hybrid => {\n let fts_results = search_fts(conn, query, top_k, fts_mode)?;\n let fts_tuples: Vec<_> = fts_results.iter().map(|r| (r.document_id, r.rank)).collect();\n\n match client {\n Some(client) => {\n let query_embedding = client.embed_batch(vec\\![query.to_string()]).await?;\n let embedding = query_embedding.into_iter().next().unwrap();\n let vec_results = search_vector(conn, &embedding, top_k)?;\n let vec_tuples: Vec<_> = vec_results.iter().map(|r| (r.document_id, r.distance)).collect();\n let ranked = rank_rrf(&vec_tuples, &fts_tuples);\n // ... map to HybridResult\n Ok((results, warnings))\n }\n None => {\n warnings.push(\"Ollama unavailable, falling back to lexical search\".into());\n let ranked = rank_rrf(&[], &fts_tuples);\n // ... map to HybridResult\n Ok((results, warnings))\n }\n }\n}\n```\n\n## Acceptance Criteria\n- [ ] Function is `async` (per PRD — Ollama client methods are async)\n- [ ] Signature takes `client: Option<&OllamaClient>` (not required)\n- [ ] Signature takes `ollama_base_url: Option<&str>` for actionable error messages\n- [ ] Returns `(Vec, Vec)` — results + warnings\n- [ ] Lexical mode: FTS-only results ranked via RRF (single list)\n- [ ] Semantic mode: vector-only results ranked via RRF; error if client is None\n- [ ] Hybrid mode: both FTS + vector results merged via RRF\n- [ ] Graceful degradation: client=None in Hybrid falls back to FTS with warning (not error)\n- [ ] Adaptive recall: unfiltered max(50, limit*10), filtered max(200, limit*50), capped 1500\n- [ ] All modes produce consistent --explain output (vector_rank, fts_rank, rrf_score)\n- [ ] SearchMode::from_str accepts aliases: \"fts\" for Lexical, \"vector\" for Semantic\n- [ ] `cargo build` succeeds\n\n## Files\n- `src/search/hybrid.rs` — new file\n- `src/search/mod.rs` — add `pub use hybrid::{search_hybrid, HybridResult, SearchMode};`\n\n## TDD Loop\nRED: Tests (some integration, some unit):\n- `test_lexical_mode` — FTS results only\n- `test_semantic_mode` — vector results only\n- `test_hybrid_mode` — both lists merged\n- `test_graceful_degradation` — None client falls back to FTS with warning in warnings vec\n- `test_adaptive_recall_unfiltered` — recall = max(50, limit*10)\n- `test_adaptive_recall_filtered` — recall = max(200, limit*50)\n- `test_recall_cap` — never exceeds 1500\n- `test_search_mode_from_str` — \"hybrid\", \"lexical\", \"fts\", \"semantic\", \"vector\", invalid\nGREEN: Implement search_hybrid\nVERIFY: `cargo test hybrid`\n\n## Edge Cases\n- Both FTS and vector return zero results: empty output (not error)\n- FTS returns results but vector returns empty: RRF still works (single-list)\n- Very high limit (100) with filters: recall = min(5000, 1500) = 1500\n- Semantic mode with client=None: error (OllamaUnavailable), not degradation\n- Semantic mode with 0% coverage: return LoreError::EmbeddingsNotBuilt","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:26:50.343002Z","created_by":"tayloreernisse","updated_at":"2026-01-30T17:56:16.631748Z","closed_at":"2026-01-30T17:56:16.631682Z","close_reason":"Implemented hybrid search with 3 modes (lexical/semantic/hybrid), graceful degradation when Ollama unavailable, adaptive recall (50-1500), RRF fusion. 6 tests pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-3eu","depends_on_id":"bd-1k1","type":"blocks","created_at":"2026-01-30T15:29:24.913458Z","created_by":"tayloreernisse"},{"issue_id":"bd-3eu","depends_on_id":"bd-335","type":"blocks","created_at":"2026-01-30T15:29:25.025502Z","created_by":"tayloreernisse"},{"issue_id":"bd-3eu","depends_on_id":"bd-3ez","type":"blocks","created_at":"2026-01-30T15:29:24.987809Z","created_by":"tayloreernisse"},{"issue_id":"bd-3eu","depends_on_id":"bd-bjo","type":"blocks","created_at":"2026-01-30T15:29:24.950761Z","created_by":"tayloreernisse"}]} @@ -152,7 +152,7 @@ {"id":"bd-cbo","title":"[CP1] Cargo.toml updates - async-stream and futures","description":"Add required dependencies for async pagination streams.\n\n## Changes\nAdd to Cargo.toml:\n- async-stream = \"0.3\"\n- futures = \"0.3\"\n\n## Why\nThe pagination methods use async generators which require async-stream crate.\nfutures crate provides StreamExt for consuming the streams.\n\n## Done When\n- cargo check passes with new deps\n- No unused dependency warnings\n\nFiles: Cargo.toml","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T15:42:31.143927Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.661666Z","deleted_at":"2026-01-25T17:02:01.661662Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-cq2","title":"[CP1] Integration tests for label linkage","description":"Integration tests verifying label linkage and stale removal.\n\n## Tests (tests/label_linkage_tests.rs)\n\n- clears_existing_labels_before_linking_new_set\n- removes_stale_label_links_on_issue_update\n- handles_issue_with_all_labels_removed\n- preserves_labels_that_still_exist\n\n## Test Scenario\n1. Create issue with labels [A, B]\n2. Verify issue_labels has links to A and B\n3. Update issue with labels [B, C]\n4. Verify A link removed, B preserved, C added\n\n## Why This Matters\nThe clear-and-relink pattern ensures GitLab reality is reflected locally.\nIf we only INSERT, removed labels would persist incorrectly.\n\nFiles: tests/label_linkage_tests.rs\nDone when: Stale label links correctly removed on resync","status":"tombstone","priority":3,"issue_type":"task","created_at":"2026-01-25T16:59:10.665771Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:02.062192Z","deleted_at":"2026-01-25T17:02:02.062188Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0} {"id":"bd-czk","title":"Add entity_references table to migration 010","description":"## Background\nThe entity_references table is now part of migration 011 (combined with resource event tables and dependent fetch queue). This bead is satisfied by bd-hu3 since the entity_references table schema is included in the same migration.\n\n## Approach\nThis bead's work is folded into bd-hu3 (Write migration 011). The entity_references table from Phase B spec §2.2 is included in migrations/011_resource_events.sql alongside the event tables and queue.\n\nThe entity_references schema includes:\n- source/target entity type + id with reference_type and source_method\n- Unresolved reference support (target_entity_id NULL with target_project_path + target_entity_iid)\n- UNIQUE constraint using COALESCE for nullable columns\n- Partial indexes for source, target (where not null), and unresolved refs\n\nNo separate migration file needed — this is in 011.\n\n## Acceptance Criteria\n- [ ] entity_references table exists in migration 011 (verified by bd-hu3)\n- [ ] UNIQUE constraint handles NULL columns via COALESCE\n- [ ] Indexes created: source composite, target composite (partial), unresolved (partial)\n- [ ] reference_type CHECK includes 'closes', 'mentioned', 'related'\n- [ ] source_method CHECK includes 'api_closes_issues', 'api_state_event', 'system_note_parse'\n\n## Files\n- migrations/011_resource_events.sql (part of bd-hu3)\n\n## TDD Loop\nCovered by bd-hu3's test_migration_011_entity_references_dedup test.\n\nVERIFY: `cargo test migration_tests -- --nocapture`\n\n## Edge Cases\n- Same as bd-hu3's entity_references edge cases","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:32:33.506883Z","created_by":"tayloreernisse","updated_at":"2026-02-02T22:42:06.104237Z","closed_at":"2026-02-02T22:42:06.104190Z","close_reason":"Work folded into bd-hu3 (migration 011 includes entity_references table)","compaction_level":0,"original_size":0,"labels":["gate-2","phase-b","schema"]} -{"id":"bd-dty","title":"Implement timeline robot mode JSON output","description":"## Background\n\nThe robot mode JSON output for timeline must follow the established pattern from other commands: \\`{ok: true, data: {...}, meta: {...}}\\`. This bead defines the exact JSON schema and implements the serialization.\n\n## Approach\n\nCreate \\`print_timeline_json()\\` in \\`src/cli/commands/timeline.rs\\`:\n\n```rust\n#[derive(Serialize)]\nstruct TimelineJsonOutput {\n ok: bool,\n data: TimelineJsonData,\n meta: TimelineJsonMeta,\n}\n\n#[derive(Serialize)]\nstruct TimelineJsonData {\n query: String,\n event_count: usize,\n seed_entities: Vec,\n expanded_entities: Vec,\n unresolved_references: Vec,\n events: Vec,\n}\n\n#[derive(Serialize)]\nstruct TimelineJsonMeta {\n search_mode: String, // \"fts5\"\n expansion_depth: u32,\n expand_mentions: bool,\n total_entities: usize, // seeds + expanded\n total_events: usize, // before limit\n evidence_notes_included: usize,\n unresolved_references: usize,\n showing: usize, // after limit\n}\n\n#[derive(Serialize)]\nstruct TimelineEventJson {\n timestamp: String, // ISO 8601\n entity_type: String,\n entity_iid: i64,\n project_path: String,\n event_type: TimelineEventType, // already Serialize via serde tag\n summary: String,\n actor: Option,\n is_seed: bool,\n}\n\n#[derive(Serialize)]\nstruct EntityRefJson {\n entity_type: String,\n entity_iid: i64,\n project_path: String,\n}\n\n#[derive(Serialize)]\nstruct ExpandedEntityRefJson {\n entity_type: String,\n entity_iid: i64,\n project_path: String,\n provenance_seed_iid: i64,\n edge_type: String,\n depth: u32,\n}\n\npub fn print_timeline_json(result: &TimelineResult, meta: &TimelineQueryMeta) {\n // Convert timestamps from ms epoch to ISO 8601 using core::time::ms_to_iso()\n // Serialize and println!\n}\n```\n\n### Timestamp Conversion\n\nAll internal timestamps are ms epoch UTC. Robot output uses ISO 8601:\n```rust\nuse crate::core::time::ms_to_iso;\n\nlet iso = ms_to_iso(event.timestamp); // \"2024-01-15T10:30:00Z\"\n```\n\n## Acceptance Criteria\n\n- [ ] \\`lore --robot timeline \"query\"\\` outputs valid JSON to stdout\n- [ ] JSON matches the \\`{ok, data, meta}\\` envelope pattern\n- [ ] All timestamps in ISO 8601 format (not ms epoch)\n- [ ] event_type uses serde tagged enum (\\`{\"kind\": \"state_changed\", \"from\": \"opened\", \"to\": \"closed\"}\\`)\n- [ ] meta.total_events = count before limit; meta.showing = count after limit\n- [ ] meta.evidence_notes_included counts NoteEvidence events\n- [ ] unresolved_references listed in both data and meta (data has details, meta has count)\n- [ ] \\`cargo check --all-targets\\` passes\n- [ ] \\`cargo clippy --all-targets -- -D warnings\\` passes\n\n## Files\n\n- \\`src/cli/commands/timeline.rs\\` (add print_timeline_json function + JSON structs)\n- \\`src/cli/commands/mod.rs\\` (re-export print_timeline_json)\n\n## TDD Loop\n\nRED: No unit test for JSON output shape -- verify with:\n```bash\nlore --robot timeline \"test\" | jq '.data.events[0].event_type.kind'\nlore --robot timeline \"test\" | jq '.meta'\n```\n\nGREEN: Implement the JSON structs and serialization.\n\nVERIFY: \\`cargo check --all-targets && lore --robot timeline \"test\" | python3 -m json.tool\\`\n\n## Edge Cases\n\n- Empty results: events array is [], meta.showing=0, meta.total_events=0\n- Very long query string: include full query in data.query (no truncation)\n- Unicode in event summaries: serde handles UTF-8 natively\n- Null actor: serializes as \\`\"actor\": null\\` (not omitted)","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-02T21:33:28.374690Z","created_by":"tayloreernisse","updated_at":"2026-02-05T18:56:14.032437Z","compaction_level":0,"original_size":0,"labels":["cli","gate-3","phase-b","robot-mode"],"dependencies":[{"issue_id":"bd-dty","depends_on_id":"bd-3as","type":"blocks","created_at":"2026-02-02T21:33:37.703617Z","created_by":"tayloreernisse"},{"issue_id":"bd-dty","depends_on_id":"bd-ike","type":"parent-child","created_at":"2026-02-02T21:33:28.377349Z","created_by":"tayloreernisse"}]} +{"id":"bd-dty","title":"Implement timeline robot mode JSON output","description":"## Background\n\nThe robot mode JSON output for timeline must follow the established pattern from other commands: `{ok: true, data: {...}, meta: {...}}`. This bead defines the exact JSON schema and implements the serialization.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.5 (Robot Mode JSON). The JSON output structure MUST match the spec exactly -- this is the contract for AI agent consumers.\n\n## Approach\n\nCreate `print_timeline_json()` in `src/cli/commands/timeline.rs`:\n\n```rust\n#[derive(Serialize)]\nstruct TimelineJsonOutput {\n ok: bool,\n data: TimelineJsonData,\n meta: TimelineJsonMeta,\n}\n\n#[derive(Serialize)]\nstruct TimelineJsonData {\n query: String,\n event_count: usize,\n seed_entities: Vec,\n expanded_entities: Vec,\n unresolved_references: Vec,\n events: Vec,\n}\n\n#[derive(Serialize)]\nstruct TimelineJsonMeta {\n search_mode: String, // \"lexical\"\n expansion_depth: u32,\n expand_mentions: bool,\n total_entities: usize, // seeds + expanded\n total_events: usize, // before limit\n evidence_notes_included: usize,\n unresolved_references: usize,\n showing: usize, // after limit\n}\n\n/// Spec Section 3.5: seed_entities format\n#[derive(Serialize)]\nstruct SeedEntityJson {\n #[serde(rename = \"type\")]\n entity_type: String, // \"issue\" | \"merge_request\"\n iid: i64,\n project: String, // NOTE: \"project\" not \"project_path\"\n}\n\n/// Spec Section 3.5: expanded_entities format with nested \"via\" object\n#[derive(Serialize)]\nstruct ExpandedEntityJson {\n #[serde(rename = \"type\")]\n entity_type: String,\n iid: i64,\n project: String,\n depth: u32,\n via: ViaJson, // NESTED per spec -- not flat fields\n}\n\n/// Spec Section 3.5: the \"via\" provenance object\n#[derive(Serialize)]\nstruct ViaJson {\n from: SeedEntityJson, // which entity referenced this one\n reference_type: String, // \"closes\", \"mentioned\", \"related\"\n source_method: String, // \"api\", \"note_parse\", etc.\n}\n\n/// Spec Section 3.5: unresolved_references format\n#[derive(Serialize)]\nstruct UnresolvedRefJson {\n source: SeedEntityJson, // entity containing the reference\n target_project: String,\n target_type: String,\n target_iid: i64,\n reference_type: String,\n}\n\n/// Spec Section 3.5: events array element format\n#[derive(Serialize)]\nstruct TimelineEventJson {\n timestamp: String, // ISO 8601\n entity_type: String,\n entity_iid: i64,\n project: String, // NOTE: \"project\" not \"project_path\"\n event_type: String, // flat string: \"created\", \"state_changed\", etc.\n summary: String,\n actor: Option,\n url: Option, // web URL for the event (spec requirement)\n is_seed: bool,\n details: Option, // event-type-specific details (spec Section 3.5)\n}\n```\n\n### JSON Key Names (from spec)\n\nThe spec uses `\"project\"` not `\"project_path\"`, and `\"type\"` not `\"entity_type\"` at the top level of entity objects. Use `#[serde(rename = \"type\")]` to achieve this.\n\n### Details Object\n\nThe spec (Section 3.5) shows a `details` object on events with type-specific fields:\n- created: `{ \"labels\": [...] }`\n- note_evidence: `{ \"note_id\": 12345, \"snippet\": \"...\" }`\n- state_changed: `{ \"state\": \"closed\" }`\n- label_added: `{ \"label\": \"security-review\" }`\n\n### Timestamp Conversion\n\nAll internal timestamps are ms epoch UTC. Robot output uses ISO 8601:\n```rust\nuse crate::core::time::ms_to_iso;\nlet iso = ms_to_iso(event.timestamp); // \"2024-01-15T10:30:00Z\"\n```\n\n### Example Output (from spec Section 3.5)\n\n```json\n{\n \"ok\": true,\n \"data\": {\n \"query\": \"auth migration\",\n \"event_count\": 12,\n \"seed_entities\": [\n { \"type\": \"issue\", \"iid\": 234, \"project\": \"group/repo\" }\n ],\n \"expanded_entities\": [\n {\n \"type\": \"issue\", \"iid\": 299, \"project\": \"group/repo\",\n \"depth\": 1,\n \"via\": {\n \"from\": { \"type\": \"merge_request\", \"iid\": 567, \"project\": \"group/repo\" },\n \"reference_type\": \"closes\",\n \"source_method\": \"api_closes_issues\"\n }\n }\n ],\n \"unresolved_references\": [\n {\n \"source\": { \"type\": \"merge_request\", \"iid\": 567, \"project\": \"group/repo\" },\n \"target_project\": \"group/other-repo\",\n \"target_type\": \"issue\",\n \"target_iid\": 42,\n \"reference_type\": \"mentioned\"\n }\n ],\n \"events\": [\n {\n \"timestamp\": \"2024-03-15T10:00:00Z\",\n \"entity_type\": \"issue\",\n \"entity_iid\": 234,\n \"project\": \"group/repo\",\n \"event_type\": \"created\",\n \"summary\": \"Migrate to OAuth2\",\n \"actor\": \"alice\",\n \"url\": \"https://gitlab.com/group/repo/-/issues/234\",\n \"is_seed\": true,\n \"details\": { \"labels\": [\"auth\", \"breaking-change\"] }\n }\n ]\n },\n \"meta\": {\n \"search_mode\": \"lexical\",\n \"expansion_depth\": 1,\n \"expand_mentions\": false,\n \"total_entities\": 3,\n \"total_events\": 12,\n \"evidence_notes_included\": 4,\n \"unresolved_references\": 1\n }\n}\n```\n\n## Acceptance Criteria\n\n- [ ] `lore --robot timeline \"query\"` outputs valid JSON to stdout\n- [ ] JSON matches the `{ok, data, meta}` envelope pattern\n- [ ] All timestamps in ISO 8601 format (not ms epoch)\n- [ ] Entity objects use `\"type\"` key (not `\"entity_type\"`) and `\"project\"` key (not `\"project_path\"`) per spec\n- [ ] `expanded_entities` uses nested `\"via\"` object with `\"from\"`, `\"reference_type\"`, `\"source_method\"` per spec Section 3.5\n- [ ] `unresolved_references` includes `\"source\"` entity object per spec\n- [ ] Events include `\"url\"` field when available\n- [ ] Events include `\"details\"` object with event-type-specific fields per spec\n- [ ] meta.total_events = count before limit; meta.showing = count after limit\n- [ ] meta.evidence_notes_included counts NoteEvidence events\n- [ ] meta.search_mode = \"lexical\" (FTS5-based)\n- [ ] unresolved_references listed in both data and meta (data has details, meta has count)\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/cli/commands/timeline.rs` (add print_timeline_json function + JSON structs)\n- `src/cli/commands/mod.rs` (re-export print_timeline_json)\n\n## TDD Loop\n\nRED: No unit test for JSON output shape -- verify with:\n```bash\nlore --robot timeline \"test\" | jq '.data.expanded_entities[0].via.from'\nlore --robot timeline \"test\" | jq '.data.events[0].url'\nlore --robot timeline \"test\" | jq '.data.events[0].details'\nlore --robot timeline \"test\" | jq '.meta'\n```\n\nGREEN: Implement the JSON structs and serialization.\n\nVERIFY: `cargo check --all-targets && lore --robot timeline \"test\" | python3 -m json.tool`\n\n## Edge Cases\n\n- Empty results: events array is [], meta.showing=0, meta.total_events=0\n- Very long query string: include full query in data.query (no truncation)\n- Unicode in event summaries: serde handles UTF-8 natively\n- Null actor: serializes as `\"actor\": null` (not omitted)\n- Null url: serializes as `\"url\": null` (not omitted)\n- Events without details: `\"details\": null`\n- source_method value: use actual DB values (\"api\", \"note_parse\", \"description_parse\") not spec's original values -- migration 011 CHECK constraint is the source of truth","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-02T21:33:28.374690Z","created_by":"tayloreernisse","updated_at":"2026-02-05T19:09:40.287155Z","compaction_level":0,"original_size":0,"labels":["cli","gate-3","phase-b","robot-mode"],"dependencies":[{"issue_id":"bd-dty","depends_on_id":"bd-3as","type":"blocks","created_at":"2026-02-02T21:33:37.703617Z","created_by":"tayloreernisse"},{"issue_id":"bd-dty","depends_on_id":"bd-ike","type":"parent-child","created_at":"2026-02-02T21:33:28.377349Z","created_by":"tayloreernisse"}]} {"id":"bd-epj","title":"[CP0] Config loading with Zod validation","description":"## Background\n\nConfig loading is critical infrastructure - every CLI command needs the config. Uses Zod for schema validation with sensible defaults. Must handle missing files gracefully with typed errors.\n\nReference: docs/prd/checkpoint-0.md sections \"Configuration Schema\", \"Config Resolution Order\"\n\n## Approach\n\n**src/core/config.ts:**\n```typescript\nimport { z } from 'zod';\nimport { readFileSync } from 'node:fs';\nimport { ConfigNotFoundError, ConfigValidationError } from './errors';\nimport { getConfigPath } from './paths';\n\nexport const ConfigSchema = z.object({\n gitlab: z.object({\n baseUrl: z.string().url(),\n tokenEnvVar: z.string().default('GITLAB_TOKEN'),\n }),\n projects: z.array(z.object({\n path: z.string().min(1),\n })).min(1),\n sync: z.object({\n backfillDays: z.number().int().positive().default(14),\n staleLockMinutes: z.number().int().positive().default(10),\n heartbeatIntervalSeconds: z.number().int().positive().default(30),\n cursorRewindSeconds: z.number().int().nonnegative().default(2),\n primaryConcurrency: z.number().int().positive().default(4),\n dependentConcurrency: z.number().int().positive().default(2),\n }).default({}),\n storage: z.object({\n dbPath: z.string().optional(),\n backupDir: z.string().optional(),\n compressRawPayloads: z.boolean().default(true),\n }).default({}),\n embedding: z.object({\n provider: z.literal('ollama').default('ollama'),\n model: z.string().default('nomic-embed-text'),\n baseUrl: z.string().url().default('http://localhost:11434'),\n concurrency: z.number().int().positive().default(4),\n }).default({}),\n});\n\nexport type Config = z.infer;\n\nexport function loadConfig(cliOverride?: string): Config {\n const path = getConfigPath(cliOverride);\n // throws ConfigNotFoundError if missing\n // throws ConfigValidationError if invalid\n}\n```\n\n## Acceptance Criteria\n\n- [ ] `loadConfig()` returns validated Config object\n- [ ] `loadConfig()` throws ConfigNotFoundError if file missing\n- [ ] `loadConfig()` throws ConfigValidationError with Zod errors if invalid\n- [ ] Empty optional fields get default values\n- [ ] projects array must have at least 1 item\n- [ ] gitlab.baseUrl must be valid URL\n- [ ] All number fields must be positive integers\n- [ ] tests/unit/config.test.ts passes (8 tests)\n\n## Files\n\nCREATE:\n- src/core/config.ts\n- tests/unit/config.test.ts\n- tests/fixtures/mock-responses/valid-config.json\n- tests/fixtures/mock-responses/invalid-config.json\n\n## TDD Loop\n\nRED:\n```typescript\n// tests/unit/config.test.ts\ndescribe('Config', () => {\n it('loads config from file path')\n it('throws ConfigNotFoundError if file missing')\n it('throws ConfigValidationError if required fields missing')\n it('validates project paths are non-empty strings')\n it('applies default values for optional fields')\n it('loads from XDG path by default')\n it('respects GI_CONFIG_PATH override')\n it('respects --config flag override')\n})\n```\n\nGREEN: Implement loadConfig() function\n\nVERIFY: `npm run test -- tests/unit/config.test.ts`\n\n## Edge Cases\n\n- JSON parse error should wrap in ConfigValidationError\n- Zod error messages should be human-readable\n- File exists but empty → ConfigValidationError\n- File has extra fields → should pass (Zod strips by default)","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-24T16:09:49.091078Z","created_by":"tayloreernisse","updated_at":"2026-01-25T03:04:32.592139Z","closed_at":"2026-01-25T03:04:32.592003Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-epj","depends_on_id":"bd-gg1","type":"blocks","created_at":"2026-01-24T16:13:07.835800Z","created_by":"tayloreernisse"}]} {"id":"bd-gba","title":"OBSERV: Add tracing-appender dependency to Cargo.toml","description":"## Background\ntracing-appender provides non-blocking, daily-rotating file writes for the tracing ecosystem. It's the canonical solution used by tokio-rs projects. We need it for the file logging layer (Phase 1) that writes JSON logs to ~/.local/share/lore/logs/.\n\n## Approach\nAdd tracing-appender to [dependencies] in Cargo.toml (line ~54, after the existing tracing-subscriber entry):\n\n```toml\ntracing-appender = \"0.2\"\n```\n\nAlso add the \"json\" feature to tracing-subscriber since the file layer and --log-format json both need it:\n\n```toml\ntracing-subscriber = { version = \"0.3\", features = [\"env-filter\", \"json\"] }\n```\n\nCurrent tracing deps (Cargo.toml lines 53-54):\n tracing = \"0.1\"\n tracing-subscriber = { version = \"0.3\", features = [\"env-filter\"] }\n\n## Acceptance Criteria\n- [ ] cargo check --all-targets succeeds with tracing-appender available\n- [ ] tracing_appender::rolling::daily() is importable\n- [ ] tracing-subscriber json feature is available (fmt::layer().json() compiles)\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- Cargo.toml (modify lines 53-54 region)\n\n## TDD Loop\nRED: Not applicable (dependency addition)\nGREEN: Add deps, run cargo check\nVERIFY: cargo check --all-targets && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- Ensure tracing-appender 0.2 is compatible with tracing-subscriber 0.3 (both from tokio-rs/tracing monorepo, always compatible)\n- The \"json\" feature on tracing-subscriber pulls in serde_json, which is already a dependency","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-04T15:53:55.364100Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:10:22.520471Z","closed_at":"2026-02-04T17:10:22.520423Z","close_reason":"Added tracing-appender 0.2 and json feature to tracing-subscriber","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-gba","depends_on_id":"bd-2nx","type":"parent-child","created_at":"2026-02-04T15:53:55.366945Z","created_by":"tayloreernisse"}]} {"id":"bd-gg1","title":"[CP0] Core utilities - paths, time, errors, logger","description":"## Background\n\nCore utilities provide the foundation for all other modules. Path resolution enables XDG-compliant config/data locations. Time utilities ensure consistent timestamp handling (ms epoch for DB, ISO for API). Error classes provide typed exceptions for clean error handling. Logger provides structured logging to stderr.\n\nReference: docs/prd/checkpoint-0.md sections \"Config + Data Locations\", \"Timestamp Convention\", \"Error Classes\", \"Logging Configuration\"\n\n## Approach\n\n**src/core/paths.ts:**\n- `getConfigPath(cliOverride?)`: resolution order is CLI flag → GI_CONFIG_PATH env → XDG default → local fallback\n- `getDataDir()`: uses XDG_DATA_HOME or ~/.local/share/gi\n- `getDbPath(configOverride?)`: returns data dir + data.db\n- `getBackupDir(configOverride?)`: returns data dir + backups/\n\n**src/core/time.ts:**\n- `isoToMs(isoString)`: converts GitLab API ISO 8601 → ms epoch\n- `msToIso(ms)`: converts ms epoch → ISO 8601\n- `nowMs()`: returns Date.now() for DB storage\n\n**src/core/errors.ts:**\nError hierarchy (all extend GiError base class with code and cause):\n- ConfigNotFoundError, ConfigValidationError\n- GitLabAuthError, GitLabNotFoundError, GitLabRateLimitError, GitLabNetworkError\n- DatabaseLockError, MigrationError\n- TokenNotSetError\n\n**src/core/logger.ts:**\n- pino logger to stderr (fd 2) with pino-pretty in dev\n- Child loggers: dbLogger, gitlabLogger, configLogger\n- LOG_LEVEL env var support (default: info)\n\n## Acceptance Criteria\n\n- [ ] `getConfigPath()` returns ~/.config/gi/config.json when no overrides\n- [ ] `getConfigPath()` respects GI_CONFIG_PATH env var\n- [ ] `getConfigPath(\"./custom.json\")` returns \"./custom.json\"\n- [ ] `isoToMs(\"2024-01-27T00:00:00.000Z\")` returns 1706313600000\n- [ ] `msToIso(1706313600000)` returns \"2024-01-27T00:00:00.000Z\"\n- [ ] All error classes have correct code property\n- [ ] Logger outputs to stderr (not stdout)\n- [ ] tests/unit/paths.test.ts passes\n- [ ] tests/unit/errors.test.ts passes\n\n## Files\n\nCREATE:\n- src/core/paths.ts\n- src/core/time.ts\n- src/core/errors.ts\n- src/core/logger.ts\n- tests/unit/paths.test.ts\n- tests/unit/errors.test.ts\n\n## TDD Loop\n\nRED: Write tests first\n```typescript\n// tests/unit/paths.test.ts\ndescribe('getConfigPath', () => {\n it('uses XDG_CONFIG_HOME if set')\n it('falls back to ~/.config/gi if XDG not set')\n it('prefers --config flag over environment')\n it('prefers environment over XDG default')\n it('falls back to local gi.config.json in dev')\n})\n```\n\nGREEN: Implement paths.ts, errors.ts, time.ts, logger.ts\n\nVERIFY: `npm run test -- tests/unit/paths.test.ts tests/unit/errors.test.ts`\n\n## Edge Cases\n\n- XDG_CONFIG_HOME may not exist - don't create, just return path\n- existsSync() check for local fallback - only return if file exists\n- Time conversion must handle timezone edge cases - always use UTC\n- Logger must work even if pino-pretty not installed (production)","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-24T16:09:48.604382Z","created_by":"tayloreernisse","updated_at":"2026-01-25T02:53:26.527997Z","closed_at":"2026-01-25T02:53:26.527862Z","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-gg1","depends_on_id":"bd-327","type":"blocks","created_at":"2026-01-24T16:13:07.368187Z","created_by":"tayloreernisse"}]} @@ -179,6 +179,6 @@ {"id":"bd-v6i","title":"[CP1] gi ingest --type=issues command","description":"## Background\n\nThe `gi ingest --type=issues` command is the main entry point for issue ingestion. It acquires a single-flight lock, calls the orchestrator for each configured project, and outputs progress/summary to the user.\n\n## Approach\n\n### Module: src/cli/commands/ingest.rs\n\n### Clap Definition\n\n```rust\n#[derive(Args)]\npub struct IngestArgs {\n /// Resource type to ingest\n #[arg(long, value_parser = [\"issues\", \"merge_requests\"])]\n pub r#type: String,\n\n /// Filter to single project\n #[arg(long)]\n pub project: Option,\n\n /// Override stale sync lock\n #[arg(long)]\n pub force: bool,\n}\n```\n\n### Handler Function\n\n```rust\npub async fn handle_ingest(args: IngestArgs, config: &Config) -> Result<()>\n```\n\n### Logic\n\n1. **Acquire single-flight lock**: `acquire_sync_lock(conn, args.force)?`\n2. **Get projects to sync**:\n - If `args.project` specified, filter to that one\n - Otherwise, get all configured projects from DB\n3. **For each project**:\n - Print \"Ingesting issues for {project_path}...\"\n - Call `ingest_project_issues(conn, client, config, project_id, gitlab_project_id)`\n - Print \"{N} issues fetched, {M} new labels\"\n4. **Print discussion sync summary**:\n - \"Fetching discussions ({N} issues with updates)...\"\n - \"{N} discussions, {M} notes (excluding {K} system notes)\"\n - \"Skipped discussion sync for {N} unchanged issues.\"\n5. **Release lock**: Lock auto-released when handler returns\n\n### Output Format (matches PRD)\n\n```\nIngesting issues...\n\n group/project-one: 1,234 issues fetched, 45 new labels\n\nFetching discussions (312 issues with updates)...\n\n group/project-one: 312 issues → 1,234 discussions, 5,678 notes\n\nTotal: 1,234 issues, 1,234 discussions, 5,678 notes (excluding 1,234 system notes)\nSkipped discussion sync for 922 unchanged issues.\n```\n\n## Acceptance Criteria\n\n- [ ] Clap args parse --type, --project, --force correctly\n- [ ] Single-flight lock acquired before sync starts\n- [ ] Lock error message is clear if concurrent run attempted\n- [ ] Progress output shows per-project counts\n- [ ] Summary includes unchanged issues skipped count\n- [ ] --force flag allows overriding stale lock\n\n## Files\n\n- src/cli/commands/mod.rs (add `pub mod ingest;`)\n- src/cli/commands/ingest.rs (create)\n- src/cli/mod.rs (add Ingest variant to Commands enum)\n\n## TDD Loop\n\nRED:\n```rust\n// tests/cli_ingest_tests.rs\n#[tokio::test] async fn ingest_issues_acquires_lock()\n#[tokio::test] async fn ingest_issues_fails_on_concurrent_run()\n#[tokio::test] async fn ingest_issues_respects_project_filter()\n#[tokio::test] async fn ingest_issues_force_overrides_stale_lock()\n```\n\nGREEN: Implement handler with lock and orchestrator calls\n\nVERIFY: `cargo test cli_ingest`\n\n## Edge Cases\n\n- No projects configured - return early with helpful message\n- Project filter matches nothing - error with \"project not found\"\n- Lock already held - clear error \"Sync already in progress\"\n- Ctrl-C during sync - lock should be released (via Drop or SIGINT handler)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-25T17:02:38.312565Z","created_by":"tayloreernisse","updated_at":"2026-01-25T22:56:44.090142Z","closed_at":"2026-01-25T22:56:44.090086Z","close_reason":"done","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-v6i","depends_on_id":"bd-ozy","type":"blocks","created_at":"2026-01-25T17:04:05.629772Z","created_by":"tayloreernisse"}]} {"id":"bd-xhz","title":"[CP1] GitLab client pagination methods","description":"## Background\n\nGitLab pagination methods enable fetching large result sets (issues, discussions) as async streams. The client uses `x-next-page` headers to determine continuation and applies cursor rewind for tuple-based incremental sync.\n\n## Approach\n\nAdd pagination methods to GitLabClient using `async-stream` crate:\n\n### Methods to Add\n\n```rust\nimpl GitLabClient {\n /// Paginate through issues for a project.\n pub fn paginate_issues(\n &self,\n gitlab_project_id: i64,\n updated_after: Option, // ms epoch cursor\n cursor_rewind_seconds: u32,\n ) -> Pin> + Send + '_>>\n\n /// Paginate through discussions for an issue.\n pub fn paginate_issue_discussions(\n &self,\n gitlab_project_id: i64,\n issue_iid: i64,\n ) -> Pin> + Send + '_>>\n\n /// Make request and return response with headers for pagination.\n async fn request_with_headers(\n &self,\n path: &str,\n params: &[(&str, String)],\n ) -> Result<(T, HeaderMap)>\n}\n```\n\n### Pagination Logic\n\n1. Start at page 1, per_page=100\n2. For issues: add scope=all, state=all, order_by=updated_at, sort=asc\n3. Apply cursor rewind: `updated_after = cursor - rewind_seconds` (clamped to 0)\n4. Yield each item from response\n5. Check `x-next-page` header for continuation\n6. Stop when header is empty/absent OR response is empty\n\n### Cursor Rewind\n\n```rust\nif let Some(ts) = updated_after {\n let rewind_ms = (cursor_rewind_seconds as i64) * 1000;\n let rewound = (ts - rewind_ms).max(0); // Clamp to avoid underflow\n // Convert to ISO 8601 for updated_after param\n}\n```\n\n## Acceptance Criteria\n\n- [ ] `paginate_issues` returns Stream of GitLabIssue\n- [ ] `paginate_issues` adds scope=all, state=all, order_by=updated_at, sort=asc\n- [ ] `paginate_issues` applies cursor rewind with max(0) clamping\n- [ ] `paginate_issue_discussions` returns Stream of GitLabDiscussion\n- [ ] Both methods follow x-next-page header until empty\n- [ ] Both methods stop on empty response (fallback)\n- [ ] `request_with_headers` returns (T, HeaderMap) tuple\n\n## Files\n\n- src/gitlab/client.rs (edit - add methods)\n\n## TDD Loop\n\nRED:\n```rust\n// tests/pagination_tests.rs\n#[tokio::test] async fn fetches_all_pages_when_multiple_exist()\n#[tokio::test] async fn respects_per_page_parameter()\n#[tokio::test] async fn follows_x_next_page_header_until_empty()\n#[tokio::test] async fn falls_back_to_empty_page_stop_if_headers_missing()\n#[tokio::test] async fn applies_cursor_rewind_for_tuple_semantics()\n#[tokio::test] async fn clamps_negative_rewind_to_zero()\n```\n\nGREEN: Implement pagination methods with async-stream\n\nVERIFY: `cargo test pagination`\n\n## Edge Cases\n\n- cursor_updated_at near zero - rewind must not underflow (use max(0))\n- GitLab returns empty x-next-page - treat as end of pages\n- GitLab omits pagination headers entirely - use empty response as stop condition\n- DateTime conversion fails - omit updated_after and fetch all (safe fallback)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-25T17:02:38.222168Z","created_by":"tayloreernisse","updated_at":"2026-01-25T22:28:39.192876Z","closed_at":"2026-01-25T22:28:39.192815Z","close_reason":"Implemented paginate_issues and paginate_issue_discussions with async-stream, cursor rewind with max(0) clamping, x-next-page header following, 4 unit tests passing","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-xhz","depends_on_id":"bd-1np","type":"blocks","created_at":"2026-01-25T17:04:05.398212Z","created_by":"tayloreernisse"},{"issue_id":"bd-xhz","depends_on_id":"bd-2ys","type":"blocks","created_at":"2026-01-25T17:04:05.371440Z","created_by":"tayloreernisse"}]} {"id":"bd-ymd","title":"[CP1] Final validation - Gate A through D","description":"Run all tests and verify all internal gates pass.\n\n## Gate A: Issues Only (Must Pass First)\n- [ ] gi ingest --type=issues fetches all issues from configured projects\n- [ ] Issues stored with correct schema, including last_seen_at\n- [ ] Cursor-based sync is resumable (re-run fetches only new/updated)\n- [ ] Incremental cursor updates every 100 issues\n- [ ] Raw payloads stored for each issue\n- [ ] gi list issues and gi count issues work\n\n## Gate B: Labels Correct (Must Pass)\n- [ ] Labels extracted and stored (name-only)\n- [ ] Label links created correctly\n- [ ] Stale label links removed on re-sync (verified with test)\n- [ ] Label count per issue matches GitLab\n\n## Gate C: Dependent Discussion Sync (Must Pass)\n- [ ] Discussions fetched for issues with updated_at advancement\n- [ ] Notes stored with is_system flag correctly set\n- [ ] Raw payloads stored for discussions and notes\n- [ ] discussions_synced_for_updated_at watermark updated after sync\n- [ ] Unchanged issues skip discussion refetch (verified with test)\n- [ ] Bounded concurrency (dependent_concurrency respected)\n\n## Gate D: Resumability Proof (Must Pass)\n- [ ] Kill mid-run, rerun; bounded redo (cursor progress preserved)\n- [ ] No redundant discussion refetch after crash recovery\n- [ ] Single-flight lock prevents concurrent runs\n\n## Final Gate (Must Pass)\n- [ ] All unit tests pass (cargo test)\n- [ ] All integration tests pass (mocked with wiremock)\n- [ ] cargo clippy passes with no warnings\n- [ ] cargo fmt --check passes\n- [ ] Compiles with --release\n\n## Validation Commands\ncargo test\ncargo clippy -- -D warnings\ncargo fmt --check\ncargo build --release\n\nFiles: All CP1 files\nDone when: All gate criteria pass","status":"tombstone","priority":2,"issue_type":"task","created_at":"2026-01-25T16:59:26.795633Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:02.132613Z","deleted_at":"2026-01-25T17:02:02.132608Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"task","compaction_level":0,"original_size":0} -{"id":"bd-ypa","title":"Implement timeline expand phase: BFS cross-reference expansion","description":"## Background\n\nThe expand phase is step 3 of the timeline pipeline. Starting from seed entities, it performs BFS over entity_references to discover related entities. This enriches the timeline with events from linked issues and MRs that the keyword search alone wouldn't find.\n\n## Approach\n\nCreate \\`src/core/timeline_expand.rs\\`:\n\n```rust\nuse std::collections::{HashSet, VecDeque};\nuse rusqlite::Connection;\nuse crate::core::timeline::{EntityRef, ExpandedEntityRef, UnresolvedRef};\n\npub struct ExpandResult {\n pub expanded_entities: Vec,\n pub unresolved_references: Vec,\n}\n\npub fn expand_timeline(\n conn: &Connection,\n seeds: &[EntityRef],\n depth: u32, // 0=no expansion, 1=default, 2+=deep\n include_mentions: bool, // --expand-mentions flag\n max_entities: usize, // cap at 100 to prevent explosion\n) -> Result {\n // BFS over entity_references\n // Default edge types: \"closes\", \"related\"\n // If include_mentions: also traverse \"mentioned\" edges\n // Track provenance: which seed entity and edge type led to discovery\n // Collect unresolved refs (target_entity_id IS NULL)\n}\n```\n\n### BFS Algorithm\n\n```\nfn expand_timeline(conn, seeds, depth, include_mentions, max_entities):\n visited: HashSet<(entity_type, entity_id)> = seeds as set\n queue: VecDeque<(EntityRef, u32 depth, EntityRef provenance_seed, String edge_type)>\n expanded: Vec = []\n unresolved: Vec = []\n\n // Initialize queue with seeds at depth 0\n for seed in seeds:\n // Query outgoing edges from this entity\n SELECT target_entity_type, target_entity_id, target_project_path,\n target_entity_iid, reference_type\n FROM entity_references\n WHERE source_entity_type = ?1 AND source_entity_id = ?2\n AND reference_type IN ('closes', 'related' [, 'mentioned'])\n\n // Also query incoming edges (reverse direction)\n SELECT source_entity_type, source_entity_id, reference_type\n FROM entity_references\n WHERE target_entity_type = ?1 AND target_entity_id = ?2\n AND reference_type IN ('closes', 'related' [, 'mentioned'])\n\n for each neighbor:\n if target_entity_id IS NULL: add to unresolved\n elif (type, id) not in visited AND expanded.len() < max_entities:\n add to visited, queue, expanded (with depth+1, provenance)\n\n // Continue BFS up to configured depth\n while queue not empty AND current_depth < depth:\n ...same pattern...\n```\n\n### Edge Type Filtering\n\n```rust\nfn edge_types(include_mentions: bool) -> Vec<&'static str> {\n if include_mentions {\n vec![\"closes\", \"related\", \"mentioned\"]\n } else {\n vec![\"closes\", \"related\"]\n }\n}\n```\n\nRegister in \\`src/core/mod.rs\\`: \\`pub mod timeline_expand;\\`\n\n## Acceptance Criteria\n\n- [ ] BFS traverses outgoing AND incoming edges in entity_references\n- [ ] Default: only \"closes\" and \"related\" edges traversed\n- [ ] \\`--expand-mentions\\`: also traverses \"mentioned\" edges\n- [ ] depth=0: returns empty expanded list (no expansion)\n- [ ] depth=1: one hop from seeds\n- [ ] max_entities cap prevents explosion (default 100)\n- [ ] Provenance tracked: each expanded entity knows which seed led to it\n- [ ] Unresolved references (target_entity_id IS NULL) collected separately\n- [ ] No duplicates: visited set prevents re-expansion\n- [ ] Module registered in \\`src/core/mod.rs\\`\n- [ ] \\`cargo check --all-targets\\` passes\n- [ ] \\`cargo clippy --all-targets -- -D warnings\\` passes\n\n## Files\n\n- \\`src/core/timeline_expand.rs\\` (NEW)\n- \\`src/core/mod.rs\\` (add \\`pub mod timeline_expand;\\`)\n\n## TDD Loop\n\nRED: Create tests in \\`src/core/timeline_expand.rs\\`:\n- \\`test_expand_depth_zero\\` - returns empty expanded list\n- \\`test_expand_finds_linked_entity\\` - seed issue -> closes -> linked MR found\n- \\`test_expand_bidirectional\\` - if A closes B, starting from B finds A\n- \\`test_expand_respects_max_entities\\` - stops at cap\n- \\`test_expand_skips_mentions_by_default\\` - \"mentioned\" edges not traversed without flag\n- \\`test_expand_includes_mentions_when_flagged\\` - \"mentioned\" edges traversed with flag\n- \\`test_expand_collects_unresolved\\` - NULL target_entity_id goes to unresolved list\n\nTests need in-memory DB with migration 011 applied and test entity_references rows.\n\nGREEN: Implement BFS with visited set, provenance tracking, and edge type filtering.\n\nVERIFY: \\`cargo test --lib -- timeline_expand\\`\n\n## Edge Cases\n\n- Circular references (A closes B, B related to A): visited set prevents infinite loop\n- Entity referenced from multiple seeds: only first discovery tracked (first-come provenance)\n- Empty entity_references table: returns empty expanded list, not error\n- Self-referencing entity (source = target): skip, don't add to expanded\n- Cross-project references where target_entity_id is NULL: add to unresolved","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-02T21:33:08.659381Z","created_by":"tayloreernisse","updated_at":"2026-02-05T18:55:32.874886Z","compaction_level":0,"original_size":0,"labels":["gate-3","phase-b","query"],"dependencies":[{"issue_id":"bd-ypa","depends_on_id":"bd-32q","type":"blocks","created_at":"2026-02-02T21:33:37.448515Z","created_by":"tayloreernisse"},{"issue_id":"bd-ypa","depends_on_id":"bd-3ia","type":"blocks","created_at":"2026-02-02T21:33:37.528233Z","created_by":"tayloreernisse"},{"issue_id":"bd-ypa","depends_on_id":"bd-ike","type":"parent-child","created_at":"2026-02-02T21:33:08.661036Z","created_by":"tayloreernisse"}]} +{"id":"bd-ypa","title":"Implement timeline expand phase: BFS cross-reference expansion","description":"## Background\n\nThe expand phase is step 3 of the timeline pipeline (spec Section 3.2). Starting from seed entities, it performs BFS over entity_references to discover related entities. This enriches the timeline with events from linked issues and MRs that the keyword search alone wouldn't find.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.2 step 3, Section 3.5 (expanded_entities JSON).\n\n## Approach\n\nCreate `src/core/timeline_expand.rs`:\n\n```rust\nuse std::collections::{HashSet, VecDeque};\nuse rusqlite::Connection;\nuse crate::core::timeline::{EntityRef, ExpandedEntityRef, UnresolvedRef};\n\npub struct ExpandResult {\n pub expanded_entities: Vec,\n pub unresolved_references: Vec,\n}\n\npub fn expand_timeline(\n conn: &Connection,\n seeds: &[EntityRef],\n depth: u32, // 0=no expansion, 1=default, 2+=deep\n include_mentions: bool, // --expand-mentions flag\n max_entities: usize, // cap at 100 to prevent explosion\n) -> Result {\n // BFS over entity_references\n // Default edge types: \"closes\", \"related\" (spec: skip \"mentioned\" by default)\n // If include_mentions: also traverse \"mentioned\" edges\n // Track provenance: which entity + edge type led to discovery\n // Collect unresolved refs (target_entity_id IS NULL)\n}\n```\n\n### BFS Algorithm\n\n```\nfn expand_timeline(conn, seeds, depth, include_mentions, max_entities):\n visited: HashSet<(entity_type, entity_id)> = seeds as set\n queue: VecDeque<(EntityRef, u32 current_depth)>\n expanded: Vec = []\n unresolved: Vec = []\n\n // Seed initial expansion targets\n for seed in seeds:\n for neighbor in query_neighbors(conn, &seed, edge_types):\n if neighbor is unresolved:\n add to unresolved with source=seed\n elif (type, id) not in visited AND expanded.len() < max_entities:\n visited.insert((type, id))\n expanded.push(ExpandedEntityRef {\n entity_ref: neighbor,\n depth: 1,\n via_from: seed.clone(),\n via_reference_type: edge.reference_type,\n via_source_method: edge.source_method,\n })\n if 1 < depth: queue.push_back((neighbor, 1))\n\n // Continue BFS for depth > 1\n while let Some((entity, current_depth)) = queue.pop_front():\n if current_depth >= depth: continue\n for neighbor in query_neighbors(conn, &entity, edge_types):\n ...same pattern with current_depth + 1...\n```\n\n### Provenance Tracking (Critical for spec compliance)\n\nPer spec Section 3.5, each expanded entity needs a `via` object:\n```json\n\"via\": {\n \"from\": { \"type\": \"merge_request\", \"iid\": 567, \"project\": \"group/repo\" },\n \"reference_type\": \"closes\",\n \"source_method\": \"api_closes_issues\"\n}\n```\n\nThe `ExpandedEntityRef` struct (defined in bd-20e) carries these fields:\n- `via_from: EntityRef` - the entity that referenced this one\n- `via_reference_type: String` - \"closes\", \"mentioned\", \"related\"\n- `via_source_method: String` - from entity_references.source_method column\n\n### Query for neighbors\n\nTraverses BOTH outgoing AND incoming edges:\n```sql\n-- Outgoing: this entity references others\nSELECT target_entity_type, target_entity_id, target_project_path,\n target_entity_iid, reference_type, source_method\nFROM entity_references\nWHERE source_entity_type = ?1 AND source_entity_id = ?2\n AND reference_type IN ('closes', 'related' [, 'mentioned'])\n\n-- Incoming: other entities reference this one\nSELECT source_entity_type, source_entity_id, reference_type, source_method\nFROM entity_references\nWHERE target_entity_type = ?1 AND target_entity_id = ?2\n AND reference_type IN ('closes', 'related' [, 'mentioned'])\n```\n\n### Unresolved References\n\nPer spec Section 3.5, when `target_entity_id IS NULL`, the reference is to an external entity. Collect these in `UnresolvedRef` with:\n- `source`: the entity containing the reference\n- `target_project`: from `target_project_path` column\n- `target_type`: from `target_entity_type` column\n- `target_iid`: from `target_entity_iid` column\n- `reference_type`: from `reference_type` column\n\n### Edge Type Filtering (spec Section 3.1)\n\n```rust\nfn edge_types(include_mentions: bool) -> Vec<&'static str> {\n if include_mentions {\n vec![\"closes\", \"related\", \"mentioned\"]\n } else {\n vec![\"closes\", \"related\"]\n }\n}\n```\n\nRegister in `src/core/mod.rs`: `pub mod timeline_expand;`\n\n## Acceptance Criteria\n\n- [ ] BFS traverses outgoing AND incoming edges in entity_references\n- [ ] Default: only \"closes\" and \"related\" edges traversed (spec Section 3.1)\n- [ ] `--expand-mentions`: also traverses \"mentioned\" edges\n- [ ] depth=0: returns empty expanded list (no expansion)\n- [ ] depth=1: one hop from seeds\n- [ ] max_entities cap prevents explosion (default 100)\n- [ ] Provenance tracked per spec: via_from, via_reference_type, via_source_method\n- [ ] source_method value comes from entity_references.source_method column\n- [ ] Unresolved references (target_entity_id IS NULL) collected per spec Section 3.5\n- [ ] No duplicates: visited set prevents re-expansion\n- [ ] Module registered in `src/core/mod.rs`\n- [ ] `cargo check --all-targets` passes\n- [ ] `cargo clippy --all-targets -- -D warnings` passes\n\n## Files\n\n- `src/core/timeline_expand.rs` (NEW)\n- `src/core/mod.rs` (add `pub mod timeline_expand;`)\n\n## TDD Loop\n\nRED: Create tests in `src/core/timeline_expand.rs`:\n- `test_expand_depth_zero` - returns empty expanded list\n- `test_expand_finds_linked_entity` - seed issue -> closes -> linked MR found\n- `test_expand_bidirectional` - if A closes B, starting from B finds A\n- `test_expand_respects_max_entities` - stops at cap\n- `test_expand_skips_mentions_by_default` - \"mentioned\" edges not traversed without flag\n- `test_expand_includes_mentions_when_flagged` - \"mentioned\" edges traversed with flag\n- `test_expand_collects_unresolved` - NULL target_entity_id goes to unresolved list\n- `test_expand_tracks_provenance` - expanded entity has correct via_from, via_reference_type, via_source_method\n\nTests need in-memory DB with migration 011 applied and test entity_references rows.\n\nGREEN: Implement BFS with visited set, provenance tracking, and edge type filtering.\n\nVERIFY: `cargo test --lib -- timeline_expand`\n\n## Edge Cases\n\n- Circular references (A closes B, B related to A): visited set prevents infinite loop\n- Entity referenced from multiple seeds: only first discovery tracked (first-come provenance)\n- Empty entity_references table: returns empty expanded list, not error\n- Self-referencing entity (source = target): skip, don't add to expanded\n- Cross-project references where target_entity_id is NULL: add to unresolved","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-02T21:33:08.659381Z","created_by":"tayloreernisse","updated_at":"2026-02-05T19:11:19.095262Z","compaction_level":0,"original_size":0,"labels":["gate-3","phase-b","query"],"dependencies":[{"issue_id":"bd-ypa","depends_on_id":"bd-32q","type":"blocks","created_at":"2026-02-02T21:33:37.448515Z","created_by":"tayloreernisse"},{"issue_id":"bd-ypa","depends_on_id":"bd-3ia","type":"blocks","created_at":"2026-02-02T21:33:37.528233Z","created_by":"tayloreernisse"},{"issue_id":"bd-ypa","depends_on_id":"bd-ike","type":"parent-child","created_at":"2026-02-02T21:33:08.661036Z","created_by":"tayloreernisse"}]} {"id":"bd-z0s","title":"[CP1] Final validation - Gate A through D","description":"Run all tests and verify all internal gates pass.\n\n## Gate A: Issues Only (Must Pass First)\n- [ ] gi ingest --type=issues fetches all issues from configured projects\n- [ ] Issues stored with correct schema, including last_seen_at\n- [ ] Cursor-based sync is resumable (re-run fetches only new/updated)\n- [ ] Incremental cursor updates every 100 issues\n- [ ] Raw payloads stored for each issue\n- [ ] gi list issues and gi count issues work\n\n## Gate B: Labels Correct (Must Pass)\n- [ ] Labels extracted and stored (name-only)\n- [ ] Label links created correctly\n- [ ] **Stale label links removed on re-sync** (verified with test)\n- [ ] Label count per issue matches GitLab\n\n## Gate C: Dependent Discussion Sync (Must Pass)\n- [ ] Discussions fetched for issues with updated_at advancement\n- [ ] Notes stored with is_system flag correctly set\n- [ ] Raw payloads stored for discussions and notes\n- [ ] discussions_synced_for_updated_at watermark updated after sync\n- [ ] **Unchanged issues skip discussion refetch** (verified with test)\n- [ ] Bounded concurrency (dependent_concurrency respected)\n\n## Gate D: Resumability Proof (Must Pass)\n- [ ] Kill mid-run, rerun; bounded redo (cursor progress preserved)\n- [ ] No redundant discussion refetch after crash recovery\n- [ ] Single-flight lock prevents concurrent runs\n\n## Final Gate (Must Pass)\n- [ ] All unit tests pass (cargo test)\n- [ ] All integration tests pass (mocked with wiremock)\n- [ ] cargo clippy passes with no warnings\n- [ ] cargo fmt --check passes\n- [ ] Compiles with --release\n\n## Validation Commands\ncargo test\ncargo clippy -- -D warnings\ncargo fmt --check\ncargo build --release\n\n## Data Integrity Checks\n- SELECT COUNT(*) FROM issues matches GitLab issue count\n- Every issue has a raw_payloads row\n- Every discussion has a raw_payloads row\n- Labels in issue_labels junction all exist in labels table\n- Re-running gi ingest --type=issues fetches 0 new items\n- After removing a label in GitLab and re-syncing, the link is removed\n\nFiles: All CP1 files\nDone when: All gate criteria pass","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-25T17:02:38.459095Z","created_by":"tayloreernisse","updated_at":"2026-01-25T23:27:09.567537Z","closed_at":"2026-01-25T23:27:09.567478Z","close_reason":"All gates pass: 71 tests, clippy clean, fmt clean, release build successful","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-z0s","depends_on_id":"bd-17v","type":"blocks","created_at":"2026-01-25T17:04:05.889114Z","created_by":"tayloreernisse"},{"issue_id":"bd-z0s","depends_on_id":"bd-2f0","type":"blocks","created_at":"2026-01-25T17:04:05.841210Z","created_by":"tayloreernisse"},{"issue_id":"bd-z0s","depends_on_id":"bd-39w","type":"blocks","created_at":"2026-01-25T17:04:05.913316Z","created_by":"tayloreernisse"},{"issue_id":"bd-z0s","depends_on_id":"bd-3n1","type":"blocks","created_at":"2026-01-25T17:04:05.817830Z","created_by":"tayloreernisse"},{"issue_id":"bd-z0s","depends_on_id":"bd-o7b","type":"blocks","created_at":"2026-01-25T17:04:05.864480Z","created_by":"tayloreernisse"},{"issue_id":"bd-z0s","depends_on_id":"bd-v6i","type":"blocks","created_at":"2026-01-25T17:04:05.794555Z","created_by":"tayloreernisse"}]} {"id":"bd-z94","title":"Implement 'lore file-history' command with human and robot output","description":"## Background\n\nThe file-history command is Gate 4's user-facing CLI. It answers \"which MRs touched this file, and why?\" by combining mr_file_changes data with rename chain resolution. Follows the same CLI pattern as issues/mrs commands with both human and robot output.\n\n## Approach\n\n### 1. Add FileHistory subcommand in \\`src/cli/mod.rs\\`\n\n```rust\n/// Show MR history for a file path with rename chain resolution\n#[command(name = \"file-history\")]\nFileHistory(FileHistoryArgs),\n```\n\n```rust\n#[derive(Parser)]\npub struct FileHistoryArgs {\n /// File path to query\n pub path: String,\n\n /// Filter by project path\n #[arg(short = 'p', long, help_heading = \"Filters\")]\n pub project: Option,\n\n /// Include DiffNote discussion snippets\n #[arg(long, help_heading = \"Output\", overrides_with = \"no_discussions\")]\n pub discussions: bool,\n\n #[arg(long = \"no-discussions\", hide = true, overrides_with = \"discussions\")]\n pub no_discussions: bool,\n\n /// Disable rename chain following\n #[arg(long = \"no-follow-renames\", help_heading = \"Output\")]\n pub no_follow_renames: bool,\n\n /// Only show merged MRs (skip open/closed)\n #[arg(long, help_heading = \"Filters\", overrides_with = \"no_merged\")]\n pub merged: bool,\n\n #[arg(long = \"no-merged\", hide = true, overrides_with = \"merged\")]\n pub no_merged: bool,\n\n /// Maximum results\n #[arg(short = 'n', long = \"limit\", default_value = \"50\", help_heading = \"Output\")]\n pub limit: usize,\n}\n```\n\n### 2. Handler in \\`src/main.rs\\`\n\n```rust\nSome(Commands::FileHistory(args)) => handle_file_history(cli.config.as_deref(), args, robot_mode),\n```\n\n### 3. Query Logic\n\n```rust\npub fn run_file_history(\n conn: &Connection,\n project_id: i64,\n path: &str,\n follow_renames: bool,\n merged_only: bool,\n include_discussions: bool,\n limit: usize,\n) -> Result {\n // 1. Resolve rename chain (unless no_follow_renames)\n // 2. Query mr_file_changes for all resolved paths\n // 3. Filter by state if merged_only\n // 4. Optionally fetch DiffNote discussions per MR\n // 5. Order by MR updated_at DESC, limit\n}\n```\n\n### 4. Human Output\n\n```\nFile History: src/auth/oauth.rs (via 3 paths, 5 MRs)\nRename chain: src/authentication/oauth.rs -> src/auth/oauth.rs\n\nMR !456 \"Implement OAuth2 flow\" merged @alice 2024-01-22 modified\nMR !489 \"Fix OAuth token expiry\" merged @bob 2024-02-15 modified\nMR !234 \"Initial auth module\" merged @alice 2023-11-01 added\n```\n\n### 5. Robot JSON\n\n```json\n{\n \"ok\": true,\n \"data\": {\n \"path\": \"src/auth/oauth.rs\",\n \"rename_chain\": [\"src/authentication/oauth.rs\", \"src/auth/oauth.rs\"],\n \"merge_requests\": [\n {\n \"iid\": 456,\n \"title\": \"Implement OAuth2 flow\",\n \"state\": \"merged\",\n \"author\": \"alice\",\n \"change_type\": \"modified\",\n \"discussions\": []\n }\n ]\n },\n \"meta\": {\n \"total_paths_searched\": 2,\n \"total_mrs\": 5,\n \"showing\": 5,\n \"follow_renames\": true\n }\n}\n```\n\n## Acceptance Criteria\n\n- [ ] \\`lore file-history src/foo.rs\\` works with human output\n- [ ] \\`lore --robot file-history src/foo.rs\\` works with JSON output\n- [ ] Rename chain displayed in human output\n- [ ] \\`--no-follow-renames\\` disables rename resolution\n- [ ] \\`--merged\\` filters to only merged MRs\n- [ ] \\`--discussions\\` includes DiffNote snippets\n- [ ] \\`-p group/repo\\` filters to single project (ambiguous -> exit 18)\n- [ ] \\`-n 10\\` limits results\n- [ ] File with no MR history: \"No MR history found for 'path'\" (exit 0)\n- [ ] Command appears in robot-docs and VALID_COMMANDS\n- [ ] \\`cargo check --all-targets\\` passes\n- [ ] \\`cargo clippy --all-targets -- -D warnings\\` passes\n\n## Files\n\n- \\`src/cli/mod.rs\\` (add FileHistoryArgs + Commands::FileHistory)\n- \\`src/cli/commands/file_history.rs\\` (NEW -- handler + output functions)\n- \\`src/cli/commands/mod.rs\\` (re-export)\n- \\`src/main.rs\\` (add handle_file_history + match arm + VALID_COMMANDS + robot-docs)\n\n## TDD Loop\n\nRED: No unit tests for CLI wiring. Verify with:\n- \\`cargo check --all-targets\\`\n- Manual: \\`lore file-history src/main.rs\\` against a synced database with diffs\n\nGREEN: Wire up the clap struct, handler, and output functions.\n\nVERIFY: \\`cargo check --all-targets && cargo clippy --all-targets -- -D warnings\\`\n\n## Edge Cases\n\n- File path with spaces: clap handles quoting, but query must preserve spaces exactly\n- Path not in any MR: return empty result, not error\n- Path only in renamed MRs (old name): rename chain must find it\n- Large result set (100+ MRs): limit prevents unbounded output\n- \\`--merged\\` combined with \\`--discussions\\`: only show discussions on merged MRs","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-02T21:34:09.027259Z","created_by":"tayloreernisse","updated_at":"2026-02-05T18:55:04.304507Z","compaction_level":0,"original_size":0,"labels":["cli","gate-4","phase-b"],"dependencies":[{"issue_id":"bd-z94","depends_on_id":"bd-14q","type":"parent-child","created_at":"2026-02-02T21:34:09.028633Z","created_by":"tayloreernisse"},{"issue_id":"bd-z94","depends_on_id":"bd-1yx","type":"blocks","created_at":"2026-02-02T21:34:16.784122Z","created_by":"tayloreernisse"},{"issue_id":"bd-z94","depends_on_id":"bd-2yo","type":"blocks","created_at":"2026-02-02T21:34:16.741201Z","created_by":"tayloreernisse"},{"issue_id":"bd-z94","depends_on_id":"bd-3ia","type":"blocks","created_at":"2026-02-02T21:34:16.824983Z","created_by":"tayloreernisse"}]}