Story 52: Mergemaster agent role with merge_agent_work MCP tool

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-20 17:36:35 +00:00
parent 9be55ad198
commit 9dab18d597
3 changed files with 763 additions and 19 deletions

View File

@@ -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='<brief summary of what you implemented>'). 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."

View File

@@ -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<MergeReport, String> {
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<String>,
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() {
// 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."
));
};
std::fs::create_dir_all(&archived_dir)
.map_err(|e| format!("Failed to create work/5_archived/ directory: {e}"))?;
std::fs::rename(&current_path, &archived_path)
std::fs::rename(&source_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/");
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(), current_path.as_path()],
&[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(&current_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), 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(&current_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");
}
}
}

View File

@@ -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<Value>) -> 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<String, String> {
))
}
// ── Mergemaster tool implementations ─────────────────────────────
async fn tool_merge_agent_work(args: &Value, ctx: &AppContext) -> Result<String, String> {
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<String, String> {
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 <base>..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<Vec<String>> {
@@ -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(&current_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());
}
}