Story 50: Unified Current Work Directory

- Move current/ to .story_kit/current/ (out of stories/)
- Type-aware routing for bugs, spikes, stories
- close_bug_to_archive() for bug lifecycle
- All path references updated across agents.rs, workflow.rs, mcp.rs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-20 16:21:30 +00:00
parent b689466a61
commit 7f672cae5f
4 changed files with 315 additions and 63 deletions

View File

@@ -692,54 +692,96 @@ pub fn git_stage_and_commit(
Ok(())
}
/// Move a story file from upcoming/ to current/ and auto-commit to master.
/// Determine the work item type from its ID.
/// Returns "bug" for `bug-*` IDs, "spike" for `spike-*` IDs, "story" otherwise.
fn item_type_from_id(item_id: &str) -> &'static str {
if item_id.starts_with("bug-") {
"bug"
} else if item_id.starts_with("spike-") {
"spike"
} else {
"story"
}
}
/// Return the source directory path for a work item based on its type.
fn item_source_dir(project_root: &Path, item_id: &str) -> PathBuf {
let sk = project_root.join(".story_kit");
match item_type_from_id(item_id) {
"bug" => sk.join("bugs"),
"spike" => sk.join("spikes"),
_ => sk.join("stories").join("upcoming"),
}
}
/// Return the archive directory path for a work item based on its type.
fn item_archive_dir(project_root: &Path, item_id: &str) -> PathBuf {
let sk = project_root.join(".story_kit");
match item_type_from_id(item_id) {
"bug" => sk.join("bugs").join("archive"),
"spike" => sk.join("spikes").join("archive"),
_ => sk.join("stories").join("archived"),
}
}
/// Move a work item (story, bug, or spike) to the unified `.story_kit/current/` directory.
///
/// 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).
/// Idempotent: if the item is already in `current/`, returns Ok without committing.
/// If the item is not found in its source directory, logs a warning and returns Ok.
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"));
let current_dir = project_root.join(".story_kit").join("current");
let current_path = current_dir.join(format!("{story_id}.md"));
if current_path.exists() {
// Already in current/ — idempotent, nothing to do.
return Ok(());
}
if !upcoming_path.exists() {
let source_dir = item_source_dir(project_root, story_id);
let source_path = source_dir.join(format!("{story_id}.md"));
if !source_path.exists() {
eprintln!(
"[lifecycle] Story '{story_id}' not found in upcoming/; skipping move to current/"
"[lifecycle] Work item '{story_id}' not found in {}; skipping move to current/",
source_dir.display()
);
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}"))?;
.map_err(|e| format!("Failed to create .story_kit/current/ directory: {e}"))?;
std::fs::rename(&upcoming_path, &current_path)
.map_err(|e| format!("Failed to move story '{story_id}' to current/: {e}"))?;
std::fs::rename(&source_path, &current_path)
.map_err(|e| format!("Failed to move '{story_id}' to current/: {e}"))?;
eprintln!("[lifecycle] Moved story '{story_id}' from upcoming/ to current/");
eprintln!(
"[lifecycle] Moved '{story_id}' from {} to current/",
source_dir.display()
);
let msg = format!("story-kit: start story {story_id}");
let msg = format!("story-kit: start {story_id}");
git_stage_and_commit(
project_root,
&[current_path.as_path(), upcoming_path.as_path()],
&[current_path.as_path(), source_path.as_path()],
&msg,
)
}
/// Move a story file from current/ to archived/ (human accept action) and auto-commit.
/// Move a story from `.story_kit/current/` to `.story_kit/stories/archived/` 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.
/// * If the story is in `current/`, it is renamed to `stories/archived/` and committed.
/// * If the story is already in `stories/archived/`, this is a no-op (idempotent).
/// * If the story is not found in `current/` or `stories/archived/`, an error is returned.
pub fn move_story_to_archived(project_root: &Path, story_id: &str) -> Result<(), String> {
let stories_dir = project_root.join(".story_kit").join("stories");
let current_path = stories_dir.join("current").join(format!("{story_id}.md"));
let archived_path = stories_dir.join("archived").join(format!("{story_id}.md"));
let current_path = project_root
.join(".story_kit")
.join("current")
.join(format!("{story_id}.md"));
let archived_dir = project_root
.join(".story_kit")
.join("stories")
.join("archived");
let archived_path = archived_dir.join(format!("{story_id}.md"));
if archived_path.exists() {
// Already archived — idempotent, nothing to do.
@@ -747,12 +789,11 @@ pub fn move_story_to_archived(project_root: &Path, story_id: &str) -> Result<(),
}
if current_path.exists() {
let archived_dir = stories_dir.join("archived");
std::fs::create_dir_all(&archived_dir)
.map_err(|e| format!("Failed to create archived stories directory: {e}"))?;
.map_err(|e| format!("Failed to create stories/archived/ directory: {e}"))?;
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/");
eprintln!("[lifecycle] Moved story '{story_id}' from current/ to stories/archived/");
let msg = format!("story-kit: accept story {story_id}");
git_stage_and_commit(
@@ -768,6 +809,50 @@ pub fn move_story_to_archived(project_root: &Path, story_id: &str) -> Result<(),
))
}
/// Move a bug from `.story_kit/current/` to `.story_kit/bugs/archive/` and auto-commit.
///
/// * If the bug is in `current/`, it is moved to `bugs/archive/` and committed.
/// * If the bug is still in `bugs/` (never started), it is moved directly to `bugs/archive/`.
/// * If the bug is already in `bugs/archive/`, this is a no-op (idempotent).
/// * If the bug is not found anywhere, an error is returned.
pub fn close_bug_to_archive(project_root: &Path, bug_id: &str) -> Result<(), String> {
let sk = project_root.join(".story_kit");
let current_path = sk.join("current").join(format!("{bug_id}.md"));
let bugs_path = sk.join("bugs").join(format!("{bug_id}.md"));
let archive_dir = sk.join("bugs").join("archive");
let archive_path = archive_dir.join(format!("{bug_id}.md"));
if archive_path.exists() {
return Ok(());
}
let source_path = if current_path.exists() {
current_path.clone()
} else if bugs_path.exists() {
bugs_path.clone()
} else {
return Err(format!(
"Bug '{bug_id}' not found in current/ or bugs/. Cannot close bug."
));
};
std::fs::create_dir_all(&archive_dir)
.map_err(|e| format!("Failed to create bugs/archive/ directory: {e}"))?;
std::fs::rename(&source_path, &archive_path)
.map_err(|e| format!("Failed to move bug '{bug_id}' to archive: {e}"))?;
eprintln!(
"[lifecycle] Closed bug '{bug_id}' → bugs/archive/"
);
let msg = format!("story-kit: close bug {bug_id}");
git_stage_and_commit(
project_root,
&[archive_path.as_path(), source_path.as_path()],
&msg,
)
}
// ── Acceptance-gate helpers ───────────────────────────────────────────────────
/// Check whether the given directory has any uncommitted git changes.
@@ -1232,9 +1317,9 @@ mod tests {
init_git_repo(repo);
let upcoming = repo.join(".story_kit/stories/upcoming");
let current = repo.join(".story_kit/stories/current");
let current_dir = repo.join(".story_kit/current");
fs::create_dir_all(&upcoming).unwrap();
fs::create_dir_all(&current).unwrap();
fs::create_dir_all(&current_dir).unwrap();
let story_file = upcoming.join("10_my_story.md");
fs::write(&story_file, "---\nname: Test\ntest_plan: pending\n---\n").unwrap();
@@ -1254,8 +1339,8 @@ mod tests {
assert!(!story_file.exists(), "upcoming file should be gone");
assert!(
current.join("10_my_story.md").exists(),
"current file should exist"
current_dir.join("10_my_story.md").exists(),
"current/ file should exist"
);
}
@@ -1268,10 +1353,10 @@ mod tests {
let repo = tmp.path();
init_git_repo(repo);
let current = repo.join(".story_kit/stories/current");
fs::create_dir_all(&current).unwrap();
let current_dir = repo.join(".story_kit/current");
fs::create_dir_all(&current_dir).unwrap();
fs::write(
current.join("11_my_story.md"),
current_dir.join("11_my_story.md"),
"---\nname: Test\ntest_plan: pending\n---\n",
)
.unwrap();
@@ -1279,7 +1364,7 @@ mod tests {
// 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());
assert!(current_dir.join("11_my_story.md").exists());
}
#[test]
@@ -1295,6 +1380,117 @@ mod tests {
assert!(result.is_ok(), "should return Ok when story is not found");
}
#[test]
fn move_bug_to_current_moves_from_bugs_dir() {
use std::fs;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
let bugs_dir = repo.join(".story_kit/bugs");
let current_dir = repo.join(".story_kit/current");
fs::create_dir_all(&bugs_dir).unwrap();
fs::create_dir_all(&current_dir).unwrap();
let bug_file = bugs_dir.join("bug-1-test.md");
fs::write(&bug_file, "# Bug 1\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add bug"])
.current_dir(repo)
.output()
.unwrap();
move_story_to_current(repo, "bug-1-test").unwrap();
assert!(!bug_file.exists(), "bugs/ file should be gone");
assert!(
current_dir.join("bug-1-test.md").exists(),
"current/ file should exist"
);
}
#[test]
fn close_bug_moves_from_current_to_archive() {
use std::fs;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
let current_dir = repo.join(".story_kit/current");
fs::create_dir_all(&current_dir).unwrap();
let bug_in_current = current_dir.join("bug-2-test.md");
fs::write(&bug_in_current, "# Bug 2\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add bug to current"])
.current_dir(repo)
.output()
.unwrap();
close_bug_to_archive(repo, "bug-2-test").unwrap();
let archive_path = repo.join(".story_kit/bugs/archive/bug-2-test.md");
assert!(!bug_in_current.exists(), "current/ file should be gone");
assert!(archive_path.exists(), "archive file should exist");
}
#[test]
fn close_bug_moves_from_bugs_dir_when_not_started() {
use std::fs;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let repo = tmp.path();
init_git_repo(repo);
let bugs_dir = repo.join(".story_kit/bugs");
fs::create_dir_all(&bugs_dir).unwrap();
let bug_file = bugs_dir.join("bug-3-test.md");
fs::write(&bug_file, "# Bug 3\n").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add bug"])
.current_dir(repo)
.output()
.unwrap();
close_bug_to_archive(repo, "bug-3-test").unwrap();
let archive_path = repo.join(".story_kit/bugs/archive/bug-3-test.md");
assert!(!bug_file.exists(), "bugs/ file should be gone");
assert!(archive_path.exists(), "archive file should exist");
}
#[test]
fn item_type_from_id_detects_types() {
assert_eq!(item_type_from_id("bug-1-test"), "bug");
assert_eq!(item_type_from_id("spike-1-research"), "spike");
assert_eq!(item_type_from_id("50_my_story"), "story");
assert_eq!(item_type_from_id("1_simple"), "story");
}
// ── git_stage_and_commit tests ─────────────────────────────────────────────
#[test]