diff --git a/server/src/http/mcp/mod.rs b/server/src/http/mcp/mod.rs index f3db0176..0e4bd633 100644 --- a/server/src/http/mcp/mod.rs +++ b/server/src/http/mcp/mod.rs @@ -339,6 +339,11 @@ fn handle_tools_list(id: Option) -> JsonRpcResponse { "items": { "type": "string" }, "description": "Optional list of acceptance criteria" }, + "depends_on": { + "type": "array", + "items": { "type": "integer" }, + "description": "Optional list of story IDs this story depends on; written as a YAML inline sequence in front matter" + }, "commit": { "type": "boolean", "description": "If true, git-add and git-commit the new story file to the current branch" diff --git a/server/src/http/mcp/story_tools.rs b/server/src/http/mcp/story_tools.rs index eff88eec..004482eb 100644 --- a/server/src/http/mcp/story_tools.rs +++ b/server/src/http/mcp/story_tools.rs @@ -23,6 +23,9 @@ pub(super) fn tool_create_story(args: &Value, ctx: &AppContext) -> Result> = args .get("acceptance_criteria") .and_then(|v| serde_json::from_value(v.clone()).ok()); + let depends_on: Option> = args + .get("depends_on") + .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_backlog/ and auto-commits with a deterministic message. let commit = false; @@ -33,6 +36,7 @@ pub(super) fn tool_create_story(args: &Value, ctx: &AppContext) -> Result, acceptance_criteria: Option<&[String]>, + depends_on: Option<&[u32]>, commit: bool, ) -> Result { let story_number = next_item_number(root)?; @@ -42,6 +43,10 @@ pub fn create_story_file( let mut content = String::new(); content.push_str("---\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(); + content.push_str(&format!("depends_on: [{}]\n", nums.join(", "))); + } content.push_str("---\n\n"); content.push_str(&format!("# Story {story_number}: {name}\n\n")); @@ -185,12 +190,16 @@ pub fn add_criterion_to_file( /// Encode a string value as a YAML scalar. /// -/// Booleans (`true`/`false`) and integers are written as native YAML types (unquoted). -/// Everything else is written as a quoted string to avoid ambiguity. +/// Booleans (`true`/`false`), integers, and inline sequences (`[...]`) are +/// written as native YAML types (unquoted). Everything else is written as a +/// quoted string to avoid ambiguity. fn yaml_encode_scalar(value: &str) -> String { match value { "true" | "false" => value.to_string(), s if s.parse::().is_ok() => s.to_string(), + // YAML inline sequences like [490] or [490, 491] — write unquoted so + // serde_yaml can deserialise them as Vec. + s if s.starts_with('[') && s.ends_with(']') => s.to_string(), s => format!("\"{}\"", s.replace('"', "\\\"").replace('\n', " ").replace('\r', "")), } } @@ -321,7 +330,7 @@ mod tests { fn create_story_with_colon_in_name_produces_valid_yaml() { let tmp = tempfile::tempdir().unwrap(); let name = "Server-owned agent completion: remove report_completion dependency"; - let result = create_story_file(tmp.path(), name, None, None, false); + let result = create_story_file(tmp.path(), name, None, None, None, false); assert!(result.is_ok(), "create_story_file failed: {result:?}"); let backlog = tmp.path().join(".huskies/work/1_backlog"); @@ -652,4 +661,73 @@ mod tests { let meta = parse_front_matter(&contents).expect("front matter should parse"); assert_eq!(meta.name.as_deref(), Some("My Story"), "name preserved after writing bool field"); } + + // ── Bug 493 regression tests ────────────────────────────────────────────── + + /// Bug 493 fix 1: update_story with depends_on as a string like "[490]" must + /// write the value unquoted so serde_yaml can deserialise it as Vec. + #[test] + fn update_story_depends_on_stored_as_yaml_array_not_quoted_string() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".huskies/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + let filepath = current.join("30_test.md"); + fs::write(&filepath, "---\nname: T\n---\n\nNo sections.\n").unwrap(); + + let mut fields = HashMap::new(); + fields.insert("depends_on".to_string(), "[490]".to_string()); + update_story_in_file(tmp.path(), "30_test", None, None, Some(&fields)).unwrap(); + + let result = fs::read_to_string(&filepath).unwrap(); + assert!(result.contains("depends_on: [490]"), "should be unquoted array: {result}"); + assert!(!result.contains("depends_on: \"[490]\""), "must not be quoted: {result}"); + + // Must round-trip through the parser correctly. + let meta = parse_front_matter(&result).expect("front matter should parse"); + assert_eq!(meta.depends_on, Some(vec![490])); + } + + /// Bug 493 fix 2: create_story_file with depends_on must write it as a + /// YAML front matter array, not as an acceptance criterion checkbox. + #[test] + fn create_story_with_depends_on_writes_front_matter_array() { + let tmp = tempfile::tempdir().unwrap(); + let story_id = create_story_file( + tmp.path(), + "Dependent Story", + None, + None, + Some(&[489]), + false, + ) + .unwrap(); + + let backlog = tmp.path().join(".huskies/work/1_backlog"); + let contents = fs::read_to_string(backlog.join(format!("{story_id}.md"))).unwrap(); + + // depends_on must be in front matter, not in AC text. + assert!(contents.contains("depends_on: [489]"), "missing front matter: {contents}"); + assert!(!contents.contains("- [ ] depends_on"), "must not appear as checkbox: {contents}"); + + let meta = parse_front_matter(&contents).expect("front matter should parse"); + assert_eq!(meta.depends_on, Some(vec![489])); + } + + /// Multi-element depends_on array round-trips correctly. + #[test] + fn update_story_depends_on_multi_element_array() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".huskies/work/2_current"); + fs::create_dir_all(¤t).unwrap(); + let filepath = current.join("31_test.md"); + fs::write(&filepath, "---\nname: T\n---\n\nNo sections.\n").unwrap(); + + let mut fields = HashMap::new(); + fields.insert("depends_on".to_string(), "[490, 491]".to_string()); + update_story_in_file(tmp.path(), "31_test", None, None, Some(&fields)).unwrap(); + + let result = fs::read_to_string(&filepath).unwrap(); + let meta = parse_front_matter(&result).expect("front matter should parse"); + assert_eq!(meta.depends_on, Some(vec![490, 491])); + } }