6 Commits

Author SHA1 Message Date
teernisse
ace9c8bf17 docs(specs): add SPEC_explain.md for explain command design
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 13:27:39 -04:00
teernisse
cab8c540da fix(show): include gitlab_id on notes in issue/MR detail views
The show command's NoteDetail and MrNoteDetail structs were missing
gitlab_id, making individual notes unaddressable in robot mode output.
This was inconsistent with the notes list command which already exposed
gitlab_id. Without an identifier, agents consuming show output could
not construct GitLab web URLs or reference specific notes for follow-up
operations via glab.

Added gitlab_id to:
- NoteDetail / NoteDetailJson (issue discussions)
- MrNoteDetail / MrNoteDetailJson (MR discussions)
- Both SQL queries (shifted column indices accordingly)
- Both From<&T> conversion impls

Deliberately scoped to show command only — me/timeline/trace structs
were evaluated and intentionally left unchanged because they serve
different consumption patterns where note-level identity is not needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 13:27:33 -04:00
teernisse
d94bcbfbe7 docs(me): clarify dashboard section scoping in README
Document that the activity feed and since-last-check inbox cover items
in any state (open, closed, merged), while the issues and MRs sections
show only open items. Add the previously undocumented since-last-check
inbox section to the dashboard description.
2026-03-10 11:07:10 -04:00
teernisse
62fbd7275e fix(me): show activity on closed/merged items in dashboard
The activity feed and since-last-check inbox previously filtered to
only open items via state = 'opened' checks in the SQL subqueries.
This meant comments on merged MRs (post-merge follow-ups, questions)
and closed issues were silently dropped from the feed.

Remove the state filter from the association checks in both
query_activity() and query_since_last_check(). The user-association
checks (assigned, authored, reviewing) remain — activity still only
appears for items the user is connected to, regardless of state.

The simplified subqueries also eliminate unnecessary JOINs to the
issues/merge_requests tables that were only needed for the state
check, resulting in slightly more efficient index-only scans on
issue_assignees and mr_reviewers.

Add 4 tests covering: merged MR (authored), closed MR (reviewer),
closed issue (assignee), and merged MR in the since-last-check inbox.
2026-03-10 11:07:05 -04:00
teernisse
06852e90a6 docs(cli): add command restructure audit and implementation plan
CLI audit scoring the current command surface across human ergonomics,
robot/agent ergonomics, documentation quality, and flag design. Paired
with a detailed implementation plan for restructuring commands into a
more consistent, discoverable hierarchy.
2026-03-10 11:06:53 -04:00
teernisse
4b0535f852 perf(timeline): guard against overly broad seed queries
Add pre-flight FTS count check before expensive bm25-ranked search.
Queries matching >10,000 documents are rejected instantly with a
suggestion to use a more specific query or --since filter.

Prevents multi-minute CPU spin on queries like 'merge request' that
match most of the corpus (106K/178K documents).
2026-03-06 21:22:43 -05:00
10 changed files with 2220 additions and 33 deletions

View File

@@ -431,11 +431,12 @@ lore me --reset-cursor # Reset since-last-check cursor
The dashboard detects the current user from GitLab authentication and shows: The dashboard detects the current user from GitLab authentication and shows:
- **Issues section**: Open issues assigned to you - **Issues section**: Open issues assigned to you
- **MRs section**: MRs you authored + MRs where you're a reviewer - **MRs section**: Open MRs you authored + open MRs where you're a reviewer
- **Activity section**: Recent events (state changes, comments, etc.) on your items - **Activity section**: Recent events (state changes, comments, labels, milestones, assignments) on your items regardless of state — including closed issues and merged/closed MRs
- **Mentions section**: Items where you're @mentioned but not assigned/authoring/reviewing - **Mentions section**: Items where you're @mentioned but not assigned/authoring/reviewing
- **Since last check**: Cursor-based inbox of actionable events from others since your last check, covering items in any state
The `--since` flag affects only the activity section. Other sections show current state regardless of time window. The `--since` flag affects only the activity section. The issues and MRs sections show open items only. The since-last-check inbox uses a persistent cursor (reset with `--reset-cursor`).
#### Field Selection (Robot Mode) #### Field Selection (Robot Mode)

View File

