feat: implement plan file authority (RFC 0017)

Plan files (.plan.md) are now the authoritative source for RFC task
tracking, with SQLite as a derived cache rebuilt on read.

Changes:
- Add plan.rs with PlanFile parsing/generation
- Add schema v7 migration for plan_cache table
- Modify handle_rfc_plan to write .plan.md files
- Modify handle_rfc_task_complete to update .plan.md
- Implement rebuild-on-read for stale cache detection
- Add RFC header validation (table vs inline format)
- Extend blue_lint with headers check and --fix support

Also includes spike investigating Claude Code task integration.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eric Garcia 2026-01-25 20:10:05 -05:00
parent 87e0066c36
commit 9759f0e3db
7 changed files with 945 additions and 16 deletions

View file

@ -0,0 +1,84 @@
# Spike: Claude Code Task Integration
| | |
|---|---|
| **Status** | In Progress |
| **Date** | 2026-01-26 |
| **Time Box** | 2 hours |
---
## Question
How can Blue integrate with Claude Code's built-in task management (TaskCreate/TaskUpdate/TaskList) to provide bidirectional sync between Blue's RFC tasks and Claude Code's UI?
---
## Investigation
### What Claude Code Provides
Claude Code has built-in task management tools:
- `TaskCreate` - Create tasks with subject, description, activeForm
- `TaskUpdate` - Update status (pending → in_progress → completed)
- `TaskList` - List all tasks with status
- `TaskGet` - Get full task details
These render in Claude Code's UI with a progress tracker showing task status.
### Integration Approaches
#### Approach 1: MCP Server Push (Not Viable)
MCP servers can only respond to requests - they cannot initiate calls to Claude Code's task system.
#### Approach 2: Claude Code Skill (Recommended)
Create a `/blue-plan` skill that orchestrates both systems:
```
User: /blue-plan rfc-17
Skill:
1. Call blue_rfc_get to fetch RFC tasks
2. For each task, call TaskCreate with:
- subject: task description
- metadata: { blue_rfc: 17, blue_task_index: 0 }
3. As work progresses, TaskUpdate syncs status
4. On completion, call blue_rfc_task_complete
```
**Pros**: Clean separation, skill handles orchestration
**Cons**: Manual invocation required
#### Approach 3: .plan.md as Shared Interface
The `.plan.md` file (RFC 0017) becomes the bridge:
- Blue writes/reads plan files
- A watcher process syncs changes to Claude Code tasks
- Task checkbox state is the single source of truth
**Pros**: File-based, works offline
**Cons**: Requires external sync process
#### Approach 4: Hybrid - Skill + Plan File
1. `/blue-plan` skill reads `.plan.md` and creates Claude Code tasks
2. User works, marking tasks complete in Claude Code
3. On session end, skill writes back to `.plan.md`
4. Blue picks up changes on next read (rebuild-on-read from RFC 0017)
### Recommended Path
**Phase 1**: Implement RFC 0017 (plan file authority) - gives us the file format
**Phase 2**: Create `/blue-plan` skill that syncs plan → Claude Code tasks
**Phase 3**: Add completion writeback to skill
### Key Insight
The `.plan.md` format is already compatible with Claude Code's task model:
- `- [ ] Task` maps to TaskCreate with status=pending
- `- [x] Task` maps to status=completed
The skill just needs to translate between formats.
## Conclusion
Integration is viable via a Claude Code skill that reads `.plan.md` files and creates corresponding Claude Code tasks. This preserves Blue's file-first philosophy while enabling Claude Code's task UI.
**Next**: Create RFC for skill implementation after RFC 0017 is complete.

View file

