Story 60: Status-Based Directory Layout with work/ pipeline

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-20 17:16:48 +00:00
parent 5fc085fd9e
commit e1e0d49759
74 changed files with 102 additions and 418 deletions

View File

@@ -692,48 +692,41 @@ pub fn git_stage_and_commit(
Ok(())
}
/// Determine the work item type from its ID.
/// Returns "bug" for `bug-*` IDs, "spike" for `spike-*` IDs, "story" otherwise.
/// Determine the work item type from its ID (new naming: `{N}_{type}_{slug}`).
/// Returns "bug", "spike", or "story".
fn item_type_from_id(item_id: &str) -> &'static str {
if item_id.starts_with("bug-") {
// New format: {digits}_{type}_{slug}
let after_num = item_id.trim_start_matches(|c: char| c.is_ascii_digit());
if after_num.starts_with("_bug_") {
"bug"
} else if item_id.starts_with("spike-") {
} else if after_num.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 source directory path for a work item (always work/1_upcoming/).
fn item_source_dir(project_root: &Path, _item_id: &str) -> PathBuf {
project_root.join(".story_kit").join("work").join("1_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"),
}
/// Return the archive directory path for a work item (always work/5_archived/).
fn item_archive_dir(project_root: &Path, _item_id: &str) -> PathBuf {
project_root.join(".story_kit").join("work").join("5_archived")
}
/// Move a work item (story, bug, or spike) to the unified `.story_kit/current/` directory.
/// Move a work item (story, bug, or spike) from `work/1_upcoming/` to `work/2_current/`.
///
/// 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.
/// Idempotent: if the item is already in `2_current/`, returns Ok without committing.
/// If the item is not found in `1_upcoming/`, logs a warning and returns Ok.
pub fn move_story_to_current(project_root: &Path, story_id: &str) -> Result<(), String> {
let current_dir = project_root.join(".story_kit").join("current");
let sk = project_root.join(".story_kit").join("work");
let current_dir = sk.join("2_current");
let current_path = current_dir.join(format!("{story_id}.md"));
if current_path.exists() {
// Already in current/ — idempotent, nothing to do.
// Already in 2_current/ — idempotent, nothing to do.
return Ok(());
}
@@ -742,20 +735,20 @@ pub fn move_story_to_current(project_root: &Path, story_id: &str) -> Result<(),
if !source_path.exists() {
eprintln!(
"[lifecycle] Work item '{story_id}' not found in {}; skipping move to current/",
"[lifecycle] Work item '{story_id}' not found in {}; skipping move to 2_current/",
source_dir.display()
);
return Ok(());
}
std::fs::create_dir_all(&current_dir)
.map_err(|e| format!("Failed to create .story_kit/current/ directory: {e}"))?;
.map_err(|e| format!("Failed to create work/2_current/ directory: {e}"))?;
std::fs::rename(&source_path, &current_path)
.map_err(|e| format!("Failed to move '{story_id}' to current/: {e}"))?;
.map_err(|e| format!("Failed to move '{story_id}' to 2_current/: {e}"))?;
eprintln!(
"[lifecycle] Moved '{story_id}' from {} to current/",
"[lifecycle] Moved '{story_id}' from {} to work/2_current/",
source_dir.display()
);
@@ -767,20 +760,15 @@ pub fn move_story_to_current(project_root: &Path, story_id: &str) -> Result<(),
)
}
/// Move a story from `.story_kit/current/` to `.story_kit/stories/archived/` and auto-commit.
/// Move a story from `work/2_current/` to `work/5_archived/` and auto-commit.
///
/// * 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.
/// * If the story is in `2_current/`, it is moved to `5_archived/` and committed.
/// * If the story is already in `5_archived/`, this is a no-op (idempotent).
/// * If the story is not found in `2_current/` or `5_archived/`, an error is returned.
pub fn move_story_to_archived(project_root: &Path, story_id: &str) -> Result<(), String> {
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 sk = project_root.join(".story_kit").join("work");
let current_path = sk.join("2_current").join(format!("{story_id}.md"));
let archived_dir = sk.join("5_archived");
let archived_path = archived_dir.join(format!("{story_id}.md"));
if archived_path.exists() {
@@ -790,10 +778,10 @@ pub fn move_story_to_archived(project_root: &Path, story_id: &str) -> Result<(),
if current_path.exists() {
std::fs::create_dir_all(&archived_dir)
.map_err(|e| format!("Failed to create stories/archived/ directory: {e}"))?;
.map_err(|e| format!("Failed to create work/5_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 stories/archived/");
.map_err(|e| format!("Failed to move story '{story_id}' to 5_archived/: {e}"))?;
eprintln!("[lifecycle] Moved story '{story_id}' from work/2_current/ to work/5_archived/");
let msg = format!("story-kit: accept story {story_id}");
git_stage_and_commit(
@@ -805,20 +793,20 @@ pub fn move_story_to_archived(project_root: &Path, story_id: &str) -> Result<(),
}
Err(format!(
"Story '{story_id}' not found in current/. Cannot accept story."
"Story '{story_id}' not found in work/2_current/. Cannot accept story."
))
}
/// Move a bug from `.story_kit/current/` to `.story_kit/bugs/archive/` and auto-commit.
/// Move a bug from `work/2_current/` or `work/1_upcoming/` to `work/5_archived/` 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 in `2_current/`, it is moved to `5_archived/` and committed.
/// * If the bug is still in `1_upcoming/` (never started), it is moved directly to `5_archived/`.
/// * If the bug is already in `5_archived/`, 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 sk = project_root.join(".story_kit").join("work");
let current_path = sk.join("2_current").join(format!("{bug_id}.md"));
let upcoming_path = sk.join("1_upcoming").join(format!("{bug_id}.md"));
let archive_dir = item_archive_dir(project_root, bug_id);
let archive_path = archive_dir.join(format!("{bug_id}.md"));
@@ -828,21 +816,21 @@ pub fn close_bug_to_archive(project_root: &Path, bug_id: &str) -> Result<(), Str
let source_path = if current_path.exists() {
current_path.clone()
} else if bugs_path.exists() {
bugs_path.clone()
} else if upcoming_path.exists() {
upcoming_path.clone()
} else {
return Err(format!(
"Bug '{bug_id}' not found in current/ or bugs/. Cannot close bug."
"Bug '{bug_id}' not found in work/2_current/ or work/1_upcoming/. Cannot close bug."
));
};
std::fs::create_dir_all(&archive_dir)
.map_err(|e| format!("Failed to create bugs/archive/ directory: {e}"))?;
.map_err(|e| format!("Failed to create work/5_archived/ directory: {e}"))?;
std::fs::rename(&source_path, &archive_path)
.map_err(|e| format!("Failed to move bug '{bug_id}' to archive: {e}"))?;
.map_err(|e| format!("Failed to move bug '{bug_id}' to 5_archived/: {e}"))?;
eprintln!(
"[lifecycle] Closed bug '{bug_id}' → bugs/archive/"
"[lifecycle] Closed bug '{bug_id}' → work/5_archived/"
);
let msg = format!("story-kit: close bug {bug_id}");