diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index c2ae9ea..e45a420 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -153,6 +153,7 @@ {"id":"bd-2og9","title":"Implement entity cache + render cache","description":"## Background\nEntity cache provides near-instant detail view reopens during Enter/Esc drill workflows by caching IssueDetail/MrDetail payloads. Render cache prevents per-frame recomputation of expensive render artifacts (markdown to styled text, discussion tree shaping). Both use bounded LRU eviction with selective invalidation.\n\n## Approach\n\n### Entity Cache (entity_cache.rs)\n\n```rust\nuse std::collections::HashMap;\n\npub struct EntityCache {\n entries: HashMap, // value + last-access tick\n capacity: usize,\n tick: u64,\n}\n\nimpl EntityCache {\n pub fn new(capacity: usize) -> Self;\n pub fn get(&mut self, key: &EntityKey) -> Option<&V>; // updates tick\n pub fn put(&mut self, key: EntityKey, value: V); // evicts oldest if at capacity\n pub fn invalidate(&mut self, keys: &[EntityKey]); // selective by key set\n}\n```\n\n- `EntityKey` is `(EntityType, i64)` from core types (bd-c9gk) — e.g., `(EntityType::Issue, 42)`\n- Default capacity: 64 entries (sufficient for typical drill-in/out workflows)\n- LRU eviction: on `put()` when at capacity, find entry with lowest tick and remove it\n- `get()` bumps the access tick to keep recently-accessed entries alive\n- `invalidate()` takes a slice of changed keys (from sync results) and removes only those entries — NOT a blanket clear\n\n### Render Cache (render_cache.rs)\n\n```rust\npub struct RenderCacheKey {\n content_hash: u64, // FxHash of source content\n terminal_width: u16, // width affects line wrapping\n}\n\npub struct RenderCache {\n entries: HashMap,\n capacity: usize,\n}\n\nimpl RenderCache {\n pub fn new(capacity: usize) -> Self;\n pub fn get(&self, key: &RenderCacheKey) -> Option<&V>;\n pub fn put(&mut self, key: RenderCacheKey, value: V);\n pub fn invalidate_width(&mut self, keep_width: u16); // remove entries NOT matching this width\n pub fn invalidate_all(&mut self); // theme change = full clear\n}\n```\n\n- Default capacity: 256 entries\n- Used for: markdown->styled text, discussion tree layout, issue body rendering\n- `content_hash` uses `std::hash::Hasher` with FxHash (or std DefaultHasher) on source text\n- `invalidate_width(keep_width)`: on terminal resize, remove entries cached at old width\n- `invalidate_all()`: on theme change, clear everything (colors changed)\n- Both caches are NOT thread-safe (single-threaded TUI event loop). No Arc/Mutex needed.\n\n### Integration Point\nBoth caches live as fields on the main LoreApp struct. Cache miss falls through to normal DB query transparently — the action functions check cache first, query DB on miss, populate cache on return.\n\n## Acceptance Criteria\n- [ ] EntityCache::get returns Some for recently put items\n- [ ] EntityCache::put evicts the least-recently-accessed entry when at capacity\n- [ ] EntityCache::invalidate removes only the specified keys, leaves others intact\n- [ ] EntityCache capacity defaults to 64\n- [ ] RenderCache::get returns Some for matching (hash, width) pair\n- [ ] RenderCache::invalidate_width removes entries with non-matching width\n- [ ] RenderCache::invalidate_all clears everything\n- [ ] RenderCache capacity defaults to 256\n- [ ] Both caches are Send (no Rc, no raw pointers) but NOT required to be Sync\n- [ ] No unsafe code\n\n## Files\n- CREATE: crates/lore-tui/src/entity_cache.rs\n- CREATE: crates/lore-tui/src/render_cache.rs\n- MODIFY: crates/lore-tui/src/lib.rs (add `pub mod entity_cache; pub mod render_cache;`)\n\n## TDD Anchor\nRED: Write `test_entity_cache_lru_eviction` that creates EntityCache with capacity 3, puts 4 items, asserts first item (lowest tick) is evicted and the other 3 remain.\nGREEN: Implement LRU eviction using tick-based tracking.\nVERIFY: cargo test --manifest-path crates/lore-tui/Cargo.toml entity_cache\n\nAdditional tests:\n- test_entity_cache_get_bumps_tick (accessed item survives eviction over older untouched items)\n- test_entity_cache_invalidate_selective (removes only specified keys)\n- test_entity_cache_invalidate_nonexistent_key (no panic)\n- test_render_cache_width_invalidation (entries at old width removed, current width kept)\n- test_render_cache_invalidate_all (empty after call)\n- test_render_cache_capacity_eviction\n\n## Edge Cases\n- Invalidating an EntityKey not in the cache is a no-op (no panic)\n- Zero-capacity cache: all gets return None, all puts are no-ops (degenerate but safe)\n- RenderCacheKey equality: two different strings can have the same hash (collision) — accept this; worst case is a wrong cached render that gets corrected on next invalidation\n- Entity cache should NOT be prewarmed synchronously during sync — sync results just invalidate stale entries, and the next view() call repopulates on demand\n\n## Dependency Context\nDepends on bd-c9gk (core types) for EntityKey type definition.\nBoth caches are integrated into LoreApp (bd-6pmy) as struct fields.\nAction functions (from Phase 2/3 screen beads) check cache before querying DB.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T17:03:25.520201Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:11:34.626204Z","compaction_level":0,"original_size":0,"labels":["TUI"],"dependencies":[{"issue_id":"bd-2og9","depends_on_id":"bd-1df9","type":"blocks","created_at":"2026-02-12T18:11:34.626177Z","created_by":"tayloreernisse"},{"issue_id":"bd-2og9","depends_on_id":"bd-c9gk","type":"blocks","created_at":"2026-02-12T17:39:25.511630Z","created_by":"tayloreernisse"}]} {"id":"bd-2px","title":"[CP1] Epic: Issue Ingestion","description":"Ingest all issues, labels, and issue discussions from configured GitLab repositories with resumable cursor-based incremental sync. This establishes the core data ingestion pattern reused for MRs in CP2.\n\n## Success Criteria\n- gi ingest --type=issues fetches all issues (count matches GitLab UI)\n- Labels extracted from issue payloads (name-only)\n- Label linkage reflects current GitLab state (removed labels unlinked on re-sync)\n- Issue discussions fetched per-issue (dependent sync)\n- Cursor-based sync is resumable (re-running fetches 0 new items)\n- Discussion sync skips unchanged issues (per-issue watermark)\n- Sync tracking records all runs\n- Single-flight lock prevents concurrent runs\n\n## Internal Gates\n- Gate A: Issues only (cursor + upsert + raw payloads + list/count/show)\n- Gate B: Labels correct (stale-link removal verified)\n- Gate C: Dependent discussion sync (watermark prevents redundant refetch)\n- Gate D: Resumability proof (kill mid-run, rerun; bounded redo)\n\nReference: docs/prd/checkpoint-1.md","status":"tombstone","priority":1,"issue_type":"epic","created_at":"2026-01-25T15:42:13.167698Z","created_by":"tayloreernisse","updated_at":"2026-01-25T17:02:01.638609Z","deleted_at":"2026-01-25T17:02:01.638606Z","deleted_by":"tayloreernisse","delete_reason":"recreating with correct deps","original_type":"epic","compaction_level":0,"original_size":0} {"id":"bd-2rk9","title":"WHO: CLI skeleton — WhoArgs, Commands::Who, dispatch arm","description":"## Background\n\nWire up the CLI plumbing so `lore who --help` works and dispatch reaches the who module. This is pure boilerplate — no query logic yet.\n\n## Approach\n\n### 1. src/cli/mod.rs — WhoArgs struct (after TimelineArgs, ~line 195)\n\n```rust\n#[derive(Parser)]\n#[command(after_help = \"\\x1b[1mExamples:\\x1b[0m\n lore who src/features/auth/ # Who knows about this area?\n lore who @asmith # What is asmith working on?\n lore who @asmith --reviews # What review patterns does asmith have?\n lore who --active # What discussions need attention?\n lore who --overlap src/features/auth/ # Who else is touching these files?\n lore who --path README.md # Expert lookup for a root file\")]\npub struct WhoArgs {\n /// Username or file path (path if contains /)\n pub target: Option,\n\n /// Force expert mode for a file/directory path (handles root files like README.md, Makefile)\n #[arg(long, help_heading = \"Mode\", conflicts_with_all = [\"active\", \"overlap\", \"reviews\"])]\n pub path: Option,\n\n /// Show active unresolved discussions\n #[arg(long, help_heading = \"Mode\", conflicts_with_all = [\"target\", \"overlap\", \"reviews\", \"path\"])]\n pub active: bool,\n\n /// Find users with MRs/notes touching this file path\n #[arg(long, help_heading = \"Mode\", conflicts_with_all = [\"target\", \"active\", \"reviews\", \"path\"])]\n pub overlap: Option,\n\n /// Show review pattern analysis (requires username target)\n #[arg(long, help_heading = \"Mode\", requires = \"target\", conflicts_with_all = [\"active\", \"overlap\", \"path\"])]\n pub reviews: bool,\n\n /// Time window (7d, 2w, 6m, YYYY-MM-DD). Default varies by mode.\n #[arg(long, help_heading = \"Filters\")]\n pub since: Option,\n\n /// Scope to a project (supports fuzzy matching)\n #[arg(short = 'p', long, help_heading = \"Filters\")]\n pub project: Option,\n\n /// Maximum results per section (1..=500)\n #[arg(short = 'n', long = \"limit\", default_value = \"20\",\n value_parser = clap::value_parser!(u16).range(1..=500),\n help_heading = \"Output\")]\n pub limit: u16,\n}\n```\n\n### 2. Commands enum — add Who(WhoArgs) after Timeline, before hidden List\n\n### 3. src/cli/commands/mod.rs — add `pub mod who;` and re-exports:\n```rust\npub use who::{run_who, print_who_human, print_who_json, WhoRun};\n```\n\n### 4. src/main.rs — dispatch arm + handler:\n```rust\nSome(Commands::Who(args)) => handle_who(cli.config.as_deref(), args, robot_mode),\n```\n\n### 5. src/cli/commands/who.rs — stub file with signatures that compile\n\n## Files\n\n- `src/cli/mod.rs` — WhoArgs struct + Commands::Who variant\n- `src/cli/commands/mod.rs` — pub mod who + re-exports\n- `src/main.rs` — dispatch arm + handle_who function + imports\n- `src/cli/commands/who.rs` — CREATE stub file\n\n## TDD Loop\n\nRED: `cargo check --all-targets` fails (missing who module)\nGREEN: Create stub who.rs with empty/todo!() implementations, wire up all 4 files\nVERIFY: `cargo check --all-targets && cargo run -- who --help`\n\n## Acceptance Criteria\n\n- [ ] `cargo check --all-targets` passes\n- [ ] `lore who --help` displays all flags with correct grouping (Mode, Filters, Output)\n- [ ] `lore who --active --overlap foo` rejected by clap (conflicts_with)\n- [ ] `lore who --reviews` rejected by clap (requires target)\n- [ ] WhoArgs is pub and importable from lore::cli\n\n## Edge Cases\n\n- conflicts_with_all on --path must NOT include \"target\" (--path is used alongside positional target in some cases... actually no, --path replaces target — check the plan: it conflicts with active/overlap/reviews but NOT target. Wait, looking at the plan: --path does NOT conflict with target. But if both target and --path are provided, --path takes priority in resolve_mode. The clap struct allows both.)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-08T02:39:58.436660Z","created_by":"tayloreernisse","updated_at":"2026-02-08T04:10:29.594923Z","closed_at":"2026-02-08T04:10:29.594882Z","close_reason":"Implemented by agent team: migration 017, CLI skeleton, all 5 query modes, human+robot output, 20 tests. All quality gates pass.","compaction_level":0,"original_size":0} +{"id":"bd-2rqs","title":"Dynamic shell completions for file paths (lore complete-path)","description":"Add a hidden lore complete-path subcommand that queries the DB for matching file paths, enabling tab-completion in bash/zsh/fish. Reuse path_resolver suffix_probe. Prior art: kubectl, gh, docker all use hidden subcommands for dynamic completions. Must be fast under 100ms. clap_complete v4 has custom completer API.","status":"open","priority":3,"issue_type":"feature","created_at":"2026-02-13T16:31:48.589428Z","created_by":"tayloreernisse","updated_at":"2026-02-13T16:31:48.592659Z","compaction_level":0,"original_size":0,"labels":["cli-ux","gate-4"]} {"id":"bd-2rr","title":"OBSERV: Replace subscriber init with dual-layer setup","description":"## Background\nThis is the core infrastructure bead for Phase 1. It replaces the single-layer subscriber (src/main.rs:44-58) with a dual-layer registry that separates stderr and file concerns. The file layer provides always-on post-mortem data; the stderr layer respects -v flags.\n\n## Approach\nReplace src/main.rs lines 44-58 with a function (e.g., init_tracing()) that:\n\n1. Build stderr filter from -v count (or RUST_LOG override):\n```rust\nfn build_stderr_filter(verbose: u8, quiet: bool) -> EnvFilter {\n if let Ok(rust_log) = std::env::var(\"RUST_LOG\") {\n return EnvFilter::new(rust_log);\n }\n if quiet {\n return EnvFilter::new(\"lore=warn,error\");\n }\n match verbose {\n 0 => EnvFilter::new(\"lore=info,warn\"),\n 1 => EnvFilter::new(\"lore=debug,warn\"),\n 2 => EnvFilter::new(\"lore=debug,info\"),\n _ => EnvFilter::new(\"trace,debug\"),\n }\n}\n```\n\n2. Build file filter (always lore=debug,warn unless RUST_LOG set):\n```rust\nfn build_file_filter() -> EnvFilter {\n if let Ok(rust_log) = std::env::var(\"RUST_LOG\") {\n return EnvFilter::new(rust_log);\n }\n EnvFilter::new(\"lore=debug,warn\")\n}\n```\n\n3. Assemble the registry:\n```rust\nlet stderr_layer = fmt::layer()\n .with_target(false)\n .with_writer(SuspendingWriter);\n// Conditionally add .json() based on log_format\n\nlet file_appender = tracing_appender::rolling::daily(log_dir, \"lore\");\nlet (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);\nlet file_layer = fmt::layer()\n .json()\n .with_writer(non_blocking);\n\ntracing_subscriber::registry()\n .with(stderr_layer.with_filter(build_stderr_filter(cli.verbose, cli.quiet)))\n .with(file_layer.with_filter(build_file_filter()))\n .init();\n```\n\nCRITICAL: The non_blocking _guard must be held for the program's lifetime. Store it in main() scope, NOT in the init function. If the guard drops, the file writer thread stops and buffered logs are lost.\n\nCRITICAL: Per-layer filtering requires each .with_filter() to produce a Filtered type. The two layers will have different concrete types (one with json, one without). This is fine -- the registry accepts heterogeneous layers via .with().\n\nWhen --log-format json: wrap stderr_layer with .json() too. This requires conditional construction. Two approaches:\n A) Use Box> for dynamic dispatch (simpler, tiny perf hit)\n B) Use an enum wrapper (zero cost but more code)\nRecommend approach A for simplicity. The overhead is one vtable indirection per log event, dwarfed by I/O.\n\nWhen file_logging is false (LoggingConfig.file_logging == false): skip adding the file layer entirely.\n\n## Acceptance Criteria\n- [ ] lore sync writes JSON log lines to ~/.local/share/lore/logs/lore.YYYY-MM-DD.log\n- [ ] lore -v sync shows DEBUG lore::* on stderr, deps at WARN\n- [ ] lore -vv sync shows DEBUG lore::* + INFO deps on stderr\n- [ ] lore -vvv sync shows TRACE everything on stderr\n- [ ] RUST_LOG=lore::gitlab=trace overrides -v for both layers\n- [ ] lore --log-format json sync emits JSON on stderr\n- [ ] -q + -v: -q wins (stderr at WARN+)\n- [ ] -q does NOT affect file layer (still DEBUG+)\n- [ ] File layer does NOT use SuspendingWriter\n- [ ] Non-blocking guard kept alive for program duration\n- [ ] Existing behavior unchanged when no new flags passed\n- [ ] cargo clippy --all-targets -- -D warnings passes\n\n## Files\n- src/main.rs (replace lines 44-58, add init_tracing function or inline)\n\n## TDD Loop\nRED:\n - test_verbosity_filter_construction: assert filter directives for verbose=0,1,2,3\n - test_rust_log_overrides_verbose: set env, assert TRACE not DEBUG\n - test_quiet_overrides_verbose: -q + -v => WARN+\n - test_json_log_output_format: capture file output, parse as JSON\n - test_suspending_writer_dual_layer: no garbled stderr with progress bars\nGREEN: Implement build_stderr_filter, build_file_filter, assemble registry\nVERIFY: cargo test && cargo clippy --all-targets -- -D warnings\n\n## Edge Cases\n- _guard lifetime: if guard is dropped early, buffered log lines are lost. MUST hold in main() scope.\n- Type erasure: stderr layer with/without .json() produces different types. Use Box> or separate init paths.\n- Empty RUST_LOG string: env::var returns Ok(\"\"), which EnvFilter::new(\"\") defaults to TRACE. May want to check is_empty().\n- File I/O error on log dir: tracing-appender handles this gracefully (no panic), but logs will be silently lost. The doctor command (bd-2i10) can diagnose this.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-04T15:53:55.577025Z","created_by":"tayloreernisse","updated_at":"2026-02-04T17:15:04.384114Z","closed_at":"2026-02-04T17:15:04.384062Z","close_reason":"Replaced single-layer subscriber with dual-layer setup: stderr (human/json, -v controlled) + file (always-on JSON, daily rotation via tracing-appender)","compaction_level":0,"original_size":0,"labels":["observability"],"dependencies":[{"issue_id":"bd-2rr","depends_on_id":"bd-17n","type":"blocks","created_at":"2026-02-04T15:55:19.397949Z","created_by":"tayloreernisse"},{"issue_id":"bd-2rr","depends_on_id":"bd-1k4","type":"blocks","created_at":"2026-02-04T15:55:19.461728Z","created_by":"tayloreernisse"},{"issue_id":"bd-2rr","depends_on_id":"bd-1o1","type":"blocks","created_at":"2026-02-04T15:55:19.327157Z","created_by":"tayloreernisse"},{"issue_id":"bd-2rr","depends_on_id":"bd-2nx","type":"parent-child","created_at":"2026-02-04T15:53:55.577882Z","created_by":"tayloreernisse"},{"issue_id":"bd-2rr","depends_on_id":"bd-gba","type":"blocks","created_at":"2026-02-04T15:55:19.262870Z","created_by":"tayloreernisse"}]} {"id":"bd-2sr2","title":"Robot sync envelope: status enrichment metadata","description":"## Background\nAgents need machine-readable status enrichment metadata in the robot sync output to detect issues like unsupported GraphQL, partial errors, or enrichment failures. Without this, enrichment problems are invisible to automation.\n\n## Approach\nWire IngestProjectResult status fields into the per-project robot sync JSON. Add aggregate error count to top-level summary.\n\n## Files\n- Wherever robot sync output JSON is constructed (likely src/cli/commands/ingest.rs or the sync output serialization path — search for IngestProjectResult -> JSON conversion)\n\n## Implementation\n\nPer-project status_enrichment object in robot sync JSON:\n{\n \"mode\": \"fetched\" | \"unsupported\" | \"skipped\",\n \"reason\": null | \"graphql_endpoint_missing\" | \"auth_forbidden\",\n \"seen\": N,\n \"enriched\": N,\n \"cleared\": N,\n \"without_widget\": N,\n \"partial_errors\": N,\n \"first_partial_error\": null | \"message\",\n \"error\": null | \"message\"\n}\n\nSource fields from IngestProjectResult:\n mode <- status_enrichment_mode\n reason <- status_unsupported_reason\n seen <- statuses_seen\n enriched <- statuses_enriched\n cleared <- statuses_cleared\n without_widget <- statuses_without_widget\n partial_errors <- partial_error_count\n first_partial_error <- first_partial_error\n error <- status_enrichment_error\n\nTop-level sync summary: add status_enrichment_errors: N (count of projects where error is Some)\n\nField semantics:\n mode \"fetched\": enrichment ran (even if 0 statuses or error occurred)\n mode \"unsupported\": 404/403 from GraphQL\n mode \"skipped\": config toggle off\n seen > 0 + enriched == 0: project has issues but none with status\n partial_errors > 0: some pages returned incomplete data\n\n## Acceptance Criteria\n- [ ] Robot sync JSON includes per-project status_enrichment object\n- [ ] All 9 fields present with correct types\n- [ ] mode reflects actual enrichment outcome (fetched/unsupported/skipped)\n- [ ] Top-level status_enrichment_errors count present\n- [ ] Test: full robot sync output validates structure\n\n## TDD Loop\nRED: test_robot_sync_includes_status_enrichment\nGREEN: Wire fields into JSON serialization\nVERIFY: cargo test robot_sync\n\n## Edge Cases\n- Find the exact location where IngestProjectResult is serialized to JSON — may be in a Serialize impl or manual json! macro\n- All numeric fields default to 0, all Option fields default to null in JSON\n- mode is always present (never null)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-11T06:42:29.127412Z","created_by":"tayloreernisse","updated_at":"2026-02-11T07:21:33.422233Z","closed_at":"2026-02-11T07:21:33.422193Z","close_reason":"Implemented by agent swarm — all quality gates pass (595 tests, 0 failures)","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2sr2","depends_on_id":"bd-2y79","type":"parent-child","created_at":"2026-02-11T06:42:29.130750Z","created_by":"tayloreernisse"},{"issue_id":"bd-2sr2","depends_on_id":"bd-3dum","type":"blocks","created_at":"2026-02-11T06:42:45.995816Z","created_by":"tayloreernisse"}]} {"id":"bd-2sx","title":"Implement lore embed CLI command","description":"## Background\nThe embed CLI command is the user-facing wrapper for the embedding pipeline. It runs Ollama health checks, selects documents to embed (pending or failed), shows progress, and reports results. This is the standalone command for building embeddings outside of the sync orchestrator.\n\n## Approach\nCreate `src/cli/commands/embed.rs` per PRD Section 4.4.\n\n**IMPORTANT: The embed command is async.** The underlying `embed_documents()` function is `async fn` (uses `FuturesUnordered` for concurrent HTTP to Ollama). The CLI runner must use tokio runtime.\n\n**Core function (async):**\n```rust\npub async fn run_embed(\n config: &Config,\n retry_failed: bool,\n) -> Result\n```\n\n**Pipeline:**\n1. Create OllamaClient from config.embedding (base_url, model, timeout_secs)\n2. Run `client.health_check().await` — fail early with clear error if Ollama unavailable or model missing\n3. Determine selection: `EmbedSelection::RetryFailed` if --retry-failed, else `EmbedSelection::Pending`\n4. Call `embed_documents(conn, &client, selection, concurrency, progress_callback).await`\n - `concurrency` param controls max in-flight HTTP requests to Ollama\n - `progress_callback` drives indicatif progress bar\n5. Show progress bar (indicatif) during embedding\n6. Return EmbedResult with counts\n\n**CLI args:**\n```rust\n#[derive(Args)]\npub struct EmbedArgs {\n #[arg(long)]\n retry_failed: bool,\n}\n```\n\n**Output:**\n- Human: \"Embedded 42 documents (15 chunks), 2 errors, 5 skipped (unchanged)\"\n- JSON: `{\"ok\": true, \"data\": {\"embedded\": 42, \"chunks\": 15, \"errors\": 2, \"skipped\": 5}}`\n\n**Tokio integration note:**\nThe embed command runs async code. Either:\n- Use `#[tokio::main]` on main and propagate async through CLI dispatch\n- Or use `tokio::runtime::Runtime::new()` in the embed command handler\n\n## Acceptance Criteria\n- [ ] Command is async (embed_documents is async, health_check is async)\n- [ ] OllamaClient created from config.embedding settings\n- [ ] Health check runs first — clear error if Ollama down (exit code 14)\n- [ ] Clear error if model not found: \"Pull the model: ollama pull nomic-embed-text\" (exit code 15)\n- [ ] Embeds pending documents (no existing embeddings or stale content_hash)\n- [ ] --retry-failed re-attempts documents with last_error\n- [ ] Progress bar shows during embedding (indicatif)\n- [ ] embed_documents called with concurrency parameter\n- [ ] embed_documents called with progress_callback for progress bar\n- [ ] Human + JSON output\n- [ ] `cargo build` succeeds\n\n## Files\n- `src/cli/commands/embed.rs` — new file\n- `src/cli/commands/mod.rs` — add `pub mod embed;`\n- `src/cli/mod.rs` — add EmbedArgs, wire up embed subcommand\n- `src/main.rs` — add embed command handler (async dispatch)\n\n## TDD Loop\nRED: Integration test needing Ollama\nGREEN: Implement run_embed (async)\nVERIFY: `cargo build && cargo test embed`\n\n## Edge Cases\n- No documents in DB: \"No documents to embed\" (not error)\n- All documents already embedded and unchanged: \"0 documents to embed (all up to date)\"\n- Ollama goes down mid-embedding: pipeline records errors for remaining docs, returns partial result\n- --retry-failed with no failed docs: \"No failed documents to retry\"","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-30T15:26:34.126482Z","created_by":"tayloreernisse","updated_at":"2026-01-30T18:02:38.633115Z","closed_at":"2026-01-30T18:02:38.633055Z","close_reason":"Embed CLI command fully wired: EmbedArgs, Commands::Embed variant, handle_embed handler, clean build, all tests pass","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-2sx","depends_on_id":"bd-am7","type":"blocks","created_at":"2026-01-30T15:29:24.766104Z","created_by":"tayloreernisse"}]} @@ -264,6 +265,7 @@ {"id":"bd-dty","title":"Implement timeline robot mode JSON output","description":"## Background\n\nRobot mode JSON for timeline follows the {ok, data, meta} envelope pattern. The JSON schema MUST match spec Section 3.5 exactly — this is the contract for AI agent consumers.\n\n**Spec reference:** `docs/phase-b-temporal-intelligence.md` Section 3.5 (Robot Mode JSON).\n\n## Codebase Context\n\n- Robot mode pattern: all commands use {ok: true, data: {...}, meta: {...}} envelope\n- Timestamps: internal ms epoch UTC -> output ISO 8601 via core::time::ms_to_iso()\n- source_method values in DB: 'api', 'note_parse', 'description_parse' (NOT spec's api_closes_issues etc.)\n- Serde rename: use #[serde(rename = \"type\")] for entity objects per spec\n\n## Approach\n\nCreate `print_timeline_json()` in `src/cli/commands/timeline.rs`:\n\n### Key JSON structure (spec Section 3.5):\n- data.seed_entities: [{type, iid, project}] — note \"type\" not \"entity_type\", \"project\" not \"project_path\"\n- data.expanded_entities: [{type, iid, project, depth, via: {from: {type,iid,project}, reference_type, source_method}}]\n- data.unresolved_references: [{source: {type,iid,project}, target_project, target_type, target_iid, reference_type}]\n- data.events: [{timestamp (ISO 8601), entity_type, entity_iid, project, event_type, summary, actor, url, is_seed, details}]\n- meta: {search_mode: \"lexical\", expansion_depth, expand_mentions, total_entities, total_events, evidence_notes_included, unresolved_references, showing}\n\n### Details object per event type:\n- created: {labels: [...]}\n- note_evidence: {note_id, snippet}\n- state_changed: {state}\n- label_added: {label}\n\n### Rust JSON Structs\n\n```rust\n#[derive(Serialize)]\nstruct TimelineJson {\n ok: bool,\n data: TimelineDataJson,\n meta: TimelineMetaJson,\n}\n\n#[derive(Serialize)]\nstruct TimelineDataJson {\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 EntityJson {\n #[serde(rename = \"type\")]\n entity_type: String,\n iid: i64,\n project: String,\n}\n\n#[derive(Serialize)]\nstruct TimelineMetaJson {\n search_mode: String, // always \"lexical\"\n expansion_depth: u32,\n expand_mentions: bool,\n total_entities: usize,\n total_events: usize, // before limit\n evidence_notes_included: usize,\n unresolved_references: usize,\n showing: usize, // after limit\n}\n```\n\n### source_method values: use CODEBASE values (api/note_parse/description_parse), not spec values\n\n## Acceptance Criteria\n\n- [ ] Valid JSON to stdout\n- [ ] {ok, data, meta} envelope\n- [ ] ISO 8601 timestamps\n- [ ] Entity objects use \"type\" and \"project\" keys per spec\n- [ ] Nested \"via\" object on expanded entities per spec\n- [ ] Events include url and details fields\n- [ ] meta.total_events before limit; meta.showing after limit\n- [ ] source_method uses codebase values\n- [ ] `cargo check --all-targets` passes\n\n## Files\n\n- `src/cli/commands/timeline.rs` (add print_timeline_json + JSON structs)\n- `src/cli/commands/mod.rs` (re-export)\n\n## TDD Loop\n\nVerify: `lore --robot timeline \"test\" | jq '.data.expanded_entities[0].via.from'`\n\n## Edge Cases\n\n- Empty results: events=[], meta.showing=0\n- Null actor/url: serialize as null (not omitted)\n- source_method: use actual DB values, not spec originals","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-02T21:33:28.374690Z","created_by":"tayloreernisse","updated_at":"2026-02-06T13:49:12.653118Z","closed_at":"2026-02-06T13:49:12.653067Z","close_reason":"Implemented print_timeline_json_with_meta() robot JSON output in src/cli/commands/timeline.rs with {ok,data,meta} envelope, ISO timestamps, entity/expanded/unresolved JSON structs, event details per type","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-ef0u","title":"NOTE-2B: SourceType enum extension for notes","description":"## Background\nThe SourceType enum in src/documents/extractor.rs (line 15-19) needs a Note variant for the document pipeline to handle note-type documents.\n\n## Approach\nIn src/documents/extractor.rs:\n1. Add Note variant to SourceType enum (line 15-19, after Discussion):\n pub enum SourceType { Issue, MergeRequest, Discussion, Note }\n\n2. Add match arm to as_str() (line 22-28): Self::Note => \"note\"\n\n3. Add parse aliases (line 30-37): \"note\" | \"notes\" => Some(Self::Note)\n\n4. Display impl (line 40-43) already delegates to as_str() — no change needed.\n\n5. IMPORTANT: Also update seed_dirty() in src/cli/commands/generate_docs.rs (line 66-70) which has a match on SourceType that maps to table names. SourceType::Note should NOT be added to this match — notes are seeded differently (by querying the notes table, not by table name pattern). This is handled by NOTE-2E.\n\n## Files\n- MODIFY: src/documents/extractor.rs (SourceType enum at line 15, as_str at line 22, parse at line 30)\n\n## TDD Anchor\nRED: test_source_type_parse_note — assert SourceType::parse(\"note\") == Some(SourceType::Note)\nGREEN: Add Note variant and match arms.\nVERIFY: cargo test source_type_parse_note -- --nocapture\nTests: test_source_type_note_as_str (assert as_str() == \"note\"), test_source_type_note_display (assert format!(\"{}\", SourceType::Note) == \"note\"), test_source_type_parse_notes_alias (assert parse(\"notes\") works)\n\n## Acceptance Criteria\n- [ ] SourceType::Note variant exists\n- [ ] as_str() returns \"note\"\n- [ ] parse() accepts \"note\", \"notes\" (case-insensitive via to_lowercase)\n- [ ] Display trait works via as_str delegation\n- [ ] No change to seed_dirty() match — that's a separate bead (NOTE-2E)\n- [ ] All 4 tests pass, clippy clean\n- [ ] CRITICAL: regenerate_one() in src/documents/regenerator.rs (line 86-91) has exhaustive match on SourceType — adding Note variant will cause a compile error until NOTE-2D adds the match arm. Either add a temporary todo!() or coordinate with NOTE-2D.\n\n## Dependency Context\n- Depends on NOTE-2A (bd-1oi7): migration 024 must exist so test DBs accept source_type='note' in documents/dirty_sources tables\n\n## Edge Cases\n- Exhaustive match: Adding the variant breaks regenerate_one() (line 86-91) and seed_dirty() (line 66-70) until downstream beads handle it. Agent should add temporary unreachable!() arms with comments referencing the downstream bead IDs.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T17:01:45.555568Z","created_by":"tayloreernisse","updated_at":"2026-02-12T18:13:24.004157Z","closed_at":"2026-02-12T18:13:24.004106Z","close_reason":"Implemented by agent swarm","compaction_level":0,"original_size":0,"labels":["per-note","search"],"dependencies":[{"issue_id":"bd-ef0u","depends_on_id":"bd-18yh","type":"blocks","created_at":"2026-02-12T17:04:49.376312Z","created_by":"tayloreernisse"},{"issue_id":"bd-ef0u","depends_on_id":"bd-2ezb","type":"blocks","created_at":"2026-02-12T17:04:49.521665Z","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-flwo","title":"Interactive path selection for ambiguous matches (TTY picker)","description":"When a partial file path matches multiple files, show an interactive numbered picker in TTY mode instead of a hard error. In robot mode, return candidates as structured JSON in the error envelope. Use dialoguer crate for selection UI. The path_resolver module already detects ambiguity via SuffixResult::Ambiguous and limits to 11 candidates.","status":"open","priority":3,"issue_type":"feature","created_at":"2026-02-13T16:31:50.005222Z","created_by":"tayloreernisse","updated_at":"2026-02-13T16:31:50.007520Z","compaction_level":0,"original_size":0,"labels":["cli-ux","gate-4"]} {"id":"bd-g0d5","title":"WHO: Verification gate — check, clippy, fmt, EXPLAIN QUERY PLAN","description":"## Background\n\nFinal verification gate before the who epic is considered complete. Confirms code quality, test coverage, and index utilization against real data.\n\n## Approach\n\n### Step 1: Compiler checks\n```bash\ncargo check --all-targets\ncargo clippy --all-targets -- -D warnings\ncargo fmt --check\ncargo test\n```\n\n### Step 2: Manual smoke test (against real DB)\n```bash\ncargo run --release -- who src/features/global-search/\ncargo run --release -- who @asmith\ncargo run --release -- who @asmith --reviews\ncargo run --release -- who --active\ncargo run --release -- who --active --since 30d\ncargo run --release -- who --overlap libs/shared-frontend/src/features/global-search/\ncargo run --release -- who --path README.md\ncargo run --release -- who --path Makefile\ncargo run --release -- -J who src/features/global-search/ # robot mode\ncargo run --release -- -J who @asmith # robot mode\ncargo run --release -- who src/features/global-search/ -p typescript # project scoped\n```\n\n### Step 3: EXPLAIN QUERY PLAN verification\n```bash\n# Expert: should use idx_notes_diffnote_path_created\nsqlite3 ~/.local/share/lore/lore.db \"\n EXPLAIN QUERY PLAN\n SELECT n.author_username, COUNT(*), MAX(n.created_at)\n FROM notes n\n WHERE n.note_type = 'DiffNote' AND n.is_system = 0\n AND n.position_new_path LIKE 'src/features/global-search/%' ESCAPE '\\\\'\n AND n.created_at >= 0\n GROUP BY n.author_username;\"\n\n# Active global: should use idx_discussions_unresolved_recent_global\nsqlite3 ~/.local/share/lore/lore.db \"\n EXPLAIN QUERY PLAN\n SELECT d.id, d.last_note_at FROM discussions d\n WHERE d.resolvable = 1 AND d.resolved = 0 AND d.last_note_at >= 0\n ORDER BY d.last_note_at DESC LIMIT 20;\"\n\n# Active scoped: should use idx_discussions_unresolved_recent\nsqlite3 ~/.local/share/lore/lore.db \"\n EXPLAIN QUERY PLAN\n SELECT d.id, d.last_note_at FROM discussions d\n WHERE d.resolvable = 1 AND d.resolved = 0 AND d.project_id = 1\n AND d.last_note_at >= 0\n ORDER BY d.last_note_at DESC LIMIT 20;\"\n```\n\n## Files\n\nNo files modified — verification only.\n\n## TDD Loop\n\nThis bead is the TDD VERIFY phase for the entire epic. No code written.\nVERIFY: All commands in Steps 1-3 must succeed. Document results.\n\n## Acceptance Criteria\n\n- [ ] cargo check --all-targets: 0 errors\n- [ ] cargo clippy --all-targets -- -D warnings: 0 warnings\n- [ ] cargo fmt --check: no formatting changes needed\n- [ ] cargo test: all tests pass (including 20+ who tests)\n- [ ] Expert EXPLAIN shows idx_notes_diffnote_path_created\n- [ ] Active global EXPLAIN shows idx_discussions_unresolved_recent_global\n- [ ] Active scoped EXPLAIN shows idx_discussions_unresolved_recent\n- [ ] All 5 modes produce reasonable output against real data\n- [ ] Robot mode produces valid JSON for all modes\n\n## Edge Cases\n\n- DB path may differ from ~/.local/share/lore/lore.db — check config with `lore -J doctor` first to get actual db_path\n- EXPLAIN QUERY PLAN output format varies by SQLite version — look for the index name in any output column, not an exact string match\n- If the DB has not been synced recently, smoke tests may return empty results — run `lore sync` first if needed\n- Project name \"typescript\" in the -p flag may not exist — use an actual project from `lore -J status` output\n- The real DB may not have migration 017 yet — run `cargo run --release -- migrate` first if the who command fails with a missing index error\n- clippy::pedantic + clippy::nursery are enabled — common issues: arrays vs vec![] for sorted collections, too_many_arguments on test helpers (use #[allow])","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-08T02:41:42.642988Z","created_by":"tayloreernisse","updated_at":"2026-02-08T04:10:29.606672Z","closed_at":"2026-02-08T04:10:29.606631Z","close_reason":"Implemented by agent team: migration 017, CLI skeleton, all 5 query modes, human+robot output, 20 tests. All quality gates pass.","compaction_level":0,"original_size":0,"dependencies":[{"issue_id":"bd-g0d5","depends_on_id":"bd-tfh3","type":"blocks","created_at":"2026-02-08T02:43:40.339977Z","created_by":"tayloreernisse"},{"issue_id":"bd-g0d5","depends_on_id":"bd-zibc","type":"blocks","created_at":"2026-02-08T02:43:40.492501Z","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-gcnx","title":"NOTE-TEST: Test bead","description":"type: task","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T16:58:40.129030Z","updated_at":"2026-02-12T16:58:47.794167Z","closed_at":"2026-02-12T16:58:47.794116Z","close_reason":"test","compaction_level":0,"original_size":0} diff --git a/.github/workflows/roam.yml b/.github/workflows/roam.yml new file mode 100644 index 0000000..89b5396 --- /dev/null +++ b/.github/workflows/roam.yml @@ -0,0 +1,21 @@ +name: Roam Code Analysis +on: + pull_request: + branches: [main, master] +permissions: + contents: read + pull-requests: write +jobs: + roam: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: pip install roam-code + - run: roam index + - run: roam fitness + - run: roam pr-risk --json diff --git a/.roam/fitness.yaml b/.roam/fitness.yaml new file mode 100644 index 0000000..cf82198 --- /dev/null +++ b/.roam/fitness.yaml @@ -0,0 +1,11 @@ +rules: + - name: No circular imports in core + type: dependency + source: "src/**" + forbidden_target: "tests/**" + reason: "Production code should not import test modules" + - name: Complexity threshold + type: metric + metric: cognitive_complexity + threshold: 30 + reason: "Functions above 30 cognitive complexity need refactoring" diff --git a/src/cli/commands/show.rs b/src/cli/commands/show.rs index 4f520ad..887b755 100644 --- a/src/cli/commands/show.rs +++ b/src/cli/commands/show.rs @@ -1228,9 +1228,19 @@ mod tests { .unwrap(); } - fn seed_discussion_with_notes(conn: &Connection, issue_id: i64, project_id: i64, user_notes: usize, system_notes: usize) { + fn seed_discussion_with_notes( + conn: &Connection, + issue_id: i64, + project_id: i64, + user_notes: usize, + system_notes: usize, + ) { let disc_id: i64 = conn - .query_row("SELECT COALESCE(MAX(id), 0) + 1 FROM discussions", [], |r| r.get(0)) + .query_row( + "SELECT COALESCE(MAX(id), 0) + 1 FROM discussions", + [], + |r| r.get(0), + ) .unwrap(); conn.execute( "INSERT INTO discussions (id, gitlab_discussion_id, project_id, issue_id, noteable_type, first_note_at, last_note_at, last_seen_at) diff --git a/src/search/vector.rs b/src/search/vector.rs index 2e9cfb9..1d307fe 100644 --- a/src/search/vector.rs +++ b/src/search/vector.rs @@ -150,7 +150,10 @@ mod tests { #[test] fn test_knn_k_reproduces_original_bug_scenario() { let k = compute_knn_k(1500, 1); - assert!(k <= SQLITE_VEC_KNN_MAX, "k={k} exceeded 4096 at RECALL_CAP with 1 chunk"); + assert!( + k <= SQLITE_VEC_KNN_MAX, + "k={k} exceeded 4096 at RECALL_CAP with 1 chunk" + ); } #[test]