@ -565,6 +565,129 @@ pub fn update_markdown_status(
Ok(changed)
}
/// RFC header format types (RFC 0017)
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HeaderFormat {
/// Table format: `| **Status** | Draft |`
Table,
/// Inline format: `**Status:** Draft`
Inline,
/// No recognizable header format
Missing,
}
/// Validate RFC header format
///
/// Returns the detected header format:
/// - Table: canonical format `| **Status** | Draft |`
/// - Inline: non-canonical format `**Status:** Draft`
/// - Missing: no status header found
pub fn validate_rfc_header(content: &str) -> HeaderFormat {
let table_pattern = regex::Regex::new(r"\| \*\*Status\*\* \| [^|]+ \|").unwrap();
let inline_pattern = regex::Regex::new(r"\*\*Status:\*\*\s+\S+").unwrap();
if table_pattern.is_match(content) {
HeaderFormat::Table
} else if inline_pattern.is_match(content) {
HeaderFormat::Inline
} else {
HeaderFormat::Missing
}
}
/// Convert inline header format to table format
///
/// Converts patterns like:
/// ```text
/// **Status:** Draft
/// **Created:** 2026-01-25
/// **Author:** Claude
/// ```
///
/// To:
/// ```text
/// | | |
/// |---|---|
/// | **Status** | Draft |
/// | **Created** | 2026-01-25 |
/// | **Author** | Claude |
/// ```
pub fn convert_inline_to_table_header(content: &str) -> String {
// Match inline metadata patterns: **Key:** Value
let inline_re = regex::Regex::new(r"\*\*([^:*]+):\*\*\s*(.+)").unwrap();
let mut metadata_lines: Vec<(String, String)> = Vec::new();
let mut other_lines: Vec<String> = Vec::new();
let mut in_header_section = false;
let mut header_ended = false;
for line in content.lines() {
// Skip title line
if line.starts_with("# ") {
other_lines.push(line.to_string());
in_header_section = true;
continue;
}
// Check for inline metadata
if in_header_section && !header_ended {
if let Some(caps) = inline_re.captures(line) {
let key = caps.get(1).unwrap().as_str().trim();
let value = caps.get(2).unwrap().as_str().trim();
metadata_lines.push((key.to_string(), value.to_string()));
continue;
}
// Empty line in header section is ok
if line.trim().is_empty() && !metadata_lines.is_empty() {
continue;
}
// If we had metadata and hit something else, header section ended
if !metadata_lines.is_empty() {
header_ended = true;
}
}
other_lines.push(line.to_string());
}
if metadata_lines.is_empty() {
return content.to_string();
}
// Reconstruct content with table format
let mut result = String::new();
// Find and add title
if let Some(title_pos) = other_lines.iter().position(|l| l.starts_with("# ")) {
result.push_str(&other_lines[title_pos]);
result.push_str("\n\n");
// Add table header
result.push_str("| | |\n");
result.push_str("|---|---|\n");
// Add metadata rows
for (key, value) in &metadata_lines {
result.push_str(&format!("| **{}** | {} |\n", key, value));
}
// Add remaining content
let remaining = other_lines[title_pos + 1..].join("\n");
let trimmed = remaining.trim_start();
if !trimmed.is_empty() {
result.push('\n');
result.push_str(trimmed);
}
} else {
// No title found, return original
return content.to_string();
}
result
}
#[cfg(test)]
mod tests {
use super::*;
@ -632,4 +755,46 @@ mod tests {
let changed = update_markdown_status(&file, "implemented").unwrap();
assert!(!changed);
}
#[test]
fn test_validate_rfc_header_table_format() {
let content = "# RFC 0001: Test\n\n| | |\n|---|---|\n| **Status** | Draft |\n| **Date** | 2026-01-24 |\n";
assert_eq!(validate_rfc_header(content), HeaderFormat::Table);
}
#[test]
fn test_validate_rfc_header_inline_format() {
let content = "# RFC 0001: Test\n\n**Status:** Draft\n**Date:** 2026-01-24\n";
assert_eq!(validate_rfc_header(content), HeaderFormat::Inline);
}
#[test]
fn test_validate_rfc_header_missing() {
let content = "# RFC 0001: Test\n\nJust some content without status.\n";
assert_eq!(validate_rfc_header(content), HeaderFormat::Missing);
}
#[test]
fn test_convert_inline_to_table_header() {
let content = "# RFC 0001: Test\n\n**Status:** Draft\n**Created:** 2026-01-25\n**Author:** Claude\n\n## Problem\n\nSomething is wrong.\n";
let converted = convert_inline_to_table_header(content);
assert!(converted.contains("| | |"));
assert!(converted.contains("|---|---|"));
assert!(converted.contains("| **Status** | Draft |"));
assert!(converted.contains("| **Created** | 2026-01-25 |"));
assert!(converted.contains("| **Author** | Claude |"));
assert!(converted.contains("## Problem"));
assert!(converted.contains("Something is wrong."));
assert!(!converted.contains("**Status:**"));
}
#[test]
fn test_convert_inline_to_table_header_no_change() {
let content = "# RFC 0001: Test\n\n| | |\n|---|---|\n| **Status** | Draft |\n\n## Problem\n";
let converted = convert_inline_to_table_header(content);
// Should not change already-table-formatted content
assert_eq!(converted, content);
}
}

