Story 44: Agent Completion Report via MCP

- report_completion MCP tool for agents to signal done
- Rejects if worktree has uncommitted changes
- Runs acceptance gates (clippy, tests) automatically
- Stores completion status on agent record
- 10 new tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-20 15:02:34 +00:00
parent 679370e48a
commit 1b71449dd0
5 changed files with 464 additions and 18 deletions

View File

@@ -23,6 +23,7 @@ Read CLAUDE.md first, then .story_kit/README.md to understand the dev process (S
## Your MCP Tools ## Your MCP Tools
You have these tools via the story-kit MCP server: You have these tools via the story-kit MCP server:
- start_agent(story_id, agent_name) - Start a coder agent on a story - start_agent(story_id, agent_name) - Start a coder agent on a story
- wait_for_agent(story_id, agent_name, timeout_ms) - Block until the agent reaches a terminal state (completed/failed). Returns final status including completion report with gates_passed.
- get_agent_output(story_id, agent_name, timeout_ms) - Poll agent output (returns recent events, call repeatedly) - get_agent_output(story_id, agent_name, timeout_ms) - Poll agent output (returns recent events, call repeatedly)
- list_agents() - See all running agents and their status - list_agents() - See all running agents and their status
- stop_agent(story_id, agent_name) - Stop a running agent - stop_agent(story_id, agent_name) - Stop a running agent
@@ -34,19 +35,18 @@ You have these tools via the story-kit MCP server:
2. Read the story file from .story_kit/stories/ to understand requirements 2. Read the story file from .story_kit/stories/ to understand requirements
3. Move it to current/ if it is in upcoming/ 3. Move it to current/ if it is in upcoming/
4. Start coder-1 on the story: call start_agent with story_id="{{story_id}}" and agent_name="coder-1" 4. Start coder-1 on the story: call start_agent with story_id="{{story_id}}" and agent_name="coder-1"
5. Monitor progress: call get_agent_output every 30-60 seconds to check on the agent 5. Wait for completion: call wait_for_agent with story_id="{{story_id}}" and agent_name="coder-1". The coder will call report_completion when done, which runs acceptance gates automatically. wait_for_agent returns when the coder reports completion.
6. If the agent completes, review the worktree changes 6. Check the result: inspect the "completion" field in the wait_for_agent response — if gates_passed is true, the work is done; if false, review the gate_output and decide whether to start a fresh coder.
7. If the agent gets stuck or fails, stop it and start a fresh agent 7. If the agent gets stuck or fails without calling report_completion, stop it and start a fresh agent.
8. When the work looks complete, call ensure_acceptance to verify all gates pass 8. STOP here. Do NOT accept the story or merge to master. Report the status to the human for final review and acceptance.
9. STOP here. Do NOT accept the story or merge to master. Report the status to the human for final review and acceptance.
## Rules ## Rules
- Do NOT implement code yourself - delegate to coder agents - Do NOT implement code yourself - delegate to coder agents
- Only run one coder at a time per story - Only run one coder at a time per story
- Focus on coordination, monitoring, and quality review - Focus on coordination, monitoring, and quality review
- Never accept stories or merge to master - that is the human's job - Never accept stories or merge to master - that is the human's job
- Your job ends when ensure_acceptance passes and you have reported the result""" - Your job ends when the coder's completion report shows gates_passed=true and you have reported the result"""
system_prompt = "You are a supervisor agent. Read CLAUDE.md and .story_kit/README.md first to understand the project dev process. Use MCP tools to coordinate sub-agents. Never implement code directly - always delegate to coder agents and monitor their progress. Never accept stories or merge to master - get all gates green and report to the human." system_prompt = "You are a supervisor agent. Read CLAUDE.md and .story_kit/README.md first to understand the project dev process. Use MCP tools to coordinate sub-agents. Never implement code directly - always delegate to coder agents and monitor their progress. Use wait_for_agent to block until the coder calls report_completion (which runs acceptance gates automatically). Never accept stories or merge to master - get all gates green and report to the human."
[[agent]] [[agent]]
name = "coder-1" name = "coder-1"
@@ -54,8 +54,8 @@ role = "Full-stack engineer. Implements features across all components."
model = "sonnet" model = "sonnet"
max_turns = 50 max_turns = 50
max_budget_usd = 5.00 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/stories/ - move it to 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" 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/stories/ - move it to 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." 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]] [[agent]]
name = "coder-2" name = "coder-2"
@@ -63,8 +63,8 @@ role = "Full-stack engineer. Implements features across all components."
model = "sonnet" model = "sonnet"
max_turns = 50 max_turns = 50
max_budget_usd = 5.00 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/stories/ - move it to 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" 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/stories/ - move it to 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." 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]] [[agent]]
name = "coder-3" name = "coder-3"
@@ -72,5 +72,5 @@ role = "Full-stack engineer. Implements features across all components."
model = "sonnet" model = "sonnet"
max_turns = 50 max_turns = 50
max_budget_usd = 5.00 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/stories/ - move it to 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" 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/stories/ - move it to 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." 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."

