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

@@ -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"));
}
}