34 KiB
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)
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
# 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):
/// 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):
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):
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 explainandcargo clippyafter 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
--sectionsfilter: omit unselected sections from output (both robot and human) - Respect
--sincefilter: scope events/notes queries withcreated_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_decisionsarray → 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
/// 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:
- Collect all
resource_state_eventsandresource_label_eventsfor the entity - Merge into unified chronological list with (timestamp, actor, description)
- For each event, find the FIRST non-system note by the SAME actor within 60 minutes AFTER the event
- Pair them as a
KeyDecision - Cap at
params.max_decisions(default 10)
SQL for state events:
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:
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):
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
-- 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
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:
SELECT author_username, created_at
FROM notes
WHERE discussion_id = ?1
ORDER BY created_at ASC
LIMIT 1
And count notes per discussion:
SELECT COUNT(*) FROM notes WHERE discussion_id = ?1 AND is_system = 0
Robot Mode Output Schema
{
"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-historyortracefor 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, NEWsrc/cli/commands/explain.rs - Depends on: Nothing
- Test-first:
- Write
test_explain_issue_basicin explain.rs: insert a minimal issue + project + 1 discussion + 1 note + 1 state event into in-memory DB, callrun_explain()with default ExplainParams, assert all 7 top-level sections present in result - Write
test_explain_mrin explain.rs: insert MR with merged_at, callrun_explain(), assertentity.type == "merge_request"and merged_at is populated - Write
test_explain_singular_entity_type: call withentity_type: "issue", assert it resolves same as"issues" - Run tests — all must FAIL (red)
- 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), skeletonrun_explain()in explain.rs - Run tests — all must PASS (green)
- Write
- Acceptance:
cargo test explain::tests::test_explain_issue_basic,test_explain_mr, andtest_explain_singular_entity_typepass. 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_typevalidated by#[arg(value_parser = ["issues", "mrs", "issue", "mr"])]- Normalize in handler:
"issue"->"issues","mr"->"mrs" - Parse
--sinceusingcrate::core::time::parse_since()— returns ms epoch threshold - Validate
--sectionsvalues against allowed set:["entity", "description", "key_decisions", "activity", "open_threads", "related", "timeline"] - Copy the
find_issue/find_mrandget_*query patterns from show/issue.rs and show/mr.rs — they're private functions so can't be imported - Use
resolve_project()fromcrate::core::projectfor project resolution - Use
ms_to_iso()fromcrate::core::timefor 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:
- Write
test_explain_key_decision_heuristic: insert state change event at T, insert note by SAME author at T+30min, callextract_key_decisions(), assert 1 decision with correct action + context_note - Write
test_explain_key_decision_ignores_unrelated_notes: insert state change by alice, insert note by bob at T+30min, assert 0 decisions - Write
test_explain_key_decision_label_event: insert label add event + correlated note, assert decision.action starts with "label: +" - Run tests — all must FAIL (red)
- Write
test_explain_max_decisions: insert 5 correlated event+note pairs, call withmax_decisions: 3, assert exactly 3 decisions returned - Write
test_explain_since_scopes_events: insert event at T-60d and event at T-10d, call withsince: Some(T-30d), assert only recent event appears - Run tests — all must FAIL (red)
- Implement
extract_key_decisions()function:- Query resource_state_events and resource_label_events for entity (with optional
--sincefilter) - Merge into unified chronological list
- For each event, find first non-system note by same actor within 60min (notes also
--sincefiltered) - Cap at
params.max_decisions
- Query resource_state_events and resource_label_events for entity (with optional
- Run tests — all must PASS (green)
- Write
- Acceptance: All 5 tests pass. Heuristic correctly correlates events with explanatory notes.
--max-decisionsand--sincerespected. - 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 eitherissue_idormerge_request_idbased on entity_type - Pass
params.since(Option) as the?2parameter — 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
- State events query:
-
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:
- 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 - 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 - 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 - Run tests — all must FAIL (red)
- Implement:
fetch_open_threads(): query discussions WHERE resolvable=1 AND resolved=0, fetch first note author + note count per threadbuild_activity_summary(): count state events, label events, non-system notes, find min/max timestampsfetch_related_entities(): query entity_references in both directions (source and target)- Description excerpt: first 500 chars of description, or "(no description)" if NULL
- Run tests — all must PASS (green)
- Write
- 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()
- Open threads query:
-
Task 4: Wire timeline excerpt using existing pipeline
- Implements: UJ-1
- Files:
src/cli/commands/explain.rs - Depends on: Task 1
- Test-first:
- Write
test_explain_timeline_excerpt: insert issue + state events + notes, call run_explain() withno_timeline: false, assert timeline_excerpt is Some and non-empty and capped at 20 events - Write
test_explain_no_timeline_flag: call run_explain() withno_timeline: true, assert timeline_excerpt is None - Run tests — both must FAIL (red)
- Implement: when
!params.no_timelineand--sectionsincludes "timeline" (or is None), callseed_timeline_direct()with entity type + IID, thencollect_events(), convert first 20 TimelineEvents into TimelineEventSummary structs. Otherwise set timeline_excerpt to None. - Run tests — both must PASS (green)
- Write
- 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;anduse crate::timeline::collect::collect_events; seed_timeline_direct()takes(conn, entity_type, iid, project_id)— verify exact signature before implementingcollect_events()returnsVec<TimelineEvent>— map to simplifiedTimelineEventSummary(timestamp, event_type string, actor, summary)- Timeline pipeline uses
EntityRefstruct fromcrate::timeline::types— needs entity's local DB id and project_path - Cap at 20 events:
events.truncate(20)after collection --no-timelinetakes precedence over--sections timeline(if both specified, skip timeline)
- Import:
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:
- Write
test_explain_robot_output_shape: call run_explain() with all sections, serialize to JSON, assert all 7 top-level keys present - Write
test_explain_sections_filter_robot: call run_explain() withsections: Some(vec!["key_decisions", "activity"]), serialize, assert onlyentity+key_decisions+activitykeys present (entity always included), assertdescription_excerpt,open_threads,related,timeline_excerptare absent - Run tests — both must FAIL (red)
- 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. Checkparams.sectionsbefore rendering each section. - Register in robot-docs manifest (include
--sections,--no-timeline,--max-decisions,--sinceflags)
- Robot mode:
- Run tests — both must PASS (green)
- Write
- 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!()withRobotMetafromcrate::cli::robot - Human rendering: use
Theme::bold(),Icons,render::truncate()fromcrate::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 checksis_some()before rendering.
- Robot envelope: use
Corrections from Original Bead
The bead (bd-9lbr) was created before a codebase rearchitecture. Key corrections:
-
src/core/events_db.rsdoes not exist — Event storage is insrc/ingestion/storage/events.rs(insert only). Event queries are inline intimeline/collect.rs. Explain needs its own inline queries. -
ResourceStateEvent/ResourceLabelEventstructs don't exist — The timeline queries raw rows directly. Explain should define lightweight local structs or use tuples. -
run_show_issue()/run_show_mr()are private — They live ininclude!()files inside show/mod.rs. Cannot be imported. Copy the query patterns instead. -
bd-2g50 blocker is CLOSED —
IssueDetailalready hasclosed_at,references_full,user_notes_count,confidential. No blocker. -
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.
-
entity_references has no fetch query — Only
insert_entity_reference()andcount_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:
- after_help examples block — every other lore command has this, explain was missing it
- --sections flag — robot token efficiency, skip unselected sections entirely
- Singular entity type tolerance — accept
issue/mralongsideissues/mrs - --no-timeline flag — skip heaviest section for faster execution
- --max-decisions N flag — user control over key_decisions cap (default 10)
- --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>withskip_serializing_iffor section filtering - All sections remain complete — spec is ready for implementation