View File

@@ -1,6 +1,6 @@
--- ---
name: Agent Completion Report via MCP name: Agent Completion Report via MCP
test_plan: pending test_plan: approved
--- ---
# Story 44: Agent Completion Report via MCP # Story 44: Agent Completion Report via MCP
@@ -12,11 +12,29 @@ As an agent finishing work on a story, I want to report my completion status via
## Acceptance Criteria ## Acceptance Criteria
- [ ] MCP tool report_completion(story_id, agent_name, summary) allows agents to signal they are done - [ ] MCP tool report_completion(story_id, agent_name, summary) allows agents to signal they are done
- [ ] Server auto-commits all changes in the worktree (git add -A && git commit) with a deterministic message before running gates — agents do NOT need to commit themselves - [ ] Server rejects the report if the agent's worktree has uncommitted changes
- [ ] Server runs acceptance gates (clippy, tests) automatically on report - [ ] Server runs acceptance gates (clippy, tests) automatically on report
- [ ] Completion status and results are stored on the agent record for retrieval by wait_for_agent or the supervisor - [ ] Completion status and results are stored on the agent record for retrieval by wait_for_agent or the supervisor
- [ ] Agent prompts are updated to call report_completion as their final action - [ ] Agent prompts are updated to call report_completion as their final action
## Test Plan
### Unit Tests (agents.rs)
- `report_completion_rejects_nonexistent_agent` — calling on a non-existent agent returns Err
- `report_completion_rejects_non_running_agent` — calling on an already-Completed agent returns Err
- `report_completion_rejects_dirty_worktree` — calling with uncommitted changes returns Err containing "uncommitted"
- `report_completion_stores_result_and_transitions_status` — with a clean real git worktree, completes and stores a CompletionReport
### Unit Tests (mcp.rs)
- `report_completion_in_tools_list` — tool appears in handle_tools_list output
- `report_completion_tool_missing_story_id` — returns Err mentioning "story_id"
- `report_completion_tool_missing_agent_name` — returns Err mentioning "agent_name"
- `report_completion_tool_missing_summary` — returns Err mentioning "summary"
- `report_completion_tool_nonexistent_agent` — isError response for unknown agent
- `wait_for_agent_includes_completion_field` — wait_for_agent JSON output has "completion" key
## Out of Scope ## Out of Scope
- TBD - Frontend UI for displaying completion reports
- Persisting completion reports to disk across server restarts
- Running biome/frontend checks in the acceptance gates (Rust only for now)

View File

