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:
parent
0246ef6a22
commit
6ff8ba706c
9 changed files with 815 additions and 1 deletions
15
.claude/settings.json
Normal file
15
.claude/settings.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "blue guard --path=\"$TOOL_INPUT:file_path\""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -122,6 +122,17 @@ enum Commands {
|
|||
#[command(subcommand)]
|
||||
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)]
|
||||
|
|
@ -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 <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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -60,6 +60,10 @@ pub struct Spike {
|
|||
pub outcome: Option<SpikeOutcome>,
|
||||
pub findings: 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
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -1110,6 +1110,241 @@ fn fetch_pending_notifications(ctx: &RealmContext) -> Vec<Value> {
|
|||
.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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
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<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> {
|
||||
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<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
|
||||
|
||||
fn handle_delete(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
|
||||
|
|
|
|||
Loading…
Reference in a new issue