Accept story 45: Deterministic Story Lifecycle Management
- accept_story MCP tool moves current/ to archived/ - move_story_to_archived helper with idempotent behavior - start_agent auto-moves upcoming/ to current/ Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 ───────────────────────────────────────────────────
|
// ── Acceptance-gate helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Check whether the given directory has any uncommitted git changes.
|
/// Check whether the given directory has any uncommitted git changes.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::agents::move_story_to_archived;
|
||||||
use crate::config::ProjectConfig;
|
use crate::config::ProjectConfig;
|
||||||
use crate::http::context::AppContext;
|
use crate::http::context::AppContext;
|
||||||
use crate::http::settings::get_editor_command_from_store;
|
use crate::http::settings::get_editor_command_from_store;
|
||||||
@@ -599,6 +600,20 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
|||||||
},
|
},
|
||||||
"required": ["story_id", "agent_name", "summary"]
|
"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),
|
"get_editor_command" => tool_get_editor_command(&args, ctx),
|
||||||
// Completion reporting
|
// Completion reporting
|
||||||
"report_completion" => tool_report_completion(&args, ctx).await,
|
"report_completion" => tool_report_completion(&args, ctx).await,
|
||||||
|
// Lifecycle tools
|
||||||
|
"accept_story" => tool_accept_story(&args, ctx),
|
||||||
_ => Err(format!("Unknown tool: {tool_name}")),
|
_ => Err(format!("Unknown tool: {tool_name}")),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1041,6 +1058,18 @@ async fn tool_report_completion(args: &Value, ctx: &AppContext) -> Result<String
|
|||||||
.map_err(|e| format!("Serialization error: {e}"))
|
.map_err(|e| format!("Serialization error: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tool_accept_story(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 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 <base>..HEAD --oneline` in the worktree and return the commit
|
/// Run `git log <base>..HEAD --oneline` in the worktree and return the commit
|
||||||
/// summaries, or `None` if git is unavailable or there are no new commits.
|
/// 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<Vec<String>> {
|
async fn get_worktree_commits(worktree_path: &str, base_branch: &str) -> Option<Vec<String>> {
|
||||||
@@ -1192,7 +1221,8 @@ mod tests {
|
|||||||
assert!(names.contains(&"remove_worktree"));
|
assert!(names.contains(&"remove_worktree"));
|
||||||
assert!(names.contains(&"get_editor_command"));
|
assert!(names.contains(&"get_editor_command"));
|
||||||
assert!(names.contains(&"report_completion"));
|
assert!(names.contains(&"report_completion"));
|
||||||
assert_eq!(tools.len(), 18);
|
assert!(names.contains(&"accept_story"));
|
||||||
|
assert_eq!(tools.len(), 19);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user