@@ -0,0 +1,389 @@
# Gitlore CLI Command Audit
## 1. Full Command Inventory
**29 visible + 4 hidden + 2 stub = 35 total command surface**
| # | Command | Aliases | Args | Flags | Purpose |
|---|---------|---------|------|-------|---------|
| 1 | `issues` | `issue` | `[IID]` | 15 | List/show issues |
| 2 | `mrs` | `mr`, `merge-requests` | `[IID]` | 16 | List/show MRs |
| 3 | `notes` | `note` | — | 16 | List notes |
| 4 | `search` | `find`, `query` | `<QUERY>` | 13 | Hybrid FTS+vector search |
| 5 | `timeline` | — | `<QUERY>` | 11 | Chronological event reconstruction |
| 6 | `who` | — | `[TARGET]` | 16 | People intelligence (5 modes) |
| 7 | `me` | — | — | 10 | Personal dashboard |
| 8 | `file-history` | — | `<PATH>` | 6 | MRs that touched a file |
| 9 | `trace` | — | `<PATH>` | 5 | file->MR->issue->discussion chain |
| 10 | `drift` | — | `<TYPE> <IID>` | 3 | Discussion divergence detection |
| 11 | `related` | — | `<QUERY_OR_TYPE> [IID]` | 3 | Semantic similarity |
| 12 | `count` | — | `<ENTITY>` | 2 | Count entities |
| 13 | `sync` | — | — | 14 | Full pipeline: ingest+docs+embed |
| 14 | `ingest` | — | `[ENTITY]` | 5 | Fetch from GitLab API |
| 15 | `generate-docs` | — | — | 2 | Build searchable documents |
| 16 | `embed` | — | — | 2 | Generate vector embeddings |
| 17 | `status` | `st` | — | 0 | Last sync times per project |
| 18 | `health` | — | — | 0 | Quick pre-flight (exit code only) |
| 19 | `doctor` | — | — | 0 | Full environment diagnostic |
| 20 | `stats` | `stat` | — | 3 | Document/index statistics |
| 21 | `init` | — | — | 6 | Setup config + database |
| 22 | `auth` | — | — | 0 | Verify GitLab token |
| 23 | `token` | — | subcommand | 1-2 | Token CRUD (set/show) |
| 24 | `cron` | — | subcommand | 0-1 | Auto-sync scheduling |
| 25 | `migrate` | — | — | 0 | Apply DB migrations |
| 26 | `robot-docs` | — | — | 1 | Agent self-discovery manifest |
| 27 | `completions` | — | `<SHELL>` | 0 | Shell completions |
| 28 | `version` | — | — | 0 | Version info |
| 29 | *help* | — | — | — | (clap built-in) |
| | **Hidden/deprecated:** | | | | |
| 30 | `list` | — | `<ENTITY>` | 14 | deprecated, use issues/mrs |
| 31 | `show` | — | `<ENTITY> <IID>` | 1 | deprecated, use issues/mrs |
| 32 | `auth-test` | — | — | 0 | deprecated, use auth |
| 33 | `sync-status` | — | — | 0 | deprecated, use status |
| 34 | `backup` | — | — | 0 | Stub (not implemented) |
| 35 | `reset` | — | — | 1 | Stub (not implemented) |
---
## 2. Semantic Overlap Analysis
### Cluster A: "Is the system working?" (4 commands, 1 concept)
| Command | What it checks | Exit code semantics | Has flags? |
|---------|---------------|---------------------|------------|
| `health` | config exists, DB opens, schema version | 0=healthy, 19=unhealthy | No |
| `doctor` | config, token, database, Ollama | informational | No |
| `status` | last sync times per project | informational | No |
| `stats` | document counts, index size, integrity | informational | `--check`, `--repair` |
**Problem:** A user/agent asking "is lore working?" must choose among four commands. `health` is a strict subset of `doctor`. `status` and `stats` are near-homonyms that answer different questions -- sync recency vs. index health. `count` (Cluster E) also overlaps with what `stats` reports.
**Cognitive cost:** High. The CLI literature (Clig.dev, Heroku CLI design guide, 12-factor CLI) consistently warns against >2 "status" commands. Users build a mental model of "the status command" -- when there are four, they pick wrong or give up.
**Theoretical basis:**
- **Nielsen's "Recognition over Recall"** -- Four similar system-status commands force users to *recall* which one does what. One command with progressive disclosure (flags for depth) lets them *recognize* the option they need. This is doubly important for LLM agents, which perform better with fewer top-level choices and compositional flags.
- **Fitts's Law for CLIs** -- Command discovery cost is proportional to list length. Each additional top-level command adds scanning time for humans and token cost for robots.
### Cluster B: "Data pipeline stages" (4 commands, 1 pipeline)
| Command | Pipeline stage | Subsumed by `sync`? |
|---------|---------------|---------------------|
| `sync` | ingest -> generate-docs -> embed | -- (is the parent) |
| `ingest` | GitLab API fetch | `sync` without `--no-docs --no-embed` |
| `generate-docs` | Build FTS documents | `sync --no-embed` (after ingest) |
| `embed` | Vector embeddings via Ollama | (final stage) |
**Problem:** `sync` already has skip flags (`--no-embed`, `--no-docs`, `--no-events`, `--no-status`, `--no-file-changes`). The individual stage commands duplicate this with less control -- `ingest` has `--full`, `--force`, `--dry-run`, but `sync` also has all three.
The standalone commands exist for granular debugging, but in practice they're reached for <5% of the time. They inflate the help screen while `sync` handles 95% of use cases.
### Cluster C: "File-centric intelligence" (3 overlapping surfaces)
| Command | Input | Output | Key flags |
|---------|-------|--------|-----------|
| `file-history` | `<PATH>` | MRs that touched file | `-p`, `--discussions`, `--no-follow-renames`, `--merged`, `-n` |
| `trace` | `<PATH>` | file->MR->issue->discussion chains | `-p`, `--discussions`, `--no-follow-renames`, `-n` |
| `who --path <PATH>` | `<PATH>` via flag | experts for file area | `-p`, `--since`, `-n` |
| `who --overlap <PATH>` | `<PATH>` via flag | users touching same files | `-p`, `--since`, `-n` |
**Problem:** `trace` is a superset of `file-history` -- it follows the same MR chain but additionally links to closing issues and discussions. They share 4 of 5 filter flags. A user who wants "what happened to this file?" has to choose between two commands that sound nearly identical.
### Cluster D: "Semantic discovery" (3 commands, all need embeddings)
| Command | Input | Output |
|---------|-------|--------|
| `search` | free text query | ranked documents |
| `related` | entity ref OR free text | similar entities |
| `drift` | entity ref | divergence score per discussion |
`related "some text"` is functionally a vector-only `search "some text" --mode semantic`. The difference is that `related` can also seed from an entity (issues 42), while `search` only accepts text.
`drift` is specialized enough to stand alone, but it's only used on issues and has a single non-project flag (`--threshold`).
### Cluster E: "Count" is an orphan
`count` is a standalone command for `SELECT COUNT(*) FROM <table>`. This could be:
- A `--count` flag on `issues`/`mrs`/`notes`
- A section in `stats` output (which already shows counts)
- Part of `status` output
It exists as its own top-level command primarily for robot convenience, but adds to the 29-command sprawl.
---
## 3. Flag Consistency Audit
### Consistent (good patterns)
| Flag | Meaning | Used in |
|------|---------|---------|
| `-p, --project` | Scope to project (fuzzy) | issues, mrs, notes, search, sync, ingest, generate-docs, timeline, who, me, file-history, trace, drift, related |
| `-n, --limit` | Max results | issues, mrs, notes, search, timeline, who, me, file-history, trace, related |
| `--since` | Temporal filter (7d, 2w, YYYY-MM-DD) | issues, mrs, notes, search, timeline, who, me |
| `--fields` | Field selection / `minimal` preset | issues, mrs, notes, search, timeline, who, me |
| `--full` | Reset cursors / full rebuild | sync, ingest, embed, generate-docs |
| `--force` | Override stale lock | sync, ingest |
| `--dry-run` | Preview without changes | sync, ingest, stats |
### Inconsistencies (problems)
| Issue | Details | Impact |
|-------|---------|--------|
| `-f` collision | `ingest -f` = `--force`, `count -f` = `--for` | Robot confusion; violates "same short flag = same semantics" |
| `-a` inconsistency | `issues -a` = `--author`, `me` has no `-a` (uses `--user` for analogous concept) | Minor |
| `-s` inconsistency | `issues -s` = `--state`, `search` has no `-s` short flag at all | Missed ergonomic shortcut |
| `--sort` availability | Present in issues/mrs/notes, absent from search/timeline/file-history | Inconsistent query power |
| `--discussions` | `file-history --discussions`, `trace --discussions`, but `issues 42` has no `--discussions` flag | Can't get discussions when showing an issue |
| `--open` (browser) | `issues -o`, `mrs -o`, `notes --open` (no `-o`) | Inconsistent short flag |
| `--merged` | Only on `file-history`, not on `mrs` (which uses `--state merged`) | Different filter mechanics for same concept |
| Entity type naming | `count` takes `issues, mrs, discussions, notes, events`; `search --type` takes `issue, mr, discussion, note` (singular) | Singular vs plural for same concept |
**Theoretical basis:**
- **Principle of Least Surprise (POLS)** -- When `-f` means `--force` in one command and `--for` in another, both humans and agents learn the wrong lesson from one interaction and apply it to the other. CLI design guides (GNU standards, POSIX conventions, clig.dev) are unanimous: short flags should have consistent semantics across all subcommands.
- **Singular/plural inconsistency** (`issues` vs `issue` as entity type values) is particularly harmful for LLM agents, which use pattern matching on prior successful invocations. If `lore count issues` works, the agent will try `lore search --type issues` -- and get a parse error.
---
## 4. Robot Ergonomics Assessment
### Strengths (well above average for a CLI)
| Feature | Rating | Notes |
|---------|--------|-------|
| Structured output | Excellent | Consistent `{ok, data, meta}` envelope |
| Auto-detection | Excellent | Non-TTY -> robot mode, `LORE_ROBOT` env var |
| Error output | Excellent | Structured JSON to stderr with `actions` array for recovery |
| Exit codes | Excellent | 20 distinct, well-documented codes |
| Self-discovery | Excellent | `robot-docs` manifest, `--brief` for token savings |
| Typo tolerance | Excellent | Autocorrect with confidence scores + structured warnings |
| Field selection | Good | `--fields minimal` saves ~60% tokens |
| No-args behavior | Good | Robot mode auto-outputs robot-docs |
### Weaknesses
| Issue | Severity | Recommendation |
|-------|----------|----------------|
| 29 commands in robot-docs manifest | High | Agents spend tokens evaluating which command to use. Grouping would reduce decision space. |
| `status`/`stats`/`stat` near-homonyms | High | LLMs are particularly susceptible to surface-level lexical confusion. `stat` is an alias for `stats` while `status` is a different command -- this guarantees agent errors. |
| Singular vs plural entity types | Medium | `count issues` works but `search --type issues` fails. Agents learn from one and apply to the other. |
| Overlapping file commands | Medium | Agent must decide between `trace`, `file-history`, and `who --path`. The decision tree isn't obvious from names alone. |
| `count` as separate command | Low | Could be a flag; standalone command inflates the decision space |
---
## 5. Human Ergonomics Assessment
### Strengths
| Feature | Rating | Notes |
|---------|--------|-------|
| Help text quality | Excellent | Every command has examples, help headings organize flags |
| Short flags | Good | `-p`, `-n`, `-s`, `-a`, `-J` cover 80% of common use |
| Alias coverage | Good | `issue`/`issues`, `mr`/`mrs`, `st`/`status`, `find`/`search` |
| Subcommand inference | Good | `lore issu` -> `issues` via clap infer |
| Color/icon system | Good | Auto, with overrides |
### Weaknesses
| Issue | Severity | Recommendation |
|-------|----------|----------------|
| 29 commands in flat help | High | Doesn't fit one terminal screen. No grouping -> overwhelming |
| `status` vs `stats` naming | High | Humans will type wrong one repeatedly |
| `health` vs `doctor` distinction | Medium | "Which one do I run?" -- unclear from names |
| `who` 5-mode overload | Medium | Help text is long; mode exclusions are complex |
| Pipeline stages as top-level | Low | `ingest`/`generate-docs`/`embed` rarely used directly but clutter help |
| `generate-docs` is 14 chars | Low | Longest command name; `gen-docs` or `gendocs` would help |
---
## 6. Proposals (Ranked by Impact x Feasibility)
### P1: Help Grouping (HIGH impact, LOW effort)
**Problem:** 29 flat commands -> information overload.
**Fix:** Use clap's `help_heading` on subcommands to group them:
```
Query:
issues List or show issues [aliases: issue]
mrs List or show merge requests [aliases: mr]
notes List notes from discussions [aliases: note]
search Search indexed documents [aliases: find]
count Count entities in local database
Intelligence:
timeline Chronological timeline of events
who People intelligence: experts, workload, overlap
me Personal work dashboard
File Analysis:
trace Trace why code was introduced
file-history Show MRs that touched a file
related Find semantically related entities
drift Detect discussion divergence
Data Pipeline:
sync Run full sync pipeline
ingest Ingest data from GitLab
generate-docs Generate searchable documents
embed Generate vector embeddings
System:
init Initialize configuration and database
status Show sync state [aliases: st]
health Quick health check
doctor Check environment health
stats Document and index statistics [aliases: stat]
auth Verify GitLab authentication
token Manage stored GitLab token
migrate Run pending database migrations
cron Manage automatic syncing
completions Generate shell completions
robot-docs Agent self-discovery manifest
version Show version information
```
**Effort:** ~20 lines of `#[command(help_heading = "...")]` annotations. No behavior changes.
### P2: Resolve `status`/`stats` Confusion (HIGH impact, LOW effort)
**Option A (recommended):** Rename `stats` -> `index`.
- `lore status` = when did I last sync? (pipeline state)
- `lore index` = how big is my index? (data inventory)
- The alias `stat` goes away (it was causing confusion anyway)
**Option B:** Rename `status` -> `sync-state` and `stats` -> `db-stats`. More descriptive but longer.
**Option C:** Merge both under `check` (see P4).
### P3: Fix Singular/Plural Entity Type Inconsistency (MEDIUM impact, TRIVIAL effort)
Accept both singular and plural forms everywhere:
- `count` already takes `issues` (plural) -- also accept `issue`
- `search --type` already takes `issue` (singular) -- also accept `issues`
- `drift` takes `issues` -- also accept `issue`
This is a ~10 line change in the value parsers and eliminates an entire class of agent errors.
### P4: Merge `health` + `doctor` (MEDIUM impact, LOW effort)
`health` is a fast subset of `doctor`. Merge:
- `lore doctor` = full diagnostic (current behavior)
- `lore doctor --quick` = fast pre-flight, exit-code-only (current `health`)
- Drop `health` as a separate command, add a hidden alias for backward compat
### P5: Fix `-f` Short Flag Collision (MEDIUM impact, TRIVIAL effort)
Change `count`'s `-f, --for` to just `--for` (no short flag). `-f` should mean `--force` project-wide, or nowhere.
### P6: Consolidate `trace` + `file-history` (MEDIUM impact, MEDIUM effort)
`trace` already does everything `file-history` does plus more. Options:
**Option A:** Make `file-history` an alias for `trace --flat` (shows MR list without issue/discussion linking).
**Option B:** Add `--mrs-only` to `trace` that produces `file-history` output. Deprecate `file-history` with a hidden alias.
Either way, one fewer top-level command and no lost functionality.
### P7: Hide Pipeline Sub-stages (LOW impact, TRIVIAL effort)
Move `ingest`, `generate-docs`, `embed` to `#[command(hide = true)]`. They remain usable but don't clutter `--help`. Direct users to `sync` with stage-skip flags.
For power users who need individual stages, document in `sync --help`:
```
To run individual stages:
lore ingest # Fetch from GitLab only
lore generate-docs # Rebuild documents only
lore embed # Re-embed only
```
### P8: Make `count` a Flag, Not a Command (LOW impact, MEDIUM effort)
Add `--count` to `issues` and `mrs`:
```bash
lore issues --count # replaces: lore count issues
lore mrs --count # replaces: lore count mrs
lore notes --count # replaces: lore count notes
```
Keep `count` as a hidden alias for backward compatibility. Removes one top-level command.
### P9: Consistent `--open` Short Flag (LOW impact, TRIVIAL effort)
`notes --open` lacks the `-o` shorthand that `issues` and `mrs` have. Add it.
### P10: Add `--sort` to `search` (LOW impact, LOW effort)
`search` returns ranked results but offers no `--sort` override. Adding `--sort=score,created,updated` would bring it in line with `issues`/`mrs`/`notes`.
---
## 7. Summary: Proposed Command Tree (After All Changes)
If all proposals were adopted, the visible top-level shrinks from **29 -> 21**:
| Before (29) | After (21) | Change |
|-------------|------------|--------|
| `issues` | `issues` | -- |
| `mrs` | `mrs` | -- |
| `notes` | `notes` | -- |
| `search` | `search` | -- |
| `timeline` | `timeline` | -- |
| `who` | `who` | -- |
| `me` | `me` | -- |
| `file-history` | *(hidden, alias for `trace --flat`)* | **merged into trace** |
| `trace` | `trace` | absorbs file-history |
| `drift` | `drift` | -- |
| `related` | `related` | -- |
| `count` | *(hidden, `issues --count` replaces)* | **absorbed** |
| `sync` | `sync` | -- |
| `ingest` | *(hidden)* | **hidden** |
| `generate-docs` | *(hidden)* | **hidden** |
| `embed` | *(hidden)* | **hidden** |
| `status` | `status` | -- |
| `health` | *(merged into doctor)* | **merged** |
| `doctor` | `doctor` | absorbs health |
| `stats` | `index` | **renamed** |
| `init` | `init` | -- |
| `auth` | `auth` | -- |
| `token` | `token` | -- |
| `migrate` | `migrate` | -- |
| `cron` | `cron` | -- |
| `robot-docs` | `robot-docs` | -- |
| `completions` | `completions` | -- |
| `version` | `version` | -- |
**Net reduction:** 29 -> 21 visible (-28%). The hidden commands remain fully functional and documented in `robot-docs` for agents that already use them.
**Theoretical basis:**
- **Miller's Law** -- Humans can hold 7+/-2 items in working memory. 29 commands far exceeds this. Even with help grouping (P1), the sheer count creates decision fatigue. The literature on CLI design (Heroku's "12-Factor CLI", clig.dev's "Command Line Interface Guidelines") recommends 10-15 top-level commands maximum, with grouping or nesting for anything beyond.
- **For LLM agents specifically:** Research on tool-use with large tool sets (Schick et al. 2023, Qin et al. 2023) shows that agent accuracy degrades as the tool count increases, roughly following an inverse log curve. Reducing from 29 to 21 commands in the robot-docs manifest would measurably improve agent command selection accuracy.
- **Backward compatibility is free:** Since AGENTS.md says "we don't care about backward compatibility," hidden aliases cost nothing and prevent breakage for agents with cached robot-docs.
---
## 8. Priority Matrix
| Proposal | Impact | Effort | Risk | Recommended Order |
|----------|--------|--------|------|-------------------|
| P1: Help grouping | High | Trivial | None | **Do first** |
| P3: Singular/plural fix | Medium | Trivial | None | **Do first** |
| P5: Fix `-f` collision | Medium | Trivial | None | **Do first** |
| P9: `notes -o` shorthand | Low | Trivial | None | **Do first** |
| P2: Rename `stats`->`index` | High | Low | Alias needed | **Do second** |
| P4: Merge health->doctor | Medium | Low | Alias needed | **Do second** |
| P7: Hide pipeline stages | Low | Trivial | Needs docs update | **Do second** |
| P6: Merge file-history->trace | Medium | Medium | Flag design | **Plan carefully** |
| P8: count -> --count flag | Low | Medium | Compat shim | **Plan carefully** |
| P10: `--sort` on search | Low | Low | None | **When convenient** |
The "do first" tier is 4 changes that could ship in a single commit with zero risk and immediate ergonomic improvement for both humans and agents.

View File

