diff --git a/crates/blue-core/src/lib.rs b/crates/blue-core/src/lib.rs index 1cde6b1..689bc05 100644 --- a/crates/blue-core/src/lib.rs +++ b/crates/blue-core/src/lib.rs @@ -35,4 +35,4 @@ pub use repo::{detect_blue, BlueHome, RepoError, WorktreeInfo}; pub use state::{ItemType, ProjectState, StateError, StatusSummary, WorkItem}; pub use store::{DocType, Document, DocumentStore, FileIndexEntry, IndexSearchResult, IndexStatus, LinkType, Reminder, ReminderStatus, SearchResult, Session, SessionType, StagingLock, StagingLockQueueEntry, StagingLockResult, StoreError, SymbolIndexEntry, Task as StoreTask, TaskProgress, Worktree, INDEX_PROMPT_VERSION}; pub use voice::*; -pub use workflow::{PrdStatus, RfcStatus, SpikeOutcome as WorkflowSpikeOutcome, SpikeStatus, WorkflowError}; +pub use workflow::{PrdStatus, RfcStatus, SpikeOutcome as WorkflowSpikeOutcome, SpikeStatus, WorkflowError, validate_rfc_transition}; diff --git a/crates/blue-mcp/src/error.rs b/crates/blue-mcp/src/error.rs index 1276843..7642cfe 100644 --- a/crates/blue-mcp/src/error.rs +++ b/crates/blue-mcp/src/error.rs @@ -27,6 +27,9 @@ pub enum ServerError { #[error("Not found: {0}")] NotFound(String), + + #[error("Workflow error: {0}")] + Workflow(String), } impl ServerError { @@ -41,6 +44,7 @@ impl ServerError { ServerError::StateLoadFailed(_) => -32001, ServerError::CommandFailed(_) => -32002, ServerError::NotFound(_) => -32003, + ServerError::Workflow(_) => -32004, } } } diff --git a/crates/blue-mcp/src/handlers/pr.rs b/crates/blue-mcp/src/handlers/pr.rs index ff57c33..2301252 100644 --- a/crates/blue-mcp/src/handlers/pr.rs +++ b/crates/blue-mcp/src/handlers/pr.rs @@ -14,7 +14,7 @@ use std::process::Command; -use blue_core::{CreatePrOpts, MergeStrategy, ProjectState, create_forge_cached, detect_forge_type_cached, parse_git_url}; +use blue_core::{CreatePrOpts, DocType, MergeStrategy, ProjectState, create_forge_cached, detect_forge_type_cached, parse_git_url}; use serde_json::{json, Value}; use crate::error::ServerError; @@ -41,6 +41,36 @@ pub enum TaskCategory { pub fn handle_create(state: &ProjectState, args: &Value) -> Result { let rfc = args.get("rfc").and_then(|v| v.as_str()); + // If RFC is provided, validate workflow state (RFC 0014) + if let Some(rfc_title) = rfc { + // Check RFC exists and has worktree + if let Ok(doc) = state.store.find_document(DocType::Rfc, rfc_title) { + // Warn if RFC isn't implemented yet + if doc.status != "implemented" && doc.status != "in-progress" { + return Ok(json!({ + "status": "error", + "message": blue_core::voice::error( + &format!("RFC '{}' is {} - complete implementation first", rfc_title, doc.status), + "Use blue_rfc_complete after finishing work" + ) + })); + } + + // Check worktree exists + if let Some(doc_id) = doc.id { + if state.store.get_worktree(doc_id).ok().flatten().is_none() { + return Ok(json!({ + "status": "warning", + "message": blue_core::voice::error( + "No worktree for this RFC", + "PRs usually come from worktrees. Proceed with caution." + ) + })); + } + } + } + } + // If RFC is provided, format title as "RFC NNNN: Title Case Name" let title = if let Some(rfc_title) = rfc { let (stripped, number) = strip_rfc_number_prefix(rfc_title); diff --git a/crates/blue-mcp/src/handlers/worktree.rs b/crates/blue-mcp/src/handlers/worktree.rs index a80396f..4345aca 100644 --- a/crates/blue-mcp/src/handlers/worktree.rs +++ b/crates/blue-mcp/src/handlers/worktree.rs @@ -119,17 +119,37 @@ pub fn handle_create(state: &ProjectState, args: &Value) -> Result Result { // Record in store - if let Some(doc_id) = doc.id { - let wt = StoreWorktree { - id: None, - document_id: doc_id, - branch_name: branch_name.clone(), - worktree_path: worktree_path.display().to_string(), - created_at: None, - }; - let _ = state.store.add_worktree(&wt); - } + let wt = StoreWorktree { + id: None, + document_id: doc_id, + branch_name: branch_name.clone(), + worktree_path: worktree_path.display().to_string(), + created_at: None, + }; + let _ = state.store.add_worktree(&wt); // Update RFC status to in-progress if accepted if doc.status == "accepted" { @@ -479,4 +497,23 @@ mod tests { assert_eq!(stripped, "0007feature"); assert_eq!(number, None); } + + #[test] + fn test_worktree_requires_plan() { + use blue_core::{Document, ProjectState}; + + let state = ProjectState::for_test(); + + // Create an accepted RFC without a plan + let mut doc = Document::new(DocType::Rfc, "test-rfc", "accepted"); + doc.number = Some(1); + state.store.add_document(&doc).unwrap(); + + // Try to create worktree - should fail due to missing plan + let args = serde_json::json!({ "title": "test-rfc" }); + let result = handle_create(&state, &args).unwrap(); + + assert_eq!(result["status"], "error"); + assert!(result["message"].as_str().unwrap().contains("needs a plan")); + } } diff --git a/crates/blue-mcp/src/server.rs b/crates/blue-mcp/src/server.rs index d775aaf..3b363f2 100644 --- a/crates/blue-mcp/src/server.rs +++ b/crates/blue-mcp/src/server.rs @@ -9,7 +9,7 @@ use serde::Deserialize; use serde_json::{json, Value}; use tracing::{debug, info}; -use blue_core::{detect_blue, DocType, Document, ProjectState, Rfc}; +use blue_core::{detect_blue, DocType, Document, ProjectState, Rfc, RfcStatus, validate_rfc_transition}; use crate::error::ServerError; @@ -2398,39 +2398,68 @@ impl BlueServer { .and_then(|v| v.as_str()) .ok_or(ServerError::InvalidParams)?; - let status = args + let status_str = args .get("status") .and_then(|v| v.as_str()) .ok_or(ServerError::InvalidParams)?; let state = self.ensure_state()?; - // Find the document to get its file path + // Find the document to get its file path and current status let doc = state.store.find_document(DocType::Rfc, title) .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + // Parse statuses and validate transition (RFC 0014) + let current_status = RfcStatus::parse(&doc.status) + .map_err(|e| ServerError::Workflow(e.to_string()))?; + let target_status = RfcStatus::parse(status_str) + .map_err(|e| ServerError::Workflow(e.to_string()))?; + + // Validate the transition + validate_rfc_transition(current_status, target_status) + .map_err(|e| ServerError::Workflow(e.to_string()))?; + // Check for worktree if going to in-progress (RFC 0011) let has_worktree = state.has_worktree(title); - let worktree_warning = if status == "in-progress" && !has_worktree { + let worktree_warning = if status_str == "in-progress" && !has_worktree { Some("No worktree exists for this RFC. Consider using blue_worktree_create for isolated development.") } else { None }; // Update database - state.store.update_document_status(DocType::Rfc, title, status) + state.store.update_document_status(DocType::Rfc, title, status_str) .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; // Update markdown file (RFC 0008) let file_updated = if let Some(ref file_path) = doc.file_path { let full_path = state.home.docs_path.join(file_path); - blue_core::update_markdown_status(&full_path, status).unwrap_or(false) + blue_core::update_markdown_status(&full_path, status_str).unwrap_or(false) } else { false }; + // Conversational hints guide Claude to next action (RFC 0014) + let hint = match target_status { + RfcStatus::Accepted => Some( + "RFC accepted. Ask the user: 'Ready to begin implementation? \ + I'll create a worktree and set up the environment.'" + ), + RfcStatus::InProgress => Some( + "Implementation started. Work in the worktree, mark plan tasks \ + as you complete them." + ), + RfcStatus::Implemented => Some( + "Implementation complete. Ask the user: 'Ready to create a PR?'" + ), + RfcStatus::Superseded => Some( + "RFC superseded. The newer RFC takes precedence." + ), + RfcStatus::Draft => None, + }; + // Build next_action for accepted status (RFC 0011) - let next_action = if status == "accepted" { + let next_action = if status_str == "accepted" { Some(json!({ "tool": "blue_worktree_create", "args": { "title": title }, @@ -2443,11 +2472,11 @@ impl BlueServer { let mut response = json!({ "status": "success", "title": title, - "new_status": status, + "new_status": status_str, "file_updated": file_updated, "message": blue_core::voice::success( - &format!("Updated '{}' to {}", title, status), - None + &format!("Updated '{}' to {}", title, status_str), + hint ) });