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

@@ -142,7 +142,7 @@ struct ValidateStoriesResponse {
pub fn load_upcoming_stories(ctx: &AppContext) -> Result<Vec<UpcomingStory>, String> {
let root = ctx.state.get_project_root()?;
let upcoming_dir = root.join(".story_kit").join("stories").join("upcoming");
let upcoming_dir = root.join(".story_kit").join("work").join("1_upcoming");
if !upcoming_dir.exists() {
return Ok(Vec::new());
@@ -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> {
let root = ctx.state.get_project_root()?;
let current_dir = root.join(".story_kit").join("current");
let current_dir = root.join(".story_kit").join("work").join("2_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<Json<TodoListResponse>> {
let root = self.ctx.state.get_project_root().map_err(bad_request)?;
let current_dir = root.join(".story_kit").join("current");
let current_dir = root.join(".story_kit").join("work").join("2_current");
if !current_dir.exists() {
return Ok(Json(TodoListResponse {
@@ -631,15 +631,15 @@ pub fn create_story_file(
acceptance_criteria: Option<&[String]>,
commit: bool,
) -> Result<String, String> {
let story_number = next_story_number(root)?;
let story_number = next_item_number(root)?;
let slug = slugify_name(name);
if slug.is_empty() {
return Err("Name must contain at least one alphanumeric character.".to_string());
}
let filename = format!("{story_number}_{slug}.md");
let upcoming_dir = root.join(".story_kit").join("stories").join("upcoming");
let filename = format!("{story_number}_story_{slug}.md");
let upcoming_dir = root.join(".story_kit").join("work").join("1_upcoming");
fs::create_dir_all(&upcoming_dir)
.map_err(|e| format!("Failed to create upcoming directory: {e}"))?;
@@ -701,39 +701,9 @@ fn git_commit_story_file(root: &Path, filepath: &Path, story_id: &str) -> Result
// ── Bug file helpers ──────────────────────────────────────────────
/// Determine the next bug number by scanning `.story_kit/bugs/` and `.story_kit/bugs/archive/`.
fn next_bug_number(root: &Path) -> Result<u32, String> {
let bugs_base = root.join(".story_kit").join("bugs");
let mut max_num: u32 = 0;
for dir in [bugs_base.clone(), bugs_base.join("archive")] {
if !dir.exists() {
continue;
}
for entry in
fs::read_dir(dir).map_err(|e| format!("Failed to read bugs 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();
// Bug filenames: bug-N-slug.md — extract the N after "bug-"
if let Some(rest) = name_str.strip_prefix("bug-") {
let num_str: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
if let Ok(n) = num_str.parse::<u32>()
&& n > max_num
{
max_num = n;
}
}
}
}
Ok(max_num + 1)
}
/// Create a bug file in `.story_kit/bugs/` with a deterministic filename and auto-commit.
/// Create a bug file in `work/1_upcoming/` with a deterministic filename and auto-commit.
///
/// Returns the bug_id (e.g. `"bug-3-login_crash"`).
/// Returns the bug_id (e.g. `"4_bug_login_crash"`).
pub fn create_bug_file(
root: &Path,
name: &str,
@@ -743,17 +713,17 @@ pub fn create_bug_file(
expected_result: &str,
acceptance_criteria: Option<&[String]>,
) -> Result<String, String> {
let bug_number = next_bug_number(root)?;
let bug_number = next_item_number(root)?;
let slug = slugify_name(name);
if slug.is_empty() {
return Err("Name must contain at least one alphanumeric character.".to_string());
}
let filename = format!("bug-{bug_number}-{slug}.md");
let bugs_dir = root.join(".story_kit").join("bugs");
let filename = format!("{bug_number}_bug_{slug}.md");
let bugs_dir = root.join(".story_kit").join("work").join("1_upcoming");
fs::create_dir_all(&bugs_dir)
.map_err(|e| format!("Failed to create bugs directory: {e}"))?;
.map_err(|e| format!("Failed to create upcoming directory: {e}"))?;
let filepath = bugs_dir.join(&filename);
if filepath.exists() {
@@ -797,6 +767,14 @@ pub fn create_bug_file(
Ok(bug_id)
}
/// Returns true if the item stem (filename without extension) is a bug item.
/// Bug items follow the pattern: {N}_bug_{slug}
fn is_bug_item(stem: &str) -> bool {
// Format: {digits}_bug_{rest}
let after_num = stem.trim_start_matches(|c: char| c.is_ascii_digit());
after_num.starts_with("_bug_")
}
/// Extract the human-readable name from a bug file's first heading.
fn extract_bug_name(path: &Path) -> Option<String> {
let contents = fs::read_to_string(path).ok()?;
@@ -811,23 +789,22 @@ fn extract_bug_name(path: &Path) -> Option<String> {
None
}
/// List all open bugs — files directly in `.story_kit/bugs/` (excluding `archive/` subdir).
/// List all open bugs — files in `work/1_upcoming/` matching the `_bug_` naming pattern.
///
/// Returns a sorted list of `(bug_id, name)` pairs.
pub fn list_bug_files(root: &Path) -> Result<Vec<(String, String)>, String> {
let bugs_dir = root.join(".story_kit").join("bugs");
if !bugs_dir.exists() {
let upcoming_dir = root.join(".story_kit").join("work").join("1_upcoming");
if !upcoming_dir.exists() {
return Ok(Vec::new());
}
let mut bugs = Vec::new();
for entry in
fs::read_dir(&bugs_dir).map_err(|e| format!("Failed to read bugs directory: {e}"))?
fs::read_dir(&upcoming_dir).map_err(|e| format!("Failed to read upcoming directory: {e}"))?
{
let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
let path = entry.path();
// Skip subdirectories (archive/)
if path.is_dir() {
continue;
}
@@ -836,12 +813,17 @@ pub fn list_bug_files(root: &Path) -> Result<Vec<(String, String)>, String> {
continue;
}
let bug_id = path
let stem = path
.file_stem()
.and_then(|stem| stem.to_str())
.ok_or_else(|| "Invalid bug file name.".to_string())?
.to_string();
.and_then(|s| s.to_str())
.ok_or_else(|| "Invalid file name.".to_string())?;
// Only include bug items: {N}_bug_{slug}
if !is_bug_item(stem) {
continue;
}
let bug_id = stem.to_string();
let name = extract_bug_name(&path).unwrap_or_else(|| bug_id.clone());
bugs.push((bug_id, name));
}
@@ -850,21 +832,22 @@ pub fn list_bug_files(root: &Path) -> Result<Vec<(String, String)>, String> {
Ok(bugs)
}
/// Locate a story file by searching .story_kit/current/ then stories/upcoming/.
/// Locate a work item file by searching work/2_current/ then work/1_upcoming/.
fn find_story_file(project_root: &Path, story_id: &str) -> Result<PathBuf, String> {
let filename = format!("{story_id}.md");
// Check unified current/ directory first
let current_path = project_root.join(".story_kit").join("current").join(&filename);
let sk = project_root.join(".story_kit").join("work");
// Check 2_current/ first
let current_path = sk.join("2_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);
// Fall back to 1_upcoming/
let upcoming_path = sk.join("1_upcoming").join(&filename);
if upcoming_path.exists() {
return Ok(upcoming_path);
}
Err(format!(
"Story '{story_id}' not found in current/ or upcoming/."
"Story '{story_id}' not found in work/2_current/ or work/1_upcoming/."
))
}
@@ -1005,13 +988,13 @@ fn slugify_name(name: &str) -> String {
result
}
fn next_story_number(root: &std::path::Path) -> Result<u32, String> {
let stories_base = root.join(".story_kit").join("stories");
/// Scan all `work/` subdirectories for the highest item number across all types (stories, bugs, spikes).
fn next_item_number(root: &std::path::Path) -> Result<u32, String> {
let work_base = root.join(".story_kit").join("work");
let mut max_num: u32 = 0;
// Scan stories/upcoming/ and stories/archived/ for story numbers
for subdir in &["upcoming", "archived"] {
let dir = stories_base.join(subdir);
for subdir in &["1_upcoming", "2_current", "3_qa", "4_merge", "5_archived"] {
let dir = work_base.join(subdir);
if !dir.exists() {
continue;
}
@@ -1021,24 +1004,7 @@ fn next_story_number(root: &std::path::Path) -> Result<u32, String> {
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::<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(&current_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();
// Filename format: {N}_{type}_{slug}.md — extract leading N
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
@@ -1056,10 +1022,10 @@ pub fn validate_story_dirs(
) -> Result<Vec<StoryValidationResult>, String> {
let mut results = Vec::new();
// Directories to validate: unified current/ + stories/upcoming/
// Directories to validate: work/2_current/ + work/1_upcoming/
let dirs_to_validate: Vec<PathBuf> = vec![
root.join(".story_kit").join("current"),
root.join(".story_kit").join("stories").join("upcoming"),
root.join(".story_kit").join("work").join("2_current"),
root.join(".story_kit").join("work").join("1_upcoming"),
];
for dir in &dirs_to_validate {