@@ -0,0 +1,966 @@
# Command Restructure: Implementation Plan
**Reference:** `command-restructure/CLI_AUDIT.md`
**Scope:** 10 proposals, 3 implementation phases, estimated ~15 files touched
---
## Phase 1: Zero-Risk Quick Wins (1 commit)
These four changes are purely additive -- no behavior changes, no renames, no removed commands.
### P1: Help Grouping
**Goal:** Group the 29 visible commands into 5 semantic clusters in `--help` output.
**File:** `src/cli/mod.rs` (lines 117-399, the `Commands` enum)
**Changes:** Add `#[command(help_heading = "...")]` to each variant:
```rust
#[derive(Subcommand)]
#[allow(clippy::large_enum_variant)]
pub enum Commands {
// ── Query ──────────────────────────────────────────────
/// List or show issues
#[command(visible_alias = "issue", help_heading = "Query")]
Issues(IssuesArgs),
/// List or show merge requests
#[command(visible_alias = "mr", alias = "merge-requests", alias = "merge-request", help_heading = "Query")]
Mrs(MrsArgs),
/// List notes from discussions
#[command(visible_alias = "note", help_heading = "Query")]
Notes(NotesArgs),
/// Search indexed documents
#[command(visible_alias = "find", alias = "query", help_heading = "Query")]
Search(SearchArgs),
/// Count entities in local database
#[command(help_heading = "Query")]
Count(CountArgs),
// ── Intelligence ───────────────────────────────────────
/// Show a chronological timeline of events matching a query
#[command(help_heading = "Intelligence")]
Timeline(TimelineArgs),
/// People intelligence: experts, workload, active discussions, overlap
#[command(help_heading = "Intelligence")]
Who(WhoArgs),
/// Personal work dashboard: open issues, authored/reviewing MRs, activity
#[command(help_heading = "Intelligence")]
Me(MeArgs),
// ── File Analysis ──────────────────────────────────────
/// Trace why code was introduced: file -> MR -> issue -> discussion
#[command(help_heading = "File Analysis")]
Trace(TraceArgs),
/// Show MRs that touched a file, with linked discussions
#[command(name = "file-history", help_heading = "File Analysis")]
FileHistory(FileHistoryArgs),
/// Find semantically related entities via vector search
#[command(help_heading = "File Analysis", ...)]
Related { ... },
/// Detect discussion divergence from original intent
#[command(help_heading = "File Analysis", ...)]
Drift { ... },
// ── Data Pipeline ──────────────────────────────────────
/// Run full sync pipeline: ingest -> generate-docs -> embed
#[command(help_heading = "Data Pipeline")]
Sync(SyncArgs),
/// Ingest data from GitLab
#[command(help_heading = "Data Pipeline")]
Ingest(IngestArgs),
/// Generate searchable documents from ingested data
#[command(name = "generate-docs", help_heading = "Data Pipeline")]
GenerateDocs(GenerateDocsArgs),
/// Generate vector embeddings for documents via Ollama
#[command(help_heading = "Data Pipeline")]
Embed(EmbedArgs),
// ── System ─────────────────────────────────────────────
// (init, status, health, doctor, stats, auth, token, migrate, cron,
// completions, robot-docs, version -- all get help_heading = "System")
}
```
**Verification:**
- `lore --help` shows grouped output
- All existing commands still work identically
- `lore robot-docs` output unchanged (robot-docs is hand-crafted, not derived from clap)
**Files touched:** `src/cli/mod.rs` only
---
### P3: Singular/Plural Entity Type Fix
**Goal:** Accept both `issue`/`issues`, `mr`/`mrs` everywhere entity types are value-parsed.
**File:** `src/cli/args.rs`
**Change 1 -- `CountArgs.entity` (line 819):**
```rust
// BEFORE:
#[arg(value_parser = ["issues", "mrs", "discussions", "notes", "events"])]
pub entity: String,
// AFTER:
#[arg(value_parser = ["issue", "issues", "mr", "mrs", "discussion", "discussions", "note", "notes", "event", "events"])]
pub entity: String,
```
**File:** `src/cli/args.rs`
**Change 2 -- `SearchArgs.source_type` (line 369):**
```rust
// BEFORE:
#[arg(long = "type", value_parser = ["issue", "mr", "discussion", "note"], ...)]
pub source_type: Option<String>,
// AFTER:
#[arg(long = "type", value_parser = ["issue", "issues", "mr", "mrs", "discussion", "discussions", "note", "notes"], ...)]
pub source_type: Option<String>,
```
**File:** `src/cli/mod.rs`
**Change 3 -- `Drift.entity_type` (line 287):**
```rust
// BEFORE:
#[arg(value_parser = ["issues"])]
pub entity_type: String,
// AFTER:
#[arg(value_parser = ["issue", "issues"])]
pub entity_type: String,
```
**Normalization layer:** In the handlers that consume these values, normalize to the canonical form (plural for entity names, singular for source_type) so downstream code doesn't need changes:
**File:** `src/app/handlers.rs`
In `handle_count` (~line 409): Normalize entity string before passing to `run_count`:
```rust
let entity = match args.entity.as_str() {
"issue" => "issues",
"mr" => "mrs",
"discussion" => "discussions",
"note" => "notes",
"event" => "events",
other => other,
};
```
In `handle_search` (search handler): Normalize source_type:
```rust
let source_type = args.source_type.as_deref().map(|t| match t {
"issues" => "issue",
"mrs" => "mr",
"discussions" => "discussion",
"notes" => "note",
other => other,
});
```
In `handle_drift` (~line 225): Normalize entity_type:
```rust
let entity_type = if entity_type == "issue" { "issues" } else { &entity_type };
```
**Verification:**
- `lore count issue` works (same as `lore count issues`)
- `lore search --type issues 'foo'` works (same as `--type issue`)
- `lore drift issue 42` works (same as `drift issues 42`)
- All existing invocations unchanged
**Files touched:** `src/cli/args.rs`, `src/cli/mod.rs`, `src/app/handlers.rs`
---
### P5: Fix `-f` Short Flag Collision
**Goal:** Remove `-f` shorthand from `count --for` so `-f` consistently means `--force` across the CLI.
**File:** `src/cli/args.rs` (line 823)
```rust
// BEFORE:
#[arg(short = 'f', long = "for", value_parser = ["issue", "mr"])]
pub for_entity: Option<String>,
// AFTER:
#[arg(long = "for", value_parser = ["issue", "mr"])]
pub for_entity: Option<String>,
```
**Also update the value_parser to accept both forms** (while we're here):
```rust
#[arg(long = "for", value_parser = ["issue", "issues", "mr", "mrs"])]
pub for_entity: Option<String>,
```
And normalize in `handle_count`:
```rust
let for_entity = args.for_entity.as_deref().map(|f| match f {
"issues" => "issue",
"mrs" => "mr",
other => other,
});
```
**File:** `src/app/robot_docs.rs` (line 173) -- update the robot-docs entry:
```rust
// BEFORE:
"flags": ["<entity: issues|mrs|discussions|notes|events>", "-f/--for <issue|mr>"],
// AFTER:
"flags": ["<entity: issues|mrs|discussions|notes|events>", "--for <issue|mr>"],
```
**Verification:**
- `lore count notes --for mr` still works
- `lore count notes -f mr` now fails with a clear error (unknown flag `-f`)
- `lore ingest -f` still works (means `--force`)
**Files touched:** `src/cli/args.rs`, `src/app/robot_docs.rs`
---
### P9: Consistent `--open` Short Flag on `notes`
**Goal:** Add `-o` shorthand to `notes --open`, matching `issues` and `mrs`.
**File:** `src/cli/args.rs` (line 292)
```rust
// BEFORE:
#[arg(long, help_heading = "Actions")]
pub open: bool,
// AFTER:
#[arg(short = 'o', long, help_heading = "Actions", overrides_with = "no_open")]
pub open: bool,
#[arg(long = "no-open", hide = true, overrides_with = "open")]
pub no_open: bool,
```
**Verification:**
- `lore notes -o` opens first result in browser
- Matches behavior of `lore issues -o` and `lore mrs -o`
**Files touched:** `src/cli/args.rs`
---
### Phase 1 Commit Summary
**Files modified:**
1. `src/cli/mod.rs` -- help_heading on all Commands variants + drift value_parser
2. `src/cli/args.rs` -- singular/plural value_parsers, remove `-f` from count, add `-o` to notes
3. `src/app/handlers.rs` -- normalization of entity/source_type strings
4. `src/app/robot_docs.rs` -- update count flags documentation
**Test plan:**
```bash
cargo check --all-targets
cargo clippy --all-targets -- -D warnings
cargo fmt --check
cargo test
lore --help # Verify grouped output
lore count issue # Verify singular accepted
lore search --type issues 'x' # Verify plural accepted
lore drift issue 42 # Verify singular accepted
lore notes -o # Verify short flag works
```
---
## Phase 2: Renames and Merges (2-3 commits)
These changes rename commands and merge overlapping ones. Hidden aliases preserve backward compatibility.
### P2: Rename `stats` -> `index`
**Goal:** Eliminate `status`/`stats`/`stat` confusion. `stats` becomes `index`.
**File:** `src/cli/mod.rs`
```rust
// BEFORE:
/// Show document and index statistics
#[command(visible_alias = "stat", help_heading = "System")]
Stats(StatsArgs),
// AFTER:
/// Show document and index statistics
#[command(visible_alias = "idx", alias = "stats", alias = "stat", help_heading = "System")]
Index(StatsArgs),
```
Note: `alias = "stats"` and `alias = "stat"` are hidden aliases (not `visible_alias`) -- old invocations still work, but `--help` shows `index`.
**File:** `src/main.rs` (line 257)
```rust
// BEFORE:
Some(Commands::Stats(args)) => handle_stats(cli.config.as_deref(), args, robot_mode).await,
// AFTER:
Some(Commands::Index(args)) => handle_stats(cli.config.as_deref(), args, robot_mode).await,
```
**File:** `src/app/robot_docs.rs` (line 181)
```rust
// BEFORE:
"stats": {
"description": "Show document and index statistics",
...
// AFTER:
"index": {
"description": "Show document and index statistics (formerly 'stats')",
...
```
Also update references in:
- `robot_docs.rs` quick_start.lore_exclusive array (line 415): `"stats: Database statistics..."` -> `"index: Database statistics..."`
- `robot_docs.rs` aliases.deprecated_commands: add `"stats": "index"`, `"stat": "index"`
**File:** `src/cli/autocorrect.rs`
Update `CANONICAL_SUBCOMMANDS` (line 366-area):
```rust
// Replace "stats" with "index" in the canonical list
// Add ("stats", "index") and ("stat", "index") to SUBCOMMAND_ALIASES
```
Update `COMMAND_FLAGS` (line 166-area):
```rust
// BEFORE:
("stats", &["--check", ...]),
// AFTER:
("index", &["--check", ...]),
```
**File:** `src/cli/robot.rs` -- update `expand_fields_preset` if any preset key is `"stats"` (currently no stats preset, so no change needed).
**Verification:**
- `lore index` works (shows document/index stats)
- `lore stats` still works (hidden alias)
- `lore stat` still works (hidden alias)
- `lore index --check` works
- `lore --help` shows `index` in System group, not `stats`
- `lore robot-docs` shows `index` key in commands map
**Files touched:** `src/cli/mod.rs`, `src/main.rs`, `src/app/robot_docs.rs`, `src/cli/autocorrect.rs`
---
### P4: Merge `health` into `doctor`
**Goal:** One diagnostic command (`doctor`) with a `--quick` flag for the pre-flight check that `health` currently provides.
**File:** `src/cli/mod.rs`
```rust
// BEFORE:
/// Quick health check: config, database, schema version
#[command(after_help = "...")]
Health,
/// Check environment health
#[command(after_help = "...")]
Doctor,
// AFTER:
// Remove Health variant entirely. Add hidden alias:
/// Check environment health (--quick for fast pre-flight)
#[command(
after_help = "...",
alias = "health", // hidden backward compat
help_heading = "System"
)]
Doctor {
/// Fast pre-flight check only (config, DB, schema). Exit 0 = healthy.
#[arg(long)]
quick: bool,
},
```
**File:** `src/main.rs`
```rust
// BEFORE:
Some(Commands::Doctor) => handle_doctor(cli.config.as_deref(), robot_mode).await,
...
Some(Commands::Health) => handle_health(cli.config.as_deref(), robot_mode).await,
// AFTER:
Some(Commands::Doctor { quick }) => {
if quick {
handle_health(cli.config.as_deref(), robot_mode).await
} else {
handle_doctor(cli.config.as_deref(), robot_mode).await
}
}
// Health variant removed from enum, so no separate match arm
```
**File:** `src/app/robot_docs.rs`
Merge the `health` and `doctor` entries:
```rust
"doctor": {
"description": "Environment health check. Use --quick for fast pre-flight (exit 0 = healthy, 19 = unhealthy).",
"flags": ["--quick"],
"example": "lore --robot doctor",
"notes": {
"quick_mode": "lore --robot doctor --quick — fast pre-flight check (formerly 'lore health'). Only checks config, DB, schema version. Returns exit 19 on failure.",
"full_mode": "lore --robot doctor — full diagnostic: config, auth, database, Ollama"
},
"response_schema": {
"full": { ... }, // current doctor schema
"quick": { ... } // current health schema
}
}
```
Remove the standalone `health` entry from the commands map.
**File:** `src/cli/autocorrect.rs`
- Remove `"health"` from `CANONICAL_SUBCOMMANDS` (clap's `alias` handles it)
- Or keep it -- since clap treats aliases as valid subcommands, the autocorrect system will still resolve typos like `"helth"` to `"health"` which clap then maps to `doctor`. Either way works.
**File:** `src/app/robot_docs.rs` -- update `workflows.pre_flight`:
```rust
"pre_flight": [
"lore --robot doctor --quick"
],
```
Add to aliases.deprecated_commands:
```rust
"health": "doctor --quick"
```
**Verification:**
- `lore doctor` runs full diagnostic (unchanged behavior)
- `lore doctor --quick` runs fast pre-flight (exit 0/19)
- `lore health` still works (hidden alias, runs `doctor --quick`)
- `lore --help` shows only `doctor` in System group
- `lore robot-docs` shows merged entry
**Files touched:** `src/cli/mod.rs`, `src/main.rs`, `src/app/robot_docs.rs`, `src/cli/autocorrect.rs`
**Important edge case:** `lore health` via the hidden alias will invoke `Doctor { quick: false }` unless we handle it specially. Two options:
**Option A (simpler):** Instead of making `health` an alias of `doctor`, keep both variants but hide `Health`:
```rust
#[command(hide = true, help_heading = "System")]
Health,
```
Then in `main.rs`, `Commands::Health` maps to `handle_health()` as before. This is less clean but zero-risk.
**Option B (cleaner):** In the autocorrect layer, rewrite `health` -> `doctor --quick` before clap parsing:
```rust
// In SUBCOMMAND_ALIASES or a new pre-clap rewrite:
("health", "doctor"), // plus inject "--quick" flag
```
This requires a small enhancement to autocorrect to support flag injection during alias resolution.
**Recommendation:** Use Option A for initial implementation. It's one line (`hide = true`) and achieves the goal of removing `health` from `--help` while preserving full backward compatibility. The `doctor --quick` flag is additive.
---
### P7: Hide Pipeline Sub-stages
**Goal:** Remove `ingest`, `generate-docs`, `embed` from `--help` while keeping them fully functional.
**File:** `src/cli/mod.rs`
```rust
// Add hide = true to each:
/// Ingest data from GitLab
#[command(hide = true)]
Ingest(IngestArgs),
/// Generate searchable documents from ingested data
#[command(name = "generate-docs", hide = true)]
GenerateDocs(GenerateDocsArgs),
/// Generate vector embeddings for documents via Ollama
#[command(hide = true)]
Embed(EmbedArgs),
```
**File:** `src/cli/mod.rs` -- Update `Sync` help text to mention the individual stage commands:
```rust
/// Run full sync pipeline: ingest -> generate-docs -> embed
#[command(after_help = "\x1b[1mExamples:\x1b[0m
lore sync # Full pipeline: ingest + docs + embed
lore sync --no-embed # Skip embedding step
...
\x1b[1mIndividual stages:\x1b[0m
lore ingest # Fetch from GitLab only
lore generate-docs # Rebuild documents only
lore embed # Re-embed only",
help_heading = "Data Pipeline"
)]
Sync(SyncArgs),
```
**File:** `src/app/robot_docs.rs` -- Add a `"hidden": true` field to the ingest/generate-docs/embed entries so agents know these are secondary:
```rust
"ingest": {
"hidden": true,
"description": "Sync data from GitLab (prefer 'sync' for full pipeline)",
...
```
**Verification:**
- `lore --help` no longer shows ingest, generate-docs, embed
- `lore ingest`, `lore generate-docs`, `lore embed` all still work
- `lore sync --help` mentions individual stage commands
- `lore robot-docs` still includes all three (with `hidden: true`)
**Files touched:** `src/cli/mod.rs`, `src/app/robot_docs.rs`
---
### Phase 2 Commit Summary
**Commit A: Rename `stats` -> `index`**
- `src/cli/mod.rs`, `src/main.rs`, `src/app/robot_docs.rs`, `src/cli/autocorrect.rs`
**Commit B: Merge `health` into `doctor`, hide pipeline stages**
- `src/cli/mod.rs`, `src/main.rs`, `src/app/robot_docs.rs`, `src/cli/autocorrect.rs`
**Test plan:**
```bash
cargo check --all-targets
cargo clippy --all-targets -- -D warnings
cargo fmt --check
cargo test
# Rename verification
lore index # Works (new name)
lore stats # Works (hidden alias)
lore index --check # Works
# Doctor merge verification
lore doctor # Full diagnostic
lore doctor --quick # Fast pre-flight
lore health # Still works (hidden)
# Hidden stages verification
lore --help # ingest/generate-docs/embed gone
lore ingest # Still works
lore sync --help # Mentions individual stages
```
---
## Phase 3: Structural Consolidation (requires careful design)
These changes merge or absorb commands. More effort, more testing, but the biggest UX wins.
### P6: Consolidate `file-history` into `trace`
**Goal:** `trace` absorbs `file-history`. One command for file-centric intelligence.
**Approach:** Add `--mrs-only` flag to `trace`. When set, output matches `file-history` format (flat MR list, no issue/discussion linking). `file-history` becomes a hidden alias.
**File:** `src/cli/args.rs` -- Add flag to `TraceArgs`:
```rust
pub struct TraceArgs {
pub path: String,
#[arg(short = 'p', long, help_heading = "Filters")]
pub project: Option<String>,
#[arg(long, help_heading = "Output")]
pub discussions: bool,
#[arg(long = "no-follow-renames", help_heading = "Filters")]
pub no_follow_renames: bool,
#[arg(short = 'n', long = "limit", default_value = "20", help_heading = "Output")]
pub limit: usize,
// NEW: absorb file-history behavior
/// Show only MR list without issue/discussion linking (file-history mode)
#[arg(long = "mrs-only", help_heading = "Output")]
pub mrs_only: bool,
/// Only show merged MRs (file-history mode)
#[arg(long, help_heading = "Filters")]
pub merged: bool,
}
```
**File:** `src/cli/mod.rs` -- Hide `FileHistory`:
```rust
/// Show MRs that touched a file, with linked discussions
#[command(name = "file-history", hide = true, help_heading = "File Analysis")]
FileHistory(FileHistoryArgs),
```
**File:** `src/app/handlers.rs` -- Route `trace --mrs-only` to the file-history handler:
```rust
fn handle_trace(
config_override: Option<&str>,
args: TraceArgs,
robot_mode: bool,
) -> Result<(), Box<dyn std::error::Error>> {
if args.mrs_only {
// Delegate to file-history handler
let fh_args = FileHistoryArgs {
path: args.path,
project: args.project,
discussions: args.discussions,
no_follow_renames: args.no_follow_renames,
merged: args.merged,
limit: args.limit,
};
return handle_file_history(config_override, fh_args, robot_mode);
}
// ... existing trace logic ...
}
```
**File:** `src/app/robot_docs.rs` -- Update trace entry, mark file-history as deprecated:
```rust
"trace": {
"description": "Trace why code was introduced: file -> MR -> issue -> discussion. Use --mrs-only for flat MR listing.",
"flags": ["<path>", "-p/--project", "--discussions", "--no-follow-renames", "-n/--limit", "--mrs-only", "--merged"],
...
},
"file-history": {
"hidden": true,
"deprecated": "Use 'trace --mrs-only' instead",
...
}
```
**Verification:**
- `lore trace src/main.rs` works unchanged
- `lore trace src/main.rs --mrs-only` produces file-history output
- `lore trace src/main.rs --mrs-only --merged` filters to merged MRs
- `lore file-history src/main.rs` still works (hidden command)
- `lore --help` shows only `trace` in File Analysis group
**Files touched:** `src/cli/args.rs`, `src/cli/mod.rs`, `src/app/handlers.rs`, `src/app/robot_docs.rs`
---
### P8: Make `count` a Flag on Entity Commands
**Goal:** `lore issues --count` replaces `lore count issues`. Standalone `count` becomes hidden.
**File:** `src/cli/args.rs` -- Add `--count` to `IssuesArgs`, `MrsArgs`, `NotesArgs`:
```rust
// In IssuesArgs:
/// Show count only (no listing)
#[arg(long, help_heading = "Output", conflicts_with_all = ["iid", "open"])]
pub count: bool,
// In MrsArgs:
/// Show count only (no listing)
#[arg(long, help_heading = "Output", conflicts_with_all = ["iid", "open"])]
pub count: bool,
// In NotesArgs:
/// Show count only (no listing)
#[arg(long, help_heading = "Output", conflicts_with = "open")]
pub count: bool,
```
**File:** `src/app/handlers.rs` -- In `handle_issues`, `handle_mrs`, `handle_notes`, check the count flag early:
```rust
// In handle_issues (pseudocode):
if args.count {
let count_args = CountArgs { entity: "issues".to_string(), for_entity: None };
return handle_count(config_override, count_args, robot_mode).await;
}
```
**File:** `src/cli/mod.rs` -- Hide `Count`:
```rust
/// Count entities in local database
#[command(hide = true, help_heading = "Query")]
Count(CountArgs),
```
**File:** `src/app/robot_docs.rs` -- Mark count as hidden, add `--count` documentation to issues/mrs/notes entries.
**Verification:**
- `lore issues --count` returns issue count
- `lore mrs --count` returns MR count
- `lore notes --count` returns note count
- `lore count issues` still works (hidden)
- `lore count discussions --for mr` still works (no equivalent in the new pattern -- discussions/events/references still need the standalone `count` command)
**Important note:** `count` supports entity types that don't have their own command (discussions, events, references). The standalone `count` must remain functional (just hidden). The `--count` flag on `issues`/`mrs`/`notes` handles the common cases only.
**Files touched:** `src/cli/args.rs`, `src/cli/mod.rs`, `src/app/handlers.rs`, `src/app/robot_docs.rs`
---
### P10: Add `--sort` to `search`
**Goal:** Allow sorting search results by score, created date, or updated date.
**File:** `src/cli/args.rs` -- Add to `SearchArgs`:
```rust
/// Sort results by field (score is default for ranked search)
#[arg(long, value_parser = ["score", "created", "updated"], default_value = "score", help_heading = "Sorting")]
pub sort: String,
/// Sort ascending (default: descending)
#[arg(long, help_heading = "Sorting", overrides_with = "no_asc")]
pub asc: bool,
#[arg(long = "no-asc", hide = true, overrides_with = "asc")]
pub no_asc: bool,
```
**File:** `src/cli/commands/search.rs` -- Thread the sort parameter through to the search query.
The search function currently returns results sorted by score. When `--sort created` or `--sort updated` is specified, apply an `ORDER BY` clause to the final result set.
**File:** `src/app/robot_docs.rs` -- Add `--sort` and `--asc` to the search command's flags list.
**Verification:**
- `lore search 'auth' --sort score` (default, unchanged)
- `lore search 'auth' --sort created --asc` (oldest first)
- `lore search 'auth' --sort updated` (most recently updated first)
**Files touched:** `src/cli/args.rs`, `src/cli/commands/search.rs`, `src/app/robot_docs.rs`
---
### Phase 3 Commit Summary
**Commit C: Consolidate file-history into trace**
- `src/cli/args.rs`, `src/cli/mod.rs`, `src/app/handlers.rs`, `src/app/robot_docs.rs`
**Commit D: Add `--count` flag to entity commands**
- `src/cli/args.rs`, `src/cli/mod.rs`, `src/app/handlers.rs`, `src/app/robot_docs.rs`
**Commit E: Add `--sort` to search**
- `src/cli/args.rs`, `src/cli/commands/search.rs`, `src/app/robot_docs.rs`
**Test plan:**
```bash
cargo check --all-targets
cargo clippy --all-targets -- -D warnings
cargo fmt --check
cargo test
# trace consolidation
lore trace src/main.rs --mrs-only
lore trace src/main.rs --mrs-only --merged --discussions
lore file-history src/main.rs # backward compat
# count flag
lore issues --count
lore mrs --count -s opened
lore notes --count --for-issue 42
lore count discussions --for mr # still works
# search sort
lore search 'auth' --sort created --asc
```
---
## Documentation Updates
After all implementation is complete:
### CLAUDE.md / AGENTS.md
Update the robot mode command reference to reflect:
- `stats` -> `index` (with note that `stats` is a hidden alias)
- `health` -> `doctor --quick` (with note that `health` is a hidden alias)
- Remove `ingest`, `generate-docs`, `embed` from the primary command table (mention as "hidden, use `sync`")
- Remove `file-history` from primary table (mention as "hidden, use `trace --mrs-only`")
- Add `--count` flag to issues/mrs/notes documentation
- Add `--sort` flag to search documentation
- Add `--mrs-only` and `--merged` flags to trace documentation
### robot-docs Self-Discovery
The `robot_docs.rs` changes above handle this. Key points:
- New `"hidden": true` field on deprecated/hidden commands
- Updated descriptions mentioning canonical alternatives
- Updated flags lists
- Updated workflows section
---
## File Impact Summary
| File | Phase 1 | Phase 2 | Phase 3 | Total Changes |
|------|---------|---------|---------|---------------|
| `src/cli/mod.rs` | help_heading, drift value_parser | stats->index rename, hide health, hide pipeline stages | hide file-history, hide count | 4 passes |
| `src/cli/args.rs` | singular/plural, remove `-f`, add `-o` | — | `--mrs-only`/`--merged` on trace, `--count` on entities, `--sort` on search | 2 passes |
| `src/app/handlers.rs` | normalize entity strings | route doctor --quick | trace mrs-only delegation, count flag routing | 3 passes |
| `src/app/robot_docs.rs` | update count flags | rename stats->index, merge health+doctor, add hidden field | update trace, file-history, count, search entries | 3 passes |
| `src/cli/autocorrect.rs` | — | update CANONICAL_SUBCOMMANDS, SUBCOMMAND_ALIASES, COMMAND_FLAGS | — | 1 pass |
| `src/main.rs` | — | stats->index variant rename, doctor variant change | — | 1 pass |
| `src/cli/commands/search.rs` | — | — | sort parameter threading | 1 pass |
---
## Before / After Summary
### Command Count
| Metric | Before | After | Delta |
|--------|--------|-------|-------|
| Visible top-level commands | 29 | 21 | -8 (-28%) |
| Hidden commands (functional) | 4 | 12 | +8 (absorbed) |
| Stub/unimplemented commands | 2 | 2 | 0 |
| Total functional commands | 33 | 33 | 0 (nothing lost) |
### `lore --help` Output
**Before (29 commands, flat list, ~50 lines of commands):**
```
Commands:
issues List or show issues [aliases: issue]
mrs List or show merge requests [aliases: mr]
notes List notes from discussions [aliases: note]
ingest Ingest data from GitLab
count Count entities in local database
status Show sync state [aliases: st]
auth Verify GitLab authentication
doctor Check environment health
version Show version information
init Initialize configuration and database
search Search indexed documents [aliases: find]
stats Show document and index statistics [aliases: stat]
generate-docs Generate searchable documents from ingested data
embed Generate vector embeddings for documents via Ollama
sync Run full sync pipeline: ingest -> generate-docs -> embed
migrate Run pending database migrations
health Quick health check: config, database, schema version
robot-docs Machine-readable command manifest for agent self-discovery
completions Generate shell completions
timeline Show a chronological timeline of events matching a query
who People intelligence: experts, workload, active discussions, overlap
me Personal work dashboard: open issues, authored/reviewing MRs, activity
file-history Show MRs that touched a file, with linked discussions
trace Trace why code was introduced: file -> MR -> issue -> discussion
drift Detect discussion divergence from original intent
related Find semantically related entities via vector search
cron Manage cron-based automatic syncing
token Manage stored GitLab token
help Print this message or the help of the given subcommand(s)
```
**After (21 commands, grouped, ~35 lines of commands):**
```
Query:
issues List or show issues [aliases: issue]
mrs List or show merge requests [aliases: mr]
notes List notes from discussions [aliases: note]
search Search indexed documents [aliases: find]
Intelligence:
timeline Chronological timeline of events
who People intelligence: experts, workload, overlap
me Personal work dashboard
File Analysis:
trace Trace code provenance / file history
related Find semantically related entities
drift Detect discussion divergence
Data Pipeline:
sync Run full sync pipeline
System:
init Initialize configuration and database
status Show sync state [aliases: st]
doctor Check environment health (--quick for pre-flight)
index Document and index statistics [aliases: idx]
auth Verify GitLab authentication
token Manage stored GitLab token
migrate Run pending database migrations
cron Manage automatic syncing
robot-docs Agent self-discovery manifest
completions Generate shell completions
version Show version information
```
### Flag Consistency
| Issue | Before | After |
|-------|--------|-------|
| `-f` collision (force vs for) | `ingest -f`=force, `count -f`=for | `-f` removed from count; `-f` = force everywhere |
| Singular/plural entity types | `count issues` but `search --type issue` | Both forms accepted everywhere |
| `notes --open` missing `-o` | `notes --open` (no shorthand) | `notes -o` works (matches issues/mrs) |
| `search` missing `--sort` | No sort override | `--sort score\|created\|updated` + `--asc` |
### Naming Confusion
| Before | After | Resolution |
|--------|-------|------------|
| `status` vs `stats` vs `stat` (3 names, 2 commands) | `status` + `index` (2 names, 2 commands) | Eliminated near-homonym collision |
| `health` vs `doctor` (2 commands, overlapping scope) | `doctor` + `doctor --quick` (1 command) | Progressive disclosure |
| `trace` vs `file-history` (2 commands, overlapping function) | `trace` + `trace --mrs-only` (1 command) | Superset absorbs subset |
### Robot Ergonomics
| Metric | Before | After |
|--------|--------|-------|
| Commands in robot-docs manifest | 29 | 21 visible + hidden section |
| Agent decision space for "system check" | 4 commands | 2 commands (status, doctor) |
| Agent decision space for "file query" | 3 commands + 2 who modes | 1 command (trace) + 2 who modes |
| Entity type parse errors from singular/plural | Common | Eliminated |
| Estimated token cost of robot-docs | Baseline | ~15% reduction (fewer entries, hidden flagged) |
### What Stays Exactly The Same
- All 33 functional commands remain callable (nothing is removed)
- All existing flags and their behavior are preserved
- All response schemas are unchanged
- All exit codes are unchanged
- The autocorrect system continues to work
- All hidden/deprecated commands emit their existing warnings
### What Breaks (Intentional)
- `lore count -f mr` (the `-f` shorthand) -- must use `--for` instead
- `lore --help` layout changes (commands are grouped, 8 commands hidden)
- `lore robot-docs` output changes (new `hidden` field, renamed keys)
- Any scripts parsing `--help` text (but `robot-docs` is the stable contract)

701
specs/SPEC_explain.md Normal file
View File

@@ -0,0 +1,701 @@
# Spec: lore explain — Auto-Generated Issue/MR Narratives
**Bead:** bd-9lbr
**Created:** 2026-03-10
## Spec Status
| Section | Status | Notes |
|---------|--------|-------|
| Objective | complete | |
| Tech Stack | complete | |
| Project Structure | complete | |
| Commands | complete | |
| Code Style | complete | UX-audited: after_help, --sections, --since, --no-timeline, --max-decisions, singular types |
| Boundaries | complete | |
| Testing Strategy | complete | 13 test cases (7 original + 5 UX flags + 1 singular type) |
| Git Workflow | complete | jj-first |
| User Journeys | complete | 3 journeys covering agent, human, pipeline use |
| Architecture | complete | ExplainParams + section filtering + time scoping |
| Success Criteria | complete | 15 criteria (10 original + 5 UX flags) |
| Non-Goals | complete | |
| Tasks | complete | 5 tasks across 3 phases, all updated for UX flags |
**Definition of Complete:** All sections `complete`, Open Questions empty,
every user journey has tasks, every task has TDD workflow and acceptance criteria.
---
## Quick Reference
- [Entity Detail] (Architecture): reuse show/ query patterns (private — copy, don't import)
- [Timeline] (Architecture): import `crate::timeline::seed::seed_timeline_direct` + `collect_events`
- [Events] (Architecture): new inline queries against resource_state_events/resource_label_events
- [References] (Architecture): new query against entity_references table
- [Discussions] (Architecture): adapted from show/ patterns, add resolved/resolvable filter
---
## Open Questions (Resolve Before Implementation)
<!-- All resolved -->
---
## Objective
**Goal:** Add `lore explain issues N` / `lore explain mrs N` to auto-generate structured narratives of what happened on an issue or MR.
**Problem:** Understanding the full story of an issue/MR requires reading dozens of notes, cross-referencing state changes, checking related entities, and piecing together a timeline. This is time-consuming for humans and nearly impossible for AI agents without custom orchestration.
**Success metrics:**
- Produces a complete narrative in <500ms for an issue with 50 notes
- All 7 sections populated (entity, description_excerpt, key_decisions, activity, open_threads, related, timeline_excerpt)
- Works fully offline (no API calls, no LLM)
- Deterministic and reproducible (same input = same output)
---
## Tech Stack & Constraints
| Layer | Technology | Version |
|-------|-----------|---------|
| Language | Rust | nightly-2026-03-01 (rust-toolchain.toml) |
| Framework | clap (derive) | As in Cargo.toml |
| Database | SQLite via rusqlite | Bundled |
| Testing | cargo test | Inline #[cfg(test)] |
| Async | asupersync | 0.2 |
**Constraints:**
- No LLM dependency — template-based, deterministic
- No network calls — all data from local SQLite
- Performance: <500ms for 50-note entity
- Unsafe code forbidden (`#![forbid(unsafe_code)]`)
---
## Project Structure
```
src/cli/commands/
explain.rs # NEW: command module (queries, heuristic, result types)
src/cli/
mod.rs # EDIT: add Explain variant to Commands enum
src/app/
handlers.rs # EDIT: add handle_explain dispatch
robot_docs.rs # EDIT: register explain in robot-docs manifest
src/main.rs # EDIT: add Explain match arm
```
---
## Commands
```bash
# Build
cargo check --all-targets
# Test
cargo test explain
# Lint
cargo clippy --all-targets -- -D warnings
# Format
cargo fmt --check
```
---
## Code Style
**Command registration (from cli/mod.rs):**
```rust
/// Auto-generate a structured narrative of an issue or MR
#[command(after_help = "\x1b[1mExamples:\x1b[0m
lore explain issues 42 # Narrative for issue #42
lore explain mrs 99 -p group/repo # Narrative for MR !99 in specific project
lore -J explain issues 42 # JSON output for automation
lore explain issues 42 --sections key_decisions,open_threads # Specific sections only
lore explain issues 42 --since 30d # Narrative scoped to last 30 days
lore explain issues 42 --no-timeline # Skip timeline (faster)")]
Explain {
/// Entity type: "issues" or "mrs" (singular forms also accepted)
#[arg(value_parser = ["issues", "mrs", "issue", "mr"])]
entity_type: String,
/// Entity IID
iid: i64,
/// Scope to project (fuzzy match)
#[arg(short, long)]
project: Option<String>,
/// Select specific sections (comma-separated)
/// Valid: entity, description, key_decisions, activity, open_threads, related, timeline
#[arg(long, value_delimiter = ',', help_heading = "Output")]
sections: Option<Vec<String>>,
/// Skip timeline excerpt (faster execution)
#[arg(long, help_heading = "Output")]
no_timeline: bool,
/// Maximum key decisions to include
#[arg(long, default_value = "10", help_heading = "Output")]
max_decisions: usize,
/// Time scope for events/notes (e.g. 7d, 2w, 1m, or YYYY-MM-DD)
#[arg(long, help_heading = "Filters")]
since: Option<String>,
},
```
**Entity type normalization:** The handler must normalize singular forms: `"issue"` -> `"issues"`, `"mr"` -> `"mrs"`. This prevents common typos from causing errors.
**Query pattern (from show/issue.rs):**
```rust
fn find_issue(conn: &Connection, iid: i64, project_filter: Option<&str>) -> Result<IssueRow> {
let project_id = resolve_project(conn, project_filter)?;
let mut stmt = conn.prepare_cached("SELECT ... FROM issues WHERE iid = ?1 AND project_id = ?2")?;
// ...
}
```
**Robot mode output (from cli/robot.rs):**
```rust
let response = serde_json::json!({
"ok": true,
"data": result,
"meta": { "elapsed_ms": elapsed.as_millis() }
});
println!("{}", serde_json::to_string(&response)?);
```
---
## Boundaries
### Always (autonomous)
- Run `cargo test explain` and `cargo clippy` after every code change
- Follow existing query patterns from show/issue.rs and show/mr.rs
- Use `resolve_project()` for project resolution (fuzzy match)
- Cap key_decisions at `--max-decisions` (default 10), timeline_excerpt at 20 events
- Normalize singular entity types (`issue` -> `issues`, `mr` -> `mrs`)
- Respect `--sections` filter: omit unselected sections from output (both robot and human)
- Respect `--since` filter: scope events/notes queries with `created_at >= ?` threshold
### Ask First (needs approval)
- Adding new dependencies to Cargo.toml
- Modifying existing query functions in show/ or timeline/
- Changing the entity_references table schema
### Never (hard stops)
- No LLM calls — explain must be deterministic
- No API/network calls — fully offline
- No new database migrations — use existing schema only
- Do not modify show/ or timeline/ modules (copy patterns instead)
---
## Testing Strategy (TDD — Red-Green)
**Methodology:** Test-Driven Development. Write tests first, confirm red, implement, confirm green.
**Framework:** cargo test, inline `#[cfg(test)]`
**Location:** `src/cli/commands/explain.rs` (inline test module)
**Test categories:**
- Unit tests: key-decisions heuristic, activity counting, description truncation
- Integration tests: full explain pipeline with in-memory DB
**User journey test mapping:**
| Journey | Test | Scenarios |
|---------|------|-----------|
| UJ-1: Agent explains issue | test_explain_issue_basic | All 7 sections present, robot JSON valid |
| UJ-1: Agent explains MR | test_explain_mr | entity.type = "merge_request", merged_at included |
| UJ-1: Singular entity type | test_explain_singular_entity_type | `"issue"` normalizes to `"issues"` |
| UJ-1: Section filtering | test_explain_sections_filter_robot | Only selected sections in output |
| UJ-1: No-timeline flag | test_explain_no_timeline_flag | timeline_excerpt is None |
| UJ-2: Human reads narrative | (human render tested manually) | Headers, indentation, color |
| UJ-3: Key decisions | test_explain_key_decision_heuristic | Note within 60min of state change by same actor |
| UJ-3: No false decisions | test_explain_key_decision_ignores_unrelated_notes | Different author's note excluded |
| UJ-3: Max decisions cap | test_explain_max_decisions | Respects `--max-decisions` parameter |
| UJ-3: Since scopes events | test_explain_since_scopes_events | Only recent events included |
| UJ-3: Open threads | test_explain_open_threads | Only unresolved discussions in output |
| UJ-3: Edge case | test_explain_no_notes | Empty sections, no panic |
| UJ-3: Activity counts | test_explain_activity_counts | Correct state/label/note counts |
---
## Git Workflow
- **jj-first** — all VCS via jj, not git
- **Commit format:** `feat(explain): <description>`
- **No branches** — commit in place, use jj bookmarks to push
---
## User Journeys (Prioritized)
### P1 — Critical
- **UJ-1: Agent queries issue/MR narrative**
- Actor: AI agent (via robot mode)
- Flow: `lore -J explain issues 42` → JSON with 7 sections → agent parses and acts
- Error paths: Issue not found (exit 17), ambiguous project (exit 18)
- Implemented by: Task 1, 2, 3, 4
### P2 — Important
- **UJ-2: Human reads explain output**
- Actor: Developer at terminal
- Flow: `lore explain issues 42` → formatted narrative with headers, colors, indentation
- Error paths: Same as UJ-1 but with human-readable error messages
- Implemented by: Task 5
### P3 — Nice to Have
- **UJ-3: Agent uses key-decisions to understand context**
- Actor: AI agent making decisions
- Flow: Parse `key_decisions` array → understand who decided what and when → inform action
- Error paths: No key decisions found (empty array, not error)
- Implemented by: Task 3
---
## Architecture / Data Model
### Data Assembly Pipeline (sync, no async needed)
```
1. RESOLVE → resolve_project() + find entity by IID
2. PARSE → normalize entity_type, parse --since, validate --sections
3. DETAIL → entity metadata (title, state, author, labels, assignees, status)
4. EVENTS → resource_state_events + resource_label_events (optionally --since scoped)
5. NOTES → non-system notes via discussions join (optionally --since scoped)
6. HEURISTIC → key_decisions = events correlated with notes by same actor within 60min
7. THREADS → discussions WHERE resolvable=1 AND resolved=0
8. REFERENCES → entity_references (both directions: source and target)
9. TIMELINE → seed_timeline_direct + collect_events (capped at 20, skip if --no-timeline)
10. FILTER → apply --sections filter: drop unselected sections before serialization
11. ASSEMBLE → combine into ExplainResult
```
**Section filtering:** When `--sections` is provided, only the listed sections are populated.
Unselected sections are set to their zero-value (`None`, empty vec, etc.) and omitted
from robot JSON via `#[serde(skip_serializing_if = "...")]`. The `entity` section is always
included (needed for identification). Human mode skips rendering unselected sections.
**Time scoping:** When `--since` is provided, parse it using `crate::core::time::parse_since()`
(same function used by timeline, me, file-history). Add `AND created_at >= ?` to events
and notes queries. The entity header, references, and open threads are NOT time-scoped
(they represent current state, not historical events).
### Key Types
```rust
/// Parameters controlling explain behavior.
pub struct ExplainParams {
pub entity_type: String, // "issues" or "mrs" (already normalized)
pub iid: i64,
pub project: Option<String>,
pub sections: Option<Vec<String>>, // None = all sections
pub no_timeline: bool,
pub max_decisions: usize, // default 10
pub since: Option<i64>, // ms epoch threshold from --since parsing
}
#[derive(Debug, Serialize)]
pub struct ExplainResult {
pub entity: EntitySummary,
#[serde(skip_serializing_if = "Option::is_none")]
pub description_excerpt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub key_decisions: Option<Vec<KeyDecision>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub activity: Option<ActivitySummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub open_threads: Option<Vec<OpenThread>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub related: Option<RelatedEntities>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeline_excerpt: Option<Vec<TimelineEventSummary>>,
}
#[derive(Debug, Serialize)]
pub struct EntitySummary {
#[serde(rename = "type")]
pub entity_type: String, // "issue" or "merge_request"
pub iid: i64,
pub title: String,
pub state: String,
pub author: String,
pub assignees: Vec<String>,
pub labels: Vec<String>,
pub created_at: String, // ISO 8601
pub updated_at: String, // ISO 8601
pub url: Option<String>,
pub status_name: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct KeyDecision {
pub timestamp: String, // ISO 8601
pub actor: String,
pub action: String, // "state: opened -> closed" or "label: +bug"
pub context_note: String, // truncated to 500 chars
}
#[derive(Debug, Serialize)]
pub struct ActivitySummary {
pub state_changes: usize,
pub label_changes: usize,
pub notes: usize, // non-system only
pub first_event: Option<String>, // ISO 8601
pub last_event: Option<String>, // ISO 8601
}
#[derive(Debug, Serialize)]
pub struct OpenThread {
pub discussion_id: String,
pub started_by: String,
pub started_at: String, // ISO 8601
pub note_count: usize,
pub last_note_at: String, // ISO 8601
}
#[derive(Debug, Serialize)]
pub struct RelatedEntities {
pub closing_mrs: Vec<ClosingMrInfo>,
pub related_issues: Vec<RelatedEntityInfo>,
}
#[derive(Debug, Serialize)]
pub struct TimelineEventSummary {
pub timestamp: String, // ISO 8601
pub event_type: String,
pub actor: Option<String>,
pub summary: String,
}
```
### Key Decisions Heuristic
The heuristic identifies notes that explain WHY state/label changes were made:
1. Collect all `resource_state_events` and `resource_label_events` for the entity
2. Merge into unified chronological list with (timestamp, actor, description)
3. For each event, find the FIRST non-system note by the SAME actor within 60 minutes AFTER the event
4. Pair them as a `KeyDecision`
5. Cap at `params.max_decisions` (default 10)
**SQL for state events:**
```sql
SELECT state, actor_username, created_at
FROM resource_state_events
WHERE issue_id = ?1 -- or merge_request_id = ?1
AND (?2 IS NULL OR created_at >= ?2) -- --since filter
ORDER BY created_at ASC
```
**SQL for label events:**
```sql
SELECT action, label_name, actor_username, created_at
FROM resource_label_events
WHERE issue_id = ?1 -- or merge_request_id = ?1
AND (?2 IS NULL OR created_at >= ?2) -- --since filter
ORDER BY created_at ASC
```
**SQL for non-system notes (for correlation):**
```sql
SELECT n.body, n.author_username, n.created_at
FROM notes n
JOIN discussions d ON n.discussion_id = d.id
WHERE d.noteable_type = ?1 AND d.issue_id = ?2 -- or d.merge_request_id
AND n.is_system = 0
AND (?3 IS NULL OR n.created_at >= ?3) -- --since filter
ORDER BY n.created_at ASC
```
**Entity ID resolution:** The `discussions` table uses `issue_id` / `merge_request_id` columns (CHECK constraint: exactly one non-NULL). The `resource_state_events` and `resource_label_events` tables use the same pattern.
### Cross-References Query
```sql
-- Outgoing references (this entity references others)
SELECT target_entity_type, target_entity_id, target_project_path,
target_entity_iid, reference_type, source_method
FROM entity_references
WHERE source_entity_type = ?1 AND source_entity_id = ?2
-- Incoming references (others reference this entity)
SELECT source_entity_type, source_entity_id,
reference_type, source_method
FROM entity_references
WHERE target_entity_type = ?1 AND target_entity_id = ?2
```
**Note:** For closing MRs, reuse the pattern from show/issue.rs `get_closing_mrs()` which queries entity_references with `reference_type = 'closes'`.
### Open Threads Query
```sql
SELECT d.gitlab_discussion_id, d.first_note_at, d.last_note_at
FROM discussions d
WHERE d.issue_id = ?1 -- or d.merge_request_id
AND d.resolvable = 1
AND d.resolved = 0
ORDER BY d.last_note_at DESC
```
Then for each discussion, fetch the first note's author:
```sql
SELECT author_username, created_at
FROM notes
WHERE discussion_id = ?1
ORDER BY created_at ASC
LIMIT 1
```
And count notes per discussion:
```sql
SELECT COUNT(*) FROM notes WHERE discussion_id = ?1 AND is_system = 0
```
### Robot Mode Output Schema
```json
{
"ok": true,
"data": {
"entity": {
"type": "issue", "iid": 3864, "title": "...", "state": "opened",
"author": "teernisse", "assignees": ["teernisse"],
"labels": ["customer:BNSF"], "created_at": "2026-01-10T...",
"updated_at": "2026-02-12T...", "url": "...", "status_name": "In progress"
},
"description_excerpt": "First 500 chars...",
"key_decisions": [{
"timestamp": "2026-01-15T...",
"actor": "teernisse",
"action": "state: opened -> closed",
"context_note": "Starting work on the integration..."
}],
"activity": {
"state_changes": 3, "label_changes": 5, "notes": 42,
"first_event": "2026-01-10T...", "last_event": "2026-02-12T..."
},
"open_threads": [{
"discussion_id": "abc123",
"started_by": "cseiber",
"started_at": "2026-02-01T...",
"note_count": 5,
"last_note_at": "2026-02-10T..."
}],
"related": {
"closing_mrs": [{ "iid": 200, "title": "...", "state": "merged" }],
"related_issues": [{ "iid": 3800, "title": "Rail Break Card", "type": "related" }]
},
"timeline_excerpt": [
{ "timestamp": "...", "event_type": "state_changed", "actor": "teernisse", "summary": "State changed to closed" }
]
},
"meta": { "elapsed_ms": 350 }
}
```
---
## Success Criteria
| # | Criterion | Input | Expected Output |
|---|-----------|-------|----------------|
| 1 | Issue explain produces all 7 sections | `lore -J explain issues N` | JSON with entity, description_excerpt, key_decisions, activity, open_threads, related, timeline_excerpt |
| 2 | MR explain produces all 7 sections | `lore -J explain mrs N` | Same shape, entity.type = "merge_request" |
| 3 | Key decisions captures correlated notes | State change + note by same actor within 60min | KeyDecision with action + context_note |
| 4 | Key decisions ignores unrelated notes | Note by different author near state change | Not in key_decisions array |
| 5 | Open threads filters correctly | 2 discussions: 1 resolved, 1 unresolved | Only unresolved in open_threads |
| 6 | Activity counts are accurate | 3 state events, 2 label events, 10 notes | Matching counts in activity section |
| 7 | Performance | Issue with 50 notes | <500ms |
| 8 | Entity not found | Non-existent IID | Exit code 17, suggestion to sync |
| 9 | Ambiguous project | IID exists in multiple projects, no -p | Exit code 18, suggestion to use -p |
| 10 | Human render | `lore explain issues N` (no -J) | Formatted narrative with headers |
| 11 | Singular entity type accepted | `lore explain issue 42` | Same as `lore explain issues 42` |
| 12 | Section filtering works | `--sections key_decisions,activity` | Only those 2 sections + entity in JSON |
| 13 | No-timeline skips timeline | `--no-timeline` | timeline_excerpt absent, faster execution |
| 14 | Max-decisions caps output | `--max-decisions 3` | At most 3 key_decisions |
| 15 | Since scopes events/notes | `--since 30d` | Only events/notes from last 30 days in activity, key_decisions |
---
## Non-Goals
- **No LLM summarization** — This is template-based v1. LLM enhancement is a separate future feature.
- **No new database migrations** — Uses existing schema (resource_state_events, resource_label_events, discussions, notes, entity_references tables all exist).
- **No modification of show/ or timeline/ modules** — Copy patterns, don't refactor existing code. If we later want to share code, that's a separate refactoring bead.
- **No interactive mode** — Output only, no prompts or follow-up questions.
- **No MR diff analysis** — No file-level change summaries. Use `file-history` or `trace` for that.
- **No assignee/reviewer history** — Activity summary counts events but doesn't track assignment changes over time.
---
## Tasks
### Phase 1: Setup & Registration
- [ ] **Task 1:** Register explain command in CLI and wire dispatch
- **Implements:** Infrastructure (UJ-1, UJ-2 prerequisite)
- **Files:** `src/cli/mod.rs`, `src/cli/commands/mod.rs`, `src/main.rs`, `src/app/handlers.rs`, NEW `src/cli/commands/explain.rs`
- **Depends on:** Nothing
- **Test-first:**
1. Write `test_explain_issue_basic` in explain.rs: insert a minimal issue + project + 1 discussion + 1 note + 1 state event into in-memory DB, call `run_explain()` with default ExplainParams, assert all 7 top-level sections present in result
2. Write `test_explain_mr` in explain.rs: insert MR with merged_at, call `run_explain()`, assert `entity.type == "merge_request"` and merged_at is populated
3. Write `test_explain_singular_entity_type`: call with `entity_type: "issue"`, assert it resolves same as `"issues"`
4. Run tests — all must FAIL (red)
5. Implement: Explain variant in Commands enum (with all flags: `--sections`, `--no-timeline`, `--max-decisions`, `--since`, singular entity type acceptance), handle_explain in handlers.rs (normalize entity_type, parse --since, build ExplainParams), skeleton `run_explain()` in explain.rs
6. Run tests — all must PASS (green)
- **Acceptance:** `cargo test explain::tests::test_explain_issue_basic`, `test_explain_mr`, and `test_explain_singular_entity_type` pass. Command registered in CLI help with after_help examples block.
- **Implementation notes:**
- Use inline args pattern (like Drift) with all flags from Code Style section
- `entity_type` validated by `#[arg(value_parser = ["issues", "mrs", "issue", "mr"])]`
- Normalize in handler: `"issue"` -> `"issues"`, `"mr"` -> `"mrs"`
- Parse `--since` using `crate::core::time::parse_since()` — returns ms epoch threshold
- Validate `--sections` values against allowed set: `["entity", "description", "key_decisions", "activity", "open_threads", "related", "timeline"]`
- Copy the `find_issue`/`find_mr` and `get_*` query patterns from show/issue.rs and show/mr.rs — they're private functions so can't be imported
- Use `resolve_project()` from `crate::core::project` for project resolution
- Use `ms_to_iso()` from `crate::core::time` for timestamp conversion
### Phase 2: Core Logic
- [ ] **Task 2:** Implement key-decisions heuristic
- **Implements:** UJ-3
- **Files:** `src/cli/commands/explain.rs`
- **Depends on:** Task 1
- **Test-first:**
1. Write `test_explain_key_decision_heuristic`: insert state change event at T, insert note by SAME author at T+30min, call `extract_key_decisions()`, assert 1 decision with correct action + context_note
2. Write `test_explain_key_decision_ignores_unrelated_notes`: insert state change by alice, insert note by bob at T+30min, assert 0 decisions
3. Write `test_explain_key_decision_label_event`: insert label add event + correlated note, assert decision.action starts with "label: +"
4. Run tests — all must FAIL (red)
4. Write `test_explain_max_decisions`: insert 5 correlated event+note pairs, call with `max_decisions: 3`, assert exactly 3 decisions returned
5. Write `test_explain_since_scopes_events`: insert event at T-60d and event at T-10d, call with `since: Some(T-30d)`, assert only recent event appears
6. Run tests — all must FAIL (red)
7. Implement `extract_key_decisions()` function:
- Query resource_state_events and resource_label_events for entity (with optional `--since` filter)
- Merge into unified chronological list
- For each event, find first non-system note by same actor within 60min (notes also `--since` filtered)
- Cap at `params.max_decisions`
8. Run tests — all must PASS (green)
- **Acceptance:** All 5 tests pass. Heuristic correctly correlates events with explanatory notes. `--max-decisions` and `--since` respected.
- **Implementation notes:**
- State events query: `SELECT state, actor_username, created_at FROM resource_state_events WHERE {id_col} = ?1 AND (?2 IS NULL OR created_at >= ?2) ORDER BY created_at`
- Label events query: `SELECT action, label_name, actor_username, created_at FROM resource_label_events WHERE {id_col} = ?1 AND (?2 IS NULL OR created_at >= ?2) ORDER BY created_at`
- Notes query: `SELECT n.body, n.author_username, n.created_at FROM notes n JOIN discussions d ON n.discussion_id = d.id WHERE d.{id_col} = ?1 AND n.is_system = 0 AND (?2 IS NULL OR n.created_at >= ?2) ORDER BY n.created_at`
- The `{id_col}` is either `issue_id` or `merge_request_id` based on entity_type
- Pass `params.since` (Option<i64>) as the `?2` parameter — NULL means no filter
- Use `crate::core::time::ms_to_iso()` for timestamp conversion in output
- Truncate context_note to 500 chars using `crate::cli::render::truncate()` or a local helper
- [ ] **Task 3:** Implement open threads, activity summary, and cross-references
- **Implements:** UJ-1
- **Files:** `src/cli/commands/explain.rs`
- **Depends on:** Task 1
- **Test-first:**
1. Write `test_explain_open_threads`: insert 2 discussions (1 with resolved=0 resolvable=1, 1 with resolved=1 resolvable=1), assert only unresolved appears in open_threads
2. Write `test_explain_activity_counts`: insert 3 state events + 2 label events + 10 non-system notes, assert activity.state_changes=3, label_changes=2, notes=10
3. Write `test_explain_no_notes`: insert issue with zero notes and zero events, assert empty key_decisions, empty open_threads, activity all zeros, description_excerpt = "(no description)" if description is NULL
4. Run tests — all must FAIL (red)
5. Implement:
- `fetch_open_threads()`: query discussions WHERE resolvable=1 AND resolved=0, fetch first note author + note count per thread
- `build_activity_summary()`: count state events, label events, non-system notes, find min/max timestamps
- `fetch_related_entities()`: query entity_references in both directions (source and target)
- Description excerpt: first 500 chars of description, or "(no description)" if NULL
6. Run tests — all must PASS (green)
- **Acceptance:** All 3 tests pass. Open threads correctly filtered. Activity counts accurate. Empty entity handled gracefully.
- **Implementation notes:**
- Open threads query: `SELECT d.gitlab_discussion_id, d.first_note_at, d.last_note_at FROM discussions d WHERE d.{id_col} = ?1 AND d.resolvable = 1 AND d.resolved = 0 ORDER BY d.last_note_at DESC`
- For first note author: `SELECT author_username FROM notes WHERE discussion_id = ?1 ORDER BY created_at ASC LIMIT 1`
- For note count: `SELECT COUNT(*) FROM notes WHERE discussion_id = ?1 AND is_system = 0`
- Cross-references: both outgoing and incoming from entity_references table
- For closing MRs, reuse the query pattern from show/issue.rs `get_closing_mrs()`
- [ ] **Task 4:** Wire timeline excerpt using existing pipeline
- **Implements:** UJ-1
- **Files:** `src/cli/commands/explain.rs`
- **Depends on:** Task 1
- **Test-first:**
1. Write `test_explain_timeline_excerpt`: insert issue + state events + notes, call run_explain() with `no_timeline: false`, assert timeline_excerpt is Some and non-empty and capped at 20 events
2. Write `test_explain_no_timeline_flag`: call run_explain() with `no_timeline: true`, assert timeline_excerpt is None
3. Run tests — both must FAIL (red)
4. Implement: when `!params.no_timeline` and `--sections` includes "timeline" (or is None), call `seed_timeline_direct()` with entity type + IID, then `collect_events()`, convert first 20 TimelineEvents into TimelineEventSummary structs. Otherwise set timeline_excerpt to None.
5. Run tests — both must PASS (green)
- **Acceptance:** Timeline excerpt present with max 20 events when enabled. Skipped entirely when `--no-timeline`. Uses existing timeline pipeline (no reimplementation).
- **Implementation notes:**
- Import: `use crate::timeline::seed::seed_timeline_direct;` and `use crate::timeline::collect::collect_events;`
- `seed_timeline_direct()` takes `(conn, entity_type, iid, project_id)` — verify exact signature before implementing
- `collect_events()` returns `Vec<TimelineEvent>` — map to simplified `TimelineEventSummary` (timestamp, event_type string, actor, summary)
- Timeline pipeline uses `EntityRef` struct from `crate::timeline::types` — needs entity's local DB id and project_path
- Cap at 20 events: `events.truncate(20)` after collection
- `--no-timeline` takes precedence over `--sections timeline` (if both specified, skip timeline)
### Phase 3: Output Rendering
- [ ] **Task 5:** Robot mode JSON output and human-readable rendering
- **Implements:** UJ-1, UJ-2
- **Files:** `src/cli/commands/explain.rs`, `src/app/robot_docs.rs`
- **Depends on:** Task 1, 2, 3, 4
- **Test-first:**
1. Write `test_explain_robot_output_shape`: call run_explain() with all sections, serialize to JSON, assert all 7 top-level keys present
2. Write `test_explain_sections_filter_robot`: call run_explain() with `sections: Some(vec!["key_decisions", "activity"])`, serialize, assert only `entity` + `key_decisions` + `activity` keys present (entity always included), assert `description_excerpt`, `open_threads`, `related`, `timeline_excerpt` are absent
3. Run tests — both must FAIL (red)
4. Implement:
- Robot mode: `print_explain_json()` wrapping ExplainResult in `{"ok": true, "data": ..., "meta": {...}}` envelope. `#[serde(skip_serializing_if = "Option::is_none")]` on optional sections handles filtering automatically.
- Human mode: `print_explain()` with section headers, colored output, indented key decisions, truncated descriptions. Check `params.sections` before rendering each section.
- Register in robot-docs manifest (include `--sections`, `--no-timeline`, `--max-decisions`, `--since` flags)
5. Run tests — both must PASS (green)
- **Acceptance:** Robot JSON matches schema. Section filtering works in both robot and human mode. Command appears in `lore robot-docs`.
- **Implementation notes:**
- Robot envelope: use `serde_json::json!()` with `RobotMeta` from `crate::cli::robot`
- Human rendering: use `Theme::bold()`, `Icons`, `render::truncate()` from `crate::cli::render`
- Follow timeline.rs rendering pattern: header with entity info -> separator line -> sections
- Register in robot_docs.rs following the existing pattern for other commands
- Section filtering: the `run_explain()` function should already return None for unselected sections. The serializer skips them. Human renderer checks `is_some()` before rendering.
---
## Corrections from Original Bead
The bead (bd-9lbr) was created before a codebase rearchitecture. Key corrections:
1. **`src/core/events_db.rs` does not exist** — Event storage is in `src/ingestion/storage/events.rs` (insert only). Event queries are inline in `timeline/collect.rs`. Explain needs its own inline queries.
2. **`ResourceStateEvent` / `ResourceLabelEvent` structs don't exist** — The timeline queries raw rows directly. Explain should define lightweight local structs or use tuples.
3. **`run_show_issue()` / `run_show_mr()` are private** — They live in `include!()` files inside show/mod.rs. Cannot be imported. Copy the query patterns instead.
4. **bd-2g50 blocker is CLOSED**`IssueDetail` already has `closed_at`, `references_full`, `user_notes_count`, `confidential`. No blocker.
5. **Clap registration pattern** — The bead shows args directly on the enum variant, which is correct for explain's simple args (matches Drift, Related pattern). No need for a separate ExplainArgs struct.
6. **entity_references has no fetch query** — Only `insert_entity_reference()` and `count_references_for_source()` exist. Explain needs a new SELECT query (inline in explain.rs).
---
## Session Log
### Session 1 — 2026-03-10
- Read bead bd-9lbr thoroughly — exceptionally detailed but written before rearchitecture
- Verified infrastructure: show/ (private functions, copy patterns), timeline/ (importable pipeline), events (inline SQL, no typed structs), xref (no fetch query), discussions (resolvable/resolved confirmed in migration 028)
- Discovered bd-2g50 blocker is CLOSED — no dependency
- Decided: two positional args (`lore explain issues N`) over single query syntax
- Decided: formalize + gap-fill approach (bead is thorough, just needs updating)
- Documented 6 corrections from original bead to current codebase state
- Drafted complete spec with 5 tasks across 3 phases
### Session 1b — 2026-03-10 (CLI UX Audit)
- Audited full CLI surface (30+ commands) against explain's proposed UX
- Identified 8 improvements, user selected 6 to incorporate:
1. **after_help examples block** — every other lore command has this, explain was missing it
2. **--sections flag** — robot token efficiency, skip unselected sections entirely
4. **Singular entity type tolerance** — accept `issue`/`mr` alongside `issues`/`mrs`
5. **--no-timeline flag** — skip heaviest section for faster execution
7. **--max-decisions N flag** — user control over key_decisions cap (default 10)
8. **--since flag** — time-scope events/notes for long-lived entities
- Skipped: #3 (command aliases ex/narrative), #6 (#42/!99 shorthand)
- Updated: Code Style, Boundaries, Architecture (ExplainParams + ExplainResult types, section filtering, time scoping, SQL queries), Success Criteria (+5 new), Testing Strategy (+5 new tests), all 5 Tasks
- ExplainResult sections now `Option<T>` with `skip_serializing_if` for section filtering
- All sections remain complete — spec is ready for implementation

View File

@@ -627,6 +627,115 @@ fn activity_is_own_flag() {
assert!(results[0].is_own); assert!(results[0].is_own);
} }
// ─── Activity on Closed/Merged Items ─────────────────────────────────────────
#[test]
fn activity_note_on_merged_authored_mr() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "alice", "merged", false);
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, Some(10), None);
let t = now_ms() - 1000;
insert_note_at(
&conn,
200,
disc_id,
1,
"bob",
false,
"follow-up question",
t,
);
let results = query_activity(&conn, "alice", &[], 0).unwrap();
assert_eq!(
results.len(),
1,
"should see activity on merged MR authored by user"
);
assert_eq!(results[0].entity_iid, 99);
assert_eq!(results[0].entity_type, "mr");
}
#[test]
fn activity_note_on_closed_mr_as_reviewer() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "bob", "closed", false);
insert_reviewer(&conn, 10, "alice");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, Some(10), None);
let t = now_ms() - 1000;
insert_note_at(&conn, 200, disc_id, 1, "bob", false, "can you re-check?", t);
let results = query_activity(&conn, "alice", &[], 0).unwrap();
assert_eq!(
results.len(),
1,
"should see activity on closed MR where user is reviewer"
);
}
#[test]
fn activity_note_on_closed_assigned_issue() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_issue_with_state(&conn, 10, 1, 42, "someone", "closed");
insert_assignee(&conn, 10, "alice");
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, None, Some(10));
let t = now_ms() - 1000;
insert_note_at(
&conn,
200,
disc_id,
1,
"bob",
false,
"reopening discussion",
t,
);
let results = query_activity(&conn, "alice", &[], 0).unwrap();
assert_eq!(
results.len(),
1,
"should see activity on closed issue assigned to user"
);
}
#[test]
fn since_last_check_includes_comment_on_merged_mr() {
let conn = setup_test_db();
insert_project(&conn, 1, "group/repo");
insert_mr(&conn, 10, 1, 99, "alice", "merged", false);
let disc_id = 100;
insert_discussion(&conn, disc_id, 1, Some(10), None);
let t = now_ms() - 1000;
insert_note_at(
&conn,
200,
disc_id,
1,
"bob",
false,
"post-merge question",
t,
);
let groups = query_since_last_check(&conn, "alice", 0).unwrap();
let total_events: usize = groups.iter().map(|g| g.events.len()).sum();
assert_eq!(
total_events, 1,
"should see others' comments on merged MR in inbox"
);
}
// ─── Assignment Detection Tests (Task #12) ───────────────────────────────── // ─── Assignment Detection Tests (Task #12) ─────────────────────────────────
#[test] #[test]

