diff --git a/.story_kit/stories/current/45_deterministic_story_lifecycle_management.md b/.story_kit/stories/archived/45_deterministic_story_lifecycle_management.md similarity index 100% rename from .story_kit/stories/current/45_deterministic_story_lifecycle_management.md rename to .story_kit/stories/archived/45_deterministic_story_lifecycle_management.md diff --git a/server/src/agents.rs b/server/src/agents.rs index 4ddf550..095cbc5 100644 --- a/server/src/agents.rs +++ b/server/src/agents.rs @@ -652,6 +652,36 @@ impl AgentPool { } } +/// Move a story file from current/ to archived/ (human accept action). +/// +/// * If the story is in current/, it is renamed to archived/. +/// * If the story is already in archived/, this is a no-op (idempotent). +/// * If the story is not found in current/ or archived/, an error is returned. +pub fn move_story_to_archived(project_root: &Path, story_id: &str) -> Result<(), String> { + let stories_dir = project_root.join(".story_kit").join("stories"); + let current_path = stories_dir.join("current").join(format!("{story_id}.md")); + let archived_path = stories_dir.join("archived").join(format!("{story_id}.md")); + + if archived_path.exists() { + // Already archived — idempotent, nothing to do. + return Ok(()); + } + + if current_path.exists() { + let archived_dir = stories_dir.join("archived"); + std::fs::create_dir_all(&archived_dir) + .map_err(|e| format!("Failed to create archived stories directory: {e}"))?; + std::fs::rename(¤t_path, &archived_path) + .map_err(|e| format!("Failed to move story '{story_id}' to archived/: {e}"))?; + eprintln!("[lifecycle] Moved story '{story_id}' from current/ to archived/"); + return Ok(()); + } + + Err(format!( + "Story '{story_id}' not found in current/. Cannot accept story." + )) +} + // ── Acceptance-gate helpers ─────────────────────────────────────────────────── /// Check whether the given directory has any uncommitted git changes. diff --git a/server/src/http/mcp.rs b/server/src/http/mcp.rs index 73e8a08..7961469 100644 --- a/server/src/http/mcp.rs +++ b/server/src/http/mcp.rs @@ -1,3 +1,4 @@ +use crate::agents::move_story_to_archived; use crate::config::ProjectConfig; use crate::http::context::AppContext; use crate::http::settings::get_editor_command_from_store; @@ -599,6 +600,20 @@ fn handle_tools_list(id: Option) -> JsonRpcResponse { }, "required": ["story_id", "agent_name", "summary"] } + }, + { + "name": "accept_story", + "description": "Accept a story: moves it from current/ to archived/.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (filename stem, e.g. '28_my_story')" + } + }, + "required": ["story_id"] + } } ] }), @@ -642,6 +657,8 @@ async fn handle_tools_call( "get_editor_command" => tool_get_editor_command(&args, ctx), // Completion reporting "report_completion" => tool_report_completion(&args, ctx).await, + // Lifecycle tools + "accept_story" => tool_accept_story(&args, ctx), _ => Err(format!("Unknown tool: {tool_name}")), }; @@ -1041,6 +1058,18 @@ async fn tool_report_completion(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 project_root = ctx.agents.get_project_root(&ctx.state)?; + move_story_to_archived(&project_root, story_id)?; + + Ok(format!("Story '{story_id}' accepted and moved to archived/.")) +} + /// Run `git log ..HEAD --oneline` in the worktree and return the commit /// summaries, or `None` if git is unavailable or there are no new commits. async fn get_worktree_commits(worktree_path: &str, base_branch: &str) -> Option> { @@ -1192,7 +1221,8 @@ mod tests { assert!(names.contains(&"remove_worktree")); assert!(names.contains(&"get_editor_command")); assert!(names.contains(&"report_completion")); - assert_eq!(tools.len(), 18); + assert!(names.contains(&"accept_story")); + assert_eq!(tools.len(), 19); } #[test]