diff --git a/server/src/http/mcp/mod.rs b/server/src/http/mcp/mod.rs index 76601995..8debde48 100644 --- a/server/src/http/mcp/mod.rs +++ b/server/src/http/mcp/mod.rs @@ -513,6 +513,28 @@ fn handle_tools_list(id: Option) -> JsonRpcResponse { "required": ["story_id", "criterion_index"] } }, + { + "name": "edit_criterion", + "description": "Update the text of an existing acceptance criterion in place, preserving its checked/unchecked state. Uses a 0-based index counting all criteria (both checked and unchecked).", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (filename stem, e.g. '28_my_story')" + }, + "criterion_index": { + "type": "integer", + "description": "0-based index of the criterion to edit (counts all criteria)" + }, + "new_text": { + "type": "string", + "description": "New text for the criterion (without the '- [ ] ' or '- [x] ' prefix)" + } + }, + "required": ["story_id", "criterion_index", "new_text"] + } + }, { "name": "add_criterion", "description": "Add an acceptance criterion to an existing story file. Appends '- [ ] {criterion}' after the last existing criterion in the '## Acceptance Criteria' section. Auto-commits via the filesystem watcher.", @@ -1242,6 +1264,7 @@ async fn handle_tools_call(id: Option, params: &Value, ctx: &AppContext) "accept_story" => story_tools::tool_accept_story(&args, ctx), // Story mutation tools (auto-commit to master) "check_criterion" => story_tools::tool_check_criterion(&args, ctx), + "edit_criterion" => story_tools::tool_edit_criterion(&args, ctx), "add_criterion" => story_tools::tool_add_criterion(&args, ctx), "update_story" => story_tools::tool_update_story(&args, ctx), // Spike lifecycle tools @@ -1426,7 +1449,7 @@ mod tests { assert!(names.contains(&"loc_file")); assert!(names.contains(&"dump_crdt")); assert!(names.contains(&"get_version")); - assert_eq!(tools.len(), 63); + assert_eq!(tools.len(), 64); } #[test] diff --git a/server/src/http/mcp/story_tools.rs b/server/src/http/mcp/story_tools.rs index df9a98d4..9a1a90ec 100644 --- a/server/src/http/mcp/story_tools.rs +++ b/server/src/http/mcp/story_tools.rs @@ -5,8 +5,9 @@ use crate::agents::{ use crate::http::context::AppContext; use crate::http::workflow::{ add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file, - create_spike_file, create_story_file, list_bug_files, list_refactor_files, load_pipeline_state, - load_upcoming_stories, update_story_in_file, validate_story_dirs, + create_spike_file, create_story_file, edit_criterion_in_file, list_bug_files, + list_refactor_files, load_pipeline_state, load_upcoming_stories, update_story_in_file, + validate_story_dirs, }; use crate::io::story_metadata::{ check_archived_deps, check_archived_deps_from_list, parse_front_matter, parse_unchecked_todos, @@ -331,6 +332,28 @@ pub(super) fn tool_check_criterion(args: &Value, ctx: &AppContext) -> Result Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + let criterion_index = args + .get("criterion_index") + .and_then(|v| v.as_u64()) + .ok_or("Missing required argument: criterion_index")? as usize; + let new_text = args + .get("new_text") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: new_text")?; + + let root = ctx.state.get_project_root()?; + edit_criterion_in_file(&root, story_id, criterion_index, new_text)?; + + Ok(format!( + "Criterion {criterion_index} updated for story '{story_id}'." + )) +} + pub(super) fn tool_add_criterion(args: &Value, ctx: &AppContext) -> Result { let story_id = args .get("story_id") diff --git a/server/src/http/workflow/mod.rs b/server/src/http/workflow/mod.rs index 82901172..c3d05293 100644 --- a/server/src/http/workflow/mod.rs +++ b/server/src/http/workflow/mod.rs @@ -7,7 +7,8 @@ pub use bug_ops::{ create_bug_file, create_refactor_file, create_spike_file, list_bug_files, list_refactor_files, }; pub use story_ops::{ - add_criterion_to_file, check_criterion_in_file, create_story_file, update_story_in_file, + add_criterion_to_file, check_criterion_in_file, create_story_file, edit_criterion_in_file, + update_story_in_file, }; pub use test_results::{ read_test_results_from_story_file, write_coverage_baseline_to_story_file, diff --git a/server/src/http/workflow/story_ops.rs b/server/src/http/workflow/story_ops.rs index 4ccd184e..8f15178f 100644 --- a/server/src/http/workflow/story_ops.rs +++ b/server/src/http/workflow/story_ops.rs @@ -126,6 +126,64 @@ pub fn check_criterion_in_file( Ok(()) } +/// Edit the text of an existing acceptance criterion without changing its checked state. +/// +/// Finds the criterion at `criterion_index` (0-based, counting all criteria regardless +/// of checked state) and replaces its text with `new_text`. +pub fn edit_criterion_in_file( + project_root: &Path, + story_id: &str, + criterion_index: usize, + new_text: &str, +) -> Result<(), String> { + let contents = read_story_content(project_root, story_id)?; + + let mut count: usize = 0; + let mut found = false; + let new_lines: Vec = contents + .lines() + .map(|line| { + let trimmed = line.trim(); + let prefix = if trimmed.starts_with("- [ ] ") { + Some("- [ ] ") + } else if trimmed.starts_with("- [x] ") { + Some("- [x] ") + } else { + None + }; + if let Some(p) = prefix { + if count == criterion_index { + count += 1; + found = true; + let indent_len = line.len() - trimmed.len(); + let indent = &line[..indent_len]; + return format!("{indent}{p}{new_text}"); + } + count += 1; + } + line.to_string() + }) + .collect(); + + if !found { + return Err(format!( + "Criterion index {criterion_index} out of range. Story '{story_id}' has \ + {count} criteria (indices 0..{}).", + count.saturating_sub(1) + )); + } + + let mut new_str = new_lines.join("\n"); + if contents.ends_with('\n') { + new_str.push('\n'); + } + + let stage = story_stage(story_id).unwrap_or_else(|| "2_current".to_string()); + write_story_content(project_root, story_id, &stage, &new_str); + + Ok(()) +} + /// Add a new acceptance criterion to a story. /// /// Appends `- [ ] {criterion}` after the last existing criterion line in the