story-kit: merge 287_story_rename_upcoming_pipeline_stage_to_backlog

This commit is contained in:
Dave
2026-03-18 14:31:12 +00:00
parent 967ebd7a84
commit df6f792214
26 changed files with 250 additions and 228 deletions

View File

@@ -16,9 +16,9 @@ pub(super) fn item_type_from_id(item_id: &str) -> &'static str {
}
}
/// Return the source directory path for a work item (always work/1_upcoming/).
/// Return the source directory path for a work item (always work/1_backlog/).
fn item_source_dir(project_root: &Path, _item_id: &str) -> PathBuf {
project_root.join(".story_kit").join("work").join("1_upcoming")
project_root.join(".story_kit").join("work").join("1_backlog")
}
/// Return the done directory path for a work item (always work/5_done/).
@@ -26,10 +26,10 @@ fn item_archive_dir(project_root: &Path, _item_id: &str) -> PathBuf {
project_root.join(".story_kit").join("work").join("5_done")
}
/// Move a work item (story, bug, or spike) from `work/1_upcoming/` to `work/2_current/`.
/// Move a work item (story, bug, or spike) from `work/1_backlog/` to `work/2_current/`.
///
/// 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.
/// If the item is not found in `1_backlog/`, logs a warning and returns Ok.
pub fn move_story_to_current(project_root: &Path, story_id: &str) -> Result<(), String> {
let sk = project_root.join(".story_kit").join("work");
let current_dir = sk.join("2_current");
@@ -219,16 +219,16 @@ pub fn move_story_to_qa(project_root: &Path, story_id: &str) -> Result<(), Strin
Ok(())
}
/// Move a bug from `work/2_current/` or `work/1_upcoming/` to `work/5_done/` and auto-commit.
/// Move a bug from `work/2_current/` or `work/1_backlog/` to `work/5_done/` and auto-commit.
///
/// * If the bug is in `2_current/`, it is moved to `5_done/` and committed.
/// * If the bug is still in `1_upcoming/` (never started), it is moved directly to `5_done/`.
/// * If the bug is still in `1_backlog/` (never started), it is moved directly to `5_done/`.
/// * If the bug is already in `5_done/`, 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").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 backlog_path = sk.join("1_backlog").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"));
@@ -238,11 +238,11 @@ 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 upcoming_path.exists() {
upcoming_path.clone()
} else if backlog_path.exists() {
backlog_path.clone()
} else {
return Err(format!(
"Bug '{bug_id}' not found in work/2_current/ or work/1_upcoming/. Cannot close bug."
"Bug '{bug_id}' not found in work/2_current/ or work/1_backlog/. Cannot close bug."
));
};
@@ -269,15 +269,15 @@ mod tests {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let upcoming = root.join(".story_kit/work/1_upcoming");
let backlog = root.join(".story_kit/work/1_backlog");
let current = root.join(".story_kit/work/2_current");
fs::create_dir_all(&upcoming).unwrap();
fs::create_dir_all(&backlog).unwrap();
fs::create_dir_all(&current).unwrap();
fs::write(upcoming.join("10_story_foo.md"), "test").unwrap();
fs::write(backlog.join("10_story_foo.md"), "test").unwrap();
move_story_to_current(root, "10_story_foo").unwrap();
assert!(!upcoming.join("10_story_foo.md").exists());
assert!(!backlog.join("10_story_foo.md").exists());
assert!(current.join("10_story_foo.md").exists());
}
@@ -295,25 +295,25 @@ mod tests {
}
#[test]
fn move_story_to_current_noop_when_not_in_upcoming() {
fn move_story_to_current_noop_when_not_in_backlog() {
let tmp = tempfile::tempdir().unwrap();
assert!(move_story_to_current(tmp.path(), "99_missing").is_ok());
}
#[test]
fn move_bug_to_current_moves_from_upcoming() {
fn move_bug_to_current_moves_from_backlog() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let upcoming = root.join(".story_kit/work/1_upcoming");
let backlog = root.join(".story_kit/work/1_backlog");
let current = root.join(".story_kit/work/2_current");
fs::create_dir_all(&upcoming).unwrap();
fs::create_dir_all(&backlog).unwrap();
fs::create_dir_all(&current).unwrap();
fs::write(upcoming.join("1_bug_test.md"), "# Bug 1\n").unwrap();
fs::write(backlog.join("1_bug_test.md"), "# Bug 1\n").unwrap();
move_story_to_current(root, "1_bug_test").unwrap();
assert!(!upcoming.join("1_bug_test.md").exists());
assert!(!backlog.join("1_bug_test.md").exists());
assert!(current.join("1_bug_test.md").exists());
}
@@ -335,17 +335,17 @@ mod tests {
}
#[test]
fn close_bug_moves_from_upcoming_when_not_started() {
fn close_bug_moves_from_backlog_when_not_started() {
use std::fs;
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let upcoming = root.join(".story_kit/work/1_upcoming");
fs::create_dir_all(&upcoming).unwrap();
fs::write(upcoming.join("3_bug_test.md"), "# Bug 3\n").unwrap();
let backlog = root.join(".story_kit/work/1_backlog");
fs::create_dir_all(&backlog).unwrap();
fs::write(backlog.join("3_bug_test.md"), "# Bug 3\n").unwrap();
close_bug_to_archive(root, "3_bug_test").unwrap();
assert!(!upcoming.join("3_bug_test.md").exists());
assert!(!backlog.join("3_bug_test.md").exists());
assert!(root.join(".story_kit/work/5_done/3_bug_test.md").exists());
}

View File

@@ -212,7 +212,7 @@ impl AgentPool {
let event_log: Arc<Mutex<Vec<AgentEvent>>> = Arc::new(Mutex::new(Vec::new()));
let log_session_id = uuid::Uuid::new_v4().to_string();
// Move story from upcoming/ to current/ before checking agent
// Move story from backlog/ to current/ before checking agent
// availability so that auto_assign_available_work can pick it up even
// when all coders are currently busy (story 203). This is idempotent:
// if the story is already in 2_current/ or a later stage, the call is
@@ -1430,7 +1430,7 @@ impl AgentPool {
///
/// Scans `work/2_current/`, `work/3_qa/`, and `work/4_merge/` for items that have no
/// active agent and assigns the first free agent of the appropriate role. Items in
/// `work/1_upcoming/` are never auto-started.
/// `work/1_backlog/` are never auto-started.
///
/// Respects the configured agent roster: the maximum number of concurrently active agents
/// per role is bounded by the count of agents of that role defined in `project.toml`.
@@ -1603,7 +1603,7 @@ impl AgentPool {
// Determine which active stage the story is in.
let stage_dir = match find_active_story_stage(project_root, story_id) {
Some(s) => s,
None => continue, // Not in any active stage (upcoming/archived or unknown).
None => continue, // Not in any active stage (backlog/archived or unknown).
};
// 4_merge/ is left for auto_assign to handle with a fresh mergemaster.
@@ -2728,8 +2728,8 @@ mod tests {
fs::write(current.join("173_story_test.md"), "test").unwrap();
// Ensure 3_qa/ exists for the move target
fs::create_dir_all(root.join(".story_kit/work/3_qa")).unwrap();
// Ensure 1_upcoming/ exists (start_agent calls move_story_to_current)
fs::create_dir_all(root.join(".story_kit/work/1_upcoming")).unwrap();
// Ensure 1_backlog/ exists (start_agent calls move_story_to_current)
fs::create_dir_all(root.join(".story_kit/work/1_backlog")).unwrap();
// Write a project.toml with a qa agent so start_agent can resolve it.
fs::create_dir_all(root.join(".story_kit")).unwrap();
@@ -3498,14 +3498,14 @@ stage = "coder"
}
/// Story 203: when all coders are busy the story file must be moved from
/// 1_upcoming/ to 2_current/ so that auto_assign_available_work can pick
/// 1_backlog/ to 2_current/ so that auto_assign_available_work can pick
/// it up once a coder finishes.
#[tokio::test]
async fn start_agent_moves_story_to_current_when_coders_busy() {
let tmp = tempfile::tempdir().unwrap();
let sk = tmp.path().join(".story_kit");
let upcoming = sk.join("work/1_upcoming");
std::fs::create_dir_all(&upcoming).unwrap();
let backlog = sk.join("work/1_backlog");
std::fs::create_dir_all(&backlog).unwrap();
std::fs::write(
sk.join("project.toml"),
r#"
@@ -3515,9 +3515,9 @@ stage = "coder"
"#,
)
.unwrap();
// Place the story in 1_upcoming/.
// Place the story in 1_backlog/.
std::fs::write(
upcoming.join("story-3.md"),
backlog.join("story-3.md"),
"---\nname: Story 3\n---\n",
)
.unwrap();
@@ -3547,10 +3547,10 @@ stage = "coder"
current_path.exists(),
"story should be in 2_current/ after busy error, but was not"
);
let upcoming_path = upcoming.join("story-3.md");
let backlog_path = backlog.join("story-3.md");
assert!(
!upcoming_path.exists(),
"story should no longer be in 1_upcoming/"
!backlog_path.exists(),
"story should no longer be in 1_backlog/"
);
}
@@ -3774,7 +3774,7 @@ stage = "coder"
// Create the story in upcoming so `move_story_to_current` succeeds,
// but do NOT init a git repo — `create_worktree` will fail in the spawn.
let upcoming = root.join(".story_kit/work/1_upcoming");
let upcoming = root.join(".story_kit/work/1_backlog");
fs::create_dir_all(&upcoming).unwrap();
fs::write(
upcoming.join("50_story_test.md"),
@@ -3924,7 +3924,7 @@ stage = "coder"
let root = tmp.path().to_path_buf();
let sk_dir = root.join(".story_kit");
fs::create_dir_all(sk_dir.join("work/1_upcoming")).unwrap();
fs::create_dir_all(sk_dir.join("work/1_backlog")).unwrap();
fs::write(
root.join(".story_kit/project.toml"),
"[[agent]]\nname = \"coder-1\"\n",
@@ -3933,12 +3933,12 @@ stage = "coder"
// Both stories must exist in upcoming so move_story_to_current can run
// (only the winner reaches that point, but we set both up defensively).
fs::write(
root.join(".story_kit/work/1_upcoming/86_story_foo.md"),
root.join(".story_kit/work/1_backlog/86_story_foo.md"),
"---\nname: Foo\n---\n",
)
.unwrap();
fs::write(
root.join(".story_kit/work/1_upcoming/130_story_bar.md"),
root.join(".story_kit/work/1_backlog/130_story_bar.md"),
"---\nname: Bar\n---\n",
)
.unwrap();
@@ -4138,14 +4138,14 @@ stage = "coder"
let root = tmp.path();
let sk_dir = root.join(".story_kit");
fs::create_dir_all(sk_dir.join("work/1_upcoming")).unwrap();
fs::create_dir_all(sk_dir.join("work/1_backlog")).unwrap();
fs::write(
root.join(".story_kit/project.toml"),
"[[agent]]\nname = \"coder-1\"\n\n[[agent]]\nname = \"coder-2\"\n",
)
.unwrap();
fs::write(
root.join(".story_kit/work/1_upcoming/99_story_baz.md"),
root.join(".story_kit/work/1_backlog/99_story_baz.md"),
"---\nname: Baz\n---\n",
)
.unwrap();

View File

@@ -339,7 +339,7 @@ impl AgentsApi {
.map_err(bad_request)?;
let stages = [
("1_upcoming", "upcoming"),
("1_backlog", "backlog"),
("2_current", "current"),
("3_qa", "qa"),
("4_merge", "merge"),
@@ -809,12 +809,12 @@ allowed_tools = ["Read", "Bash"]
}
#[tokio::test]
async fn get_work_item_content_returns_content_from_upcoming() {
async fn get_work_item_content_returns_content_from_backlog() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
make_stage_dir(root, "1_upcoming");
make_stage_dir(root, "1_backlog");
std::fs::write(
root.join(".story_kit/work/1_upcoming/42_story_foo.md"),
root.join(".story_kit/work/1_backlog/42_story_foo.md"),
"---\nname: \"Foo Story\"\n---\n\n# Story 42: Foo Story\n\nSome content.",
)
.unwrap();
@@ -828,7 +828,7 @@ allowed_tools = ["Read", "Bash"]
.unwrap()
.0;
assert!(result.content.contains("Some content."));
assert_eq!(result.stage, "upcoming");
assert_eq!(result.stage, "backlog");
assert_eq!(result.name, Some("Foo Story".to_string()));
}
@@ -1113,7 +1113,7 @@ allowed_tools = ["Read", "Bash"]
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
// Create work dirs including 2_current for the story file.
for stage in &["1_upcoming", "2_current", "5_done", "6_archived"] {
for stage in &["1_backlog", "2_current", "5_done", "6_archived"] {
std::fs::create_dir_all(root.join(".story_kit").join("work").join(stage)).unwrap();
}

View File

@@ -672,7 +672,7 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
},
{
"name": "create_spike",
"description": "Create a spike file in .story_kit/work/1_upcoming/ with a deterministic filename and YAML front matter. Returns the spike_id.",
"description": "Create a spike file in .story_kit/work/1_backlog/ with a deterministic filename and YAML front matter. Returns the spike_id.",
"inputSchema": {
"type": "object",
"properties": {
@@ -690,7 +690,7 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
},
{
"name": "create_bug",
"description": "Create a bug file in work/1_upcoming/ with a deterministic filename and auto-commit to master. Returns the bug_id.",
"description": "Create a bug file in work/1_backlog/ with a deterministic filename and auto-commit to master. Returns the bug_id.",
"inputSchema": {
"type": "object",
"properties": {
@@ -725,7 +725,7 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
},
{
"name": "list_bugs",
"description": "List all open bugs in work/1_upcoming/ matching the _bug_ naming convention.",
"description": "List all open bugs in work/1_backlog/ matching the _bug_ naming convention.",
"inputSchema": {
"type": "object",
"properties": {}
@@ -733,7 +733,7 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
},
{
"name": "create_refactor",
"description": "Create a refactor work item in work/1_upcoming/ with a deterministic filename and YAML front matter. Returns the refactor_id.",
"description": "Create a refactor work item in work/1_backlog/ with a deterministic filename and YAML front matter. Returns the refactor_id.",
"inputSchema": {
"type": "object",
"properties": {
@@ -756,7 +756,7 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
},
{
"name": "list_refactors",
"description": "List all open refactors in work/1_upcoming/ matching the _refactor_ naming convention.",
"description": "List all open refactors in work/1_backlog/ matching the _refactor_ naming convention.",
"inputSchema": {
"type": "object",
"properties": {}
@@ -764,7 +764,7 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
},
{
"name": "close_bug",
"description": "Archive a bug from work/2_current/ or work/1_upcoming/ to work/5_done/ and auto-commit to master.",
"description": "Archive a bug from work/2_current/ or work/1_backlog/ to work/5_done/ and auto-commit to master.",
"inputSchema": {
"type": "object",
"properties": {
@@ -1022,7 +1022,7 @@ fn tool_create_story(args: &Value, ctx: &AppContext) -> Result<String, String> {
.get("acceptance_criteria")
.and_then(|v| serde_json::from_value(v.clone()).ok());
// Spike 61: write the file only — the filesystem watcher detects the new
// .md file in work/1_upcoming/ and auto-commits with a deterministic message.
// .md file in work/1_backlog/ and auto-commits with a deterministic message.
let commit = false;
let root = ctx.state.get_project_root()?;
@@ -1091,16 +1091,16 @@ fn tool_get_pipeline_status(ctx: &AppContext) -> Result<String, String> {
active.extend(map_items(&state.merge, "merge"));
active.extend(map_items(&state.done, "done"));
let upcoming: Vec<Value> = state
.upcoming
let backlog: Vec<Value> = state
.backlog
.iter()
.map(|s| json!({ "story_id": s.story_id, "name": s.name }))
.collect();
serde_json::to_string_pretty(&json!({
"active": active,
"upcoming": upcoming,
"upcoming_count": upcoming.len(),
"backlog": backlog,
"backlog_count": backlog.len(),
}))
.map_err(|e| format!("Serialization error: {e}"))
}
@@ -2452,7 +2452,7 @@ mod tests {
let root = tmp.path();
for (stage, id, name) in &[
("1_upcoming", "10_story_upcoming", "Upcoming Story"),
("1_backlog", "10_story_upcoming", "Upcoming Story"),
("2_current", "20_story_current", "Current Story"),
("3_qa", "30_story_qa", "QA Story"),
("4_merge", "40_story_merge", "Merge Story"),
@@ -2481,11 +2481,11 @@ mod tests {
assert!(stages.contains(&"merge"));
assert!(stages.contains(&"done"));
// Upcoming backlog
let upcoming = parsed["upcoming"].as_array().unwrap();
assert_eq!(upcoming.len(), 1);
assert_eq!(upcoming[0]["story_id"], "10_story_upcoming");
assert_eq!(parsed["upcoming_count"], 1);
// Backlog
let backlog = parsed["backlog"].as_array().unwrap();
assert_eq!(backlog.len(), 1);
assert_eq!(backlog[0]["story_id"], "10_story_upcoming");
assert_eq!(parsed["backlog_count"], 1);
}
#[test]
@@ -2801,8 +2801,8 @@ mod tests {
let t = tool.unwrap();
let desc = t["description"].as_str().unwrap();
assert!(
desc.contains("work/1_upcoming/"),
"create_bug description should reference work/1_upcoming/, got: {desc}"
desc.contains("work/1_backlog/"),
"create_bug description should reference work/1_backlog/, got: {desc}"
);
assert!(
!desc.contains(".story_kit/bugs"),
@@ -2826,8 +2826,8 @@ mod tests {
let t = tool.unwrap();
let desc = t["description"].as_str().unwrap();
assert!(
desc.contains("work/1_upcoming/"),
"list_bugs description should reference work/1_upcoming/, got: {desc}"
desc.contains("work/1_backlog/"),
"list_bugs description should reference work/1_backlog/, got: {desc}"
);
assert!(
!desc.contains(".story_kit/bugs"),
@@ -2911,7 +2911,7 @@ mod tests {
assert!(result.contains("1_bug_login_crash"));
let bug_file = tmp
.path()
.join(".story_kit/work/1_upcoming/1_bug_login_crash.md");
.join(".story_kit/work/1_backlog/1_bug_login_crash.md");
assert!(bug_file.exists());
}
@@ -2927,15 +2927,15 @@ mod tests {
#[test]
fn tool_list_bugs_returns_open_bugs() {
let tmp = tempfile::tempdir().unwrap();
let upcoming_dir = tmp.path().join(".story_kit/work/1_upcoming");
std::fs::create_dir_all(&upcoming_dir).unwrap();
let backlog_dir = tmp.path().join(".story_kit/work/1_backlog");
std::fs::create_dir_all(&backlog_dir).unwrap();
std::fs::write(
upcoming_dir.join("1_bug_crash.md"),
backlog_dir.join("1_bug_crash.md"),
"# Bug 1: App Crash\n",
)
.unwrap();
std::fs::write(
upcoming_dir.join("2_bug_typo.md"),
backlog_dir.join("2_bug_typo.md"),
"# Bug 2: Typo in Header\n",
)
.unwrap();
@@ -2963,9 +2963,9 @@ mod tests {
fn tool_close_bug_moves_to_archive() {
let tmp = tempfile::tempdir().unwrap();
setup_git_repo_in(tmp.path());
let upcoming_dir = tmp.path().join(".story_kit/work/1_upcoming");
std::fs::create_dir_all(&upcoming_dir).unwrap();
let bug_file = upcoming_dir.join("1_bug_crash.md");
let backlog_dir = tmp.path().join(".story_kit/work/1_backlog");
std::fs::create_dir_all(&backlog_dir).unwrap();
let bug_file = backlog_dir.join("1_bug_crash.md");
std::fs::write(&bug_file, "# Bug 1: Crash\n").unwrap();
// Stage the file so it's tracked
std::process::Command::new("git")
@@ -3035,7 +3035,7 @@ mod tests {
assert!(result.contains("1_spike_compare_encoders"));
let spike_file = tmp
.path()
.join(".story_kit/work/1_upcoming/1_spike_compare_encoders.md");
.join(".story_kit/work/1_backlog/1_spike_compare_encoders.md");
assert!(spike_file.exists());
let contents = std::fs::read_to_string(&spike_file).unwrap();
assert!(contents.starts_with("---\nname: \"Compare Encoders\"\n---"));
@@ -3050,7 +3050,7 @@ mod tests {
let result = tool_create_spike(&json!({"name": "My Spike"}), &ctx).unwrap();
assert!(result.contains("1_spike_my_spike"));
let spike_file = tmp.path().join(".story_kit/work/1_upcoming/1_spike_my_spike.md");
let spike_file = tmp.path().join(".story_kit/work/1_backlog/1_spike_my_spike.md");
assert!(spike_file.exists());
let contents = std::fs::read_to_string(&spike_file).unwrap();
assert!(contents.starts_with("---\nname: \"My Spike\"\n---"));

View File

@@ -35,7 +35,7 @@ pub struct StoryValidationResult {
/// Full pipeline state across all stages.
#[derive(Clone, Debug, Serialize)]
pub struct PipelineState {
pub upcoming: Vec<UpcomingStory>,
pub backlog: Vec<UpcomingStory>,
pub current: Vec<UpcomingStory>,
pub qa: Vec<UpcomingStory>,
pub merge: Vec<UpcomingStory>,
@@ -46,7 +46,7 @@ pub struct PipelineState {
pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
let agent_map = build_active_agent_map(ctx);
Ok(PipelineState {
upcoming: load_stage_items(ctx, "1_upcoming", &HashMap::new())?,
backlog: load_stage_items(ctx, "1_backlog", &HashMap::new())?,
current: load_stage_items(ctx, "2_current", &agent_map)?,
qa: load_stage_items(ctx, "3_qa", &agent_map)?,
merge: load_stage_items(ctx, "4_merge", &agent_map)?,
@@ -130,7 +130,7 @@ fn load_stage_items(
}
pub fn load_upcoming_stories(ctx: &AppContext) -> Result<Vec<UpcomingStory>, String> {
load_stage_items(ctx, "1_upcoming", &HashMap::new())
load_stage_items(ctx, "1_backlog", &HashMap::new())
}
/// Shared create-story logic used by both the OpenApi and MCP handlers.
@@ -152,11 +152,11 @@ pub fn create_story_file(
}
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}"))?;
let backlog_dir = root.join(".story_kit").join("work").join("1_backlog");
fs::create_dir_all(&backlog_dir)
.map_err(|e| format!("Failed to create backlog directory: {e}"))?;
let filepath = upcoming_dir.join(&filename);
let filepath = backlog_dir.join(&filename);
if filepath.exists() {
return Err(format!("Story file already exists: {filename}"));
}
@@ -206,7 +206,7 @@ pub fn create_story_file(
// ── Bug file helpers ──────────────────────────────────────────────
/// Create a bug file in `work/1_upcoming/` with a deterministic filename and auto-commit.
/// Create a bug file in `work/1_backlog/` with a deterministic filename and auto-commit.
///
/// Returns the bug_id (e.g. `"4_bug_login_crash"`).
pub fn create_bug_file(
@@ -226,9 +226,9 @@ pub fn create_bug_file(
}
let filename = format!("{bug_number}_bug_{slug}.md");
let bugs_dir = root.join(".story_kit").join("work").join("1_upcoming");
let bugs_dir = root.join(".story_kit").join("work").join("1_backlog");
fs::create_dir_all(&bugs_dir)
.map_err(|e| format!("Failed to create upcoming directory: {e}"))?;
.map_err(|e| format!("Failed to create backlog directory: {e}"))?;
let filepath = bugs_dir.join(&filename);
if filepath.exists() {
@@ -276,7 +276,7 @@ pub fn create_bug_file(
// ── Spike file helpers ────────────────────────────────────────────
/// Create a spike file in `work/1_upcoming/` with a deterministic filename.
/// Create a spike file in `work/1_backlog/` with a deterministic filename.
///
/// Returns the spike_id (e.g. `"4_spike_filesystem_watcher_architecture"`).
pub fn create_spike_file(
@@ -292,11 +292,11 @@ pub fn create_spike_file(
}
let filename = format!("{spike_number}_spike_{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}"))?;
let backlog_dir = root.join(".story_kit").join("work").join("1_backlog");
fs::create_dir_all(&backlog_dir)
.map_err(|e| format!("Failed to create backlog directory: {e}"))?;
let filepath = upcoming_dir.join(&filename);
let filepath = backlog_dir.join(&filename);
if filepath.exists() {
return Err(format!("Spike file already exists: {filename}"));
}
@@ -338,7 +338,7 @@ pub fn create_spike_file(
Ok(spike_id)
}
/// Create a refactor work item file in `work/1_upcoming/`.
/// Create a refactor work item file in `work/1_backlog/`.
///
/// Returns the refactor_id (e.g. `"5_refactor_split_agents_rs"`).
pub fn create_refactor_file(
@@ -355,11 +355,11 @@ pub fn create_refactor_file(
}
let filename = format!("{refactor_number}_refactor_{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}"))?;
let backlog_dir = root.join(".story_kit").join("work").join("1_backlog");
fs::create_dir_all(&backlog_dir)
.map_err(|e| format!("Failed to create backlog directory: {e}"))?;
let filepath = upcoming_dir.join(&filename);
let filepath = backlog_dir.join(&filename);
if filepath.exists() {
return Err(format!("Refactor file already exists: {filename}"));
}
@@ -427,18 +427,18 @@ fn extract_bug_name(path: &Path) -> Option<String> {
None
}
/// List all open bugs — files in `work/1_upcoming/` matching the `_bug_` naming pattern.
/// List all open bugs — files in `work/1_backlog/` 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 upcoming_dir = root.join(".story_kit").join("work").join("1_upcoming");
if !upcoming_dir.exists() {
let backlog_dir = root.join(".story_kit").join("work").join("1_backlog");
if !backlog_dir.exists() {
return Ok(Vec::new());
}
let mut bugs = Vec::new();
for entry in
fs::read_dir(&upcoming_dir).map_err(|e| format!("Failed to read upcoming directory: {e}"))?
fs::read_dir(&backlog_dir).map_err(|e| format!("Failed to read backlog directory: {e}"))?
{
let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
let path = entry.path();
@@ -477,18 +477,18 @@ fn is_refactor_item(stem: &str) -> bool {
after_num.starts_with("_refactor_")
}
/// List all open refactors — files in `work/1_upcoming/` matching the `_refactor_` naming pattern.
/// List all open refactors — files in `work/1_backlog/` matching the `_refactor_` naming pattern.
///
/// Returns a sorted list of `(refactor_id, name)` pairs.
pub fn list_refactor_files(root: &Path) -> Result<Vec<(String, String)>, String> {
let upcoming_dir = root.join(".story_kit").join("work").join("1_upcoming");
if !upcoming_dir.exists() {
let backlog_dir = root.join(".story_kit").join("work").join("1_backlog");
if !backlog_dir.exists() {
return Ok(Vec::new());
}
let mut refactors = Vec::new();
for entry in fs::read_dir(&upcoming_dir)
.map_err(|e| format!("Failed to read upcoming directory: {e}"))?
for entry in fs::read_dir(&backlog_dir)
.map_err(|e| format!("Failed to read backlog directory: {e}"))?
{
let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?;
let path = entry.path();
@@ -525,11 +525,11 @@ pub fn list_refactor_files(root: &Path) -> Result<Vec<(String, String)>, String>
/// Locate a work item file by searching all active pipeline stages.
///
/// Searches in priority order: 2_current, 1_upcoming, 3_qa, 4_merge, 5_done, 6_archived.
/// Searches in priority order: 2_current, 1_backlog, 3_qa, 4_merge, 5_done, 6_archived.
fn find_story_file(project_root: &Path, story_id: &str) -> Result<PathBuf, String> {
let filename = format!("{story_id}.md");
let sk = project_root.join(".story_kit").join("work");
for stage in &["2_current", "1_upcoming", "3_qa", "4_merge", "5_done", "6_archived"] {
for stage in &["2_current", "1_backlog", "3_qa", "4_merge", "5_done", "6_archived"] {
let path = sk.join(stage).join(&filename);
if path.exists() {
return Ok(path);
@@ -778,7 +778,7 @@ 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;
for subdir in &["1_upcoming", "2_current", "3_qa", "4_merge", "5_done", "6_archived"] {
for subdir in &["1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived"] {
let dir = work_base.join(subdir);
if !dir.exists() {
continue;
@@ -973,10 +973,10 @@ pub fn validate_story_dirs(
) -> Result<Vec<StoryValidationResult>, String> {
let mut results = Vec::new();
// Directories to validate: work/2_current/ + work/1_upcoming/
// Directories to validate: work/2_current/ + work/1_backlog/
let dirs_to_validate: Vec<PathBuf> = vec![
root.join(".story_kit").join("work").join("2_current"),
root.join(".story_kit").join("work").join("1_upcoming"),
root.join(".story_kit").join("work").join("1_backlog"),
];
for dir in &dirs_to_validate {
@@ -1042,7 +1042,7 @@ mod tests {
let root = tmp.path().to_path_buf();
for (stage, id) in &[
("1_upcoming", "10_story_upcoming"),
("1_backlog", "10_story_upcoming"),
("2_current", "20_story_current"),
("3_qa", "30_story_qa"),
("4_merge", "40_story_merge"),
@@ -1060,8 +1060,8 @@ mod tests {
let ctx = crate::http::context::AppContext::new_test(root);
let state = load_pipeline_state(&ctx).unwrap();
assert_eq!(state.upcoming.len(), 1);
assert_eq!(state.upcoming[0].story_id, "10_story_upcoming");
assert_eq!(state.backlog.len(), 1);
assert_eq!(state.backlog[0].story_id, "10_story_upcoming");
assert_eq!(state.current.len(), 1);
assert_eq!(state.current[0].story_id, "20_story_current");
@@ -1164,15 +1164,15 @@ mod tests {
#[test]
fn load_upcoming_parses_metadata() {
let tmp = tempfile::tempdir().unwrap();
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
fs::create_dir_all(&upcoming).unwrap();
let backlog = tmp.path().join(".story_kit/work/1_backlog");
fs::create_dir_all(&backlog).unwrap();
fs::write(
upcoming.join("31_story_view_upcoming.md"),
backlog.join("31_story_view_upcoming.md"),
"---\nname: View Upcoming\n---\n# Story\n",
)
.unwrap();
fs::write(
upcoming.join("32_story_worktree.md"),
backlog.join("32_story_worktree.md"),
"---\nname: Worktree Orchestration\n---\n# Story\n",
)
.unwrap();
@@ -1189,11 +1189,11 @@ mod tests {
#[test]
fn load_upcoming_skips_non_md_files() {
let tmp = tempfile::tempdir().unwrap();
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
fs::create_dir_all(&upcoming).unwrap();
fs::write(upcoming.join(".gitkeep"), "").unwrap();
let backlog = tmp.path().join(".story_kit/work/1_backlog");
fs::create_dir_all(&backlog).unwrap();
fs::write(backlog.join(".gitkeep"), "").unwrap();
fs::write(
upcoming.join("31_story_example.md"),
backlog.join("31_story_example.md"),
"---\nname: A Story\n---\n",
)
.unwrap();
@@ -1208,16 +1208,16 @@ mod tests {
fn validate_story_dirs_valid_files() {
let tmp = tempfile::tempdir().unwrap();
let current = tmp.path().join(".story_kit/work/2_current");
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
let backlog = tmp.path().join(".story_kit/work/1_backlog");
fs::create_dir_all(&current).unwrap();
fs::create_dir_all(&upcoming).unwrap();
fs::create_dir_all(&backlog).unwrap();
fs::write(
current.join("28_story_todos.md"),
"---\nname: Show TODOs\n---\n# Story\n",
)
.unwrap();
fs::write(
upcoming.join("36_story_front_matter.md"),
backlog.join("36_story_front_matter.md"),
"---\nname: Enforce Front Matter\n---\n# Story\n",
)
.unwrap();
@@ -1302,7 +1302,7 @@ mod tests {
#[test]
fn next_item_number_empty_dirs() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join(".story_kit/work/1_upcoming");
let base = tmp.path().join(".story_kit/work/1_backlog");
fs::create_dir_all(&base).unwrap();
assert_eq!(next_item_number(tmp.path()).unwrap(), 1);
}
@@ -1310,13 +1310,13 @@ mod tests {
#[test]
fn next_item_number_scans_all_dirs() {
let tmp = tempfile::tempdir().unwrap();
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
let backlog = tmp.path().join(".story_kit/work/1_backlog");
let current = tmp.path().join(".story_kit/work/2_current");
let archived = tmp.path().join(".story_kit/work/5_done");
fs::create_dir_all(&upcoming).unwrap();
fs::create_dir_all(&backlog).unwrap();
fs::create_dir_all(&current).unwrap();
fs::create_dir_all(&archived).unwrap();
fs::write(upcoming.join("10_story_foo.md"), "").unwrap();
fs::write(backlog.join("10_story_foo.md"), "").unwrap();
fs::write(current.join("20_story_bar.md"), "").unwrap();
fs::write(archived.join("15_story_baz.md"), "").unwrap();
assert_eq!(next_item_number(tmp.path()).unwrap(), 21);
@@ -1334,9 +1334,9 @@ mod tests {
#[test]
fn create_story_writes_correct_content() {
let tmp = tempfile::tempdir().unwrap();
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
fs::create_dir_all(&upcoming).unwrap();
fs::write(upcoming.join("36_story_existing.md"), "").unwrap();
let backlog = tmp.path().join(".story_kit/work/1_backlog");
fs::create_dir_all(&backlog).unwrap();
fs::write(backlog.join("36_story_existing.md"), "").unwrap();
let number = next_item_number(tmp.path()).unwrap();
assert_eq!(number, 37);
@@ -1345,7 +1345,7 @@ mod tests {
assert_eq!(slug, "my_new_feature");
let filename = format!("{number}_{slug}.md");
let filepath = upcoming.join(&filename);
let filepath = backlog.join(&filename);
let mut content = String::new();
content.push_str("---\n");
@@ -1377,10 +1377,10 @@ mod tests {
let result = create_story_file(tmp.path(), name, None, None, false);
assert!(result.is_ok(), "create_story_file failed: {result:?}");
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
let backlog = tmp.path().join(".story_kit/work/1_backlog");
let story_id = result.unwrap();
let filename = format!("{story_id}.md");
let contents = fs::read_to_string(upcoming.join(&filename)).unwrap();
let contents = fs::read_to_string(backlog.join(&filename)).unwrap();
let meta = parse_front_matter(&contents).expect("front matter should be valid YAML");
assert_eq!(meta.name.as_deref(), Some(name));
@@ -1389,10 +1389,10 @@ mod tests {
#[test]
fn create_story_rejects_duplicate() {
let tmp = tempfile::tempdir().unwrap();
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
fs::create_dir_all(&upcoming).unwrap();
let backlog = tmp.path().join(".story_kit/work/1_backlog");
fs::create_dir_all(&backlog).unwrap();
let filepath = upcoming.join("1_story_my_feature.md");
let filepath = backlog.join("1_story_my_feature.md");
fs::write(&filepath, "existing").unwrap();
// Simulate the check
@@ -1511,17 +1511,17 @@ mod tests {
}
#[test]
fn find_story_file_searches_current_then_upcoming() {
fn find_story_file_searches_current_then_backlog() {
let tmp = tempfile::tempdir().unwrap();
let current = tmp.path().join(".story_kit/work/2_current");
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
let backlog = tmp.path().join(".story_kit/work/1_backlog");
fs::create_dir_all(&current).unwrap();
fs::create_dir_all(&upcoming).unwrap();
fs::create_dir_all(&backlog).unwrap();
// Only in upcoming
fs::write(upcoming.join("6_test.md"), "").unwrap();
// Only in backlog
fs::write(backlog.join("6_test.md"), "").unwrap();
let found = find_story_file(tmp.path(), "6_test").unwrap();
assert!(found.ends_with("1_upcoming/6_test.md") || found.ends_with("1_upcoming\\6_test.md"));
assert!(found.ends_with("1_backlog/6_test.md") || found.ends_with("1_backlog\\6_test.md"));
// Also in current — current should win
fs::write(current.join("6_test.md"), "").unwrap();
@@ -1724,19 +1724,19 @@ mod tests {
#[test]
fn next_item_number_increments_from_existing_bugs() {
let tmp = tempfile::tempdir().unwrap();
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
fs::create_dir_all(&upcoming).unwrap();
fs::write(upcoming.join("1_bug_crash.md"), "").unwrap();
fs::write(upcoming.join("3_bug_another.md"), "").unwrap();
let backlog = tmp.path().join(".story_kit/work/1_backlog");
fs::create_dir_all(&backlog).unwrap();
fs::write(backlog.join("1_bug_crash.md"), "").unwrap();
fs::write(backlog.join("3_bug_another.md"), "").unwrap();
assert_eq!(next_item_number(tmp.path()).unwrap(), 4);
}
#[test]
fn next_item_number_scans_archived_too() {
let tmp = tempfile::tempdir().unwrap();
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
let backlog = tmp.path().join(".story_kit/work/1_backlog");
let archived = tmp.path().join(".story_kit/work/5_done");
fs::create_dir_all(&upcoming).unwrap();
fs::create_dir_all(&backlog).unwrap();
fs::create_dir_all(&archived).unwrap();
fs::write(archived.join("5_bug_old.md"), "").unwrap();
assert_eq!(next_item_number(tmp.path()).unwrap(), 6);
@@ -1752,11 +1752,11 @@ mod tests {
#[test]
fn list_bug_files_excludes_archive_subdir() {
let tmp = tempfile::tempdir().unwrap();
let upcoming_dir = tmp.path().join(".story_kit/work/1_upcoming");
let backlog_dir = tmp.path().join(".story_kit/work/1_backlog");
let archived_dir = tmp.path().join(".story_kit/work/5_done");
fs::create_dir_all(&upcoming_dir).unwrap();
fs::create_dir_all(&backlog_dir).unwrap();
fs::create_dir_all(&archived_dir).unwrap();
fs::write(upcoming_dir.join("1_bug_open.md"), "# Bug 1: Open Bug\n").unwrap();
fs::write(backlog_dir.join("1_bug_open.md"), "# Bug 1: Open Bug\n").unwrap();
fs::write(archived_dir.join("2_bug_closed.md"), "# Bug 2: Closed Bug\n").unwrap();
let result = list_bug_files(tmp.path()).unwrap();
@@ -1768,11 +1768,11 @@ mod tests {
#[test]
fn list_bug_files_sorted_by_id() {
let tmp = tempfile::tempdir().unwrap();
let upcoming_dir = tmp.path().join(".story_kit/work/1_upcoming");
fs::create_dir_all(&upcoming_dir).unwrap();
fs::write(upcoming_dir.join("3_bug_third.md"), "# Bug 3: Third\n").unwrap();
fs::write(upcoming_dir.join("1_bug_first.md"), "# Bug 1: First\n").unwrap();
fs::write(upcoming_dir.join("2_bug_second.md"), "# Bug 2: Second\n").unwrap();
let backlog_dir = tmp.path().join(".story_kit/work/1_backlog");
fs::create_dir_all(&backlog_dir).unwrap();
fs::write(backlog_dir.join("3_bug_third.md"), "# Bug 3: Third\n").unwrap();
fs::write(backlog_dir.join("1_bug_first.md"), "# Bug 1: First\n").unwrap();
fs::write(backlog_dir.join("2_bug_second.md"), "# Bug 2: Second\n").unwrap();
let result = list_bug_files(tmp.path()).unwrap();
assert_eq!(result.len(), 3);
@@ -1810,7 +1810,7 @@ mod tests {
let filepath = tmp
.path()
.join(".story_kit/work/1_upcoming/1_bug_login_crash.md");
.join(".story_kit/work/1_backlog/1_bug_login_crash.md");
assert!(filepath.exists());
let contents = fs::read_to_string(&filepath).unwrap();
assert!(
@@ -1854,7 +1854,7 @@ mod tests {
)
.unwrap();
let filepath = tmp.path().join(".story_kit/work/1_upcoming/1_bug_some_bug.md");
let filepath = tmp.path().join(".story_kit/work/1_backlog/1_bug_some_bug.md");
let contents = fs::read_to_string(&filepath).unwrap();
assert!(
contents.starts_with("---\nname: \"Some Bug\"\n---"),
@@ -1876,7 +1876,7 @@ mod tests {
let filepath = tmp
.path()
.join(".story_kit/work/1_upcoming/1_spike_filesystem_watcher_architecture.md");
.join(".story_kit/work/1_backlog/1_spike_filesystem_watcher_architecture.md");
assert!(filepath.exists());
let contents = fs::read_to_string(&filepath).unwrap();
assert!(
@@ -1900,7 +1900,7 @@ mod tests {
create_spike_file(tmp.path(), "FS Watcher Spike", Some(description)).unwrap();
let filepath =
tmp.path().join(".story_kit/work/1_upcoming/1_spike_fs_watcher_spike.md");
tmp.path().join(".story_kit/work/1_backlog/1_spike_fs_watcher_spike.md");
let contents = fs::read_to_string(&filepath).unwrap();
assert!(contents.contains(description));
}
@@ -1910,7 +1910,7 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
create_spike_file(tmp.path(), "My Spike", None).unwrap();
let filepath = tmp.path().join(".story_kit/work/1_upcoming/1_spike_my_spike.md");
let filepath = tmp.path().join(".story_kit/work/1_backlog/1_spike_my_spike.md");
let contents = fs::read_to_string(&filepath).unwrap();
// Should have placeholder TBD in Question section
assert!(contents.contains("## Question\n\n- TBD\n"));
@@ -1931,10 +1931,10 @@ mod tests {
let result = create_spike_file(tmp.path(), name, None);
assert!(result.is_ok(), "create_spike_file failed: {result:?}");
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
let backlog = tmp.path().join(".story_kit/work/1_backlog");
let spike_id = result.unwrap();
let filename = format!("{spike_id}.md");
let contents = fs::read_to_string(upcoming.join(&filename)).unwrap();
let contents = fs::read_to_string(backlog.join(&filename)).unwrap();
let meta = parse_front_matter(&contents).expect("front matter should be valid YAML");
assert_eq!(meta.name.as_deref(), Some(name));
@@ -1943,9 +1943,9 @@ mod tests {
#[test]
fn create_spike_file_increments_from_existing_items() {
let tmp = tempfile::tempdir().unwrap();
let upcoming = tmp.path().join(".story_kit/work/1_upcoming");
fs::create_dir_all(&upcoming).unwrap();
fs::write(upcoming.join("5_story_existing.md"), "").unwrap();
let backlog = tmp.path().join(".story_kit/work/1_backlog");
fs::create_dir_all(&backlog).unwrap();
fs::write(backlog.join("5_story_existing.md"), "").unwrap();
let spike_id = create_spike_file(tmp.path(), "My Spike", None).unwrap();
assert!(spike_id.starts_with("6_spike_"), "expected spike number 6, got: {spike_id}");

View File

@@ -79,7 +79,7 @@ enum WsResponse {
},
/// Full pipeline state pushed on connect and after every work-item watcher event.
PipelineState {
upcoming: Vec<crate::http::workflow::UpcomingStory>,
backlog: Vec<crate::http::workflow::UpcomingStory>,
current: Vec<crate::http::workflow::UpcomingStory>,
qa: Vec<crate::http::workflow::UpcomingStory>,
merge: Vec<crate::http::workflow::UpcomingStory>,
@@ -160,7 +160,7 @@ impl From<WatcherEvent> for Option<WsResponse> {
impl From<PipelineState> for WsResponse {
fn from(s: PipelineState) -> Self {
WsResponse::PipelineState {
upcoming: s.upcoming,
backlog: s.backlog,
current: s.current,
qa: s.qa,
merge: s.merge,
@@ -695,7 +695,7 @@ mod tests {
agent: None,
};
let resp = WsResponse::PipelineState {
upcoming: vec![story],
backlog: vec![story],
current: vec![],
qa: vec![],
merge: vec![],
@@ -703,8 +703,8 @@ mod tests {
};
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["type"], "pipeline_state");
assert_eq!(json["upcoming"].as_array().unwrap().len(), 1);
assert_eq!(json["upcoming"][0]["story_id"], "10_story_test");
assert_eq!(json["backlog"].as_array().unwrap().len(), 1);
assert_eq!(json["backlog"][0]["story_id"], "10_story_test");
assert!(json["current"].as_array().unwrap().is_empty());
assert!(json["done"].as_array().unwrap().is_empty());
}
@@ -824,7 +824,7 @@ mod tests {
#[test]
fn pipeline_state_converts_to_ws_response() {
let state = PipelineState {
upcoming: vec![UpcomingStory {
backlog: vec![UpcomingStory {
story_id: "1_story_a".to_string(),
name: Some("Story A".to_string()),
error: None,
@@ -851,8 +851,8 @@ mod tests {
let resp: WsResponse = state.into();
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["type"], "pipeline_state");
assert_eq!(json["upcoming"].as_array().unwrap().len(), 1);
assert_eq!(json["upcoming"][0]["story_id"], "1_story_a");
assert_eq!(json["backlog"].as_array().unwrap().len(), 1);
assert_eq!(json["backlog"][0]["story_id"], "1_story_a");
assert_eq!(json["current"].as_array().unwrap().len(), 1);
assert_eq!(json["current"][0]["story_id"], "2_story_b");
assert!(json["qa"].as_array().unwrap().is_empty());
@@ -864,7 +864,7 @@ mod tests {
#[test]
fn empty_pipeline_state_converts_to_ws_response() {
let state = PipelineState {
upcoming: vec![],
backlog: vec![],
current: vec![],
qa: vec![],
merge: vec![],
@@ -873,7 +873,7 @@ mod tests {
let resp: WsResponse = state.into();
let json = serde_json::to_value(&resp).unwrap();
assert_eq!(json["type"], "pipeline_state");
assert!(json["upcoming"].as_array().unwrap().is_empty());
assert!(json["backlog"].as_array().unwrap().is_empty());
assert!(json["current"].as_array().unwrap().is_empty());
assert!(json["qa"].as_array().unwrap().is_empty());
assert!(json["merge"].as_array().unwrap().is_empty());
@@ -991,7 +991,7 @@ mod tests {
#[test]
fn pipeline_state_with_agent_converts_correctly() {
let state = PipelineState {
upcoming: vec![],
backlog: vec![],
current: vec![UpcomingStory {
story_id: "10_story_x".to_string(),
name: Some("Story X".to_string()),
@@ -1046,7 +1046,7 @@ mod tests {
let root = tmp.path().to_path_buf();
// Create minimal pipeline dirs so load_pipeline_state succeeds.
for stage in &["1_upcoming", "2_current", "3_qa", "4_merge"] {
for stage in &["1_backlog", "2_current", "3_qa", "4_merge"] {
std::fs::create_dir_all(root.join(".story_kit").join("work").join(stage)).unwrap();
}
@@ -1155,7 +1155,7 @@ mod tests {
assert_eq!(initial["type"], "pipeline_state");
// All stages should be empty arrays since no .md files were created.
assert!(initial["upcoming"].as_array().unwrap().is_empty());
assert!(initial["backlog"].as_array().unwrap().is_empty());
assert!(initial["current"].as_array().unwrap().is_empty());
assert!(initial["qa"].as_array().unwrap().is_empty());
assert!(initial["merge"].as_array().unwrap().is_empty());

View File

@@ -409,7 +409,7 @@ fn scaffold_story_kit(root: &Path) -> Result<(), String> {
// Create the work/ pipeline directories, each with a .gitkeep so empty dirs survive git clone
let work_stages = [
"1_upcoming",
"1_backlog",
"2_current",
"3_qa",
"4_merge",
@@ -1085,7 +1085,7 @@ mod tests {
let dir = tempdir().unwrap();
scaffold_story_kit(dir.path()).unwrap();
let stages = ["1_upcoming", "2_current", "3_qa", "4_merge", "5_done", "6_archived"];
let stages = ["1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived"];
for stage in &stages {
let path = dir.path().join(".story_kit/work").join(stage);
assert!(path.is_dir(), "work/{} should be a directory", stage);

View File

@@ -78,7 +78,7 @@ pub fn is_config_file(path: &Path, git_root: &Path) -> bool {
/// Map a pipeline directory name to a (action, commit-message-prefix) pair.
fn stage_metadata(stage: &str, item_id: &str) -> Option<(&'static str, String)> {
let (action, prefix) = match stage {
"1_upcoming" => ("create", format!("story-kit: create {item_id}")),
"1_backlog" => ("create", format!("story-kit: create {item_id}")),
"2_current" => ("start", format!("story-kit: start {item_id}")),
"3_qa" => ("qa", format!("story-kit: queue {item_id} for QA")),
"4_merge" => ("merge", format!("story-kit: queue {item_id} for merge")),
@@ -111,7 +111,7 @@ fn stage_for_path(path: &Path) -> Option<String> {
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str())?;
matches!(stage, "1_upcoming" | "2_current" | "3_qa" | "4_merge" | "5_done" | "6_archived")
matches!(stage, "1_backlog" | "2_current" | "3_qa" | "4_merge" | "5_done" | "6_archived")
.then(|| stage.to_string())
}
@@ -159,7 +159,7 @@ fn git_add_work_and_commit(git_root: &Path, message: &str) -> Result<bool, Strin
/// Intermediate stages (current, qa, merge, done) are transient pipeline state
/// that don't need to be committed — they're only relevant while the server is
/// running and are broadcast to WebSocket clients for real-time UI updates.
const COMMIT_WORTHY_STAGES: &[&str] = &["1_upcoming", "5_done", "6_archived"];
const COMMIT_WORTHY_STAGES: &[&str] = &["1_backlog", "5_done", "6_archived"];
/// Return `true` if changes in `stage` should be committed to git.
fn should_commit_stage(stage: &str) -> bool {
@@ -172,7 +172,7 @@ fn should_commit_stage(stage: &str) -> bool {
/// (they represent the destination of a move or a new file). Deletions are
/// captured by `git add -A .story_kit/work/` automatically.
///
/// Only terminal stages (`1_upcoming` and `6_archived`) trigger git commits.
/// Only terminal stages (`1_backlog` and `6_archived`) trigger git commits.
/// All stages broadcast a [`WatcherEvent`] so the frontend stays in sync.
fn flush_pending(
pending: &HashMap<PathBuf, String>,
@@ -574,13 +574,13 @@ mod tests {
fn flush_pending_commits_and_broadcasts_for_terminal_stage() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
let stage_dir = make_stage_dir(tmp.path(), "1_upcoming");
let stage_dir = make_stage_dir(tmp.path(), "1_backlog");
let story_path = stage_dir.join("42_story_foo.md");
fs::write(&story_path, "---\nname: test\n---\n").unwrap();
let (tx, mut rx) = tokio::sync::broadcast::channel(16);
let mut pending = HashMap::new();
pending.insert(story_path, "1_upcoming".to_string());
pending.insert(story_path, "1_backlog".to_string());
flush_pending(&pending, tmp.path(), &tx);
@@ -592,7 +592,7 @@ mod tests {
action,
commit_msg,
} => {
assert_eq!(stage, "1_upcoming");
assert_eq!(stage, "1_backlog");
assert_eq!(item_id, "42_story_foo");
assert_eq!(action, "create");
assert_eq!(commit_msg, "story-kit: create 42_story_foo");
@@ -660,7 +660,7 @@ mod tests {
#[test]
fn flush_pending_broadcasts_for_all_pipeline_stages() {
let stages = [
("1_upcoming", "create", "story-kit: create 10_story_x"),
("1_backlog", "create", "story-kit: create 10_story_x"),
("3_qa", "qa", "story-kit: queue 10_story_x for QA"),
("4_merge", "merge", "story-kit: queue 10_story_x for merge"),
("5_done", "done", "story-kit: done 10_story_x"),
@@ -792,10 +792,10 @@ mod tests {
}
#[test]
fn flush_pending_clears_merge_failure_when_moving_to_upcoming() {
fn flush_pending_clears_merge_failure_when_moving_to_backlog() {
let tmp = TempDir::new().unwrap();
init_git_repo(tmp.path());
let stage_dir = make_stage_dir(tmp.path(), "1_upcoming");
let stage_dir = make_stage_dir(tmp.path(), "1_backlog");
let story_path = stage_dir.join("51_story_reset.md");
fs::write(
&story_path,
@@ -805,14 +805,14 @@ mod tests {
let (tx, _rx) = tokio::sync::broadcast::channel(16);
let mut pending = HashMap::new();
pending.insert(story_path.clone(), "1_upcoming".to_string());
pending.insert(story_path.clone(), "1_backlog".to_string());
flush_pending(&pending, tmp.path(), &tx);
let contents = fs::read_to_string(&story_path).unwrap();
assert!(
!contents.contains("merge_failure"),
"merge_failure should be stripped when story lands in 1_upcoming"
"merge_failure should be stripped when story lands in 1_backlog"
);
}
@@ -937,7 +937,7 @@ mod tests {
#[test]
fn should_commit_stage_only_for_terminal_stages() {
// Terminal stages — should commit.
assert!(should_commit_stage("1_upcoming"));
assert!(should_commit_stage("1_backlog"));
assert!(should_commit_stage("5_done"));
assert!(should_commit_stage("6_archived"));
// Intermediate stages — broadcast-only, no commit.

View File

@@ -15,7 +15,7 @@ use tokio::sync::broadcast;
/// Human-readable display name for a pipeline stage directory.
pub fn stage_display_name(stage: &str) -> &'static str {
match stage {
"1_upcoming" => "Upcoming",
"1_backlog" => "Backlog",
"2_current" => "Current",
"3_qa" => "QA",
"4_merge" => "Merge",
@@ -27,11 +27,11 @@ pub fn stage_display_name(stage: &str) -> &'static str {
/// Infer the previous pipeline stage for a given destination stage.
///
/// Returns `None` for `1_upcoming` since items are created there (not
/// Returns `None` for `1_backlog` since items are created there (not
/// transitioned from another stage).
pub fn inferred_from_stage(to_stage: &str) -> Option<&'static str> {
match to_stage {
"2_current" => Some("Upcoming"),
"2_current" => Some("Backlog"),
"3_qa" => Some("Current"),
"4_merge" => Some("QA"),
"5_done" => Some("Merge"),
@@ -195,7 +195,7 @@ mod tests {
#[test]
fn stage_display_name_maps_all_known_stages() {
assert_eq!(stage_display_name("1_upcoming"), "Upcoming");
assert_eq!(stage_display_name("1_backlog"), "Backlog");
assert_eq!(stage_display_name("2_current"), "Current");
assert_eq!(stage_display_name("3_qa"), "QA");
assert_eq!(stage_display_name("4_merge"), "Merge");
@@ -208,7 +208,7 @@ mod tests {
#[test]
fn inferred_from_stage_returns_previous_stage() {
assert_eq!(inferred_from_stage("2_current"), Some("Upcoming"));
assert_eq!(inferred_from_stage("2_current"), Some("Backlog"));
assert_eq!(inferred_from_stage("3_qa"), Some("Current"));
assert_eq!(inferred_from_stage("4_merge"), Some("QA"));
assert_eq!(inferred_from_stage("5_done"), Some("Merge"));
@@ -216,8 +216,8 @@ mod tests {
}
#[test]
fn inferred_from_stage_returns_none_for_upcoming() {
assert_eq!(inferred_from_stage("1_upcoming"), None);
fn inferred_from_stage_returns_none_for_backlog() {
assert_eq!(inferred_from_stage("1_backlog"), None);
}
#[test]