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 <noreply@anthropic.com>
This commit is contained in:
Eric Garcia 2026-01-30 08:59:20 -05:00
parent 0246ef6a22
commit 6ff8ba706c
9 changed files with 815 additions and 1 deletions

15
.claude/settings.json Normal file
View file

@ -0,0 +1,15 @@
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "blue guard --path=\"$TOOL_INPUT:file_path\""
}
]
}
]
}
}

View file

@ -19,6 +19,7 @@ authors = ["Eric Minton Garcia"]
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
serde_yaml = "0.9" serde_yaml = "0.9"
toml = "0.8"
# Async runtime # Async runtime
tokio = { version = "1.0", features = ["full", "io-std"] } tokio = { version = "1.0", features = ["full", "io-std"] }

View file

@ -122,6 +122,17 @@ enum Commands {
#[command(subcommand)] #[command(subcommand)]
command: Option<ContextCommands>, command: Option<ContextCommands>,
}, },
/// 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<String>,
},
} }
#[derive(Subcommand)] #[derive(Subcommand)]
@ -519,6 +530,9 @@ async fn main() -> Result<()> {
Some(Commands::Context { command }) => { Some(Commands::Context { command }) => {
handle_context_command(command).await?; handle_context_command(command).await?;
} }
Some(Commands::Guard { path, tool }) => {
handle_guard_command(&path, tool.as_deref()).await?;
}
} }
Ok(()) 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 <rfc-title>' 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<Option<WorktreeInfo>> {
// 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/<rfc-slug> or rfc/<rfc-slug>
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());
}
}

View file

@ -12,6 +12,7 @@ test-helpers = []
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
serde_yaml.workspace = true serde_yaml.workspace = true
toml.workspace = true
thiserror.workspace = true thiserror.workspace = true
anyhow.workspace = true anyhow.workspace = true
tokio.workspace = true tokio.workspace = true

View file

@ -60,6 +60,10 @@ pub struct Spike {
pub outcome: Option<SpikeOutcome>, pub outcome: Option<SpikeOutcome>,
pub findings: Option<String>, pub findings: Option<String>,
pub recommendation: Option<String>, pub recommendation: Option<String>,
/// 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<String>,
} }
/// Outcome of a spike investigation /// Outcome of a spike investigation
@ -291,6 +295,7 @@ impl Spike {
outcome: None, outcome: None,
findings: None, findings: None,
recommendation: None, recommendation: None,
produces_rfcs: Vec::new(),
} }
} }
@ -312,6 +317,9 @@ impl Spike {
if let Some(ref outcome) = self.outcome { if let Some(ref outcome) = self.outcome {
md.push_str(&format!("| **Outcome** | {} |\n", outcome.as_str())); 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("\n---\n\n");
md.push_str("## Question\n\n"); md.push_str("## Question\n\n");

View file

@ -21,7 +21,10 @@ pub use contract::{
Compatibility, Contract, ContractValue, EvolutionEntry, ValidationConfig, Compatibility, Contract, ContractValue, EvolutionEntry, ValidationConfig,
}; };
pub use domain::{Binding, BindingRole, Domain, ExportBinding, ImportBinding, ImportStatus}; 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::{ pub use service::{
CheckIssue, CheckIssueKind, CheckResult, DomainDetails, RealmDetails, RealmInfo, CheckIssue, CheckIssueKind, CheckResult, DomainDetails, RealmDetails, RealmInfo,
RealmService, RealmSyncStatus, SyncResult, WorktreeInfo, WorktreePrStatus, RealmService, RealmSyncStatus, SyncResult, WorktreeInfo, WorktreePrStatus,

View file

@ -159,6 +159,115 @@ pub struct RealmRef {
pub url: String, 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<LocalRealmMembership>,
/// 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<String, RfcDependencies>,
}
/// 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<String>,
}
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<String>) -> 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<Self, RealmError> {
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<String> {
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<String>, deps: Vec<String>) {
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<Self, RealmError> {
let path = base_path.join(".blue").join("realm.toml");
Self::load(&path)
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View file

@ -1110,6 +1110,241 @@ fn fetch_pending_notifications(ctx: &RealmContext) -> Vec<Value> {
.collect() .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<String>,
/// Error message if couldn't check
pub error: Option<String>,
}
/// 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<Value, ServerError> {
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<RfcDepStatus> = Vec::new();
let mut errors: Vec<String> = 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<String> {
// 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<String> {
// Check in /tmp/blue-realm-cache/<repo>/.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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View file

@ -13,6 +13,33 @@ use blue_core::{detect_blue, DocType, Document, ProjectState, Rfc, RfcStatus, ti
use crate::error::ServerError; use crate::error::ServerError;
/// Parse | **Source Spike** | spike-title | from RFC frontmatter (RFC 0038)
fn parse_source_spike(content: &str) -> Option<String> {
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<String> {
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 /// Blue MCP Server state
pub struct BlueServer { pub struct BlueServer {
/// Current working directory (set explicitly via tool args) /// Current working directory (set explicitly via tool args)
@ -1905,6 +1932,24 @@ impl BlueServer {
"required": ["cwd"] "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 // RFC 0005: Local LLM Integration
{ {
"name": "blue_llm_start", "name": "blue_llm_start",
@ -2363,6 +2408,8 @@ impl BlueServer {
"blue_realm_worktree_create" => self.handle_realm_worktree_create(&call.arguments), "blue_realm_worktree_create" => self.handle_realm_worktree_create(&call.arguments),
"blue_realm_pr_status" => self.handle_realm_pr_status(&call.arguments), "blue_realm_pr_status" => self.handle_realm_pr_status(&call.arguments),
"blue_notifications_list" => self.handle_notifications_list(&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 // RFC 0005: LLM tools
"blue_llm_start" => crate::handlers::llm::handle_start(&call.arguments.unwrap_or_default()), "blue_llm_start" => crate::handlers::llm::handle_start(&call.arguments.unwrap_or_default()),
"blue_llm_stop" => crate::handlers::llm::handle_stop(), "blue_llm_stop" => crate::handlers::llm::handle_stop(),
@ -2749,6 +2796,20 @@ impl BlueServer {
false 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) // Conversational hints guide Claude to next action (RFC 0014)
let hint = match target_status { let hint = match target_status {
RfcStatus::Accepted => Some( RfcStatus::Accepted => Some(
@ -2797,10 +2858,147 @@ impl BlueServer {
if let Some(warning) = worktree_warning { if let Some(warning) = worktree_warning {
response["warning"] = json!(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) 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<Value> {
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<Value> {
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<Value>) -> Result<Value, ServerError> { fn handle_rfc_plan(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
let args = args.as_ref().ok_or(ServerError::InvalidParams)?; 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) crate::handlers::realm::handle_notifications_list(self.cwd.as_deref(), state)
} }
// RFC 0038: Realm RFC Validation
fn handle_validate_realm(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
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 // RFC 0006: Delete handlers
fn handle_delete(&mut self, args: &Option<Value>) -> Result<Value, ServerError> { fn handle_delete(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {