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:
@@ -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
|
||||||
@@ -692,54 +692,96 @@ pub fn git_stage_and_commit(
|
|||||||
Ok(())
|
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.
|
/// Idempotent: if the item 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
|
/// If the item is not found in its source directory, logs a warning and returns Ok.
|
||||||
/// the user moved it manually before calling start_agent).
|
|
||||||
pub fn move_story_to_current(project_root: &Path, story_id: &str) -> Result<(), String> {
|
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 current_dir = project_root.join(".story_kit").join("current");
|
||||||
let upcoming_path = stories_dir.join("upcoming").join(format!("{story_id}.md"));
|
let current_path = current_dir.join(format!("{story_id}.md"));
|
||||||
let current_path = stories_dir.join("current").join(format!("{story_id}.md"));
|
|
||||||
|
|
||||||
if current_path.exists() {
|
if current_path.exists() {
|
||||||
// Already in current/ — idempotent, nothing to do.
|
// Already in current/ — idempotent, nothing to do.
|
||||||
return Ok(());
|
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!(
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let current_dir = stories_dir.join("current");
|
|
||||||
std::fs::create_dir_all(¤t_dir)
|
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)
|
std::fs::rename(&source_path, ¤t_path)
|
||||||
.map_err(|e| format!("Failed to move story '{story_id}' to current/: {e}"))?;
|
.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(
|
git_stage_and_commit(
|
||||||
project_root,
|
project_root,
|
||||||
&[current_path.as_path(), upcoming_path.as_path()],
|
&[current_path.as_path(), source_path.as_path()],
|
||||||
&msg,
|
&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 in `current/`, it is renamed to `stories/archived/` and committed.
|
||||||
/// * If the story is already in archived/, this is a no-op (idempotent).
|
/// * If the story is already in `stories/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 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> {
|
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 = project_root
|
||||||
let current_path = stories_dir.join("current").join(format!("{story_id}.md"));
|
.join(".story_kit")
|
||||||
let archived_path = stories_dir.join("archived").join(format!("{story_id}.md"));
|
.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() {
|
if archived_path.exists() {
|
||||||
// Already archived — idempotent, nothing to do.
|
// 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() {
|
if current_path.exists() {
|
||||||
let archived_dir = stories_dir.join("archived");
|
|
||||||
std::fs::create_dir_all(&archived_dir)
|
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)
|
std::fs::rename(¤t_path, &archived_path)
|
||||||
.map_err(|e| format!("Failed to move story '{story_id}' to archived/: {e}"))?;
|
.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}");
|
let msg = format!("story-kit: accept story {story_id}");
|
||||||
git_stage_and_commit(
|
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 ───────────────────────────────────────────────────
|
// ── Acceptance-gate helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
/// Check whether the given directory has any uncommitted git changes.
|
/// Check whether the given directory has any uncommitted git changes.
|
||||||
@@ -1232,9 +1317,9 @@ mod tests {
|
|||||||
init_git_repo(repo);
|
init_git_repo(repo);
|
||||||
|
|
||||||
let upcoming = repo.join(".story_kit/stories/upcoming");
|
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(&upcoming).unwrap();
|
||||||
fs::create_dir_all(¤t).unwrap();
|
fs::create_dir_all(¤t_dir).unwrap();
|
||||||
|
|
||||||
let story_file = upcoming.join("10_my_story.md");
|
let story_file = upcoming.join("10_my_story.md");
|
||||||
fs::write(&story_file, "---\nname: Test\ntest_plan: pending\n---\n").unwrap();
|
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!(!story_file.exists(), "upcoming file should be gone");
|
||||||
assert!(
|
assert!(
|
||||||
current.join("10_my_story.md").exists(),
|
current_dir.join("10_my_story.md").exists(),
|
||||||
"current file should exist"
|
"current/ file should exist"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1268,10 +1353,10 @@ mod tests {
|
|||||||
let repo = tmp.path();
|
let repo = tmp.path();
|
||||||
init_git_repo(repo);
|
init_git_repo(repo);
|
||||||
|
|
||||||
let current = repo.join(".story_kit/stories/current");
|
let current_dir = repo.join(".story_kit/current");
|
||||||
fs::create_dir_all(¤t).unwrap();
|
fs::create_dir_all(¤t_dir).unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
current.join("11_my_story.md"),
|
current_dir.join("11_my_story.md"),
|
||||||
"---\nname: Test\ntest_plan: pending\n---\n",
|
"---\nname: Test\ntest_plan: pending\n---\n",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1279,7 +1364,7 @@ mod tests {
|
|||||||
// Should succeed without error even though there's nothing to move
|
// Should succeed without error even though there's nothing to move
|
||||||
move_story_to_current(repo, "11_my_story").unwrap();
|
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]
|
#[test]
|
||||||
@@ -1295,6 +1380,117 @@ mod tests {
|
|||||||
assert!(result.is_ok(), "should return Ok when story is not found");
|
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 ─────────────────────────────────────────────
|
// ── git_stage_and_commit tests ─────────────────────────────────────────────
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -781,7 +781,7 @@ fn tool_get_story_todos(args: &Value, ctx: &AppContext) -> Result<String, String
|
|||||||
.ok_or("Missing required argument: story_id")?;
|
.ok_or("Missing required argument: story_id")?;
|
||||||
|
|
||||||
let root = ctx.state.get_project_root()?;
|
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");
|
||||||
let filepath = current_dir.join(format!("{story_id}.md"));
|
let filepath = current_dir.join(format!("{story_id}.md"));
|
||||||
|
|
||||||
if !filepath.exists() {
|
if !filepath.exists() {
|
||||||
@@ -1401,7 +1401,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn tool_get_story_todos_returns_unchecked() {
|
fn tool_get_story_todos_returns_unchecked() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let current_dir = tmp.path().join(".story_kit").join("stories").join("current");
|
let current_dir = tmp.path().join(".story_kit").join("current");
|
||||||
fs::create_dir_all(¤t_dir).unwrap();
|
fs::create_dir_all(¤t_dir).unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
current_dir.join("1_test.md"),
|
current_dir.join("1_test.md"),
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ pub fn load_upcoming_stories(ctx: &AppContext) -> Result<Vec<UpcomingStory>, Str
|
|||||||
|
|
||||||
fn load_current_story_metadata(ctx: &AppContext) -> Result<Vec<(String, StoryMetadata)>, String> {
|
fn load_current_story_metadata(ctx: &AppContext) -> Result<Vec<(String, StoryMetadata)>, String> {
|
||||||
let root = ctx.state.get_project_root()?;
|
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() {
|
if !current_dir.exists() {
|
||||||
return Ok(Vec::new());
|
return Ok(Vec::new());
|
||||||
@@ -513,7 +513,7 @@ impl WorkflowApi {
|
|||||||
#[oai(path = "/workflow/todos", method = "get")]
|
#[oai(path = "/workflow/todos", method = "get")]
|
||||||
async fn story_todos(&self) -> OpenApiResult<Json<TodoListResponse>> {
|
async fn story_todos(&self) -> OpenApiResult<Json<TodoListResponse>> {
|
||||||
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
|
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() {
|
if !current_dir.exists() {
|
||||||
return Ok(Json(TodoListResponse {
|
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)
|
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<PathBuf, String> {
|
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");
|
let filename = format!("{story_id}.md");
|
||||||
for subdir in &["current", "upcoming"] {
|
// Check unified current/ directory first
|
||||||
let path = base.join(subdir).join(&filename);
|
let current_path = project_root.join(".story_kit").join("current").join(&filename);
|
||||||
if path.exists() {
|
if current_path.exists() {
|
||||||
return Ok(path);
|
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!(
|
Err(format!(
|
||||||
"Story '{story_id}' not found in current/ or upcoming/."
|
"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<u32, String> {
|
fn next_story_number(root: &std::path::Path) -> Result<u32, String> {
|
||||||
let base = root.join(".story_kit").join("stories");
|
let stories_base = root.join(".story_kit").join("stories");
|
||||||
let mut max_num: u32 = 0;
|
let mut max_num: u32 = 0;
|
||||||
|
|
||||||
for subdir in &["upcoming", "current", "archived"] {
|
// Scan stories/upcoming/ and stories/archived/ for story numbers
|
||||||
let dir = base.join(subdir);
|
for subdir in &["upcoming", "archived"] {
|
||||||
|
let dir = stories_base.join(subdir);
|
||||||
if !dir.exists() {
|
if !dir.exists() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -866,7 +870,24 @@ fn next_story_number(root: &std::path::Path) -> Result<u32, String> {
|
|||||||
let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
|
let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
|
||||||
let name = entry.file_name();
|
let name = entry.file_name();
|
||||||
let name_str = name.to_string_lossy();
|
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::<u32>()
|
||||||
|
&& 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();
|
let num_str: String = name_str.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||||
if let Ok(n) = num_str.parse::<u32>()
|
if let Ok(n) = num_str.parse::<u32>()
|
||||||
&& n > max_num
|
&& n > max_num
|
||||||
@@ -882,11 +903,16 @@ fn next_story_number(root: &std::path::Path) -> Result<u32, String> {
|
|||||||
pub fn validate_story_dirs(
|
pub fn validate_story_dirs(
|
||||||
root: &std::path::Path,
|
root: &std::path::Path,
|
||||||
) -> Result<Vec<StoryValidationResult>, String> {
|
) -> Result<Vec<StoryValidationResult>, String> {
|
||||||
let base = root.join(".story_kit").join("stories");
|
|
||||||
let mut results = Vec::new();
|
let mut results = Vec::new();
|
||||||
|
|
||||||
for subdir in &["current", "upcoming"] {
|
// Directories to validate: unified current/ + stories/upcoming/
|
||||||
let dir = base.join(subdir);
|
let dirs_to_validate: Vec<PathBuf> = 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() {
|
if !dir.exists() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1120,7 +1146,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn validate_story_dirs_valid_files() {
|
fn validate_story_dirs_valid_files() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
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");
|
let upcoming = tmp.path().join(".story_kit/stories/upcoming");
|
||||||
fs::create_dir_all(¤t).unwrap();
|
fs::create_dir_all(¤t).unwrap();
|
||||||
fs::create_dir_all(&upcoming).unwrap();
|
fs::create_dir_all(&upcoming).unwrap();
|
||||||
@@ -1144,7 +1170,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn validate_story_dirs_missing_front_matter() {
|
fn validate_story_dirs_missing_front_matter() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
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::create_dir_all(¤t).unwrap();
|
||||||
fs::write(current.join("28_todos.md"), "# No front matter\n").unwrap();
|
fs::write(current.join("28_todos.md"), "# No front matter\n").unwrap();
|
||||||
|
|
||||||
@@ -1157,7 +1183,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn validate_story_dirs_missing_required_fields() {
|
fn validate_story_dirs_missing_required_fields() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
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::create_dir_all(¤t).unwrap();
|
||||||
fs::write(current.join("28_todos.md"), "---\n---\n# Story\n").unwrap();
|
fs::write(current.join("28_todos.md"), "---\n---\n# Story\n").unwrap();
|
||||||
|
|
||||||
@@ -1172,7 +1198,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn validate_story_dirs_missing_test_plan_only() {
|
fn validate_story_dirs_missing_test_plan_only() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
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::create_dir_all(¤t).unwrap();
|
||||||
fs::write(
|
fs::write(
|
||||||
current.join("28_todos.md"),
|
current.join("28_todos.md"),
|
||||||
@@ -1244,7 +1270,7 @@ mod tests {
|
|||||||
fn next_story_number_scans_all_dirs() {
|
fn next_story_number_scans_all_dirs() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let upcoming = tmp.path().join(".story_kit/stories/upcoming");
|
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");
|
let archived = tmp.path().join(".story_kit/stories/archived");
|
||||||
fs::create_dir_all(&upcoming).unwrap();
|
fs::create_dir_all(&upcoming).unwrap();
|
||||||
fs::create_dir_all(¤t).unwrap();
|
fs::create_dir_all(¤t).unwrap();
|
||||||
@@ -1354,7 +1380,7 @@ mod tests {
|
|||||||
fn check_criterion_marks_first_unchecked() {
|
fn check_criterion_marks_first_unchecked() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
setup_git_repo(tmp.path());
|
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();
|
fs::create_dir_all(¤t).unwrap();
|
||||||
let filepath = current.join("1_test.md");
|
let filepath = current.join("1_test.md");
|
||||||
fs::write(&filepath, story_with_criteria(3)).unwrap();
|
fs::write(&filepath, story_with_criteria(3)).unwrap();
|
||||||
@@ -1381,7 +1407,7 @@ mod tests {
|
|||||||
fn check_criterion_marks_second_unchecked() {
|
fn check_criterion_marks_second_unchecked() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
setup_git_repo(tmp.path());
|
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();
|
fs::create_dir_all(¤t).unwrap();
|
||||||
let filepath = current.join("2_test.md");
|
let filepath = current.join("2_test.md");
|
||||||
fs::write(&filepath, story_with_criteria(3)).unwrap();
|
fs::write(&filepath, story_with_criteria(3)).unwrap();
|
||||||
@@ -1408,7 +1434,7 @@ mod tests {
|
|||||||
fn check_criterion_out_of_range_returns_error() {
|
fn check_criterion_out_of_range_returns_error() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
setup_git_repo(tmp.path());
|
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();
|
fs::create_dir_all(¤t).unwrap();
|
||||||
let filepath = current.join("3_test.md");
|
let filepath = current.join("3_test.md");
|
||||||
fs::write(&filepath, story_with_criteria(2)).unwrap();
|
fs::write(&filepath, story_with_criteria(2)).unwrap();
|
||||||
@@ -1434,7 +1460,7 @@ mod tests {
|
|||||||
fn set_test_plan_updates_pending_to_approved() {
|
fn set_test_plan_updates_pending_to_approved() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
setup_git_repo(tmp.path());
|
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();
|
fs::create_dir_all(¤t).unwrap();
|
||||||
let filepath = current.join("4_test.md");
|
let filepath = current.join("4_test.md");
|
||||||
fs::write(
|
fs::write(
|
||||||
@@ -1464,7 +1490,7 @@ mod tests {
|
|||||||
fn set_test_plan_missing_field_returns_error() {
|
fn set_test_plan_missing_field_returns_error() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
setup_git_repo(tmp.path());
|
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();
|
fs::create_dir_all(¤t).unwrap();
|
||||||
let filepath = current.join("5_test.md");
|
let filepath = current.join("5_test.md");
|
||||||
fs::write(
|
fs::write(
|
||||||
@@ -1491,7 +1517,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn find_story_file_searches_current_then_upcoming() {
|
fn find_story_file_searches_current_then_upcoming() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
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");
|
let upcoming = tmp.path().join(".story_kit/stories/upcoming");
|
||||||
fs::create_dir_all(¤t).unwrap();
|
fs::create_dir_all(¤t).unwrap();
|
||||||
fs::create_dir_all(&upcoming).unwrap();
|
fs::create_dir_all(&upcoming).unwrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user