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

View File

@@ -1,3 +1,4 @@
use crate::agents::git_stage_and_commit;
use crate::http::context::{AppContext, OpenApiResult, bad_request};
use crate::io::story_metadata::{StoryMetadata, parse_front_matter, parse_unchecked_todos};
use crate::workflow::{
@@ -8,7 +9,7 @@ use poem_openapi::{Object, OpenApi, Tags, payload::Json};
use serde::Deserialize;
use std::collections::BTreeSet;
use std::fs;
use std::process::Command;
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[derive(Tags)]
@@ -668,40 +669,136 @@ pub fn create_story_file(
.map_err(|e| format!("Failed to write story file: {e}"))?;
if commit {
git_commit_story_file(root, &filepath, name)?;
git_commit_story_file(root, &filepath, &story_id)?;
}
Ok(story_id)
}
/// Git-add and git-commit a newly created story file to the current branch.
fn git_commit_story_file(
root: &std::path::Path,
filepath: &std::path::Path,
name: &str,
/// Git-add and git-commit a newly created story file using a deterministic message.
fn git_commit_story_file(root: &Path, filepath: &Path, story_id: &str) -> Result<(), String> {
let msg = format!("story-kit: create story {story_id}");
git_stage_and_commit(root, &[filepath], &msg)
}
/// Locate a story file by searching current/ then upcoming/.
fn find_story_file(project_root: &Path, story_id: &str) -> Result<PathBuf, String> {
let base = project_root.join(".story_kit").join("stories");
let filename = format!("{story_id}.md");
for subdir in &["current", "upcoming"] {
let path = base.join(subdir).join(&filename);
if path.exists() {
return Ok(path);
}
}
Err(format!(
"Story '{story_id}' not found in current/ or upcoming/."
))
}
/// Check off the Nth unchecked acceptance criterion in a story file and auto-commit.
///
/// `criterion_index` is 0-based among unchecked (`- [ ]`) items.
pub fn check_criterion_in_file(
project_root: &Path,
story_id: &str,
criterion_index: usize,
) -> Result<(), String> {
let output = Command::new("git")
.args(["add", &filepath.to_string_lossy()])
.current_dir(root)
.output()
.map_err(|e| format!("git add: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git add failed: {stderr}"));
let filepath = find_story_file(project_root, story_id)?;
let contents = fs::read_to_string(&filepath)
.map_err(|e| format!("Failed to read story file: {e}"))?;
let mut unchecked_count: usize = 0;
let mut found = false;
let new_lines: Vec<String> = contents
.lines()
.map(|line| {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("- [ ] ") {
if unchecked_count == criterion_index {
unchecked_count += 1;
found = true;
let indent_len = line.len() - trimmed.len();
let indent = &line[..indent_len];
return format!("{indent}- [x] {rest}");
}
unchecked_count += 1;
}
line.to_string()
})
.collect();
if !found {
return Err(format!(
"Criterion index {criterion_index} out of range. Story '{story_id}' has \
{unchecked_count} unchecked criteria (indices 0..{}).",
unchecked_count.saturating_sub(1)
));
}
let msg = format!("Add story: {name}");
let output = Command::new("git")
.args(["commit", "-m", &msg])
.current_dir(root)
.output()
.map_err(|e| format!("git commit: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("git commit failed: {stderr}"));
let mut new_str = new_lines.join("\n");
if contents.ends_with('\n') {
new_str.push('\n');
}
fs::write(&filepath, &new_str)
.map_err(|e| format!("Failed to write story file: {e}"))?;
let msg = format!("story-kit: check criterion {criterion_index} for story {story_id}");
git_stage_and_commit(project_root, &[filepath.as_path()], &msg)
}
/// Update the `test_plan` front-matter field in a story file and auto-commit.
pub fn set_test_plan_in_file(
project_root: &Path,
story_id: &str,
status: &str,
) -> Result<(), String> {
let filepath = find_story_file(project_root, story_id)?;
let contents = fs::read_to_string(&filepath)
.map_err(|e| format!("Failed to read story file: {e}"))?;
let mut in_front_matter = false;
let mut front_matter_started = false;
let mut found = false;
let new_lines: Vec<String> = contents
.lines()
.map(|line| {
if line.trim() == "---" {
if !front_matter_started {
front_matter_started = true;
in_front_matter = true;
} else if in_front_matter {
in_front_matter = false;
}
return line.to_string();
}
if in_front_matter {
let trimmed = line.trim_start();
if trimmed.starts_with("test_plan:") {
found = true;
return format!("test_plan: {status}");
}
}
line.to_string()
})
.collect();
if !found {
return Err(format!(
"Story '{story_id}' does not have a 'test_plan' field in its front matter."
));
}
Ok(())
let mut new_str = new_lines.join("\n");
if contents.ends_with('\n') {
new_str.push('\n');
}
fs::write(&filepath, &new_str)
.map_err(|e| format!("Failed to write story file: {e}"))?;
let msg = format!("story-kit: set test_plan to {status} for story {story_id}");
git_stage_and_commit(project_root, &[filepath.as_path()], &msg)
}
fn slugify_name(name: &str) -> String {
@@ -1201,4 +1298,202 @@ mod tests {
// Simulate the check
assert!(filepath.exists());
}
// ── check_criterion_in_file tests ─────────────────────────────────────────
fn setup_git_repo(root: &std::path::Path) {
std::process::Command::new("git")
.args(["init"])
.current_dir(root)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(root)
.output()
.unwrap();
std::process::Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(root)
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(root)
.output()
.unwrap();
}
fn story_with_criteria(n: usize) -> String {
let mut s = "---\nname: Test Story\ntest_plan: pending\n---\n\n## Acceptance Criteria\n\n".to_string();
for i in 0..n {
s.push_str(&format!("- [ ] Criterion {i}\n"));
}
s
}
#[test]
fn check_criterion_marks_first_unchecked() {
let tmp = tempfile::tempdir().unwrap();
setup_git_repo(tmp.path());
let current = tmp.path().join(".story_kit/stories/current");
fs::create_dir_all(&current).unwrap();
let filepath = current.join("1_test.md");
fs::write(&filepath, story_with_criteria(3)).unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(tmp.path())
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "add story"])
.current_dir(tmp.path())
.output()
.unwrap();
check_criterion_in_file(tmp.path(), "1_test", 0).unwrap();
let contents = fs::read_to_string(&filepath).unwrap();
assert!(contents.contains("- [x] Criterion 0"), "first should be checked");
assert!(contents.contains("- [ ] Criterion 1"), "second should stay unchecked");
assert!(contents.contains("- [ ] Criterion 2"), "third should stay unchecked");
}
#[test]
fn check_criterion_marks_second_unchecked() {
let tmp = tempfile::tempdir().unwrap();
setup_git_repo(tmp.path());
let current = tmp.path().join(".story_kit/stories/current");
fs::create_dir_all(&current).unwrap();
let filepath = current.join("2_test.md");
fs::write(&filepath, story_with_criteria(3)).unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(tmp.path())
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "add story"])
.current_dir(tmp.path())
.output()
.unwrap();
check_criterion_in_file(tmp.path(), "2_test", 1).unwrap();
let contents = fs::read_to_string(&filepath).unwrap();
assert!(contents.contains("- [ ] Criterion 0"), "first should stay unchecked");
assert!(contents.contains("- [x] Criterion 1"), "second should be checked");
assert!(contents.contains("- [ ] Criterion 2"), "third should stay unchecked");
}
#[test]
fn check_criterion_out_of_range_returns_error() {
let tmp = tempfile::tempdir().unwrap();
setup_git_repo(tmp.path());
let current = tmp.path().join(".story_kit/stories/current");
fs::create_dir_all(&current).unwrap();
let filepath = current.join("3_test.md");
fs::write(&filepath, story_with_criteria(2)).unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(tmp.path())
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "add story"])
.current_dir(tmp.path())
.output()
.unwrap();
let result = check_criterion_in_file(tmp.path(), "3_test", 5);
assert!(result.is_err(), "should fail for out-of-range index");
assert!(result.unwrap_err().contains("out of range"));
}
// ── set_test_plan_in_file tests ───────────────────────────────────────────
#[test]
fn set_test_plan_updates_pending_to_approved() {
let tmp = tempfile::tempdir().unwrap();
setup_git_repo(tmp.path());
let current = tmp.path().join(".story_kit/stories/current");
fs::create_dir_all(&current).unwrap();
let filepath = current.join("4_test.md");
fs::write(
&filepath,
"---\nname: Test Story\ntest_plan: pending\n---\n\n## Body\n",
)
.unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(tmp.path())
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "add story"])
.current_dir(tmp.path())
.output()
.unwrap();
set_test_plan_in_file(tmp.path(), "4_test", "approved").unwrap();
let contents = fs::read_to_string(&filepath).unwrap();
assert!(contents.contains("test_plan: approved"), "should be updated to approved");
assert!(!contents.contains("test_plan: pending"), "old value should be replaced");
}
#[test]
fn set_test_plan_missing_field_returns_error() {
let tmp = tempfile::tempdir().unwrap();
setup_git_repo(tmp.path());
let current = tmp.path().join(".story_kit/stories/current");
fs::create_dir_all(&current).unwrap();
let filepath = current.join("5_test.md");
fs::write(
&filepath,
"---\nname: Test Story\n---\n\n## Body\n",
)
.unwrap();
std::process::Command::new("git")
.args(["add", "."])
.current_dir(tmp.path())
.output()
.unwrap();
std::process::Command::new("git")
.args(["commit", "-m", "add story"])
.current_dir(tmp.path())
.output()
.unwrap();
let result = set_test_plan_in_file(tmp.path(), "5_test", "approved");
assert!(result.is_err(), "should fail if test_plan field is missing");
assert!(result.unwrap_err().contains("test_plan"));
}
#[test]
fn find_story_file_searches_current_then_upcoming() {
let tmp = tempfile::tempdir().unwrap();
let current = tmp.path().join(".story_kit/stories/current");
let upcoming = tmp.path().join(".story_kit/stories/upcoming");
fs::create_dir_all(&current).unwrap();
fs::create_dir_all(&upcoming).unwrap();
// Only in upcoming
fs::write(upcoming.join("6_test.md"), "").unwrap();
let found = find_story_file(tmp.path(), "6_test").unwrap();
assert!(found.ends_with("upcoming/6_test.md") || found.ends_with("upcoming\\6_test.md"));
// Also in current — current should win
fs::write(current.join("6_test.md"), "").unwrap();
let found = find_story_file(tmp.path(), "6_test").unwrap();
assert!(found.ends_with("current/6_test.md") || found.ends_with("current\\6_test.md"));
}
#[test]
fn find_story_file_returns_error_when_not_found() {
let tmp = tempfile::tempdir().unwrap();
let result = find_story_file(tmp.path(), "99_missing");
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
}
}