//! MCP status tools — pipeline status, story triage, and acceptance criteria inspection. 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 `.huskies/logs/{story_id}/`. fn find_most_recent_log(project_root: &Path, story_id: &str) -> Option { let dir = project_root.join(".huskies").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() } /// MCP tool: show full details for a work item in any pipeline stage. pub(super) async fn tool_show(args: &Value, ctx: &AppContext) -> Result { let raw_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()?; // Resolve numeric prefix (e.g. "5") to full story_id via find_story_by_number. let (story_id, contents) = if raw_id.chars().all(|c| c.is_ascii_digit()) { let (sid, _, _, content) = crate::chat::lookup::find_story_by_number(&root, raw_id) .ok_or_else(|| { format!("No work item with number '{raw_id}' found in any pipeline stage.") })?; let body = content.ok_or_else(|| { format!("Work item '{sid}' found in pipeline but its content is unavailable.") })?; (sid, body) } else { let body = crate::db::read_content(crate::db::ContentKey::Story(raw_id)) .ok_or_else(|| format!("Work item '{raw_id}' not found in any pipeline stage."))?; (raw_id.to_string(), body) }; let story_id = story_id.as_str(); let typed_item = crate::pipeline_state::read_typed(story_id) .map_err(|e| format!("Failed to read pipeline state: {e}"))?; // --- Metadata (story 929: CRDT-first, yaml_residue marks gaps) --- let mut front_matter = serde_json::Map::new(); if let Some(view) = crate::crdt_state::read_item(story_id) { front_matter.insert("name".to_string(), json!(view.name())); front_matter.insert("stage".to_string(), json!(view.stage().dir_name())); if let Some(agent) = view.agent() { front_matter.insert("agent".to_string(), json!(agent)); } if let Some(qa) = view.qa_mode() { front_matter.insert("qa".to_string(), json!(qa.as_str())); } let rc = view.retry_count(); if rc > 0 { front_matter.insert("retry_count".to_string(), json!(rc)); } let deps = view.depends_on(); if !deps.is_empty() { front_matter.insert("depends_on".to_string(), json!(deps)); } let origin_str = view.origin().unwrap_or("unknown"); front_matter.insert("origin".to_string(), json!(origin_str)); let stage_claim = typed_item.as_ref().and_then(|t| match &t.stage { crate::pipeline_state::Stage::Coding { claim, .. } => claim.as_ref(), crate::pipeline_state::Stage::Merge { claim, .. } => claim.as_ref(), _ => None, }); if let Some(claim) = stage_claim { front_matter.insert("claimed_by".to_string(), json!(claim.agent.0.as_str())); front_matter.insert( "claimed_at".to_string(), json!(claim.claimed_at.timestamp() as f64), ); } } if let Some(job) = crate::crdt_state::read_merge_job(story_id) && let Some(mf) = job.error { front_matter.insert("merge_failure".to_string(), json!(mf)); } // --- 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(".huskies").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(".huskies") .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_show_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_show(&json!({"story_id": "999_story_nonexistent"}), &ctx).await; assert!(result.is_err()); assert!(result.unwrap_err().contains("not found")); } #[tokio::test] async fn tool_show_returns_retry_count_and_depends_on() { let tmp = tempdir().unwrap(); crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); let story_content = "# Story\n\n## Acceptance Criteria\n\n- [ ] Do the thing\n"; crate::db::write_item_with_content( "9887_story_blocked_test", "2_current", story_content, crate::db::ItemMeta::named("Blocked Story"), ); crate::crdt_state::set_retry_count("9887_story_blocked_test", 3); crate::crdt_state::set_depends_on("9887_story_blocked_test", &[100, 200]); let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf()); let result = tool_show(&json!({"story_id": "9887_story_blocked_test"}), &ctx) .await .unwrap(); let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); assert_eq!(parsed["front_matter"]["retry_count"], 3); let depends_on = parsed["front_matter"]["depends_on"].as_array().unwrap(); assert_eq!(depends_on.len(), 2); assert_eq!(depends_on[0], 100); assert_eq!(depends_on[1], 200); } /// Story 1222 regression: `show` must return full content for a story /// after it reaches Done and again after it reaches Archived — the /// terminal-stage content purge must never evict the story body, only /// its ephemeral bookkeeping keys. #[tokio::test] async fn tool_show_returns_content_after_done_and_after_archived() { let tmp = tempdir().unwrap(); crate::crdt_state::init_for_test(); crate::db::ensure_content_store(); let story_id = "1222_story_done_archived_test"; let story_content = "# Story\n\n## Acceptance Criteria\n\n- [ ] Ship it\n"; crate::db::write_item_with_content( story_id, "5_done", story_content, crate::db::ItemMeta::named("Done Archived Test"), ); // Simulate the terminal-transition purge that fires when a story // reaches Done (story 996 GC subscriber / sweep). crate::db::gc::purge_ephemeral_content_keys_for_story(story_id); let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf()); let result = tool_show(&json!({"story_id": story_id}), &ctx) .await .expect("show must succeed for a Done story, not return content unavailable"); let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); assert_eq!(parsed["story_id"], story_id); assert_eq!(parsed["front_matter"]["name"], "Done Archived Test"); // Move to Archived and purge again — content must still be readable. crate::db::move_item_stage(story_id, "6_archived", None); crate::db::gc::purge_ephemeral_content_keys_for_story(story_id); let result = tool_show(&json!({"story_id": story_id}), &ctx) .await .expect("show must succeed for an Archived story, not return content unavailable"); let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); assert_eq!(parsed["story_id"], story_id); } #[tokio::test] async fn tool_show_returns_story_data() { let tmp = tempdir().unwrap(); crate::db::ensure_content_store(); let story_content = "# Story\n\n## Acceptance Criteria\n\n- [ ] First criterion\n- [x] Second criterion\n\n## Out of Scope\n\n- nothing\n"; crate::db::write_item_with_content( "9886_story_status_test", "2_current", story_content, crate::db::ItemMeta::named("My Test Story"), ); crate::crdt_state::set_agent( "9886_story_status_test", Some(crate::config::AgentName::Coder1), ); let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf()); let result = tool_show(&json!({"story_id": "9886_story_status_test"}), &ctx) .await .unwrap(); let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); assert_eq!(parsed["story_id"], "9886_story_status_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); } }