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:
@@ -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."
|
||||
|
||||
@@ -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(¤t_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(¤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), 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¤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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user