From 7f672cae5faf9df57bea4dc377c78d504891e965 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 20 Feb 2026 16:21:30 +0000 Subject: [PATCH] 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 --- .../50_unified_current_work_directory.md | 30 ++ server/src/agents.rs | 264 +++++++++++++++--- server/src/http/mcp.rs | 4 +- server/src/http/workflow.rs | 80 ++++-- 4 files changed, 315 insertions(+), 63 deletions(-) create mode 100644 .story_kit/stories/current/50_unified_current_work_directory.md diff --git a/.story_kit/stories/current/50_unified_current_work_directory.md b/.story_kit/stories/current/50_unified_current_work_directory.md new file mode 100644 index 0000000..92f2d46 --- /dev/null +++ b/.story_kit/stories/current/50_unified_current_work_directory.md @@ -0,0 +1,30 @@ +--- +name: Unified Current Work Directory +test_plan: approved +--- + +# Story 50: Unified Current Work Directory + +## User Story + +As a developer, I want a single `.story_kit/current/` directory (outside of `stories/`) that holds whatever work items agents are actively working on — stories, bugs, or spikes — so I can always see at a glance what coders are doing. + +## Acceptance Criteria + +- [ ] New top-level `.story_kit/current/` directory replaces `.story_kit/stories/current/` +- [ ] `start_agent` moves work items into `.story_kit/current/` regardless of type (story, bug, spike) +- [ ] `accept_story` moves from `.story_kit/current/` to `.story_kit/stories/archived/` +- [ ] `close_bug` moves from `.story_kit/current/` to `.story_kit/bugs/archive/` +- [ ] All existing references to `.story_kit/stories/current/` are updated (server code, tests, MCP tools) +- [ ] Migrate any files currently in `.story_kit/stories/current/` to `.story_kit/current/` +- [ ] Auto-commits use deterministic messages for all moves +- [ ] Integration test: full story lifecycle — create_story → start_agent (moves to current/) → check_criterion → accept_story (moves to stories/archived/) +- [ ] Integration test: full bug lifecycle — create_bug → start_agent (moves to current/) → close_bug (moves to bugs/archive/) +- [ ] Integration test: full spike lifecycle — start_agent (moves to current/) → completion (moves back or archives) +- [ ] All deterministic MCP tools (`create_story`, `accept_story`, `close_bug`, `check_criterion`, `set_test_plan`, `start_agent`) resolve paths correctly against the new directory layout +- [ ] `list_current` MCP tool (or update `list_agents`) shows all items in `.story_kit/current/` with their type (story/bug/spike) +- [ ] All agent prompts in `.story_kit/project.toml` (supervisor, coders) updated to reference correct directory paths and workflow steps + +## Out of Scope + +- UI changes to display current work items diff --git a/server/src/agents.rs b/server/src/agents.rs index 9311415..1bde91d 100644 --- a/server/src/agents.rs +++ b/server/src/agents.rs @@ -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(¤t_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, ¤t_path) - .map_err(|e| format!("Failed to move story '{story_id}' to current/: {e}"))?; + std::fs::rename(&source_path, ¤t_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(¤t_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(¤t).unwrap(); + fs::create_dir_all(¤t_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(¤t).unwrap(); + let current_dir = repo.join(".story_kit/current"); + fs::create_dir_all(¤t_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(¤t_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(¤t_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] diff --git a/server/src/http/mcp.rs b/server/src/http/mcp.rs index 154956c..75c5540 100644 --- a/server/src/http/mcp.rs +++ b/server/src/http/mcp.rs @@ -781,7 +781,7 @@ fn tool_get_story_todos(args: &Value, ctx: &AppContext) -> Result Result, Str fn load_current_story_metadata(ctx: &AppContext) -> Result, String> { let root = ctx.state.get_project_root()?; - let current_dir = root.join(".story_kit").join("stories").join("current"); + let current_dir = root.join(".story_kit").join("current"); if !current_dir.exists() { return Ok(Vec::new()); @@ -513,7 +513,7 @@ impl WorkflowApi { #[oai(path = "/workflow/todos", method = "get")] async fn story_todos(&self) -> OpenApiResult> { let root = self.ctx.state.get_project_root().map_err(bad_request)?; - let current_dir = root.join(".story_kit").join("stories").join("current"); + let current_dir = root.join(".story_kit").join("current"); if !current_dir.exists() { return Ok(Json(TodoListResponse { @@ -699,15 +699,18 @@ fn git_commit_story_file(root: &Path, filepath: &Path, story_id: &str) -> Result git_stage_and_commit(root, &[filepath], &msg) } -/// Locate a story file by searching current/ then upcoming/. +/// Locate a story file by searching .story_kit/current/ then stories/upcoming/. fn find_story_file(project_root: &Path, story_id: &str) -> Result { - 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); - } + // Check unified current/ directory first + let current_path = project_root.join(".story_kit").join("current").join(&filename); + if current_path.exists() { + return Ok(current_path); + } + // Fall back to stories/upcoming/ + let upcoming_path = project_root.join(".story_kit").join("stories").join("upcoming").join(&filename); + if upcoming_path.exists() { + return Ok(upcoming_path); } Err(format!( "Story '{story_id}' not found in current/ or upcoming/." @@ -852,11 +855,12 @@ fn slugify_name(name: &str) -> String { } fn next_story_number(root: &std::path::Path) -> Result { - let base = root.join(".story_kit").join("stories"); + let stories_base = root.join(".story_kit").join("stories"); let mut max_num: u32 = 0; - for subdir in &["upcoming", "current", "archived"] { - let dir = base.join(subdir); + // Scan stories/upcoming/ and stories/archived/ for story numbers + for subdir in &["upcoming", "archived"] { + let dir = stories_base.join(subdir); if !dir.exists() { continue; } @@ -866,7 +870,24 @@ fn next_story_number(root: &std::path::Path) -> Result { let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?; let name = entry.file_name(); let name_str = name.to_string_lossy(); - // Extract leading digits from filename + let num_str: String = name_str.chars().take_while(|c| c.is_ascii_digit()).collect(); + if let Ok(n) = num_str.parse::() + && n > max_num + { + max_num = n; + } + } + } + + // Also scan unified .story_kit/current/ for story numbers + let current_dir = root.join(".story_kit").join("current"); + if current_dir.exists() { + for entry in + fs::read_dir(¤t_dir).map_err(|e| format!("Failed to read current directory: {e}"))? + { + let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?; + let name = entry.file_name(); + let name_str = name.to_string_lossy(); let num_str: String = name_str.chars().take_while(|c| c.is_ascii_digit()).collect(); if let Ok(n) = num_str.parse::() && n > max_num @@ -882,11 +903,16 @@ fn next_story_number(root: &std::path::Path) -> Result { pub fn validate_story_dirs( root: &std::path::Path, ) -> Result, String> { - let base = root.join(".story_kit").join("stories"); let mut results = Vec::new(); - for subdir in &["current", "upcoming"] { - let dir = base.join(subdir); + // Directories to validate: unified current/ + stories/upcoming/ + let dirs_to_validate: Vec = vec![ + root.join(".story_kit").join("current"), + root.join(".story_kit").join("stories").join("upcoming"), + ]; + + for dir in &dirs_to_validate { + let subdir = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); if !dir.exists() { continue; } @@ -1120,7 +1146,7 @@ mod tests { #[test] fn validate_story_dirs_valid_files() { let tmp = tempfile::tempdir().unwrap(); - let current = tmp.path().join(".story_kit/stories/current"); + let current = tmp.path().join(".story_kit/current"); let upcoming = tmp.path().join(".story_kit/stories/upcoming"); fs::create_dir_all(¤t).unwrap(); fs::create_dir_all(&upcoming).unwrap(); @@ -1144,7 +1170,7 @@ mod tests { #[test] fn validate_story_dirs_missing_front_matter() { let tmp = tempfile::tempdir().unwrap(); - let current = tmp.path().join(".story_kit/stories/current"); + let current = tmp.path().join(".story_kit/current"); fs::create_dir_all(¤t).unwrap(); fs::write(current.join("28_todos.md"), "# No front matter\n").unwrap(); @@ -1157,7 +1183,7 @@ mod tests { #[test] fn validate_story_dirs_missing_required_fields() { let tmp = tempfile::tempdir().unwrap(); - let current = tmp.path().join(".story_kit/stories/current"); + let current = tmp.path().join(".story_kit/current"); fs::create_dir_all(¤t).unwrap(); fs::write(current.join("28_todos.md"), "---\n---\n# Story\n").unwrap(); @@ -1172,7 +1198,7 @@ mod tests { #[test] fn validate_story_dirs_missing_test_plan_only() { let tmp = tempfile::tempdir().unwrap(); - let current = tmp.path().join(".story_kit/stories/current"); + let current = tmp.path().join(".story_kit/current"); fs::create_dir_all(¤t).unwrap(); fs::write( current.join("28_todos.md"), @@ -1244,7 +1270,7 @@ mod tests { fn next_story_number_scans_all_dirs() { let tmp = tempfile::tempdir().unwrap(); let upcoming = tmp.path().join(".story_kit/stories/upcoming"); - let current = tmp.path().join(".story_kit/stories/current"); + let current = tmp.path().join(".story_kit/current"); let archived = tmp.path().join(".story_kit/stories/archived"); fs::create_dir_all(&upcoming).unwrap(); fs::create_dir_all(¤t).unwrap(); @@ -1354,7 +1380,7 @@ mod tests { 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"); + let current = tmp.path().join(".story_kit/current"); fs::create_dir_all(¤t).unwrap(); let filepath = current.join("1_test.md"); fs::write(&filepath, story_with_criteria(3)).unwrap(); @@ -1381,7 +1407,7 @@ mod tests { 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"); + let current = tmp.path().join(".story_kit/current"); fs::create_dir_all(¤t).unwrap(); let filepath = current.join("2_test.md"); fs::write(&filepath, story_with_criteria(3)).unwrap(); @@ -1408,7 +1434,7 @@ mod tests { 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"); + let current = tmp.path().join(".story_kit/current"); fs::create_dir_all(¤t).unwrap(); let filepath = current.join("3_test.md"); fs::write(&filepath, story_with_criteria(2)).unwrap(); @@ -1434,7 +1460,7 @@ mod tests { 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"); + let current = tmp.path().join(".story_kit/current"); fs::create_dir_all(¤t).unwrap(); let filepath = current.join("4_test.md"); fs::write( @@ -1464,7 +1490,7 @@ mod tests { 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"); + let current = tmp.path().join(".story_kit/current"); fs::create_dir_all(¤t).unwrap(); let filepath = current.join("5_test.md"); fs::write( @@ -1491,7 +1517,7 @@ mod tests { #[test] fn find_story_file_searches_current_then_upcoming() { let tmp = tempfile::tempdir().unwrap(); - let current = tmp.path().join(".story_kit/stories/current"); + let current = tmp.path().join(".story_kit/current"); let upcoming = tmp.path().join(".story_kit/stories/upcoming"); fs::create_dir_all(¤t).unwrap(); fs::create_dir_all(&upcoming).unwrap();