View file

@ -20,6 +20,7 @@ pub mod forge;
pub mod indexer;
pub mod llm;
pub mod manifest;
pub mod plan;
pub mod realm;
pub mod repo;
pub mod state;
@ -29,7 +30,7 @@ pub mod voice;
pub mod workflow;
pub use alignment::{AlignmentDialogue, AlignmentScore, DialogueStatus, Expert, ExpertResponse, ExpertTier, PanelTemplate, Perspective, PerspectiveStatus, Round, Tension, TensionStatus, build_expert_prompt, parse_expert_response};
pub use documents::{Adr, Audit, AuditFinding, AuditSeverity, AuditType, Decision, Rfc, Spike, SpikeOutcome, Status, Task, update_markdown_status};
pub use documents::{Adr, Audit, AuditFinding, AuditSeverity, AuditType, Decision, HeaderFormat, Rfc, Spike, SpikeOutcome, Status, Task, convert_inline_to_table_header, update_markdown_status, validate_rfc_header};
pub use forge::{BlueConfig, CreatePrOpts, Forge, ForgeConfig, ForgeError, ForgeType, ForgejoForge, GitHubForge, GitUrl, MergeStrategy, PrState, PullRequest, create_forge, create_forge_cached, detect_forge_type, detect_forge_type_cached, get_token, parse_git_url};
pub use indexer::{Indexer, IndexerConfig, IndexerError, IndexResult, ParsedSymbol, is_indexable_file, should_skip_dir, DEFAULT_INDEX_MODEL, MAX_FILE_LINES};
pub use llm::{CompletionOptions, CompletionResult, LlmBackendChoice, LlmConfig, LlmError, LlmManager, LlmProvider, LlmProviderChoice, LocalLlmConfig, ApiLlmConfig, KeywordLlm, MockLlm, ProviderStatus};
@ -40,3 +41,4 @@ pub use voice::*;
pub use workflow::{PrdStatus, RfcStatus, SpikeOutcome as WorkflowSpikeOutcome, SpikeStatus, WorkflowError, validate_rfc_transition};
pub use manifest::{ContextManifest, IdentityConfig, WorkflowConfig, ReferenceConfig, PluginConfig, SourceConfig, RefreshTrigger, SalienceTrigger, ManifestError, ManifestResolution, TierResolution, ResolvedSource};
pub use uri::{BlueUri, UriError, read_uri_content, estimate_tokens};
pub use plan::{PlanFile, PlanStatus, PlanTask, PlanError, parse_plan_markdown, generate_plan_markdown, plan_file_path, is_cache_stale, read_plan_file, write_plan_file, update_task_in_plan};

View file

