diff --git a/.blue/docs/spikes/2026-01-26-claude-code-task-integration.md b/.blue/docs/spikes/2026-01-26-claude-code-task-integration.md new file mode 100644 index 0000000..e6af4cc --- /dev/null +++ b/.blue/docs/spikes/2026-01-26-claude-code-task-integration.md @@ -0,0 +1,84 @@ +# Spike: Claude Code Task Integration + +| | | +|---|---| +| **Status** | In Progress | +| **Date** | 2026-01-26 | +| **Time Box** | 2 hours | + +--- + +## Question + +How can Blue integrate with Claude Code's built-in task management (TaskCreate/TaskUpdate/TaskList) to provide bidirectional sync between Blue's RFC tasks and Claude Code's UI? + +--- + +## Investigation + +### What Claude Code Provides + +Claude Code has built-in task management tools: +- `TaskCreate` - Create tasks with subject, description, activeForm +- `TaskUpdate` - Update status (pending → in_progress → completed) +- `TaskList` - List all tasks with status +- `TaskGet` - Get full task details + +These render in Claude Code's UI with a progress tracker showing task status. + +### Integration Approaches + +#### Approach 1: MCP Server Push (Not Viable) +MCP servers can only respond to requests - they cannot initiate calls to Claude Code's task system. + +#### Approach 2: Claude Code Skill (Recommended) +Create a `/blue-plan` skill that orchestrates both systems: + +``` +User: /blue-plan rfc-17 +Skill: + 1. Call blue_rfc_get to fetch RFC tasks + 2. For each task, call TaskCreate with: + - subject: task description + - metadata: { blue_rfc: 17, blue_task_index: 0 } + 3. As work progresses, TaskUpdate syncs status + 4. On completion, call blue_rfc_task_complete +``` + +**Pros**: Clean separation, skill handles orchestration +**Cons**: Manual invocation required + +#### Approach 3: .plan.md as Shared Interface +The `.plan.md` file (RFC 0017) becomes the bridge: +- Blue writes/reads plan files +- A watcher process syncs changes to Claude Code tasks +- Task checkbox state is the single source of truth + +**Pros**: File-based, works offline +**Cons**: Requires external sync process + +#### Approach 4: Hybrid - Skill + Plan File +1. `/blue-plan` skill reads `.plan.md` and creates Claude Code tasks +2. User works, marking tasks complete in Claude Code +3. On session end, skill writes back to `.plan.md` +4. Blue picks up changes on next read (rebuild-on-read from RFC 0017) + +### Recommended Path + +**Phase 1**: Implement RFC 0017 (plan file authority) - gives us the file format +**Phase 2**: Create `/blue-plan` skill that syncs plan → Claude Code tasks +**Phase 3**: Add completion writeback to skill + +### Key Insight + +The `.plan.md` format is already compatible with Claude Code's task model: +- `- [ ] Task` maps to TaskCreate with status=pending +- `- [x] Task` maps to status=completed + +The skill just needs to translate between formats. + +## Conclusion + +Integration is viable via a Claude Code skill that reads `.plan.md` files and creates corresponding Claude Code tasks. This preserves Blue's file-first philosophy while enabling Claude Code's task UI. + +**Next**: Create RFC for skill implementation after RFC 0017 is complete. diff --git a/crates/blue-core/src/documents.rs b/crates/blue-core/src/documents.rs index 8336ead..0a61b14 100644 --- a/crates/blue-core/src/documents.rs +++ b/crates/blue-core/src/documents.rs @@ -565,6 +565,129 @@ pub fn update_markdown_status( Ok(changed) } +/// RFC header format types (RFC 0017) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HeaderFormat { + /// Table format: `| **Status** | Draft |` + Table, + /// Inline format: `**Status:** Draft` + Inline, + /// No recognizable header format + Missing, +} + +/// Validate RFC header format +/// +/// Returns the detected header format: +/// - Table: canonical format `| **Status** | Draft |` +/// - Inline: non-canonical format `**Status:** Draft` +/// - Missing: no status header found +pub fn validate_rfc_header(content: &str) -> HeaderFormat { + let table_pattern = regex::Regex::new(r"\| \*\*Status\*\* \| [^|]+ \|").unwrap(); + let inline_pattern = regex::Regex::new(r"\*\*Status:\*\*\s+\S+").unwrap(); + + if table_pattern.is_match(content) { + HeaderFormat::Table + } else if inline_pattern.is_match(content) { + HeaderFormat::Inline + } else { + HeaderFormat::Missing + } +} + +/// Convert inline header format to table format +/// +/// Converts patterns like: +/// ```text +/// **Status:** Draft +/// **Created:** 2026-01-25 +/// **Author:** Claude +/// ``` +/// +/// To: +/// ```text +/// | | | +/// |---|---| +/// | **Status** | Draft | +/// | **Created** | 2026-01-25 | +/// | **Author** | Claude | +/// ``` +pub fn convert_inline_to_table_header(content: &str) -> String { + // Match inline metadata patterns: **Key:** Value + let inline_re = regex::Regex::new(r"\*\*([^:*]+):\*\*\s*(.+)").unwrap(); + + let mut metadata_lines: Vec<(String, String)> = Vec::new(); + let mut other_lines: Vec = Vec::new(); + let mut in_header_section = false; + let mut header_ended = false; + + for line in content.lines() { + // Skip title line + if line.starts_with("# ") { + other_lines.push(line.to_string()); + in_header_section = true; + continue; + } + + // Check for inline metadata + if in_header_section && !header_ended { + if let Some(caps) = inline_re.captures(line) { + let key = caps.get(1).unwrap().as_str().trim(); + let value = caps.get(2).unwrap().as_str().trim(); + metadata_lines.push((key.to_string(), value.to_string())); + continue; + } + + // Empty line in header section is ok + if line.trim().is_empty() && !metadata_lines.is_empty() { + continue; + } + + // If we had metadata and hit something else, header section ended + if !metadata_lines.is_empty() { + header_ended = true; + } + } + + other_lines.push(line.to_string()); + } + + if metadata_lines.is_empty() { + return content.to_string(); + } + + // Reconstruct content with table format + let mut result = String::new(); + + // Find and add title + if let Some(title_pos) = other_lines.iter().position(|l| l.starts_with("# ")) { + result.push_str(&other_lines[title_pos]); + result.push_str("\n\n"); + + // Add table header + result.push_str("| | |\n"); + result.push_str("|---|---|\n"); + + // Add metadata rows + for (key, value) in &metadata_lines { + result.push_str(&format!("| **{}** | {} |\n", key, value)); + } + + // Add remaining content + let remaining = other_lines[title_pos + 1..].join("\n"); + let trimmed = remaining.trim_start(); + if !trimmed.is_empty() { + result.push('\n'); + result.push_str(trimmed); + } + } else { + // No title found, return original + return content.to_string(); + } + + result +} + #[cfg(test)] mod tests { use super::*; @@ -632,4 +755,46 @@ mod tests { let changed = update_markdown_status(&file, "implemented").unwrap(); assert!(!changed); } + + #[test] + fn test_validate_rfc_header_table_format() { + let content = "# RFC 0001: Test\n\n| | |\n|---|---|\n| **Status** | Draft |\n| **Date** | 2026-01-24 |\n"; + assert_eq!(validate_rfc_header(content), HeaderFormat::Table); + } + + #[test] + fn test_validate_rfc_header_inline_format() { + let content = "# RFC 0001: Test\n\n**Status:** Draft\n**Date:** 2026-01-24\n"; + assert_eq!(validate_rfc_header(content), HeaderFormat::Inline); + } + + #[test] + fn test_validate_rfc_header_missing() { + let content = "# RFC 0001: Test\n\nJust some content without status.\n"; + assert_eq!(validate_rfc_header(content), HeaderFormat::Missing); + } + + #[test] + fn test_convert_inline_to_table_header() { + let content = "# RFC 0001: Test\n\n**Status:** Draft\n**Created:** 2026-01-25\n**Author:** Claude\n\n## Problem\n\nSomething is wrong.\n"; + + let converted = convert_inline_to_table_header(content); + + assert!(converted.contains("| | |")); + assert!(converted.contains("|---|---|")); + assert!(converted.contains("| **Status** | Draft |")); + assert!(converted.contains("| **Created** | 2026-01-25 |")); + assert!(converted.contains("| **Author** | Claude |")); + assert!(converted.contains("## Problem")); + assert!(converted.contains("Something is wrong.")); + assert!(!converted.contains("**Status:**")); + } + + #[test] + fn test_convert_inline_to_table_header_no_change() { + let content = "# RFC 0001: Test\n\n| | |\n|---|---|\n| **Status** | Draft |\n\n## Problem\n"; + let converted = convert_inline_to_table_header(content); + // Should not change already-table-formatted content + assert_eq!(converted, content); + } } diff --git a/crates/blue-core/src/lib.rs b/crates/blue-core/src/lib.rs index d541c0a..8ce9305 100644 --- a/crates/blue-core/src/lib.rs +++ b/crates/blue-core/src/lib.rs @@ -20,6 +20,7 @@ pub mod forge; pub mod indexer; pub mod llm; pub mod manifest; +pub mod plan; pub mod realm; pub mod repo; pub mod state; @@ -29,7 +30,7 @@ pub mod voice; pub mod workflow; pub use alignment::{AlignmentDialogue, AlignmentScore, DialogueStatus, Expert, ExpertResponse, ExpertTier, PanelTemplate, Perspective, PerspectiveStatus, Round, Tension, TensionStatus, build_expert_prompt, parse_expert_response}; -pub use documents::{Adr, Audit, AuditFinding, AuditSeverity, AuditType, Decision, Rfc, Spike, SpikeOutcome, Status, Task, update_markdown_status}; +pub use documents::{Adr, Audit, AuditFinding, AuditSeverity, AuditType, Decision, HeaderFormat, Rfc, Spike, SpikeOutcome, Status, Task, convert_inline_to_table_header, update_markdown_status, validate_rfc_header}; pub use forge::{BlueConfig, CreatePrOpts, Forge, ForgeConfig, ForgeError, ForgeType, ForgejoForge, GitHubForge, GitUrl, MergeStrategy, PrState, PullRequest, create_forge, create_forge_cached, detect_forge_type, detect_forge_type_cached, get_token, parse_git_url}; pub use indexer::{Indexer, IndexerConfig, IndexerError, IndexResult, ParsedSymbol, is_indexable_file, should_skip_dir, DEFAULT_INDEX_MODEL, MAX_FILE_LINES}; pub use llm::{CompletionOptions, CompletionResult, LlmBackendChoice, LlmConfig, LlmError, LlmManager, LlmProvider, LlmProviderChoice, LocalLlmConfig, ApiLlmConfig, KeywordLlm, MockLlm, ProviderStatus}; @@ -40,3 +41,4 @@ pub use voice::*; pub use workflow::{PrdStatus, RfcStatus, SpikeOutcome as WorkflowSpikeOutcome, SpikeStatus, WorkflowError, validate_rfc_transition}; pub use manifest::{ContextManifest, IdentityConfig, WorkflowConfig, ReferenceConfig, PluginConfig, SourceConfig, RefreshTrigger, SalienceTrigger, ManifestError, ManifestResolution, TierResolution, ResolvedSource}; pub use uri::{BlueUri, UriError, read_uri_content, estimate_tokens}; +pub use plan::{PlanFile, PlanStatus, PlanTask, PlanError, parse_plan_markdown, generate_plan_markdown, plan_file_path, is_cache_stale, read_plan_file, write_plan_file, update_task_in_plan}; diff --git a/crates/blue-core/src/plan.rs b/crates/blue-core/src/plan.rs new file mode 100644 index 0000000..eaeca8c --- /dev/null +++ b/crates/blue-core/src/plan.rs @@ -0,0 +1,383 @@ +//! Plan file parsing and generation +//! +//! RFC 0017: Plan files (.plan.md) are the authoritative source for RFC task tracking. +//! SQLite acts as a derived cache that is rebuilt on read when stale. + +use std::fs; +use std::path::{Path, PathBuf}; + +use regex::Regex; +use serde::{Deserialize, Serialize}; + +/// A parsed plan file +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlanFile { + pub rfc_title: String, + pub status: PlanStatus, + pub updated_at: String, + pub tasks: Vec, +} + +/// Plan status +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum PlanStatus { + InProgress, + Complete, + UpdatingPlan, +} + +impl PlanStatus { + pub fn as_str(&self) -> &'static str { + match self { + PlanStatus::InProgress => "in-progress", + PlanStatus::Complete => "complete", + PlanStatus::UpdatingPlan => "updating-plan", + } + } + + pub fn from_str(s: &str) -> Option { + match s.to_lowercase().replace(' ', "-").as_str() { + "in-progress" => Some(PlanStatus::InProgress), + "complete" => Some(PlanStatus::Complete), + "updating-plan" => Some(PlanStatus::UpdatingPlan), + _ => None, + } + } +} + +/// A task within a plan +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PlanTask { + pub description: String, + pub completed: bool, +} + +/// Error type for plan operations +#[derive(Debug)] +pub enum PlanError { + Io(std::io::Error), + Parse(String), + InvalidFormat(String), +} + +impl std::fmt::Display for PlanError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PlanError::Io(e) => write!(f, "IO error: {}", e), + PlanError::Parse(msg) => write!(f, "Parse error: {}", msg), + PlanError::InvalidFormat(msg) => write!(f, "Invalid format: {}", msg), + } + } +} + +impl std::error::Error for PlanError {} + +impl From for PlanError { + fn from(e: std::io::Error) -> Self { + PlanError::Io(e) + } +} + +/// Parse a plan markdown file into a PlanFile struct +pub fn parse_plan_markdown(content: &str) -> Result { + // Extract RFC title from header: # Plan: {title} + let title_re = Regex::new(r"^# Plan: (.+)$").unwrap(); + let rfc_title = content + .lines() + .find_map(|line| { + title_re + .captures(line) + .map(|c| c.get(1).unwrap().as_str().to_string()) + }) + .ok_or_else(|| PlanError::Parse("Missing '# Plan: {title}' header".to_string()))?; + + // Extract status from table: | **Status** | {status} | + let status_re = Regex::new(r"\| \*\*Status\*\* \| ([^|]+) \|").unwrap(); + let status_str = content + .lines() + .find_map(|line| { + status_re + .captures(line) + .map(|c| c.get(1).unwrap().as_str().trim().to_string()) + }) + .unwrap_or_else(|| "in-progress".to_string()); + + let status = PlanStatus::from_str(&status_str).unwrap_or(PlanStatus::InProgress); + + // Extract updated_at from table: | **Updated** | {timestamp} | + let updated_re = Regex::new(r"\| \*\*Updated\*\* \| ([^|]+) \|").unwrap(); + let updated_at = content + .lines() + .find_map(|line| { + updated_re + .captures(line) + .map(|c| c.get(1).unwrap().as_str().trim().to_string()) + }) + .unwrap_or_else(|| chrono::Utc::now().to_rfc3339()); + + // Extract tasks from ## Tasks section + let task_re = Regex::new(r"^- \[([ xX])\] (.+)$").unwrap(); + let mut tasks = Vec::new(); + let mut in_tasks_section = false; + + for line in content.lines() { + if line.starts_with("## Tasks") { + in_tasks_section = true; + continue; + } + // Stop at next section + if in_tasks_section && line.starts_with("## ") { + break; + } + if in_tasks_section { + if let Some(caps) = task_re.captures(line) { + let completed = caps.get(1).unwrap().as_str() != " "; + let description = caps.get(2).unwrap().as_str().to_string(); + tasks.push(PlanTask { + description, + completed, + }); + } + } + } + + Ok(PlanFile { + rfc_title, + status, + updated_at, + tasks, + }) +} + +/// Generate markdown content from a PlanFile +pub fn generate_plan_markdown(plan: &PlanFile) -> String { + let mut md = String::new(); + + // Title + md.push_str(&format!("# Plan: {}\n\n", plan.rfc_title)); + + // Metadata table + md.push_str("| | |\n|---|---|\n"); + md.push_str(&format!("| **RFC** | {} |\n", plan.rfc_title)); + md.push_str(&format!("| **Status** | {} |\n", plan.status.as_str())); + md.push_str(&format!("| **Updated** | {} |\n", plan.updated_at)); + md.push_str("\n"); + + // Tasks section + md.push_str("## Tasks\n\n"); + for task in &plan.tasks { + let checkbox = if task.completed { "[x]" } else { "[ ]" }; + md.push_str(&format!("- {} {}\n", checkbox, task.description)); + } + + md +} + +/// Get the path for a plan file given the RFC docs path, title, and number +pub fn plan_file_path(docs_path: &Path, rfc_title: &str, rfc_number: i32) -> PathBuf { + let filename = format!("{:04}-{}.plan.md", rfc_number, rfc_title); + docs_path.join("rfcs").join(filename) +} + +/// Check if the SQLite cache is stale compared to the plan file +/// +/// Returns true if the plan file exists and is newer than the cache mtime +pub fn is_cache_stale(plan_path: &Path, cache_mtime: Option<&str>) -> bool { + if !plan_path.exists() { + return false; + } + + let Some(cache_mtime) = cache_mtime else { + // No cache entry means stale + return true; + }; + + // Get file modification time + let Ok(metadata) = fs::metadata(plan_path) else { + return false; + }; + + let Ok(modified) = metadata.modified() else { + return false; + }; + + // Convert to RFC3339 for comparison + let file_mtime: chrono::DateTime = modified.into(); + let file_mtime_str = file_mtime.to_rfc3339(); + + // Cache is stale if file is newer + file_mtime_str > cache_mtime.to_string() +} + +/// Read and parse a plan file from disk +pub fn read_plan_file(plan_path: &Path) -> Result { + let content = fs::read_to_string(plan_path)?; + parse_plan_markdown(&content) +} + +/// Write a plan file to disk +pub fn write_plan_file(plan_path: &Path, plan: &PlanFile) -> Result<(), PlanError> { + let content = generate_plan_markdown(plan); + fs::write(plan_path, content)?; + Ok(()) +} + +/// Update a specific task in a plan file +pub fn update_task_in_plan( + plan_path: &Path, + task_index: usize, + completed: bool, +) -> Result { + let mut plan = read_plan_file(plan_path)?; + + if task_index >= plan.tasks.len() { + return Err(PlanError::InvalidFormat(format!( + "Task index {} out of bounds (max {})", + task_index, + plan.tasks.len() + ))); + } + + plan.tasks[task_index].completed = completed; + plan.updated_at = chrono::Utc::now().to_rfc3339(); + + // Check if all tasks are complete + if plan.tasks.iter().all(|t| t.completed) { + plan.status = PlanStatus::Complete; + } + + write_plan_file(plan_path, &plan)?; + Ok(plan) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_plan_markdown() { + let content = r#"# Plan: my-feature + +| | | +|---|---| +| **RFC** | my-feature | +| **Status** | in-progress | +| **Updated** | 2026-01-26T10:30:00Z | + +## Tasks + +- [x] Completed task +- [ ] Pending task +- [X] Another completed task +"#; + + let plan = parse_plan_markdown(content).unwrap(); + assert_eq!(plan.rfc_title, "my-feature"); + assert_eq!(plan.status, PlanStatus::InProgress); + assert_eq!(plan.tasks.len(), 3); + assert!(plan.tasks[0].completed); + assert!(!plan.tasks[1].completed); + assert!(plan.tasks[2].completed); + assert_eq!(plan.tasks[0].description, "Completed task"); + assert_eq!(plan.tasks[1].description, "Pending task"); + } + + #[test] + fn test_generate_plan_markdown() { + let plan = PlanFile { + rfc_title: "test-feature".to_string(), + status: PlanStatus::InProgress, + updated_at: "2026-01-26T10:30:00Z".to_string(), + tasks: vec![ + PlanTask { + description: "First task".to_string(), + completed: true, + }, + PlanTask { + description: "Second task".to_string(), + completed: false, + }, + ], + }; + + let md = generate_plan_markdown(&plan); + assert!(md.contains("# Plan: test-feature")); + assert!(md.contains("| **Status** | in-progress |")); + assert!(md.contains("- [x] First task")); + assert!(md.contains("- [ ] Second task")); + } + + #[test] + fn test_plan_file_path() { + let docs_path = Path::new("/project/.blue/docs"); + let path = plan_file_path(docs_path, "my-feature", 7); + assert_eq!( + path, + PathBuf::from("/project/.blue/docs/rfcs/0007-my-feature.plan.md") + ); + } + + #[test] + fn test_roundtrip() { + let original = PlanFile { + rfc_title: "roundtrip-test".to_string(), + status: PlanStatus::Complete, + updated_at: "2026-01-26T12:00:00Z".to_string(), + tasks: vec![ + PlanTask { + description: "Task one".to_string(), + completed: true, + }, + PlanTask { + description: "Task two".to_string(), + completed: true, + }, + ], + }; + + let markdown = generate_plan_markdown(&original); + let parsed = parse_plan_markdown(&markdown).unwrap(); + + assert_eq!(parsed.rfc_title, original.rfc_title); + assert_eq!(parsed.status, original.status); + assert_eq!(parsed.tasks.len(), original.tasks.len()); + for (p, o) in parsed.tasks.iter().zip(original.tasks.iter()) { + assert_eq!(p.description, o.description); + assert_eq!(p.completed, o.completed); + } + } + + #[test] + fn test_is_cache_stale_no_file() { + let path = Path::new("/nonexistent/path.plan.md"); + assert!(!is_cache_stale(path, Some("2026-01-01T00:00:00Z"))); + } + + #[test] + fn test_is_cache_stale_no_cache() { + let dir = tempfile::tempdir().unwrap(); + let plan_path = dir.path().join("test.plan.md"); + std::fs::write(&plan_path, "# Plan: test\n").unwrap(); + + assert!(is_cache_stale(&plan_path, None)); + } + + #[test] + fn test_status_from_str() { + assert_eq!( + PlanStatus::from_str("in-progress"), + Some(PlanStatus::InProgress) + ); + assert_eq!( + PlanStatus::from_str("In Progress"), + Some(PlanStatus::InProgress) + ); + assert_eq!(PlanStatus::from_str("complete"), Some(PlanStatus::Complete)); + assert_eq!( + PlanStatus::from_str("updating-plan"), + Some(PlanStatus::UpdatingPlan) + ); + assert_eq!(PlanStatus::from_str("invalid"), None); + } +} diff --git a/crates/blue-core/src/store.rs b/crates/blue-core/src/store.rs index 8345ea4..933a668 100644 --- a/crates/blue-core/src/store.rs +++ b/crates/blue-core/src/store.rs @@ -10,7 +10,7 @@ use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBe use tracing::{debug, info, warn}; /// Current schema version -const SCHEMA_VERSION: i32 = 6; +const SCHEMA_VERSION: i32 = 7; /// Core database schema const SCHEMA: &str = r#" @@ -1125,6 +1125,26 @@ impl DocumentStore { )?; } + // Migration from v6 to v7: Add plan_cache table (RFC 0017 - Plan File Authority) + if from_version < 7 { + debug!("Adding plan_cache table (RFC 0017)"); + + self.conn.execute( + "CREATE TABLE IF NOT EXISTS plan_cache ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + document_id INTEGER NOT NULL UNIQUE, + cache_mtime TEXT NOT NULL, + FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE + )", + [], + )?; + + self.conn.execute( + "CREATE INDEX IF NOT EXISTS idx_plan_cache_document ON plan_cache(document_id)", + [], + )?; + } + // Update schema version self.conn.execute( "UPDATE schema_version SET version = ?1", @@ -1734,6 +1754,68 @@ impl DocumentStore { .map_err(StoreError::Database) } + // ==================== Plan Cache Operations (RFC 0017) ==================== + + /// Get the cached mtime for a plan file + pub fn get_plan_cache_mtime(&self, document_id: i64) -> Result, StoreError> { + self.conn + .query_row( + "SELECT cache_mtime FROM plan_cache WHERE document_id = ?1", + params![document_id], + |row| row.get(0), + ) + .optional() + .map_err(StoreError::Database) + } + + /// Update the cached mtime for a plan file + pub fn update_plan_cache_mtime(&self, document_id: i64, mtime: &str) -> Result<(), StoreError> { + self.with_retry(|| { + self.conn.execute( + "INSERT INTO plan_cache (document_id, cache_mtime) VALUES (?1, ?2) + ON CONFLICT(document_id) DO UPDATE SET cache_mtime = excluded.cache_mtime", + params![document_id, mtime], + )?; + Ok(()) + }) + } + + /// Rebuild tasks from plan file data (RFC 0017 - authority inversion) + pub fn rebuild_tasks_from_plan( + &self, + document_id: i64, + tasks: &[crate::plan::PlanTask], + ) -> Result<(), StoreError> { + self.with_retry(|| { + // Delete existing tasks + self.conn + .execute("DELETE FROM tasks WHERE document_id = ?1", params![document_id])?; + + // Insert tasks from plan file + for (index, task) in tasks.iter().enumerate() { + let completed_at = if task.completed { + Some(chrono::Utc::now().to_rfc3339()) + } else { + None + }; + + self.conn.execute( + "INSERT INTO tasks (document_id, task_index, description, completed, completed_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![ + document_id, + index as i32, + task.description, + task.completed as i32, + completed_at + ], + )?; + } + + Ok(()) + }) + } + // ==================== Worktree Operations ==================== /// Add a worktree for a document diff --git a/crates/blue-mcp/src/handlers/lint.rs b/crates/blue-mcp/src/handlers/lint.rs index 38afad6..064a146 100644 --- a/crates/blue-mcp/src/handlers/lint.rs +++ b/crates/blue-mcp/src/handlers/lint.rs @@ -17,6 +17,7 @@ enum ProjectType { JavaScript, Python, Cdk, + RfcDocs, } impl ProjectType { @@ -26,6 +27,7 @@ impl ProjectType { ProjectType::JavaScript => "javascript", ProjectType::Python => "python", ProjectType::Cdk => "cdk", + ProjectType::RfcDocs => "rfc-docs", } } } @@ -67,6 +69,7 @@ pub fn handle_lint(args: &Value, repo_path: &Path) -> Result ProjectType::JavaScript => run_js_checks(repo_path, fix, check_type), ProjectType::Python => run_python_checks(repo_path, fix, check_type), ProjectType::Cdk => run_cdk_checks(repo_path, check_type), + ProjectType::RfcDocs => run_rfc_checks(repo_path, fix, check_type), }; all_results.extend(results); } @@ -152,6 +155,10 @@ fn detect_project_types(path: &Path) -> Vec { if path.join("cdk.json").exists() { types.push(ProjectType::Cdk); } + // RFC 0017: Check for Blue RFC docs + if path.join(".blue/docs/rfcs").exists() { + types.push(ProjectType::RfcDocs); + } types } @@ -244,6 +251,8 @@ fn count_issues(output: &str, project_type: ProjectType, check_name: &str) -> us 0 } } + // RFC headers are counted directly in run_rfc_checks + (ProjectType::RfcDocs, _) => 0, _ => 0, } } @@ -401,6 +410,92 @@ fn run_cdk_checks(path: &Path, check_type: &str) -> Vec { results } +/// RFC 0017: Run RFC header checks +fn run_rfc_checks(path: &Path, fix: bool, check_type: &str) -> Vec { + use blue_core::{HeaderFormat, convert_inline_to_table_header, validate_rfc_header}; + use std::fs; + + let mut results = Vec::new(); + + if check_type != "all" && check_type != "headers" { + return results; + } + + let rfcs_path = path.join(".blue/docs/rfcs"); + if !rfcs_path.exists() { + return results; + } + + let mut inline_count = 0; + let mut missing_count = 0; + let mut fixed_count = 0; + + // Scan RFC files (exclude .plan.md files) + if let Ok(entries) = fs::read_dir(&rfcs_path) { + for entry in entries.flatten() { + let path = entry.path(); + if let Some(ext) = path.extension() { + if ext != "md" { + continue; + } + } else { + continue; + } + + // Skip .plan.md files + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + if name.ends_with(".plan.md") { + continue; + } + } + + if let Ok(content) = fs::read_to_string(&path) { + match validate_rfc_header(&content) { + HeaderFormat::Table => { + // Good - canonical format + } + HeaderFormat::Inline => { + if fix { + let converted = convert_inline_to_table_header(&content); + if let Ok(()) = fs::write(&path, converted) { + fixed_count += 1; + } + } else { + inline_count += 1; + } + } + HeaderFormat::Missing => { + missing_count += 1; + } + } + } + } + } + + let total_issues = if fix { 0 } else { inline_count + missing_count }; + + results.push(LintResult { + project_type: ProjectType::RfcDocs, + name: "headers", + tool: "blue_lint", + passed: total_issues == 0, + issue_count: total_issues, + fix_command: "blue_lint --fix --check headers", + }); + + // Add details if there were issues or fixes + if inline_count > 0 || missing_count > 0 || fixed_count > 0 { + tracing::info!( + "RFC headers: {} inline (non-canonical), {} missing, {} fixed", + inline_count, + missing_count, + fixed_count + ); + } + + results +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/blue-mcp/src/server.rs b/crates/blue-mcp/src/server.rs index afc5e6a..9745047 100644 --- a/crates/blue-mcp/src/server.rs +++ b/crates/blue-mcp/src/server.rs @@ -2397,20 +2397,50 @@ impl BlueServer { let doc = state.store.find_document(DocType::Rfc, title) .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + let doc_id = doc.id; + let rfc_number = doc.number.unwrap_or(0); + + // RFC 0017: Check if plan file exists and cache is stale - rebuild if needed + let plan_path = blue_core::plan_file_path(&state.home.docs_path, title, rfc_number); + let mut cache_rebuilt = false; + + if let Some(id) = doc_id { + if plan_path.exists() { + let cache_mtime = state.store.get_plan_cache_mtime(id) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + if blue_core::is_cache_stale(&plan_path, cache_mtime.as_deref()) { + // Rebuild cache from plan file + let plan = blue_core::read_plan_file(&plan_path) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + state.store.rebuild_tasks_from_plan(id, &plan.tasks) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + // Update cache mtime + let mtime = chrono::Utc::now().to_rfc3339(); + state.store.update_plan_cache_mtime(id, &mtime) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + cache_rebuilt = true; + } + } + } + // Get tasks if any - let tasks = if let Some(id) = doc.id { + let tasks = if let Some(id) = doc_id { state.store.get_tasks(id).unwrap_or_default() } else { vec![] }; - let progress = if let Some(id) = doc.id { + let progress = if let Some(id) = doc_id { state.store.get_task_progress(id).ok() } else { None }; - Ok(json!({ + let mut response = json!({ "id": doc.id, "number": doc.number, "title": doc.title, @@ -2428,7 +2458,15 @@ impl BlueServer { "total": p.total, "percentage": p.percentage })) - })) + }); + + // Add plan file info if it exists + if plan_path.exists() { + response["plan_file"] = json!(plan_path.display().to_string()); + response["cache_rebuilt"] = json!(cache_rebuilt); + } + + Ok(response) } fn handle_rfc_update_status(&mut self, args: &Option) -> Result { @@ -2553,15 +2591,59 @@ impl BlueServer { let doc_id = doc.id.ok_or(ServerError::InvalidParams)?; + // RFC 0017: Status gating - only allow planning for accepted or in-progress RFCs + let status_lower = doc.status.to_lowercase(); + if status_lower != "accepted" && status_lower != "in-progress" { + return Err(ServerError::Workflow(format!( + "RFC must be 'accepted' or 'in-progress' to create a plan (current: {})", + doc.status + ))); + } + + // RFC 0017: Write .plan.md file as authoritative source + let plan_tasks: Vec = tasks + .iter() + .map(|desc| blue_core::PlanTask { + description: desc.clone(), + completed: false, + }) + .collect(); + + let plan = blue_core::PlanFile { + rfc_title: title.to_string(), + status: blue_core::PlanStatus::InProgress, + updated_at: chrono::Utc::now().to_rfc3339(), + tasks: plan_tasks.clone(), + }; + + let rfc_number = doc.number.unwrap_or(0); + let plan_path = blue_core::plan_file_path(&state.home.docs_path, title, rfc_number); + + // Ensure parent directory exists + if let Some(parent) = plan_path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| ServerError::StateLoadFailed(format!("Failed to create directory: {}", e)))?; + } + + blue_core::write_plan_file(&plan_path, &plan) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + // Update SQLite cache state.store.set_tasks(doc_id, &tasks) .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + // Update cache mtime + let mtime = chrono::Utc::now().to_rfc3339(); + state.store.update_plan_cache_mtime(doc_id, &mtime) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + Ok(json!({ "status": "success", "title": title, "task_count": tasks.len(), + "plan_file": plan_path.display().to_string(), "message": blue_core::voice::success( - &format!("Set {} tasks for '{}'", tasks.len(), title), + &format!("Set {} tasks for '{}'. Plan file created.", tasks.len(), title), Some("Mark them complete as you go with blue_rfc_task_complete.") ) })) @@ -2586,23 +2668,58 @@ impl BlueServer { .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; let doc_id = doc.id.ok_or(ServerError::InvalidParams)?; + let rfc_number = doc.number.unwrap_or(0); + + // RFC 0017: Check if .plan.md exists and use it as authority + let plan_path = blue_core::plan_file_path(&state.home.docs_path, title, rfc_number); // Parse task index or find by substring let task_index = if let Ok(idx) = task.parse::() { idx } else { - // Find task by substring - let tasks = state.store.get_tasks(doc_id) - .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + // Find task by substring - check plan file first if it exists + if plan_path.exists() { + let plan = blue_core::read_plan_file(&plan_path) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; - tasks.iter() - .find(|t| t.description.to_lowercase().contains(&task.to_lowercase())) - .map(|t| t.task_index) - .ok_or(ServerError::InvalidParams)? + plan.tasks + .iter() + .position(|t| t.description.to_lowercase().contains(&task.to_lowercase())) + .map(|idx| idx as i32) + .ok_or(ServerError::InvalidParams)? + } else { + // Fall back to SQLite + let tasks = state.store.get_tasks(doc_id) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + tasks.iter() + .find(|t| t.description.to_lowercase().contains(&task.to_lowercase())) + .map(|t| t.task_index) + .ok_or(ServerError::InvalidParams)? + } }; - state.store.complete_task(doc_id, task_index) - .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + // RFC 0017: Update .plan.md if it exists + let plan_updated = if plan_path.exists() { + let updated_plan = blue_core::update_task_in_plan(&plan_path, task_index as usize, true) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + // Rebuild SQLite cache from plan + state.store.rebuild_tasks_from_plan(doc_id, &updated_plan.tasks) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + // Update cache mtime + let mtime = chrono::Utc::now().to_rfc3339(); + state.store.update_plan_cache_mtime(doc_id, &mtime) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + true + } else { + // No plan file - update SQLite directly (legacy behavior) + state.store.complete_task(doc_id, task_index) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + false + }; let progress = state.store.get_task_progress(doc_id) .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; @@ -2611,6 +2728,7 @@ impl BlueServer { "status": "success", "title": title, "task_index": task_index, + "plan_updated": plan_updated, "progress": { "completed": progress.completed, "total": progress.total,