huskies: merge 880

This commit is contained in:
dave
2026-04-29 21:41:44 +00:00
parent 4d24b5b661
commit 7e2f122d36
16 changed files with 508 additions and 40 deletions
+4
View File
@@ -62,6 +62,10 @@ pub async fn dispatch_tool_call(
// Refactor lifecycle tools
"create_refactor" => story_tools::tool_create_refactor(&args, ctx),
"list_refactors" => story_tools::tool_list_refactors(ctx),
// Epic lifecycle tools
"create_epic" => story_tools::tool_create_epic(&args, ctx),
"list_epics" => story_tools::tool_list_epics(ctx),
"show_epic" => story_tools::tool_show_epic(&args, ctx),
// Mergemaster tools
"merge_agent_work" => merge_tools::tool_merge_agent_work(&args, ctx).await,
"get_merge_status" => merge_tools::tool_get_merge_status(&args, ctx),
+271
View File
@@ -0,0 +1,271 @@
//! Epic work-item MCP tools — create, list, and show epics.
//!
//! Epics are shared-context containers that group related stories, bugs, spikes,
//! and refactors. They are not pipeline-driven but provide authoritative context
//! injected into agent prompts for all member work items.
use crate::http::context::AppContext;
use crate::http::workflow::create_epic_file;
use crate::io::story_metadata::parse_front_matter;
use serde_json::{Value, json};
/// Create a new epic and store it in the CRDT items list.
pub(crate) fn tool_create_epic(args: &Value, ctx: &AppContext) -> Result<String, String> {
let name = args
.get("name")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: name")?;
let goal = args
.get("goal")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: goal")?;
let motivation = args.get("motivation").and_then(|v| v.as_str());
let key_files = args.get("key_files").and_then(|v| v.as_str());
let success_criteria: Option<Vec<String>> = args
.get("success_criteria")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
});
let root = ctx.state.get_project_root()?;
let epic_id = create_epic_file(
&root,
name,
goal,
motivation,
key_files,
success_criteria.as_deref(),
)?;
Ok(format!("Created epic: {epic_id}"))
}
/// List all epics with member work item counts and `n/m done` rollup.
pub(crate) fn tool_list_epics(_ctx: &AppContext) -> Result<String, String> {
use crate::pipeline_state::Stage;
let all_items = crate::pipeline_state::read_all_typed();
// Collect epics: items with type == "epic".
let mut epics: Vec<(String, String)> = Vec::new(); // (id, name)
// Collect member items: map from epic_id → list of (story_id, is_done).
let mut members: std::collections::HashMap<String, Vec<(String, bool)>> =
std::collections::HashMap::new();
for item in &all_items {
let sid = &item.story_id.0;
let content = match crate::db::read_content(sid) {
Some(c) => c,
None => continue,
};
let meta = match parse_front_matter(&content) {
Ok(m) => m,
Err(_) => continue,
};
if meta.item_type.as_deref() == Some("epic") {
epics.push((sid.clone(), item.name.clone()));
}
if let Some(epic_id) = meta.epic {
let is_done = matches!(item.stage, Stage::Done { .. });
members
.entry(epic_id)
.or_default()
.push((sid.clone(), is_done));
}
}
epics.sort_by(|a, b| a.0.cmp(&b.0));
let result: Vec<Value> = epics
.iter()
.map(|(id, name)| {
let member_list = members.get(id).cloned().unwrap_or_default();
let total = member_list.len();
let done = member_list.iter().filter(|(_, d)| *d).count();
json!({
"epic_id": id,
"name": name,
"members_total": total,
"members_done": done,
"rollup": format!("{done}/{total} done"),
})
})
.collect();
serde_json::to_string_pretty(&result).map_err(|e| format!("Serialization error: {e}"))
}
/// Show details for a single epic: content and member work items with their stages.
pub(crate) fn tool_show_epic(args: &Value, _ctx: &AppContext) -> Result<String, String> {
use crate::pipeline_state::Stage;
let epic_id = args
.get("epic_id")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: epic_id")?;
let content = crate::db::read_content(epic_id)
.ok_or_else(|| format!("Epic '{epic_id}' not found in content store"))?;
let meta = parse_front_matter(&content)
.map_err(|e| format!("Failed to parse epic front matter: {e}"))?;
if meta.item_type.as_deref() != Some("epic") {
return Err(format!(
"'{epic_id}' is not an epic (type: {:?})",
meta.item_type
));
}
// Find member items.
let all_items = crate::pipeline_state::read_all_typed();
let mut member_items: Vec<Value> = Vec::new();
for item in &all_items {
let sid = &item.story_id.0;
let member_content = match crate::db::read_content(sid) {
Some(c) => c,
None => continue,
};
let member_meta = match parse_front_matter(&member_content) {
Ok(m) => m,
Err(_) => continue,
};
if member_meta.epic.as_deref() == Some(epic_id) {
let stage_name = match &item.stage {
Stage::Upcoming | Stage::Backlog => "backlog",
Stage::Coding => "current",
Stage::Qa => "qa",
Stage::Merge { .. } => "merge",
Stage::Done { .. } => "done",
Stage::Archived { .. } => "archived",
};
member_items.push(json!({
"story_id": sid,
"name": item.name,
"stage": stage_name,
}));
}
}
let total = member_items.len();
let done = member_items.iter().filter(|i| i["stage"] == "done").count();
serde_json::to_string_pretty(&json!({
"epic_id": epic_id,
"name": meta.name,
"content": content,
"members": member_items,
"rollup": format!("{done}/{total} done"),
}))
.map_err(|e| format!("Serialization error: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::http::test_helpers::test_ctx;
use serde_json::json;
#[test]
fn tool_create_epic_creates_epic_and_returns_id() {
let tmp = tempfile::tempdir().unwrap();
crate::db::ensure_content_store();
let ctx = test_ctx(tmp.path());
let result = tool_create_epic(
&json!({
"name": "My Test Epic",
"goal": "Achieve something great",
"motivation": "Because it matters",
"success_criteria": ["All stories done", "Tests pass"]
}),
&ctx,
);
assert!(result.is_ok(), "expected ok: {result:?}");
let msg = result.unwrap();
assert!(msg.contains("Created epic:"), "unexpected msg: {msg}");
}
#[test]
fn tool_create_epic_missing_name_returns_error() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_create_epic(&json!({"goal": "Achieve something"}), &ctx);
assert!(result.is_err());
assert!(result.unwrap_err().contains("name"));
}
#[test]
fn tool_create_epic_missing_goal_returns_error() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test_ctx(tmp.path());
let result = tool_create_epic(&json!({"name": "My Epic"}), &ctx);
assert!(result.is_err());
assert!(result.unwrap_err().contains("goal"));
}
#[test]
fn tool_list_epics_includes_created_epic() {
let tmp = tempfile::tempdir().unwrap();
crate::db::ensure_content_store();
let ctx = test_ctx(tmp.path());
// Create an epic.
tool_create_epic(
&json!({"name": "List Epics Test Epic", "goal": "Testing list"}),
&ctx,
)
.unwrap();
let result = tool_list_epics(&ctx);
assert!(result.is_ok(), "expected ok: {result:?}");
let parsed: Vec<Value> = serde_json::from_str(&result.unwrap()).unwrap();
assert!(
parsed.iter().any(|e| e["name"] == "List Epics Test Epic"),
"expected epic in list: {parsed:?}"
);
}
#[test]
fn tool_list_epics_shows_member_rollup() {
crate::db::ensure_content_store();
// Write a fake epic.
crate::db::write_item_with_content(
"9990_epic_rollup",
"1_backlog",
"---\ntype: epic\nname: \"Rollup Epic\"\n---\n\n## Goal\n\nTest\n",
);
// Write two member items: one done, one current.
crate::db::write_item_with_content(
"9991_story_member_done",
"5_done",
"---\ntype: story\nname: \"Done Member\"\nepic: \"9990_epic_rollup\"\n---\n",
);
crate::db::write_item_with_content(
"9992_story_member_current",
"2_current",
"---\ntype: story\nname: \"Current Member\"\nepic: \"9990_epic_rollup\"\n---\n",
);
let tmp = tempfile::tempdir().unwrap();
let ctx = crate::http::test_helpers::test_ctx(tmp.path());
let result = tool_list_epics(&ctx).unwrap();
let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
let epic = parsed
.iter()
.find(|e| e["epic_id"] == "9990_epic_rollup")
.expect("expected rollup epic in list");
assert_eq!(epic["members_total"], 2, "two members expected");
assert_eq!(epic["members_done"], 1, "one done member expected");
assert_eq!(epic["rollup"], "1/2 done");
}
}
+3 -1
View File
@@ -1,5 +1,5 @@
//! MCP story tools — create, update, move, and manage stories, bugs, refactors,
//! and spikes via MCP.
//! spikes, and epics via MCP.
//!
//! This module is a thin adapter: it deserialises MCP payloads, delegates to
//! `crate::service::story` and `crate::http::workflow` for business logic,
@@ -7,6 +7,7 @@
mod bug;
mod criteria;
mod epic;
mod refactor;
mod spike;
mod story;
@@ -16,6 +17,7 @@ pub(crate) use criteria::{
tool_add_criterion, tool_check_criterion, tool_edit_criterion, tool_ensure_acceptance,
tool_get_story_todos, tool_record_tests, tool_remove_criterion,
};
pub(crate) use epic::{tool_create_epic, tool_list_epics, tool_show_epic};
pub(crate) use refactor::{tool_create_refactor, tool_list_refactors};
pub(crate) use spike::tool_create_spike;
pub(crate) use story::{
+10 -1
View File
@@ -64,6 +64,9 @@ pub(crate) fn tool_get_pipeline_status(ctx: &AppContext) -> Result<String, Strin
if let Some(ref mf) = s.merge_failure {
item["merge_failure"] = json!(mf);
}
if let Some(ref epic_id) = s.epic_id {
item["epic_id"] = json!(epic_id);
}
item
})
.collect()
@@ -78,7 +81,13 @@ pub(crate) fn tool_get_pipeline_status(ctx: &AppContext) -> Result<String, Strin
let backlog: Vec<Value> = state
.backlog
.iter()
.map(|s| json!({ "story_id": s.story_id, "name": s.name }))
.map(|s| {
let mut item = json!({ "story_id": s.story_id, "name": s.name });
if let Some(ref epic_id) = s.epic_id {
item["epic_id"] = json!(epic_id);
}
item
})
.collect();
serde_json::to_string_pretty(&json!({
@@ -25,6 +25,9 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String
if let Some(agent) = args.get("agent").and_then(|v| v.as_str()) {
front_matter.insert("agent".to_string(), Value::String(agent.to_string()));
}
if let Some(epic) = args.get("epic").and_then(|v| v.as_str()) {
front_matter.insert("epic".to_string(), Value::String(epic.to_string()));
}
if let Some(obj) = args.get("front_matter").and_then(|v| v.as_object()) {
for (k, v) in obj {
front_matter.insert(k.clone(), v.clone());
+4 -1
View File
@@ -101,7 +101,10 @@ mod tests {
assert!(names.contains(&"mesh_status"));
assert!(names.contains(&"run_check"));
assert!(names.contains(&"cleanup_worktrees"));
assert_eq!(tools.len(), 69);
assert!(names.contains(&"create_epic"));
assert!(names.contains(&"list_epics"));
assert!(names.contains(&"show_epic"));
assert_eq!(tools.len(), 72);
}
#[test]
@@ -242,6 +242,10 @@ pub(super) fn story_tools() -> Vec<Value> {
"type": "string",
"description": "Set or change the 'agent' YAML front matter field"
},
"epic": {
"type": "string",
"description": "Set or change the 'epic' field — the numeric epic ID this item belongs to (e.g. '880'). Agents spawned for this item will have the epic context prepended to their system prompt."
},
"front_matter": {
"type": "object",
"description": "Arbitrary YAML front matter key-value pairs to set or update. Values may be strings, booleans, integers, numbers, or arrays (e.g. [490, 491]).",
@@ -369,6 +373,59 @@ pub(super) fn story_tools() -> Vec<Value> {
"properties": {}
}
}),
json!({
"name": "create_epic",
"description": "Create an epic: a shared-context container for chains of related stories. Epics are not pipeline-driven but inject their goal and context into agent prompts for all member work items. Returns the epic_id.",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Human-readable epic name"
},
"goal": {
"type": "string",
"description": "What this epic aims to achieve"
},
"motivation": {
"type": "string",
"description": "Optional: why this epic matters"
},
"key_files": {
"type": "string",
"description": "Optional: key files or modules relevant to this epic"
},
"success_criteria": {
"type": "array",
"items": { "type": "string" },
"description": "Optional: list of high-level success criteria for the epic"
}
},
"required": ["name", "goal"]
}
}),
json!({
"name": "list_epics",
"description": "List all epics with their member work item counts and n/m done rollup.",
"inputSchema": {
"type": "object",
"properties": {}
}
}),
json!({
"name": "show_epic",
"description": "Show details for a single epic: its content and all member work items with their pipeline stages.",
"inputSchema": {
"type": "object",
"properties": {
"epic_id": {
"type": "string",
"description": "Epic identifier (e.g. '880')"
}
},
"required": ["epic_id"]
}
}),
json!({
"name": "close_bug",
"description": "Archive a bug from work/2_current/ or work/1_backlog/ to work/5_done/ and auto-commit to master.",