@ -0,0 +1,383 @@
//! Plan file parsing and generation
//!
//! RFC 0017: Plan files (.plan.md) are the authoritative source for RFC task tracking.
//! SQLite acts as a derived cache that is rebuilt on read when stale.
use std::fs;
use std::path::{Path, PathBuf};
use regex::Regex;
use serde::{Deserialize, Serialize};
/// A parsed plan file
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanFile {
pub rfc_title: String,
pub status: PlanStatus,
pub updated_at: String,
pub tasks: Vec<PlanTask>,
}
/// Plan status
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum PlanStatus {
InProgress,
Complete,
UpdatingPlan,
}
impl PlanStatus {
pub fn as_str(&self) -> &'static str {
match self {
PlanStatus::InProgress => "in-progress",
PlanStatus::Complete => "complete",
PlanStatus::UpdatingPlan => "updating-plan",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().replace(' ', "-").as_str() {
"in-progress" => Some(PlanStatus::InProgress),
"complete" => Some(PlanStatus::Complete),
"updating-plan" => Some(PlanStatus::UpdatingPlan),
_ => None,
}
}
}
/// A task within a plan
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PlanTask {
pub description: String,
pub completed: bool,
}
/// Error type for plan operations
#[derive(Debug)]
pub enum PlanError {
Io(std::io::Error),
Parse(String),
InvalidFormat(String),
}
impl std::fmt::Display for PlanError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PlanError::Io(e) => write!(f, "IO error: {}", e),
PlanError::Parse(msg) => write!(f, "Parse error: {}", msg),
PlanError::InvalidFormat(msg) => write!(f, "Invalid format: {}", msg),
}
}
}
impl std::error::Error for PlanError {}
impl From<std::io::Error> for PlanError {
fn from(e: std::io::Error) -> Self {
PlanError::Io(e)
}
}
/// Parse a plan markdown file into a PlanFile struct
pub fn parse_plan_markdown(content: &str) -> Result<PlanFile, PlanError> {
// Extract RFC title from header: # Plan: {title}
let title_re = Regex::new(r"^# Plan: (.+)$").unwrap();
let rfc_title = content
.lines()
.find_map(|line| {
title_re
.captures(line)
.map(|c| c.get(1).unwrap().as_str().to_string())
})
.ok_or_else(|| PlanError::Parse("Missing '# Plan: {title}' header".to_string()))?;
// Extract status from table: | **Status** | {status} |
let status_re = Regex::new(r"\| \*\*Status\*\* \| ([^|]+) \|").unwrap();
let status_str = content
.lines()
.find_map(|line| {
status_re
.captures(line)
.map(|c| c.get(1).unwrap().as_str().trim().to_string())
})
.unwrap_or_else(|| "in-progress".to_string());
let status = PlanStatus::from_str(&status_str).unwrap_or(PlanStatus::InProgress);
// Extract updated_at from table: | **Updated** | {timestamp} |
let updated_re = Regex::new(r"\| \*\*Updated\*\* \| ([^|]+) \|").unwrap();
let updated_at = content
.lines()
.find_map(|line| {
updated_re
.captures(line)
.map(|c| c.get(1).unwrap().as_str().trim().to_string())
})
.unwrap_or_else(|| chrono::Utc::now().to_rfc3339());
// Extract tasks from ## Tasks section
let task_re = Regex::new(r"^- \[([ xX])\] (.+)$").unwrap();
let mut tasks = Vec::new();
let mut in_tasks_section = false;
for line in content.lines() {
if line.starts_with("## Tasks") {
in_tasks_section = true;
continue;
}
// Stop at next section
if in_tasks_section && line.starts_with("## ") {
break;
}
if in_tasks_section {
if let Some(caps) = task_re.captures(line) {
let completed = caps.get(1).unwrap().as_str() != " ";
let description = caps.get(2).unwrap().as_str().to_string();
tasks.push(PlanTask {
description,
completed,
});
}
}
}
Ok(PlanFile {
rfc_title,
status,
updated_at,
tasks,
})
}
/// Generate markdown content from a PlanFile
pub fn generate_plan_markdown(plan: &PlanFile) -> String {
let mut md = String::new();
// Title
md.push_str(&format!("# Plan: {}\n\n", plan.rfc_title));
// Metadata table
md.push_str("| | |\n|---|---|\n");
md.push_str(&format!("| **RFC** | {} |\n", plan.rfc_title));
md.push_str(&format!("| **Status** | {} |\n", plan.status.as_str()));
md.push_str(&format!("| **Updated** | {} |\n", plan.updated_at));
md.push_str("\n");
// Tasks section
md.push_str("## Tasks\n\n");
for task in &plan.tasks {
let checkbox = if task.completed { "[x]" } else { "[ ]" };
md.push_str(&format!("- {} {}\n", checkbox, task.description));
}
md
}
/// Get the path for a plan file given the RFC docs path, title, and number
pub fn plan_file_path(docs_path: &Path, rfc_title: &str, rfc_number: i32) -> PathBuf {
let filename = format!("{:04}-{}.plan.md", rfc_number, rfc_title);
docs_path.join("rfcs").join(filename)
}
/// Check if the SQLite cache is stale compared to the plan file
///
/// Returns true if the plan file exists and is newer than the cache mtime
pub fn is_cache_stale(plan_path: &Path, cache_mtime: Option<&str>) -> bool {
if !plan_path.exists() {
return false;
}
let Some(cache_mtime) = cache_mtime else {
// No cache entry means stale
return true;
};
// Get file modification time
let Ok(metadata) = fs::metadata(plan_path) else {
return false;
};
let Ok(modified) = metadata.modified() else {
return false;
};
// Convert to RFC3339 for comparison
let file_mtime: chrono::DateTime<chrono::Utc> = modified.into();
let file_mtime_str = file_mtime.to_rfc3339();
// Cache is stale if file is newer
file_mtime_str > cache_mtime.to_string()
}
/// Read and parse a plan file from disk
pub fn read_plan_file(plan_path: &Path) -> Result<PlanFile, PlanError> {
let content = fs::read_to_string(plan_path)?;
parse_plan_markdown(&content)
}
/// Write a plan file to disk
pub fn write_plan_file(plan_path: &Path, plan: &PlanFile) -> Result<(), PlanError> {
let content = generate_plan_markdown(plan);
fs::write(plan_path, content)?;
Ok(())
}
/// Update a specific task in a plan file
pub fn update_task_in_plan(
plan_path: &Path,
task_index: usize,
completed: bool,
) -> Result<PlanFile, PlanError> {
let mut plan = read_plan_file(plan_path)?;
if task_index >= plan.tasks.len() {
return Err(PlanError::InvalidFormat(format!(
"Task index {} out of bounds (max {})",
task_index,
plan.tasks.len()
)));
}
plan.tasks[task_index].completed = completed;
plan.updated_at = chrono::Utc::now().to_rfc3339();
// Check if all tasks are complete
if plan.tasks.iter().all(|t| t.completed) {
plan.status = PlanStatus::Complete;
}
write_plan_file(plan_path, &plan)?;
Ok(plan)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_plan_markdown() {
let content = r#"# Plan: my-feature
| | |
|---|---|
| **RFC** | my-feature |
| **Status** | in-progress |
| **Updated** | 2026-01-26T10:30:00Z |
## Tasks
- [x] Completed task
- [ ] Pending task
- [X] Another completed task
"#;
let plan = parse_plan_markdown(content).unwrap();
assert_eq!(plan.rfc_title, "my-feature");
assert_eq!(plan.status, PlanStatus::InProgress);
assert_eq!(plan.tasks.len(), 3);
assert!(plan.tasks[0].completed);
assert!(!plan.tasks[1].completed);
assert!(plan.tasks[2].completed);
assert_eq!(plan.tasks[0].description, "Completed task");
assert_eq!(plan.tasks[1].description, "Pending task");
}
#[test]
fn test_generate_plan_markdown() {
let plan = PlanFile {
rfc_title: "test-feature".to_string(),
status: PlanStatus::InProgress,
updated_at: "2026-01-26T10:30:00Z".to_string(),
tasks: vec![
PlanTask {
description: "First task".to_string(),
completed: true,
},
PlanTask {
description: "Second task".to_string(),
completed: false,
},
],
};
let md = generate_plan_markdown(&plan);
assert!(md.contains("# Plan: test-feature"));
assert!(md.contains("| **Status** | in-progress |"));
assert!(md.contains("- [x] First task"));
assert!(md.contains("- [ ] Second task"));
}
#[test]
fn test_plan_file_path() {
let docs_path = Path::new("/project/.blue/docs");
let path = plan_file_path(docs_path, "my-feature", 7);
assert_eq!(
path,
PathBuf::from("/project/.blue/docs/rfcs/0007-my-feature.plan.md")
);
}
#[test]
fn test_roundtrip() {
let original = PlanFile {
rfc_title: "roundtrip-test".to_string(),
status: PlanStatus::Complete,
updated_at: "2026-01-26T12:00:00Z".to_string(),
tasks: vec![
PlanTask {
description: "Task one".to_string(),
completed: true,
},
PlanTask {
description: "Task two".to_string(),
completed: true,
},
],
};
let markdown = generate_plan_markdown(&original);
let parsed = parse_plan_markdown(&markdown).unwrap();
assert_eq!(parsed.rfc_title, original.rfc_title);
assert_eq!(parsed.status, original.status);
assert_eq!(parsed.tasks.len(), original.tasks.len());
for (p, o) in parsed.tasks.iter().zip(original.tasks.iter()) {
assert_eq!(p.description, o.description);
assert_eq!(p.completed, o.completed);
}
}
#[test]
fn test_is_cache_stale_no_file() {
let path = Path::new("/nonexistent/path.plan.md");
assert!(!is_cache_stale(path, Some("2026-01-01T00:00:00Z")));
}
#[test]
fn test_is_cache_stale_no_cache() {
let dir = tempfile::tempdir().unwrap();
let plan_path = dir.path().join("test.plan.md");
std::fs::write(&plan_path, "# Plan: test\n").unwrap();
assert!(is_cache_stale(&plan_path, None));
}
#[test]
fn test_status_from_str() {
assert_eq!(
PlanStatus::from_str("in-progress"),
Some(PlanStatus::InProgress)
);
assert_eq!(
PlanStatus::from_str("In Progress"),
Some(PlanStatus::InProgress)
);
assert_eq!(PlanStatus::from_str("complete"), Some(PlanStatus::Complete));
assert_eq!(
PlanStatus::from_str("updating-plan"),
Some(PlanStatus::UpdatingPlan)
);
assert_eq!(PlanStatus::from_str("invalid"), None);
}
}

