From f4376b01e15bd81730bac5af7c22dbd5f8e0fd3e Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 19 Mar 2026 10:57:27 +0000 Subject: [PATCH] story-kit: merge 304_story_mcp_tool_to_move_stories_between_pipeline_stages --- server/src/agents/lifecycle.rs | 163 +++++++++++++++++++++++++++++ server/src/agents/mod.rs | 2 +- server/src/http/mcp.rs | 182 ++++++++++++++++++++++++++++++++- 3 files changed, 344 insertions(+), 3 deletions(-) diff --git a/server/src/agents/lifecycle.rs b/server/src/agents/lifecycle.rs index 2907620..0172bbe 100644 --- a/server/src/agents/lifecycle.rs +++ b/server/src/agents/lifecycle.rs @@ -265,6 +265,78 @@ pub fn reject_story_from_qa( Ok(()) } +/// Move any work item to an arbitrary pipeline stage by searching all stages. +/// +/// Accepts `target_stage` as one of: `backlog`, `current`, `qa`, `merge`, `done`. +/// Idempotent: if the item is already in the target stage, returns Ok. +/// Returns `(from_stage, to_stage)` on success. +pub fn move_story_to_stage( + project_root: &Path, + story_id: &str, + target_stage: &str, +) -> Result<(String, String), String> { + let stage_dirs: &[(&str, &str)] = &[ + ("backlog", "1_backlog"), + ("current", "2_current"), + ("qa", "3_qa"), + ("merge", "4_merge"), + ("done", "5_done"), + ]; + + let target_dir_name = stage_dirs + .iter() + .find(|(name, _)| *name == target_stage) + .map(|(_, dir)| *dir) + .ok_or_else(|| { + format!( + "Invalid target_stage '{target_stage}'. Must be one of: backlog, current, qa, merge, done" + ) + })?; + + let sk = project_root.join(".story_kit").join("work"); + let target_dir = sk.join(target_dir_name); + let target_path = target_dir.join(format!("{story_id}.md")); + + if target_path.exists() { + return Ok((target_stage.to_string(), target_stage.to_string())); + } + + // Search all named stages plus the archive stage. + let search_dirs: &[(&str, &str)] = &[ + ("backlog", "1_backlog"), + ("current", "2_current"), + ("qa", "3_qa"), + ("merge", "4_merge"), + ("done", "5_done"), + ("archived", "6_archived"), + ]; + + let mut found_path: Option = None; + let mut from_stage = ""; + for (stage_name, dir_name) in search_dirs { + let candidate = sk.join(dir_name).join(format!("{story_id}.md")); + if candidate.exists() { + found_path = Some(candidate); + from_stage = stage_name; + break; + } + } + + let source_path = + found_path.ok_or_else(|| format!("Work item '{story_id}' not found in any pipeline stage."))?; + + std::fs::create_dir_all(&target_dir) + .map_err(|e| format!("Failed to create work/{target_dir_name}/ directory: {e}"))?; + std::fs::rename(&source_path, &target_path) + .map_err(|e| format!("Failed to move '{story_id}' to work/{target_dir_name}/: {e}"))?; + + slog!( + "[lifecycle] Moved '{story_id}' from work/{from_stage}/ to work/{target_dir_name}/" + ); + + Ok((from_stage.to_string(), target_stage.to_string())) +} + /// Move a bug from `work/2_current/` or `work/1_backlog/` to `work/5_done/` and auto-commit. /// /// * If the bug is in `2_current/`, it is moved to `5_done/` and committed. @@ -645,4 +717,95 @@ mod tests { reject_story_from_qa(root, "51_story_test", "notes").unwrap(); assert!(current_dir.join("51_story_test.md").exists()); } + + // ── move_story_to_stage tests ───────────────────────────────── + + #[test] + fn move_story_to_stage_moves_from_backlog_to_current() { + use std::fs; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let backlog = root.join(".story_kit/work/1_backlog"); + let current = root.join(".story_kit/work/2_current"); + fs::create_dir_all(&backlog).unwrap(); + fs::create_dir_all(¤t).unwrap(); + fs::write(backlog.join("60_story_move.md"), "test").unwrap(); + + let (from, to) = move_story_to_stage(root, "60_story_move", "current").unwrap(); + + assert_eq!(from, "backlog"); + assert_eq!(to, "current"); + assert!(!backlog.join("60_story_move.md").exists()); + assert!(current.join("60_story_move.md").exists()); + } + + #[test] + fn move_story_to_stage_moves_from_current_to_backlog() { + use std::fs; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let current = root.join(".story_kit/work/2_current"); + let backlog = root.join(".story_kit/work/1_backlog"); + fs::create_dir_all(¤t).unwrap(); + fs::create_dir_all(&backlog).unwrap(); + fs::write(current.join("61_story_back.md"), "test").unwrap(); + + let (from, to) = move_story_to_stage(root, "61_story_back", "backlog").unwrap(); + + assert_eq!(from, "current"); + assert_eq!(to, "backlog"); + assert!(!current.join("61_story_back.md").exists()); + assert!(backlog.join("61_story_back.md").exists()); + } + + #[test] + fn move_story_to_stage_idempotent_when_already_in_target() { + use std::fs; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let current = root.join(".story_kit/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + fs::write(current.join("62_story_idem.md"), "test").unwrap(); + + let (from, to) = move_story_to_stage(root, "62_story_idem", "current").unwrap(); + + assert_eq!(from, "current"); + assert_eq!(to, "current"); + assert!(current.join("62_story_idem.md").exists()); + } + + #[test] + fn move_story_to_stage_invalid_target_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let result = move_story_to_stage(tmp.path(), "1_story_test", "invalid"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Invalid target_stage")); + } + + #[test] + fn move_story_to_stage_not_found_returns_error() { + let tmp = tempfile::tempdir().unwrap(); + let result = move_story_to_stage(tmp.path(), "99_story_ghost", "current"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not found in any pipeline stage")); + } + + #[test] + fn move_story_to_stage_finds_in_qa_dir() { + use std::fs; + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let qa_dir = root.join(".story_kit/work/3_qa"); + let backlog = root.join(".story_kit/work/1_backlog"); + fs::create_dir_all(&qa_dir).unwrap(); + fs::create_dir_all(&backlog).unwrap(); + fs::write(qa_dir.join("63_story_qa.md"), "test").unwrap(); + + let (from, to) = move_story_to_stage(root, "63_story_qa", "backlog").unwrap(); + + assert_eq!(from, "qa"); + assert_eq!(to, "backlog"); + assert!(!qa_dir.join("63_story_qa.md").exists()); + assert!(backlog.join("63_story_qa.md").exists()); + } } diff --git a/server/src/agents/mod.rs b/server/src/agents/mod.rs index 6b644a3..268e99b 100644 --- a/server/src/agents/mod.rs +++ b/server/src/agents/mod.rs @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; pub use lifecycle::{ close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_archived, - move_story_to_merge, move_story_to_qa, reject_story_from_qa, + move_story_to_merge, move_story_to_qa, move_story_to_stage, reject_story_from_qa, }; pub use pool::AgentPool; diff --git a/server/src/http/mcp.rs b/server/src/http/mcp.rs index dac41c9..972e858 100644 --- a/server/src/http/mcp.rs +++ b/server/src/http/mcp.rs @@ -1,4 +1,4 @@ -use crate::agents::{close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_archived, move_story_to_merge, move_story_to_qa, reject_story_from_qa, AgentStatus, PipelineStage}; +use crate::agents::{close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_archived, move_story_to_merge, move_story_to_qa, move_story_to_stage, reject_story_from_qa, AgentStatus, PipelineStage}; use crate::config::ProjectConfig; use crate::log_buffer; use crate::slog; @@ -975,6 +975,25 @@ fn handle_tools_list(id: Option) -> JsonRpcResponse { } } } + }, + { + "name": "move_story", + "description": "Move a work item (story, bug, spike, or refactor) to an arbitrary pipeline stage. Prefer dedicated tools when available: use accept_story to mark items done, move_story_to_merge to queue for merging, or request_qa to trigger QA review. Use move_story only for arbitrary moves that lack a dedicated tool — for example, moving a story back to backlog or recovering a ghost story by moving it back to current.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Work item identifier (filename stem, e.g. '28_story_my_feature')" + }, + "target_stage": { + "type": "string", + "enum": ["backlog", "current", "qa", "merge", "done"], + "description": "Target pipeline stage: backlog (1_backlog), current (2_current), qa (3_qa), merge (4_merge), done (5_done)" + } + }, + "required": ["story_id", "target_stage"] + } } ] }), @@ -1051,6 +1070,8 @@ async fn handle_tools_call( "prompt_permission" => tool_prompt_permission(&args, ctx).await, // Token usage "get_token_usage" => tool_get_token_usage(&args, ctx), + // Arbitrary pipeline movement + "move_story" => tool_move_story(&args, ctx), _ => Err(format!("Unknown tool: {tool_name}")), }; @@ -2543,6 +2564,29 @@ fn tool_get_token_usage(args: &Value, ctx: &AppContext) -> Result Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + let target_stage = args + .get("target_stage") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: target_stage")?; + + let project_root = ctx.agents.get_project_root(&ctx.state)?; + + let (from_stage, to_stage) = move_story_to_stage(&project_root, story_id, target_stage)?; + + serde_json::to_string_pretty(&json!({ + "story_id": story_id, + "from_stage": from_stage, + "to_stage": to_stage, + "message": format!("Work item '{story_id}' moved from '{from_stage}' to '{to_stage}'.") + })) + .map_err(|e| format!("Serialization error: {e}")) +} + #[cfg(test)] mod tests { use super::*; @@ -2653,7 +2697,8 @@ mod tests { assert!(names.contains(&"get_pipeline_status")); assert!(names.contains(&"rebuild_and_restart")); assert!(names.contains(&"get_token_usage")); - assert_eq!(tools.len(), 40); + assert!(names.contains(&"move_story")); + assert_eq!(tools.len(), 41); } #[test] @@ -4732,4 +4777,137 @@ stage = "coder" target/debug/)" ); } + + // ── move_story tool tests ───────────────────────────────────── + + #[test] + fn move_story_in_tools_list() { + let resp = handle_tools_list(Some(json!(1))); + let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); + let tool = tools.iter().find(|t| t["name"] == "move_story"); + assert!(tool.is_some(), "move_story missing from tools list"); + let t = tool.unwrap(); + assert!(t["description"].is_string()); + let required = t["inputSchema"]["required"].as_array().unwrap(); + let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect(); + assert!(req_names.contains(&"story_id")); + assert!(req_names.contains(&"target_stage")); + } + + #[test] + fn tool_move_story_missing_story_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_move_story(&json!({"target_stage": "current"}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("story_id")); + } + + #[test] + fn tool_move_story_missing_target_stage() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_move_story(&json!({"story_id": "1_story_test"}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("target_stage")); + } + + #[test] + fn tool_move_story_invalid_target_stage() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + // Seed project root in state so get_project_root works + let backlog = root.join(".story_kit/work/1_backlog"); + fs::create_dir_all(&backlog).unwrap(); + fs::write(backlog.join("1_story_test.md"), "---\nname: Test\n---\n").unwrap(); + let ctx = test_ctx(root); + let result = tool_move_story( + &json!({"story_id": "1_story_test", "target_stage": "invalid"}), + &ctx, + ); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("Invalid target_stage")); + } + + #[test] + fn tool_move_story_moves_from_backlog_to_current() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let backlog = root.join(".story_kit/work/1_backlog"); + let current = root.join(".story_kit/work/2_current"); + fs::create_dir_all(&backlog).unwrap(); + fs::create_dir_all(¤t).unwrap(); + fs::write(backlog.join("5_story_test.md"), "---\nname: Test\n---\n").unwrap(); + + let ctx = test_ctx(root); + let result = tool_move_story( + &json!({"story_id": "5_story_test", "target_stage": "current"}), + &ctx, + ) + .unwrap(); + + assert!(!backlog.join("5_story_test.md").exists()); + assert!(current.join("5_story_test.md").exists()); + let parsed: Value = serde_json::from_str(&result).unwrap(); + assert_eq!(parsed["story_id"], "5_story_test"); + assert_eq!(parsed["from_stage"], "backlog"); + assert_eq!(parsed["to_stage"], "current"); + } + + #[test] + fn tool_move_story_moves_from_current_to_backlog() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let current = root.join(".story_kit/work/2_current"); + let backlog = root.join(".story_kit/work/1_backlog"); + fs::create_dir_all(¤t).unwrap(); + fs::create_dir_all(&backlog).unwrap(); + fs::write(current.join("6_story_back.md"), "---\nname: Back\n---\n").unwrap(); + + let ctx = test_ctx(root); + let result = tool_move_story( + &json!({"story_id": "6_story_back", "target_stage": "backlog"}), + &ctx, + ) + .unwrap(); + + assert!(!current.join("6_story_back.md").exists()); + assert!(backlog.join("6_story_back.md").exists()); + let parsed: Value = serde_json::from_str(&result).unwrap(); + assert_eq!(parsed["from_stage"], "current"); + assert_eq!(parsed["to_stage"], "backlog"); + } + + #[test] + fn tool_move_story_idempotent_when_already_in_target() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path(); + let current = root.join(".story_kit/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + fs::write(current.join("7_story_idem.md"), "---\nname: Idem\n---\n").unwrap(); + + let ctx = test_ctx(root); + let result = tool_move_story( + &json!({"story_id": "7_story_idem", "target_stage": "current"}), + &ctx, + ) + .unwrap(); + + assert!(current.join("7_story_idem.md").exists()); + let parsed: Value = serde_json::from_str(&result).unwrap(); + assert_eq!(parsed["from_stage"], "current"); + assert_eq!(parsed["to_stage"], "current"); + } + + #[test] + fn tool_move_story_error_when_not_found() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_move_story( + &json!({"story_id": "99_story_ghost", "target_stage": "current"}), + &ctx, + ); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not found in any pipeline stage")); + } }