Adding show story mcp

This commit is contained in:
Timmy
2026-06-29 12:42:45 +01:00
parent 8285a98f80
commit 705f5bcc89
16 changed files with 311 additions and 43 deletions
@@ -362,6 +362,7 @@ async fn handle_llm_message(ctx: &DiscordContext, channel: &str, user: &str, use
&project_root_str,
resume_session_id.as_deref(),
None,
None,
&mut cancel_rx,
move |token| {
let mut buf = buffer_for_callback.lock().unwrap();
@@ -109,6 +109,9 @@ pub struct BotContext {
/// configured room. Updated atomically on every `on_room_message` call so
/// the `health` command can detect a stale or dead sync loop.
pub last_matrix_event_ms: Arc<AtomicI64>,
/// Optional model override from bot.toml. Passed as `--model` to the
/// `claude` CLI when set.
pub model: Option<String>,
}
impl BotContext {
@@ -305,6 +308,7 @@ mod tests {
))),
gateway_port: None,
last_matrix_event_ms: Arc::new(AtomicI64::new(chrono::Utc::now().timestamp_millis())),
model: None,
}
}
@@ -101,6 +101,7 @@ pub(in crate::chat::transport::matrix::bot) async fn handle_message(
&project_root_str,
resume_session_id.as_deref(),
None,
ctx.model.as_deref(),
&mut cancel_rx,
move |token| {
let mut buf = buffer_for_callback.lock().unwrap();
@@ -337,6 +337,7 @@ pub async fn run_bot(
))),
gateway_port,
last_matrix_event_ms: Arc::new(AtomicI64::new(chrono::Utc::now().timestamp_millis())),
model: config.model.clone(),
};
slog!(
@@ -71,10 +71,10 @@ pub struct BotConfig {
/// (fail-closed). Defaults to 120 seconds.
#[serde(default = "default_permission_timeout_secs")]
pub permission_timeout_secs: u64,
/// Previously used to select an Anthropic model. Now ignored — the bot
/// uses Claude Code which manages its own model selection. Kept for
/// backwards compatibility so existing bot.toml files still parse.
#[allow(dead_code)]
/// Claude Code model override. When set, passed as `--model <value>` to
/// the `claude` CLI so the bot uses a specific model instead of the CLI's
/// configured default.
#[serde(default)]
pub model: Option<String>,
/// Display name the bot uses to identify itself in conversations.
/// If unset, the bot falls back to "Assistant".
@@ -694,6 +694,7 @@ mod tests {
)),
gateway_port: None,
last_matrix_event_ms: Arc::new(AtomicI64::new(chrono::Utc::now().timestamp_millis())),
model: None,
}
}
}
@@ -97,6 +97,7 @@ mod tests {
)),
gateway_port: None,
last_matrix_event_ms: Arc::new(AtomicI64::new(chrono::Utc::now().timestamp_millis())),
model: None,
};
run_projects_list(&ctx).await
}
@@ -211,6 +212,7 @@ mod tests {
)),
gateway_port: None,
last_matrix_event_ms: Arc::new(AtomicI64::new(chrono::Utc::now().timestamp_millis())),
model: None,
};
let response = run_projects_list(&ctx).await;
assert!(
@@ -66,6 +66,7 @@ pub(super) async fn handle_llm_message(
&project_root_str,
resume_session_id.as_deref(),
None,
None,
&mut cancel_rx,
move |token| {
let mut buf = buffer_for_callback.lock().unwrap();
@@ -65,6 +65,7 @@ pub(super) async fn handle_llm_message(
&project_root_str,
resume_session_id.as_deref(),
None,
None,
&mut cancel_rx,
move |token| {
let mut buf = buffer_for_callback.lock().unwrap();
+2 -2
View File
@@ -123,8 +123,8 @@ pub async fn dispatch_tool_call(
"git_add" => git_tools::tool_git_add(&args, ctx).await,
"git_commit" => git_tools::tool_git_commit(&args, ctx).await,
"git_log" => git_tools::tool_git_log(&args, ctx).await,
// Story triage
"status" => status_tools::tool_status(&args, ctx).await,
// Story detail (any stage)
"show" => status_tools::tool_show(&args, ctx).await,
// File line count
"loc_file" => diagnostics::tool_loc_file(&args, ctx),
// Setup wizard tools
+31 -30
View File
@@ -150,37 +150,40 @@ async fn git_branch(dir: &Path) -> Option<String> {
.flatten()
}
pub(super) async fn tool_status(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_id = args
pub(super) async fn tool_show(args: &Value, ctx: &AppContext) -> Result<String, String> {
let raw_id = args
.get("story_id")
.and_then(|v| v.as_str())
.ok_or("Missing required argument: story_id")?;
let root = ctx.state.get_project_root()?;
// Read from CRDT/DB content store — verify the item is in coding.
// Resolve numeric prefix (e.g. "5") to full story_id via find_story_by_number.
let (story_id, contents) = if raw_id.chars().all(|c| c.is_ascii_digit()) {
let (sid, _, _, content) = crate::chat::lookup::find_story_by_number(&root, raw_id)
.ok_or_else(|| {
format!("No work item with number '{raw_id}' found in any pipeline stage.")
})?;
let body = content.ok_or_else(|| {
format!("Work item '{sid}' found in pipeline but its content is unavailable.")
})?;
(sid, body)
} else {
let body = crate::db::read_content(crate::db::ContentKey::Story(raw_id))
.ok_or_else(|| format!("Work item '{raw_id}' not found in any pipeline stage."))?;
(raw_id.to_string(), body)
};
let story_id = story_id.as_str();
let typed_item = crate::pipeline_state::read_typed(story_id)
.map_err(|e| format!("Failed to read pipeline state: {e}"))?
.ok_or_else(|| format!(
"Story '{story_id}' not found in coding stage. Check the story_id and ensure it is in the current stage."
))?;
if !matches!(
typed_item.stage,
crate::pipeline_state::Stage::Coding { .. }
) {
return Err(format!(
"Story '{story_id}' not found in coding stage. Check the story_id and ensure it is in the current stage."
));
}
let contents = crate::db::read_content(crate::db::ContentKey::Story(story_id))
.ok_or_else(|| format!("Story '{story_id}' has no content in the content store."))?;
.map_err(|e| format!("Failed to read pipeline state: {e}"))?;
// --- Metadata (story 929: CRDT-first, yaml_residue marks gaps) ---
let mut front_matter = serde_json::Map::new();
if let Some(view) = crate::crdt_state::read_item(story_id) {
front_matter.insert("name".to_string(), json!(view.name()));
front_matter.insert("stage".to_string(), json!(view.stage().dir_name()));
if let Some(agent) = view.agent() {
front_matter.insert("agent".to_string(), json!(agent));
}
@@ -195,14 +198,13 @@ pub(super) async fn tool_status(args: &Value, ctx: &AppContext) -> Result<String
if !deps.is_empty() {
front_matter.insert("depends_on".to_string(), json!(deps));
}
// Story 1088: origin tracking.
let origin_str = view.origin().unwrap_or("unknown");
front_matter.insert("origin".to_string(), json!(origin_str));
let stage_claim = match &typed_item.stage {
let stage_claim = typed_item.as_ref().and_then(|t| match &t.stage {
crate::pipeline_state::Stage::Coding { claim, .. } => claim.as_ref(),
crate::pipeline_state::Stage::Merge { claim, .. } => claim.as_ref(),
_ => None,
};
});
if let Some(claim) = stage_claim {
front_matter.insert("claimed_by".to_string(), json!(claim.agent.0.as_str()));
front_matter.insert(
@@ -212,7 +214,6 @@ pub(super) async fn tool_status(args: &Value, ctx: &AppContext) -> Result<String
}
}
// Merge-failure detail lives on the MergeJob CRDT entry, not on WorkItem.
if let Some(job) = crate::crdt_state::read_merge_job(story_id)
&& let Some(mf) = job.error
{
@@ -343,16 +344,16 @@ mod tests {
}
#[tokio::test]
async fn tool_status_returns_error_for_missing_story() {
async fn tool_show_returns_error_for_missing_story() {
let tmp = tempdir().unwrap();
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
let result = tool_status(&json!({"story_id": "999_story_nonexistent"}), &ctx).await;
let result = tool_show(&json!({"story_id": "999_story_nonexistent"}), &ctx).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found in coding stage"));
assert!(result.unwrap_err().contains("not found"));
}
#[tokio::test]
async fn tool_status_returns_retry_count_and_depends_on() {
async fn tool_show_returns_retry_count_and_depends_on() {
let tmp = tempdir().unwrap();
crate::crdt_state::init_for_test();
@@ -368,7 +369,7 @@ mod tests {
crate::crdt_state::set_depends_on("9887_story_blocked_test", &[100, 200]);
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
let result = tool_status(&json!({"story_id": "9887_story_blocked_test"}), &ctx)
let result = tool_show(&json!({"story_id": "9887_story_blocked_test"}), &ctx)
.await
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
@@ -381,7 +382,7 @@ mod tests {
}
#[tokio::test]
async fn tool_status_returns_story_data() {
async fn tool_show_returns_story_data() {
let tmp = tempdir().unwrap();
crate::db::ensure_content_store();
@@ -398,7 +399,7 @@ mod tests {
);
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
let result = tool_status(&json!({"story_id": "9886_story_status_test"}), &ctx)
let result = tool_show(&json!({"story_id": "9886_story_status_test"}), &ctx)
.await
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
+1 -1
View File
@@ -93,7 +93,7 @@ mod tests {
assert!(names.contains(&"git_add"));
assert!(names.contains(&"git_commit"));
assert!(names.contains(&"git_log"));
assert!(names.contains(&"status"));
assert!(names.contains(&"show"));
assert!(names.contains(&"loc_file"));
assert!(names.contains(&"dump_crdt"));
assert!(names.contains(&"get_version"));
@@ -604,7 +604,7 @@ pub(super) fn story_tools() -> Vec<Value> {
}),
json!({
"name": "get_pipeline_status",
"description": "Return a structured snapshot of the full work item pipeline. Each item includes only slim fields: story_id, name (capped at 120 chars), stage, agent (with agent_name/model/status), and optional boolean flags blocked and retry_count. Active stages (current, qa, merge, done) appear in the 'active' array; backlog items in 'backlog'. For full story details, use status(story_id) or dump_crdt.",
"description": "Return a structured snapshot of the full work item pipeline. Each item includes only slim fields: story_id, name (capped at 120 chars), stage, agent (with agent_name/model/status), and optional boolean flags blocked and retry_count. Active stages (current, qa, merge, done) appear in the 'active' array; backlog items in 'backlog'. For full story details, use show(story_id).",
"inputSchema": {
"type": "object",
"properties": {}
@@ -719,14 +719,14 @@ pub(super) fn story_tools() -> Vec<Value> {
}
}),
json!({
"name": "status",
"description": "Get a full triage dump for an in-progress story: front matter, AC checklist, active worktree/branch, git diff --stat since master, last 5 commits, and last 20 lines of the most recent agent log. Returns a clear error if the story is not in work/2_current/.",
"name": "show",
"description": "Show full details for a work item in any pipeline stage: front matter, AC checklist, and (for coding-stage items) active worktree/branch, git diff --stat since master, last 5 commits, and last 20 lines of the most recent agent log. Accepts a full story_id ('42_story_my_feature') or just the numeric prefix ('42').",
"inputSchema": {
"type": "object",
"properties": {
"story_id": {
"type": "string",
"description": "Story identifier (filename stem, e.g. '42_story_my_feature')"
"description": "Story identifier — full stem (e.g. '42_story_my_feature') or just the numeric prefix (e.g. '42')"
}
},
"required": ["story_id"]
+1
View File
@@ -209,6 +209,7 @@ where
&project_root.to_string_lossy(),
config.session_id.as_deref(),
None,
None,
&mut cancel_rx,
|token| on_token(token),
|thinking| on_thinking(thinking),
+11 -2
View File
@@ -52,6 +52,7 @@ impl ClaudeCodeProvider {
project_root: &str,
session_id: Option<&str>,
system_prompt: Option<&str>,
model: Option<&str>,
cancel_rx: &mut watch::Receiver<bool>,
mut on_token: F,
mut on_thinking: T,
@@ -81,6 +82,7 @@ impl ClaudeCodeProvider {
let cwd = project_root.to_string();
let resume_id = session_id.map(|s| s.to_string());
let sys_prompt = system_prompt.map(|s| s.to_string());
let model_override = model.map(|s| s.to_string());
let cancelled_inner = cancelled.clone();
let auth_failed = Arc::new(AtomicBool::new(false));
let auth_failed_clone = auth_failed.clone();
@@ -97,6 +99,7 @@ impl ClaudeCodeProvider {
&cwd,
resume_id.as_deref(),
sys_prompt.as_deref(),
model_override.as_deref(),
cancelled_inner,
auth_failed_clone,
token_tx,
@@ -188,6 +191,7 @@ fn run_pty_session(
cwd: &str,
resume_session_id: Option<&str>,
_system_prompt: Option<&str>,
model: Option<&str>,
cancelled: Arc<AtomicBool>,
auth_failed: Arc<AtomicBool>,
token_tx: tokio::sync::mpsc::UnboundedSender<String>,
@@ -214,6 +218,10 @@ fn run_pty_session(
cmd.arg("--resume");
cmd.arg(sid);
}
if let Some(m) = model {
cmd.arg("--model");
cmd.arg(m);
}
cmd.arg("--output-format");
cmd.arg("stream-json");
cmd.arg("--verbose");
@@ -236,11 +244,12 @@ fn run_pty_session(
cmd.env("CLAUDECODE", "");
slog!(
"[pty-debug] Spawning: claude -p \"{}\" {} --output-format stream-json --verbose --include-partial-messages --permission-prompt-tool mcp__huskies__prompt_permission",
"[pty-debug] Spawning: claude -p \"{}\" {} {} --output-format stream-json --verbose --include-partial-messages --permission-prompt-tool mcp__huskies__prompt_permission",
user_message,
resume_session_id
.map(|s| format!("--resume {s}"))
.unwrap_or_default()
.unwrap_or_default(),
model.map(|m| format!("--model {m}")).unwrap_or_default()
);
let mut child = pair