diff --git a/.story_kit/project.toml b/.story_kit/project.toml index ffaae72..471faed 100644 --- a/.story_kit/project.toml +++ b/.story_kit/project.toml @@ -7,34 +7,55 @@ teardown = [] [[component]] name = "server" path = "." -setup = ["cargo check"] +setup = ["mkdir -p frontend/dist", "cargo check"] teardown = [] [[agent]] name = "supervisor" role = "Coordinates work, reviews PRs, decomposes stories." model = "opus" -max_turns = 50 -max_budget_usd = 10.00 -system_prompt = "You are a senior engineering lead. Coordinate the work, review code, and ensure quality." +max_turns = 200 +max_budget_usd = 15.00 +prompt = """You are the supervisor for story {{story_id}}. Your job is to coordinate coder agents to implement this story. + +## Your MCP Tools +You have these tools via the story-kit MCP server: +- start_agent(story_id, agent_name) - Start a coder agent on a story +- 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 +- stop_agent(story_id, agent_name) - Stop a running agent +- get_story_todos(story_id) - Get unchecked acceptance criteria for a story in current/ +- ensure_acceptance(story_id) - Check if a story passes acceptance gates + +## Your Workflow +1. Read the story file from .story_kit/stories/ to understand requirements +2. Move it to current/ if it is in upcoming/ +3. Start coder-1 on the story: call start_agent with story_id="{{story_id}}" and agent_name="coder-1" +4. Monitor progress: call get_agent_output every 30-60 seconds to check on the agent +5. If the agent completes, review the worktree changes +6. If the agent gets stuck or fails, stop it and start a fresh agent +7. When the work looks complete, call ensure_acceptance to verify + +## Rules +- Do NOT implement code yourself - delegate to coder agents +- Only run one coder at a time per story +- Focus on coordination, monitoring, and quality review""" +system_prompt = "You are a supervisor agent. Use MCP tools to coordinate sub-agents. Never implement code directly - always delegate to coder agents and monitor their progress." [[agent]] name = "coder-1" role = "Full-stack engineer. Implements features across all components." model = "sonnet" -max_turns = 30 +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/stories/ - move it to current/ if needed. Follow the SDTW process end-to-end. The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools." +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. Do not coordinate with other agents - focus on your assigned story." [[agent]] name = "coder-2" role = "Full-stack engineer. Implements features across all components." model = "sonnet" -max_turns = 30 +max_turns = 50 max_budget_usd = 5.00 - -[[agent]] -name = "reviewer" -role = "Reviews code changes, runs tests, checks quality gates." -model = "sonnet" -max_turns = 20 -max_budget_usd = 3.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 end-to-end. The worktree and feature branch already exist - do not create them. Check .mcp.json for MCP tools." +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. Do not coordinate with other agents - focus on your assigned story." diff --git a/server/src/agents.rs b/server/src/agents.rs index 1a3312a..c86ff71 100644 --- a/server/src/agents.rs +++ b/server/src/agents.rs @@ -82,10 +82,11 @@ struct StoryAgent { agent_name: String, status: AgentStatus, worktree_info: Option, - config: ProjectConfig, session_id: Option, tx: broadcast::Sender, task_handle: Option>, + /// Accumulated events for polling via get_agent_output. + event_log: Arc>>, } /// Manages concurrent story agents, each in its own worktree. @@ -140,7 +141,9 @@ impl AgentPool { } } - let (tx, _) = broadcast::channel::(256); + let (tx, _) = broadcast::channel::(1024); + + let event_log: Arc>> = Arc::new(Mutex::new(Vec::new())); // Register as pending { @@ -151,10 +154,10 @@ impl AgentPool { agent_name: resolved_name.clone(), status: AgentStatus::Pending, worktree_info: None, - config: config.clone(), session_id: None, tx: tx.clone(), task_handle: None, + event_log: event_log.clone(), }, ); } @@ -187,6 +190,7 @@ impl AgentPool { let agents_ref = self.agents.clone(); let cwd = wt_path_str.clone(); let key_clone = key.clone(); + let log_clone = event_log.clone(); let handle = tokio::spawn(async move { let _ = tx_clone.send(AgentEvent::Status { @@ -195,8 +199,10 @@ impl AgentPool { status: "running".to_string(), }); - match run_agent_pty_streaming(&sid, &aname, &command, &args, &prompt, &cwd, &tx_clone) - .await + match run_agent_pty_streaming( + &sid, &aname, &command, &args, &prompt, &cwd, &tx_clone, &log_clone, + ) + .await { Ok(session_id) => { if let Ok(mut agents) = agents_ref.lock() @@ -244,27 +250,26 @@ impl AgentPool { }) } - /// Stop a running agent and clean up its worktree. + /// Stop a running agent. Worktree is preserved for inspection. pub async fn stop_agent( &self, - project_root: &Path, + _project_root: &Path, story_id: &str, agent_name: &str, ) -> Result<(), String> { let key = composite_key(story_id, agent_name); - let (worktree_info, config, task_handle, tx) = { + let (worktree_info, task_handle, tx) = { let mut agents = self.agents.lock().map_err(|e| e.to_string())?; let agent = agents .get_mut(&key) .ok_or_else(|| format!("No agent '{agent_name}' for story '{story_id}'"))?; let wt = agent.worktree_info.clone(); - let cfg = agent.config.clone(); let handle = agent.task_handle.take(); let tx = agent.tx.clone(); agent.status = AgentStatus::Failed; - (wt, cfg, handle, tx) + (wt, handle, tx) }; // Abort the task @@ -273,11 +278,12 @@ impl AgentPool { let _ = handle.await; } - // Remove worktree - if let Some(ref wt) = worktree_info - && let Err(e) = worktree::remove_worktree(project_root, wt, &config).await - { - eprintln!("[agents] Worktree cleanup warning for {story_id}:{agent_name}: {e}"); + // Preserve worktree for inspection — don't destroy agent's work on stop. + if let Some(ref wt) = worktree_info { + eprintln!( + "[agents] Worktree preserved for {story_id}:{agent_name}: {}", + wt.path.display() + ); } let _ = tx.send(AgentEvent::Status { @@ -334,6 +340,21 @@ impl AgentPool { Ok(agent.tx.subscribe()) } + /// Drain accumulated events for polling. Returns all events since the last drain. + pub fn drain_events( + &self, + story_id: &str, + agent_name: &str, + ) -> Result, String> { + let key = composite_key(story_id, agent_name); + 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}'"))?; + let mut log = agent.event_log.lock().map_err(|e| e.to_string())?; + Ok(log.drain(..).collect()) + } + /// Get project root helper. pub fn get_project_root( &self, @@ -344,6 +365,7 @@ impl AgentPool { } /// Spawn claude agent in a PTY and stream events through the broadcast channel. +#[allow(clippy::too_many_arguments)] async fn run_agent_pty_streaming( story_id: &str, agent_name: &str, @@ -352,6 +374,7 @@ async fn run_agent_pty_streaming( prompt: &str, cwd: &str, tx: &broadcast::Sender, + event_log: &Arc>>, ) -> Result, String> { let sid = story_id.to_string(); let aname = agent_name.to_string(); @@ -360,14 +383,28 @@ async fn run_agent_pty_streaming( let prompt = prompt.to_string(); let cwd = cwd.to_string(); let tx = tx.clone(); + let event_log = event_log.clone(); tokio::task::spawn_blocking(move || { - run_agent_pty_blocking(&sid, &aname, &cmd, &args, &prompt, &cwd, &tx) + run_agent_pty_blocking(&sid, &aname, &cmd, &args, &prompt, &cwd, &tx, &event_log) }) .await .map_err(|e| format!("Agent task panicked: {e}"))? } +/// Helper to send an event to both broadcast and event log. +fn emit_event( + event: AgentEvent, + tx: &broadcast::Sender, + event_log: &Mutex>, +) { + if let Ok(mut log) = event_log.lock() { + log.push(event.clone()); + } + let _ = tx.send(event); +} + +#[allow(clippy::too_many_arguments)] fn run_agent_pty_blocking( story_id: &str, agent_name: &str, @@ -376,6 +413,7 @@ fn run_agent_pty_blocking( prompt: &str, cwd: &str, tx: &broadcast::Sender, + event_log: &Mutex>, ) -> Result, String> { let pty_system = native_pty_system(); @@ -410,6 +448,10 @@ fn run_agent_pty_blocking( cmd.cwd(cwd); cmd.env("NO_COLOR", "1"); + // Allow spawning Claude Code from within a Claude Code session + cmd.env_remove("CLAUDECODE"); + cmd.env_remove("CLAUDE_CODE_ENTRYPOINT"); + eprintln!("[agent:{story_id}:{agent_name}] Spawning {command} in {cwd} with args: {args:?}"); let mut child = pair @@ -445,11 +487,15 @@ fn run_agent_pty_blocking( Ok(j) => j, Err(_) => { // Non-JSON output (terminal escapes etc.) — send as raw output - let _ = tx.send(AgentEvent::Output { - story_id: story_id.to_string(), - agent_name: agent_name.to_string(), - text: trimmed.to_string(), - }); + emit_event( + AgentEvent::Output { + story_id: story_id.to_string(), + agent_name: agent_name.to_string(), + text: trimmed.to_string(), + }, + tx, + event_log, + ); continue; } }; @@ -469,11 +515,15 @@ fn run_agent_pty_blocking( { for block in content { if let Some(text) = block.get("text").and_then(|t| t.as_str()) { - let _ = tx.send(AgentEvent::Output { - story_id: story_id.to_string(), - agent_name: agent_name.to_string(), - text: text.to_string(), - }); + emit_event( + AgentEvent::Output { + story_id: story_id.to_string(), + agent_name: agent_name.to_string(), + text: text.to_string(), + }, + tx, + event_log, + ); } } } @@ -482,14 +532,19 @@ fn run_agent_pty_blocking( } // Forward all JSON events - let _ = tx.send(AgentEvent::AgentJson { - story_id: story_id.to_string(), - agent_name: agent_name.to_string(), - data: json, - }); + emit_event( + AgentEvent::AgentJson { + story_id: story_id.to_string(), + agent_name: agent_name.to_string(), + data: json, + }, + tx, + event_log, + ); } let _ = child.kill(); + let _ = child.wait(); eprintln!( "[agent:{story_id}:{agent_name}] Done. Session: {:?}", diff --git a/server/src/config.rs b/server/src/config.rs index 554d4f1..c378ff8 100644 --- a/server/src/config.rs +++ b/server/src/config.rs @@ -11,6 +11,7 @@ pub struct ProjectConfig { } #[derive(Debug, Clone, Deserialize)] +#[allow(dead_code)] pub struct ComponentConfig { pub name: String, #[serde(default = "default_path")] diff --git a/server/src/http/mcp.rs b/server/src/http/mcp.rs index 742a712..32572f1 100644 --- a/server/src/http/mcp.rs +++ b/server/src/http/mcp.rs @@ -438,7 +438,7 @@ fn handle_tools_list(id: Option) -> JsonRpcResponse { }, { "name": "stop_agent", - "description": "Stop a running agent and clean up its worktree.", + "description": "Stop a running agent. Worktree is preserved for inspection.", "inputSchema": { "type": "object", "properties": { @@ -532,7 +532,7 @@ async fn handle_tools_call( "list_agents" => tool_list_agents(ctx), "get_agent_config" => tool_get_agent_config(ctx), "reload_agent_config" => tool_get_agent_config(ctx), - "get_agent_output" => Err("get_agent_output requires Accept: text/event-stream for SSE streaming".into()), + "get_agent_output" => tool_get_agent_output_poll(&args, ctx).await, _ => Err(format!("Unknown tool: {tool_name}")), }; @@ -737,6 +737,40 @@ fn tool_list_agents(ctx: &AppContext) -> Result { .map_err(|e| format!("Serialization error: {e}")) } +async fn tool_get_agent_output_poll(args: &Value, ctx: &AppContext) -> Result { + let story_id = args + .get("story_id") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: story_id")?; + let agent_name = args + .get("agent_name") + .and_then(|v| v.as_str()) + .ok_or("Missing required argument: agent_name")?; + + // Drain all accumulated events since the last poll. + let drained = ctx.agents.drain_events(story_id, agent_name)?; + + let done = drained.iter().any(|e| { + matches!( + e, + crate::agents::AgentEvent::Done { .. } | crate::agents::AgentEvent::Error { .. } + ) + }); + + let events: Vec = drained + .into_iter() + .filter_map(|e| serde_json::to_value(&e).ok()) + .collect(); + + serde_json::to_string_pretty(&json!({ + "events": events, + "done": done, + "event_count": events.len(), + "message": if done { "Agent stream ended." } else if events.is_empty() { "No new events. Call again to continue." } else { "Events returned. Call again to continue." } + })) + .map_err(|e| format!("Serialization error: {e}")) +} + fn tool_get_agent_config(ctx: &AppContext) -> Result { let project_root = ctx.agents.get_project_root(&ctx.state)?; let config = ProjectConfig::load(&project_root)?; diff --git a/server/src/worktree.rs b/server/src/worktree.rs index 22b8e99..77e7f4f 100644 --- a/server/src/worktree.rs +++ b/server/src/worktree.rs @@ -3,6 +3,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; #[derive(Debug, Clone)] +#[allow(dead_code)] pub struct WorktreeInfo { pub path: PathBuf, pub branch: String, @@ -104,6 +105,7 @@ fn create_worktree_sync( } /// Remove a git worktree and its branch. +#[allow(dead_code)] pub async fn remove_worktree( project_root: &Path, info: &WorktreeInfo, @@ -120,6 +122,7 @@ pub async fn remove_worktree( .map_err(|e| format!("spawn_blocking: {e}"))? } +#[allow(dead_code)] fn remove_worktree_sync( project_root: &Path, wt_path: &Path, @@ -161,6 +164,7 @@ async fn run_setup_commands(wt_path: &Path, config: &ProjectConfig) -> Result<() Ok(()) } +#[allow(dead_code)] async fn run_teardown_commands(wt_path: &Path, config: &ProjectConfig) -> Result<(), String> { for component in &config.component { let cmd_dir = wt_path.join(&component.path);