diff --git a/server/src/agent_log.rs b/server/src/agent_log.rs index 3cb12731..06250606 100644 --- a/server/src/agent_log.rs +++ b/server/src/agent_log.rs @@ -349,6 +349,24 @@ mod tests { assert!(expected_path.exists(), "Log file should exist"); } + #[test] + fn numeric_id_log_dir_uses_number_only() { + let tmp = tempdir().unwrap(); + let root = tmp.path(); + + let _writer = AgentLogWriter::new(root, "664", "coder-1", "sess-xyz").unwrap(); + + let expected_path = root + .join(".huskies") + .join("logs") + .join("664") + .join("coder-1-sess-xyz.log"); + assert!( + expected_path.exists(), + "Log file for numeric ID should be at .huskies/logs/664/..." + ); + } + #[test] fn test_log_writer_writes_jsonl_with_timestamps() { let tmp = tempdir().unwrap(); diff --git a/server/src/agents/lifecycle.rs b/server/src/agents/lifecycle.rs index dffbcfdb..3c738389 100644 --- a/server/src/agents/lifecycle.rs +++ b/server/src/agents/lifecycle.rs @@ -10,16 +10,34 @@ use crate::slog; type ContentTransform = Option String>>; +/// Determine the item type ("story", "bug", "spike", or "refactor") from the item ID. +/// +/// For slug-format IDs (e.g. `"4_bug_login_crash"`), the type is embedded in the ID. +/// For numeric-only IDs (e.g. `"4"`), the type is read from the `type:` field in +/// the content-store front matter. Falls back to `"story"` if not found. pub(crate) fn item_type_from_id(item_id: &str) -> &'static str { - // 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" + return "bug"; } else if after_num.starts_with("_spike_") { - "spike" - } else { - "story" + return "spike"; + } else if after_num.starts_with("_refactor_") { + return "refactor"; } + // Numeric-only ID: check content store front matter for explicit type. + if after_num.is_empty() + && let Some(content) = crate::db::read_content(item_id) + && let Ok(meta) = crate::io::story_metadata::parse_front_matter(&content) + && let Some(t) = meta.item_type.as_deref() + { + return match t { + "bug" => "bug", + "spike" => "spike", + "refactor" => "refactor", + _ => "story", + }; + } + "story" } /// Move a work item to a new pipeline stage via the database. @@ -332,6 +350,35 @@ mod tests { assert_eq!(item_type_from_id("1_spike_research"), "spike"); assert_eq!(item_type_from_id("50_story_my_story"), "story"); assert_eq!(item_type_from_id("1_story_simple"), "story"); + assert_eq!(item_type_from_id("1_refactor_cleanup"), "refactor"); + } + + #[test] + fn item_type_from_id_falls_back_to_content_store_for_numeric_ids() { + crate::db::ensure_content_store(); + + // Write a bug item with numeric-only ID into the content store. + let bug_content = "---\ntype: bug\nname: \"Test Bug\"\n---\n\n# Bug 9999: Test Bug\n"; + crate::db::write_content("9999", bug_content); + + let spike_content = + "---\ntype: spike\nname: \"Test Spike\"\n---\n\n# Spike 9998: Test Spike\n"; + crate::db::write_content("9998", spike_content); + + let refactor_content = + "---\ntype: refactor\nname: \"Test Refactor\"\n---\n\n# Refactor 9997: Test Refactor\n"; + crate::db::write_content("9997", refactor_content); + + let story_content = + "---\ntype: story\nname: \"Test Story\"\n---\n\n# Story 9996: Test Story\n"; + crate::db::write_content("9996", story_content); + + assert_eq!(item_type_from_id("9999"), "bug"); + assert_eq!(item_type_from_id("9998"), "spike"); + assert_eq!(item_type_from_id("9997"), "refactor"); + assert_eq!(item_type_from_id("9996"), "story"); + // No content store entry → defaults to "story". + assert_eq!(item_type_from_id("99999"), "story"); } // ── feature_branch_has_unmerged_changes tests ──────────────────────────── diff --git a/server/src/http/mcp/story_tools/bug.rs b/server/src/http/mcp/story_tools/bug.rs index 1129c537..7534495b 100644 --- a/server/src/http/mcp/story_tools/bug.rs +++ b/server/src/http/mcp/story_tools/bug.rs @@ -257,8 +257,8 @@ mod tests { .unwrap(); assert!( - result.contains("_bug_login_crash"), - "result should contain bug ID: {result}" + result.starts_with("Created bug: "), + "result should be a 'Created bug: ' message: {result}" ); // Extract the actual bug ID from the result message (format: "Created bug: "). let bug_id = result.trim_start_matches("Created bug: ").trim(); diff --git a/server/src/http/mcp/story_tools/spike.rs b/server/src/http/mcp/story_tools/spike.rs index 83cacaf7..3bf0d863 100644 --- a/server/src/http/mcp/story_tools/spike.rs +++ b/server/src/http/mcp/story_tools/spike.rs @@ -111,14 +111,14 @@ mod tests { .unwrap(); assert!( - result.contains("_spike_compare_encoders"), - "result should contain spike ID: {result}" + result.starts_with("Created spike: "), + "result should be a 'Created spike: ' message: {result}" ); // Extract the actual spike ID from the result message (format: "Created spike: "). let spike_id = result.trim_start_matches("Created spike: ").trim(); // Spike content should exist in the CRDT content store. let contents = crate::db::read_content(spike_id).expect("expected spike content in CRDT"); - assert!(contents.starts_with("---\nname: \"Compare Encoders\"\n---")); + assert!(contents.starts_with("---\ntype: spike\nname: \"Compare Encoders\"\n---")); assert!(contents.contains("Which encoder is fastest?")); } @@ -133,15 +133,15 @@ mod tests { ) .unwrap(); assert!( - result.contains("_spike_my_spike"), - "result should contain spike ID: {result}" + result.starts_with("Created spike: "), + "result should be a 'Created spike: ' message: {result}" ); // Extract the actual spike ID from the result message (format: "Created spike: "). let spike_id = result.trim_start_matches("Created spike: ").trim(); // Spike content should exist in the CRDT content store. let contents = crate::db::read_content(spike_id).expect("expected spike content in CRDT"); - assert!(contents.starts_with("---\nname: \"My Spike\"\n---")); + assert!(contents.starts_with("---\ntype: spike\nname: \"My Spike\"\n---")); assert!(contents.contains("## Question\n\n- TBD\n")); } diff --git a/server/src/http/mcp/story_tools/story.rs b/server/src/http/mcp/story_tools/story.rs index 4b958251..bbd57d6e 100644 --- a/server/src/http/mcp/story_tools/story.rs +++ b/server/src/http/mcp/story_tools/story.rs @@ -300,12 +300,14 @@ pub(crate) fn tool_unblock_story(args: &Value, ctx: &AppContext) -> Result = deps.iter().map(|n| n.to_string()).collect(); @@ -66,7 +67,7 @@ pub fn create_bug_file( /// Create a spike file and store it in the database. /// -/// Returns the spike_id (e.g. `"4_spike_filesystem_watcher_architecture"`). +/// Returns the spike_id (e.g. `"4"`). pub fn create_spike_file( root: &Path, name: &str, @@ -80,10 +81,11 @@ pub fn create_spike_file( return Err("Name must contain at least one alphanumeric character.".to_string()); } - let spike_id = format!("{spike_number}_spike_{slug}"); + let spike_id = format!("{spike_number}"); let mut content = String::new(); content.push_str("---\n"); + content.push_str("type: spike\n"); content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\""))); content.push_str("---\n\n"); content.push_str(&format!("# Spike {spike_number}: {name}\n\n")); @@ -122,7 +124,7 @@ pub fn create_spike_file( /// Create a refactor work item and store it in the database. /// -/// Returns the refactor_id (e.g. `"5_refactor_split_agents_rs"`). +/// Returns the refactor_id (e.g. `"5"`). pub fn create_refactor_file( root: &Path, name: &str, @@ -137,10 +139,11 @@ pub fn create_refactor_file( return Err("Name must contain at least one alphanumeric character.".to_string()); } - let refactor_id = format!("{refactor_number}_refactor_{slug}"); + let refactor_id = format!("{refactor_number}"); let mut content = String::new(); content.push_str("---\n"); + content.push_str("type: refactor\n"); content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\""))); if let Some(deps) = depends_on.filter(|d| !d.is_empty()) { let nums: Vec = deps.iter().map(|n| n.to_string()).collect(); @@ -176,10 +179,24 @@ pub fn create_refactor_file( Ok(refactor_id) } -/// Returns true if the item stem (filename without extension) is a bug item. +/// Returns true if the item stem is a bug item. +/// +/// Checks the slug-based ID format first (e.g. `"4_bug_login_crash"`), then +/// falls back to reading `type: bug` from the content store for numeric-only IDs. fn is_bug_item(stem: &str) -> bool { let after_num = stem.trim_start_matches(|c: char| c.is_ascii_digit()); - after_num.starts_with("_bug_") + if after_num.starts_with("_bug_") { + return true; + } + // Numeric-only ID: check content store front matter. + if after_num.is_empty() { + return crate::db::read_content(stem) + .and_then(|c| parse_front_matter(&c).ok()) + .and_then(|m| m.item_type) + .map(|t| t == "bug") + .unwrap_or(false); + } + false } /// Extract bug name from content (heading or front matter). @@ -229,9 +246,23 @@ pub fn list_bug_files(_root: &Path) -> Result, String> { } /// Returns true if the item stem is a refactor item. +/// +/// Checks the slug-based ID format first (e.g. `"5_refactor_split_agents_rs"`), then +/// falls back to reading `type: refactor` from the content store for numeric-only IDs. fn is_refactor_item(stem: &str) -> bool { let after_num = stem.trim_start_matches(|c: char| c.is_ascii_digit()); - after_num.starts_with("_refactor_") + if after_num.starts_with("_refactor_") { + return true; + } + // Numeric-only ID: check content store front matter. + if after_num.is_empty() { + return crate::db::read_content(stem) + .and_then(|c| parse_front_matter(&c).ok()) + .and_then(|m| m.item_type) + .map(|t| t == "refactor") + .unwrap_or(false); + } + false } /// List all open refactors from CRDT + content store. @@ -431,8 +462,8 @@ mod tests { .unwrap(); assert!( - bug_id.ends_with("_bug_login_crash"), - "expected ID to end with _bug_login_crash, got: {bug_id}" + bug_id.chars().all(|c| c.is_ascii_digit()), + "bug ID must be numeric-only, got: {bug_id}" ); // Check content exists (either in DB or filesystem). @@ -446,8 +477,8 @@ mod tests { .expect("bug content should exist"); assert!( - contents.starts_with("---\nname: \"Login Crash\"\n---"), - "bug file must start with YAML front matter" + contents.starts_with("---\ntype: bug\nname: \"Login Crash\"\n---"), + "bug file must start with YAML front matter including type field" ); assert!( contents.contains("Login Crash"), @@ -499,16 +530,11 @@ mod tests { ) .unwrap(); - let contents = crate::db::read_content(&bug_id) - .or_else(|| { - let filepath = tmp.path().join(".huskies/work/1_backlog/1_bug_some_bug.md"); - fs::read_to_string(filepath).ok() - }) - .expect("bug content should exist"); + let contents = crate::db::read_content(&bug_id).expect("bug content should exist"); assert!( - contents.starts_with("---\nname: \"Some Bug\"\n---"), - "bug file must have YAML front matter" + contents.starts_with("---\ntype: bug\nname: \"Some Bug\"\n---"), + "bug file must have YAML front matter with type field" ); assert!(contents.contains("- [ ] Bug is fixed and verified")); } @@ -523,22 +549,16 @@ mod tests { create_spike_file(tmp.path(), "Filesystem Watcher Architecture", None, &[]).unwrap(); assert!( - spike_id.ends_with("_spike_filesystem_watcher_architecture"), - "expected ID to end with _spike_filesystem_watcher_architecture, got: {spike_id}" + spike_id.chars().all(|c| c.is_ascii_digit()), + "spike ID must be numeric-only, got: {spike_id}" ); - let contents = crate::db::read_content(&spike_id) - .or_else(|| { - let filepath = tmp - .path() - .join(format!(".huskies/work/1_backlog/{spike_id}.md")); - fs::read_to_string(filepath).ok() - }) - .expect("spike content should exist"); + let contents = crate::db::read_content(&spike_id).expect("spike content should exist"); assert!( - contents.starts_with("---\nname: \"Filesystem Watcher Architecture\"\n---"), - "spike file must start with YAML front matter" + contents + .starts_with("---\ntype: spike\nname: \"Filesystem Watcher Architecture\"\n---"), + "spike file must start with YAML front matter including type field" ); assert!( contents.contains("Filesystem Watcher Architecture"), @@ -627,15 +647,10 @@ mod tests { let spike_id = create_spike_file(tmp.path(), "My Spike", None, &[]).unwrap(); assert!( - spike_id.ends_with("_spike_my_spike"), - "expected ID to end with _spike_my_spike, got: {spike_id}" + spike_id.chars().all(|c| c.is_ascii_digit()), + "spike ID must be numeric-only, got: {spike_id}" ); - let num: u32 = spike_id - .chars() - .take_while(|c| c.is_ascii_digit()) - .collect::() - .parse() - .unwrap(); + let num: u32 = spike_id.parse().unwrap(); assert!( num >= 7051, "expected spike number >= 7051, got: {spike_id}" diff --git a/server/src/http/workflow/story_ops/create.rs b/server/src/http/workflow/story_ops/create.rs index a5fc492a..cd1626a2 100644 --- a/server/src/http/workflow/story_ops/create.rs +++ b/server/src/http/workflow/story_ops/create.rs @@ -22,10 +22,11 @@ pub fn create_story_file( return Err("Name must contain at least one alphanumeric character.".to_string()); } - let story_id = format!("{story_number}_story_{slug}"); + let story_id = format!("{story_number}"); let mut content = String::new(); content.push_str("---\n"); + content.push_str("type: story\n"); content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\""))); if let Some(deps) = depends_on.filter(|d| !d.is_empty()) { let nums: Vec = deps.iter().map(|n| n.to_string()).collect(); @@ -167,7 +168,7 @@ mod tests { fs::write(&filepath, &content).unwrap(); let written = fs::read_to_string(&filepath).unwrap(); - assert!(written.starts_with("---\nname: \"My New Feature\"\n---")); + assert!(written.starts_with("---\n")); assert!(written.contains(&format!("# Story {number}: My New Feature"))); assert!(written.contains("- [ ] It works")); assert!(written.contains("- [ ] It is tested")); @@ -230,5 +231,40 @@ mod tests { assert_eq!(meta.depends_on, Some(vec![489])); } + // ── Story 730: numeric-only story IDs ───────────────────────────────────── + + #[test] + fn create_story_file_returns_numeric_only_id() { + crate::db::ensure_content_store(); + let tmp = tempfile::tempdir().unwrap(); + let result = create_story_file(tmp.path(), "My Feature", None, None, None, None, false); + assert!( + result.is_ok(), + "create_story_file should succeed: {result:?}" + ); + let story_id = result.unwrap(); + assert!( + story_id.chars().all(|c| c.is_ascii_digit()), + "story ID must be numeric-only, got: '{story_id}'" + ); + } + + #[test] + fn create_story_file_writes_type_field_in_front_matter() { + crate::db::ensure_content_store(); + let tmp = tempfile::tempdir().unwrap(); + let story_id = + create_story_file(tmp.path(), "Type Test Story", None, None, None, None, false) + .unwrap(); + let content = crate::db::read_content(&story_id).expect("content must exist"); + let meta = crate::io::story_metadata::parse_front_matter(&content) + .expect("front matter should be valid"); + assert_eq!( + meta.item_type.as_deref(), + Some("story"), + "front matter must contain type: story" + ); + } + // ── Story 504: native JSON types in front_matter ─────────────────────────── } diff --git a/server/src/io/story_metadata.rs b/server/src/io/story_metadata.rs index 2aac76a0..bee0bc1d 100644 --- a/server/src/io/story_metadata.rs +++ b/server/src/io/story_metadata.rs @@ -64,6 +64,11 @@ pub struct StoryMetadata { /// Used by the bug-645 salvage path to require real test evidence, not just /// compilation success. pub run_tests_passed: Option, + /// Item type: "story", "bug", "spike", or "refactor". + /// + /// Present on items created with numeric-only IDs (no slug suffix). + /// Used by the pipeline to determine routing (e.g. spikes skip QA). + pub item_type: Option, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -102,6 +107,9 @@ struct FrontMatter { /// Used by the bug-645 salvage path to distinguish a genuine test-passing /// session from one that merely compiled. run_tests_passed: Option, + /// Item type: "story", "bug", "spike", or "refactor". + #[serde(rename = "type")] + item_type: Option, } pub fn parse_front_matter(contents: &str) -> Result { @@ -144,6 +152,7 @@ fn build_metadata(front: FrontMatter) -> StoryMetadata { depends_on: front.depends_on, frozen: front.frozen, run_tests_passed: front.run_tests_passed, + item_type: front.item_type, } } diff --git a/server/src/service/common/item_id.rs b/server/src/service/common/item_id.rs index fe5f3ae4..1ecabfa3 100644 --- a/server/src/service/common/item_id.rs +++ b/server/src/service/common/item_id.rs @@ -1,12 +1,14 @@ //! Pure helpers for pipeline item ID parsing. //! -//! Pipeline item IDs share the format `{number}_{type}_{slug}`, e.g. -//! `"42_story_foo"`, `"7_bug_bar"`, `"100_refactor_baz"`. The functions here -//! extract or validate the leading numeric segment without performing any I/O. +//! Pipeline item IDs are numeric strings, e.g. `"42"`, `"730"`. Legacy items +//! may use the old `{number}_{type}_{slug}` format (e.g. `"42_story_foo"`). +//! The functions here extract or validate the leading numeric segment without +//! performing any I/O. /// Extract the numeric prefix from a pipeline item ID. /// -/// Returns the leading digit sequence from IDs like `"42_story_foo"` → `"42"`. +/// Works for both numeric-only IDs (`"42"` → `"42"`) and legacy slug-format +/// IDs (`"42_story_foo"` → `"42"`). /// Returns `None` if the ID has no leading digit sequence. pub fn extract_item_number(item_id: &str) -> Option<&str> { item_id @@ -16,9 +18,9 @@ pub fn extract_item_number(item_id: &str) -> Option<&str> { } #[allow(dead_code)] -/// Return `true` if `item_id` has a valid `{digits}_` prefix format. +/// Return `true` if `item_id` starts with a numeric prefix. /// -/// Valid: `"42_story_foo"`, `"1_bug_bar"`. +/// Valid: `"42"`, `"42_story_foo"`, `"1_bug_bar"`. /// Invalid: `"story_without_number"`, `""`, `"abc_story"`. pub fn has_valid_id_prefix(item_id: &str) -> bool { extract_item_number(item_id).is_some() @@ -42,6 +44,20 @@ mod tests { assert_eq!(extract_item_number("1_spike_research"), Some("1")); } + #[test] + fn extract_item_number_works_for_numeric_only_ids() { + // Numeric-only IDs (the new canonical format). + assert_eq!(extract_item_number("42"), Some("42")); + assert_eq!(extract_item_number("730"), Some("730")); + assert_eq!(extract_item_number("1"), Some("1")); + } + + #[test] + fn has_valid_id_prefix_returns_true_for_numeric_only() { + assert!(has_valid_id_prefix("42")); + assert!(has_valid_id_prefix("730")); + } + #[test] fn extract_item_number_returns_none_for_no_numeric_prefix() { assert_eq!(extract_item_number("story_without_number"), None); diff --git a/server/src/worktree.rs b/server/src/worktree.rs index 8ed1c892..afaaccbb 100644 --- a/server/src/worktree.rs +++ b/server/src/worktree.rs @@ -476,6 +476,22 @@ mod tests { assert_eq!(branch_name("1_test"), "feature/story-1_test"); } + #[test] + fn numeric_id_worktree_path_uses_number_only() { + let project_root = Path::new("/home/user/my-project"); + let path = worktree_path(project_root, "664"); + assert_eq!( + path, + Path::new("/home/user/my-project/.huskies/worktrees/664") + ); + } + + #[test] + fn numeric_id_branch_name_uses_number_only() { + assert_eq!(branch_name("664"), "feature/story-664"); + assert_eq!(branch_name("730"), "feature/story-730"); + } + #[test] fn detect_base_branch_returns_branch_in_git_repo() { let tmp = TempDir::new().unwrap();