story-kit: start 274_story_mcp_pipeline_status_tool_with_agent_assignments

This commit is contained in:
Dave
2026-03-18 09:25:01 +00:00
parent d9775834ed
commit 8bbbe8fbdd
2 changed files with 149 additions and 2 deletions

View File

@@ -0,0 +1,20 @@
---
name: "MCP pipeline status tool with agent assignments"
---
# Story 274: MCP pipeline status tool with agent assignments
## User Story
As a user checking pipeline status, I want an MCP tool that returns a structured status report including which agent is assigned to each work item, so that I can quickly see what's active and spot stuck items.
## Acceptance Criteria
- [ ] New MCP tool (e.g. `get_pipeline_status`) returns all work items across all active pipeline stages (current, qa, merge, done) with their stage, name, and assigned agent
- [ ] Upcoming backlog items are included with count or listing
- [ ] Agent assignment info comes from story front matter (`agent` field) and/or the running agent list
- [ ] Response is structured/deterministic (not free-form prose)
## Out of Scope
- TBD

View File

@@ -8,7 +8,7 @@ use crate::http::settings::get_editor_command_from_store;
use crate::http::workflow::{ use crate::http::workflow::{
add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file, add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file,
create_spike_file, create_story_file, list_bug_files, list_refactor_files, create_spike_file, create_story_file, list_bug_files, list_refactor_files,
load_upcoming_stories, update_story_in_file, validate_story_dirs, load_pipeline_state, load_upcoming_stories, update_story_in_file, validate_story_dirs,
}; };
use crate::worktree; use crate::worktree;
use crate::io::story_metadata::{parse_front_matter, parse_unchecked_todos, write_merge_failure}; use crate::io::story_metadata::{parse_front_matter, parse_unchecked_todos, write_merge_failure};
@@ -862,6 +862,14 @@ fn handle_tools_list(id: Option<Value>) -> JsonRpcResponse {
"required": ["story_id"] "required": ["story_id"]
} }
}, },
{
"name": "get_pipeline_status",
"description": "Return a structured snapshot of the full work item pipeline. Includes all active stages (current, qa, merge, done) with each item's stage, name, and assigned agent. Also includes upcoming backlog items.",
"inputSchema": {
"type": "object",
"properties": {}
}
},
{ {
"name": "get_server_logs", "name": "get_server_logs",
"description": "Return recent server log lines captured in the in-process ring buffer. Useful for diagnosing runtime behaviour such as WebSocket events, MCP call flow, and filesystem watcher activity.", "description": "Return recent server log lines captured in the in-process ring buffer. Useful for diagnosing runtime behaviour such as WebSocket events, MCP call flow, and filesystem watcher activity.",
@@ -963,6 +971,8 @@ async fn handle_tools_call(
"report_merge_failure" => tool_report_merge_failure(&args, ctx), "report_merge_failure" => tool_report_merge_failure(&args, ctx),
// QA tools // QA tools
"request_qa" => tool_request_qa(&args, ctx).await, "request_qa" => tool_request_qa(&args, ctx).await,
// Pipeline status
"get_pipeline_status" => tool_get_pipeline_status(ctx),
// Diagnostics // Diagnostics
"get_server_logs" => tool_get_server_logs(&args), "get_server_logs" => tool_get_server_logs(&args),
// Permission bridge (Claude Code → frontend dialog) // Permission bridge (Claude Code → frontend dialog)
@@ -1044,6 +1054,47 @@ fn tool_list_upcoming(ctx: &AppContext) -> Result<String, String> {
.map_err(|e| format!("Serialization error: {e}")) .map_err(|e| format!("Serialization error: {e}"))
} }
fn tool_get_pipeline_status(ctx: &AppContext) -> Result<String, String> {
let state = load_pipeline_state(ctx)?;
fn map_items(items: &[crate::http::workflow::UpcomingStory], stage: &str) -> Vec<Value> {
items
.iter()
.map(|s| {
json!({
"story_id": s.story_id,
"name": s.name,
"stage": stage,
"agent": s.agent.as_ref().map(|a| json!({
"agent_name": a.agent_name,
"model": a.model,
"status": a.status,
})),
})
})
.collect()
}
let mut active: Vec<Value> = Vec::new();
active.extend(map_items(&state.current, "current"));
active.extend(map_items(&state.qa, "qa"));
active.extend(map_items(&state.merge, "merge"));
active.extend(map_items(&state.done, "done"));
let upcoming: Vec<Value> = state
.upcoming
.iter()
.map(|s| json!({ "story_id": s.story_id, "name": s.name }))
.collect();
serde_json::to_string_pretty(&json!({
"active": active,
"upcoming": upcoming,
"upcoming_count": upcoming.len(),
}))
.map_err(|e| format!("Serialization error: {e}"))
}
fn tool_get_story_todos(args: &Value, ctx: &AppContext) -> Result<String, String> { fn tool_get_story_todos(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_id = args let story_id = args
.get("story_id") .get("story_id")
@@ -2230,7 +2281,8 @@ mod tests {
assert!(names.contains(&"request_qa")); assert!(names.contains(&"request_qa"));
assert!(names.contains(&"get_server_logs")); assert!(names.contains(&"get_server_logs"));
assert!(names.contains(&"prompt_permission")); assert!(names.contains(&"prompt_permission"));
assert_eq!(tools.len(), 34); assert!(names.contains(&"get_pipeline_status"));
assert_eq!(tools.len(), 35);
} }
#[test] #[test]
@@ -2297,6 +2349,81 @@ mod tests {
assert!(result.unwrap_err().contains("Missing required argument")); assert!(result.unwrap_err().contains("Missing required argument"));
} }
#[test]
fn tool_get_pipeline_status_returns_structured_response() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for (stage, id, name) in &[
("1_upcoming", "10_story_upcoming", "Upcoming Story"),
("2_current", "20_story_current", "Current Story"),
("3_qa", "30_story_qa", "QA Story"),
("4_merge", "40_story_merge", "Merge Story"),
("5_done", "50_story_done", "Done Story"),
] {
let dir = root.join(".story_kit/work").join(stage);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(format!("{id}.md")),
format!("---\nname: \"{name}\"\n---\n"),
)
.unwrap();
}
let ctx = test_ctx(root);
let result = tool_get_pipeline_status(&ctx).unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
// Active stages include current, qa, merge, done
let active = parsed["active"].as_array().unwrap();
assert_eq!(active.len(), 4);
let stages: Vec<&str> = active.iter().map(|i| i["stage"].as_str().unwrap()).collect();
assert!(stages.contains(&"current"));
assert!(stages.contains(&"qa"));
assert!(stages.contains(&"merge"));
assert!(stages.contains(&"done"));
// Upcoming backlog
let upcoming = parsed["upcoming"].as_array().unwrap();
assert_eq!(upcoming.len(), 1);
assert_eq!(upcoming[0]["story_id"], "10_story_upcoming");
assert_eq!(parsed["upcoming_count"], 1);
}
#[test]
fn tool_get_pipeline_status_includes_agent_assignment() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let current = root.join(".story_kit/work/2_current");
std::fs::create_dir_all(&current).unwrap();
std::fs::write(
current.join("20_story_active.md"),
"---\nname: \"Active Story\"\n---\n",
)
.unwrap();
let ctx = test_ctx(root);
ctx.agents.inject_test_agent(
"20_story_active",
"coder-1",
crate::agents::AgentStatus::Running,
);
let result = tool_get_pipeline_status(&ctx).unwrap();
let parsed: Value = serde_json::from_str(&result).unwrap();
let active = parsed["active"].as_array().unwrap();
assert_eq!(active.len(), 1);
let item = &active[0];
assert_eq!(item["story_id"], "20_story_active");
assert_eq!(item["stage"], "current");
assert!(!item["agent"].is_null(), "agent should be present");
assert_eq!(item["agent"]["agent_name"], "coder-1");
assert_eq!(item["agent"]["status"], "running");
}
#[test] #[test]
fn tool_get_story_todos_missing_file() { fn tool_get_story_todos_missing_file() {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();