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:
parent
87e0066c36
commit
9759f0e3db
7 changed files with 945 additions and 16 deletions
84
.blue/docs/spikes/2026-01-26-claude-code-task-integration.md
Normal file
84
.blue/docs/spikes/2026-01-26-claude-code-task-integration.md
Normal 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.
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
383
crates/blue-core/src/plan.rs
Normal file
383
crates/blue-core/src/plan.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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,23 +2668,58 @@ impl BlueServer {
|
|||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
|
||||
let doc_id = doc.id.ok_or(ServerError::InvalidParams)?;
|
||||
let rfc_number = doc.number.unwrap_or(0);
|
||||
|
||||
// RFC 0017: Check if .plan.md exists and use it as authority
|
||||
let plan_path = blue_core::plan_file_path(&state.home.docs_path, title, rfc_number);
|
||||
|
||||
// Parse task index or find by substring
|
||||
let task_index = if let Ok(idx) = task.parse::<i32>() {
|
||||
idx
|
||||
} else {
|
||||
// Find task by substring
|
||||
let tasks = state.store.get_tasks(doc_id)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
// Find task by substring - check plan file first if it exists
|
||||
if plan_path.exists() {
|
||||
let plan = blue_core::read_plan_file(&plan_path)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
|
||||
tasks.iter()
|
||||
.find(|t| t.description.to_lowercase().contains(&task.to_lowercase()))
|
||||
.map(|t| t.task_index)
|
||||
.ok_or(ServerError::InvalidParams)?
|
||||
plan.tasks
|
||||
.iter()
|
||||
.position(|t| t.description.to_lowercase().contains(&task.to_lowercase()))
|
||||
.map(|idx| idx as i32)
|
||||
.ok_or(ServerError::InvalidParams)?
|
||||
} else {
|
||||
// Fall back to SQLite
|
||||
let tasks = state.store.get_tasks(doc_id)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
|
||||
tasks.iter()
|
||||
.find(|t| t.description.to_lowercase().contains(&task.to_lowercase()))
|
||||
.map(|t| t.task_index)
|
||||
.ok_or(ServerError::InvalidParams)?
|
||||
}
|
||||
};
|
||||
|
||||
state.store.complete_task(doc_id, task_index)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
// RFC 0017: Update .plan.md if it exists
|
||||
let plan_updated = if plan_path.exists() {
|
||||
let updated_plan = blue_core::update_task_in_plan(&plan_path, task_index as usize, true)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
|
||||
// Rebuild SQLite cache from plan
|
||||
state.store.rebuild_tasks_from_plan(doc_id, &updated_plan.tasks)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
|
||||
// Update cache mtime
|
||||
let mtime = chrono::Utc::now().to_rfc3339();
|
||||
state.store.update_plan_cache_mtime(doc_id, &mtime)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
|
||||
true
|
||||
} else {
|
||||
// No plan file - update SQLite directly (legacy behavior)
|
||||
state.store.complete_task(doc_id, task_index)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
false
|
||||
};
|
||||
|
||||
let progress = state.store.get_task_progress(doc_id)
|
||||
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
|
||||
|
|
@ -2611,6 +2728,7 @@ impl BlueServer {
|
|||
"status": "success",
|
||||
"title": title,
|
||||
"task_index": task_index,
|
||||
"plan_updated": plan_updated,
|
||||
"progress": {
|
||||
"completed": progress.completed,
|
||||
"total": progress.total,
|
||||
|
|
|
|||
Loading…
Reference in a new issue