View File

@@ -362,19 +362,18 @@ pub fn query_activity(
let project_clause = build_project_clause_at("p.id", project_ids, 3); let project_clause = build_project_clause_at("p.id", project_ids, 3);
// Build the "my items" subquery fragments for issue/MR association checks. // Build the "my items" subquery fragments for issue/MR association checks.
// These ensure we only see activity on items CURRENTLY associated with the user // These ensure we only see activity on items associated with the user,
// AND currently open (AC-3.6). Without the state filter, activity would include // regardless of state (open, closed, or merged). Comments on merged MRs
// events on closed/merged items that don't appear in the dashboard lists. // and closed issues are still relevant (follow-up discussions, post-merge
// questions, etc.).
let my_issue_check = "EXISTS ( let my_issue_check = "EXISTS (
SELECT 1 FROM issue_assignees ia SELECT 1 FROM issue_assignees ia
JOIN issues i2 ON ia.issue_id = i2.id WHERE ia.issue_id = {entity_issue_id} AND ia.username = ?1
WHERE ia.issue_id = {entity_issue_id} AND ia.username = ?1 AND i2.state = 'opened'
)"; )";
let my_mr_check = "( let my_mr_check = "(
EXISTS (SELECT 1 FROM merge_requests mr2 WHERE mr2.id = {entity_mr_id} AND mr2.author_username = ?1 AND mr2.state = 'opened') EXISTS (SELECT 1 FROM merge_requests mr2 WHERE mr2.id = {entity_mr_id} AND mr2.author_username = ?1)
OR EXISTS (SELECT 1 FROM mr_reviewers rv OR EXISTS (SELECT 1 FROM mr_reviewers rv
JOIN merge_requests mr3 ON rv.merge_request_id = mr3.id WHERE rv.merge_request_id = {entity_mr_id} AND rv.username = ?1)
WHERE rv.merge_request_id = {entity_mr_id} AND rv.username = ?1 AND mr3.state = 'opened')
)"; )";
// Source 1: Human comments on my items // Source 1: Human comments on my items
@@ -574,7 +573,7 @@ struct RawSinceCheckRow {
/// Query actionable events from others since `cursor_ms`. /// Query actionable events from others since `cursor_ms`.
/// Returns events from three sources: /// Returns events from three sources:
/// 1. Others' comments on my open items /// 1. Others' comments on my items (any state)
/// 2. @mentions on any item (not restricted to my items) /// 2. @mentions on any item (not restricted to my items)
/// 3. Assignment/review-request system notes mentioning me /// 3. Assignment/review-request system notes mentioning me
pub fn query_since_last_check( pub fn query_since_last_check(
@@ -583,19 +582,18 @@ pub fn query_since_last_check(
cursor_ms: i64, cursor_ms: i64,
) -> Result<Vec<SinceCheckGroup>> { ) -> Result<Vec<SinceCheckGroup>> {
// Build the "my items" subquery fragments (reused from activity). // Build the "my items" subquery fragments (reused from activity).
// No state filter: comments on closed/merged items are still actionable.
let my_issue_check = "EXISTS ( let my_issue_check = "EXISTS (
SELECT 1 FROM issue_assignees ia SELECT 1 FROM issue_assignees ia
JOIN issues i2 ON ia.issue_id = i2.id WHERE ia.issue_id = {entity_issue_id} AND ia.username = ?1
WHERE ia.issue_id = {entity_issue_id} AND ia.username = ?1 AND i2.state = 'opened'
)"; )";
let my_mr_check = "( let my_mr_check = "(
EXISTS (SELECT 1 FROM merge_requests mr2 WHERE mr2.id = {entity_mr_id} AND mr2.author_username = ?1 AND mr2.state = 'opened') EXISTS (SELECT 1 FROM merge_requests mr2 WHERE mr2.id = {entity_mr_id} AND mr2.author_username = ?1)
OR EXISTS (SELECT 1 FROM mr_reviewers rv OR EXISTS (SELECT 1 FROM mr_reviewers rv
JOIN merge_requests mr3 ON rv.merge_request_id = mr3.id WHERE rv.merge_request_id = {entity_mr_id} AND rv.username = ?1)
WHERE rv.merge_request_id = {entity_mr_id} AND rv.username = ?1 AND mr3.state = 'opened')
)"; )";
// Source 1: Others' comments on my open items // Source 1: Others' comments on my items (any state)
let source1 = format!( let source1 = format!(
"SELECT n.created_at, 'note', "SELECT n.created_at, 'note',
CASE WHEN d.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END, CASE WHEN d.issue_id IS NOT NULL THEN 'issue' ELSE 'mr' END,

View File

@@ -44,6 +44,7 @@ pub struct DiscussionDetail {
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct NoteDetail { pub struct NoteDetail {
pub gitlab_id: i64,
pub author_username: String, pub author_username: String,
pub body: String, pub body: String,
pub created_at: i64, pub created_at: i64,
@@ -277,7 +278,7 @@ fn get_issue_discussions(conn: &Connection, issue_id: i64) -> Result<Vec<Discuss
.collect::<std::result::Result<Vec<_>, _>>()?; .collect::<std::result::Result<Vec<_>, _>>()?;
let mut note_stmt = conn.prepare( let mut note_stmt = conn.prepare(
"SELECT author_username, body, created_at, is_system "SELECT gitlab_id, author_username, body, created_at, is_system
FROM notes FROM notes
WHERE discussion_id = ? WHERE discussion_id = ?
ORDER BY position", ORDER BY position",
@@ -287,11 +288,12 @@ fn get_issue_discussions(conn: &Connection, issue_id: i64) -> Result<Vec<Discuss
for (disc_id, individual_note) in disc_rows { for (disc_id, individual_note) in disc_rows {
let notes: Vec<NoteDetail> = note_stmt let notes: Vec<NoteDetail> = note_stmt
.query_map([disc_id], |row| { .query_map([disc_id], |row| {
let is_system: i64 = row.get(3)?; let is_system: i64 = row.get(4)?;
Ok(NoteDetail { Ok(NoteDetail {
author_username: row.get(0)?, gitlab_id: row.get(0)?,
body: row.get(1)?, author_username: row.get(1)?,
created_at: row.get(2)?, body: row.get(2)?,
created_at: row.get(3)?,
is_system: is_system == 1, is_system: is_system == 1,
}) })
})? })?

View File

@@ -29,6 +29,7 @@ pub struct MrDiscussionDetail {
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub struct MrNoteDetail { pub struct MrNoteDetail {
pub gitlab_id: i64,
pub author_username: String, pub author_username: String,
pub body: String, pub body: String,
pub created_at: i64, pub created_at: i64,
@@ -224,7 +225,7 @@ fn get_mr_discussions(conn: &Connection, mr_id: i64) -> Result<Vec<MrDiscussionD
.collect::<std::result::Result<Vec<_>, _>>()?; .collect::<std::result::Result<Vec<_>, _>>()?;
let mut note_stmt = conn.prepare( let mut note_stmt = conn.prepare(
"SELECT author_username, body, created_at, is_system, "SELECT gitlab_id, author_username, body, created_at, is_system,
position_old_path, position_new_path, position_old_line, position_old_path, position_new_path, position_old_line,
position_new_line, position_type position_new_line, position_type
FROM notes FROM notes
@@ -236,12 +237,12 @@ fn get_mr_discussions(conn: &Connection, mr_id: i64) -> Result<Vec<MrDiscussionD
for (disc_id, individual_note) in disc_rows { for (disc_id, individual_note) in disc_rows {
let notes: Vec<MrNoteDetail> = note_stmt let notes: Vec<MrNoteDetail> = note_stmt
.query_map([disc_id], |row| { .query_map([disc_id], |row| {
let is_system: i64 = row.get(3)?; let is_system: i64 = row.get(4)?;
let old_path: Option<String> = row.get(4)?; let old_path: Option<String> = row.get(5)?;
let new_path: Option<String> = row.get(5)?; let new_path: Option<String> = row.get(6)?;
let old_line: Option<i64> = row.get(6)?; let old_line: Option<i64> = row.get(7)?;
let new_line: Option<i64> = row.get(7)?; let new_line: Option<i64> = row.get(8)?;
let position_type: Option<String> = row.get(8)?; let position_type: Option<String> = row.get(9)?;
let position = if old_path.is_some() let position = if old_path.is_some()
|| new_path.is_some() || new_path.is_some()
@@ -260,9 +261,10 @@ fn get_mr_discussions(conn: &Connection, mr_id: i64) -> Result<Vec<MrDiscussionD
}; };
Ok(MrNoteDetail { Ok(MrNoteDetail {
author_username: row.get(0)?, gitlab_id: row.get(0)?,
body: row.get(1)?, author_username: row.get(1)?,
created_at: row.get(2)?, body: row.get(2)?,
created_at: row.get(3)?,
is_system: is_system == 1, is_system: is_system == 1,
position, position,
}) })

View File

@@ -398,6 +398,7 @@ pub struct DiscussionDetailJson {
#[derive(Serialize)] #[derive(Serialize)]
pub struct NoteDetailJson { pub struct NoteDetailJson {
pub gitlab_id: i64,
pub author_username: String, pub author_username: String,
pub body: String, pub body: String,
pub created_at: String, pub created_at: String,
@@ -458,6 +459,7 @@ impl From<&DiscussionDetail> for DiscussionDetailJson {
impl From<&NoteDetail> for NoteDetailJson { impl From<&NoteDetail> for NoteDetailJson {
fn from(note: &NoteDetail) -> Self { fn from(note: &NoteDetail) -> Self {
Self { Self {
gitlab_id: note.gitlab_id,
author_username: note.author_username.clone(), author_username: note.author_username.clone(),
body: note.body.clone(), body: note.body.clone(),
created_at: ms_to_iso(note.created_at), created_at: ms_to_iso(note.created_at),
@@ -497,6 +499,7 @@ pub struct MrDiscussionDetailJson {
#[derive(Serialize)] #[derive(Serialize)]
pub struct MrNoteDetailJson { pub struct MrNoteDetailJson {
pub gitlab_id: i64,
pub author_username: String, pub author_username: String,
pub body: String, pub body: String,
pub created_at: String, pub created_at: String,
@@ -542,6 +545,7 @@ impl From<&MrDiscussionDetail> for MrDiscussionDetailJson {
impl From<&MrNoteDetail> for MrNoteDetailJson { impl From<&MrNoteDetail> for MrNoteDetailJson {
fn from(note: &MrNoteDetail) -> Self { fn from(note: &MrNoteDetail) -> Self {
Self { Self {
gitlab_id: note.gitlab_id,
author_username: note.author_username.clone(), author_username: note.author_username.clone(),
body: note.body.clone(), body: note.body.clone(),
created_at: ms_to_iso(note.created_at), created_at: ms_to_iso(note.created_at),

View File

@@ -48,6 +48,21 @@ pub async fn seed_timeline(
}); });
} }
// Guard: reject overly broad queries before running expensive ranked search.
// The count query (no bm25/snippet) is cheap even on broad matches.
const SEED_MATCH_CEILING: i64 = 10_000;
let match_count: i64 = conn.query_row(
"SELECT COUNT(*) FROM documents_fts WHERE documents_fts MATCH ?1",
[&fts_query],
|row| row.get(0),
)?;
if match_count > SEED_MATCH_CEILING {
return Err(crate::core::error::LoreError::Other(format!(
"Query too broad: matched {match_count} documents (ceiling: {SEED_MATCH_CEILING}). \
Use a more specific query or narrow with --since.",
)));
}
// Use hybrid search for seed entity discovery (better recall than FTS alone). // Use hybrid search for seed entity discovery (better recall than FTS alone).
// search_hybrid gracefully falls back to FTS-only when Ollama is unavailable. // search_hybrid gracefully falls back to FTS-only when Ollama is unavailable.
let filters = SearchFilters { let filters = SearchFilters {