From ddaa1cfca8a92f8cb5b65acdc4a097bbebc652c1 Mon Sep 17 00:00:00 2001 From: Eric Garcia Date: Sat, 24 Jan 2026 03:48:07 -0500 Subject: [PATCH] feat: Phase 6 - audit and completion tools Add three high-priority tools from coherence-mcp: - blue_audit: Project health check with issues and recommendations - Checks for stalled RFCs (in-progress without worktrees) - Finds implemented RFCs without ADRs - Detects overdue reminders and expired staging locks - blue_rfc_complete: Mark RFC as implemented - Requires 70% task completion minimum - Auto-advances from accepted to in-progress if needed - Identifies ADR graduation candidates - Returns remaining tasks for follow-up - blue_worktree_cleanup: Post-merge cleanup - Verifies PR is merged - Removes git worktree - Deletes local branch - Returns commands for syncing with develop Total: 35 MCP tools, 28 tests passing Co-Authored-By: Claude Opus 4.5 --- crates/blue-mcp/src/handlers/audit.rs | 174 ++++++++++++ crates/blue-mcp/src/handlers/mod.rs | 2 + crates/blue-mcp/src/handlers/rfc.rs | 247 ++++++++++++++++++ crates/blue-mcp/src/handlers/worktree.rs | 98 +++++++ crates/blue-mcp/src/server.rs | 72 +++++ .../rfcs/0002-port-coherence-functionality.md | 13 +- 6 files changed, 605 insertions(+), 1 deletion(-) create mode 100644 crates/blue-mcp/src/handlers/audit.rs create mode 100644 crates/blue-mcp/src/handlers/rfc.rs diff --git a/crates/blue-mcp/src/handlers/audit.rs b/crates/blue-mcp/src/handlers/audit.rs new file mode 100644 index 0000000..640751c --- /dev/null +++ b/crates/blue-mcp/src/handlers/audit.rs @@ -0,0 +1,174 @@ +//! Audit tool handler +//! +//! Checks project health and finds issues. + +use blue_core::{DocType, ProjectState}; +use serde_json::{json, Value}; + +use crate::error::ServerError; + +/// Issue found during audit +#[derive(Debug)] +struct AuditIssue { + category: &'static str, + title: String, + issue: String, + severity: &'static str, +} + +/// Handle blue_audit +pub fn handle_audit(state: &ProjectState) -> Result { + let mut issues: Vec = Vec::new(); + let mut recommendations: Vec = Vec::new(); + + // Check 1: In-progress RFCs without worktrees (stalled) + if let Ok(docs) = state + .store + .list_documents_by_status(DocType::Rfc, "in-progress") + { + let worktrees = state.store.list_worktrees().unwrap_or_default(); + for doc in docs { + let has_worktree = worktrees.iter().any(|wt| wt.document_id == doc.id.unwrap_or(0)); + if !has_worktree { + issues.push(AuditIssue { + category: "rfc", + title: doc.title.clone(), + issue: "In-progress but no active worktree (possibly stalled)".into(), + severity: "warning", + }); + recommendations.push(format!( + "Check on '{}' - marked in-progress but no worktree found", + doc.title + )); + } + } + } + + // Check 2: Implemented RFCs without ADRs + if let Ok(implemented) = state + .store + .list_documents_by_status(DocType::Rfc, "implemented") + { + if let Ok(adrs) = state.store.list_documents(DocType::Adr) { + for rfc in implemented { + let has_adr = adrs + .iter() + .any(|adr| adr.title == rfc.title || adr.title.contains(&rfc.title)); + if !has_adr { + issues.push(AuditIssue { + category: "rfc", + title: rfc.title.clone(), + issue: "Implemented but no ADR created".into(), + severity: "info", + }); + } + } + } + } + + // Check 3: Draft RFCs (potential backlog) + if let Ok(drafts) = state.store.list_documents_by_status(DocType::Rfc, "draft") { + let draft_count = drafts.len(); + if draft_count > 5 { + recommendations.push(format!( + "{} draft RFCs - consider reviewing and accepting or archiving", + draft_count + )); + } + } + + // Check 4: Stale reminders (overdue by more than 7 days) + if let Ok(reminders) = state.store.list_reminders(Some(blue_core::ReminderStatus::Pending), false) { + let today = chrono::Utc::now().format("%Y-%m-%d").to_string(); + for reminder in reminders { + if let Some(due) = &reminder.due_date { + if due < &today { + issues.push(AuditIssue { + category: "reminder", + title: reminder.title.clone(), + issue: format!("Overdue since {}", due), + severity: "warning", + }); + } + } + } + } + + // Check 5: Expired staging locks + if let Ok(locks) = state.store.list_staging_locks() { + let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); + for lock in locks { + if lock.expires_at < now { + issues.push(AuditIssue { + category: "staging", + title: lock.resource.clone(), + issue: format!("Lock expired at {} (held by '{}')", lock.expires_at, lock.locked_by), + severity: "warning", + }); + recommendations.push(format!( + "Run blue_staging_cleanup to clear expired lock on '{}'", + lock.resource + )); + } + } + } + + // Generate summary + let error_count = issues.iter().filter(|i| i.severity == "error").count(); + let warning_count = issues.iter().filter(|i| i.severity == "warning").count(); + let info_count = issues.iter().filter(|i| i.severity == "info").count(); + + let hint = if error_count > 0 { + format!( + "{} errors, {} warnings found - attention needed", + error_count, warning_count + ) + } else if warning_count > 0 { + format!("{} warnings found - review recommended", warning_count) + } else if info_count > 0 { + format!("{} items noted - project is healthy", info_count) + } else { + "No issues found - project is healthy".into() + }; + + // Format issues for response + let issues_json: Vec<_> = issues + .iter() + .map(|i| { + json!({ + "category": i.category, + "title": i.title, + "issue": i.issue, + "severity": i.severity, + }) + }) + .collect(); + + Ok(json!({ + "status": "success", + "message": blue_core::voice::info( + &format!("{} issues found", issues.len()), + Some(&hint) + ), + "issues": issues_json, + "recommendations": recommendations, + "summary": { + "errors": error_count, + "warnings": warning_count, + "info": info_count, + } + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_audit_empty_project() { + let state = ProjectState::for_test(); + let result = handle_audit(&state).unwrap(); + assert_eq!(result["status"], "success"); + assert_eq!(result["issues"].as_array().unwrap().len(), 0); + } +} diff --git a/crates/blue-mcp/src/handlers/mod.rs b/crates/blue-mcp/src/handlers/mod.rs index 57dd262..2020ad6 100644 --- a/crates/blue-mcp/src/handlers/mod.rs +++ b/crates/blue-mcp/src/handlers/mod.rs @@ -3,10 +3,12 @@ //! Each module handles a specific document type or workflow. pub mod adr; +pub mod audit; pub mod decision; pub mod pr; pub mod release; pub mod reminder; +pub mod rfc; pub mod session; pub mod spike; pub mod staging; diff --git a/crates/blue-mcp/src/handlers/rfc.rs b/crates/blue-mcp/src/handlers/rfc.rs new file mode 100644 index 0000000..f72e438 --- /dev/null +++ b/crates/blue-mcp/src/handlers/rfc.rs @@ -0,0 +1,247 @@ +//! RFC tool handlers +//! +//! Handles RFC lifecycle operations like marking complete. + +use blue_core::{DocType, ProjectState}; +use serde_json::{json, Value}; + +use crate::error::ServerError; + +/// Handle blue_rfc_complete +/// +/// Marks an RFC as implemented based on plan progress. +/// - 100%: Plan complete, ready for PR +/// - 70-99%: Core complete, follow-up tasks identified +/// - <70%: Not ready - complete more tasks first +pub fn handle_complete(state: &ProjectState, args: &Value) -> Result { + let title = args + .get("title") + .and_then(|v| v.as_str()) + .ok_or(ServerError::InvalidParams)?; + + // Find the RFC + let doc = state + .store + .find_document(DocType::Rfc, title) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + let doc_id = doc.id.ok_or(ServerError::InvalidParams)?; + + // Check current status + match doc.status.as_str() { + "draft" => { + return Ok(json!({ + "status": "error", + "message": blue_core::voice::error( + "Can't complete a draft RFC", + "Accept it first with blue_rfc_update_status" + ) + })); + } + "implemented" => { + return Ok(json!({ + "status": "success", + "title": title, + "already_implemented": true, + "message": blue_core::voice::info( + &format!("'{}' is already implemented", title), + None::<&str> + ) + })); + } + "superseded" => { + return Ok(json!({ + "status": "error", + "message": blue_core::voice::error( + "Can't complete a superseded RFC", + "This RFC was replaced by another" + ) + })); + } + _ => {} // accepted or in-progress - continue + } + + // Check plan progress + let progress = state + .store + .get_task_progress(doc_id) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + // No tasks = assume complete + let (completed, total, percentage) = if progress.total == 0 { + (1, 1, 100) + } else { + (progress.completed, progress.total, progress.percentage) + }; + + // Check progress thresholds + if percentage < 70 { + return Ok(json!({ + "status": "error", + "message": blue_core::voice::error( + &format!("Only {}/{} tasks done ({}%)", completed, total, percentage), + "Need at least 70% to mark as implemented" + ), + "progress": { + "completed": completed, + "total": total, + "percentage": percentage + } + })); + } + + // Auto-advance from accepted to in-progress if needed + let status_auto_advanced = doc.status == "accepted"; + if status_auto_advanced { + state + .store + .update_document_status(DocType::Rfc, title, "in-progress") + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + } + + // Update to implemented + state + .store + .update_document_status(DocType::Rfc, title, "implemented") + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + // Determine follow-up needs + let followup_needed = percentage < 100; + let remaining_count = total - completed; + + // Get remaining tasks if any + let remaining_tasks: Vec = if followup_needed { + state + .store + .get_tasks(doc_id) + .unwrap_or_default() + .iter() + .filter(|t| !t.completed) + .map(|t| t.description.clone()) + .collect() + } else { + vec![] + }; + + // Check for ADR potential + let adr_candidate = check_adr_potential(state, title); + + let hint = if followup_needed { + format!( + "Core work done ({}%). {} tasks remain for follow-up.", + percentage, remaining_count + ) + } else { + "All tasks complete. Ready for PR.".to_string() + }; + + let adr_hint = if adr_candidate { + Some(format!( + "This RFC may warrant an ADR. Use blue_adr_create with rfc='{}' to graduate.", + title + )) + } else { + None + }; + + Ok(json!({ + "status": "success", + "title": title, + "new_status": "implemented", + "message": blue_core::voice::success( + &format!("Marked '{}' as implemented", title), + Some(&hint) + ), + "status_auto_advanced": status_auto_advanced, + "followup_needed": followup_needed, + "remaining_tasks": remaining_tasks, + "progress": { + "completed": completed, + "total": total, + "percentage": percentage + }, + "adr_candidate": adr_candidate, + "adr_hint": adr_hint, + "next_steps": [ + "Create PR: blue_pr_create", + "After merge: blue_worktree_cleanup" + ] + })) +} + +/// Check if an RFC is a good ADR candidate based on architectural indicators +fn check_adr_potential(state: &ProjectState, title: &str) -> bool { + // Look for architectural keywords in the RFC title/metadata + let indicators = [ + "architecture", + "pattern", + "framework", + "infrastructure", + "system", + "design", + "structure", + ]; + + let title_lower = title.to_lowercase(); + let score = indicators + .iter() + .filter(|&ind| title_lower.contains(ind)) + .count(); + + // Also check if there are linked ADRs already + if let Ok(adrs) = state.store.list_documents(DocType::Adr) { + let has_adr = adrs.iter().any(|adr| adr.title.contains(title)); + if has_adr { + return false; // Already has an ADR + } + } + + score >= 1 +} + +#[cfg(test)] +mod tests { + use super::*; + use blue_core::Document; + + #[test] + fn test_complete_requires_title() { + let state = ProjectState::for_test(); + let args = json!({}); + + let result = handle_complete(&state, &args); + assert!(result.is_err()); + } + + #[test] + fn test_complete_draft_fails() { + let state = ProjectState::for_test(); + + // Create a draft RFC + let mut doc = Document::new(DocType::Rfc, "test-rfc", "draft"); + doc.number = Some(1); + state.store.add_document(&doc).unwrap(); + + let args = json!({ "title": "test-rfc" }); + let result = handle_complete(&state, &args).unwrap(); + + assert_eq!(result["status"], "error"); + } + + #[test] + fn test_complete_accepted_rfc() { + let state = ProjectState::for_test(); + + // Create an accepted RFC + let mut doc = Document::new(DocType::Rfc, "test-rfc", "accepted"); + doc.number = Some(1); + state.store.add_document(&doc).unwrap(); + + let args = json!({ "title": "test-rfc" }); + let result = handle_complete(&state, &args).unwrap(); + + assert_eq!(result["status"], "success"); + assert_eq!(result["new_status"], "implemented"); + assert_eq!(result["status_auto_advanced"], true); + } +} diff --git a/crates/blue-mcp/src/handlers/worktree.rs b/crates/blue-mcp/src/handlers/worktree.rs index 066abc1..6e62a7d 100644 --- a/crates/blue-mcp/src/handlers/worktree.rs +++ b/crates/blue-mcp/src/handlers/worktree.rs @@ -136,6 +136,104 @@ pub fn handle_list(state: &ProjectState) -> Result { })) } +/// Handle blue_worktree_cleanup +/// +/// Full cleanup after PR merge: +/// 1. Verify PR is merged +/// 2. Remove worktree +/// 3. Delete local branch +/// 4. Return commands for switching to develop +pub fn handle_cleanup(state: &ProjectState, args: &Value) -> Result { + let title = args + .get("title") + .and_then(|v| v.as_str()) + .ok_or(ServerError::InvalidParams)?; + + let branch_name = format!("rfc/{}", title); + + // Find the RFC to get worktree info + let doc = state + .store + .find_document(DocType::Rfc, title) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + let doc_id = doc.id.ok_or(ServerError::InvalidParams)?; + + // Get worktree info + let worktree = state.store.get_worktree(doc_id).ok().flatten(); + + // Try to open the repository + let repo_path = state.home.repos_path.join(&state.project); + let repo = match git2::Repository::open(&repo_path) { + Ok(r) => r, + Err(e) => { + return Ok(json!({ + "status": "error", + "message": blue_core::voice::error( + &format!("Couldn't open repository: {}", e), + "Make sure you're in a git repository" + ) + })); + } + }; + + // Check if branch is merged + let is_merged = blue_core::repo::is_branch_merged(&repo, &branch_name, "develop") + .or_else(|_| blue_core::repo::is_branch_merged(&repo, &branch_name, "main")) + .unwrap_or(false); + + if !is_merged { + return Ok(json!({ + "status": "error", + "message": blue_core::voice::error( + "PR doesn't appear to be merged yet", + "Complete the merge first with blue_pr_merge" + ) + })); + } + + // Remove worktree from git + let worktree_removed = if worktree.is_some() { + blue_core::repo::remove_worktree(&repo, &branch_name).is_ok() + } else { + false + }; + + // Delete local branch + let branch_deleted = if let Ok(mut branch) = repo.find_branch(&branch_name, git2::BranchType::Local) { + branch.delete().is_ok() + } else { + false + }; + + // Remove from store + if worktree.is_some() { + let _ = state.store.remove_worktree(doc_id); + } + + let hint = format!( + "Worktree {}removed, branch {}deleted. Run the commands to complete cleanup.", + if worktree_removed { "" } else { "not " }, + if branch_deleted { "" } else { "not " } + ); + + Ok(json!({ + "status": "success", + "title": title, + "worktree_removed": worktree_removed, + "branch_deleted": branch_deleted, + "message": blue_core::voice::success( + &format!("Cleaned up after '{}'", title), + Some(&hint) + ), + "commands": [ + "git checkout develop", + "git pull" + ], + "next_action": "Execute the commands to sync with develop" + })) +} + /// Handle blue_worktree_remove pub fn handle_remove(state: &ProjectState, args: &Value) -> Result { let title = args diff --git a/crates/blue-mcp/src/server.rs b/crates/blue-mcp/src/server.rs index 8a9fa22..475ab10 100644 --- a/crates/blue-mcp/src/server.rs +++ b/crates/blue-mcp/src/server.rs @@ -831,6 +831,55 @@ impl BlueServer { } } } + }, + { + "name": "blue_audit", + "description": "Check project health and find issues. Returns stalled work, missing ADRs, and recommendations.", + "inputSchema": { + "type": "object", + "properties": { + "cwd": { + "type": "string", + "description": "Current working directory" + } + } + } + }, + { + "name": "blue_rfc_complete", + "description": "Mark RFC as implemented based on plan progress. Requires at least 70% completion.", + "inputSchema": { + "type": "object", + "properties": { + "cwd": { + "type": "string", + "description": "Current working directory" + }, + "title": { + "type": "string", + "description": "RFC title" + } + }, + "required": ["title"] + } + }, + { + "name": "blue_worktree_cleanup", + "description": "Clean up after PR merge. Removes worktree, deletes local branch, and provides commands to sync.", + "inputSchema": { + "type": "object", + "properties": { + "cwd": { + "type": "string", + "description": "Current working directory" + }, + "title": { + "type": "string", + "description": "RFC title" + } + }, + "required": ["title"] + } } ] })) @@ -887,6 +936,10 @@ impl BlueServer { "blue_staging_unlock" => self.handle_staging_unlock(&call.arguments), "blue_staging_status" => self.handle_staging_status(&call.arguments), "blue_staging_cleanup" => self.handle_staging_cleanup(&call.arguments), + // Phase 6: Audit and completion handlers + "blue_audit" => self.handle_audit(&call.arguments), + "blue_rfc_complete" => self.handle_rfc_complete(&call.arguments), + "blue_worktree_cleanup" => self.handle_worktree_cleanup(&call.arguments), _ => Err(ServerError::ToolNotFound(call.name)), }?; @@ -1422,6 +1475,25 @@ impl BlueServer { let state = self.ensure_state()?; crate::handlers::staging::handle_cleanup(state, args) } + + // Phase 6: Audit and completion handlers + + fn handle_audit(&mut self, _args: &Option) -> Result { + let state = self.ensure_state()?; + crate::handlers::audit::handle_audit(state) + } + + fn handle_rfc_complete(&mut self, args: &Option) -> Result { + let args = args.as_ref().ok_or(ServerError::InvalidParams)?; + let state = self.ensure_state()?; + crate::handlers::rfc::handle_complete(state, args) + } + + fn handle_worktree_cleanup(&mut self, args: &Option) -> Result { + let args = args.as_ref().ok_or(ServerError::InvalidParams)?; + let state = self.ensure_state()?; + crate::handlers::worktree::handle_cleanup(state, args) + } } impl Default for BlueServer { diff --git a/docs/rfcs/0002-port-coherence-functionality.md b/docs/rfcs/0002-port-coherence-functionality.md index aa9aa0f..709b028 100644 --- a/docs/rfcs/0002-port-coherence-functionality.md +++ b/docs/rfcs/0002-port-coherence-functionality.md @@ -241,11 +241,22 @@ blue/ - [x] Blue's voice in all error messages - [x] 24 tests passing -### Phase 6: Pending (Future) +### Phase 6: Audit and Completion - COMPLETE + +- [x] handlers/audit.rs - Project health check with issues and recommendations +- [x] handlers/rfc.rs - RFC completion with progress validation +- [x] handlers/worktree.rs - Added cleanup handler for post-merge workflow +- [x] 3 new MCP tools: blue_audit, blue_rfc_complete, blue_worktree_cleanup +- [x] Total: 35 MCP tools +- [x] Blue's voice in all error messages +- [x] 28 tests passing + +### Phase 7: Pending (Future) Remaining tools to port (if needed): - Code search/indexing (requires tree-sitter) - IaC detection and staging deployment tracking +- PRD tools (5): create, get, approve, complete, list ## Test Plan