@@ -5,6 +5,7 @@ use serde::Serialize;
use std::collections::HashMap; use std::collections::HashMap;
use std::io::{BufRead, BufReader}; use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tokio::sync::broadcast; use tokio::sync::broadcast;
@@ -69,6 +70,14 @@ impl std::fmt::Display for AgentStatus {
} }
} }
/// Report produced by an agent calling `report_completion`.
#[derive(Debug, Serialize, Clone)]
pub struct CompletionReport {
pub summary: String,
pub gates_passed: bool,
pub gate_output: String,
}
#[derive(Debug, Serialize, Clone)] #[derive(Debug, Serialize, Clone)]
pub struct AgentInfo { pub struct AgentInfo {
pub story_id: String, pub story_id: String,
@@ -77,6 +86,7 @@ pub struct AgentInfo {
pub session_id: Option<String>, pub session_id: Option<String>,
pub worktree_path: Option<String>, pub worktree_path: Option<String>,
pub base_branch: Option<String>, pub base_branch: Option<String>,
pub completion: Option<CompletionReport>,
} }
struct StoryAgent { struct StoryAgent {
@@ -88,6 +98,8 @@ struct StoryAgent {
task_handle: Option<tokio::task::JoinHandle<()>>, task_handle: Option<tokio::task::JoinHandle<()>>,
/// Accumulated events for polling via get_agent_output. /// Accumulated events for polling via get_agent_output.
event_log: Arc<Mutex<Vec<AgentEvent>>>, event_log: Arc<Mutex<Vec<AgentEvent>>>,
/// Set when the agent calls report_completion.
completion: Option<CompletionReport>,
} }
/// Build an `AgentInfo` snapshot from a `StoryAgent` map entry. /// Build an `AgentInfo` snapshot from a `StoryAgent` map entry.
@@ -105,6 +117,7 @@ fn agent_info_from_entry(story_id: &str, agent: &StoryAgent) -> AgentInfo {
.worktree_info .worktree_info
.as_ref() .as_ref()
.map(|wt| wt.base_branch.clone()), .map(|wt| wt.base_branch.clone()),
completion: agent.completion.clone(),
} }
} }
@@ -179,6 +192,7 @@ impl AgentPool {
tx: tx.clone(), tx: tx.clone(),
task_handle: None, task_handle: None,
event_log: event_log.clone(), event_log: event_log.clone(),
completion: None,
}, },
); );
} }
@@ -269,6 +283,7 @@ impl AgentPool {
session_id: None, session_id: None,
worktree_path: Some(wt_path_str), worktree_path: Some(wt_path_str),
base_branch: Some(wt_info.base_branch.clone()), base_branch: Some(wt_info.base_branch.clone()),
completion: None,
}) })
} }
@@ -431,6 +446,7 @@ impl AgentPool {
session_id, session_id,
worktree_path: None, worktree_path: None,
base_branch: None, base_branch: None,
completion: None,
} }
}); });
} }
@@ -476,6 +492,91 @@ impl AgentPool {
worktree::create_worktree(project_root, story_id, &config, self.port).await worktree::create_worktree(project_root, story_id, &config, self.port).await
} }
/// Report that an agent has finished work on a story.
///
/// - Rejects with an error if the worktree has uncommitted changes.
/// - Runs acceptance gates (cargo clippy + cargo nextest run / cargo test).
/// - Stores the `CompletionReport` on the agent record.
/// - Transitions status to `Completed` (gates passed) or `Failed` (gates failed).
/// - Emits a `Done` event so `wait_for_agent` unblocks.
pub async fn report_completion(
&self,
story_id: &str,
agent_name: &str,
summary: &str,
) -> Result<CompletionReport, String> {
let key = composite_key(story_id, agent_name);
// Verify agent exists, is Running, and grab its worktree path.
let worktree_path = {
let agents = self.agents.lock().map_err(|e| e.to_string())?;
let agent = agents
.get(&key)
.ok_or_else(|| format!("No agent '{agent_name}' for story '{story_id}'"))?;
if agent.status != AgentStatus::Running {
return Err(format!(
"Agent '{agent_name}' for story '{story_id}' is not running (status: {}). \
report_completion can only be called by a running agent.",
agent.status
));
}
agent
.worktree_info
.as_ref()
.map(|wt| wt.path.clone())
.ok_or_else(|| {
format!(
"Agent '{agent_name}' for story '{story_id}' has no worktree. \
Cannot run acceptance gates."
)
})?
};
let path = worktree_path.clone();
// Run gate checks in a blocking thread to avoid stalling the async runtime.
let (gates_passed, gate_output) = tokio::task::spawn_blocking(move || {
// Step 1: Reject if worktree is dirty.
check_uncommitted_changes(&path)?;
// Step 2: Run clippy + tests and return (passed, output).
run_acceptance_gates(&path)
})
.await
.map_err(|e| format!("Gate check task panicked: {e}"))??;
let report = CompletionReport {
summary: summary.to_string(),
gates_passed,
gate_output,
};
// Store the completion report and advance status.
let (tx, session_id) = {
let mut agents = self.agents.lock().map_err(|e| e.to_string())?;
let agent = agents.get_mut(&key).ok_or_else(|| {
format!("Agent '{agent_name}' for story '{story_id}' disappeared during gate check")
})?;
agent.completion = Some(report.clone());
agent.status = if gates_passed {
AgentStatus::Completed
} else {
AgentStatus::Failed
};
(agent.tx.clone(), agent.session_id.clone())
};
// Emit Done so wait_for_agent unblocks.
let _ = tx.send(AgentEvent::Done {
story_id: story_id.to_string(),
agent_name: agent_name.to_string(),
session_id,
});
Ok(report)
}
/// Return the port this server is running on. /// Return the port this server is running on.
pub fn port(&self) -> u16 { pub fn port(&self) -> u16 {
self.port self.port
@@ -511,10 +612,135 @@ impl AgentPool {
tx: tx.clone(), tx: tx.clone(),
task_handle: None, task_handle: None,
event_log: Arc::new(Mutex::new(Vec::new())), event_log: Arc::new(Mutex::new(Vec::new())),
completion: None,
}, },
); );
tx tx
} }
/// Test helper: inject an agent with a specific worktree path for testing
/// gate-related logic.
#[cfg(test)]
pub fn inject_test_agent_with_path(
&self,
story_id: &str,
agent_name: &str,
status: AgentStatus,
worktree_path: PathBuf,
) -> broadcast::Sender<AgentEvent> {
let (tx, _) = broadcast::channel::<AgentEvent>(64);
let key = composite_key(story_id, agent_name);
let mut agents = self.agents.lock().unwrap();
agents.insert(
key,
StoryAgent {
agent_name: agent_name.to_string(),
status,
worktree_info: Some(WorktreeInfo {
path: worktree_path,
branch: format!("feature/story-{story_id}"),
base_branch: "master".to_string(),
}),
session_id: None,
tx: tx.clone(),
task_handle: None,
event_log: Arc::new(Mutex::new(Vec::new())),
completion: None,
},
);
tx
}
}
// ── Acceptance-gate helpers ───────────────────────────────────────────────────
/// Check whether the given directory has any uncommitted git changes.
/// Returns `Err` with a descriptive message if there are any.
fn check_uncommitted_changes(path: &Path) -> Result<(), String> {
let output = Command::new("git")
.args(["status", "--porcelain"])
.current_dir(path)
.output()
.map_err(|e| format!("Failed to run git status: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout);
if !stdout.trim().is_empty() {
return Err(format!(
"Worktree has uncommitted changes. Commit your work before calling \
report_completion:\n{stdout}"
));
}
Ok(())
}
/// Run `cargo clippy` and `cargo nextest run` (falling back to `cargo test`) in
/// the given directory. Returns `(gates_passed, combined_output)`.
fn run_acceptance_gates(path: &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(path)
.output()
.map_err(|e| format!("Failed to run cargo clippy: {e}"))?;
all_output.push_str("=== cargo clippy ===\n");
let clippy_stdout = String::from_utf8_lossy(&clippy.stdout);
let clippy_stderr = String::from_utf8_lossy(&clippy.stderr);
if !clippy_stdout.is_empty() {
all_output.push_str(&clippy_stdout);
}
if !clippy_stderr.is_empty() {
all_output.push_str(&clippy_stderr);
}
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(path)
.output()
{
Ok(o) => {
let combined = format!(
"{}{}",
String::from_utf8_lossy(&o.stdout),
String::from_utf8_lossy(&o.stderr)
);
(o.status.success(), combined)
}
Err(_) => {
// nextest not available — fall back to cargo test
let o = Command::new("cargo")
.args(["test"])
.current_dir(path)
.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;
}
Ok((all_passed, all_output))
} }
/// Spawn claude agent in a PTY and stream events through the broadcast channel. /// Spawn claude agent in a PTY and stream events through the broadcast channel.
@@ -792,4 +1018,66 @@ mod tests {
let info = pool.wait_for_agent("s5", "bot", 2000).await.unwrap(); let info = pool.wait_for_agent("s5", "bot", 2000).await.unwrap();
assert_eq!(info.story_id, "s5"); assert_eq!(info.story_id, "s5");
} }
// ── report_completion tests ────────────────────────────────────
#[tokio::test]
async fn report_completion_rejects_nonexistent_agent() {
let pool = AgentPool::new(3001);
let result = pool
.report_completion("no_story", "no_bot", "done")
.await;
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(msg.contains("No agent"), "unexpected: {msg}");
}
#[tokio::test]
async fn report_completion_rejects_non_running_agent() {
let pool = AgentPool::new(3001);
pool.inject_test_agent("s6", "bot", AgentStatus::Completed);
let result = pool.report_completion("s6", "bot", "done").await;
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(
msg.contains("not running"),
"expected 'not running' in: {msg}"
);
}
#[tokio::test]
async fn report_completion_rejects_dirty_worktree() {
use std::fs;
use tempfile::tempdir;
let tmp = tempdir().unwrap();
let repo = tmp.path();
// Init a real git repo and make an initial commit
Command::new("git")
.args(["init"])
.current_dir(repo)
.output()
.unwrap();
Command::new("git")
.args(["commit", "--allow-empty", "-m", "init"])
.current_dir(repo)
.output()
.unwrap();
// Write an uncommitted file
fs::write(repo.join("dirty.txt"), "not committed").unwrap();
let pool = AgentPool::new(3001);
pool.inject_test_agent_with_path("s7", "bot", AgentStatus::Running, repo.to_path_buf());
let result = pool.report_completion("s7", "bot", "done").await;
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(
msg.contains("uncommitted"),
"expected 'uncommitted' in: {msg}"
);
}
} }