View file

@ -10,7 +10,7 @@ use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBe
use tracing::{debug, info, warn};
/// Current schema version
const SCHEMA_VERSION: i32 = 6;
const SCHEMA_VERSION: i32 = 7;
/// Core database schema
const SCHEMA: &str = r#"
@ -1125,6 +1125,26 @@ impl DocumentStore {
)?;
}
// Migration from v6 to v7: Add plan_cache table (RFC 0017 - Plan File Authority)
if from_version < 7 {
debug!("Adding plan_cache table (RFC 0017)");
self.conn.execute(
"CREATE TABLE IF NOT EXISTS plan_cache (
id INTEGER PRIMARY KEY AUTOINCREMENT,
document_id INTEGER NOT NULL UNIQUE,
cache_mtime TEXT NOT NULL,
FOREIGN KEY (document_id) REFERENCES documents(id) ON DELETE CASCADE
)",
[],
)?;
self.conn.execute(
"CREATE INDEX IF NOT EXISTS idx_plan_cache_document ON plan_cache(document_id)",
[],
)?;
}
// Update schema version
self.conn.execute(
"UPDATE schema_version SET version = ?1",
@ -1734,6 +1754,68 @@ impl DocumentStore {
.map_err(StoreError::Database)
}
// ==================== Plan Cache Operations (RFC 0017) ====================
/// Get the cached mtime for a plan file
pub fn get_plan_cache_mtime(&self, document_id: i64) -> Result<Option<String>, StoreError> {
self.conn
.query_row(
"SELECT cache_mtime FROM plan_cache WHERE document_id = ?1",
params![document_id],
|row| row.get(0),
)
.optional()
.map_err(StoreError::Database)
}
/// Update the cached mtime for a plan file
pub fn update_plan_cache_mtime(&self, document_id: i64, mtime: &str) -> Result<(), StoreError> {
self.with_retry(|| {
self.conn.execute(
"INSERT INTO plan_cache (document_id, cache_mtime) VALUES (?1, ?2)
ON CONFLICT(document_id) DO UPDATE SET cache_mtime = excluded.cache_mtime",
params![document_id, mtime],
)?;
Ok(())
})
}
/// Rebuild tasks from plan file data (RFC 0017 - authority inversion)
pub fn rebuild_tasks_from_plan(
&self,
document_id: i64,
tasks: &[crate::plan::PlanTask],
) -> Result<(), StoreError> {
self.with_retry(|| {
// Delete existing tasks
self.conn
.execute("DELETE FROM tasks WHERE document_id = ?1", params![document_id])?;
// Insert tasks from plan file
for (index, task) in tasks.iter().enumerate() {
let completed_at = if task.completed {
Some(chrono::Utc::now().to_rfc3339())
} else {
None
};
self.conn.execute(
"INSERT INTO tasks (document_id, task_index, description, completed, completed_at)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![
document_id,
index as i32,
task.description,
task.completed as i32,
completed_at
],
)?;
}
Ok(())
})
}
// ==================== Worktree Operations ====================
/// Add a worktree for a document

