story-kit: merge 78_story_create_spike_mcp_tool

This commit is contained in:
Dave
2026-02-23 20:27:09 +00:00
parent 2b3063ace2
commit a25553f1bc
2 changed files with 261 additions and 2 deletions

View File

@@ -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<Value>) -> 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<String, String
))
}
// ── Spike lifecycle tool implementations ─────────────────────────
fn tool_create_spike(args: &Value, ctx: &AppContext) -> Result<String, String> {
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<String, String> {
@@ -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]

View File

@@ -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<String, String> {
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}");
}
}