View File

@@ -59,7 +59,11 @@ fn default_agent_command() -> String {
} }
fn default_agent_prompt() -> String { fn default_agent_prompt() -> String {
"Read .story_kit/README.md, then pick up story {{story_id}}".to_string() "You are working in a git worktree on story {{story_id}}. \
Read .story_kit/README.md to understand the dev process, then pick up the story. \
When all work is committed, call report_completion with story_id='{{story_id}}', \
agent_name='{{agent_name}}', and a brief summary as your final action."
.to_string()
} }
/// Legacy config format with `agent` as an optional single table (`[agent]`). /// Legacy config format with `agent` as an optional single table (`[agent]`).
@@ -187,10 +191,12 @@ impl ProjectConfig {
}; };
let bb = base_branch.unwrap_or("master"); let bb = base_branch.unwrap_or("master");
let aname = agent.name.as_str();
let render = |s: &str| { let render = |s: &str| {
s.replace("{{worktree_path}}", worktree_path) s.replace("{{worktree_path}}", worktree_path)
.replace("{{story_id}}", story_id) .replace("{{story_id}}", story_id)
.replace("{{base_branch}}", bb) .replace("{{base_branch}}", bb)
.replace("{{agent_name}}", aname)
}; };
let command = render(&agent.command); let command = render(&agent.command);

