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:
Dave
2026-02-20 15:31:13 +00:00
parent ee844c0fa9
commit 928cc64bfa
3 changed files with 643 additions and 37 deletions

View File

@@ -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();