RFC 0014: Workflow Enforcement Parity (#1)

Co-authored-by: Eric Garcia <eric.garcia@gmail.com>
Co-committed-by: Eric Garcia <eric.garcia@gmail.com>
This commit is contained in:
Eric Garcia 2026-01-25 19:21:42 +00:00 committed by eric
parent 1afdd05ea6
commit a07737f3dc
5 changed files with 132 additions and 32 deletions

View file

@ -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};

View file

@ -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,
}
}
}

View file

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

View file

@ -119,9 +119,30 @@ pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, Server
}));
}
// Check RFC has a plan (RFC 0014: plan enforcement)
let doc_id = doc.id.ok_or(ServerError::InvalidParams)?;
let tasks = state
.store
.get_tasks(doc_id)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
if tasks.is_empty() {
return Ok(json!({
"status": "error",
"message": blue_core::voice::error(
&format!("RFC '{}' needs a plan before creating worktree", title),
"Create a plan first with blue_rfc_plan"
),
"next_action": {
"tool": "blue_rfc_plan",
"args": { "title": title },
"hint": "Create implementation tasks before starting work"
}
}));
}
// Check if worktree already exists
if let Some(id) = doc.id {
if let Ok(Some(_existing)) = state.store.get_worktree(id) {
if let Ok(Some(_existing)) = state.store.get_worktree(doc_id) {
return Ok(json!({
"status": "error",
"message": blue_core::voice::error(
@ -130,7 +151,6 @@ pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, Server
)
}));
}
}
// Create branch name and worktree path (RFC 0007: strip number prefix)
let (stripped_name, _rfc_number) = strip_rfc_number_prefix(title);
@ -144,7 +164,6 @@ pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, Server
match blue_core::repo::create_worktree(&repo, &branch_name, &worktree_path) {
Ok(()) => {
// Record in store
if let Some(doc_id) = doc.id {
let wt = StoreWorktree {
id: None,
document_id: doc_id,
@ -153,7 +172,6 @@ pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, Server
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"));
}
}

View file

@ -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
)
});