feat: implement lore who — people intelligence commands (5 modes)
Add `lore who` command with 5 query modes answering collaboration questions using existing DB data (280K notes, 210K discussions, 33K DiffNotes): - Expert: who knows about a file/directory (DiffNote path analysis + MR breadth scoring) - Workload: what is a person working on (assigned issues, authored/reviewing MRs, discussions) - Active: what discussions need attention (unresolved resolvable, global/project-scoped) - Overlap: who else is touching these files (dual author+reviewer role tracking) - Reviews: what review patterns does a person have (prefix-based category extraction) Includes migration 017 (5 composite indexes), CLI skeleton with clap conflicts_with validation, robot JSON output with input+resolved_input reproducibility, human terminal output, and 20 unit tests. All quality gates pass. Closes: bd-1q8z, bd-34rr, bd-2rk9, bd-2ldg, bd-zqpf, bd-s3rc, bd-m7k1, bd-b51e, bd-2711, bd-1rdi, bd-3mj2, bd-tfh3, bd-zibc, bd-g0d5 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
|||||||
bd-ike
|
bd-1q8z
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ title: "who-command-design"
|
|||||||
status: iterating
|
status: iterating
|
||||||
iteration: 8
|
iteration: 8
|
||||||
target_iterations: 8
|
target_iterations: 8
|
||||||
beads_revision: 0
|
beads_revision: 1
|
||||||
related_plans: []
|
related_plans: []
|
||||||
created: 2026-02-07
|
created: 2026-02-07
|
||||||
updated: 2026-02-07
|
updated: 2026-02-07
|
||||||
|
|||||||
28
migrations/017_who_indexes.sql
Normal file
28
migrations/017_who_indexes.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
-- Migration 017: Composite indexes for `who` query paths
|
||||||
|
|
||||||
|
-- Expert/Overlap: DiffNote path prefix + timestamp filter.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_diffnote_path_created
|
||||||
|
ON notes(position_new_path, created_at, project_id)
|
||||||
|
WHERE note_type = 'DiffNote' AND is_system = 0;
|
||||||
|
|
||||||
|
-- Active/Workload: discussion participation lookups.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_notes_discussion_author
|
||||||
|
ON notes(discussion_id, author_username)
|
||||||
|
WHERE is_system = 0;
|
||||||
|
|
||||||
|
-- Active (project-scoped): unresolved discussions by recency, scoped by project.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_discussions_unresolved_recent
|
||||||
|
ON discussions(project_id, last_note_at)
|
||||||
|
WHERE resolvable = 1 AND resolved = 0;
|
||||||
|
|
||||||
|
-- Active (global): unresolved discussions by recency (no project scope).
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_discussions_unresolved_recent_global
|
||||||
|
ON discussions(last_note_at)
|
||||||
|
WHERE resolvable = 1 AND resolved = 0;
|
||||||
|
|
||||||
|
-- Workload: issue assignees by username.
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_issue_assignees_username
|
||||||
|
ON issue_assignees(username, issue_id);
|
||||||
|
|
||||||
|
INSERT INTO schema_version (version, applied_at, description)
|
||||||
|
VALUES (17, strftime('%s', 'now') * 1000, 'Composite indexes for who query paths');
|
||||||
@@ -12,6 +12,7 @@ pub mod stats;
|
|||||||
pub mod sync;
|
pub mod sync;
|
||||||
pub mod sync_status;
|
pub mod sync_status;
|
||||||
pub mod timeline;
|
pub mod timeline;
|
||||||
|
pub mod who;
|
||||||
|
|
||||||
pub use auth_test::run_auth_test;
|
pub use auth_test::run_auth_test;
|
||||||
pub use count::{
|
pub use count::{
|
||||||
@@ -41,3 +42,4 @@ pub use stats::{print_stats, print_stats_json, run_stats};
|
|||||||
pub use sync::{SyncOptions, SyncResult, print_sync, print_sync_json, run_sync};
|
pub use sync::{SyncOptions, SyncResult, print_sync, print_sync_json, run_sync};
|
||||||
pub use sync_status::{print_sync_status, print_sync_status_json, run_sync_status};
|
pub use sync_status::{print_sync_status, print_sync_status_json, run_sync_status};
|
||||||
pub use timeline::{TimelineParams, print_timeline, print_timeline_json_with_meta, run_timeline};
|
pub use timeline::{TimelineParams, print_timeline, print_timeline_json_with_meta, run_timeline};
|
||||||
|
pub use who::{WhoRun, print_who_human, print_who_json, run_who};
|
||||||
|
|||||||
2676
src/cli/commands/who.rs
Normal file
2676
src/cli/commands/who.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -193,6 +193,9 @@ pub enum Commands {
|
|||||||
/// Show a chronological timeline of events matching a query
|
/// Show a chronological timeline of events matching a query
|
||||||
Timeline(TimelineArgs),
|
Timeline(TimelineArgs),
|
||||||
|
|
||||||
|
/// People intelligence: experts, workload, active discussions, overlap
|
||||||
|
Who(WhoArgs),
|
||||||
|
|
||||||
#[command(hide = true)]
|
#[command(hide = true)]
|
||||||
List {
|
List {
|
||||||
#[arg(value_parser = ["issues", "mrs"])]
|
#[arg(value_parser = ["issues", "mrs"])]
|
||||||
@@ -685,6 +688,56 @@ pub struct TimelineArgs {
|
|||||||
pub max_evidence: usize,
|
pub max_evidence: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(after_help = "\x1b[1mExamples:\x1b[0m
|
||||||
|
lore who src/features/auth/ # Who knows about this area?
|
||||||
|
lore who @asmith # What is asmith working on?
|
||||||
|
lore who @asmith --reviews # What review patterns does asmith have?
|
||||||
|
lore who --active # What discussions need attention?
|
||||||
|
lore who --overlap src/features/auth/ # Who else is touching these files?
|
||||||
|
lore who --path README.md # Expert lookup for a root file
|
||||||
|
lore who --path Makefile # Expert lookup for a dotless root file")]
|
||||||
|
pub struct WhoArgs {
|
||||||
|
/// Username or file path (path if contains /)
|
||||||
|
pub target: Option<String>,
|
||||||
|
|
||||||
|
/// Force expert mode for a file/directory path.
|
||||||
|
/// Root files (README.md, LICENSE, Makefile) are treated as exact matches.
|
||||||
|
/// Use a trailing `/` to force directory-prefix matching.
|
||||||
|
#[arg(long, help_heading = "Mode", conflicts_with_all = ["active", "overlap", "reviews"])]
|
||||||
|
pub path: Option<String>,
|
||||||
|
|
||||||
|
/// Show active unresolved discussions
|
||||||
|
#[arg(long, help_heading = "Mode", conflicts_with_all = ["target", "overlap", "reviews", "path"])]
|
||||||
|
pub active: bool,
|
||||||
|
|
||||||
|
/// Find users with MRs/notes touching this file path
|
||||||
|
#[arg(long, help_heading = "Mode", conflicts_with_all = ["target", "active", "reviews", "path"])]
|
||||||
|
pub overlap: Option<String>,
|
||||||
|
|
||||||
|
/// Show review pattern analysis (requires username target)
|
||||||
|
#[arg(long, help_heading = "Mode", requires = "target", conflicts_with_all = ["active", "overlap", "path"])]
|
||||||
|
pub reviews: bool,
|
||||||
|
|
||||||
|
/// Time window (7d, 2w, 6m, YYYY-MM-DD). Default varies by mode.
|
||||||
|
#[arg(long, help_heading = "Filters")]
|
||||||
|
pub since: Option<String>,
|
||||||
|
|
||||||
|
/// Scope to a project (supports fuzzy matching)
|
||||||
|
#[arg(short = 'p', long, help_heading = "Filters")]
|
||||||
|
pub project: Option<String>,
|
||||||
|
|
||||||
|
/// Maximum results per section (1..=500, bounded for output safety)
|
||||||
|
#[arg(
|
||||||
|
short = 'n',
|
||||||
|
long = "limit",
|
||||||
|
default_value = "20",
|
||||||
|
value_parser = clap::value_parser!(u16).range(1..=500),
|
||||||
|
help_heading = "Output"
|
||||||
|
)]
|
||||||
|
pub limit: u16,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
pub struct CountArgs {
|
pub struct CountArgs {
|
||||||
/// Entity type to count (issues, mrs, discussions, notes, events)
|
/// Entity type to count (issues, mrs, discussions, notes, events)
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ const MIGRATIONS: &[(&str, &str)] = &[
|
|||||||
"016",
|
"016",
|
||||||
include_str!("../../migrations/016_mr_file_changes.sql"),
|
include_str!("../../migrations/016_mr_file_changes.sql"),
|
||||||
),
|
),
|
||||||
|
("017", include_str!("../../migrations/017_who_indexes.sql")),
|
||||||
];
|
];
|
||||||
|
|
||||||
pub fn create_connection(db_path: &Path) -> Result<Connection> {
|
pub fn create_connection(db_path: &Path) -> Result<Connection> {
|
||||||
|
|||||||
60
src/main.rs
60
src/main.rs
@@ -18,15 +18,15 @@ use lore::cli::commands::{
|
|||||||
print_list_mrs_json, print_search_results, print_search_results_json, print_show_issue,
|
print_list_mrs_json, print_search_results, print_search_results_json, print_show_issue,
|
||||||
print_show_issue_json, print_show_mr, print_show_mr_json, print_stats, print_stats_json,
|
print_show_issue_json, print_show_mr, print_show_mr_json, print_stats, print_stats_json,
|
||||||
print_sync, print_sync_json, print_sync_status, print_sync_status_json, print_timeline,
|
print_sync, print_sync_json, print_sync_status, print_sync_status_json, print_timeline,
|
||||||
print_timeline_json_with_meta, run_auth_test, run_count, run_count_events, run_doctor,
|
print_timeline_json_with_meta, print_who_human, print_who_json, run_auth_test, run_count,
|
||||||
run_embed, run_generate_docs, run_ingest, run_ingest_dry_run, run_init, run_list_issues,
|
run_count_events, run_doctor, run_embed, run_generate_docs, run_ingest, run_ingest_dry_run,
|
||||||
run_list_mrs, run_search, run_show_issue, run_show_mr, run_stats, run_sync, run_sync_status,
|
run_init, run_list_issues, run_list_mrs, run_search, run_show_issue, run_show_mr, run_stats,
|
||||||
run_timeline,
|
run_sync, run_sync_status, run_timeline, run_who,
|
||||||
};
|
};
|
||||||
use lore::cli::robot::RobotMeta;
|
use lore::cli::robot::RobotMeta;
|
||||||
use lore::cli::{
|
use lore::cli::{
|
||||||
Cli, Commands, CountArgs, EmbedArgs, GenerateDocsArgs, IngestArgs, IssuesArgs, MrsArgs,
|
Cli, Commands, CountArgs, EmbedArgs, GenerateDocsArgs, IngestArgs, IssuesArgs, MrsArgs,
|
||||||
SearchArgs, StatsArgs, SyncArgs, TimelineArgs,
|
SearchArgs, StatsArgs, SyncArgs, TimelineArgs, WhoArgs,
|
||||||
};
|
};
|
||||||
use lore::core::db::{
|
use lore::core::db::{
|
||||||
LATEST_SCHEMA_VERSION, create_connection, get_schema_version, run_migrations,
|
LATEST_SCHEMA_VERSION, create_connection, get_schema_version, run_migrations,
|
||||||
@@ -161,6 +161,7 @@ async fn main() {
|
|||||||
handle_search(cli.config.as_deref(), args, robot_mode).await
|
handle_search(cli.config.as_deref(), args, robot_mode).await
|
||||||
}
|
}
|
||||||
Some(Commands::Timeline(args)) => handle_timeline(cli.config.as_deref(), args, robot_mode),
|
Some(Commands::Timeline(args)) => handle_timeline(cli.config.as_deref(), args, robot_mode),
|
||||||
|
Some(Commands::Who(args)) => handle_who(cli.config.as_deref(), args, robot_mode),
|
||||||
Some(Commands::Stats(args)) => handle_stats(cli.config.as_deref(), args, robot_mode).await,
|
Some(Commands::Stats(args)) => handle_stats(cli.config.as_deref(), args, robot_mode).await,
|
||||||
Some(Commands::Embed(args)) => handle_embed(cli.config.as_deref(), args, robot_mode).await,
|
Some(Commands::Embed(args)) => handle_embed(cli.config.as_deref(), args, robot_mode).await,
|
||||||
Some(Commands::Sync(args)) => {
|
Some(Commands::Sync(args)) => {
|
||||||
@@ -488,6 +489,7 @@ fn suggest_similar_command(invalid: &str) -> String {
|
|||||||
"robot-docs",
|
"robot-docs",
|
||||||
"completions",
|
"completions",
|
||||||
"timeline",
|
"timeline",
|
||||||
|
"who",
|
||||||
];
|
];
|
||||||
|
|
||||||
let invalid_lower = invalid.to_lowercase();
|
let invalid_lower = invalid.to_lowercase();
|
||||||
@@ -2012,6 +2014,28 @@ fn handle_robot_docs(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>>
|
|||||||
"meta": {"elapsed_ms": "int"}
|
"meta": {"elapsed_ms": "int"}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"who": {
|
||||||
|
"description": "People intelligence: experts, workload, active discussions, overlap, review patterns",
|
||||||
|
"flags": ["<target>", "--path <path>", "--active", "--overlap <path>", "--reviews", "--since <duration>", "-p/--project", "-n/--limit"],
|
||||||
|
"modes": {
|
||||||
|
"expert": "lore who <file-path> -- Who knows about this area? (also: --path for root files)",
|
||||||
|
"workload": "lore who <username> -- What is someone working on?",
|
||||||
|
"reviews": "lore who <username> --reviews -- Review pattern analysis",
|
||||||
|
"active": "lore who --active -- Active unresolved discussions",
|
||||||
|
"overlap": "lore who --overlap <path> -- Who else is touching these files?"
|
||||||
|
},
|
||||||
|
"example": "lore --robot who src/features/auth/",
|
||||||
|
"response_schema": {
|
||||||
|
"ok": "bool",
|
||||||
|
"data": {
|
||||||
|
"mode": "string",
|
||||||
|
"input": {"target": "string|null", "path": "string|null", "project": "string|null", "since": "string|null", "limit": "int"},
|
||||||
|
"resolved_input": {"mode": "string", "project_id": "int|null", "project_path": "string|null", "since_ms": "int", "since_iso": "string", "since_mode": "string (default|explicit|none)", "limit": "int"},
|
||||||
|
"...": "mode-specific fields"
|
||||||
|
},
|
||||||
|
"meta": {"elapsed_ms": "int"}
|
||||||
|
}
|
||||||
|
},
|
||||||
"robot-docs": {
|
"robot-docs": {
|
||||||
"description": "This command (agent self-discovery manifest)",
|
"description": "This command (agent self-discovery manifest)",
|
||||||
"flags": [],
|
"flags": [],
|
||||||
@@ -2062,6 +2086,14 @@ fn handle_robot_docs(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>>
|
|||||||
"lore --robot sync",
|
"lore --robot sync",
|
||||||
"lore --robot timeline '<keyword>' --since 30d",
|
"lore --robot timeline '<keyword>' --since 30d",
|
||||||
"lore --robot timeline '<keyword>' --depth 2 --expand-mentions"
|
"lore --robot timeline '<keyword>' --depth 2 --expand-mentions"
|
||||||
|
],
|
||||||
|
"people_intelligence": [
|
||||||
|
"lore --robot who src/path/to/feature/",
|
||||||
|
"lore --robot who @username",
|
||||||
|
"lore --robot who @username --reviews",
|
||||||
|
"lore --robot who --active --since 7d",
|
||||||
|
"lore --robot who --overlap src/path/",
|
||||||
|
"lore --robot who --path README.md"
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2118,6 +2150,24 @@ fn handle_robot_docs(robot_mode: bool) -> Result<(), Box<dyn std::error::Error>>
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_who(
|
||||||
|
config_override: Option<&str>,
|
||||||
|
args: WhoArgs,
|
||||||
|
robot_mode: bool,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let config = Config::load(config_override)?;
|
||||||
|
let run = run_who(&config, &args)?;
|
||||||
|
let elapsed_ms = start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
|
if robot_mode {
|
||||||
|
print_who_json(&run, &args, elapsed_ms);
|
||||||
|
} else {
|
||||||
|
print_who_human(&run.result, run.resolved_input.project_path.as_deref());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn handle_list_compat(
|
async fn handle_list_compat(
|
||||||
config_override: Option<&str>,
|
config_override: Option<&str>,
|
||||||
|
|||||||
Reference in New Issue
Block a user