diff --git a/server/src/http/mcp/mod.rs b/server/src/http/mcp/mod.rs index 76beb1f..ddb9ed2 100644 --- a/server/src/http/mcp/mod.rs +++ b/server/src/http/mcp/mod.rs @@ -15,6 +15,7 @@ pub mod merge_tools; pub mod qa_tools; pub mod shell_tools; pub mod story_tools; +pub mod whatsup_tools; /// Returns true when the Accept header includes text/event-stream. fn wants_sse(req: &Request) -> bool { @@ -1121,6 +1122,20 @@ fn handle_tools_list(id: Option) -> JsonRpcResponse { }, "required": ["worktree_path"] } + }, + { + "name": "whatsup", + "description": "Get a full triage dump for an in-progress story: front matter, AC checklist, active worktree/branch, git diff --stat since master, last 5 commits, and last 20 lines of the most recent agent log. Returns a clear error if the story is not in work/2_current/.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (filename stem, e.g. '42_story_my_feature')" + } + }, + "required": ["story_id"] + } } ] }), @@ -1209,6 +1224,8 @@ async fn handle_tools_call( "git_add" => git_tools::tool_git_add(&args, ctx).await, "git_commit" => git_tools::tool_git_commit(&args, ctx).await, "git_log" => git_tools::tool_git_log(&args, ctx).await, + // Story triage + "whatsup" => whatsup_tools::tool_whatsup(&args, ctx).await, _ => Err(format!("Unknown tool: {tool_name}")), }; @@ -1324,7 +1341,8 @@ mod tests { assert!(names.contains(&"git_add")); assert!(names.contains(&"git_commit")); assert!(names.contains(&"git_log")); - assert_eq!(tools.len(), 48); + assert!(names.contains(&"whatsup")); + assert_eq!(tools.len(), 49); } #[test] diff --git a/server/src/http/mcp/whatsup_tools.rs b/server/src/http/mcp/whatsup_tools.rs new file mode 100644 index 0000000..5413603 --- /dev/null +++ b/server/src/http/mcp/whatsup_tools.rs @@ -0,0 +1,364 @@ +use crate::http::context::AppContext; +use serde_json::{Value, json}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Parse all AC items from a story file, returning (text, is_checked) pairs. +fn parse_ac_items(contents: &str) -> Vec<(String, bool)> { + let mut in_ac_section = false; + let mut items = Vec::new(); + + for line in contents.lines() { + let trimmed = line.trim(); + if trimmed == "## Acceptance Criteria" { + in_ac_section = true; + continue; + } + // Stop at the next heading + if in_ac_section && trimmed.starts_with("## ") { + break; + } + if in_ac_section { + if let Some(rest) = trimmed.strip_prefix("- [x] ").or(trimmed.strip_prefix("- [X] ")) { + items.push((rest.to_string(), true)); + } else if let Some(rest) = trimmed.strip_prefix("- [ ] ") { + items.push((rest.to_string(), false)); + } + } + } + + items +} + +/// Find the most recent log file for any agent under `.storkit/logs/{story_id}/`. +fn find_most_recent_log(project_root: &Path, story_id: &str) -> Option { + let dir = project_root + .join(".storkit") + .join("logs") + .join(story_id); + + if !dir.is_dir() { + return None; + } + + let mut best: Option<(PathBuf, std::time::SystemTime)> = None; + + let entries = fs::read_dir(&dir).ok()?; + for entry in entries.flatten() { + let path = entry.path(); + let name = match path.file_name().and_then(|n| n.to_str()) { + Some(n) => n.to_string(), + None => continue, + }; + if !name.ends_with(".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) +} + +/// Return the last N raw lines from a file. +fn last_n_lines(path: &Path, n: usize) -> Result, String> { + let content = + fs::read_to_string(path).map_err(|e| format!("Failed to read log file: {e}"))?; + let lines: Vec = content + .lines() + .rev() + .take(n) + .map(|l| l.to_string()) + .collect::>() + .into_iter() + .rev() + .collect(); + Ok(lines) +} + +/// Run `git diff --stat {base}...HEAD` in the worktree. +async fn git_diff_stat(worktree: &Path, base: &str) -> Option { + let dir = worktree.to_path_buf(); + let base_arg = format!("{base}...HEAD"); + tokio::task::spawn_blocking(move || { + let output = std::process::Command::new("git") + .args(["diff", "--stat", &base_arg]) + .current_dir(&dir) + .output() + .ok()?; + if output.status.success() { + Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + None + } + }) + .await + .ok() + .flatten() +} + +/// Return the last N commit messages on the current branch relative to base. +async fn git_log_commits(worktree: &Path, base: &str, count: usize) -> Option> { + let dir = worktree.to_path_buf(); + let range = format!("{base}..HEAD"); + let count_str = count.to_string(); + tokio::task::spawn_blocking(move || { + let output = std::process::Command::new("git") + .args(["log", &range, "--oneline", &format!("-{count_str}")]) + .current_dir(&dir) + .output() + .ok()?; + if output.status.success() { + let lines: Vec = String::from_utf8(output.stdout) + .ok()? + .lines() + .filter(|l| !l.is_empty()) + .map(|l| l.to_string()) + .collect(); + Some(lines) + } else { + None + } + }) + .await + .ok() + .flatten() +} + +/// Return the active branch name for the given directory. +async fn git_branch(dir: &Path) -> Option { + let dir = dir.to_path_buf(); + tokio::task::spawn_blocking(move || { + let output = std::process::Command::new("git") + .args(["rev-parse", "--abbrev-ref", "HEAD"]) + .current_dir(&dir) + .output() + .ok()?; + if output.status.success() { + Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + None + } + }) + .await + .ok() + .flatten() +} + +pub(super) async fn tool_whatsup(args: &Value, ctx: &AppContext) -> Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + + let root = ctx.state.get_project_root()?; + let current_dir = root.join(".storkit").join("work").join("2_current"); + let filepath = current_dir.join(format!("{story_id}.md")); + + if !filepath.exists() { + return Err(format!( + "Story '{story_id}' not found in work/2_current/. Check the story_id and ensure it is in the current stage." + )); + } + + let contents = + fs::read_to_string(&filepath).map_err(|e| format!("Failed to read story file: {e}"))?; + + // --- Front matter --- + let mut front_matter = serde_json::Map::new(); + if let Ok(meta) = crate::io::story_metadata::parse_front_matter(&contents) { + if let Some(name) = &meta.name { + front_matter.insert("name".to_string(), json!(name)); + } + if let Some(agent) = &meta.agent { + front_matter.insert("agent".to_string(), json!(agent)); + } + if let Some(true) = meta.blocked { + front_matter.insert("blocked".to_string(), json!(true)); + } + if let Some(qa) = &meta.qa { + front_matter.insert("qa".to_string(), json!(qa.as_str())); + } + if let Some(rc) = meta.retry_count + && rc > 0 + { + front_matter.insert("retry_count".to_string(), json!(rc)); + } + if let Some(mf) = &meta.merge_failure { + front_matter.insert("merge_failure".to_string(), json!(mf)); + } + if let Some(rh) = meta.review_hold + && rh + { + front_matter.insert("review_hold".to_string(), json!(rh)); + } + } + + // --- AC checklist --- + let ac_items: Vec = parse_ac_items(&contents) + .into_iter() + .map(|(text, checked)| json!({ "text": text, "checked": checked })) + .collect(); + + // --- Worktree --- + let worktree_path = root.join(".storkit").join("worktrees").join(story_id); + let (_, worktree_info) = if worktree_path.is_dir() { + let branch = git_branch(&worktree_path).await; + ( + branch.clone(), + Some(json!({ + "path": worktree_path.to_string_lossy(), + "branch": branch, + })), + ) + } else { + (None, None) + }; + + // --- Git diff stat --- + let diff_stat = if worktree_path.is_dir() { + git_diff_stat(&worktree_path, "master").await + } else { + None + }; + + // --- Last 5 commits --- + let commits = if worktree_path.is_dir() { + git_log_commits(&worktree_path, "master", 5).await + } else { + None + }; + + // --- Most recent agent log (last 20 lines) --- + let agent_log = match find_most_recent_log(&root, story_id) { + Some(log_path) => { + let filename = log_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_string(); + match last_n_lines(&log_path, 20) { + Ok(lines) => Some(json!({ + "file": filename, + "lines": lines, + })), + Err(_) => None, + } + } + None => None, + }; + + let result = json!({ + "story_id": story_id, + "front_matter": front_matter, + "acceptance_criteria": ac_items, + "worktree": worktree_info, + "git_diff_stat": diff_stat, + "commits": commits, + "agent_log": agent_log, + }); + + serde_json::to_string_pretty(&result).map_err(|e| format!("Serialization error: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn parse_ac_items_returns_checked_and_unchecked() { + let content = "---\nname: test\n---\n\n## Acceptance Criteria\n\n- [ ] item one\n- [x] item two\n- [X] item three\n\n## Out of Scope\n\n- [ ] not an ac\n"; + let items = parse_ac_items(content); + assert_eq!(items.len(), 3); + assert_eq!(items[0], ("item one".to_string(), false)); + assert_eq!(items[1], ("item two".to_string(), true)); + assert_eq!(items[2], ("item three".to_string(), true)); + } + + #[test] + fn parse_ac_items_empty_when_no_section() { + let content = "---\nname: test\n---\n\nNo AC section here.\n"; + let items = parse_ac_items(content); + assert!(items.is_empty()); + } + + #[test] + fn find_most_recent_log_returns_none_for_missing_dir() { + let tmp = tempdir().unwrap(); + let result = find_most_recent_log(tmp.path(), "nonexistent_story"); + assert!(result.is_none()); + } + + #[test] + fn find_most_recent_log_returns_newest_file() { + let tmp = tempdir().unwrap(); + let log_dir = tmp + .path() + .join(".storkit") + .join("logs") + .join("42_story_foo"); + fs::create_dir_all(&log_dir).unwrap(); + + let old_path = log_dir.join("coder-1-sess-old.log"); + fs::write(&old_path, "old content").unwrap(); + + // Ensure different mtime + std::thread::sleep(std::time::Duration::from_millis(50)); + + let new_path = log_dir.join("coder-1-sess-new.log"); + fs::write(&new_path, "new content").unwrap(); + + let result = find_most_recent_log(tmp.path(), "42_story_foo").unwrap(); + assert!( + result.to_string_lossy().contains("sess-new"), + "Expected newest file, got: {}", + result.display() + ); + } + + #[tokio::test] + async fn tool_whatsup_returns_error_for_missing_story() { + let tmp = tempdir().unwrap(); + let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf()); + let result = tool_whatsup(&json!({"story_id": "999_story_nonexistent"}), &ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not found in work/2_current/")); + } + + #[tokio::test] + async fn tool_whatsup_returns_story_data() { + let tmp = tempdir().unwrap(); + let current_dir = tmp + .path() + .join(".storkit") + .join("work") + .join("2_current"); + fs::create_dir_all(¤t_dir).unwrap(); + + let story_content = "---\nname: My Test Story\nagent: coder-1\n---\n\n## Acceptance Criteria\n\n- [ ] First criterion\n- [x] Second criterion\n\n## Out of Scope\n\n- nothing\n"; + fs::write(current_dir.join("42_story_test.md"), story_content).unwrap(); + + let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf()); + let result = tool_whatsup(&json!({"story_id": "42_story_test"}), &ctx) + .await + .unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + + assert_eq!(parsed["story_id"], "42_story_test"); + assert_eq!(parsed["front_matter"]["name"], "My Test Story"); + assert_eq!(parsed["front_matter"]["agent"], "coder-1"); + + let ac = parsed["acceptance_criteria"].as_array().unwrap(); + assert_eq!(ac.len(), 2); + assert_eq!(ac[0]["text"], "First criterion"); + assert_eq!(ac[0]["checked"], false); + assert_eq!(ac[1]["text"], "Second criterion"); + assert_eq!(ac[1]["checked"], true); + } +}