Story 46: Deterministic Story Mutations with Auto-Commit
- Add git_stage_and_commit() helper for deterministic commits - move_story_to_current() auto-commits on start_agent - accept_story auto-commits move to archived/ - New MCP tools: check_criterion, set_test_plan (total: 21) - create_story MCP always auto-commits - Tests for check_criterion and set_test_plan Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,10 @@ 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;
|
||||
use crate::http::workflow::{create_story_file, load_upcoming_stories, validate_story_dirs};
|
||||
use crate::http::workflow::{
|
||||
check_criterion_in_file, create_story_file, load_upcoming_stories, set_test_plan_in_file,
|
||||
validate_story_dirs,
|
||||
};
|
||||
use crate::worktree;
|
||||
use crate::io::story_metadata::{parse_front_matter, parse_unchecked_todos};
|
||||
use crate::workflow::{evaluate_acceptance_with_coverage, TestCaseResult, TestStatus};
|
||||
@@ -603,7 +606,7 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
||||
},
|
||||
{
|
||||
"name": "accept_story",
|
||||
"description": "Accept a story: moves it from current/ to archived/.",
|
||||
"description": "Accept a story: moves it from current/ to archived/ and auto-commits to master.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -614,6 +617,42 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
|
||||
},
|
||||
"required": ["story_id"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "check_criterion",
|
||||
"description": "Check off an acceptance criterion (- [ ] → - [x]) by 0-based index among unchecked items, then auto-commit to master. Use get_story_todos to see the current list of unchecked criteria.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"story_id": {
|
||||
"type": "string",
|
||||
"description": "Story identifier (filename stem, e.g. '28_my_story')"
|
||||
},
|
||||
"criterion_index": {
|
||||
"type": "integer",
|
||||
"description": "0-based index of the unchecked criterion to check off"
|
||||
}
|
||||
},
|
||||
"required": ["story_id", "criterion_index"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "set_test_plan",
|
||||
"description": "Update the test_plan front-matter field of a story file and auto-commit to master. Common values: 'pending', 'approved'.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"story_id": {
|
||||
"type": "string",
|
||||
"description": "Story identifier (filename stem, e.g. '28_my_story')"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "New value for the test_plan field (e.g. 'approved', 'pending')"
|
||||
}
|
||||
},
|
||||
"required": ["story_id", "status"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}),
|
||||
@@ -659,6 +698,9 @@ async fn handle_tools_call(
|
||||
"report_completion" => tool_report_completion(&args, ctx).await,
|
||||
// Lifecycle tools
|
||||
"accept_story" => tool_accept_story(&args, ctx),
|
||||
// Story mutation tools (auto-commit to master)
|
||||
"check_criterion" => tool_check_criterion(&args, ctx),
|
||||
"set_test_plan" => tool_set_test_plan(&args, ctx),
|
||||
_ => Err(format!("Unknown tool: {tool_name}")),
|
||||
};
|
||||
|
||||
@@ -690,10 +732,8 @@ fn tool_create_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
let acceptance_criteria: Option<Vec<String>> = args
|
||||
.get("acceptance_criteria")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok());
|
||||
let commit = args
|
||||
.get("commit")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
// MCP tool always auto-commits the new story file to master.
|
||||
let commit = true;
|
||||
|
||||
let root = ctx.state.get_project_root()?;
|
||||
let story_id = create_story_file(
|
||||
@@ -1067,7 +1107,45 @@ fn tool_accept_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
|
||||
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/."))
|
||||
Ok(format!(
|
||||
"Story '{story_id}' accepted, moved to archived/, and committed to master."
|
||||
))
|
||||
}
|
||||
|
||||
fn tool_check_criterion(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 criterion_index = args
|
||||
.get("criterion_index")
|
||||
.and_then(|v| v.as_u64())
|
||||
.ok_or("Missing required argument: criterion_index")? as usize;
|
||||
|
||||
let root = ctx.state.get_project_root()?;
|
||||
check_criterion_in_file(&root, story_id, criterion_index)?;
|
||||
|
||||
Ok(format!(
|
||||
"Criterion {criterion_index} checked for story '{story_id}'. Committed to master."
|
||||
))
|
||||
}
|
||||
|
||||
fn tool_set_test_plan(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 status = args
|
||||
.get("status")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Missing required argument: status")?;
|
||||
|
||||
let root = ctx.state.get_project_root()?;
|
||||
set_test_plan_in_file(&root, story_id, status)?;
|
||||
|
||||
Ok(format!(
|
||||
"test_plan set to '{status}' for story '{story_id}'. Committed to master."
|
||||
))
|
||||
}
|
||||
|
||||
/// Run `git log <base>..HEAD --oneline` in the worktree and return the commit
|
||||
@@ -1222,7 +1300,9 @@ mod tests {
|
||||
assert!(names.contains(&"get_editor_command"));
|
||||
assert!(names.contains(&"report_completion"));
|
||||
assert!(names.contains(&"accept_story"));
|
||||
assert_eq!(tools.len(), 19);
|
||||
assert!(names.contains(&"check_criterion"));
|
||||
assert!(names.contains(&"set_test_plan"));
|
||||
assert_eq!(tools.len(), 21);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1253,11 +1333,32 @@ mod tests {
|
||||
#[test]
|
||||
fn tool_create_story_and_list_upcoming() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// The MCP tool always commits, so we need a real git repo.
|
||||
std::process::Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["config", "user.name", "Test"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", "init"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
// Create a story
|
||||
// Create a story (always auto-commits in MCP handler)
|
||||
let result = tool_create_story(
|
||||
&json!({"name": "Test Story", "acceptance_criteria": ["AC1", "AC2"], "commit": false}),
|
||||
&json!({"name": "Test Story", "acceptance_criteria": ["AC1", "AC2"]}),
|
||||
&ctx,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
Reference in New Issue
Block a user