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 <noreply@anthropic.com>
This commit is contained in:
Eric Garcia 2026-01-24 03:48:07 -05:00
parent 8977b30e63
commit ddaa1cfca8
6 changed files with 605 additions and 1 deletions

View file

@ -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<Value, ServerError> {
let mut issues: Vec<AuditIssue> = Vec::new();
let mut recommendations: Vec<String> = 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);
}
}

View file

@ -3,10 +3,12 @@
//! Each module handles a specific document type or workflow. //! Each module handles a specific document type or workflow.
pub mod adr; pub mod adr;
pub mod audit;
pub mod decision; pub mod decision;
pub mod pr; pub mod pr;
pub mod release; pub mod release;
pub mod reminder; pub mod reminder;
pub mod rfc;
pub mod session; pub mod session;
pub mod spike; pub mod spike;
pub mod staging; pub mod staging;

View file

@ -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<Value, ServerError> {
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<String> = 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);
}
}

View file

@ -136,6 +136,104 @@ pub fn handle_list(state: &ProjectState) -> Result<Value, ServerError> {
})) }))
} }
/// 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<Value, ServerError> {
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 /// Handle blue_worktree_remove
pub fn handle_remove(state: &ProjectState, args: &Value) -> Result<Value, ServerError> { pub fn handle_remove(state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
let title = args let title = args

View file

@ -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_unlock" => self.handle_staging_unlock(&call.arguments),
"blue_staging_status" => self.handle_staging_status(&call.arguments), "blue_staging_status" => self.handle_staging_status(&call.arguments),
"blue_staging_cleanup" => self.handle_staging_cleanup(&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)), _ => Err(ServerError::ToolNotFound(call.name)),
}?; }?;
@ -1422,6 +1475,25 @@ impl BlueServer {
let state = self.ensure_state()?; let state = self.ensure_state()?;
crate::handlers::staging::handle_cleanup(state, args) crate::handlers::staging::handle_cleanup(state, args)
} }
// Phase 6: Audit and completion handlers
fn handle_audit(&mut self, _args: &Option<Value>) -> Result<Value, ServerError> {
let state = self.ensure_state()?;
crate::handlers::audit::handle_audit(state)
}
fn handle_rfc_complete(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
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<Value>) -> Result<Value, ServerError> {
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 { impl Default for BlueServer {

View file

@ -241,11 +241,22 @@ blue/
- [x] Blue's voice in all error messages - [x] Blue's voice in all error messages
- [x] 24 tests passing - [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): Remaining tools to port (if needed):
- Code search/indexing (requires tree-sitter) - Code search/indexing (requires tree-sitter)
- IaC detection and staging deployment tracking - IaC detection and staging deployment tracking
- PRD tools (5): create, get, approve, complete, list
## Test Plan ## Test Plan