storkit: merge 363_story_mcp_tool_for_whatsup_story_triage

This commit is contained in:
dave
2026-03-22 17:44:09 +00:00
parent 628b60ad15
commit 4262af7faa
2 changed files with 383 additions and 1 deletions

View File

@@ -15,6 +15,7 @@ pub mod merge_tools;
pub mod qa_tools; pub mod qa_tools;
pub mod shell_tools; pub mod shell_tools;
pub mod story_tools; pub mod story_tools;
pub mod whatsup_tools;
/// Returns true when the Accept header includes text/event-stream. /// Returns true when the Accept header includes text/event-stream.
fn wants_sse(req: &Request) -> bool { fn wants_sse(req: &Request) -> bool {
@@ -1121,6 +1122,20 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
}, },
"required": ["worktree_path"] "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_add" => git_tools::tool_git_add(&args, ctx).await,
"git_commit" => git_tools::tool_git_commit(&args, ctx).await, "git_commit" => git_tools::tool_git_commit(&args, ctx).await,
"git_log" => git_tools::tool_git_log(&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}")), _ => Err(format!("Unknown tool: {tool_name}")),
}; };
@@ -1324,7 +1341,8 @@ mod tests {
assert!(names.contains(&"git_add")); assert!(names.contains(&"git_add"));
assert!(names.contains(&"git_commit")); assert!(names.contains(&"git_commit"));
assert!(names.contains(&"git_log")); assert!(names.contains(&"git_log"));
assert_eq!(tools.len(), 48); assert!(names.contains(&"whatsup"));
assert_eq!(tools.len(), 49);
} }
#[test] #[test]

View File

@@ -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<PathBuf> {
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<Vec<String>, String> {
let content =
fs::read_to_string(path).map_err(|e| format!("Failed to read log file: {e}"))?;
let lines: Vec<String> = content
.lines()
.rev()
.take(n)
.map(|l| l.to_string())
.collect::<Vec<_>>()
.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<String> {
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<Vec<String>> {
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> = 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<String> {
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<String, String> {
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<Value> = 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(&current_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);
}
}