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

@@ -203,6 +203,9 @@ impl AgentPool {
status: "pending".to_string(),
});
// Move story from upcoming/ to current/ and auto-commit before creating the worktree.
move_story_to_current(project_root, story_id)?;
// Create worktree
let wt_info = worktree::create_worktree(project_root, story_id, &config, self.port).await?;
@@ -578,6 +581,7 @@ impl AgentPool {
}
/// Return the port this server is running on.
#[allow(dead_code)]
pub fn port(&self) -> u16 {
self.port
}
@@ -652,9 +656,84 @@ impl AgentPool {
}
}
/// Move a story file from current/ to archived/ (human accept action).
/// Stage one or more file paths and create a deterministic commit in the given git root.
///
/// * If the story is in current/, it is renamed to archived/.
/// Pass deleted paths too so git stages their removal alongside any new files.
pub fn git_stage_and_commit(
git_root: &Path,
paths: &[&Path],
message: &str,
) -> Result<(), String> {
let mut add_cmd = Command::new("git");
add_cmd.arg("add").current_dir(git_root);
for path in paths {
add_cmd.arg(path.to_string_lossy().as_ref());
}
let output = add_cmd.output().map_err(|e| format!("git add: {e}"))?;
if !output.status.success() {
return Err(format!(
"git add failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
let output = Command::new("git")
.args(["commit", "-m", message])
.current_dir(git_root)
.output()
.map_err(|e| format!("git commit: {e}"))?;
if !output.status.success() {
return Err(format!(
"git commit failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
Ok(())
}
/// Move a story file from upcoming/ to current/ and auto-commit to master.
///
/// Idempotent: if the story is already in current/, returns Ok without committing.
/// If the story is not found in upcoming/, logs a warning and returns Ok (e.g. if
/// the user moved it manually before calling start_agent).
pub fn move_story_to_current(project_root: &Path, story_id: &str) -> Result<(), String> {
let stories_dir = project_root.join(".story_kit").join("stories");
let upcoming_path = stories_dir.join("upcoming").join(format!("{story_id}.md"));
let current_path = stories_dir.join("current").join(format!("{story_id}.md"));
if current_path.exists() {
// Already in current/ — idempotent, nothing to do.
return Ok(());
}
if !upcoming_path.exists() {
eprintln!(
"[lifecycle] Story '{story_id}' not found in upcoming/; skipping move to current/"
);
return Ok(());
}
let current_dir = stories_dir.join("current");
std::fs::create_dir_all(&current_dir)
.map_err(|e| format!("Failed to create current stories directory: {e}"))?;
std::fs::rename(&upcoming_path, &current_path)
.map_err(|e| format!("Failed to move story '{story_id}' to current/: {e}"))?;
eprintln!("[lifecycle] Moved story '{story_id}' from upcoming/ to current/");
let msg = format!("story-kit: start story {story_id}");
git_stage_and_commit(
project_root,
&[current_path.as_path(), upcoming_path.as_path()],
&msg,
)
}
/// Move a story file from current/ to archived/ (human accept action) and auto-commit.
///
/// * If the story is in current/, it is renamed to archived/ and committed.
/// * 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> {
@@ -674,6 +753,13 @@ pub fn move_story_to_archived(project_root: &Path, story_id: &str) -> Result<(),
std::fs::rename(&current_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/");
let msg = format!("story-kit: accept story {story_id}");
git_stage_and_commit(
project_root,
&[archived_path.as_path(), current_path.as_path()],
&msg,
)?;
return Ok(());
}
@@ -1110,4 +1196,128 @@ mod tests {
"expected 'uncommitted' in: {msg}"
);
}
// ── move_story_to_current tests ────────────────────────────────────────────
fn init_git_repo(repo: &std::path::Path) {
Command::new("git")
.args(["init"])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "test@test.com"])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Test"])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(repo)
.output()
.unwrap();
}
#[test]
fn move_story_to_current_moves_file_and_commits() {
use std::fs;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
let upcoming = repo.join(".story_kit/stories/upcoming");
let current = repo.join(".story_kit/stories/current");
fs::create_dir_all(&upcoming).unwrap();
fs::create_dir_all(&current).unwrap();
let story_file = upcoming.join("10_my_story.md");
fs::write(&story_file, "---\nname: Test\ntest_plan: pending\n---\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add story"])
.current_dir(repo)
.output()
.unwrap();
move_story_to_current(repo, "10_my_story").unwrap();
assert!(!story_file.exists(), "upcoming file should be gone");
assert!(
current.join("10_my_story.md").exists(),
"current file should exist"
);
}
#[test]
fn move_story_to_current_is_idempotent_when_already_current() {
use std::fs;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
let current = repo.join(".story_kit/stories/current");
fs::create_dir_all(&current).unwrap();
fs::write(
current.join("11_my_story.md"),
"---\nname: Test\ntest_plan: pending\n---\n",
)
.unwrap();
// Should succeed without error even though there's nothing to move
move_story_to_current(repo, "11_my_story").unwrap();
assert!(current.join("11_my_story.md").exists());
}
#[test]
fn move_story_to_current_noop_when_not_in_upcoming() {
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
// Story doesn't exist anywhere — should return Ok (lenient)
let result = move_story_to_current(repo, "99_missing");
assert!(result.is_ok(), "should return Ok when story is not found");
}
// ── git_stage_and_commit tests ─────────────────────────────────────────────
#[test]
fn git_stage_and_commit_creates_commit() {
use std::fs;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
let file = repo.join("hello.txt");
fs::write(&file, "hello").unwrap();
git_stage_and_commit(repo, &[file.as_path()], "story-kit: test commit").unwrap();
// Verify the commit exists
let output = Command::new("git")
.args(["log", "--oneline", "-1"])
.current_dir(repo)
.output()
.unwrap();
let log = String::from_utf8_lossy(&output.stdout);
assert!(log.contains("story-kit: test commit"), "commit should appear in log: {log}");
}
}