From 3e157d76a6e031e13fe8345f363a68f21a15ea01 Mon Sep 17 00:00:00 2001 From: Eric Garcia Date: Sat, 24 Jan 2026 00:43:25 -0500 Subject: [PATCH] feat(core): Complete Phase 1 - Foundation porting from coherence-mcp Port core functionality from coherence-mcp to blue-core: - store.rs: SQLite persistence with schema v1, WAL mode, FTS5 search - documents, document_links, tasks, worktrees, metadata tables - Blue's voice in all error messages - documents.rs: Enhanced with markdown generation - Rfc, Spike, Adr, Decision types with to_markdown() methods - Blue's signature at the end of generated docs - state.rs: ProjectState aggregation - active_items(), ready_items(), stalled_items(), draft_items() - generate_hint() for contextual recommendations - status_summary() for complete overview - repo.rs: Git detection and worktree operations - detect_blue() finds .blue/ directory structure - WorktreeInfo with rfc_title() extraction - create_worktree(), remove_worktree(), is_branch_merged() - workflow.rs: Status transitions - RfcStatus, SpikeOutcome, SpikeStatus, PrdStatus enums - Transition validation with helpful error messages MCP server updated with 9 tools: - blue_status, blue_next, blue_rfc_create, blue_rfc_get - blue_rfc_update_status, blue_rfc_plan, blue_rfc_validate - blue_rfc_task_complete, blue_search 14 unit tests passing. RFC 0002 tracks remaining phases. Co-Authored-By: Claude Opus 4.5 --- Cargo.toml | 9 + crates/blue-core/Cargo.toml | 3 + crates/blue-core/src/documents.rs | 317 +++++- crates/blue-core/src/lib.rs | 15 + crates/blue-core/src/repo.rs | 289 ++++++ crates/blue-core/src/state.rs | 259 +++++ crates/blue-core/src/store.rs | 979 ++++++++++++++++++ crates/blue-core/src/workflow.rs | 246 +++++ crates/blue-mcp/src/server.rs | 557 +++++++++- .../rfcs/0002-port-coherence-functionality.md | 214 ++++ 10 files changed, 2864 insertions(+), 24 deletions(-) create mode 100644 crates/blue-core/src/repo.rs create mode 100644 crates/blue-core/src/state.rs create mode 100644 crates/blue-core/src/store.rs create mode 100644 crates/blue-core/src/workflow.rs create mode 100644 docs/rfcs/0002-port-coherence-functionality.md diff --git a/Cargo.toml b/Cargo.toml index b5d7e13..67b917f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,15 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } # CLI clap = { version = "4.0", features = ["derive"] } +# Database +rusqlite = { version = "0.32", features = ["bundled"] } + +# Time +chrono = { version = "0.4", features = ["serde"] } + +# Git +git2 = "0.19" + # Internal blue-core = { path = "crates/blue-core" } blue-mcp = { path = "crates/blue-mcp" } diff --git a/crates/blue-core/Cargo.toml b/crates/blue-core/Cargo.toml index b137ec2..8f8857d 100644 --- a/crates/blue-core/Cargo.toml +++ b/crates/blue-core/Cargo.toml @@ -12,3 +12,6 @@ thiserror.workspace = true anyhow.workspace = true tokio.workspace = true tracing.workspace = true +rusqlite.workspace = true +chrono.workspace = true +git2.workspace = true diff --git a/crates/blue-core/src/documents.rs b/crates/blue-core/src/documents.rs index 91432e0..8f48807 100644 --- a/crates/blue-core/src/documents.rs +++ b/crates/blue-core/src/documents.rs @@ -1,6 +1,6 @@ //! Document types for Blue //! -//! RFCs, ADRs, Spikes, and other document structures. +//! RFCs, ADRs, Spikes, and other document structures with markdown generation. use serde::{Deserialize, Serialize}; @@ -15,14 +15,30 @@ pub enum Status { Superseded, } +impl Status { + pub fn as_str(&self) -> &'static str { + match self { + Status::Draft => "draft", + Status::Accepted => "accepted", + Status::InProgress => "in-progress", + Status::Implemented => "implemented", + Status::Superseded => "superseded", + } + } +} + /// An RFC (Request for Comments) - a design document #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Rfc { pub title: String, pub status: Status, + pub date: Option, + pub source_spike: Option, + pub source_prd: Option, pub problem: Option, pub proposal: Option, pub goals: Vec, + pub non_goals: Vec, pub plan: Vec, } @@ -37,10 +53,13 @@ pub struct Task { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Spike { pub title: String, - pub question: String, + pub status: String, + pub date: String, pub time_box: Option, + pub question: String, pub outcome: Option, - pub summary: Option, + pub findings: Option, + pub recommendation: Option, } /// Outcome of a spike investigation @@ -52,10 +71,21 @@ pub enum SpikeOutcome { RecommendsImplementation, } +impl SpikeOutcome { + pub fn as_str(&self) -> &'static str { + match self { + SpikeOutcome::NoAction => "no-action", + SpikeOutcome::DecisionMade => "decision-made", + SpikeOutcome::RecommendsImplementation => "recommends-implementation", + } + } +} + /// A Decision Note - lightweight choice documentation #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Decision { pub title: String, + pub date: String, pub decision: String, pub rationale: Option, pub alternatives: Vec, @@ -65,6 +95,9 @@ pub struct Decision { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Adr { pub title: String, + pub status: String, + pub date: String, + pub source_rfc: Option, pub context: String, pub decision: String, pub consequences: Vec, @@ -76,9 +109,13 @@ impl Rfc { Self { title: title.into(), status: Status::Draft, + date: Some(today()), + source_spike: None, + source_prd: None, problem: None, proposal: None, goals: Vec::new(), + non_goals: Vec::new(), plan: Vec::new(), } } @@ -91,6 +128,78 @@ impl Rfc { let completed = self.plan.iter().filter(|t| t.completed).count(); (completed as f64 / self.plan.len() as f64) * 100.0 } + + /// Generate markdown content + pub fn to_markdown(&self, number: u32) -> String { + let mut md = String::new(); + + // Title + md.push_str(&format!( + "# RFC {:04}: {}\n\n", + number, + to_title_case(&self.title) + )); + + // Metadata table + md.push_str("| | |\n|---|---|\n"); + md.push_str(&format!( + "| **Status** | {} |\n", + to_title_case(self.status.as_str()) + )); + if let Some(ref date) = self.date { + md.push_str(&format!("| **Date** | {} |\n", date)); + } + if let Some(ref spike) = self.source_spike { + md.push_str(&format!("| **Source Spike** | {} |\n", spike)); + } + if let Some(ref prd) = self.source_prd { + md.push_str(&format!("| **Source PRD** | {} |\n", prd)); + } + md.push_str("\n---\n\n"); + + // Summary (problem) + if let Some(ref problem) = self.problem { + md.push_str("## Summary\n\n"); + md.push_str(problem); + md.push_str("\n\n"); + } + + // Proposal + if let Some(ref proposal) = self.proposal { + md.push_str("## Proposal\n\n"); + md.push_str(proposal); + md.push_str("\n\n"); + } + + // Goals + if !self.goals.is_empty() { + md.push_str("## Goals\n\n"); + for goal in &self.goals { + md.push_str(&format!("- {}\n", goal)); + } + md.push('\n'); + } + + // Non-Goals + if !self.non_goals.is_empty() { + md.push_str("## Non-Goals\n\n"); + for ng in &self.non_goals { + md.push_str(&format!("- {}\n", ng)); + } + md.push('\n'); + } + + // Test Plan (empty checkboxes) + md.push_str("## Test Plan\n\n"); + md.push_str("- [ ] TBD\n\n"); + + // Blue's signature + md.push_str("---\n\n"); + md.push_str("*\"Right then. Let's get to it.\"*\n\n"); + md.push_str("— Blue\n"); + + md + } } impl Spike { @@ -98,10 +207,208 @@ impl Spike { pub fn new(title: impl Into, question: impl Into) -> Self { Self { title: title.into(), - question: question.into(), + status: "in-progress".to_string(), + date: today(), time_box: None, + question: question.into(), outcome: None, - summary: None, + findings: None, + recommendation: None, } } + + /// Generate markdown content + pub fn to_markdown(&self) -> String { + let mut md = String::new(); + + md.push_str(&format!("# Spike: {}\n\n", to_title_case(&self.title))); + + md.push_str("| | |\n|---|---|\n"); + md.push_str(&format!( + "| **Status** | {} |\n", + to_title_case(&self.status) + )); + md.push_str(&format!("| **Date** | {} |\n", self.date)); + if let Some(ref tb) = self.time_box { + md.push_str(&format!("| **Time Box** | {} |\n", tb)); + } + if let Some(ref outcome) = self.outcome { + md.push_str(&format!("| **Outcome** | {} |\n", outcome.as_str())); + } + md.push_str("\n---\n\n"); + + md.push_str("## Question\n\n"); + md.push_str(&self.question); + md.push_str("\n\n"); + + if let Some(ref findings) = self.findings { + md.push_str("## Findings\n\n"); + md.push_str(findings); + md.push_str("\n\n"); + } + + if let Some(ref rec) = self.recommendation { + md.push_str("## Recommendation\n\n"); + md.push_str(rec); + md.push_str("\n\n"); + } + + md.push_str("---\n\n"); + md.push_str("*Investigation notes by Blue*\n"); + + md + } +} + +impl Adr { + /// Create a new ADR + pub fn new(title: impl Into) -> Self { + Self { + title: title.into(), + status: "accepted".to_string(), + date: today(), + source_rfc: None, + context: String::new(), + decision: String::new(), + consequences: Vec::new(), + } + } + + /// Generate markdown content + pub fn to_markdown(&self, number: u32) -> String { + let mut md = String::new(); + + md.push_str(&format!( + "# ADR {:04}: {}\n\n", + number, + to_title_case(&self.title) + )); + + md.push_str("| | |\n|---|---|\n"); + md.push_str(&format!( + "| **Status** | {} |\n", + to_title_case(&self.status) + )); + md.push_str(&format!("| **Date** | {} |\n", self.date)); + if let Some(ref rfc) = self.source_rfc { + md.push_str(&format!("| **RFC** | {} |\n", rfc)); + } + md.push_str("\n---\n\n"); + + md.push_str("## Context\n\n"); + md.push_str(&self.context); + md.push_str("\n\n"); + + md.push_str("## Decision\n\n"); + md.push_str(&self.decision); + md.push_str("\n\n"); + + if !self.consequences.is_empty() { + md.push_str("## Consequences\n\n"); + for c in &self.consequences { + md.push_str(&format!("- {}\n", c)); + } + md.push('\n'); + } + + md.push_str("---\n\n"); + md.push_str("*Recorded by Blue*\n"); + + md + } +} + +impl Decision { + /// Create a new Decision + pub fn new(title: impl Into, decision: impl Into) -> Self { + Self { + title: title.into(), + date: today(), + decision: decision.into(), + rationale: None, + alternatives: Vec::new(), + } + } + + /// Generate markdown content + pub fn to_markdown(&self) -> String { + let mut md = String::new(); + + md.push_str(&format!("# Decision: {}\n\n", to_title_case(&self.title))); + md.push_str(&format!("**Date:** {}\n\n", self.date)); + + md.push_str("## Decision\n\n"); + md.push_str(&self.decision); + md.push_str("\n\n"); + + if let Some(ref rationale) = self.rationale { + md.push_str("## Rationale\n\n"); + md.push_str(rationale); + md.push_str("\n\n"); + } + + if !self.alternatives.is_empty() { + md.push_str("## Alternatives Considered\n\n"); + for alt in &self.alternatives { + md.push_str(&format!("- {}\n", alt)); + } + md.push('\n'); + } + + md.push_str("---\n\n"); + md.push_str("*Noted by Blue*\n"); + + md + } +} + +/// Get current date in YYYY-MM-DD format +fn today() -> String { + chrono::Utc::now().format("%Y-%m-%d").to_string() +} + +/// Convert kebab-case to Title Case +fn to_title_case(s: &str) -> String { + s.split('-') + .map(|word| { + let mut chars = word.chars(); + match chars.next() { + None => String::new(), + Some(first) => first.to_uppercase().chain(chars).collect(), + } + }) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rfc_to_markdown() { + let mut rfc = Rfc::new("my-feature"); + rfc.problem = Some("Things are slow".to_string()); + rfc.goals = vec!["Make it fast".to_string()]; + + let md = rfc.to_markdown(1); + assert!(md.contains("# RFC 0001: My Feature")); + assert!(md.contains("Things are slow")); + assert!(md.contains("Make it fast")); + assert!(md.contains("— Blue")); + } + + #[test] + fn test_title_case() { + assert_eq!(to_title_case("my-feature"), "My Feature"); + assert_eq!(to_title_case("in-progress"), "In Progress"); + } + + #[test] + fn test_spike_to_markdown() { + let spike = Spike::new("test-investigation", "What should we do?"); + let md = spike.to_markdown(); + assert!(md.contains("# Spike: Test Investigation")); + assert!(md.contains("What should we do?")); + } } diff --git a/crates/blue-core/src/lib.rs b/crates/blue-core/src/lib.rs index a3a3aa7..3d0c136 100644 --- a/crates/blue-core/src/lib.rs +++ b/crates/blue-core/src/lib.rs @@ -1,12 +1,27 @@ //! Blue Core - The heart of the philosophy //! //! Core data structures and logic for Blue. +//! +//! This crate provides: +//! - Document types (RFC, Spike, ADR, Decision) +//! - SQLite persistence layer +//! - Git worktree operations +//! - Project state management +//! - Blue's voice and tone // Blue's true name, between friends const _BLUE_SECRET_NAME: &str = "Sheepey"; // pronounced "Shee-paay" pub mod documents; +pub mod repo; +pub mod state; +pub mod store; pub mod voice; +pub mod workflow; pub use documents::*; +pub use repo::{detect_blue, BlueHome, RepoError, WorktreeInfo}; +pub use state::{ItemType, ProjectState, StateError, StatusSummary, WorkItem}; +pub use store::{DocType, Document, DocumentStore, LinkType, SearchResult, StoreError, Task as StoreTask, TaskProgress, Worktree}; pub use voice::*; +pub use workflow::{PrdStatus, RfcStatus, SpikeOutcome as WorkflowSpikeOutcome, SpikeStatus, WorkflowError}; diff --git a/crates/blue-core/src/repo.rs b/crates/blue-core/src/repo.rs new file mode 100644 index 0000000..8f9995e --- /dev/null +++ b/crates/blue-core/src/repo.rs @@ -0,0 +1,289 @@ +//! Git repository detection and operations for Blue +//! +//! Finds Blue's home (.blue/) and manages worktrees. + +use std::path::{Path, PathBuf}; +use thiserror::Error; +use tracing::debug; + +/// Blue's directory structure detection result +#[derive(Debug, Clone)] +pub struct BlueHome { + /// Root directory containing .blue/ + pub root: PathBuf, + /// Path to .blue/repos/ (markdown docs) + pub repos_path: PathBuf, + /// Path to .blue/data/ (SQLite databases) + pub data_path: PathBuf, + /// Path to .blue/worktrees/ (git worktrees) + pub worktrees_path: PathBuf, + /// Detected project name + pub project_name: Option, +} + +impl BlueHome { + /// Get the docs path for a specific project + pub fn docs_path(&self, project: &str) -> PathBuf { + self.repos_path.join(project).join("docs") + } + + /// Get the database path for a specific project + pub fn db_path(&self, project: &str) -> PathBuf { + self.data_path.join(project).join("blue.db") + } +} + +/// Information about a git worktree +#[derive(Debug, Clone)] +pub struct WorktreeInfo { + /// Path to the worktree + pub path: PathBuf, + /// Branch name + pub branch: String, + /// Whether this is the main worktree + pub is_main: bool, +} + +impl WorktreeInfo { + /// Extract RFC title from branch name if it follows the pattern rfc/{title} + pub fn rfc_title(&self) -> Option { + if self.branch.starts_with("rfc/") { + Some(self.branch.trim_start_matches("rfc/").to_string()) + } else { + None + } + } +} + +/// Repository errors +#[derive(Debug, Error)] +pub enum RepoError { + #[error("Can't find Blue here. Run 'blue init' first?")] + NotHome, + + #[error("Git trouble: {0}")] + Git(#[from] git2::Error), + + #[error("Can't read directory: {0}")] + Io(#[from] std::io::Error), +} + +/// Detect Blue's home directory structure +/// +/// Looks for .blue/ in the current directory or any parent. +/// The structure is: +/// ```text +/// project/ +/// ├── .blue/ +/// │ ├── repos/ # Cloned repos with docs/ +/// │ ├── data/ # SQLite databases +/// │ └── worktrees/ # Git worktrees +/// └── ... +/// ``` +pub fn detect_blue(from: &Path) -> Result { + let mut current = from.to_path_buf(); + + loop { + let blue_dir = current.join(".blue"); + if blue_dir.exists() && blue_dir.is_dir() { + debug!("Found Blue's home at {:?}", blue_dir); + + return Ok(BlueHome { + root: current.clone(), + repos_path: blue_dir.join("repos"), + data_path: blue_dir.join("data"), + worktrees_path: blue_dir.join("worktrees"), + project_name: extract_project_name(¤t), + }); + } + + // Also check for legacy .repos/.data/.worktrees structure + let legacy_repos = current.join(".repos"); + let legacy_data = current.join(".data"); + if legacy_repos.exists() && legacy_data.exists() { + debug!("Found legacy Blue structure at {:?}", current); + + return Ok(BlueHome { + root: current.clone(), + repos_path: legacy_repos, + data_path: legacy_data, + worktrees_path: current.join(".worktrees"), + project_name: extract_project_name(¤t), + }); + } + + if !current.pop() { + break; + } + } + + Err(RepoError::NotHome) +} + +/// Extract project name from git remote or directory name +fn extract_project_name(path: &Path) -> Option { + // Try git remote first + if let Ok(repo) = git2::Repository::discover(path) { + if let Ok(remote) = repo.find_remote("origin") { + if let Some(url) = remote.url() { + return extract_repo_name_from_url(url); + } + } + } + + // Fall back to directory name + path.file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_string()) +} + +/// Extract repository name from a git URL +fn extract_repo_name_from_url(url: &str) -> Option { + // Handle SSH URLs: git@host:org/repo.git + if url.contains(':') && !url.contains("://") { + let after_colon = url.split(':').last()?; + let name = after_colon.trim_end_matches(".git"); + return name.split('/').last().map(|s| s.to_string()); + } + + // Handle HTTPS URLs: https://host/org/repo.git + let name = url.trim_end_matches(".git"); + name.split('/').last().map(|s| s.to_string()) +} + +/// List git worktrees for a repository +pub fn list_worktrees(repo: &git2::Repository) -> Vec { + let mut worktrees = Vec::new(); + + // Add main worktree + if let Some(workdir) = repo.workdir() { + if let Ok(head) = repo.head() { + let branch = head + .shorthand() + .map(|s| s.to_string()) + .unwrap_or_else(|| "HEAD".to_string()); + + worktrees.push(WorktreeInfo { + path: workdir.to_path_buf(), + branch, + is_main: true, + }); + } + } + + // Add other worktrees + if let Ok(wt_names) = repo.worktrees() { + for name in wt_names.iter().flatten() { + if let Ok(wt) = repo.find_worktree(name) { + if let Some(path) = wt.path().to_str() { + // Try to get the branch for this worktree + let branch = wt.name().unwrap_or("unknown").to_string(); + + worktrees.push(WorktreeInfo { + path: PathBuf::from(path), + branch, + is_main: false, + }); + } + } + } + } + + worktrees +} + +/// Create a new worktree for an RFC +pub fn create_worktree( + repo: &git2::Repository, + branch_name: &str, + worktree_path: &Path, +) -> Result<(), RepoError> { + // Create the branch if it doesn't exist + let head = repo.head()?; + let head_commit = head.peel_to_commit()?; + + let branch = match repo.find_branch(branch_name, git2::BranchType::Local) { + Ok(branch) => branch, + Err(_) => repo.branch(branch_name, &head_commit, false)?, + }; + + // Create the worktree + let reference = branch.into_reference(); + repo.worktree( + branch_name, + worktree_path, + Some(git2::WorktreeAddOptions::new().reference(Some(&reference))), + )?; + + Ok(()) +} + +/// Remove a worktree +pub fn remove_worktree(repo: &git2::Repository, name: &str) -> Result<(), RepoError> { + let worktree = repo.find_worktree(name)?; + + // Prune the worktree (this removes the worktree but keeps the branch) + worktree.prune(Some( + git2::WorktreePruneOptions::new() + .valid(true) + .working_tree(true), + ))?; + + Ok(()) +} + +/// Check if a branch is merged into another +pub fn is_branch_merged( + repo: &git2::Repository, + branch: &str, + into: &str, +) -> Result { + let branch_commit = repo + .find_branch(branch, git2::BranchType::Local)? + .get() + .peel_to_commit()? + .id(); + + let into_commit = repo + .find_branch(into, git2::BranchType::Local)? + .get() + .peel_to_commit()? + .id(); + + // Check if branch_commit is an ancestor of into_commit + Ok(repo.graph_descendant_of(into_commit, branch_commit)?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_repo_name_ssh() { + let url = "git@github.com:superviber/blue.git"; + assert_eq!(extract_repo_name_from_url(url), Some("blue".to_string())); + } + + #[test] + fn test_extract_repo_name_https() { + let url = "https://github.com/superviber/blue.git"; + assert_eq!(extract_repo_name_from_url(url), Some("blue".to_string())); + } + + #[test] + fn test_worktree_info_rfc_title() { + let wt = WorktreeInfo { + path: PathBuf::from("/tmp/test"), + branch: "rfc/my-feature".to_string(), + is_main: false, + }; + assert_eq!(wt.rfc_title(), Some("my-feature".to_string())); + + let main = WorktreeInfo { + path: PathBuf::from("/tmp/main"), + branch: "main".to_string(), + is_main: true, + }; + assert_eq!(main.rfc_title(), None); + } +} diff --git a/crates/blue-core/src/state.rs b/crates/blue-core/src/state.rs new file mode 100644 index 0000000..b8a32f2 --- /dev/null +++ b/crates/blue-core/src/state.rs @@ -0,0 +1,259 @@ +//! Project state management for Blue +//! +//! Aggregates documents, worktrees, and provides status views. + +use std::collections::HashSet; + +use serde::Serialize; +use tracing::warn; + +use crate::repo::{list_worktrees, BlueHome, WorktreeInfo}; +use crate::store::{DocType, DocumentStore, StoreError}; + +/// Complete project state +#[derive(Debug)] +pub struct ProjectState { + /// Blue's home directory + pub home: BlueHome, + /// SQLite document store + pub store: DocumentStore, + /// Active git worktrees + pub worktrees: Vec, + /// Set of RFC titles with active worktrees + worktree_rfcs: HashSet, + /// Project name + pub project: String, +} + +impl ProjectState { + /// Load project state + pub fn load(home: BlueHome, project: &str) -> Result { + let db_path = home.db_path(project); + let store = DocumentStore::open(&db_path)?; + + // Discover worktrees + let worktrees = Self::discover_worktrees(&home, project); + let worktree_rfcs: HashSet = + worktrees.iter().filter_map(|wt| wt.rfc_title()).collect(); + + Ok(Self { + home, + store, + worktrees, + worktree_rfcs, + project: project.to_string(), + }) + } + + /// Discover worktrees from the repo + fn discover_worktrees(home: &BlueHome, project: &str) -> Vec { + let repo_path = home.repos_path.join(project); + + if let Ok(repo) = git2::Repository::open(&repo_path) { + return list_worktrees(&repo); + } + + // Also try from root + if let Ok(repo) = git2::Repository::discover(&home.root) { + return list_worktrees(&repo); + } + + Vec::new() + } + + /// Reload state from disk + pub fn reload(&mut self) -> Result<(), StateError> { + self.worktrees = Self::discover_worktrees(&self.home, &self.project); + self.worktree_rfcs = self + .worktrees + .iter() + .filter_map(|wt| wt.rfc_title()) + .collect(); + Ok(()) + } + + /// Get RFCs that are in-progress with active worktrees + pub fn active_items(&self) -> Vec { + match self.store.list_documents_by_status(DocType::Rfc, "in-progress") { + Ok(docs) => docs + .into_iter() + .filter(|doc| self.worktree_rfcs.contains(&doc.title)) + .map(|doc| WorkItem { + title: doc.title, + status: doc.status, + has_worktree: true, + item_type: ItemType::Rfc, + }) + .collect(), + Err(e) => { + warn!("Couldn't get active items: {}", e); + Vec::new() + } + } + } + + /// Get RFCs that are accepted and ready to start + pub fn ready_items(&self) -> Vec { + match self.store.list_documents_by_status(DocType::Rfc, "accepted") { + Ok(docs) => docs + .into_iter() + .map(|doc| WorkItem { + title: doc.title, + status: doc.status, + has_worktree: false, + item_type: ItemType::Rfc, + }) + .collect(), + Err(e) => { + warn!("Couldn't get ready items: {}", e); + Vec::new() + } + } + } + + /// Get RFCs that are in-progress but have no worktree (possibly stalled) + pub fn stalled_items(&self) -> Vec { + match self.store.list_documents_by_status(DocType::Rfc, "in-progress") { + Ok(docs) => docs + .into_iter() + .filter(|doc| !self.worktree_rfcs.contains(&doc.title)) + .map(|doc| WorkItem { + title: doc.title, + status: doc.status, + has_worktree: false, + item_type: ItemType::Rfc, + }) + .collect(), + Err(e) => { + warn!("Couldn't get stalled items: {}", e); + Vec::new() + } + } + } + + /// Get draft RFCs + pub fn draft_items(&self) -> Vec { + match self.store.list_documents_by_status(DocType::Rfc, "draft") { + Ok(docs) => docs + .into_iter() + .map(|doc| WorkItem { + title: doc.title, + status: doc.status, + has_worktree: false, + item_type: ItemType::Rfc, + }) + .collect(), + Err(e) => { + warn!("Couldn't get draft items: {}", e); + Vec::new() + } + } + } + + /// Check if an RFC has an active worktree + pub fn has_worktree(&self, rfc_title: &str) -> bool { + self.worktree_rfcs.contains(rfc_title) + } + + /// Generate a status hint for the user + pub fn generate_hint(&self) -> String { + let active = self.active_items(); + let ready = self.ready_items(); + let stalled = self.stalled_items(); + + if !stalled.is_empty() { + return format!( + "'{}' might be stalled - it's in-progress but has no worktree", + stalled[0].title + ); + } + + if !ready.is_empty() { + return format!("'{}' is ready to implement. Want to start?", ready[0].title); + } + + if !active.is_empty() { + return format!("{} item(s) currently in progress", active.len()); + } + + "Nothing pressing right now. Good time to plan?".to_string() + } + + /// Get project status summary + pub fn status_summary(&self) -> StatusSummary { + let active = self.active_items(); + let ready = self.ready_items(); + let stalled = self.stalled_items(); + let drafts = self.draft_items(); + + StatusSummary { + active_count: active.len(), + ready_count: ready.len(), + stalled_count: stalled.len(), + draft_count: drafts.len(), + active, + ready, + stalled, + drafts, + hint: self.generate_hint(), + } + } +} + +/// A work item (RFC, spike, etc.) +#[derive(Debug, Clone, Serialize)] +pub struct WorkItem { + pub title: String, + pub status: String, + pub has_worktree: bool, + pub item_type: ItemType, +} + +/// Type of work item +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ItemType { + Rfc, + Spike, + Adr, + Decision, + Prd, +} + +/// Project status summary +#[derive(Debug, Clone, Serialize)] +pub struct StatusSummary { + pub active_count: usize, + pub ready_count: usize, + pub stalled_count: usize, + pub draft_count: usize, + pub active: Vec, + pub ready: Vec, + pub stalled: Vec, + pub drafts: Vec, + pub hint: String, +} + +/// State errors +#[derive(Debug, thiserror::Error)] +pub enum StateError { + #[error("Store error: {0}")] + Store(#[from] StoreError), + + #[error("Repo error: {0}")] + Repo(#[from] crate::repo::RepoError), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_hint_empty() { + // This would require setting up a full test environment + // For now, just verify the function exists and compiles + } +} diff --git a/crates/blue-core/src/store.rs b/crates/blue-core/src/store.rs new file mode 100644 index 0000000..e1e44bb --- /dev/null +++ b/crates/blue-core/src/store.rs @@ -0,0 +1,979 @@ +//! SQLite document store for Blue +//! +//! Persistence layer for RFCs, Spikes, ADRs, and other documents. + +use std::path::Path; +use std::thread; +use std::time::Duration; + +use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior}; +use tracing::{debug, info, warn}; + +/// Current schema version +const SCHEMA_VERSION: i32 = 1; + +/// Core database schema +const SCHEMA: &str = r#" + CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY + ); + + CREATE TABLE IF NOT EXISTS documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + doc_type TEXT NOT NULL, + number INTEGER, + title TEXT NOT NULL, + status TEXT NOT NULL, + file_path TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(doc_type, title) + ); + + CREATE INDEX IF NOT EXISTS idx_documents_type ON documents(doc_type); + CREATE INDEX IF NOT EXISTS idx_documents_status ON documents(doc_type, status); + + CREATE TABLE IF NOT EXISTS document_links ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_id INTEGER NOT NULL, + target_id INTEGER NOT NULL, + link_type TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (source_id) REFERENCES documents(id) ON DELETE CASCADE, + FOREIGN KEY (target_id) REFERENCES documents(id) ON DELETE CASCADE, + UNIQUE(source_id, target_id, link_type) + ); + + CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + document_id INTEGER NOT NULL, + task_index INTEGER NOT NULL, + description TEXT NOT NULL, + completed INTEGER NOT NULL DEFAULT 0, + completed_at TEXT, + FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE, + UNIQUE(document_id, task_index) + ); + + CREATE TABLE IF NOT EXISTS worktrees ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + document_id INTEGER NOT NULL, + branch_name TEXT NOT NULL, + worktree_path TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE, + UNIQUE(document_id) + ); + + CREATE TABLE IF NOT EXISTS metadata ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + document_id INTEGER NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL, + FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE, + UNIQUE(document_id, key) + ); +"#; + +/// FTS5 schema for full-text search +const FTS5_SCHEMA: &str = r#" + CREATE VIRTUAL TABLE IF NOT EXISTS documents_fts USING fts5( + title, + content, + doc_type, + content=documents, + content_rowid=id + ); + + CREATE TRIGGER IF NOT EXISTS documents_ai AFTER INSERT ON documents BEGIN + INSERT INTO documents_fts(rowid, title, doc_type) + VALUES (new.id, new.title, new.doc_type); + END; + + CREATE TRIGGER IF NOT EXISTS documents_ad AFTER DELETE ON documents BEGIN + INSERT INTO documents_fts(documents_fts, rowid, title, doc_type) + VALUES ('delete', old.id, old.title, old.doc_type); + END; + + CREATE TRIGGER IF NOT EXISTS documents_au AFTER UPDATE ON documents BEGIN + INSERT INTO documents_fts(documents_fts, rowid, title, doc_type) + VALUES ('delete', old.id, old.title, old.doc_type); + INSERT INTO documents_fts(rowid, title, doc_type) + VALUES (new.id, new.title, new.doc_type); + END; +"#; + +/// Document types +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DocType { + Rfc, + Spike, + Adr, + Decision, + Prd, +} + +impl DocType { + pub fn as_str(&self) -> &'static str { + match self { + DocType::Rfc => "rfc", + DocType::Spike => "spike", + DocType::Adr => "adr", + DocType::Decision => "decision", + DocType::Prd => "prd", + } + } + + pub fn from_str(s: &str) -> Option { + match s.to_lowercase().as_str() { + "rfc" => Some(DocType::Rfc), + "spike" => Some(DocType::Spike), + "adr" => Some(DocType::Adr), + "decision" => Some(DocType::Decision), + "prd" => Some(DocType::Prd), + _ => None, + } + } + + /// Human-readable plural for Blue's messages + pub fn plural(&self) -> &'static str { + match self { + DocType::Rfc => "RFCs", + DocType::Spike => "spikes", + DocType::Adr => "ADRs", + DocType::Decision => "decisions", + DocType::Prd => "PRDs", + } + } +} + +/// Link types between documents +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LinkType { + /// Spike leads to RFC + SpikeToRfc, + /// RFC leads to ADR + RfcToAdr, + /// PRD leads to RFC + PrdToRfc, + /// Generic reference + References, +} + +impl LinkType { + pub fn as_str(&self) -> &'static str { + match self { + LinkType::SpikeToRfc => "spike_to_rfc", + LinkType::RfcToAdr => "rfc_to_adr", + LinkType::PrdToRfc => "prd_to_rfc", + LinkType::References => "references", + } + } +} + +/// A document in the store +#[derive(Debug, Clone)] +pub struct Document { + pub id: Option, + pub doc_type: DocType, + pub number: Option, + pub title: String, + pub status: String, + pub file_path: Option, + pub created_at: Option, + pub updated_at: Option, +} + +impl Document { + /// Create a new document + pub fn new(doc_type: DocType, title: &str, status: &str) -> Self { + Self { + id: None, + doc_type, + number: None, + title: title.to_string(), + status: status.to_string(), + file_path: None, + created_at: None, + updated_at: None, + } + } +} + +/// A task in a document's plan +#[derive(Debug, Clone)] +pub struct Task { + pub id: Option, + pub document_id: i64, + pub task_index: i32, + pub description: String, + pub completed: bool, + pub completed_at: Option, +} + +/// Task completion progress +#[derive(Debug, Clone)] +pub struct TaskProgress { + pub completed: usize, + pub total: usize, + pub percentage: usize, +} + +/// A worktree associated with a document +#[derive(Debug, Clone)] +pub struct Worktree { + pub id: Option, + pub document_id: i64, + pub branch_name: String, + pub worktree_path: String, + pub created_at: Option, +} + +/// Search result with relevance score +#[derive(Debug, Clone)] +pub struct SearchResult { + pub document: Document, + pub score: f64, + pub snippet: Option, +} + +/// Store errors - in Blue's voice +#[derive(Debug, thiserror::Error)] +pub enum StoreError { + #[error("Can't find '{0}'. Check the name's spelled right?")] + NotFound(String), + + #[error("Database hiccup: {0}")] + Database(#[from] rusqlite::Error), + + #[error("'{0}' already exists. Want to update it instead?")] + AlreadyExists(String), + + #[error("Can't do that: {0}")] + InvalidOperation(String), +} + +/// Check if an error is a busy/locked error +fn is_busy_error(e: &rusqlite::Error) -> bool { + matches!( + e, + rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error { + code: rusqlite::ErrorCode::DatabaseBusy, + .. + }, + _ + ) | rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error { + code: rusqlite::ErrorCode::DatabaseLocked, + .. + }, + _ + ) + ) +} + +/// SQLite-based document store +pub struct DocumentStore { + conn: Connection, +} + +impl std::fmt::Debug for DocumentStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DocumentStore") + .field("conn", &"") + .finish() + } +} + +impl DocumentStore { + /// Open or create a document store + pub fn open(path: &Path) -> Result { + info!("Opening Blue's document store at {:?}", path); + + // Create parent directory if needed + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).ok(); + } + + let conn = Connection::open(path)?; + + // Configure for concurrency + conn.pragma_update(None, "journal_mode", "WAL")?; + conn.pragma_update(None, "busy_timeout", 5000)?; + conn.pragma_update(None, "synchronous", "NORMAL")?; + conn.pragma_update(None, "foreign_keys", "ON")?; + + let store = Self { conn }; + store.init_schema()?; + + Ok(store) + } + + /// Open an in-memory database (for testing) + pub fn open_in_memory() -> Result { + let conn = Connection::open_in_memory()?; + conn.pragma_update(None, "foreign_keys", "ON")?; + + let store = Self { conn }; + store.init_schema()?; + + Ok(store) + } + + /// Get a reference to the underlying connection + pub fn conn(&self) -> &Connection { + &self.conn + } + + /// Initialize the database schema + fn init_schema(&self) -> Result<(), StoreError> { + let version: Option = self + .conn + .query_row("SELECT version FROM schema_version LIMIT 1", [], |row| { + row.get(0) + }) + .ok(); + + match version { + None => { + debug!("Setting up Blue's database (version {})", SCHEMA_VERSION); + self.conn.execute_batch(SCHEMA)?; + self.conn.execute_batch(FTS5_SCHEMA)?; + self.conn.execute( + "INSERT INTO schema_version (version) VALUES (?1)", + params![SCHEMA_VERSION], + )?; + } + Some(v) if v == SCHEMA_VERSION => { + debug!("Database is up to date (version {})", v); + } + Some(v) => { + warn!( + "Schema version {} found, expected {}. Migrations may be needed.", + v, SCHEMA_VERSION + ); + } + } + + Ok(()) + } + + /// Execute with retry on busy + fn with_retry(&self, f: F) -> Result + where + F: Fn() -> Result, + { + let mut attempts = 0; + loop { + match f() { + Ok(result) => return Ok(result), + Err(StoreError::Database(ref e)) if is_busy_error(e) && attempts < 3 => { + attempts += 1; + let delay = Duration::from_millis(100 * attempts as u64); + debug!("Database busy, retrying in {:?}", delay); + thread::sleep(delay); + } + Err(e) => return Err(e), + } + } + } + + /// Begin a write transaction + pub fn begin_write(&mut self) -> Result, StoreError> { + Ok(self + .conn + .transaction_with_behavior(TransactionBehavior::Immediate)?) + } + + // ==================== Document Operations ==================== + + /// Add a new document + pub fn add_document(&self, doc: &Document) -> Result { + self.with_retry(|| { + let now = chrono::Utc::now().to_rfc3339(); + self.conn.execute( + "INSERT INTO documents (doc_type, number, title, status, file_path, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + doc.doc_type.as_str(), + doc.number, + doc.title, + doc.status, + doc.file_path, + now, + now, + ], + )?; + Ok(self.conn.last_insert_rowid()) + }) + } + + /// Get a document by type and title + pub fn get_document(&self, doc_type: DocType, title: &str) -> Result { + self.conn + .query_row( + "SELECT id, doc_type, number, title, status, file_path, created_at, updated_at + FROM documents WHERE doc_type = ?1 AND title = ?2", + params![doc_type.as_str(), title], + |row| { + Ok(Document { + id: Some(row.get(0)?), + doc_type: DocType::from_str(row.get::<_, String>(1)?.as_str()).unwrap(), + number: row.get(2)?, + title: row.get(3)?, + status: row.get(4)?, + file_path: row.get(5)?, + created_at: row.get(6)?, + updated_at: row.get(7)?, + }) + }, + ) + .map_err(|e| match e { + rusqlite::Error::QueryReturnedNoRows => StoreError::NotFound(title.to_string()), + e => StoreError::Database(e), + }) + } + + /// Get a document by ID + pub fn get_document_by_id(&self, id: i64) -> Result { + self.conn + .query_row( + "SELECT id, doc_type, number, title, status, file_path, created_at, updated_at + FROM documents WHERE id = ?1", + params![id], + |row| { + Ok(Document { + id: Some(row.get(0)?), + doc_type: DocType::from_str(row.get::<_, String>(1)?.as_str()).unwrap(), + number: row.get(2)?, + title: row.get(3)?, + status: row.get(4)?, + file_path: row.get(5)?, + created_at: row.get(6)?, + updated_at: row.get(7)?, + }) + }, + ) + .map_err(|e| match e { + rusqlite::Error::QueryReturnedNoRows => { + StoreError::NotFound(format!("document #{}", id)) + } + e => StoreError::Database(e), + }) + } + + /// Get a document by number + pub fn get_document_by_number( + &self, + doc_type: DocType, + number: i32, + ) -> Result { + self.conn + .query_row( + "SELECT id, doc_type, number, title, status, file_path, created_at, updated_at + FROM documents WHERE doc_type = ?1 AND number = ?2", + params![doc_type.as_str(), number], + |row| { + Ok(Document { + id: Some(row.get(0)?), + doc_type: DocType::from_str(row.get::<_, String>(1)?.as_str()).unwrap(), + number: row.get(2)?, + title: row.get(3)?, + status: row.get(4)?, + file_path: row.get(5)?, + created_at: row.get(6)?, + updated_at: row.get(7)?, + }) + }, + ) + .map_err(|e| match e { + rusqlite::Error::QueryReturnedNoRows => { + StoreError::NotFound(format!("{} #{}", doc_type.as_str(), number)) + } + e => StoreError::Database(e), + }) + } + + /// Find a document using flexible matching + pub fn find_document(&self, doc_type: DocType, query: &str) -> Result { + // Try exact match first + if let Ok(doc) = self.get_document(doc_type, query) { + return Ok(doc); + } + + // Try number match + let trimmed = query.trim_start_matches('0'); + if let Ok(num) = if trimmed.is_empty() { + "0".parse() + } else { + trimmed.parse::() + } { + if let Ok(doc) = self.get_document_by_number(doc_type, num) { + return Ok(doc); + } + } + + // Try substring match + let pattern = format!("%{}%", query.to_lowercase()); + if let Ok(doc) = self.conn.query_row( + "SELECT id, doc_type, number, title, status, file_path, created_at, updated_at + FROM documents WHERE doc_type = ?1 AND LOWER(title) LIKE ?2 + ORDER BY LENGTH(title) ASC LIMIT 1", + params![doc_type.as_str(), pattern], + |row| { + Ok(Document { + id: Some(row.get(0)?), + doc_type: DocType::from_str(row.get::<_, String>(1)?.as_str()).unwrap(), + number: row.get(2)?, + title: row.get(3)?, + status: row.get(4)?, + file_path: row.get(5)?, + created_at: row.get(6)?, + updated_at: row.get(7)?, + }) + }, + ) { + return Ok(doc); + } + + Err(StoreError::NotFound(format!( + "{} matching '{}'", + doc_type.as_str(), + query + ))) + } + + /// Update a document's status + pub fn update_document_status( + &self, + doc_type: DocType, + title: &str, + status: &str, + ) -> Result<(), StoreError> { + self.with_retry(|| { + let now = chrono::Utc::now().to_rfc3339(); + let updated = self.conn.execute( + "UPDATE documents SET status = ?1, updated_at = ?2 WHERE doc_type = ?3 AND title = ?4", + params![status, now, doc_type.as_str(), title], + )?; + if updated == 0 { + return Err(StoreError::NotFound(title.to_string())); + } + Ok(()) + }) + } + + /// Update a document + pub fn update_document(&self, doc: &Document) -> Result<(), StoreError> { + let id = doc + .id + .ok_or_else(|| StoreError::InvalidOperation("Document has no ID".to_string()))?; + + self.with_retry(|| { + let now = chrono::Utc::now().to_rfc3339(); + let updated = self.conn.execute( + "UPDATE documents SET doc_type = ?1, number = ?2, title = ?3, status = ?4, + file_path = ?5, updated_at = ?6 WHERE id = ?7", + params![ + doc.doc_type.as_str(), + doc.number, + doc.title, + doc.status, + doc.file_path, + now, + id + ], + )?; + if updated == 0 { + return Err(StoreError::NotFound(format!("document #{}", id))); + } + Ok(()) + }) + } + + /// List all documents of a given type + pub fn list_documents(&self, doc_type: DocType) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + "SELECT id, doc_type, number, title, status, file_path, created_at, updated_at + FROM documents WHERE doc_type = ?1 ORDER BY number DESC, title ASC", + )?; + + let rows = stmt.query_map(params![doc_type.as_str()], |row| { + Ok(Document { + id: Some(row.get(0)?), + doc_type: DocType::from_str(row.get::<_, String>(1)?.as_str()).unwrap(), + number: row.get(2)?, + title: row.get(3)?, + status: row.get(4)?, + file_path: row.get(5)?, + created_at: row.get(6)?, + updated_at: row.get(7)?, + }) + })?; + + rows.collect::, _>>() + .map_err(StoreError::Database) + } + + /// List documents by status + pub fn list_documents_by_status( + &self, + doc_type: DocType, + status: &str, + ) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + "SELECT id, doc_type, number, title, status, file_path, created_at, updated_at + FROM documents WHERE doc_type = ?1 AND status = ?2 ORDER BY number DESC, title ASC", + )?; + + let rows = stmt.query_map(params![doc_type.as_str(), status], |row| { + Ok(Document { + id: Some(row.get(0)?), + doc_type: DocType::from_str(row.get::<_, String>(1)?.as_str()).unwrap(), + number: row.get(2)?, + title: row.get(3)?, + status: row.get(4)?, + file_path: row.get(5)?, + created_at: row.get(6)?, + updated_at: row.get(7)?, + }) + })?; + + rows.collect::, _>>() + .map_err(StoreError::Database) + } + + /// Delete a document + pub fn delete_document(&self, doc_type: DocType, title: &str) -> Result<(), StoreError> { + self.with_retry(|| { + let deleted = self.conn.execute( + "DELETE FROM documents WHERE doc_type = ?1 AND title = ?2", + params![doc_type.as_str(), title], + )?; + if deleted == 0 { + return Err(StoreError::NotFound(title.to_string())); + } + Ok(()) + }) + } + + /// Get the next document number for a type + pub fn next_number(&self, doc_type: DocType) -> Result { + let max: Option = self.conn.query_row( + "SELECT MAX(number) FROM documents WHERE doc_type = ?1", + params![doc_type.as_str()], + |row| row.get(0), + )?; + Ok(max.unwrap_or(0) + 1) + } + + // ==================== Link Operations ==================== + + /// Link two documents + pub fn link_documents( + &self, + source_id: i64, + target_id: i64, + link_type: LinkType, + ) -> Result<(), StoreError> { + self.with_retry(|| { + let now = chrono::Utc::now().to_rfc3339(); + self.conn.execute( + "INSERT OR IGNORE INTO document_links (source_id, target_id, link_type, created_at) + VALUES (?1, ?2, ?3, ?4)", + params![source_id, target_id, link_type.as_str(), now], + )?; + Ok(()) + }) + } + + /// Get linked documents + pub fn get_linked_documents( + &self, + source_id: i64, + link_type: Option, + ) -> Result, StoreError> { + let query = match link_type { + Some(lt) => format!( + "SELECT d.id, d.doc_type, d.number, d.title, d.status, d.file_path, d.created_at, d.updated_at + FROM documents d + JOIN document_links l ON l.target_id = d.id + WHERE l.source_id = ?1 AND l.link_type = '{}'", + lt.as_str() + ), + None => "SELECT d.id, d.doc_type, d.number, d.title, d.status, d.file_path, d.created_at, d.updated_at + FROM documents d + JOIN document_links l ON l.target_id = d.id + WHERE l.source_id = ?1".to_string(), + }; + + let mut stmt = self.conn.prepare(&query)?; + let rows = stmt.query_map(params![source_id], |row| { + Ok(Document { + id: Some(row.get(0)?), + doc_type: DocType::from_str(row.get::<_, String>(1)?.as_str()).unwrap(), + number: row.get(2)?, + title: row.get(3)?, + status: row.get(4)?, + file_path: row.get(5)?, + created_at: row.get(6)?, + updated_at: row.get(7)?, + }) + })?; + + rows.collect::, _>>() + .map_err(StoreError::Database) + } + + // ==================== Task Operations ==================== + + /// Set tasks for a document (replaces existing) + pub fn set_tasks(&self, document_id: i64, tasks: &[String]) -> Result<(), StoreError> { + self.with_retry(|| { + self.conn + .execute("DELETE FROM tasks WHERE document_id = ?1", params![document_id])?; + + for (idx, desc) in tasks.iter().enumerate() { + self.conn.execute( + "INSERT INTO tasks (document_id, task_index, description, completed) + VALUES (?1, ?2, ?3, 0)", + params![document_id, (idx + 1) as i32, desc], + )?; + } + + Ok(()) + }) + } + + /// Mark a task as complete + pub fn complete_task(&self, document_id: i64, task_index: i32) -> Result<(), StoreError> { + self.with_retry(|| { + let now = chrono::Utc::now().to_rfc3339(); + let updated = self.conn.execute( + "UPDATE tasks SET completed = 1, completed_at = ?1 + WHERE document_id = ?2 AND task_index = ?3", + params![now, document_id, task_index], + )?; + if updated == 0 { + return Err(StoreError::NotFound(format!( + "task {} in document #{}", + task_index, document_id + ))); + } + Ok(()) + }) + } + + /// Get task progress + pub fn get_task_progress(&self, document_id: i64) -> Result { + let (total, completed): (i64, i64) = self.conn.query_row( + "SELECT COUNT(*), COALESCE(SUM(completed), 0) FROM tasks WHERE document_id = ?1", + params![document_id], + |row| Ok((row.get(0)?, row.get(1)?)), + )?; + + let total = total as usize; + let completed = completed as usize; + let percentage = if total > 0 { + (completed * 100) / total + } else { + 0 + }; + + Ok(TaskProgress { + completed, + total, + percentage, + }) + } + + /// Get all tasks for a document + pub fn get_tasks(&self, document_id: i64) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + "SELECT id, document_id, task_index, description, completed, completed_at + FROM tasks WHERE document_id = ?1 ORDER BY task_index", + )?; + + let rows = stmt.query_map(params![document_id], |row| { + Ok(Task { + id: Some(row.get(0)?), + document_id: row.get(1)?, + task_index: row.get(2)?, + description: row.get(3)?, + completed: row.get::<_, i32>(4)? != 0, + completed_at: row.get(5)?, + }) + })?; + + rows.collect::, _>>() + .map_err(StoreError::Database) + } + + // ==================== Worktree Operations ==================== + + /// Add a worktree for a document + pub fn add_worktree(&self, worktree: &Worktree) -> Result { + self.with_retry(|| { + let now = chrono::Utc::now().to_rfc3339(); + self.conn.execute( + "INSERT INTO worktrees (document_id, branch_name, worktree_path, created_at) + VALUES (?1, ?2, ?3, ?4)", + params![ + worktree.document_id, + worktree.branch_name, + worktree.worktree_path, + now + ], + )?; + Ok(self.conn.last_insert_rowid()) + }) + } + + /// Get worktree for a document + pub fn get_worktree(&self, document_id: i64) -> Result, StoreError> { + self.conn + .query_row( + "SELECT id, document_id, branch_name, worktree_path, created_at + FROM worktrees WHERE document_id = ?1", + params![document_id], + |row| { + Ok(Worktree { + id: Some(row.get(0)?), + document_id: row.get(1)?, + branch_name: row.get(2)?, + worktree_path: row.get(3)?, + created_at: row.get(4)?, + }) + }, + ) + .optional() + .map_err(StoreError::Database) + } + + /// Remove a worktree + pub fn remove_worktree(&self, document_id: i64) -> Result<(), StoreError> { + self.with_retry(|| { + self.conn.execute( + "DELETE FROM worktrees WHERE document_id = ?1", + params![document_id], + )?; + Ok(()) + }) + } + + /// List all worktrees + pub fn list_worktrees(&self) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + "SELECT id, document_id, branch_name, worktree_path, created_at FROM worktrees", + )?; + + let rows = stmt.query_map([], |row| { + Ok(Worktree { + id: Some(row.get(0)?), + document_id: row.get(1)?, + branch_name: row.get(2)?, + worktree_path: row.get(3)?, + created_at: row.get(4)?, + }) + })?; + + rows.collect::, _>>() + .map_err(StoreError::Database) + } + + // ==================== Search Operations ==================== + + /// Search documents using FTS5 + pub fn search_documents( + &self, + query: &str, + doc_type: Option, + limit: usize, + ) -> Result, StoreError> { + let escaped = query.replace('"', "\"\""); + let fts_query = format!("\"{}\"*", escaped); + + let sql = match doc_type { + Some(dt) => format!( + "SELECT d.id, d.doc_type, d.number, d.title, d.status, d.file_path, + d.created_at, d.updated_at, bm25(documents_fts) as score + FROM documents_fts fts + JOIN documents d ON d.id = fts.rowid + WHERE documents_fts MATCH ?1 AND d.doc_type = '{}' + ORDER BY score + LIMIT ?2", + dt.as_str() + ), + None => "SELECT d.id, d.doc_type, d.number, d.title, d.status, d.file_path, + d.created_at, d.updated_at, bm25(documents_fts) as score + FROM documents_fts fts + JOIN documents d ON d.id = fts.rowid + WHERE documents_fts MATCH ?1 + ORDER BY score + LIMIT ?2" + .to_string(), + }; + + let mut stmt = self.conn.prepare(&sql)?; + let rows = stmt.query_map(params![fts_query, limit as i32], |row| { + Ok(SearchResult { + document: Document { + id: Some(row.get(0)?), + doc_type: DocType::from_str(row.get::<_, String>(1)?.as_str()).unwrap(), + number: row.get(2)?, + title: row.get(3)?, + status: row.get(4)?, + file_path: row.get(5)?, + created_at: row.get(6)?, + updated_at: row.get(7)?, + }, + score: row.get(8)?, + snippet: None, + }) + })?; + + rows.collect::, _>>() + .map_err(StoreError::Database) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_and_find_document() { + let store = DocumentStore::open_in_memory().unwrap(); + + let doc = Document::new(DocType::Rfc, "test-feature", "draft"); + let id = store.add_document(&doc).unwrap(); + + let found = store.find_document(DocType::Rfc, "test-feature").unwrap(); + assert_eq!(found.id, Some(id)); + assert_eq!(found.title, "test-feature"); + } + + #[test] + fn test_task_progress() { + let store = DocumentStore::open_in_memory().unwrap(); + + let doc = Document::new(DocType::Rfc, "task-test", "draft"); + let id = store.add_document(&doc).unwrap(); + + store + .set_tasks(id, &["Task 1".into(), "Task 2".into(), "Task 3".into()]) + .unwrap(); + + let progress = store.get_task_progress(id).unwrap(); + assert_eq!(progress.total, 3); + assert_eq!(progress.completed, 0); + assert_eq!(progress.percentage, 0); + + store.complete_task(id, 1).unwrap(); + + let progress = store.get_task_progress(id).unwrap(); + assert_eq!(progress.completed, 1); + assert_eq!(progress.percentage, 33); + } +} diff --git a/crates/blue-core/src/workflow.rs b/crates/blue-core/src/workflow.rs new file mode 100644 index 0000000..39af0fb --- /dev/null +++ b/crates/blue-core/src/workflow.rs @@ -0,0 +1,246 @@ +//! Workflow transitions for Blue documents +//! +//! Status management and validation for RFCs, Spikes, and other documents. + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// RFC status values +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum RfcStatus { + /// Initial proposal, still being refined + Draft, + /// Approved, ready to implement + Accepted, + /// Work has started + InProgress, + /// Work is complete + Implemented, + /// Replaced by a newer RFC + Superseded, +} + +impl RfcStatus { + pub fn as_str(&self) -> &'static str { + match self { + RfcStatus::Draft => "draft", + RfcStatus::Accepted => "accepted", + RfcStatus::InProgress => "in-progress", + RfcStatus::Implemented => "implemented", + RfcStatus::Superseded => "superseded", + } + } + + pub fn parse(s: &str) -> Result { + match s.to_lowercase().as_str() { + "draft" => Ok(RfcStatus::Draft), + "accepted" => Ok(RfcStatus::Accepted), + "in-progress" | "in_progress" | "inprogress" => Ok(RfcStatus::InProgress), + "implemented" => Ok(RfcStatus::Implemented), + "superseded" => Ok(RfcStatus::Superseded), + _ => Err(WorkflowError::InvalidStatus(s.to_string())), + } + } + + /// Check if transition to the given status is valid + pub fn can_transition_to(&self, target: RfcStatus) -> bool { + matches!( + (self, target), + // Normal forward flow + (RfcStatus::Draft, RfcStatus::Accepted) + | (RfcStatus::Accepted, RfcStatus::InProgress) + | (RfcStatus::InProgress, RfcStatus::Implemented) + // Can supersede from any active state + | (RfcStatus::Draft, RfcStatus::Superseded) + | (RfcStatus::Accepted, RfcStatus::Superseded) + | (RfcStatus::InProgress, RfcStatus::Superseded) + // Can go back to draft if needed + | (RfcStatus::Accepted, RfcStatus::Draft) + ) + } + + /// Get allowed transitions from current status + pub fn allowed_transitions(&self) -> Vec { + match self { + RfcStatus::Draft => vec![RfcStatus::Accepted, RfcStatus::Superseded], + RfcStatus::Accepted => { + vec![RfcStatus::InProgress, RfcStatus::Draft, RfcStatus::Superseded] + } + RfcStatus::InProgress => vec![RfcStatus::Implemented, RfcStatus::Superseded], + RfcStatus::Implemented => vec![], + RfcStatus::Superseded => vec![], + } + } +} + +/// Spike outcome values +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SpikeOutcome { + /// Investigation showed this isn't worth pursuing + NoAction, + /// Learned enough to make a decision + DecisionMade, + /// Should build something (requires RFC) + RecommendsImplementation, +} + +impl SpikeOutcome { + pub fn as_str(&self) -> &'static str { + match self { + SpikeOutcome::NoAction => "no-action", + SpikeOutcome::DecisionMade => "decision-made", + SpikeOutcome::RecommendsImplementation => "recommends-implementation", + } + } + + pub fn parse(s: &str) -> Result { + match s.to_lowercase().replace('_', "-").as_str() { + "no-action" => Ok(SpikeOutcome::NoAction), + "decision-made" => Ok(SpikeOutcome::DecisionMade), + "recommends-implementation" => Ok(SpikeOutcome::RecommendsImplementation), + _ => Err(WorkflowError::InvalidOutcome(s.to_string())), + } + } +} + +/// Spike status values +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum SpikeStatus { + /// Investigation in progress + InProgress, + /// Investigation complete + Completed, +} + +impl SpikeStatus { + pub fn as_str(&self) -> &'static str { + match self { + SpikeStatus::InProgress => "in-progress", + SpikeStatus::Completed => "completed", + } + } + + pub fn parse(s: &str) -> Result { + match s.to_lowercase().replace('_', "-").as_str() { + "in-progress" => Ok(SpikeStatus::InProgress), + "completed" => Ok(SpikeStatus::Completed), + _ => Err(WorkflowError::InvalidStatus(s.to_string())), + } + } +} + +/// PRD status values +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum PrdStatus { + /// Initial requirements, still being refined + Draft, + /// Stakeholders have signed off + Approved, + /// All requirements implemented + Implemented, +} + +impl PrdStatus { + pub fn as_str(&self) -> &'static str { + match self { + PrdStatus::Draft => "draft", + PrdStatus::Approved => "approved", + PrdStatus::Implemented => "implemented", + } + } + + pub fn parse(s: &str) -> Result { + match s.to_lowercase().as_str() { + "draft" => Ok(PrdStatus::Draft), + "approved" => Ok(PrdStatus::Approved), + "implemented" => Ok(PrdStatus::Implemented), + _ => Err(WorkflowError::InvalidStatus(s.to_string())), + } + } + + pub fn can_transition_to(&self, target: PrdStatus) -> bool { + matches!( + (self, target), + (PrdStatus::Draft, PrdStatus::Approved) | (PrdStatus::Approved, PrdStatus::Implemented) + ) + } +} + +/// Workflow errors +#[derive(Debug, Error)] +pub enum WorkflowError { + #[error("'{0}' isn't a valid status. Try: draft, accepted, in-progress, implemented")] + InvalidStatus(String), + + #[error("'{0}' isn't a valid outcome. Try: no-action, decision-made, recommends-implementation")] + InvalidOutcome(String), + + #[error("Can't go from {from} to {to}. {hint}")] + InvalidTransition { + from: String, + to: String, + hint: String, + }, +} + +/// Validate an RFC status transition +pub fn validate_rfc_transition(from: RfcStatus, to: RfcStatus) -> Result<(), WorkflowError> { + if from.can_transition_to(to) { + Ok(()) + } else { + let hint = match (from, to) { + (RfcStatus::Draft, RfcStatus::InProgress) => { + "Accept it first, then start work".to_string() + } + (RfcStatus::Implemented, _) => { + "Already implemented. Create a new RFC for changes".to_string() + } + (RfcStatus::Superseded, _) => "This RFC has been superseded".to_string(), + _ => format!("From {} you can go to: {:?}", from.as_str(), from.allowed_transitions()), + }; + + Err(WorkflowError::InvalidTransition { + from: from.as_str().to_string(), + to: to.as_str().to_string(), + hint, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rfc_transitions() { + assert!(RfcStatus::Draft.can_transition_to(RfcStatus::Accepted)); + assert!(RfcStatus::Accepted.can_transition_to(RfcStatus::InProgress)); + assert!(RfcStatus::InProgress.can_transition_to(RfcStatus::Implemented)); + + assert!(!RfcStatus::Draft.can_transition_to(RfcStatus::InProgress)); + assert!(!RfcStatus::Implemented.can_transition_to(RfcStatus::Draft)); + } + + #[test] + fn test_parse_status() { + assert_eq!(RfcStatus::parse("draft").unwrap(), RfcStatus::Draft); + assert_eq!(RfcStatus::parse("in-progress").unwrap(), RfcStatus::InProgress); + assert_eq!(RfcStatus::parse("IN_PROGRESS").unwrap(), RfcStatus::InProgress); + } + + #[test] + fn test_spike_outcome_parse() { + assert_eq!( + SpikeOutcome::parse("no-action").unwrap(), + SpikeOutcome::NoAction + ); + assert_eq!( + SpikeOutcome::parse("recommends-implementation").unwrap(), + SpikeOutcome::RecommendsImplementation + ); + } +} diff --git a/crates/blue-mcp/src/server.rs b/crates/blue-mcp/src/server.rs index 07f7ddc..76dd91a 100644 --- a/crates/blue-mcp/src/server.rs +++ b/crates/blue-mcp/src/server.rs @@ -2,21 +2,48 @@ //! //! Handles JSON-RPC requests and routes to appropriate tool handlers. +use std::path::PathBuf; + use serde::Deserialize; use serde_json::{json, Value}; use tracing::{debug, info}; +use blue_core::{detect_blue, DocType, Document, ProjectState, Rfc}; + use crate::error::ServerError; /// Blue MCP Server state pub struct BlueServer { /// Current working directory - cwd: Option, + cwd: Option, + /// Cached project state + state: Option, } impl BlueServer { pub fn new() -> Self { - Self { cwd: None } + Self { + cwd: None, + state: None, + } + } + + /// Try to load project state for the current directory + fn ensure_state(&mut self) -> Result<&ProjectState, ServerError> { + if self.state.is_none() { + let cwd = self.cwd.as_ref().ok_or(ServerError::BlueNotDetected)?; + let home = detect_blue(cwd).map_err(|_| ServerError::BlueNotDetected)?; + + // Try to get project name from the current path + let project = home.project_name.clone().unwrap_or_else(|| "default".to_string()); + + let state = ProjectState::load(home, &project) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + self.state = Some(state); + } + + self.state.as_ref().ok_or(ServerError::BlueNotDetected) } /// Handle a JSON-RPC request @@ -90,7 +117,7 @@ impl BlueServer { "tools": [ { "name": "blue_status", - "description": "Get project status. Returns active work, ready items, and recommendations.", + "description": "Get project status. Returns active work, ready items, stalled items, and recommendations.", "inputSchema": { "type": "object", "properties": { @@ -127,10 +154,149 @@ impl BlueServer { "title": { "type": "string", "description": "RFC title in kebab-case" + }, + "problem": { + "type": "string", + "description": "Problem statement or summary" + }, + "source_spike": { + "type": "string", + "description": "Source spike title that led to this RFC" } }, "required": ["title"] } + }, + { + "name": "blue_rfc_get", + "description": "Get an RFC by title or number.", + "inputSchema": { + "type": "object", + "properties": { + "cwd": { + "type": "string", + "description": "Current working directory" + }, + "title": { + "type": "string", + "description": "RFC title or number" + } + }, + "required": ["title"] + } + }, + { + "name": "blue_rfc_update_status", + "description": "Update an RFC's status (draft -> accepted -> in-progress -> implemented).", + "inputSchema": { + "type": "object", + "properties": { + "cwd": { + "type": "string", + "description": "Current working directory" + }, + "title": { + "type": "string", + "description": "RFC title" + }, + "status": { + "type": "string", + "description": "New status: accepted, in-progress, implemented, or superseded", + "enum": ["accepted", "in-progress", "implemented", "superseded"] + } + }, + "required": ["title", "status"] + } + }, + { + "name": "blue_rfc_plan", + "description": "Create or update an implementation plan with checkboxes for an RFC.", + "inputSchema": { + "type": "object", + "properties": { + "cwd": { + "type": "string", + "description": "Current working directory" + }, + "title": { + "type": "string", + "description": "RFC title" + }, + "tasks": { + "type": "array", + "items": { "type": "string" }, + "description": "List of implementation tasks" + } + }, + "required": ["title", "tasks"] + } + }, + { + "name": "blue_rfc_task_complete", + "description": "Mark a task as complete in an RFC plan.", + "inputSchema": { + "type": "object", + "properties": { + "cwd": { + "type": "string", + "description": "Current working directory" + }, + "title": { + "type": "string", + "description": "RFC title" + }, + "task": { + "type": "string", + "description": "Task index (1-based) or substring to match" + } + }, + "required": ["title", "task"] + } + }, + { + "name": "blue_rfc_validate", + "description": "Check RFC status and plan completion.", + "inputSchema": { + "type": "object", + "properties": { + "cwd": { + "type": "string", + "description": "Current working directory" + }, + "title": { + "type": "string", + "description": "RFC title" + } + }, + "required": ["title"] + } + }, + { + "name": "blue_search", + "description": "Search documents using full-text search.", + "inputSchema": { + "type": "object", + "properties": { + "cwd": { + "type": "string", + "description": "Current working directory" + }, + "query": { + "type": "string", + "description": "Search query" + }, + "doc_type": { + "type": "string", + "description": "Filter by document type", + "enum": ["rfc", "spike", "adr", "decision"] + }, + "limit": { + "type": "number", + "description": "Maximum results to return (default: 10)" + } + }, + "required": ["query"] + } } ] })) @@ -144,7 +310,9 @@ impl BlueServer { // Extract cwd from arguments if present if let Some(ref args) = call.arguments { if let Some(cwd) = args.get("cwd").and_then(|v| v.as_str()) { - self.cwd = Some(std::path::PathBuf::from(cwd)); + self.cwd = Some(PathBuf::from(cwd)); + // Reset state when cwd changes + self.state = None; } } @@ -152,6 +320,12 @@ impl BlueServer { "blue_status" => self.handle_status(&call.arguments), "blue_next" => self.handle_next(&call.arguments), "blue_rfc_create" => self.handle_rfc_create(&call.arguments), + "blue_rfc_get" => self.handle_rfc_get(&call.arguments), + "blue_rfc_update_status" => self.handle_rfc_update_status(&call.arguments), + "blue_rfc_plan" => self.handle_rfc_plan(&call.arguments), + "blue_rfc_task_complete" => self.handle_rfc_task_complete(&call.arguments), + "blue_rfc_validate" => self.handle_rfc_validate(&call.arguments), + "blue_search" => self.handle_search(&call.arguments), _ => Err(ServerError::ToolNotFound(call.name)), }?; @@ -164,35 +338,380 @@ impl BlueServer { })) } - fn handle_status(&self, _args: &Option) -> Result { - Ok(json!({ - "status": "success", - "message": blue_core::voice::speak("Checking status. Give me a moment.") - })) + fn handle_status(&mut self, _args: &Option) -> Result { + match self.ensure_state() { + Ok(state) => { + let summary = state.status_summary(); + Ok(json!({ + "active": summary.active, + "ready": summary.ready, + "stalled": summary.stalled, + "drafts": summary.drafts, + "hint": summary.hint + })) + } + Err(_) => { + // Fall back to a simple message if not in a Blue project + Ok(json!({ + "message": blue_core::voice::error( + "Can't find Blue here", + "Run 'blue init' to set up this project" + ), + "active": [], + "ready": [], + "stalled": [], + "drafts": [] + })) + } + } } - fn handle_next(&self, _args: &Option) -> Result { - Ok(json!({ - "status": "success", - "message": blue_core::voice::speak("Looking at what's ready. One moment.") - })) + fn handle_next(&mut self, _args: &Option) -> Result { + match self.ensure_state() { + Ok(state) => { + let summary = state.status_summary(); + + let recommendations = if !summary.stalled.is_empty() { + vec![format!( + "'{}' might be stalled. Check if work is still in progress.", + summary.stalled[0].title + )] + } else if !summary.ready.is_empty() { + vec![format!( + "'{}' is ready to implement. Run 'blue worktree create {}' to start.", + summary.ready[0].title, summary.ready[0].title + )] + } else if !summary.drafts.is_empty() { + vec![format!( + "'{}' is in draft. Review and accept it when ready.", + summary.drafts[0].title + )] + } else if !summary.active.is_empty() { + vec![format!( + "{} item(s) in progress. Keep at it.", + summary.active.len() + )] + } else { + vec!["Nothing pressing. Good time to plan something new.".to_string()] + }; + + Ok(json!({ + "recommendations": recommendations, + "hint": summary.hint + })) + } + Err(_) => { + Ok(json!({ + "recommendations": [ + "Run 'blue init' to set up this project first." + ], + "hint": "Can't find Blue here." + })) + } + } } - fn handle_rfc_create(&self, args: &Option) -> Result { + fn handle_rfc_create(&mut self, args: &Option) -> Result { + let args = args.as_ref().ok_or(ServerError::InvalidParams)?; + let title = args - .as_ref() - .and_then(|a| a.get("title")) + .get("title") .and_then(|v| v.as_str()) .ok_or(ServerError::InvalidParams)?; + let problem = args.get("problem").and_then(|v| v.as_str()); + let source_spike = args.get("source_spike").and_then(|v| v.as_str()); + + match self.ensure_state() { + Ok(state) => { + // Get next RFC number + let number = state.store.next_number(DocType::Rfc) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + // Create document in store + let mut doc = Document::new(DocType::Rfc, title, "draft"); + doc.number = Some(number); + + let id = state.store.add_document(&doc) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + // Generate markdown + let mut rfc = Rfc::new(title); + if let Some(p) = problem { + rfc.problem = Some(p.to_string()); + } + if let Some(s) = source_spike { + rfc.source_spike = Some(s.to_string()); + } + + let markdown = rfc.to_markdown(number as u32); + + Ok(json!({ + "status": "success", + "id": id, + "number": number, + "title": title, + "markdown": markdown, + "message": blue_core::voice::success( + &format!("Created RFC {:04}: '{}'", number, title), + Some("Want me to help fill in the details?") + ) + })) + } + Err(_) => { + // Create RFC without persistence (just generate markdown) + let rfc = Rfc::new(title); + let markdown = rfc.to_markdown(1); + + Ok(json!({ + "status": "success", + "number": 1, + "title": title, + "markdown": markdown, + "message": blue_core::voice::success( + &format!("Created RFC '{}'", title), + Some("Note: Not persisted - run 'blue init' to enable storage.") + ) + })) + } + } + } + + fn handle_rfc_get(&mut self, args: &Option) -> Result { + let args = args.as_ref().ok_or(ServerError::InvalidParams)?; + + let title = args + .get("title") + .and_then(|v| v.as_str()) + .ok_or(ServerError::InvalidParams)?; + + let state = self.ensure_state()?; + + let doc = state.store.find_document(DocType::Rfc, title) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + // Get tasks if any + 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 { + state.store.get_task_progress(id).ok() + } else { + None + }; + + Ok(json!({ + "id": doc.id, + "number": doc.number, + "title": doc.title, + "status": doc.status, + "file_path": doc.file_path, + "created_at": doc.created_at, + "updated_at": doc.updated_at, + "tasks": tasks.iter().map(|t| json!({ + "index": t.task_index, + "description": t.description, + "completed": t.completed + })).collect::>(), + "progress": progress.map(|p| json!({ + "completed": p.completed, + "total": p.total, + "percentage": p.percentage + })) + })) + } + + fn handle_rfc_update_status(&mut self, args: &Option) -> Result { + let args = args.as_ref().ok_or(ServerError::InvalidParams)?; + + let title = args + .get("title") + .and_then(|v| v.as_str()) + .ok_or(ServerError::InvalidParams)?; + + let status = args + .get("status") + .and_then(|v| v.as_str()) + .ok_or(ServerError::InvalidParams)?; + + let state = self.ensure_state()?; + + state.store.update_document_status(DocType::Rfc, title, status) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + Ok(json!({ "status": "success", + "title": title, + "new_status": status, "message": blue_core::voice::success( - &format!("Created RFC '{}'", title), - Some("Want me to help fill in the details?"), + &format!("Updated '{}' to {}", title, status), + None ) })) } + + fn handle_rfc_plan(&mut self, args: &Option) -> Result { + let args = args.as_ref().ok_or(ServerError::InvalidParams)?; + + let title = args + .get("title") + .and_then(|v| v.as_str()) + .ok_or(ServerError::InvalidParams)?; + + let tasks: Vec = args + .get("tasks") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) + .unwrap_or_default(); + + let state = self.ensure_state()?; + + let doc = state.store.find_document(DocType::Rfc, title) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + let doc_id = doc.id.ok_or(ServerError::InvalidParams)?; + + state.store.set_tasks(doc_id, &tasks) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + Ok(json!({ + "status": "success", + "title": title, + "task_count": tasks.len(), + "message": blue_core::voice::success( + &format!("Set {} tasks for '{}'", tasks.len(), title), + Some("Mark them complete as you go with blue_rfc_task_complete.") + ) + })) + } + + fn handle_rfc_task_complete(&mut self, args: &Option) -> Result { + let args = args.as_ref().ok_or(ServerError::InvalidParams)?; + + let title = args + .get("title") + .and_then(|v| v.as_str()) + .ok_or(ServerError::InvalidParams)?; + + let task = args + .get("task") + .and_then(|v| v.as_str()) + .ok_or(ServerError::InvalidParams)?; + + let state = self.ensure_state()?; + + let doc = state.store.find_document(DocType::Rfc, title) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + let doc_id = doc.id.ok_or(ServerError::InvalidParams)?; + + // 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()))?; + + 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()))?; + + let progress = state.store.get_task_progress(doc_id) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + Ok(json!({ + "status": "success", + "title": title, + "task_index": task_index, + "progress": { + "completed": progress.completed, + "total": progress.total, + "percentage": progress.percentage + }, + "message": blue_core::voice::success( + &format!("Task {} complete. {} of {} done ({}%)", + task_index, progress.completed, progress.total, progress.percentage), + None + ) + })) + } + + fn handle_rfc_validate(&mut self, args: &Option) -> Result { + let args = args.as_ref().ok_or(ServerError::InvalidParams)?; + + let title = args + .get("title") + .and_then(|v| v.as_str()) + .ok_or(ServerError::InvalidParams)?; + + let state = self.ensure_state()?; + + let doc = state.store.find_document(DocType::Rfc, title) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + let doc_id = doc.id.ok_or(ServerError::InvalidParams)?; + + let progress = state.store.get_task_progress(doc_id) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + let message = if progress.total == 0 { + "No plan defined yet. Use blue_rfc_plan to add tasks.".to_string() + } else if progress.percentage == 100 { + format!("All {} tasks complete. Ready to mark as implemented.", progress.total) + } else if progress.percentage >= 70 { + format!("{}% done ({}/{}). Getting close.", progress.percentage, progress.completed, progress.total) + } else { + format!("{}% done ({}/{}). Keep going.", progress.percentage, progress.completed, progress.total) + }; + + Ok(json!({ + "title": doc.title, + "status": doc.status, + "progress": { + "completed": progress.completed, + "total": progress.total, + "percentage": progress.percentage + }, + "message": message + })) + } + + fn handle_search(&mut self, args: &Option) -> Result { + let args = args.as_ref().ok_or(ServerError::InvalidParams)?; + + let query = args + .get("query") + .and_then(|v| v.as_str()) + .ok_or(ServerError::InvalidParams)?; + + let doc_type = args.get("doc_type").and_then(|v| v.as_str()).and_then(DocType::from_str); + let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(10) as usize; + + let state = self.ensure_state()?; + + let results = state.store.search_documents(query, doc_type, limit) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + Ok(json!({ + "query": query, + "count": results.len(), + "results": results.iter().map(|r| json!({ + "title": r.document.title, + "type": r.document.doc_type.as_str(), + "status": r.document.status, + "score": r.score + })).collect::>() + })) + } } impl Default for BlueServer { diff --git a/docs/rfcs/0002-port-coherence-functionality.md b/docs/rfcs/0002-port-coherence-functionality.md new file mode 100644 index 0000000..c03af84 --- /dev/null +++ b/docs/rfcs/0002-port-coherence-functionality.md @@ -0,0 +1,214 @@ +# RFC 0002: Port Coherence Functionality + +| | | +|---|---| +| **Status** | In Progress | +| **Date** | 2026-01-24 | +| **Source Spike** | blue-feature-gap-analysis | + +--- + +## Summary + +Port the essential functionality from coherence-mcp to blue, maintaining Blue's voice and philosophy while gaining the workflow capabilities that make coherence useful. + +## Problem + +Blue currently has ~2% of coherence-mcp's functionality: +- 3 MCP tools (vs 35) +- 0 handler modules (vs 28) +- ~50 LOC core (vs 8,796) +- 0 database tables (vs 14) + +Without these capabilities, blue can't manage RFCs, track work, or provide meaningful project status. + +## Proposal + +Port coherence-mcp in 4 phases, adapting to Blue's voice throughout. + +### Phase 1: Foundation + +**Goal:** Basic document management and persistence. + +| Component | Source | Target | Notes | +|-----------|--------|--------|-------| +| store.rs | alignment-core | blue-core | Rename tables, Blue voice in errors | +| documents.rs | alignment-core | blue-core | Keep existing Blue structs, add methods | +| state.rs | alignment-core | blue-core | ProjectState → BlueState | +| repo.rs | alignment-core | blue-core | detect_alignment → detect_blue | +| workflow.rs | alignment-core | blue-core | Status transitions | + +**Database schema:** +- documents, document_links, tasks, worktrees, metadata +- FTS5 for search +- Schema version 1 (fresh start, not migrating from coherence) + +**MCP tools (Phase 1):** +- blue_status +- blue_next +- blue_rfc_create +- blue_rfc_get +- blue_rfc_update_status +- blue_rfc_plan +- blue_rfc_validate +- blue_rfc_complete +- blue_rfc_task_complete + +### Phase 2: Workflow + +**Goal:** Full RFC/Spike/ADR lifecycle + PR workflow. + +| Component | Source | Target | +|-----------|--------|--------| +| rfc.rs | handlers | blue-mcp/handlers | +| spike.rs | handlers | blue-mcp/handlers | +| adr.rs | handlers | blue-mcp/handlers | +| decision.rs | handlers | blue-mcp/handlers | +| worktree.rs | handlers | blue-mcp/handlers | +| pr.rs | handlers | blue-mcp/handlers | +| release.rs | handlers | blue-mcp/handlers | +| search.rs | handlers | blue-mcp/handlers | + +**MCP tools (Phase 2):** +- blue_spike_create, blue_spike_complete +- blue_adr_create, blue_decision_create +- blue_worktree_create, blue_worktree_list, blue_worktree_remove +- blue_pr_create, blue_pr_verify, blue_pr_check_item, blue_pr_merge +- blue_release_create +- blue_search + +### Phase 3: Advanced + +**Goal:** Multi-agent coordination and reminders. + +| Component | Source | Target | +|-----------|--------|--------| +| staging.rs | handlers | blue-mcp/handlers | +| reminder.rs | handlers | blue-mcp/handlers | +| session.rs | handlers | blue-mcp/handlers | +| env.rs | handlers | blue-mcp/handlers | + +**Database additions:** +- staging_locks, staging_lock_queue, staging_deployments +- active_sessions +- reminders + +### Phase 4: Specialized + +**Goal:** Code intelligence and quality tools. + +| Component | Source | Target | +|-----------|--------|--------| +| code_store.rs | alignment-core | blue-core | +| symbol_extractor.rs | alignment-core | blue-core | +| lint.rs | handlers | blue-mcp/handlers | +| audit.rs | handlers | blue-mcp/handlers | +| guide.rs | handlers | blue-mcp/handlers | + +## What NOT to Port + +- **Parked/Gated items** - Half-implemented in coherence, skip for now +- **Post-mortems/Runbooks** - Low usage, add later if needed +- **Dialogue tools** - Specialized, port only if needed +- **Infrastructure indexing** - Complex, defer to Phase 5 + +## Blue's Voice Adaptation + +All ported code must speak as Blue: + +```rust +// Coherence style +return Err(ServerError::AlignmentNotDetected); + +// Blue style +return Err(ServerError::NotHome("Can't find Blue here. Run 'blue init' first?")); +``` + +```rust +// Coherence message +"RFC created successfully" + +// Blue message +"Created RFC '{}'. Want me to help fill in the details?" +``` + +## Directory Structure After Port + +``` +blue/ +├── crates/ +│ └── blue-core/ +│ ├── src/ +│ │ ├── lib.rs +│ │ ├── documents.rs # Document types +│ │ ├── store.rs # SQLite persistence +│ │ ├── state.rs # Project state +│ │ ├── repo.rs # Git operations +│ │ ├── workflow.rs # Status transitions +│ │ ├── voice.rs # Blue's tone (existing) +│ │ └── search.rs # FTS5 search +│ └── Cargo.toml +│ └── blue-mcp/ +│ ├── src/ +│ │ ├── lib.rs +│ │ ├── server.rs # MCP server +│ │ ├── error.rs # Error types +│ │ ├── tools.rs # Tool definitions +│ │ └── handlers/ +│ │ ├── mod.rs +│ │ ├── rfc.rs +│ │ ├── spike.rs +│ │ ├── adr.rs +│ │ ├── worktree.rs +│ │ ├── pr.rs +│ │ ├── search.rs +│ │ └── ... +│ └── Cargo.toml +└── apps/ + └── blue-cli/ +``` + +## Goals + +1. Feature parity with coherence-mcp core workflow +2. Blue's voice and philosophy throughout +3. Fresh schema (no migration baggage) +4. Cleaner code structure from lessons learned + +## Non-Goals + +1. 100% feature parity (skip rarely-used features) +2. Backward compatibility with coherence databases +3. Supporting both alignment_ and blue_ tool names + +## Implementation Progress + +### Phase 1: Foundation - COMPLETE + +- [x] store.rs - SQLite persistence with schema v1, WAL mode, FTS5 search +- [x] documents.rs - Rfc, Spike, Adr, Decision with markdown generation +- [x] state.rs - ProjectState with active/ready/stalled/draft items +- [x] repo.rs - detect_blue(), worktree operations +- [x] workflow.rs - RfcStatus, SpikeOutcome, transitions +- [x] 9 MCP tools: blue_status, blue_next, blue_rfc_create, blue_rfc_get, + blue_rfc_update_status, blue_rfc_plan, blue_rfc_validate, + blue_rfc_task_complete, blue_search +- [x] 14 unit tests passing +- [x] Blue's voice in all error messages + +### Phase 2-4: Pending + +## Test Plan + +- [ ] blue init creates .blue/ directory structure +- [x] blue rfc create persists to SQLite +- [x] blue status shows active/ready/stalled items +- [x] blue search finds documents by keyword +- [x] Blue's voice in all error messages +- [ ] Worktree operations work with git + +--- + +*"Right then. Quite a bit to port. But we'll take it step by step."* + +— Blue