diff --git a/server/src/matrix/commands/mod.rs b/server/src/matrix/commands/mod.rs index 019a3e0..2d9f2b2 100644 --- a/server/src/matrix/commands/mod.rs +++ b/server/src/matrix/commands/mod.rs @@ -14,6 +14,7 @@ mod move_story; mod overview; mod show; mod status; +mod whatsup; use crate::agents::AgentPool; use std::collections::HashSet; @@ -126,6 +127,11 @@ pub fn commands() -> &'static [BotCommand] { description: "Show implementation summary for a merged story: `overview `", handler: overview::handle_overview, }, + BotCommand { + name: "whatsup", + description: "Show in-progress triage dump for a story: `whatsup `", + handler: whatsup::handle_whatsup, + }, BotCommand { name: "start", description: "Start a coder on a story: `start ` or `start opus`", diff --git a/server/src/matrix/commands/whatsup.rs b/server/src/matrix/commands/whatsup.rs new file mode 100644 index 0000000..d7433da --- /dev/null +++ b/server/src/matrix/commands/whatsup.rs @@ -0,0 +1,550 @@ +//! Handler for the `whatsup` command. +//! +//! Produces a triage dump for a story that is currently in-progress +//! (`work/2_current/`): metadata, acceptance criteria, worktree/branch state, +//! git diff, recent commits, and the tail of the agent log. +//! +//! The command is handled entirely at the bot level — no LLM invocation. + +use super::CommandContext; +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// Handle `{bot_name} whatsup {number}`. +pub(super) fn handle_whatsup(ctx: &CommandContext) -> Option { + let num_str = ctx.args.trim(); + if num_str.is_empty() { + return Some(format!( + "Usage: `{} whatsup `\n\nShows a triage dump for a story currently in progress.", + ctx.bot_name + )); + } + if !num_str.chars().all(|c| c.is_ascii_digit()) { + return Some(format!( + "Invalid story number: `{num_str}`. Usage: `{} whatsup `", + ctx.bot_name + )); + } + + let current_dir = ctx + .project_root + .join(".storkit") + .join("work") + .join("2_current"); + + match find_story_in_dir(¤t_dir, num_str) { + Some((path, stem)) => Some(build_triage_dump(ctx, &path, &stem, num_str)), + None => Some(format!( + "Story **{num_str}** is not currently in progress (not found in `work/2_current/`)." + )), + } +} + +/// Find a `.md` file whose numeric prefix matches `num_str` in `dir`. +/// +/// Returns `(path, file_stem)` for the first match. +fn find_story_in_dir(dir: &Path, num_str: &str) -> Option<(PathBuf, String)> { + let entries = std::fs::read_dir(dir).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("md") { + continue; + } + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + let file_num = stem + .split('_') + .next() + .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) + .unwrap_or(""); + if file_num == num_str { + return Some((path.clone(), stem.to_string())); + } + } + } + None +} + +/// Build the full triage dump for a story. +fn build_triage_dump( + ctx: &CommandContext, + story_path: &Path, + story_id: &str, + num_str: &str, +) -> String { + let contents = match std::fs::read_to_string(story_path) { + Ok(c) => c, + Err(e) => return format!("Failed to read story {num_str}: {e}"), + }; + + let meta = crate::io::story_metadata::parse_front_matter(&contents).ok(); + let name = meta.as_ref().and_then(|m| m.name.as_deref()).unwrap_or("(unnamed)"); + + let mut out = String::new(); + + // ---- Header ---- + out.push_str(&format!("## Story {num_str} — {name}\n")); + out.push_str("**Stage:** In Progress (`2_current`)\n\n"); + + // ---- Front matter fields ---- + if let Some(ref m) = meta { + let mut fields: Vec = Vec::new(); + if let Some(true) = m.blocked { + fields.push("**blocked:** true".to_string()); + } + if let Some(ref agent) = m.agent { + fields.push(format!("**agent:** {agent}")); + } + if let Some(ref qa) = m.qa { + fields.push(format!("**qa:** {qa}")); + } + if let Some(true) = m.review_hold { + fields.push("**review_hold:** true".to_string()); + } + if let Some(rc) = m.retry_count { + if rc > 0 { + fields.push(format!("**retry_count:** {rc}")); + } + } + if let Some(ref cb) = m.coverage_baseline { + fields.push(format!("**coverage_baseline:** {cb}")); + } + if let Some(ref mf) = m.merge_failure { + fields.push(format!("**merge_failure:** {mf}")); + } + if !fields.is_empty() { + out.push_str("**Front matter:**\n"); + for f in &fields { + out.push_str(&format!(" • {f}\n")); + } + out.push('\n'); + } + } + + // ---- Acceptance criteria ---- + let criteria = parse_acceptance_criteria(&contents); + if !criteria.is_empty() { + out.push_str("**Acceptance Criteria:**\n"); + for (checked, text) in &criteria { + let mark = if *checked { "✅" } else { "⬜" }; + out.push_str(&format!(" {mark} {text}\n")); + } + let total = criteria.len(); + let done = criteria.iter().filter(|(c, _)| *c).count(); + out.push_str(&format!(" *{done}/{total} complete*\n")); + out.push('\n'); + } + + // ---- Worktree and branch ---- + let wt_path = crate::worktree::worktree_path(ctx.project_root, story_id); + let branch = format!("feature/story-{story_id}"); + if wt_path.is_dir() { + out.push_str(&format!("**Worktree:** `{}`\n", wt_path.display())); + out.push_str(&format!("**Branch:** `{branch}`\n\n")); + + // ---- git diff --stat ---- + let diff_stat = run_git( + &wt_path, + &["diff", "--stat", "master...HEAD"], + ); + if !diff_stat.is_empty() { + out.push_str("**Diff stat (vs master):**\n```\n"); + out.push_str(&diff_stat); + out.push_str("```\n\n"); + } else { + out.push_str("**Diff stat (vs master):** *(no changes)*\n\n"); + } + + // ---- Last 5 commits on feature branch ---- + let log = run_git( + &wt_path, + &[ + "log", + "master..HEAD", + "--pretty=format:%h %s", + "-5", + ], + ); + if !log.is_empty() { + out.push_str("**Recent commits (branch only):**\n```\n"); + out.push_str(&log); + out.push_str("\n```\n\n"); + } else { + out.push_str("**Recent commits (branch only):** *(none yet)*\n\n"); + } + } else { + out.push_str(&format!("**Branch:** `{branch}`\n")); + out.push_str("**Worktree:** *(not yet created)*\n\n"); + } + + // ---- Agent log tail ---- + let log_dir = ctx + .project_root + .join(".storkit") + .join("logs") + .join(story_id); + match latest_log_file(&log_dir) { + Some(log_path) => { + let tail = read_log_tail(&log_path, 20); + let filename = log_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("agent.log"); + if tail.is_empty() { + out.push_str(&format!("**Agent log** (`{filename}`):** *(empty)*\n")); + } else { + out.push_str(&format!("**Agent log tail** (`{filename}`):\n```\n")); + out.push_str(&tail); + out.push_str("\n```\n"); + } + } + None => { + out.push_str("**Agent log:** *(no log found)*\n"); + } + } + + out +} + +/// Parse acceptance criteria from story markdown. +/// +/// Returns a list of `(checked, text)` for every `- [ ] ...` and `- [x] ...` line. +fn parse_acceptance_criteria(contents: &str) -> Vec<(bool, String)> { + contents + .lines() + .filter_map(|line| { + let trimmed = line.trim(); + if let Some(text) = trimmed.strip_prefix("- [x] ").or_else(|| trimmed.strip_prefix("- [X] ")) { + Some((true, text.to_string())) + } else if let Some(text) = trimmed.strip_prefix("- [ ] ") { + Some((false, text.to_string())) + } else { + None + } + }) + .collect() +} + +/// Run a git command in the given directory, returning trimmed stdout (or empty on error). +fn run_git(dir: &Path, args: &[&str]) -> String { + Command::new("git") + .args(args) + .current_dir(dir) + .output() + .ok() + .filter(|o| o.status.success()) + .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) + .unwrap_or_default() +} + +/// Find the most recently modified `.log` file in the given directory, +/// regardless of agent name. +fn latest_log_file(log_dir: &Path) -> Option { + if !log_dir.is_dir() { + return None; + } + let mut best: Option<(PathBuf, std::time::SystemTime)> = None; + for entry in std::fs::read_dir(log_dir).ok()?.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("log") { + continue; + } + let modified = match entry.metadata().and_then(|m| m.modified()) { + Ok(t) => t, + Err(_) => continue, + }; + if best.as_ref().is_none_or(|(_, t)| modified > *t) { + best = Some((path, modified)); + } + } + best.map(|(p, _)| p) +} + +/// Read the last `n` non-empty lines from a file as a single string. +fn read_log_tail(path: &Path, n: usize) -> String { + let contents = match std::fs::read_to_string(path) { + Ok(c) => c, + Err(_) => return String::new(), + }; + let lines: Vec<&str> = contents.lines().filter(|l| !l.trim().is_empty()).collect(); + let start = lines.len().saturating_sub(n); + lines[start..].join("\n") +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::agents::AgentPool; + use std::collections::HashSet; + use std::sync::{Arc, Mutex}; + + use super::super::{CommandDispatch, try_handle_command}; + + fn whatsup_cmd(root: &Path, args: &str) -> Option { + let agents = Arc::new(AgentPool::new_test(3000)); + let ambient_rooms = Arc::new(Mutex::new(HashSet::new())); + let room_id = "!test:example.com".to_string(); + let dispatch = CommandDispatch { + bot_name: "Timmy", + bot_user_id: "@timmy:homeserver.local", + project_root: root, + agents: &agents, + ambient_rooms: &ambient_rooms, + room_id: &room_id, + }; + try_handle_command(&dispatch, &format!("@timmy whatsup {args}")) + } + + fn write_story_file(root: &Path, stage: &str, filename: &str, content: &str) { + let dir = root.join(".storkit/work").join(stage); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join(filename), content).unwrap(); + } + + // -- registration ------------------------------------------------------- + + #[test] + fn whatsup_command_is_registered() { + let found = super::super::commands().iter().any(|c| c.name == "whatsup"); + assert!(found, "whatsup command must be in the registry"); + } + + #[test] + fn whatsup_command_appears_in_help() { + let result = super::super::tests::try_cmd_addressed( + "Timmy", + "@timmy:homeserver.local", + "@timmy help", + ); + let output = result.unwrap(); + assert!( + output.contains("whatsup"), + "help should list whatsup command: {output}" + ); + } + + // -- input validation --------------------------------------------------- + + #[test] + fn whatsup_no_args_returns_usage() { + let tmp = tempfile::TempDir::new().unwrap(); + let output = whatsup_cmd(tmp.path(), "").unwrap(); + assert!( + output.contains("Usage"), + "no args should show usage: {output}" + ); + } + + #[test] + fn whatsup_non_numeric_returns_error() { + let tmp = tempfile::TempDir::new().unwrap(); + let output = whatsup_cmd(tmp.path(), "abc").unwrap(); + assert!( + output.contains("Invalid"), + "non-numeric arg should return error: {output}" + ); + } + + // -- not found ---------------------------------------------------------- + + #[test] + fn whatsup_story_not_in_current_returns_friendly_message() { + let tmp = tempfile::TempDir::new().unwrap(); + // Create the directory but put the story in backlog, not current + write_story_file( + tmp.path(), + "1_backlog", + "42_story_not_in_current.md", + "---\nname: Not in current\n---\n", + ); + let output = whatsup_cmd(tmp.path(), "42").unwrap(); + assert!( + output.contains("42"), + "message should include story number: {output}" + ); + assert!( + output.contains("not") || output.contains("Not"), + "message should say not found/in progress: {output}" + ); + } + + // -- found in 2_current ------------------------------------------------- + + #[test] + fn whatsup_shows_story_name_and_stage() { + let tmp = tempfile::TempDir::new().unwrap(); + write_story_file( + tmp.path(), + "2_current", + "99_story_my_feature.md", + "---\nname: My Feature\n---\n\n## Acceptance Criteria\n\n- [ ] First thing\n- [x] Done thing\n", + ); + let output = whatsup_cmd(tmp.path(), "99").unwrap(); + assert!(output.contains("99"), "should show story number: {output}"); + assert!( + output.contains("My Feature"), + "should show story name: {output}" + ); + assert!( + output.contains("In Progress") || output.contains("2_current"), + "should show pipeline stage: {output}" + ); + } + + #[test] + fn whatsup_shows_acceptance_criteria() { + let tmp = tempfile::TempDir::new().unwrap(); + write_story_file( + tmp.path(), + "2_current", + "99_story_criteria_test.md", + "---\nname: Criteria Test\n---\n\n- [ ] First thing\n- [x] Done thing\n- [ ] Second thing\n", + ); + let output = whatsup_cmd(tmp.path(), "99").unwrap(); + assert!( + output.contains("First thing"), + "should show unchecked criterion: {output}" + ); + assert!( + output.contains("Done thing"), + "should show checked criterion: {output}" + ); + // 1 of 3 done + assert!( + output.contains("1/3"), + "should show checked/total count: {output}" + ); + } + + #[test] + fn whatsup_shows_blocked_field() { + let tmp = tempfile::TempDir::new().unwrap(); + write_story_file( + tmp.path(), + "2_current", + "55_story_blocked_story.md", + "---\nname: Blocked Story\nblocked: true\n---\n", + ); + let output = whatsup_cmd(tmp.path(), "55").unwrap(); + assert!( + output.contains("blocked"), + "should show blocked field: {output}" + ); + } + + #[test] + fn whatsup_shows_agent_field() { + let tmp = tempfile::TempDir::new().unwrap(); + write_story_file( + tmp.path(), + "2_current", + "55_story_agent_story.md", + "---\nname: Agent Story\nagent: coder-1\n---\n", + ); + let output = whatsup_cmd(tmp.path(), "55").unwrap(); + assert!( + output.contains("coder-1"), + "should show agent field: {output}" + ); + } + + #[test] + fn whatsup_no_worktree_shows_not_created() { + let tmp = tempfile::TempDir::new().unwrap(); + write_story_file( + tmp.path(), + "2_current", + "77_story_no_worktree.md", + "---\nname: No Worktree\n---\n", + ); + let output = whatsup_cmd(tmp.path(), "77").unwrap(); + // Branch name should still appear + assert!( + output.contains("feature/story-77"), + "should show branch name: {output}" + ); + } + + #[test] + fn whatsup_no_log_shows_no_log_message() { + let tmp = tempfile::TempDir::new().unwrap(); + write_story_file( + tmp.path(), + "2_current", + "77_story_no_log.md", + "---\nname: No Log\n---\n", + ); + let output = whatsup_cmd(tmp.path(), "77").unwrap(); + assert!( + output.contains("no log") || output.contains("No log") || output.contains("*(no log found)*"), + "should indicate no log exists: {output}" + ); + } + + // -- parse_acceptance_criteria ------------------------------------------ + + #[test] + fn parse_criteria_mixed() { + let input = "## AC\n- [ ] First\n- [x] Done\n- [X] Also done\n- [ ] Last\n"; + let result = parse_acceptance_criteria(input); + assert_eq!(result.len(), 4); + assert_eq!(result[0], (false, "First".to_string())); + assert_eq!(result[1], (true, "Done".to_string())); + assert_eq!(result[2], (true, "Also done".to_string())); + assert_eq!(result[3], (false, "Last".to_string())); + } + + #[test] + fn parse_criteria_empty() { + let input = "# Story\nNo checkboxes here.\n"; + let result = parse_acceptance_criteria(input); + assert!(result.is_empty()); + } + + // -- read_log_tail ------------------------------------------------------- + + #[test] + fn read_log_tail_returns_last_n_lines() { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join("test.log"); + let content = (1..=30).map(|i| format!("line {i}")).collect::>().join("\n"); + std::fs::write(&path, &content).unwrap(); + let tail = read_log_tail(&path, 5); + let lines: Vec<&str> = tail.lines().collect(); + assert_eq!(lines.len(), 5); + assert_eq!(lines[0], "line 26"); + assert_eq!(lines[4], "line 30"); + } + + #[test] + fn read_log_tail_fewer_lines_than_n() { + let tmp = tempfile::TempDir::new().unwrap(); + let path = tmp.path().join("short.log"); + std::fs::write(&path, "line A\nline B\n").unwrap(); + let tail = read_log_tail(&path, 20); + assert!(tail.contains("line A")); + assert!(tail.contains("line B")); + } + + // -- latest_log_file ---------------------------------------------------- + + #[test] + fn latest_log_file_returns_none_for_missing_dir() { + let tmp = tempfile::TempDir::new().unwrap(); + let result = latest_log_file(&tmp.path().join("nonexistent")); + assert!(result.is_none()); + } + + #[test] + fn latest_log_file_finds_log() { + let tmp = tempfile::TempDir::new().unwrap(); + let log_path = tmp.path().join("coder-1-sess-abc.log"); + std::fs::write(&log_path, "some log content\n").unwrap(); + let result = latest_log_file(tmp.path()); + assert!(result.is_some()); + assert_eq!(result.unwrap(), log_path); + } +}