View file

@ -17,6 +17,7 @@ enum ProjectType {
JavaScript,
Python,
Cdk,
RfcDocs,
}
impl ProjectType {
@ -26,6 +27,7 @@ impl ProjectType {
ProjectType::JavaScript => "javascript",
ProjectType::Python => "python",
ProjectType::Cdk => "cdk",
ProjectType::RfcDocs => "rfc-docs",
}
}
}
@ -67,6 +69,7 @@ pub fn handle_lint(args: &Value, repo_path: &Path) -> Result<Value, ServerError>
ProjectType::JavaScript => run_js_checks(repo_path, fix, check_type),
ProjectType::Python => run_python_checks(repo_path, fix, check_type),
ProjectType::Cdk => run_cdk_checks(repo_path, check_type),
ProjectType::RfcDocs => run_rfc_checks(repo_path, fix, check_type),
};
all_results.extend(results);
}
@ -152,6 +155,10 @@ fn detect_project_types(path: &Path) -> Vec<ProjectType> {
if path.join("cdk.json").exists() {
types.push(ProjectType::Cdk);
}
// RFC 0017: Check for Blue RFC docs
if path.join(".blue/docs/rfcs").exists() {
types.push(ProjectType::RfcDocs);
}
types
}
@ -244,6 +251,8 @@ fn count_issues(output: &str, project_type: ProjectType, check_name: &str) -> us
0
}
}
// RFC headers are counted directly in run_rfc_checks
(ProjectType::RfcDocs, _) => 0,
_ => 0,
}
}
@ -401,6 +410,92 @@ fn run_cdk_checks(path: &Path, check_type: &str) -> Vec<LintResult> {
results
}
/// RFC 0017: Run RFC header checks
fn run_rfc_checks(path: &Path, fix: bool, check_type: &str) -> Vec<LintResult> {
use blue_core::{HeaderFormat, convert_inline_to_table_header, validate_rfc_header};
use std::fs;
let mut results = Vec::new();
if check_type != "all" && check_type != "headers" {
return results;
}
let rfcs_path = path.join(".blue/docs/rfcs");
if !rfcs_path.exists() {
return results;
}
let mut inline_count = 0;
let mut missing_count = 0;
let mut fixed_count = 0;
// Scan RFC files (exclude .plan.md files)
if let Ok(entries) = fs::read_dir(&rfcs_path) {
for entry in entries.flatten() {
let path = entry.path();
if let Some(ext) = path.extension() {
if ext != "md" {
continue;
}
} else {
continue;
}
// Skip .plan.md files
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.ends_with(".plan.md") {
continue;
}
}
if let Ok(content) = fs::read_to_string(&path) {
match validate_rfc_header(&content) {
HeaderFormat::Table => {
// Good - canonical format
}
HeaderFormat::Inline => {
if fix {
let converted = convert_inline_to_table_header(&content);
if let Ok(()) = fs::write(&path, converted) {
fixed_count += 1;
}
} else {
inline_count += 1;
}
}
HeaderFormat::Missing => {
missing_count += 1;
}
}
}
}
}
let total_issues = if fix { 0 } else { inline_count + missing_count };
results.push(LintResult {
project_type: ProjectType::RfcDocs,
name: "headers",
tool: "blue_lint",
passed: total_issues == 0,
issue_count: total_issues,
fix_command: "blue_lint --fix --check headers",
});
// Add details if there were issues or fixes
if inline_count > 0 || missing_count > 0 || fixed_count > 0 {
tracing::info!(
"RFC headers: {} inline (non-canonical), {} missing, {} fixed",
inline_count,
missing_count,
fixed_count
);
}
results
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -2397,20 +2397,50 @@ impl BlueServer {
let doc = state.store.find_document(DocType::Rfc, title)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
let doc_id = doc.id;
let rfc_number = doc.number.unwrap_or(0);
// RFC 0017: Check if plan file exists and cache is stale - rebuild if needed
let plan_path = blue_core::plan_file_path(&state.home.docs_path, title, rfc_number);
let mut cache_rebuilt = false;
if let Some(id) = doc_id {
if plan_path.exists() {
let cache_mtime = state.store.get_plan_cache_mtime(id)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
if blue_core::is_cache_stale(&plan_path, cache_mtime.as_deref()) {
// Rebuild cache from plan file
let plan = blue_core::read_plan_file(&plan_path)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
state.store.rebuild_tasks_from_plan(id, &plan.tasks)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
// Update cache mtime
let mtime = chrono::Utc::now().to_rfc3339();
state.store.update_plan_cache_mtime(id, &mtime)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
cache_rebuilt = true;
}
}
}
// Get tasks if any
let tasks = if let Some(id) = doc.id {
let tasks = if let Some(id) = doc_id {
state.store.get_tasks(id).unwrap_or_default()
} else {
vec![]
};
let progress = if let Some(id) = doc.id {
let progress = if let Some(id) = doc_id {
state.store.get_task_progress(id).ok()
} else {
None
};
Ok(json!({
let mut response = json!({
"id": doc.id,
"number": doc.number,
"title": doc.title,
@ -2428,7 +2458,15 @@ impl BlueServer {
"total": p.total,
"percentage": p.percentage
}))
}))
});
// Add plan file info if it exists
if plan_path.exists() {
response["plan_file"] = json!(plan_path.display().to_string());
response["cache_rebuilt"] = json!(cache_rebuilt);
}
Ok(response)
}
fn handle_rfc_update_status(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
@ -2553,15 +2591,59 @@ impl BlueServer {
let doc_id = doc.id.ok_or(ServerError::InvalidParams)?;
// RFC 0017: Status gating - only allow planning for accepted or in-progress RFCs
let status_lower = doc.status.to_lowercase();
if status_lower != "accepted" && status_lower != "in-progress" {
return Err(ServerError::Workflow(format!(
"RFC must be 'accepted' or 'in-progress' to create a plan (current: {})",
doc.status
)));
}
// RFC 0017: Write .plan.md file as authoritative source
let plan_tasks: Vec<blue_core::PlanTask> = tasks
.iter()
.map(|desc| blue_core::PlanTask {
description: desc.clone(),
completed: false,
})
.collect();
let plan = blue_core::PlanFile {
rfc_title: title.to_string(),
status: blue_core::PlanStatus::InProgress,
updated_at: chrono::Utc::now().to_rfc3339(),
tasks: plan_tasks.clone(),
};
let rfc_number = doc.number.unwrap_or(0);
let plan_path = blue_core::plan_file_path(&state.home.docs_path, title, rfc_number);
// Ensure parent directory exists
if let Some(parent) = plan_path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| ServerError::StateLoadFailed(format!("Failed to create directory: {}", e)))?;
}
blue_core::write_plan_file(&plan_path, &plan)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
// Update SQLite cache
state.store.set_tasks(doc_id, &tasks)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
// Update cache mtime
let mtime = chrono::Utc::now().to_rfc3339();
state.store.update_plan_cache_mtime(doc_id, &mtime)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
Ok(json!({
"status": "success",
"title": title,
"task_count": tasks.len(),
"plan_file": plan_path.display().to_string(),
"message": blue_core::voice::success(
&format!("Set {} tasks for '{}'", tasks.len(), title),
&format!("Set {} tasks for '{}'. Plan file created.", tasks.len(), title),
Some("Mark them complete as you go with blue_rfc_task_complete.")
)
}))
@ -2586,12 +2668,27 @@ impl BlueServer {
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
let doc_id = doc.id.ok_or(ServerError::InvalidParams)?;
let rfc_number = doc.number.unwrap_or(0);
// RFC 0017: Check if .plan.md exists and use it as authority
let plan_path = blue_core::plan_file_path(&state.home.docs_path, title, rfc_number);
// Parse task index or find by substring
let task_index = if let Ok(idx) = task.parse::<i32>() {
idx
} else {
// Find task by substring
// Find task by substring - check plan file first if it exists
if plan_path.exists() {
let plan = blue_core::read_plan_file(&plan_path)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
plan.tasks
.iter()
.position(|t| t.description.to_lowercase().contains(&task.to_lowercase()))
.map(|idx| idx as i32)
.ok_or(ServerError::InvalidParams)?
} else {
// Fall back to SQLite
let tasks = state.store.get_tasks(doc_id)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
@ -2599,10 +2696,30 @@ impl BlueServer {
.find(|t| t.description.to_lowercase().contains(&task.to_lowercase()))
.map(|t| t.task_index)
.ok_or(ServerError::InvalidParams)?
}
};
// RFC 0017: Update .plan.md if it exists
let plan_updated = if plan_path.exists() {
let updated_plan = blue_core::update_task_in_plan(&plan_path, task_index as usize, true)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
// Rebuild SQLite cache from plan
state.store.rebuild_tasks_from_plan(doc_id, &updated_plan.tasks)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
// Update cache mtime
let mtime = chrono::Utc::now().to_rfc3339();
state.store.update_plan_cache_mtime(doc_id, &mtime)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
true
} else {
// No plan file - update SQLite directly (legacy behavior)
state.store.complete_task(doc_id, task_index)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
false
};
let progress = state.store.get_task_progress(doc_id)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
@ -2611,6 +2728,7 @@ impl BlueServer {
"status": "success",
"title": title,
"task_index": task_index,
"plan_updated": plan_updated,
"progress": {
"completed": progress.completed,
"total": progress.total,