diff --git a/.story_kit/project.toml b/.story_kit/project.toml index b7ab2c0..ce3cb3e 100644 --- a/.story_kit/project.toml +++ b/.story_kit/project.toml @@ -74,3 +74,27 @@ max_turns = 50 max_budget_usd = 5.00 prompt = "You are working in a git worktree on story {{story_id}}. Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. Pick up the story from .story_kit/work/ - move it to work/2_current/ if needed. Follow the SDTW process through implementation and verification (Steps 1-3). The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools. Do NOT accept the story or merge - commit your work and stop. If the user asks to review your changes, tell them to run: cd \"{{worktree_path}}\" && git difftool {{base_branch}}...HEAD\n\nIMPORTANT: When all your work is committed, call report_completion as your FINAL action: report_completion(story_id='{{story_id}}', agent_name='{{agent_name}}', summary=''). The server will run cargo clippy and tests automatically to verify your work." system_prompt = "You are a full-stack engineer working autonomously in a git worktree. Follow the Story-Driven Test Workflow strictly. Run cargo clippy and biome checks before considering work complete. Commit all your work before finishing - use a descriptive commit message. Do not accept stories, move them to archived, or merge to master - a human will do that. Do not coordinate with other agents - focus on your assigned story. ALWAYS call report_completion as your absolute final action after committing." + +[[agent]] +name = "mergemaster" +role = "Merges completed coder work into master, runs quality gates, archives stories, and cleans up worktrees." +model = "sonnet" +max_turns = 30 +max_budget_usd = 3.00 +prompt = """You are the mergemaster agent for story {{story_id}}. Your job is to merge the completed coder work into master using the merge_agent_work MCP tool. + +Read CLAUDE.md first, then .story_kit/README.md to understand the dev process. + +## Your Workflow +1. Call merge_agent_work(story_id='{{story_id}}') via the MCP tool to trigger the full merge pipeline +2. Review the result: check success, had_conflicts, gates_passed, and gate_output +3. If merge succeeded and gates passed: report success to the human +4. If conflicts were found: report the conflict details so the human can resolve them +5. If gates failed after merge: report the failing output so a coder can fix it + +## Rules +- Do NOT implement code yourself +- Do NOT resolve complex conflicts yourself - report them clearly +- Your job is to trigger the merge pipeline and report results +- Call report_completion as your final action with a summary of what happened""" +system_prompt = "You are the mergemaster agent. Your sole responsibility is to trigger the merge_agent_work MCP tool and report the results. Do not write code. Do not resolve conflicts manually. Report success or failure clearly so the human can act." diff --git a/server/src/agents.rs b/server/src/agents.rs index fa3b338..1b8d330 100644 --- a/server/src/agents.rs +++ b/server/src/agents.rs @@ -580,6 +580,90 @@ impl AgentPool { Ok(report) } + /// Run the full mergemaster pipeline for a completed story: + /// + /// 1. Squash-merge the story's feature branch into the current branch (master). + /// 2. If conflicts are found: abort the merge and report them. + /// 3. If the merge succeeds: run quality gates (cargo clippy + tests + pnpm). + /// 4. If all gates pass: archive the story and clean up the worktree. + /// + /// Returns a `MergeReport` with full details of what happened. + pub async fn merge_agent_work( + &self, + project_root: &Path, + story_id: &str, + ) -> Result { + let branch = format!("feature/story-{story_id}"); + let wt_path = worktree::worktree_path(project_root, story_id); + let root = project_root.to_path_buf(); + let sid = story_id.to_string(); + let br = branch.clone(); + + // Run blocking operations (git + cargo) off the async runtime. + let (merge_success, had_conflicts, conflict_details, merge_output) = + tokio::task::spawn_blocking(move || run_squash_merge(&root, &br, &sid)) + .await + .map_err(|e| format!("Merge task panicked: {e}"))??; + + if !merge_success { + return Ok(MergeReport { + story_id: story_id.to_string(), + success: false, + had_conflicts, + conflict_details, + gates_passed: false, + gate_output: merge_output, + worktree_cleaned_up: false, + story_archived: false, + }); + } + + // Merge succeeded — run quality gates in the project root. + let root2 = project_root.to_path_buf(); + let (gates_passed, gate_output) = + tokio::task::spawn_blocking(move || run_merge_quality_gates(&root2)) + .await + .map_err(|e| format!("Gate check task panicked: {e}"))??; + + if !gates_passed { + return Ok(MergeReport { + story_id: story_id.to_string(), + success: true, + had_conflicts: false, + conflict_details: None, + gates_passed: false, + gate_output, + worktree_cleaned_up: false, + story_archived: false, + }); + } + + // Gates passed — archive the story. + let story_archived = move_story_to_archived(project_root, story_id).is_ok(); + + // Clean up the worktree if it exists. + let worktree_cleaned_up = if wt_path.exists() { + let config = crate::config::ProjectConfig::load(project_root) + .unwrap_or_default(); + worktree::remove_worktree_by_story_id(project_root, story_id, &config) + .await + .is_ok() + } else { + false + }; + + Ok(MergeReport { + story_id: story_id.to_string(), + success: true, + had_conflicts: false, + conflict_details: None, + gates_passed: true, + gate_output, + worktree_cleaned_up, + story_archived, + }) + } + /// Return the port this server is running on. #[allow(dead_code)] pub fn port(&self) -> u16 { @@ -656,6 +740,19 @@ impl AgentPool { } } +/// Result of a mergemaster merge operation. +#[derive(Debug, Serialize, Clone)] +pub struct MergeReport { + pub story_id: String, + pub success: bool, + pub had_conflicts: bool, + pub conflict_details: Option, + pub gates_passed: bool, + pub gate_output: String, + pub worktree_cleaned_up: bool, + pub story_archived: bool, +} + /// Stage one or more file paths and create a deterministic commit in the given git root. /// /// Pass deleted paths too so git stages their removal alongside any new files. @@ -694,6 +791,7 @@ pub fn git_stage_and_commit( /// Determine the work item type from its ID (new naming: `{N}_{type}_{slug}`). /// Returns "bug", "spike", or "story". +#[allow(dead_code)] fn item_type_from_id(item_id: &str) -> &'static str { // New format: {digits}_{type}_{slug} let after_num = item_id.trim_start_matches(|c: char| c.is_ascii_digit()); @@ -763,11 +861,13 @@ pub fn move_story_to_current(project_root: &Path, story_id: &str) -> Result<(), /// Move a story from `work/2_current/` to `work/5_archived/` and auto-commit. /// /// * If the story is in `2_current/`, it is moved to `5_archived/` and committed. +/// * If the story is in `4_merge/`, it is moved to `5_archived/` and committed. /// * If the story is already in `5_archived/`, this is a no-op (idempotent). -/// * If the story is not found in `2_current/` or `5_archived/`, an error is returned. +/// * If the story is not found in `2_current/`, `4_merge/`, or `5_archived/`, an error is returned. pub fn move_story_to_archived(project_root: &Path, story_id: &str) -> Result<(), String> { let sk = project_root.join(".story_kit").join("work"); let current_path = sk.join("2_current").join(format!("{story_id}.md")); + let merge_path = sk.join("4_merge").join(format!("{story_id}.md")); let archived_dir = sk.join("5_archived"); let archived_path = archived_dir.join(format!("{story_id}.md")); @@ -776,25 +876,71 @@ pub fn move_story_to_archived(project_root: &Path, story_id: &str) -> Result<(), return Ok(()); } - if current_path.exists() { - std::fs::create_dir_all(&archived_dir) - .map_err(|e| format!("Failed to create work/5_archived/ directory: {e}"))?; - std::fs::rename(¤t_path, &archived_path) - .map_err(|e| format!("Failed to move story '{story_id}' to 5_archived/: {e}"))?; - eprintln!("[lifecycle] Moved story '{story_id}' from work/2_current/ to work/5_archived/"); + // Check 2_current/ first, then 4_merge/ + let source_path = if current_path.exists() { + current_path.clone() + } else if merge_path.exists() { + merge_path.clone() + } else { + return Err(format!( + "Story '{story_id}' not found in work/2_current/ or work/4_merge/. Cannot accept story." + )); + }; - let msg = format!("story-kit: accept story {story_id}"); - git_stage_and_commit( - project_root, - &[archived_path.as_path(), current_path.as_path()], - &msg, - )?; + std::fs::create_dir_all(&archived_dir) + .map_err(|e| format!("Failed to create work/5_archived/ directory: {e}"))?; + std::fs::rename(&source_path, &archived_path) + .map_err(|e| format!("Failed to move story '{story_id}' to 5_archived/: {e}"))?; + + let from_dir = if source_path == current_path { + "work/2_current/" + } else { + "work/4_merge/" + }; + eprintln!("[lifecycle] Moved story '{story_id}' from {from_dir} to work/5_archived/"); + + let msg = format!("story-kit: accept story {story_id}"); + git_stage_and_commit( + project_root, + &[archived_path.as_path(), source_path.as_path()], + &msg, + ) +} + +/// Move a story/bug from `work/2_current/` to `work/4_merge/` and auto-commit. +/// +/// This stages a work item as ready for the mergemaster to pick up and merge into master. +/// Idempotent: if already in `4_merge/`, returns Ok without committing. +pub fn move_story_to_merge(project_root: &Path, story_id: &str) -> Result<(), String> { + let sk = project_root.join(".story_kit").join("work"); + let current_path = sk.join("2_current").join(format!("{story_id}.md")); + let merge_dir = sk.join("4_merge"); + let merge_path = merge_dir.join(format!("{story_id}.md")); + + if merge_path.exists() { + // Already in 4_merge/ — idempotent, nothing to do. return Ok(()); } - Err(format!( - "Story '{story_id}' not found in work/2_current/. Cannot accept story." - )) + if !current_path.exists() { + return Err(format!( + "Work item '{story_id}' not found in work/2_current/. Cannot move to 4_merge/." + )); + } + + std::fs::create_dir_all(&merge_dir) + .map_err(|e| format!("Failed to create work/4_merge/ directory: {e}"))?; + std::fs::rename(¤t_path, &merge_path) + .map_err(|e| format!("Failed to move '{story_id}' to 4_merge/: {e}"))?; + + eprintln!("[lifecycle] Moved '{story_id}' from work/2_current/ to work/4_merge/"); + + let msg = format!("story-kit: queue {story_id} for merge"); + git_stage_and_commit( + project_root, + &[merge_path.as_path(), current_path.as_path()], + &msg, + ) } /// Move a bug from `work/2_current/` or `work/1_upcoming/` to `work/5_archived/` and auto-commit. @@ -932,6 +1078,187 @@ fn run_acceptance_gates(path: &Path) -> Result<(bool, String), String> { Ok((all_passed, all_output)) } +// ── Mergemaster helpers ─────────────────────────────────────────────────────── + +/// Squash-merge a feature branch into the current branch in the project root. +/// +/// Returns `(success, had_conflicts, conflict_details, output)`. +fn run_squash_merge( + project_root: &Path, + branch: &str, + story_id: &str, +) -> Result<(bool, bool, Option, String), String> { + let mut all_output = String::new(); + + // ── git merge --squash ──────────────────────────────────────── + all_output.push_str(&format!("=== git merge --squash {branch} ===\n")); + let merge = Command::new("git") + .args(["merge", "--squash", branch]) + .current_dir(project_root) + .output() + .map_err(|e| format!("Failed to run git merge: {e}"))?; + + let merge_stdout = String::from_utf8_lossy(&merge.stdout).to_string(); + let merge_stderr = String::from_utf8_lossy(&merge.stderr).to_string(); + all_output.push_str(&merge_stdout); + all_output.push_str(&merge_stderr); + all_output.push('\n'); + + if !merge.status.success() { + // Conflicts detected — abort the merge and report. + let conflict_details = format!( + "Merge conflicts in branch '{branch}':\n{merge_stdout}{merge_stderr}" + ); + + // Abort the merge to restore clean state. + let _ = Command::new("git") + .args(["merge", "--abort"]) + .current_dir(project_root) + .output(); + + all_output.push_str("=== Merge aborted due to conflicts ===\n"); + return Ok((false, true, Some(conflict_details), all_output)); + } + + // ── git commit ───────────────────────────────────────────── + all_output.push_str("=== git commit ===\n"); + let commit_msg = format!("story-kit: merge {story_id}"); + let commit = Command::new("git") + .args(["commit", "-m", &commit_msg]) + .current_dir(project_root) + .output() + .map_err(|e| format!("Failed to run git commit: {e}"))?; + + let commit_stdout = String::from_utf8_lossy(&commit.stdout).to_string(); + let commit_stderr = String::from_utf8_lossy(&commit.stderr).to_string(); + all_output.push_str(&commit_stdout); + all_output.push_str(&commit_stderr); + all_output.push('\n'); + + if !commit.status.success() { + // Nothing to commit (e.g. empty diff) — treat as success. + if commit_stderr.contains("nothing to commit") + || commit_stdout.contains("nothing to commit") + { + return Ok((true, false, None, all_output)); + } + return Ok((false, false, None, all_output)); + } + + Ok((true, false, None, all_output)) +} + +/// Run quality gates in the project root after a successful merge. +/// +/// Runs: cargo clippy, cargo nextest run / cargo test, and pnpm gates if frontend/ exists. +/// Returns `(gates_passed, combined_output)`. +fn run_merge_quality_gates(project_root: &Path) -> Result<(bool, String), String> { + let mut all_output = String::new(); + let mut all_passed = true; + + // ── cargo clippy ────────────────────────────────────────────── + let clippy = Command::new("cargo") + .args(["clippy", "--all-targets", "--all-features"]) + .current_dir(project_root) + .output() + .map_err(|e| format!("Failed to run cargo clippy: {e}"))?; + + all_output.push_str("=== cargo clippy ===\n"); + let clippy_out = format!( + "{}{}", + String::from_utf8_lossy(&clippy.stdout), + String::from_utf8_lossy(&clippy.stderr) + ); + all_output.push_str(&clippy_out); + all_output.push('\n'); + + if !clippy.status.success() { + all_passed = false; + } + + // ── cargo nextest run (fallback: cargo test) ────────────────── + all_output.push_str("=== tests ===\n"); + + let (test_success, test_out) = match Command::new("cargo") + .args(["nextest", "run"]) + .current_dir(project_root) + .output() + { + Ok(o) => { + let combined = format!( + "{}{}", + String::from_utf8_lossy(&o.stdout), + String::from_utf8_lossy(&o.stderr) + ); + (o.status.success(), combined) + } + Err(_) => { + let o = Command::new("cargo") + .args(["test"]) + .current_dir(project_root) + .output() + .map_err(|e| format!("Failed to run cargo test: {e}"))?; + let combined = format!( + "{}{}", + String::from_utf8_lossy(&o.stdout), + String::from_utf8_lossy(&o.stderr) + ); + (o.status.success(), combined) + } + }; + + all_output.push_str(&test_out); + all_output.push('\n'); + + if !test_success { + all_passed = false; + } + + // ── pnpm (if frontend/ directory exists) ───────────────────── + let frontend_dir = project_root.join("frontend"); + if frontend_dir.exists() { + all_output.push_str("=== pnpm build ===\n"); + let pnpm_build = Command::new("pnpm") + .args(["run", "build"]) + .current_dir(&frontend_dir) + .output() + .map_err(|e| format!("Failed to run pnpm build: {e}"))?; + + let build_out = format!( + "{}{}", + String::from_utf8_lossy(&pnpm_build.stdout), + String::from_utf8_lossy(&pnpm_build.stderr) + ); + all_output.push_str(&build_out); + all_output.push('\n'); + + if !pnpm_build.status.success() { + all_passed = false; + } + + all_output.push_str("=== pnpm test ===\n"); + let pnpm_test = Command::new("pnpm") + .args(["test", "--run"]) + .current_dir(&frontend_dir) + .output() + .map_err(|e| format!("Failed to run pnpm test: {e}"))?; + + let test_out = format!( + "{}{}", + String::from_utf8_lossy(&pnpm_test.stdout), + String::from_utf8_lossy(&pnpm_test.stderr) + ); + all_output.push_str(&test_out); + all_output.push('\n'); + + if !pnpm_test.status.success() { + all_passed = false; + } + } + + Ok((all_passed, all_output)) +} + /// Spawn claude agent in a PTY and stream events through the broadcast channel. #[allow(clippy::too_many_arguments)] async fn run_agent_pty_streaming( @@ -1504,4 +1831,210 @@ mod tests { let log = String::from_utf8_lossy(&output.stdout); assert!(log.contains("story-kit: test commit"), "commit should appear in log: {log}"); } + + // ── move_story_to_merge tests ────────────────────────────────────────────── + + #[test] + fn move_story_to_merge_moves_file_and_commits() { + use std::fs; + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let repo = tmp.path(); + init_git_repo(repo); + + let current_dir = repo.join(".story_kit/work/2_current"); + fs::create_dir_all(¤t_dir).unwrap(); + let story_file = current_dir.join("20_story_my_story.md"); + fs::write(&story_file, "---\nname: Test\ntest_plan: approved\n---\n").unwrap(); + + Command::new("git") + .args(["add", "."]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "add story"]) + .current_dir(repo) + .output() + .unwrap(); + + move_story_to_merge(repo, "20_story_my_story").unwrap(); + + let merge_path = repo.join(".story_kit/work/4_merge/20_story_my_story.md"); + assert!(!story_file.exists(), "2_current file should be gone"); + assert!(merge_path.exists(), "4_merge file should exist"); + } + + #[test] + fn move_story_to_merge_idempotent_when_already_in_merge() { + use std::fs; + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let repo = tmp.path(); + init_git_repo(repo); + + let merge_dir = repo.join(".story_kit/work/4_merge"); + fs::create_dir_all(&merge_dir).unwrap(); + fs::write( + merge_dir.join("21_story_test.md"), + "---\nname: Test\ntest_plan: approved\n---\n", + ) + .unwrap(); + + // Should succeed without error even though there's nothing to move + move_story_to_merge(repo, "21_story_test").unwrap(); + assert!(merge_dir.join("21_story_test.md").exists()); + } + + #[test] + fn move_story_to_merge_errors_when_not_in_current() { + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let repo = tmp.path(); + init_git_repo(repo); + + let result = move_story_to_merge(repo, "99_nonexistent"); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("not found in work/2_current/")); + } + + // ── move_story_to_archived with 4_merge source ──────────────────────────── + + #[test] + fn move_story_to_archived_finds_in_merge_dir() { + use std::fs; + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let repo = tmp.path(); + init_git_repo(repo); + + let merge_dir = repo.join(".story_kit/work/4_merge"); + fs::create_dir_all(&merge_dir).unwrap(); + let story_file = merge_dir.join("22_story_test.md"); + fs::write(&story_file, "---\nname: Test\ntest_plan: approved\n---\n").unwrap(); + + Command::new("git") + .args(["add", "."]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "add story in merge"]) + .current_dir(repo) + .output() + .unwrap(); + + move_story_to_archived(repo, "22_story_test").unwrap(); + + let archived = repo.join(".story_kit/work/5_archived/22_story_test.md"); + assert!(!story_file.exists(), "4_merge file should be gone"); + assert!(archived.exists(), "5_archived file should exist"); + } + + #[test] + fn move_story_to_archived_error_when_not_in_current_or_merge() { + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let repo = tmp.path(); + init_git_repo(repo); + + let result = move_story_to_archived(repo, "99_nonexistent"); + assert!(result.is_err()); + let msg = result.unwrap_err(); + assert!(msg.contains("4_merge"), "error should mention 4_merge: {msg}"); + } + + // ── merge_agent_work tests ──────────────────────────────────────────────── + + #[tokio::test] + async fn merge_agent_work_returns_error_when_branch_not_found() { + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let repo = tmp.path(); + init_git_repo(repo); + + let pool = AgentPool::new(3001); + // branch feature/story-99_nonexistent does not exist + let result = pool + .merge_agent_work(repo, "99_nonexistent") + .await + .unwrap(); + // Should fail (no branch) — not panic + assert!(!result.success, "should fail when branch missing"); + } + + #[tokio::test] + async fn merge_agent_work_succeeds_on_clean_branch() { + use std::fs; + use tempfile::tempdir; + + let tmp = tempdir().unwrap(); + let repo = tmp.path(); + init_git_repo(repo); + + // Create a feature branch with a commit + Command::new("git") + .args(["checkout", "-b", "feature/story-23_test"]) + .current_dir(repo) + .output() + .unwrap(); + fs::write(repo.join("feature.txt"), "feature content").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "add feature"]) + .current_dir(repo) + .output() + .unwrap(); + + // Switch back to master (initial branch) + Command::new("git") + .args(["checkout", "master"]) + .current_dir(repo) + .output() + .unwrap(); + + // Create the story file in 4_merge/ so we can test archival + let merge_dir = repo.join(".story_kit/work/4_merge"); + fs::create_dir_all(&merge_dir).unwrap(); + let story_file = merge_dir.join("23_test.md"); + fs::write(&story_file, "---\nname: Test\ntest_plan: approved\n---\n").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(repo) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "add story in merge"]) + .current_dir(repo) + .output() + .unwrap(); + + let pool = AgentPool::new(3001); + let report = pool.merge_agent_work(repo, "23_test").await.unwrap(); + + // Merge should succeed (gates will run but cargo/pnpm results will depend on env) + // At minimum the merge itself should succeed + assert!(!report.had_conflicts, "should have no conflicts"); + // Note: gates_passed may be false in test env without Rust project, that's OK + // The important thing is the merge itself ran + assert!( + report.success || report.gate_output.contains("Failed to run") || !report.gates_passed, + "report should be coherent: {report:?}" + ); + // Story should be archived if gates passed + if report.story_archived { + let archived = repo.join(".story_kit/work/5_archived/23_test.md"); + assert!(archived.exists(), "archived file should exist"); + } + } } diff --git a/server/src/http/mcp.rs b/server/src/http/mcp.rs index 91c4a81..b2eb469 100644 --- a/server/src/http/mcp.rs +++ b/server/src/http/mcp.rs @@ -1,4 +1,4 @@ -use crate::agents::move_story_to_archived; +use crate::agents::{close_bug_to_archive, move_story_to_archived, move_story_to_merge}; use crate::config::ProjectConfig; use crate::http::context::AppContext; use crate::http::settings::get_editor_command_from_store; @@ -6,7 +6,6 @@ use crate::http::workflow::{ check_criterion_in_file, create_bug_file, create_story_file, list_bug_files, load_upcoming_stories, set_test_plan_in_file, validate_story_dirs, }; -use crate::agents::close_bug_to_archive; use crate::worktree; use crate::io::story_metadata::{parse_front_matter, parse_unchecked_todos}; use crate::workflow::{evaluate_acceptance_with_coverage, TestCaseResult, TestStatus}; @@ -711,6 +710,38 @@ fn handle_tools_list(id: Option) -> JsonRpcResponse { }, "required": ["bug_id"] } + }, + { + "name": "merge_agent_work", + "description": "Trigger the mergemaster pipeline for a completed story: squash-merge the feature branch into master, run quality gates (cargo clippy, cargo test, pnpm build, pnpm test), archive the story from work/4_merge/ or work/2_current/ to work/5_archived/, and clean up the worktree and branch. Reports success/failure with details including any conflicts found and gate output.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (e.g. '52_story_mergemaster_agent_role')" + }, + "agent_name": { + "type": "string", + "description": "Optional: name of the coder agent whose work is being merged (for logging)" + } + }, + "required": ["story_id"] + } + }, + { + "name": "move_story_to_merge", + "description": "Move a story or bug from work/2_current/ to work/4_merge/ to queue it for the mergemaster pipeline. Auto-commits to master.", + "inputSchema": { + "type": "object", + "properties": { + "story_id": { + "type": "string", + "description": "Story identifier (filename stem, e.g. '28_my_story')" + } + }, + "required": ["story_id"] + } } ] }), @@ -763,6 +794,9 @@ async fn handle_tools_call( "create_bug" => tool_create_bug(&args, ctx), "list_bugs" => tool_list_bugs(ctx), "close_bug" => tool_close_bug(&args, ctx), + // Mergemaster tools + "merge_agent_work" => tool_merge_agent_work(&args, ctx).await, + "move_story_to_merge" => tool_move_story_to_merge(&args, ctx), _ => Err(format!("Unknown tool: {tool_name}")), }; @@ -1275,6 +1309,57 @@ fn tool_close_bug(args: &Value, ctx: &AppContext) -> Result { )) } +// ── Mergemaster tool implementations ───────────────────────────── + +async fn tool_merge_agent_work(args: &Value, ctx: &AppContext) -> Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + let agent_name = args.get("agent_name").and_then(|v| v.as_str()); + + let project_root = ctx.agents.get_project_root(&ctx.state)?; + let report = ctx.agents.merge_agent_work(&project_root, story_id).await?; + + let status_msg = if report.success && report.gates_passed { + "Merge complete: all quality gates passed. Story archived and worktree cleaned up." + } else if report.had_conflicts { + "Merge failed: conflicts detected. Merge was aborted. Resolve conflicts manually and retry." + } else if report.success && !report.gates_passed { + "Merge committed but quality gates failed. Review gate_output and fix issues before re-running." + } else { + "Merge failed. Review gate_output for details." + }; + + serde_json::to_string_pretty(&json!({ + "story_id": story_id, + "agent_name": agent_name, + "success": report.success, + "had_conflicts": report.had_conflicts, + "conflict_details": report.conflict_details, + "gates_passed": report.gates_passed, + "gate_output": report.gate_output, + "worktree_cleaned_up": report.worktree_cleaned_up, + "story_archived": report.story_archived, + "message": status_msg, + })) + .map_err(|e| format!("Serialization error: {e}")) +} + +fn tool_move_story_to_merge(args: &Value, ctx: &AppContext) -> Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + + let project_root = ctx.agents.get_project_root(&ctx.state)?; + move_story_to_merge(&project_root, story_id)?; + + Ok(format!( + "Story '{story_id}' moved to work/4_merge/ and committed. Ready for mergemaster." + )) +} + /// Run `git log ..HEAD --oneline` in the worktree and return the commit /// summaries, or `None` if git is unavailable or there are no new commits. async fn get_worktree_commits(worktree_path: &str, base_branch: &str) -> Option> { @@ -1432,7 +1517,9 @@ mod tests { assert!(names.contains(&"create_bug")); assert!(names.contains(&"list_bugs")); assert!(names.contains(&"close_bug")); - assert_eq!(tools.len(), 24); + assert!(names.contains(&"merge_agent_work")); + assert!(names.contains(&"move_story_to_merge")); + assert_eq!(tools.len(), 26); } #[test] @@ -2021,4 +2108,104 @@ mod tests { assert!(!bug_file.exists()); assert!(tmp.path().join(".story_kit/work/5_archived/1_bug_crash.md").exists()); } + + // ── Mergemaster tool tests ───────────────────────────────────────────── + + #[test] + fn merge_agent_work_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"] == "merge_agent_work"); + assert!(tool.is_some(), "merge_agent_work 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(&"story_id")); + // agent_name is optional + assert!(!req_names.contains(&"agent_name")); + } + + #[test] + fn move_story_to_merge_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"] == "move_story_to_merge"); + assert!(tool.is_some(), "move_story_to_merge 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(&"story_id")); + } + + #[tokio::test] + async fn tool_merge_agent_work_missing_story_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_merge_agent_work(&json!({}), &ctx).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("story_id")); + } + + #[test] + fn tool_move_story_to_merge_missing_story_id() { + let tmp = tempfile::tempdir().unwrap(); + let ctx = test_ctx(tmp.path()); + let result = tool_move_story_to_merge(&json!({}), &ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("story_id")); + } + + #[test] + fn tool_move_story_to_merge_moves_file() { + let tmp = tempfile::tempdir().unwrap(); + setup_git_repo_in(tmp.path()); + let current_dir = tmp.path().join(".story_kit/work/2_current"); + std::fs::create_dir_all(¤t_dir).unwrap(); + let story_file = current_dir.join("24_story_test.md"); + std::fs::write(&story_file, "---\nname: Test\ntest_plan: approved\n---\n").unwrap(); + std::process::Command::new("git") + .args(["add", "."]) + .current_dir(tmp.path()) + .output() + .unwrap(); + std::process::Command::new("git") + .args(["commit", "-m", "add story"]) + .current_dir(tmp.path()) + .output() + .unwrap(); + + let ctx = test_ctx(tmp.path()); + let result = tool_move_story_to_merge(&json!({"story_id": "24_story_test"}), &ctx).unwrap(); + assert!(result.contains("4_merge")); + assert!(!story_file.exists(), "2_current file should be gone"); + assert!( + tmp.path().join(".story_kit/work/4_merge/24_story_test.md").exists(), + "4_merge file should exist" + ); + } + + #[tokio::test] + async fn tool_merge_agent_work_returns_coherent_report() { + let tmp = tempfile::tempdir().unwrap(); + setup_git_repo_in(tmp.path()); + let ctx = test_ctx(tmp.path()); + + // Try to merge a non-existent branch — should return a report (not panic) + let result = tool_merge_agent_work( + &json!({"story_id": "99_nonexistent", "agent_name": "coder-1"}), + &ctx, + ) + .await + .unwrap(); + let parsed: Value = serde_json::from_str(&result).unwrap(); + assert_eq!(parsed["story_id"], "99_nonexistent"); + assert_eq!(parsed["agent_name"], "coder-1"); + assert!(parsed.get("success").is_some()); + assert!(parsed.get("had_conflicts").is_some()); + assert!(parsed.get("gates_passed").is_some()); + assert!(parsed.get("gate_output").is_some()); + assert!(parsed.get("message").is_some()); + } }