blue/crates/blue-mcp/src/handlers/decision.rs
Eric Garcia bfcc9453fb feat(mcp): Complete Phase 2 - Workflow handlers
Add document lifecycle handlers for spike, ADR, decision, and worktree
operations. This brings the tool count from 9 to 16.

New tools:
- blue_spike_create: Start time-boxed investigation
- blue_spike_complete: Complete investigation with outcome
- blue_adr_create: Create Architecture Decision Record
- blue_decision_create: Create lightweight Decision Note
- blue_worktree_create: Create isolated git worktree for RFC
- blue_worktree_list: List active worktrees
- blue_worktree_remove: Remove worktree after merge

All handlers use Blue's voice for consistent messaging.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 00:49:57 -05:00

98 lines
2.9 KiB
Rust

//! Decision tool handlers
//!
//! Handles lightweight Decision Note creation.
use std::fs;
use blue_core::{Decision, DocType, Document, ProjectState};
use serde_json::{json, Value};
use crate::error::ServerError;
/// Handle blue_decision_create
pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
let title = args
.get("title")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
let decision_text = args
.get("decision")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
let rationale = args.get("rationale").and_then(|v| v.as_str());
let alternatives: Vec<String> = args
.get("alternatives")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
// Create decision
let mut decision = Decision::new(title, decision_text);
if let Some(rat) = rationale {
decision.rationale = Some(rat.to_string());
}
decision.alternatives = alternatives;
// Generate markdown
let markdown = decision.to_markdown();
// Compute file path (date-based)
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
let file_name = format!("{}-{}.md", today, to_kebab_case(title));
let file_path = format!("decisions/{}", file_name);
// Write the file
let docs_path = state.home.docs_path(&state.project);
let decision_path = docs_path.join(&file_path);
// Check if already exists
if decision_path.exists() {
return Ok(json!({
"status": "error",
"message": blue_core::voice::error(
&format!("Decision '{}' already exists for today", title),
"Use a different title or update the existing one"
)
}));
}
if let Some(parent) = decision_path.parent() {
fs::create_dir_all(parent).map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
}
fs::write(&decision_path, &markdown).map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
// Add to store
let mut doc = Document::new(DocType::Decision, title, "recorded");
doc.file_path = Some(file_path.clone());
let id = state
.store
.add_document(&doc)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
Ok(json!({
"status": "success",
"id": id,
"title": title,
"file": decision_path.display().to_string(),
"markdown": markdown,
"message": blue_core::voice::success(
&format!("Recorded decision: '{}'", title),
None
)
}))
}
/// Convert a string to kebab-case
fn to_kebab_case(s: &str) -> String {
s.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}