huskies: merge 618_story_extract_mcp_only_domain_services

This commit is contained in:
dave
2026-04-24 21:12:03 +00:00
parent 360bca45c8
commit c16d9e471d
29 changed files with 1924 additions and 409 deletions
+5 -88
View File
@@ -1,10 +1,15 @@
//! MCP diagnostic tools — server logs, CRDT dump, and story movement helpers.
//!
//! This file is a thin adapter: it deserialises MCP payloads, delegates to
//! `crate::service::diagnostics` for all business logic, and serialises responses.
use crate::agents::move_story_to_stage;
use crate::http::context::AppContext;
use crate::log_buffer;
use crate::service::diagnostics::{add_permission_rule, generate_permission_rule};
use crate::slog;
use crate::slog_warn;
use serde_json::{Value, json};
#[allow(unused_imports)]
use std::fs;
pub(super) fn tool_get_server_logs(args: &Value) -> Result<String, String> {
@@ -44,94 +49,6 @@ pub(super) async fn tool_rebuild_and_restart(ctx: &AppContext) -> Result<String,
crate::rebuild::rebuild_and_restart(&ctx.agents, &project_root, notifier).await
}
/// Generate a Claude Code permission rule string for the given tool name and input.
///
/// - `Edit` / `Write` / `Read` / `Grep` / `Glob` etc. → just the tool name
/// - `Bash` → `Bash(first_word *)` derived from the `command` field in `tool_input`
/// - `mcp__*` → the full tool name (e.g. `mcp__huskies__create_story`)
fn generate_permission_rule(tool_name: &str, tool_input: &Value) -> String {
if tool_name == "Bash" {
// Extract command from tool_input.command and use first word as prefix
let command_str = tool_input
.get("command")
.and_then(|v| v.as_str())
.unwrap_or("");
let first_word = command_str.split_whitespace().next().unwrap_or("unknown");
format!("Bash({first_word} *)")
} else {
// For Edit, Write, Read, Glob, Grep, MCP tools, etc. — use the tool name directly
tool_name.to_string()
}
}
/// Add a permission rule to `.claude/settings.json` in the project root.
/// Does nothing if the rule already exists. Creates the file if missing.
pub(super) fn add_permission_rule(
project_root: &std::path::Path,
rule: &str,
) -> Result<(), String> {
let claude_dir = project_root.join(".claude");
fs::create_dir_all(&claude_dir)
.map_err(|e| format!("Failed to create .claude/ directory: {e}"))?;
let settings_path = claude_dir.join("settings.json");
let mut settings: Value = if settings_path.exists() {
let content = fs::read_to_string(&settings_path)
.map_err(|e| format!("Failed to read settings.json: {e}"))?;
serde_json::from_str(&content).map_err(|e| format!("Failed to parse settings.json: {e}"))?
} else {
json!({ "permissions": { "allow": [] } })
};
let allow_arr = settings
.pointer_mut("/permissions/allow")
.and_then(|v| v.as_array_mut());
let allow = match allow_arr {
Some(arr) => arr,
None => {
// Ensure the structure exists
settings
.as_object_mut()
.unwrap()
.entry("permissions")
.or_insert(json!({ "allow": [] }));
settings
.pointer_mut("/permissions/allow")
.unwrap()
.as_array_mut()
.unwrap()
}
};
// Check for duplicates — exact string match
let rule_value = Value::String(rule.to_string());
if allow.contains(&rule_value) {
return Ok(());
}
// Also check for wildcard coverage: if "mcp__huskies__*" exists, don't add
// a more specific "mcp__huskies__create_story".
let dominated = allow.iter().any(|existing| {
if let Some(pat) = existing.as_str()
&& let Some(prefix) = pat.strip_suffix('*')
{
return rule.starts_with(prefix);
}
false
});
if dominated {
return Ok(());
}
allow.push(rule_value);
let pretty =
serde_json::to_string_pretty(&settings).map_err(|e| format!("Failed to serialize: {e}"))?;
fs::write(&settings_path, pretty).map_err(|e| format!("Failed to write settings.json: {e}"))?;
Ok(())
}
/// MCP tool called by Claude Code via `--permission-prompt-tool`.
///
/// Forwards the permission request through the shared channel to the active
+16 -71
View File
@@ -1,68 +1,34 @@
//! MCP git tools — status, diff, add, commit, and log operations on agent worktrees.
//!
//! This file is a thin adapter: it deserialises MCP payloads, delegates to
//! `crate::service::git_ops` for all business logic, and serialises responses.
use crate::http::context::AppContext;
use serde_json::{Value, json};
use std::path::PathBuf;
/// Validates that `worktree_path` exists and is inside the project's
/// `.huskies/worktrees/` directory. Returns the canonicalized path.
///
/// Thin wrapper that obtains the project root from `ctx` and delegates to
/// `service::git_ops::io::validate_worktree_path`.
fn validate_worktree_path(worktree_path: &str, ctx: &AppContext) -> Result<PathBuf, String> {
let wd = PathBuf::from(worktree_path);
if !wd.is_absolute() {
return Err("worktree_path must be an absolute path".to_string());
}
if !wd.exists() {
return Err(format!("worktree_path does not exist: {worktree_path}"));
}
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let worktrees_root = project_root.join(".huskies").join("worktrees");
let canonical_wd = wd
.canonicalize()
.map_err(|e| format!("Cannot canonicalize worktree_path: {e}"))?;
let canonical_wt = if worktrees_root.exists() {
worktrees_root
.canonicalize()
.map_err(|e| format!("Cannot canonicalize worktrees root: {e}"))?
} else {
return Err("No worktrees directory found in project".to_string());
};
if !canonical_wd.starts_with(&canonical_wt) {
return Err(format!(
"worktree_path must be inside .huskies/worktrees/. Got: {worktree_path}"
));
}
Ok(canonical_wd)
crate::service::git_ops::io::validate_worktree_path(worktree_path, &project_root)
.map_err(|e| e.to_string())
}
/// Run a git command in the given directory and return its output.
async fn run_git(args: Vec<&'static str>, dir: PathBuf) -> Result<std::process::Output, String> {
tokio::task::spawn_blocking(move || {
std::process::Command::new("git")
.args(&args)
.current_dir(&dir)
.output()
})
.await
.map_err(|e| format!("Task join error: {e}"))?
.map_err(|e| format!("Failed to run git: {e}"))
crate::service::git_ops::io::run_git(args, dir)
.await
.map_err(|e| e.to_string())
}
/// Run a git command with owned args in the given directory.
async fn run_git_owned(args: Vec<String>, dir: PathBuf) -> Result<std::process::Output, String> {
tokio::task::spawn_blocking(move || {
std::process::Command::new("git")
.args(&args)
.current_dir(&dir)
.output()
})
.await
.map_err(|e| format!("Task join error: {e}"))?
.map_err(|e| format!("Failed to run git: {e}"))
crate::service::git_ops::io::run_git_owned(args, dir)
.await
.map_err(|e| e.to_string())
}
/// git_status — returns working tree status (staged, unstaged, untracked files).
@@ -86,29 +52,8 @@ pub(super) async fn tool_git_status(args: &Value, ctx: &AppContext) -> Result<St
));
}
let mut staged: Vec<String> = Vec::new();
let mut unstaged: Vec<String> = Vec::new();
let mut untracked: Vec<String> = Vec::new();
for line in stdout.lines() {
if line.len() < 3 {
continue;
}
let x = line.chars().next().unwrap_or(' ');
let y = line.chars().nth(1).unwrap_or(' ');
let path = line[3..].to_string();
match (x, y) {
('?', '?') => untracked.push(path),
(' ', _) => unstaged.push(path),
(_, ' ') => staged.push(path),
_ => {
// Both staged and unstaged modifications
staged.push(path.clone());
unstaged.push(path);
}
}
}
let (staged, unstaged, untracked) =
crate::service::git_ops::parse_git_status_porcelain(&stdout);
serde_json::to_string_pretty(&json!({
"staged": staged,
+7 -80
View File
@@ -1,8 +1,12 @@
//! MCP QA tools — request, approve, and reject QA reviews for stories.
//!
//! This file is a thin adapter: it deserialises MCP payloads, delegates to
//! `crate::service::qa` for all business logic, and serialises responses.
use crate::agents::{
move_story_to_done, move_story_to_merge, move_story_to_qa, reject_story_from_qa,
};
use crate::http::context::AppContext;
use crate::service::qa::{find_free_port, is_spike, merge_spike_branch_to_master};
use crate::slog;
use crate::slog_warn;
use serde_json::{Value, json};
@@ -57,8 +61,7 @@ pub(super) async fn tool_approve_qa(args: &Value, ctx: &AppContext) -> Result<St
let _ = crate::io::story_metadata::clear_front_matter_field(&qa_path, "review_hold");
}
let item_type = crate::agents::lifecycle::item_type_from_id(story_id);
if item_type == "spike" {
if is_spike(story_id) {
// Spikes skip the merge stage entirely: merge the feature branch to master
// directly (fast-forward or simple merge), then move straight to done.
let branch = format!("feature/story-{story_id}");
@@ -68,7 +71,8 @@ pub(super) async fn tool_approve_qa(args: &Value, ctx: &AppContext) -> Result<St
let merge_ok =
tokio::task::spawn_blocking(move || merge_spike_branch_to_master(&root, &br, &sid))
.await
.map_err(|e| format!("Merge task panicked: {e}"))??;
.map_err(|e| format!("Merge task panicked: {e}"))?
.map_err(|e| e.to_string())?;
move_story_to_done(&project_root, story_id)?;
@@ -115,73 +119,6 @@ pub(super) async fn tool_approve_qa(args: &Value, ctx: &AppContext) -> Result<St
}
}
/// Merge a spike's feature branch into master using a fast-forward or simple merge.
///
/// Unlike the squash-merge pipeline used for stories, spikes skip quality gates
/// and preserve their commit history. Returns `true` if a merge was performed,
/// `false` if the branch had no unmerged commits.
fn merge_spike_branch_to_master(
project_root: &std::path::Path,
branch: &str,
story_id: &str,
) -> Result<bool, String> {
use std::process::Command;
// Check the branch exists and has unmerged changes.
if !crate::agents::lifecycle::feature_branch_has_unmerged_changes(project_root, story_id) {
slog!("[qa] Spike '{story_id}': feature branch has no unmerged changes, skipping merge.");
return Ok(false);
}
// Ensure we are on master.
let checkout = Command::new("git")
.args(["checkout", "master"])
.current_dir(project_root)
.output()
.map_err(|e| format!("git checkout master failed: {e}"))?;
if !checkout.status.success() {
return Err(format!(
"Failed to checkout master: {}",
String::from_utf8_lossy(&checkout.stderr)
));
}
// Try fast-forward first, then fall back to a regular merge.
let ff = Command::new("git")
.args(["merge", "--ff-only", branch])
.current_dir(project_root)
.output()
.map_err(|e| format!("git merge --ff-only failed: {e}"))?;
if ff.status.success() {
slog!("[qa] Spike '{story_id}': fast-forward merged '{branch}' into master.");
return Ok(true);
}
// Fast-forward failed (diverged history) — fall back to a regular merge.
let merge = Command::new("git")
.args([
"merge",
"--no-ff",
branch,
"-m",
&format!("Merge spike branch '{branch}' into master"),
])
.current_dir(project_root)
.output()
.map_err(|e| format!("git merge failed: {e}"))?;
if merge.status.success() {
slog!("[qa] Spike '{story_id}': merged '{branch}' into master (no-ff).");
Ok(true)
} else {
Err(format!(
"Failed to merge spike branch '{branch}' into master: {}",
String::from_utf8_lossy(&merge.stderr)
))
}
}
pub(super) async fn tool_reject_qa(args: &Value, ctx: &AppContext) -> Result<String, String> {
let story_id = args
.get("story_id")
@@ -294,16 +231,6 @@ pub(super) async fn tool_launch_qa_app(args: &Value, ctx: &AppContext) -> Result
.map_err(|e| format!("Serialization error: {e}"))
}
/// Find a free TCP port starting from `start`.
pub(super) fn find_free_port(start: u16) -> u16 {
for port in start..start + 100 {
if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() {
return port;
}
}
start // fallback
}
#[cfg(test)]
mod tests {
use super::*;
+10 -127
View File
@@ -1,5 +1,10 @@
//! MCP shell tools — run commands, execute tests, and stream output via MCP.
//!
//! This file is a thin adapter: it deserialises MCP payloads, delegates to
//! `crate::service::shell` for all business logic, and serialises responses.
use crate::http::context::AppContext;
#[allow(unused_imports)]
use crate::service::shell::{extract_count, is_dangerous, parse_test_counts, truncate_output};
use bytes::Bytes;
use futures::StreamExt;
use poem::{Body, Response};
@@ -11,92 +16,15 @@ const MAX_TIMEOUT_SECS: u64 = 600;
const TEST_TIMEOUT_SECS: u64 = 1200;
const MAX_OUTPUT_LINES: usize = 100;
/// Patterns that are unconditionally blocked regardless of context.
static BLOCKED_PATTERNS: &[&str] = &[
"rm -rf /",
"rm -fr /",
"rm -rf /*",
"rm -fr /*",
"rm --no-preserve-root",
":(){ :|:& };:",
"> /dev/sda",
"dd if=/dev",
];
/// Binaries that are unconditionally blocked.
static BLOCKED_BINARIES: &[&str] = &[
"sudo", "su", "shutdown", "reboot", "halt", "poweroff", "mkfs",
];
/// Returns an error message if the command matches a blocked pattern or binary.
fn is_dangerous(command: &str) -> Option<String> {
let trimmed = command.trim();
// Check each blocked pattern (substring match)
for &pattern in BLOCKED_PATTERNS {
if trimmed.contains(pattern) {
return Some(format!(
"Command blocked: dangerous pattern '{pattern}' detected"
));
}
}
// Check first token of the command against blocked binaries
if let Some(first_token) = trimmed.split_whitespace().next() {
let binary = std::path::Path::new(first_token)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(first_token);
if BLOCKED_BINARIES.contains(&binary) {
return Some(format!("Command blocked: '{binary}' is not permitted"));
}
}
None
}
/// Validates that `working_dir` exists and is inside the project's
/// `.huskies/worktrees/` directory. Returns the canonicalized path.
///
/// Thin wrapper that obtains the project root from `ctx` and delegates to
/// `service::shell::io::validate_working_dir`.
fn validate_working_dir(working_dir: &str, ctx: &AppContext) -> Result<PathBuf, String> {
let wd = PathBuf::from(working_dir);
if !wd.is_absolute() {
return Err("working_dir must be an absolute path".to_string());
}
if !wd.exists() {
return Err(format!("working_dir does not exist: {working_dir}"));
}
let project_root = ctx.agents.get_project_root(&ctx.state)?;
let worktrees_root = project_root.join(".huskies").join("worktrees");
let canonical_wd = wd
.canonicalize()
.map_err(|e| format!("Cannot canonicalize working_dir: {e}"))?;
// If worktrees_root doesn't exist yet, we can't allow anything
let canonical_wt = if worktrees_root.exists() {
worktrees_root
.canonicalize()
.map_err(|e| format!("Cannot canonicalize worktrees root: {e}"))?
} else {
return Err("No worktrees directory found in project".to_string());
};
// Also allow the merge workspace so mergemaster can fix conflicts.
let merge_workspace = project_root.join(".huskies").join("merge_workspace");
let canonical_mw = merge_workspace.canonicalize().unwrap_or_default();
let in_worktrees = canonical_wd.starts_with(&canonical_wt);
let in_merge_ws =
!canonical_mw.as_os_str().is_empty() && canonical_wd.starts_with(&canonical_mw);
if !in_worktrees && !in_merge_ws {
return Err(format!(
"working_dir must be inside .huskies/worktrees/ or .huskies/merge_workspace/. Got: {working_dir}"
));
}
Ok(canonical_wd)
crate::service::shell::io::validate_working_dir(working_dir, &project_root)
.map_err(|e| e.to_string())
}
/// Regular (non-SSE) run_command: runs the bash command to completion and
@@ -328,51 +256,6 @@ pub(super) fn handle_run_command_sse(
.body(Body::from_bytes_stream(stream.map(|r| r.map(Bytes::from))))
}
/// Truncate output to at most `max_lines` lines, keeping the tail.
fn truncate_output(output: &str, max_lines: usize) -> String {
let lines: Vec<&str> = output.lines().collect();
if lines.len() <= max_lines {
return output.to_string();
}
let omitted = lines.len() - max_lines;
let tail = lines[lines.len() - max_lines..].join("\n");
format!("[... {omitted} lines omitted ...]\n{tail}")
}
/// Parse cumulative passed/failed counts from `cargo test` output lines like:
/// `"test result: ok. 5 passed; 0 failed; ..."`
fn parse_test_counts(output: &str) -> (u64, u64) {
let mut total_passed = 0u64;
let mut total_failed = 0u64;
for line in output.lines() {
if line.contains("test result:") {
if let Some(p) = extract_count(line, "passed") {
total_passed += p;
}
if let Some(f) = extract_count(line, "failed") {
total_failed += f;
}
}
}
(total_passed, total_failed)
}
/// Extract a count immediately before `label` in `line` (e.g. `"5 passed"` → 5).
fn extract_count(line: &str, label: &str) -> Option<u64> {
let pos = line.find(label)?;
let before = line[..pos].trim_end();
let num_str: String = before
.chars()
.rev()
.take_while(|c| c.is_ascii_digit())
.collect();
if num_str.is_empty() {
return None;
}
let num_str: String = num_str.chars().rev().collect();
num_str.parse().ok()
}
/// Run the project's test suite (`script/test`) and block until complete.
///
/// Spawns the test process, then polls every second server-side until the
+6 -40
View File
@@ -1,4 +1,8 @@
//! MCP story tools — create, update, move, and manage stories, bugs, and refactors via MCP.
//!
//! This file is a thin adapter: it deserialises MCP payloads, delegates to
//! `crate::service::story` and `crate::http::workflow` for business logic,
//! and serialises responses.
use crate::agents::{
close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_done,
};
@@ -12,7 +16,9 @@ use crate::http::workflow::{
use crate::io::story_metadata::{
check_archived_deps, check_archived_deps_from_list, parse_front_matter, parse_unchecked_todos,
};
use crate::service::story::parse_test_cases;
use crate::slog_warn;
#[allow(unused_imports)]
use crate::workflow::{TestCaseResult, TestStatus, evaluate_acceptance_with_coverage};
use serde_json::{Value, json};
use std::collections::HashMap;
@@ -702,46 +708,6 @@ pub(super) fn tool_list_refactors(ctx: &AppContext) -> Result<String, String> {
.map_err(|e| format!("Serialization error: {e}"))
}
pub(super) fn parse_test_cases(value: Option<&Value>) -> Result<Vec<TestCaseResult>, String> {
let arr = match value {
Some(Value::Array(a)) => a,
Some(Value::Null) | None => return Ok(Vec::new()),
_ => return Err("Expected array for test cases".to_string()),
};
arr.iter()
.map(|item| {
let name = item
.get("name")
.and_then(|v| v.as_str())
.ok_or("Test case missing 'name'")?
.to_string();
let status_str = item
.get("status")
.and_then(|v| v.as_str())
.ok_or("Test case missing 'status'")?;
let status = match status_str {
"pass" => TestStatus::Pass,
"fail" => TestStatus::Fail,
other => {
return Err(format!(
"Invalid test status '{other}'. Use 'pass' or 'fail'."
));
}
};
let details = item
.get("details")
.and_then(|v| v.as_str())
.map(String::from);
Ok(TestCaseResult {
name,
status,
details,
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;