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 <noreply@anthropic.com>
This commit is contained in:
Eric Garcia 2026-01-24 00:43:25 -05:00
parent 940701d191
commit 3e157d76a6
10 changed files with 2864 additions and 24 deletions

View file

@ -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" }

View file

@ -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

View file

@ -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<String>,
pub source_spike: Option<String>,
pub source_prd: Option<String>,
pub problem: Option<String>,
pub proposal: Option<String>,
pub goals: Vec<String>,
pub non_goals: Vec<String>,
pub plan: Vec<Task>,
}
@ -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<String>,
pub question: String,
pub outcome: Option<SpikeOutcome>,
pub summary: Option<String>,
pub findings: Option<String>,
pub recommendation: Option<String>,
}
/// 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<String>,
pub alternatives: Vec<String>,
@ -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<String>,
pub context: String,
pub decision: String,
pub consequences: Vec<String>,
@ -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<String>, question: impl Into<String>) -> 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<String>) -> 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<String>, decision: impl Into<String>) -> 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::<Vec<_>>()
.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?"));
}
}

View file

@ -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};

View file

@ -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<String>,
}
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<String> {
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<BlueHome, RepoError> {
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(&current),
});
}
// 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(&current),
});
}
if !current.pop() {
break;
}
}
Err(RepoError::NotHome)
}
/// Extract project name from git remote or directory name
fn extract_project_name(path: &Path) -> Option<String> {
// 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<String> {
// 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<WorktreeInfo> {
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<bool, RepoError> {
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);
}
}

View file

@ -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<WorktreeInfo>,
/// Set of RFC titles with active worktrees
worktree_rfcs: HashSet<String>,
/// Project name
pub project: String,
}
impl ProjectState {
/// Load project state
pub fn load(home: BlueHome, project: &str) -> Result<Self, StateError> {
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<String> =
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<WorktreeInfo> {
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<WorkItem> {
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<WorkItem> {
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<WorkItem> {
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<WorkItem> {
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<WorkItem>,
pub ready: Vec<WorkItem>,
pub stalled: Vec<WorkItem>,
pub drafts: Vec<WorkItem>,
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
}
}

View file

@ -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<Self> {
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<i64>,
pub doc_type: DocType,
pub number: Option<i32>,
pub title: String,
pub status: String,
pub file_path: Option<String>,
pub created_at: Option<String>,
pub updated_at: Option<String>,
}
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<i64>,
pub document_id: i64,
pub task_index: i32,
pub description: String,
pub completed: bool,
pub completed_at: Option<String>,
}
/// 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<i64>,
pub document_id: i64,
pub branch_name: String,
pub worktree_path: String,
pub created_at: Option<String>,
}
/// Search result with relevance score
#[derive(Debug, Clone)]
pub struct SearchResult {
pub document: Document,
pub score: f64,
pub snippet: Option<String>,
}
/// 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", &"<Connection>")
.finish()
}
}
impl DocumentStore {
/// Open or create a document store
pub fn open(path: &Path) -> Result<Self, StoreError> {
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<Self, StoreError> {
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<i32> = 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<F, T>(&self, f: F) -> Result<T, StoreError>
where
F: Fn() -> Result<T, StoreError>,
{
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<Transaction<'_>, StoreError> {
Ok(self
.conn
.transaction_with_behavior(TransactionBehavior::Immediate)?)
}
// ==================== Document Operations ====================
/// Add a new document
pub fn add_document(&self, doc: &Document) -> Result<i64, StoreError> {
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<Document, StoreError> {
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<Document, StoreError> {
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<Document, StoreError> {
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<Document, StoreError> {
// 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::<i32>()
} {
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<Vec<Document>, 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::<Result<Vec<_>, _>>()
.map_err(StoreError::Database)
}
/// List documents by status
pub fn list_documents_by_status(
&self,
doc_type: DocType,
status: &str,
) -> Result<Vec<Document>, 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::<Result<Vec<_>, _>>()
.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<i32, StoreError> {
let max: Option<i32> = 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<LinkType>,
) -> Result<Vec<Document>, 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::<Result<Vec<_>, _>>()
.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<TaskProgress, StoreError> {
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<Vec<Task>, 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::<Result<Vec<_>, _>>()
.map_err(StoreError::Database)
}
// ==================== Worktree Operations ====================
/// Add a worktree for a document
pub fn add_worktree(&self, worktree: &Worktree) -> Result<i64, StoreError> {
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<Option<Worktree>, 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<Vec<Worktree>, 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::<Result<Vec<_>, _>>()
.map_err(StoreError::Database)
}
// ==================== Search Operations ====================
/// Search documents using FTS5
pub fn search_documents(
&self,
query: &str,
doc_type: Option<DocType>,
limit: usize,
) -> Result<Vec<SearchResult>, 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::<Result<Vec<_>, _>>()
.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);
}
}

View file

@ -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<Self, WorkflowError> {
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<RfcStatus> {
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<Self, WorkflowError> {
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<Self, WorkflowError> {
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<Self, WorkflowError> {
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
);
}
}

View file

@ -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<std::path::PathBuf>,
cwd: Option<PathBuf>,
/// Cached project state
state: Option<ProjectState>,
}
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<Value>) -> Result<Value, ServerError> {
fn handle_status(&mut self, _args: &Option<Value>) -> Result<Value, ServerError> {
match self.ensure_state() {
Ok(state) => {
let summary = state.status_summary();
Ok(json!({
"status": "success",
"message": blue_core::voice::speak("Checking status. Give me a moment.")
"active": summary.active,
"ready": summary.ready,
"stalled": summary.stalled,
"drafts": summary.drafts,
"hint": summary.hint
}))
}
fn handle_next(&self, _args: &Option<Value>) -> Result<Value, ServerError> {
Err(_) => {
// Fall back to a simple message if not in a Blue project
Ok(json!({
"status": "success",
"message": blue_core::voice::speak("Looking at what's ready. One moment.")
"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(&mut self, _args: &Option<Value>) -> Result<Value, ServerError> {
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(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
fn handle_rfc_create(&self, args: &Option<Value>) -> Result<Value, ServerError> {
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("Want me to help fill in the details?"),
Some("Note: Not persisted - run 'blue init' to enable storage.")
)
}))
}
}
}
fn handle_rfc_get(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
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::<Vec<_>>(),
"progress": progress.map(|p| json!({
"completed": p.completed,
"total": p.total,
"percentage": p.percentage
}))
}))
}
fn handle_rfc_update_status(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
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!("Updated '{}' to {}", title, status),
None
)
}))
}
fn handle_rfc_plan(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
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<String> = 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<Value>) -> Result<Value, ServerError> {
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::<i32>() {
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<Value>) -> Result<Value, ServerError> {
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<Value>) -> Result<Value, ServerError> {
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::<Vec<_>>()
}))
}
}
impl Default for BlueServer {

View file

@ -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