View File

@@ -577,6 +577,28 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
}, },
"required": ["worktree_path"] "required": ["worktree_path"]
} }
},
{
"name": "report_completion",
"description": "Report that the agent has finished work on a story. Rejects if the worktree has uncommitted changes. Runs acceptance gates (cargo clippy + tests) automatically. Stores the completion status and gate results on the agent record for retrieval by wait_for_agent or the supervisor. Call this as your final action after committing all changes.",
"inputSchema": {
"type": "object",
"properties": {
"story_id": {
"type": "string",
"description": "Story identifier (e.g. '44_my_story')"
},
"agent_name": {
"type": "string",
"description": "Agent name (as configured in project.toml)"
},
"summary": {
"type": "string",
"description": "Brief summary of the work completed"
}
},
"required": ["story_id", "agent_name", "summary"]
}
} }
] ]
}), }),
@@ -618,6 +640,8 @@ async fn handle_tools_call(
"remove_worktree" => tool_remove_worktree(&args, ctx).await, "remove_worktree" => tool_remove_worktree(&args, ctx).await,
// Editor tools // Editor tools
"get_editor_command" => tool_get_editor_command(&args, ctx), "get_editor_command" => tool_get_editor_command(&args, ctx),
// Completion reporting
"report_completion" => tool_report_completion(&args, ctx).await,
_ => Err(format!("Unknown tool: {tool_name}")), _ => Err(format!("Unknown tool: {tool_name}")),
}; };
@@ -903,6 +927,12 @@ async fn tool_wait_for_agent(args: &Value, ctx: &AppContext) -> Result<String, S
_ => None, _ => None,
}; };
let completion = info.completion.as_ref().map(|r| json!({
"summary": r.summary,
"gates_passed": r.gates_passed,
"gate_output": r.gate_output,
}));
serde_json::to_string_pretty(&json!({ serde_json::to_string_pretty(&json!({
"story_id": info.story_id, "story_id": info.story_id,
"agent_name": info.agent_name, "agent_name": info.agent_name,
@@ -911,6 +941,7 @@ async fn tool_wait_for_agent(args: &Value, ctx: &AppContext) -> Result<String, S
"worktree_path": info.worktree_path, "worktree_path": info.worktree_path,
"base_branch": info.base_branch, "base_branch": info.base_branch,
"commits": commits, "commits": commits,
"completion": completion,
})) }))
.map_err(|e| format!("Serialization error: {e}")) .map_err(|e| format!("Serialization error: {e}"))
} }
@@ -976,6 +1007,40 @@ fn tool_get_editor_command(args: &Value, ctx: &AppContext) -> Result<String, Str
Ok(format!("{editor} {worktree_path}")) Ok(format!("{editor} {worktree_path}"))
} }
async fn tool_report_completion(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())
.ok_or("Missing required argument: agent_name")?;
let summary = args
.get("summary")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: summary")?;
let report = ctx
.agents
.report_completion(story_id, agent_name, summary)
.await?;
serde_json::to_string_pretty(&json!({
"story_id": story_id,
"agent_name": agent_name,
"summary": report.summary,
"gates_passed": report.gates_passed,
"gate_output": report.gate_output,
"message": if report.gates_passed {
"Completion accepted. All acceptance gates passed."
} else {
"Completion recorded but acceptance gates failed. Review gate_output for details."
}
}))
.map_err(|e| format!("Serialization error: {e}"))
}
/// Run `git log <base>..HEAD --oneline` in the worktree and return the commit /// 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. /// 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>> { async fn get_worktree_commits(worktree_path: &str, base_branch: &str) -> Option<Vec<String>> {
@@ -1044,6 +1109,7 @@ fn parse_test_cases(value: Option<&Value>) -> Result<Vec<TestCaseResult>, String
mod tests { mod tests {
use super::*; use super::*;
use crate::http::context::AppContext; use crate::http::context::AppContext;
use crate::store::StoreOps;
// ── Unit tests ──────────────────────────────────────────────── // ── Unit tests ────────────────────────────────────────────────
@@ -1125,7 +1191,8 @@ mod tests {
assert!(names.contains(&"list_worktrees")); assert!(names.contains(&"list_worktrees"));
assert!(names.contains(&"remove_worktree")); assert!(names.contains(&"remove_worktree"));
assert!(names.contains(&"get_editor_command")); assert!(names.contains(&"get_editor_command"));
assert_eq!(tools.len(), 17); assert!(names.contains(&"report_completion"));
assert_eq!(tools.len(), 18);
} }
#[test] #[test]
@@ -1369,6 +1436,73 @@ mod tests {
assert_eq!(parsed["agent_name"], "worker"); assert_eq!(parsed["agent_name"], "worker");
// commits key present (may be null since no real worktree) // commits key present (may be null since no real worktree)
assert!(parsed.get("commits").is_some()); assert!(parsed.get("commits").is_some());
// completion key present (null for agents that didn't call report_completion)
assert!(parsed.get("completion").is_some());
}
// ── report_completion tool tests ──────────────────────────────
#[test]
fn report_completion_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"] == "report_completion")
.expect("report_completion missing from tools list");
// Schema has required fields
let required = tool["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"));
assert!(req_names.contains(&"agent_name"));
assert!(req_names.contains(&"summary"));
}
#[tokio::test]
async fn report_completion_tool_missing_story_id() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result =
tool_report_completion(&json!({"agent_name": "bot", "summary": "done"}), &ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("story_id"));
}
#[tokio::test]
async fn report_completion_tool_missing_agent_name() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result =
tool_report_completion(&json!({"story_id": "44_test", "summary": "done"}), &ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("agent_name"));
}
#[tokio::test]
async fn report_completion_tool_missing_summary() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_report_completion(
&json!({"story_id": "44_test", "agent_name": "bot"}),
&ctx,
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("summary"));
}
#[tokio::test]
async fn report_completion_tool_nonexistent_agent() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_report_completion(
&json!({"story_id": "99_nope", "agent_name": "bot", "summary": "done"}),
&ctx,
)
.await;
assert!(result.is_err());
let msg = result.unwrap_err();
assert!(msg.contains("No agent"), "unexpected: {msg}");
} }
// ── Editor command tool tests ───────────────────────────────── // ── Editor command tool tests ─────────────────────────────────