From a25553f1bc2771ec25b2bf210280b0bc05911b18 Mon Sep 17 00:00:00 2001 From: Dave Date: Mon, 23 Feb 2026 20:27:09 +0000 Subject: [PATCH] story-kit: merge 78_story_create_spike_mcp_tool --- server/src/http/mcp.rs | 111 +++++++++++++++++++++++++- server/src/http/workflow.rs | 152 ++++++++++++++++++++++++++++++++++++ 2 files changed, 261 insertions(+), 2 deletions(-) diff --git a/server/src/http/mcp.rs b/server/src/http/mcp.rs index 40dca7b..9c0649b 100644 --- a/server/src/http/mcp.rs +++ b/server/src/http/mcp.rs @@ -3,7 +3,7 @@ use crate::config::ProjectConfig; use crate::http::context::AppContext; use crate::http::settings::get_editor_command_from_store; use crate::http::workflow::{ - check_criterion_in_file, create_bug_file, create_story_file, list_bug_files, + check_criterion_in_file, create_bug_file, create_spike_file, create_story_file, list_bug_files, load_upcoming_stories, validate_story_dirs, }; use crate::worktree; @@ -614,6 +614,24 @@ fn handle_tools_list(id: Option) -> JsonRpcResponse { "required": ["story_id", "criterion_index"] } }, + { + "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.", + "inputSchema": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Human-readable spike name" + }, + "description": { + "type": "string", + "description": "Optional description / question the spike aims to answer" + } + }, + "required": ["name"] + } + }, { "name": "create_bug", "description": "Create a bug file in .story_kit/bugs/ with a deterministic filename and auto-commit to master. Returns the bug_id.", @@ -769,6 +787,8 @@ async fn handle_tools_call( "accept_story" => tool_accept_story(&args, ctx), // Story mutation tools (auto-commit to master) "check_criterion" => tool_check_criterion(&args, ctx), + // Spike lifecycle tools + "create_spike" => tool_create_spike(&args, ctx), // Bug lifecycle tools "create_bug" => tool_create_bug(&args, ctx), "list_bugs" => tool_list_bugs(ctx), @@ -1174,6 +1194,21 @@ fn tool_check_criterion(args: &Value, ctx: &AppContext) -> Result Result { + let name = args + .get("name") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: name")?; + let description = args.get("description").and_then(|v| v.as_str()); + + let root = ctx.state.get_project_root()?; + let spike_id = create_spike_file(&root, name, description)?; + + Ok(format!("Created spike: {spike_id}")) +} + // ── Bug lifecycle tool implementations ─────────────────────────── fn tool_create_bug(args: &Value, ctx: &AppContext) -> Result { @@ -1499,13 +1534,14 @@ mod tests { assert!(!names.contains(&"report_completion")); assert!(names.contains(&"accept_story")); assert!(names.contains(&"check_criterion")); + assert!(names.contains(&"create_spike")); assert!(names.contains(&"create_bug")); assert!(names.contains(&"list_bugs")); assert!(names.contains(&"close_bug")); assert!(names.contains(&"merge_agent_work")); assert!(names.contains(&"move_story_to_merge")); assert!(names.contains(&"request_qa")); - assert_eq!(tools.len(), 25); + assert_eq!(tools.len(), 26); } #[test] @@ -2010,6 +2046,77 @@ mod tests { assert!(tmp.path().join(".story_kit/work/5_archived/1_bug_crash.md").exists()); } + // ── Spike lifecycle tool tests ───────────────────────────────────────── + + #[test] + fn create_spike_in_tools_list() { + let resp = handle_tools_list(Some(json!(1))); + let tools = resp.result.unwrap()["tools"].as_array().unwrap().clone(); + let tool = tools.iter().find(|t| t["name"] == "create_spike"); + assert!(tool.is_some(), "create_spike missing from tools list"); + let t = tool.unwrap(); + assert!(t["description"].is_string()); + let required = t["inputSchema"]["required"].as_array().unwrap(); + let req_names: Vec<&str> = required.iter().map(|v| v.as_str().unwrap()).collect(); + assert!(req_names.contains(&"name")); + // description is optional + assert!(!req_names.contains(&"description")); + } + + #[test] + fn tool_create_spike_missing_name() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_create_spike(&json!({}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("name")); + } + + #[test] + fn tool_create_spike_rejects_empty_name() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_create_spike(&json!({"name": "!!!"}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("alphanumeric")); + } + + #[test] + fn tool_create_spike_creates_file() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + + let result = tool_create_spike( + &json!({"name": "Compare Encoders", "description": "Which encoder is fastest?"}), + &ctx, + ) + .unwrap(); + + assert!(result.contains("1_spike_compare_encoders")); + let spike_file = tmp + .path() + .join(".story_kit/work/1_upcoming/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---")); + assert!(contents.contains("Which encoder is fastest?")); + } + + #[test] + fn tool_create_spike_creates_file_without_description() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + + 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"); + assert!(spike_file.exists()); + let contents = std::fs::read_to_string(&spike_file).unwrap(); + assert!(contents.starts_with("---\nname: \"My Spike\"\n---")); + assert!(contents.contains("## Question\n\n- TBD\n")); + } + // ── Mergemaster tool tests ───────────────────────────────────────────── #[test] diff --git a/server/src/http/workflow.rs b/server/src/http/workflow.rs index 26557c8..740c414 100644 --- a/server/src/http/workflow.rs +++ b/server/src/http/workflow.rs @@ -269,6 +269,70 @@ pub fn create_bug_file( Ok(bug_id) } +// ── Spike file helpers ──────────────────────────────────────────── + +/// Create a spike file in `work/1_upcoming/` with a deterministic filename. +/// +/// Returns the spike_id (e.g. `"4_spike_filesystem_watcher_architecture"`). +pub fn create_spike_file( + root: &Path, + name: &str, + description: Option<&str>, +) -> Result { + let spike_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!("{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 filepath = upcoming_dir.join(&filename); + if filepath.exists() { + return Err(format!("Spike file already exists: {filename}")); + } + + let spike_id = filepath + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_default() + .to_string(); + + let mut content = String::new(); + content.push_str("---\n"); + content.push_str(&format!("name: \"{}\"\n", name.replace('"', "\\\""))); + content.push_str("---\n\n"); + content.push_str(&format!("# Spike {spike_number}: {name}\n\n")); + content.push_str("## Question\n\n"); + if let Some(desc) = description { + content.push_str(desc); + content.push('\n'); + } else { + content.push_str("- TBD\n"); + } + content.push('\n'); + content.push_str("## Hypothesis\n\n"); + content.push_str("- TBD\n\n"); + content.push_str("## Timebox\n\n"); + content.push_str("- TBD\n\n"); + content.push_str("## Investigation Plan\n\n"); + content.push_str("- TBD\n\n"); + content.push_str("## Findings\n\n"); + content.push_str("- TBD\n\n"); + content.push_str("## Recommendation\n\n"); + content.push_str("- TBD\n"); + + fs::write(&filepath, &content).map_err(|e| format!("Failed to write spike file: {e}"))?; + + // Watcher handles the git commit asynchronously. + + Ok(spike_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 { @@ -1179,4 +1243,92 @@ mod tests { ); assert!(contents.contains("- [ ] Bug is fixed and verified")); } + + // ── create_spike_file tests ──────────────────────────────────────────────── + + #[test] + fn create_spike_file_writes_correct_content() { + let tmp = tempfile::tempdir().unwrap(); + + let spike_id = + create_spike_file(tmp.path(), "Filesystem Watcher Architecture", None).unwrap(); + + assert_eq!(spike_id, "1_spike_filesystem_watcher_architecture"); + + let filepath = tmp + .path() + .join(".story_kit/work/1_upcoming/1_spike_filesystem_watcher_architecture.md"); + assert!(filepath.exists()); + let contents = fs::read_to_string(&filepath).unwrap(); + assert!( + contents.starts_with("---\nname: \"Filesystem Watcher Architecture\"\n---"), + "spike file must start with YAML front matter" + ); + assert!(contents.contains("# Spike 1: Filesystem Watcher Architecture")); + assert!(contents.contains("## Question")); + assert!(contents.contains("## Hypothesis")); + assert!(contents.contains("## Timebox")); + assert!(contents.contains("## Investigation Plan")); + assert!(contents.contains("## Findings")); + assert!(contents.contains("## Recommendation")); + } + + #[test] + fn create_spike_file_uses_description_when_provided() { + let tmp = tempfile::tempdir().unwrap(); + let description = "What is the best approach for watching filesystem events?"; + + 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"); + let contents = fs::read_to_string(&filepath).unwrap(); + assert!(contents.contains(description)); + } + + #[test] + fn create_spike_file_uses_placeholder_when_no_description() { + 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 contents = fs::read_to_string(&filepath).unwrap(); + // Should have placeholder TBD in Question section + assert!(contents.contains("## Question\n\n- TBD\n")); + } + + #[test] + fn create_spike_file_rejects_empty_name() { + let tmp = tempfile::tempdir().unwrap(); + let result = create_spike_file(tmp.path(), "!!!", None); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("alphanumeric")); + } + + #[test] + fn create_spike_file_with_special_chars_in_name_produces_valid_yaml() { + let tmp = tempfile::tempdir().unwrap(); + let name = "Spike: compare \"fast\" vs slow encoders"; + 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 spike_id = result.unwrap(); + let filename = format!("{spike_id}.md"); + let contents = fs::read_to_string(upcoming.join(&filename)).unwrap(); + + let meta = parse_front_matter(&contents).expect("front matter should be valid YAML"); + assert_eq!(meta.name.as_deref(), Some(name)); + } + + #[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 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}"); + } }