story-kit: merge 78_story_create_spike_mcp_tool
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user