From 6ff8ba706c13c101a7f078a6ae1b64b32a897363 Mon Sep 17 00:00:00 2001 From: Eric Garcia Date: Fri, 30 Jan 2026 08:59:20 -0500 Subject: [PATCH] feat: RFC 0038 SDLC workflow discipline implementation - Add `blue guard` CLI command for PreToolUse hook integration - Allowlist patterns for .blue/docs/, .claude/, /tmp/, root *.md - Worktree detection and RFC branch validation - Audit logging for bypass tracking - Add PreToolUse hook in .claude/settings.json - Add produces_rfcs field to Spike struct for multi-RFC tracking - Implement spike auto-close when RFC transitions to implemented - Add ADR suggestions when RFC transitions to in-progress - Add LocalRealmDependencies for .blue/realm.toml parsing - Add blue_rfc_validate_realm tool for cross-repo RFC validation - Add toml dependency for realm.toml parsing Co-Authored-By: Claude Opus 4.5 --- .claude/settings.json | 15 ++ Cargo.toml | 1 + apps/blue-cli/src/main.rs | 234 +++++++++++++++++++++++++ crates/blue-core/Cargo.toml | 1 + crates/blue-core/src/documents.rs | 8 + crates/blue-core/src/realm/mod.rs | 5 +- crates/blue-core/src/realm/repo.rs | 109 ++++++++++++ crates/blue-mcp/src/handlers/realm.rs | 235 ++++++++++++++++++++++++++ crates/blue-mcp/src/server.rs | 208 +++++++++++++++++++++++ 9 files changed, 815 insertions(+), 1 deletion(-) create mode 100644 .claude/settings.json diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..7442a5d --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "blue guard --path=\"$TOOL_INPUT:file_path\"" + } + ] + } + ] + } +} diff --git a/Cargo.toml b/Cargo.toml index ef671c3..82e4b45 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,7 @@ authors = ["Eric Minton Garcia"] serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde_yaml = "0.9" +toml = "0.8" # Async runtime tokio = { version = "1.0", features = ["full", "io-std"] } diff --git a/apps/blue-cli/src/main.rs b/apps/blue-cli/src/main.rs index f154486..dafc599 100644 --- a/apps/blue-cli/src/main.rs +++ b/apps/blue-cli/src/main.rs @@ -122,6 +122,17 @@ enum Commands { #[command(subcommand)] command: Option, }, + + /// Guard: Check if file writes are allowed (RFC 0038 PreToolUse hook) + Guard { + /// Path to check + #[arg(long)] + path: String, + + /// Tool that triggered the check (for audit logging) + #[arg(long)] + tool: Option, + }, } #[derive(Subcommand)] @@ -519,6 +530,9 @@ async fn main() -> Result<()> { Some(Commands::Context { command }) => { handle_context_command(command).await?; } + Some(Commands::Guard { path, tool }) => { + handle_guard_command(&path, tool.as_deref()).await?; + } } Ok(()) @@ -2225,3 +2239,223 @@ fn print_context_verbose(manifest: &blue_core::ContextManifest, resolution: &blu } } } + +// ==================== Guard Command (RFC 0038) ==================== + +/// Check if file write is allowed based on worktree and allowlist rules. +/// +/// Exit codes: +/// - 0: Allow the write +/// - 1: Block the write (not in valid worktree and not in allowlist) +async fn handle_guard_command(path: &str, tool: Option<&str>) -> Result<()> { + use std::path::Path; + + // Check for bypass environment variable + if std::env::var("BLUE_BYPASS_WORKTREE").is_ok() { + // Log bypass for audit + log_guard_bypass(path, tool, "BLUE_BYPASS_WORKTREE env set"); + return Ok(()); // Exit 0 = allow + } + + let path = Path::new(path); + + // Check allowlist patterns first (fast path) + if is_in_allowlist(path) { + return Ok(()); // Exit 0 = allow + } + + // Get current working directory + let cwd = std::env::current_dir()?; + + // Check if we're in a git worktree + let worktree_info = get_worktree_info(&cwd)?; + + match worktree_info { + Some(info) => { + // We're in a worktree - check if it's associated with an RFC + if info.is_rfc_worktree { + // Check if the path is inside this worktree + let abs_path = if path.is_absolute() { + path.to_path_buf() + } else { + cwd.join(path) + }; + + if abs_path.starts_with(&info.worktree_path) { + return Ok(()); // Exit 0 = allow writes in RFC worktree + } + } + // Not in allowlist and not in RFC worktree scope + eprintln!("guard: blocked write to {} (not in RFC worktree scope)", path.display()); + std::process::exit(1); + } + None => { + // Not in a worktree - check if there's an active RFC that might apply + // For now, block writes to source code outside worktrees + if is_source_code_path(path) { + eprintln!("guard: blocked write to {} (no active worktree)", path.display()); + eprintln!("hint: Create a worktree with 'blue worktree create ' first"); + std::process::exit(1); + } + // Non-source-code files are allowed + Ok(()) + } + } +} + +/// Allowlist patterns for files that can always be written +fn is_in_allowlist(path: &std::path::Path) -> bool { + let path_str = path.to_string_lossy(); + + // Always-allowed patterns + let allowlist = [ + ".blue/docs/", // Blue documentation + ".claude/", // Claude configuration + "/tmp/", // Temp files + "*.md", // Markdown at root (but not in crates/) + ".gitignore", // Git config + ".blue/audit/", // Audit logs + ]; + + for pattern in &allowlist { + if pattern.starts_with("*.") { + // Extension pattern - check only root level + let ext = &pattern[1..]; + if path_str.ends_with(ext) && !path_str.contains("crates/") && !path_str.contains("src/") { + return true; + } + } else if path_str.contains(pattern) { + return true; + } + } + + // Check for dialogue temp files + if path_str.contains("/tmp/blue-dialogue/") { + return true; + } + + false +} + +/// Check if a path looks like source code +fn is_source_code_path(path: &std::path::Path) -> bool { + let path_str = path.to_string_lossy(); + + // Source code indicators + let source_patterns = [ + "src/", + "crates/", + "apps/", + "lib/", + "packages/", + "tests/", + ]; + + for pattern in &source_patterns { + if path_str.contains(pattern) { + return true; + } + } + + // Check file extensions + if let Some(ext) = path.extension().and_then(|e: &std::ffi::OsStr| e.to_str()) { + let code_extensions = ["rs", "ts", "tsx", "js", "jsx", "py", "go", "java", "c", "cpp", "h"]; + if code_extensions.contains(&ext) { + return true; + } + } + + false +} + +struct WorktreeInfo { + worktree_path: std::path::PathBuf, + is_rfc_worktree: bool, +} + +/// Get information about the current git worktree +fn get_worktree_info(cwd: &std::path::Path) -> Result> { + // Check if we're in a git worktree by looking at .git file + let git_path = cwd.join(".git"); + + if git_path.is_file() { + // This is a worktree (linked worktree has .git as a file) + let content = std::fs::read_to_string(&git_path)?; + if content.starts_with("gitdir:") { + // Parse the worktree path + let worktree_path = cwd.to_path_buf(); + + // Check if this looks like an RFC worktree + // RFC worktrees are typically named feature/ or rfc/ + let dir_name = cwd.file_name() + .and_then(|n: &std::ffi::OsStr| n.to_str()) + .unwrap_or(""); + + let parent_is_worktrees = cwd.parent() + .and_then(|p: &std::path::Path| p.file_name()) + .and_then(|n: &std::ffi::OsStr| n.to_str()) + .map(|s: &str| s == "worktrees") + .unwrap_or(false); + + let is_rfc = dir_name.starts_with("rfc-") + || dir_name.starts_with("feature-") + || parent_is_worktrees; + + return Ok(Some(WorktreeInfo { + worktree_path, + is_rfc_worktree: is_rfc, + })); + } + } else if git_path.is_dir() { + // Main repository - check if we're on an RFC branch + let output = std::process::Command::new("git") + .args(["branch", "--show-current"]) + .current_dir(cwd) + .output(); + + if let Ok(output) = output { + let branch = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let is_rfc = branch.starts_with("feature/") + || branch.starts_with("rfc/") + || branch.starts_with("rfc-"); + + return Ok(Some(WorktreeInfo { + worktree_path: cwd.to_path_buf(), + is_rfc_worktree: is_rfc, + })); + } + } + + Ok(None) +} + +/// Log a guard bypass for audit trail +fn log_guard_bypass(path: &str, tool: Option<&str>, reason: &str) { + use std::fs::OpenOptions; + use std::io::Write; + + let cwd = match std::env::current_dir() { + Ok(cwd) => cwd, + Err(_) => return, + }; + + let audit_dir = cwd.join(".blue").join("audit"); + if std::fs::create_dir_all(&audit_dir).is_err() { + return; + } + + let log_path = audit_dir.join("guard-bypass.log"); + let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ"); + let tool_str = tool.unwrap_or("unknown"); + let user = std::env::var("USER").unwrap_or_else(|_| "unknown".to_string()); + + let entry = format!("{} | {} | {} | {} | {}\n", timestamp, user, tool_str, path, reason); + + if let Ok(mut file) = OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + { + let _ = file.write_all(entry.as_bytes()); + } +} diff --git a/crates/blue-core/Cargo.toml b/crates/blue-core/Cargo.toml index b6fe78c..82f0a83 100644 --- a/crates/blue-core/Cargo.toml +++ b/crates/blue-core/Cargo.toml @@ -12,6 +12,7 @@ test-helpers = [] serde.workspace = true serde_json.workspace = true serde_yaml.workspace = true +toml.workspace = true thiserror.workspace = true anyhow.workspace = true tokio.workspace = true diff --git a/crates/blue-core/src/documents.rs b/crates/blue-core/src/documents.rs index 8c5a00b..b8fee2b 100644 --- a/crates/blue-core/src/documents.rs +++ b/crates/blue-core/src/documents.rs @@ -60,6 +60,10 @@ pub struct Spike { pub outcome: Option, pub findings: Option, pub recommendation: Option, + /// RFCs produced by this spike (RFC 0038) + /// Format: ["0038", "0039"] for local or ["blue-web:0015"] for cross-repo + #[serde(default)] + pub produces_rfcs: Vec, } /// Outcome of a spike investigation @@ -291,6 +295,7 @@ impl Spike { outcome: None, findings: None, recommendation: None, + produces_rfcs: Vec::new(), } } @@ -312,6 +317,9 @@ impl Spike { if let Some(ref outcome) = self.outcome { md.push_str(&format!("| **Outcome** | {} |\n", outcome.as_str())); } + if !self.produces_rfcs.is_empty() { + md.push_str(&format!("| **Produces RFCs** | {} |\n", self.produces_rfcs.join(", "))); + } md.push_str("\n---\n\n"); md.push_str("## Question\n\n"); diff --git a/crates/blue-core/src/realm/mod.rs b/crates/blue-core/src/realm/mod.rs index 55723af..961bc7c 100644 --- a/crates/blue-core/src/realm/mod.rs +++ b/crates/blue-core/src/realm/mod.rs @@ -21,7 +21,10 @@ pub use contract::{ Compatibility, Contract, ContractValue, EvolutionEntry, ValidationConfig, }; pub use domain::{Binding, BindingRole, Domain, ExportBinding, ImportBinding, ImportStatus}; -pub use repo::{LocalRepoConfig, RealmRef, RepoConfig}; +pub use repo::{ + LocalRealmDependencies, LocalRealmMembership, LocalRepoConfig, RealmRef, RepoConfig, + RfcDependencies, +}; pub use service::{ CheckIssue, CheckIssueKind, CheckResult, DomainDetails, RealmDetails, RealmInfo, RealmService, RealmSyncStatus, SyncResult, WorktreeInfo, WorktreePrStatus, diff --git a/crates/blue-core/src/realm/repo.rs b/crates/blue-core/src/realm/repo.rs index 050e50c..c043bcd 100644 --- a/crates/blue-core/src/realm/repo.rs +++ b/crates/blue-core/src/realm/repo.rs @@ -159,6 +159,115 @@ pub struct RealmRef { pub url: String, } +/// RFC 0038: Local realm configuration stored in {repo}/.blue/realm.toml +/// +/// This file defines cross-repo RFC dependencies and realm-specific settings. +/// Example: +/// ```toml +/// [realm] +/// name = "blue-ecosystem" +/// +/// [rfc.0038] +/// depends_on = ["blue-web:0015", "blue-cli:0008"] +/// ``` +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct LocalRealmDependencies { + /// Realm membership (optional, for validation) + #[serde(default)] + pub realm: Option, + + /// RFC dependencies by RFC number/slug + /// Key: RFC identifier (e.g., "0038" or "sdlc-workflow-discipline") + /// Value: Dependency configuration + #[serde(default)] + pub rfc: std::collections::HashMap, +} + +/// Realm membership in realm.toml +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LocalRealmMembership { + /// Realm name + pub name: String, +} + +/// Dependencies for a single RFC +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RfcDependencies { + /// Cross-repo dependencies + /// Format: ["repo:rfc-id", "another-repo:rfc-id"] + /// Example: ["blue-web:0015", "blue-cli:0008"] + #[serde(default)] + pub depends_on: Vec, +} + +impl LocalRealmDependencies { + /// Create a new empty dependencies config + pub fn new() -> Self { + Self::default() + } + + /// Create with realm name + pub fn with_realm(realm_name: impl Into) -> Self { + Self { + realm: Some(LocalRealmMembership { + name: realm_name.into(), + }), + rfc: std::collections::HashMap::new(), + } + } + + /// Load from a TOML file + pub fn load(path: &Path) -> Result { + let content = std::fs::read_to_string(path).map_err(|e| RealmError::ReadFile { + path: path.display().to_string(), + source: e, + })?; + let config: Self = toml::from_str(&content).map_err(|e| { + RealmError::ValidationFailed(format!("Invalid TOML: {}", e)) + })?; + Ok(config) + } + + /// Save to a TOML file + pub fn save(&self, path: &Path) -> Result<(), RealmError> { + let content = toml::to_string_pretty(self).map_err(|e| { + RealmError::ValidationFailed(format!("Failed to serialize TOML: {}", e)) + })?; + std::fs::write(path, content).map_err(|e| RealmError::WriteFile { + path: path.display().to_string(), + source: e, + })?; + Ok(()) + } + + /// Get dependencies for a specific RFC + pub fn get_rfc_deps(&self, rfc_id: &str) -> Vec { + self.rfc + .get(rfc_id) + .map(|d| d.depends_on.clone()) + .unwrap_or_default() + } + + /// Add dependencies for an RFC + pub fn add_rfc_deps(&mut self, rfc_id: impl Into, deps: Vec) { + self.rfc.insert( + rfc_id.into(), + RfcDependencies { depends_on: deps }, + ); + } + + /// Check if the realm.toml exists at the given path + pub fn exists(base_path: &Path) -> bool { + base_path.join(".blue").join("realm.toml").exists() + } + + /// Load from the standard location (.blue/realm.toml) + pub fn load_from_blue(base_path: &Path) -> Result { + let path = base_path.join(".blue").join("realm.toml"); + Self::load(&path) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/blue-mcp/src/handlers/realm.rs b/crates/blue-mcp/src/handlers/realm.rs index c0528ca..14ac9de 100644 --- a/crates/blue-mcp/src/handlers/realm.rs +++ b/crates/blue-mcp/src/handlers/realm.rs @@ -1110,6 +1110,241 @@ fn fetch_pending_notifications(ctx: &RealmContext) -> Vec { .collect() } +// ─── Phase 5: RFC Validation (RFC 0038) ───────────────────────────────────── + +use blue_core::realm::LocalRealmDependencies; + +/// RFC dependency status for cross-repo coordination +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RfcDepStatus { + /// Dependency string (e.g., "blue-web:0015") + pub dependency: String, + /// Parsed repo name + pub repo: String, + /// Parsed RFC identifier + pub rfc_id: String, + /// Whether the dependency is resolved + pub resolved: bool, + /// Status of the RFC if found + pub status: Option, + /// Error message if couldn't check + pub error: Option, +} + +/// Handle blue_rfc_validate_realm - validate realm RFC dependencies +/// +/// Loads .blue/realm.toml and checks status of cross-repo RFC dependencies. +/// Returns a status matrix showing resolved/unresolved dependencies. +pub fn handle_validate_realm( + cwd: Option<&Path>, + strict: bool, +) -> Result { + let cwd = cwd.ok_or(ServerError::InvalidParams)?; + + // Check for .blue/realm.toml + if !LocalRealmDependencies::exists(cwd) { + return Ok(json!({ + "status": "success", + "message": "No .blue/realm.toml found - no cross-repo RFC dependencies defined", + "dependencies": [], + "summary": { + "total": 0, + "resolved": 0, + "unresolved": 0 + }, + "next_steps": ["Create .blue/realm.toml to define cross-repo RFC dependencies"] + })); + } + + // Load realm dependencies + let realm_deps = LocalRealmDependencies::load_from_blue(cwd).map_err(|e| { + ServerError::CommandFailed(format!("Failed to load .blue/realm.toml: {}", e)) + })?; + + // Collect all dependencies across all RFCs + let mut all_deps: Vec = Vec::new(); + let mut errors: Vec = Vec::new(); + + for (rfc_id, deps) in &realm_deps.rfc { + for dep in &deps.depends_on { + let status = check_dependency(cwd, dep); + if status.error.is_some() { + errors.push(format!("RFC {}: {}", rfc_id, status.error.as_ref().unwrap())); + } + all_deps.push(status); + } + } + + // Calculate summary + let total = all_deps.len(); + let resolved = all_deps.iter().filter(|d| d.resolved).count(); + let unresolved = total - resolved; + + // Build next steps + let mut next_steps = Vec::new(); + if unresolved > 0 { + next_steps.push(format!("{} unresolved RFC dependencies - coordinate with dependent repos", unresolved)); + + // List specific unresolved deps + for dep in all_deps.iter().filter(|d| !d.resolved) { + if let Some(ref status) = dep.status { + next_steps.push(format!(" {} is '{}' - wait for implementation", dep.dependency, status)); + } else if let Some(ref err) = dep.error { + next_steps.push(format!(" {} - {}", dep.dependency, err)); + } + } + } + if resolved == total && total > 0 { + next_steps.push("All RFC dependencies resolved - ready to proceed".to_string()); + } + + // In strict mode, return error status if any unresolved + let status = if strict && unresolved > 0 { + "error" + } else { + "success" + }; + + Ok(json!({ + "status": status, + "realm": realm_deps.realm.as_ref().map(|r| &r.name), + "dependencies": all_deps, + "summary": { + "total": total, + "resolved": resolved, + "unresolved": unresolved + }, + "errors": errors, + "next_steps": next_steps + })) +} + +/// Check a single dependency status +/// +/// Format: "repo:rfc-id" (e.g., "blue-web:0015") +fn check_dependency(cwd: &Path, dep: &str) -> RfcDepStatus { + // Parse dependency format: "repo:rfc-id" + let parts: Vec<&str> = dep.splitn(2, ':').collect(); + if parts.len() != 2 { + return RfcDepStatus { + dependency: dep.to_string(), + repo: String::new(), + rfc_id: String::new(), + resolved: false, + status: None, + error: Some(format!("Invalid dependency format '{}' - expected 'repo:rfc-id'", dep)), + }; + } + + let repo = parts[0].to_string(); + let rfc_id = parts[1].to_string(); + + // First, try to check locally if this is the current repo + if let Some(local_status) = check_local_rfc(cwd, &rfc_id) { + let resolved = local_status == "implemented"; + return RfcDepStatus { + dependency: dep.to_string(), + repo, + rfc_id, + resolved, + status: Some(local_status), + error: None, + }; + } + + // Check in realm cache for remote repos + if let Some(remote_status) = check_remote_rfc(&repo, &rfc_id) { + let resolved = remote_status == "implemented"; + return RfcDepStatus { + dependency: dep.to_string(), + repo, + rfc_id, + resolved, + status: Some(remote_status), + error: None, + }; + } + + // Couldn't check - report as unresolved with error + RfcDepStatus { + dependency: dep.to_string(), + repo, + rfc_id, + resolved: false, + status: None, + error: Some(format!("Could not verify RFC status in repo '{}' - repo not in realm cache", parts[0])), + } +} + +/// Check RFC status in the local repo +fn check_local_rfc(cwd: &Path, rfc_id: &str) -> Option { + // Try to find RFC by number or title in local .blue/docs/rfcs/ + let rfcs_dir = cwd.join(".blue").join("docs").join("rfcs"); + if !rfcs_dir.exists() { + return None; + } + + // Look for matching RFC files + let pattern = format!("{}-", rfc_id); + + if let Ok(entries) = std::fs::read_dir(&rfcs_dir) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with(&pattern) && name.ends_with(".md") { + // Parse status from filename (e.g., "0015-foo.implemented.md") + if name.contains(".implemented.") { + return Some("implemented".to_string()); + } else if name.contains(".accepted.") { + return Some("accepted".to_string()); + } else if name.contains(".impl.") { + return Some("in-progress".to_string()); + } else if name.contains(".draft.") { + return Some("draft".to_string()); + } + } + } + } + + None +} + +/// Check RFC status in a remote repo via realm cache +fn check_remote_rfc(repo: &str, rfc_id: &str) -> Option { + // Check in /tmp/blue-realm-cache//.blue/docs/rfcs/ + let cache_dir = std::path::PathBuf::from("/tmp/blue-realm-cache") + .join(repo) + .join(".blue") + .join("docs") + .join("rfcs"); + + if !cache_dir.exists() { + return None; + } + + // Look for matching RFC files + let pattern = format!("{}-", rfc_id); + + if let Ok(entries) = std::fs::read_dir(&cache_dir) { + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + if name.starts_with(&pattern) && name.ends_with(".md") { + // Parse status from filename + if name.contains(".implemented.") { + return Some("implemented".to_string()); + } else if name.contains(".accepted.") { + return Some("accepted".to_string()); + } else if name.contains(".impl.") { + return Some("in-progress".to_string()); + } else if name.contains(".draft.") { + return Some("draft".to_string()); + } + } + } + } + + None +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/blue-mcp/src/server.rs b/crates/blue-mcp/src/server.rs index 6f53f78..4af0177 100644 --- a/crates/blue-mcp/src/server.rs +++ b/crates/blue-mcp/src/server.rs @@ -13,6 +13,33 @@ use blue_core::{detect_blue, DocType, Document, ProjectState, Rfc, RfcStatus, ti use crate::error::ServerError; +/// Parse | **Source Spike** | spike-title | from RFC frontmatter (RFC 0038) +fn parse_source_spike(content: &str) -> Option { + let pattern = regex::Regex::new(r"\| \*\*Source Spike\*\* \| ([^|]+) \|").ok()?; + let caps = pattern.captures(content)?; + Some(caps.get(1)?.as_str().trim().to_string()) +} + +/// Parse | **Produces RFCs** | 0038, 0039 | from spike frontmatter (RFC 0038) +fn parse_produces_rfcs(content: &str) -> Vec { + let pattern = regex::Regex::new(r"\| \*\*Produces RFCs\*\* \| ([^|]+) \|").ok(); + match pattern { + Some(re) => { + if let Some(caps) = re.captures(content) { + if let Some(m) = caps.get(1) { + return m.as_str() + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + } + } + Vec::new() + } + None => Vec::new(), + } +} + /// Blue MCP Server state pub struct BlueServer { /// Current working directory (set explicitly via tool args) @@ -1905,6 +1932,24 @@ impl BlueServer { "required": ["cwd"] } }, + // RFC 0038: Realm RFC Validation + { + "name": "blue_rfc_validate_realm", + "description": "Validate cross-repo RFC dependencies defined in .blue/realm.toml. Returns status matrix showing resolved/unresolved dependencies. Use --strict to fail on unresolved deps.", + "inputSchema": { + "type": "object", + "properties": { + "cwd": { + "type": "string", + "description": "Current working directory" + }, + "strict": { + "type": "boolean", + "description": "Fail if any dependencies are unresolved (default: false, just warn)" + } + } + } + }, // RFC 0005: Local LLM Integration { "name": "blue_llm_start", @@ -2363,6 +2408,8 @@ impl BlueServer { "blue_realm_worktree_create" => self.handle_realm_worktree_create(&call.arguments), "blue_realm_pr_status" => self.handle_realm_pr_status(&call.arguments), "blue_notifications_list" => self.handle_notifications_list(&call.arguments), + // RFC 0038: Realm RFC Validation + "blue_rfc_validate_realm" => self.handle_validate_realm(&call.arguments), // RFC 0005: LLM tools "blue_llm_start" => crate::handlers::llm::handle_start(&call.arguments.unwrap_or_default()), "blue_llm_stop" => crate::handlers::llm::handle_stop(), @@ -2749,6 +2796,20 @@ impl BlueServer { false }; + // RFC 0038: Auto-close source spike when RFC transitions to implemented + let spike_closed = if status_str == "implemented" { + self.try_close_source_spike(title, &doc) + } else { + None + }; + + // RFC 0038: Suggest relevant ADRs when RFC transitions to in-progress + let adr_suggestions = if status_str == "in-progress" { + self.get_adr_suggestions(title) + } else { + None + }; + // Conversational hints guide Claude to next action (RFC 0014) let hint = match target_status { RfcStatus::Accepted => Some( @@ -2797,10 +2858,147 @@ impl BlueServer { if let Some(warning) = worktree_warning { response["warning"] = json!(warning); } + // RFC 0038: Include spike closure info + if let Some(spike_info) = spike_closed { + response["spike_closed"] = spike_info; + } + // RFC 0038: Include ADR suggestions + if let Some(adrs) = adr_suggestions { + response["adr_suggestions"] = adrs; + } Ok(response) } + /// RFC 0038: Try to close the source spike when RFC is implemented + fn try_close_source_spike(&self, _rfc_title: &str, rfc_doc: &Document) -> Option { + let state = self.state.as_ref()?; + + // Read RFC file to extract source spike + let rfc_file_path = rfc_doc.file_path.as_ref()?; + let full_path = state.home.docs_path.join(rfc_file_path); + let content = fs::read_to_string(&full_path).ok()?; + + // Parse | **Source Spike** | spike-title | from RFC frontmatter + let source_spike = parse_source_spike(&content)?; + + // Find the spike document + let spike_doc = state.store.find_document(DocType::Spike, &source_spike).ok()?; + + // Check if spike has produces_rfcs field - if so, verify all are resolved + if let Some(spike_path) = &spike_doc.file_path { + let spike_full_path = state.home.docs_path.join(spike_path); + if let Ok(spike_content) = fs::read_to_string(&spike_full_path) { + let produces_rfcs = parse_produces_rfcs(&spike_content); + + if !produces_rfcs.is_empty() { + // Check if ALL produced RFCs are resolved (implemented or superseded) + let mut all_resolved = true; + let mut pending_rfcs = Vec::new(); + + for rfc_ref in &produces_rfcs { + // Handle cross-repo references (e.g., "blue-web:0015") + let (repo, rfc_id) = if rfc_ref.contains(':') { + let parts: Vec<&str> = rfc_ref.splitn(2, ':').collect(); + (Some(parts[0]), parts[1]) + } else { + (None, rfc_ref.as_str()) + }; + + // For now, only handle local RFCs + if repo.is_some() { + continue; // Skip cross-repo for now + } + + // Find the RFC by number or title + if let Ok(doc) = state.store.find_document(DocType::Rfc, rfc_id) { + let status = doc.status.to_lowercase(); + if status != "implemented" && status != "superseded" { + all_resolved = false; + pending_rfcs.push(rfc_id.to_string()); + } + } else { + // RFC not found - don't block spike closure + } + } + + if !all_resolved { + return Some(json!({ + "status": "partial", + "spike": source_spike, + "message": format!("Spike '{}' has pending RFCs: {}", source_spike, pending_rfcs.join(", ")), + "pending_rfcs": pending_rfcs + })); + } + } + } + } + + // All checks passed - close the spike + let _ = state.store.update_document_status(DocType::Spike, &source_spike, "complete"); + + // Rename spike file (.wip.md -> .done.md) via RFC 0031 + if let Ok(Some(new_path)) = blue_core::rename_for_status( + &state.home.docs_path, + &state.store, + &spike_doc, + "complete" + ) { + let full_new_path = state.home.docs_path.join(&new_path); + let _ = blue_core::update_markdown_status(&full_new_path, "complete"); + } + + Some(json!({ + "status": "closed", + "spike": source_spike, + "message": format!("Auto-closed spike '{}' - all produced RFCs are resolved", source_spike) + })) + } + + /// RFC 0038: Get ADR suggestions for an RFC transitioning to in-progress + fn get_adr_suggestions(&self, rfc_title: &str) -> Option { + let state = self.state.as_ref()?; + + // Use the existing ADR relevance handler + let args = json!({ "context": rfc_title }); + let result = crate::handlers::adr::handle_relevant(state, &args).ok()?; + + // Extract relevant ADRs with high confidence + let relevant = result.get("relevant")?.as_array()?; + if relevant.is_empty() { + return None; + } + + // Filter to ADRs with confidence > 0.7 + let high_confidence: Vec<&Value> = relevant + .iter() + .filter(|adr| { + adr.get("confidence") + .and_then(|c| c.as_f64()) + .unwrap_or(0.0) > 0.7 + }) + .collect(); + + if high_confidence.is_empty() { + return None; + } + + // Check for architectural keywords that suggest a new ADR might be needed + let title_lower = rfc_title.to_lowercase(); + let architectural_keywords = ["breaking", "redesign", "architectural", "migration", "refactor"]; + let suggests_new_adr = architectural_keywords.iter().any(|kw| title_lower.contains(kw)); + + Some(json!({ + "relevant_adrs": high_confidence, + "suggests_new_adr": suggests_new_adr, + "hint": if suggests_new_adr { + "This RFC may warrant a new ADR after implementation. Consider using blue_adr_create." + } else { + "Review these ADRs while implementing." + } + })) + } + fn handle_rfc_plan(&mut self, args: &Option) -> Result { let args = args.as_ref().ok_or(ServerError::InvalidParams)?; @@ -3636,6 +3834,16 @@ impl BlueServer { crate::handlers::realm::handle_notifications_list(self.cwd.as_deref(), state) } + // RFC 0038: Realm RFC Validation + fn handle_validate_realm(&mut self, args: &Option) -> Result { + let strict = args + .as_ref() + .and_then(|a| a.get("strict")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + crate::handlers::realm::handle_validate_realm(self.cwd.as_deref(), strict) + } + // RFC 0006: Delete handlers fn handle_delete(&mut self, args: &Option) -> Result {