feat: implement RFC 0008 (status file sync) and RFC 0009 (audit documents)

RFC 0008: Status updates now sync to markdown files, not just DB
RFC 0009: Add Audit as first-class document type, rename blue_audit to
blue_health_check to avoid naming collision

Also includes:
- Update RFC 0005 with Ollama auto-detection and bundled Goose support
- Mark RFCs 0001-0006 as Implemented
- Add spikes documenting investigations

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eric Garcia 2026-01-24 17:56:20 -05:00
parent 36aeb2f889
commit 1be95dd4a1
23 changed files with 1490 additions and 80 deletions

Binary file not shown.

View file

@ -2,7 +2,7 @@
| | | | | |
|---|---| |---|---|
| **Status** | Draft | | **Status** | Implemented |
| **Date** | 2026-01-24 | | **Date** | 2026-01-24 |
| **Source Spike** | sqlite-storage-expansion | | **Source Spike** | sqlite-storage-expansion |

View file

@ -2,7 +2,7 @@
| | | | | |
|---|---| |---|---|
| **Status** | Draft | | **Status** | Implemented |
| **Date** | 2026-01-24 | | **Date** | 2026-01-24 |
| **Source Spike** | runbook-driven-actions | | **Source Spike** | runbook-driven-actions |

View file

@ -2,7 +2,7 @@
| | | | | |
|---|---| |---|---|
| **Status** | Draft | | **Status** | Implemented |
| **Date** | 2026-01-24 | | **Date** | 2026-01-24 |
| **Source Spike** | per-repo-blue-folder | | **Source Spike** | per-repo-blue-folder |

View file

@ -2,7 +2,7 @@
| | | | | |
|---|---| |---|---|
| **Status** | Draft | | **Status** | Implemented |
| **Date** | 2026-01-24 | | **Date** | 2026-01-24 |
| **Source Spike** | adr-adherence | | **Source Spike** | adr-adherence |
| **ADRs** | 0004 (Evidence), 0007 (Integrity), 0008 (Honor) | | **ADRs** | 0004 (Evidence), 0007 (Integrity), 0008 (Honor) |

View file

@ -2,7 +2,7 @@
| | | | | |
|---|---| |---|---|
| **Status** | Draft | | **Status** | Implemented |
| **Date** | 2026-01-24 | | **Date** | 2026-01-24 |
| **Source Spike** | local-llm-integration, agentic-cli-integration | | **Source Spike** | local-llm-integration, agentic-cli-integration |
@ -307,53 +307,81 @@ blue_model_pull name="qwen2.5:7b"
### 5.1 Goose Integration ### 5.1 Goose Integration
Blue's embedded Ollama serves Goose for agentic coding: Blue bundles Goose binary and auto-configures it for local Ollama:
``` ```
┌─────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────┐
│ User runs: goose │ User runs: blue agent
│ ↓ │ │ ↓ │
Goose connects to localhost:11434 (Blue's Ollama) Blue detects Ollama on localhost:11434
│ ↓ │ │ ↓ │
│ Uses same model Blue uses for semantic tasks │ │ Picks largest available model (e.g., qwen2.5:72b) │
│ ↓ │
│ Launches bundled Goose with Blue MCP extension │
└─────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────┘
``` ```
**Setup:** **Zero Setup:**
```bash ```bash
# 1. Start Blue (starts embedded Ollama) # Just run it - Blue handles everything
blue daemon start
# 2. Configure Goose to use Blue's Ollama
# ~/.config/goose/config.yaml
provider: ollama
model: qwen2.5-coder:32b
host: http://localhost:11434
# 3. Run Goose with Blue's MCP tools
goose --extension "blue mcp"
```
**Convenience command:**
```bash
# Start Goose with Blue pre-configured
blue agent blue agent
# Equivalent to: # What happens:
# 1. Ensure Blue daemon running (Ollama ready) # 1. Uses bundled Goose binary (downloaded at build time)
# 2. Launch Goose with Blue extension # 2. Detects Ollama running on localhost:11434
# 3. Model auto-pulled if missing # 3. Selects largest model (best for agentic work)
# 4. Sets GOOSE_PROVIDER=ollama, OLLAMA_HOST=...
# 5. Connects Blue MCP extension for workflow tools
``` ```
**Manual Model Override:**
```bash
# Use a specific provider/model
blue agent --model ollama/qwen2.5:7b
blue agent --model anthropic/claude-sonnet-4-20250514
# Pass additional Goose arguments
blue agent -- --resume --name my-session
```
**Goose Binary Bundling:**
Blue's `build.rs` downloads the Goose binary for the target platform:
| Platform | Binary |
|----------|--------|
| macOS ARM64 | goose-aarch64-apple-darwin |
| macOS x86_64 | goose-x86_64-apple-darwin |
| Linux x86_64 | goose-x86_64-unknown-linux-gnu |
| Linux ARM64 | goose-aarch64-unknown-linux-gnu |
| Windows | goose-x86_64-pc-windows-gnu |
**Build-time Download:**
```rust
// apps/blue-cli/build.rs
const GOOSE_VERSION: &str = "1.21.1";
// Downloads goose-{arch}-{os}.tar.bz2 from GitHub releases
// Extracts to OUT_DIR, sets GOOSE_BINARY_PATH env var
```
**Runtime Discovery:**
1. Check for bundled binary next to `blue` executable
2. Check compile-time `GOOSE_BINARY_PATH`
3. Fall back to system PATH (validates it's Block's Goose, not the DB migration tool)
**Shared Model Benefits:** **Shared Model Benefits:**
| Without Blue | With Blue | | Without Blue | With Blue |
|--------------|-----------| |--------------|-----------|
| Install Goose separately | Blue bundles Goose |
| Install Ollama separately | Blue bundles Ollama | | Install Ollama separately | Blue bundles Ollama |
| Configure Goose manually | `blue agent` just works | | Configure Goose manually | `blue agent` auto-configures |
| Model loaded twice (Ollama + Goose) | One model instance | | Model loaded twice | One model instance |
| 40GB RAM for two 32B models | 20GB for shared model | | 40GB RAM for two 32B models | 20GB for shared model |
### 6. Graceful Degradation ### 6. Graceful Degradation

View file

@ -2,7 +2,7 @@
| | | | | |
|---|---| |---|---|
| **Status** | In-Progress | | **Status** | Implemented |
| **Date** | 2026-01-24 | | **Date** | 2026-01-24 |
| **Ported From** | coherence-mcp RFC 0050 | | **Ported From** | coherence-mcp RFC 0050 |
| **Alignment** | 94% (12 experts, 5 tensions resolved) | | **Alignment** | 94% (12 experts, 5 tensions resolved) |

View file

@ -0,0 +1,81 @@
# RFC 0008: Status Update File Sync
| | |
|---|---|
| **Status** | Implemented |
| **Date** | 2026-01-24 |
| **Source Spike** | rfc-status-update-not-persisting |
---
## Summary
Status update handlers only update the database but not the markdown files, causing a sync mismatch between what users see in files and what's in the database.
## Problem
When status is updated via MCP tools:
- `blue_rfc_update_status` → Updates DB only ❌
- `blue_rfc_complete` → Updates DB only ❌
- `blue_spike_complete` → Updates both DB and file ✅
This causes confusion when users check markdown files expecting to see updated status.
## Proposal
Add a helper function to update markdown file status, then call it from all status-changing handlers.
### Implementation
1. Create `update_markdown_status(file_path, old_status, new_status)` helper in `blue-core`
2. Update `handle_rfc_update_status` in `server.rs` to call helper after DB update
3. Update `handle_rfc_complete` in `rfc.rs` to call helper after DB update
4. Consider adding to ADR handlers if they have status changes
### Helper Function
```rust
pub fn update_markdown_status(
file_path: &Path,
new_status: &str
) -> Result<(), std::io::Error> {
let content = fs::read_to_string(file_path)?;
// Match common status formats
let patterns = [
(r"\| \*\*Status\*\* \| [^|]+ \|", format!("| **Status** | {} |", new_status)),
(r"\*\*Status:\*\* \w+", format!("**Status:** {}", new_status)),
];
let mut updated = content;
for (pattern, replacement) in patterns {
updated = regex::Regex::new(pattern)
.unwrap()
.replace(&updated, replacement.as_str())
.to_string();
}
fs::write(file_path, updated)
}
```
## Test Plan
- [x] `blue_rfc_update_status` updates both DB and markdown file
- [x] `blue_rfc_complete` updates both DB and markdown file
- [x] Status patterns are correctly replaced (table format, inline format)
- [x] No changes to files without status fields
## Implementation Plan
- [x] Add `update_markdown_status` helper to blue-core (`documents.rs:391`)
- [x] Update `handle_rfc_update_status` in server.rs
- [x] Update `handle_complete` in rfc.rs
- [x] Add unit tests for status replacement
- [x] Refactor `blue_spike_complete` to use shared helper
---
*"Right then. Let's get to it."*
— Blue

View file

@ -0,0 +1,159 @@
# RFC 0009: Audit Document Type
| | |
|---|---|
| **Status** | Implemented |
| **Date** | 2026-01-24 |
| **Source Spike** | audit-path-integration |
---
## Summary
Add Audit as a first-class document type in Blue. Rename the existing `blue_audit` health checker to `blue_health_check` to eliminate naming collision.
## Problem
Two distinct concepts share the name "audit":
1. **Health checks** - The current `blue_audit` tool scans for stalled RFCs, overdue reminders, expired locks
2. **Audit documents** - Formal reports documenting findings (security audits, repository audits, RFC verification)
This collision violates ADR 0005 (Single Source) and ADR 0007 (Integrity). Names should mean one thing.
### Evidence
Fungal-image-analysis has audit documents with no Blue integration:
```
docs/audits/
├── 2026-01-17-repository-audit.md
└── 2026-01-17-rfc-status-verification.md
```
These are valuable artifacts with no home in `.blue/docs/`.
## Proposal
### 1. Add DocType::Audit
```rust
// store.rs
pub enum DocType {
Rfc,
Spike,
Adr,
Decision,
Prd,
Postmortem,
Runbook,
Dialogue,
Audit, // NEW
}
```
### 2. Add audits path to BlueHome
```rust
// repo.rs
pub struct BlueHome {
pub root: PathBuf,
pub docs_path: PathBuf,
pub db_path: PathBuf,
pub worktrees_path: PathBuf,
pub audits_path: PathBuf, // NEW: .blue/docs/audits
}
```
### 3. Rename blue_audit → blue_health_check
| Old | New |
|-----|-----|
| `blue_audit` | `blue_health_check` |
The health check tool remains unchanged in functionality—just renamed for clarity.
### 4. Add Audit Document Tools
| Tool | Purpose |
|------|---------|
| `blue_audit_create` | Create a new audit document |
| `blue_audit_list` | List audit documents |
| `blue_audit_get` | Retrieve an audit by title |
### 5. Audit Document Structure
```markdown
# Audit: {Title}
| | |
|---|---|
| **Status** | In Progress / Complete |
| **Date** | YYYY-MM-DD |
| **Type** | repository / security / rfc-verification / custom |
| **Scope** | What was audited |
---
## Executive Summary
Brief findings overview.
## Findings
Detailed findings with severity ratings.
## Recommendations
Actionable next steps.
---
*Audited by Blue*
```
### 6. Audit Types
| Type | Purpose |
|------|---------|
| `repository` | Full codebase health assessment |
| `security` | Security-focused review |
| `rfc-verification` | Verify RFC statuses match reality |
| `adr-adherence` | Check code follows ADR decisions |
| `custom` | User-defined audit scope |
## Non-Goals
- Automated audit generation (that's a separate RFC)
- Integration with external audit tools
- Compliance framework mappings (SOC2, etc.)
## Test Plan
- [x] `DocType::Audit` added to store.rs
- [x] Audits stored in `.blue/docs/audits/` (uses docs_path + "audits/")
- [x] `blue_health_check` replaces `blue_audit`
- [x] `blue_audit_create` generates audit document
- [x] `blue_audit_list` returns audit documents
- [x] `blue_audit_get` retrieves audit by title
- [x] `blue_audit_complete` marks audit as complete
- [ ] Existing fungal audits portable to new structure (manual migration)
## Implementation Plan
- [x] Add `DocType::Audit` to store.rs with `as_str()` and `from_str()`
- [x] Create `Audit`, `AuditType`, `AuditFinding`, `AuditSeverity` in documents.rs
- [x] Add `Audit::to_markdown()` for document generation
- [x] Rename `blue_audit``blue_health_check` in server.rs
- [x] Create handlers/audit_doc.rs for audit document tools
- [x] Register new tools in server.rs (4 tools)
- [x] Add unit tests
## Migration
Existing `blue_audit` callers will get a deprecation notice pointing to `blue_health_check`. The old name will work for one release cycle.
---
*"A name collision is a lie waiting to confuse. We fix it now."*
— Blue

View file

@ -0,0 +1,62 @@
# Spike: Audit Path Integration
| | |
|---|---|
| **Status** | In Progress |
| **Date** | 2026-01-24 |
| **Time Box** | 30 minutes |
---
## Question
Does Blue MCP need updates for audit document paths and integration?
---
## Findings
### Current State
1. **`blue_audit` tool exists** but it's a health checker, not document management:
- Checks for stalled RFCs (in-progress without worktree)
- Finds implemented RFCs without ADRs
- Detects overdue reminders
- Identifies expired staging locks
2. **No `DocType::Audit`** in `blue-core/src/store.rs`:
```rust
pub enum DocType {
Rfc, Spike, Adr, Decision, Prd, Postmortem, Runbook, Dialogue
}
```
3. **No audit document paths** - `BlueHome` doesn't define an audits directory
4. **Fungal has audit documents** in `docs/audits/`:
- `2026-01-17-repository-audit.md` - Full repo audit report
- `2026-01-17-rfc-status-verification.md` - RFC status verification
### Gap Analysis
| Feature | Status |
|---------|--------|
| DocType::Audit | ❌ Missing |
| `.blue/docs/audits/` path | ❌ Missing |
| `blue_audit_create` tool | ❌ Missing |
| `blue_audit_list` tool | ❌ Missing |
### Recommendation
**Yes, Blue MCP needs updates** to support audit documents as a first-class document type:
1. Add `DocType::Audit` to store.rs
2. Add `audits_path` to `BlueHome`
3. Create `blue_audit_create` tool for generating audit reports
4. Rename current `blue_audit` to `blue_health_check` to avoid confusion
---
*"Two audits with the same name. One checks health, one documents findings. Let's clarify."*
— Blue

View file

@ -0,0 +1,75 @@
# Spike: Rfc Status Update Not Persisting
| | |
|---|---|
| **Status** | Complete |
| **Date** | 2026-01-24 |
| **Time Box** | 1 hour |
---
## Question
Why isn't blue_rfc_update_status (and possibly spike/ADR status updates) persisting to the database?
---
## Root Cause
The issue is **bidirectional sync failure** between database and markdown files:
### Problem 1: RFC/ADR status updates don't update markdown files
When `blue_rfc_update_status` or `blue_rfc_complete` are called:
- ✅ Database is updated via `update_document_status()`
- ❌ Markdown file is NOT updated
Compare to `blue_spike_complete` which correctly updates BOTH:
```rust
// spike.rs lines 118-139
state.store.update_document_status(DocType::Spike, title, "complete")?;
// ALSO updates the markdown file:
if spike_path.exists() {
let content = fs::read_to_string(&spike_path)?;
let updated = content
.replace("| **Status** | Complete |", "| **Status** | Complete |");
fs::write(&spike_path, updated)?;
}
```
### Problem 2: Manual file edits don't update database
When users edit markdown files directly (or Claude edits them):
- ✅ Markdown file is updated
- ❌ Database is NOT updated
### Evidence
| Document | DB Status | File Status |
|----------|-----------|-------------|
| `docs-path-resolution-bug` | `in-progress` | `Completed` |
| `dialogue-to-blue-directory` | `in-progress` | `Complete` |
| `consistent-branch-naming` | `implemented` | `Implemented` |
The first two were edited manually. The third was updated correctly because we used `blue_rfc_complete`.
## Recommended Fix
**Option A: Update all status change handlers to also update markdown files**
- Add markdown file update logic to `blue_rfc_update_status`
- Add markdown file update logic to `blue_rfc_complete`
- Add markdown file update logic to `blue_adr_*` handlers
**Option B: Single source of truth**
- Treat database as authoritative
- Generate markdown on-the-fly from database when needed
- More fundamental change but eliminates sync issues
**Recommendation:** Option A for now - it's simpler and matches the existing pattern in `blue_spike_complete`.
---
*"Two sources of truth means zero sources of truth."*
— Blue

View file

@ -4,6 +4,7 @@ version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
description = "Blue CLI - Welcome home" description = "Blue CLI - Welcome home"
build = "build.rs"
[[bin]] [[bin]]
name = "blue" name = "blue"
@ -18,3 +19,8 @@ tokio.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
chrono.workspace = true chrono.workspace = true
reqwest.workspace = true
serde.workspace = true
dirs.workspace = true
tempfile.workspace = true
serde_yaml.workspace = true

212
apps/blue-cli/build.rs Normal file
View file

@ -0,0 +1,212 @@
//! Build script for Blue CLI
//!
//! Downloads Goose binary for the target platform during build.
//! Binary is placed in OUT_DIR and copied to target dir post-build.
use std::env;
use std::fs;
use std::path::PathBuf;
#[allow(unused_imports)]
use std::io::Write;
const GOOSE_VERSION: &str = "1.21.1";
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-env-changed=BLUE_SKIP_DOWNLOAD");
println!("cargo:rerun-if-env-changed=BLUE_GOOSE_PATH");
// Skip download if explicitly disabled (for CI caching)
if env::var("BLUE_SKIP_DOWNLOAD").is_ok() {
println!("cargo:warning=Skipping Goose download (BLUE_SKIP_DOWNLOAD set)");
return;
}
// Use pre-downloaded binary if specified
if let Ok(path) = env::var("BLUE_GOOSE_PATH") {
println!("cargo:warning=Using pre-downloaded Goose from {}", path);
copy_goose_binary(&PathBuf::from(path));
return;
}
// Check if we already have the binary
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
let goose_binary = out_dir.join(if cfg!(windows) { "goose.exe" } else { "goose" });
if goose_binary.exists() {
println!("cargo:warning=Goose binary already exists");
copy_goose_binary(&goose_binary);
return;
}
// Download Goose for target platform
if let Err(e) = download_goose() {
println!("cargo:warning=Failed to download Goose: {}", e);
println!("cargo:warning=blue agent will check for system Goose at runtime");
}
}
fn download_goose() -> Result<(), Box<dyn std::error::Error>> {
let target = env::var("TARGET")?;
let out_dir = PathBuf::from(env::var("OUT_DIR")?);
let (url, archive_name) = get_goose_url(&target)?;
println!("cargo:warning=Downloading Goose {} for {}", GOOSE_VERSION, target);
// Download to OUT_DIR
let archive_path = out_dir.join(&archive_name);
download_file(&url, &archive_path)?;
// Extract binary
let goose_binary = extract_goose(&archive_path, &out_dir, &target)?;
// Copy to cargo output location
copy_goose_binary(&goose_binary);
Ok(())
}
fn get_goose_url(target: &str) -> Result<(String, String), Box<dyn std::error::Error>> {
let base = format!(
"https://github.com/block/goose/releases/download/v{}",
GOOSE_VERSION
);
let (archive, name) = match target {
// macOS ARM64 (M1/M2/M3/M4)
t if t.contains("aarch64") && t.contains("apple") => (
format!("{}/goose-aarch64-apple-darwin.tar.bz2", base),
"goose-aarch64-apple-darwin.tar.bz2".to_string(),
),
// macOS x86_64
t if t.contains("x86_64") && t.contains("apple") => (
format!("{}/goose-x86_64-apple-darwin.tar.bz2", base),
"goose-x86_64-apple-darwin.tar.bz2".to_string(),
),
// Linux x86_64
t if t.contains("x86_64") && t.contains("linux") => (
format!("{}/goose-x86_64-unknown-linux-gnu.tar.bz2", base),
"goose-x86_64-unknown-linux-gnu.tar.bz2".to_string(),
),
// Linux ARM64
t if t.contains("aarch64") && t.contains("linux") => (
format!("{}/goose-aarch64-unknown-linux-gnu.tar.bz2", base),
"goose-aarch64-unknown-linux-gnu.tar.bz2".to_string(),
),
// Windows x86_64
t if t.contains("x86_64") && t.contains("windows") => (
format!("{}/goose-x86_64-pc-windows-gnu.zip", base),
"goose-x86_64-pc-windows-gnu.zip".to_string(),
),
_ => return Err(format!("Unsupported target: {}", target).into()),
};
Ok((archive, name))
}
fn download_file(url: &str, dest: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
// Use curl for simplicity - available on all platforms
let status = std::process::Command::new("curl")
.args(["-L", "-o"])
.arg(dest)
.arg(url)
.status()?;
if !status.success() {
return Err(format!("curl failed with status: {}", status).into());
}
Ok(())
}
fn extract_goose(
archive: &PathBuf,
out_dir: &PathBuf,
target: &str,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
let binary_name = if target.contains("windows") {
"goose.exe"
} else {
"goose"
};
if archive.to_string_lossy().ends_with(".tar.bz2") {
// Extract tar.bz2
let status = std::process::Command::new("tar")
.args(["-xjf"])
.arg(archive)
.arg("-C")
.arg(out_dir)
.status()?;
if !status.success() {
return Err("tar extraction failed".into());
}
} else if archive.to_string_lossy().ends_with(".zip") {
// Extract zip
let status = std::process::Command::new("unzip")
.args(["-o"])
.arg(archive)
.arg("-d")
.arg(out_dir)
.status()?;
if !status.success() {
return Err("unzip extraction failed".into());
}
}
// Find the goose binary (might be in a subdirectory)
let binary_path = find_binary(out_dir, binary_name)?;
// Make executable on Unix
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&binary_path)?.permissions();
perms.set_mode(0o755);
fs::set_permissions(&binary_path, perms)?;
}
Ok(binary_path)
}
fn find_binary(dir: &PathBuf, name: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
// Check direct path first
let direct = dir.join(name);
if direct.exists() {
return Ok(direct);
}
// Search subdirectories
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
if let Ok(found) = find_binary(&path, name) {
return Ok(found);
}
} else if path.file_name().map(|n| n == name).unwrap_or(false) {
return Ok(path);
}
}
Err(format!("Binary {} not found in {:?}", name, dir).into())
}
fn copy_goose_binary(source: &PathBuf) {
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
// Tell Cargo where the binary is
println!("cargo:rustc-env=GOOSE_BINARY_PATH={}", source.display());
// Also copy to a known location in OUT_DIR for runtime discovery
let dest = out_dir.join("goose");
if source != &dest {
if let Err(e) = fs::copy(source, &dest) {
println!("cargo:warning=Failed to copy Goose binary: {}", e);
}
}
}

View file

@ -1135,26 +1135,15 @@ async fn handle_session_command(command: SessionCommands) -> Result<()> {
async fn handle_agent_command(model: Option<String>, extra_args: Vec<String>) -> Result<()> { async fn handle_agent_command(model: Option<String>, extra_args: Vec<String>) -> Result<()> {
use std::process::Command; use std::process::Command;
// Check if Goose is installed // Find Goose binary: bundled first, then system
let goose_check = Command::new("goose") let goose_path = find_goose_binary()?;
.arg("--version")
.output();
match goose_check { // Check if Ollama is running and get available models
Err(_) => { let ollama_model = if model.is_none() {
println!("Goose not found. Install it first:"); detect_ollama_model().await
println!(" pipx install goose-ai"); } else {
println!(" # or"); None
println!(" brew install goose"); };
println!("\nSee https://github.com/block/goose for more options.");
return Ok(());
}
Ok(output) if !output.status.success() => {
println!("Goose check failed. Ensure it's properly installed.");
return Ok(());
}
Ok(_) => {}
}
// Get the path to the blue binary // Get the path to the blue binary
let blue_binary = std::env::current_exe()?; let blue_binary = std::env::current_exe()?;
@ -1163,16 +1152,68 @@ async fn handle_agent_command(model: Option<String>, extra_args: Vec<String>) ->
let extension_cmd = format!("{} mcp", blue_binary.display()); let extension_cmd = format!("{} mcp", blue_binary.display());
println!("Starting Goose with Blue extension..."); println!("Starting Goose with Blue extension...");
println!(" Goose: {}", goose_path.display());
println!(" Extension: {}", extension_cmd); println!(" Extension: {}", extension_cmd);
// Configure Goose for the model
let (provider, model_name) = if let Some(m) = &model {
// User specified a model - could be "provider/model" format
if m.contains('/') {
let parts: Vec<&str> = m.splitn(2, '/').collect();
(parts[0].to_string(), parts[1].to_string())
} else {
// Assume ollama if no provider specified
("ollama".to_string(), m.clone())
}
} else if let Some(m) = ollama_model {
("ollama".to_string(), m)
} else {
// Check if goose is already configured
let config_path = dirs::config_dir()
.map(|d| d.join("goose").join("config.yaml"));
if let Some(path) = &config_path {
if path.exists() {
let content = std::fs::read_to_string(path).unwrap_or_default();
if content.contains("GOOSE_PROVIDER") {
println!(" Using existing Goose config");
("".to_string(), "".to_string())
} else {
anyhow::bail!(
"No model available. Either:\n \
1. Start Ollama with a model: ollama run qwen2.5:7b\n \
2. Specify a model: blue agent --model ollama/qwen2.5:7b\n \
3. Configure Goose: goose configure"
);
}
} else {
anyhow::bail!(
"No model available. Either:\n \
1. Start Ollama with a model: ollama run qwen2.5:7b\n \
2. Specify a model: blue agent --model ollama/qwen2.5:7b\n \
3. Configure Goose: goose configure"
);
}
} else {
anyhow::bail!("Could not determine config directory");
}
};
// Build goose command // Build goose command
let mut cmd = Command::new("goose"); let mut cmd = Command::new(&goose_path);
cmd.arg("session"); cmd.arg("session");
cmd.arg("--with-extension").arg(&extension_cmd); cmd.arg("--with-extension").arg(&extension_cmd);
// Add model if specified // Configure via environment variables (more reliable than config file)
if let Some(m) = model { if !provider.is_empty() {
cmd.arg("--model").arg(m); cmd.env("GOOSE_PROVIDER", &provider);
cmd.env("GOOSE_MODEL", &model_name);
println!(" Provider: {}", provider);
println!(" Model: {}", model_name);
if provider == "ollama" {
cmd.env("OLLAMA_HOST", "http://localhost:11434");
}
} }
// Add any extra arguments // Add any extra arguments
@ -1199,3 +1240,212 @@ async fn handle_agent_command(model: Option<String>, extra_args: Vec<String>) ->
Ok(()) Ok(())
} }
} }
fn find_goose_binary() -> Result<std::path::PathBuf> {
use std::path::PathBuf;
let binary_name = if cfg!(windows) { "goose.exe" } else { "goose" };
// 1. Check Blue's data directory (~/.local/share/blue/bin/goose)
if let Some(data_dir) = dirs::data_dir() {
let blue_bin = data_dir.join("blue").join("bin").join(binary_name);
if blue_bin.exists() && is_block_goose(&blue_bin) {
return Ok(blue_bin);
}
}
// 2. Check for bundled binary next to blue executable
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let bundled = dir.join(binary_name);
if bundled.exists() && is_block_goose(&bundled) {
return Ok(bundled);
}
}
}
// 3. Check compile-time bundled path (dev builds)
if let Some(path) = option_env!("GOOSE_BINARY_PATH") {
let bundled = PathBuf::from(path);
if bundled.exists() && is_block_goose(&bundled) {
return Ok(bundled);
}
}
// 4. Not found - download it
println!("Goose not found. Downloading...");
download_goose_runtime()
}
fn is_block_goose(path: &std::path::Path) -> bool {
// Check if it's Block's Goose (AI agent), not pressly/goose (DB migration)
if let Ok(output) = std::process::Command::new(path).arg("--version").output() {
let version = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
// Block's goose outputs version without "DRIVER" references
// and has "session" subcommand
!version.contains("DRIVER") && !stderr.contains("DRIVER")
} else {
false
}
}
fn download_goose_runtime() -> Result<std::path::PathBuf> {
use std::path::PathBuf;
const GOOSE_VERSION: &str = "1.21.1";
let data_dir = dirs::data_dir()
.ok_or_else(|| anyhow::anyhow!("Could not determine data directory"))?;
let bin_dir = data_dir.join("blue").join("bin");
std::fs::create_dir_all(&bin_dir)?;
let binary_name = if cfg!(windows) { "goose.exe" } else { "goose" };
let dest = bin_dir.join(binary_name);
// Determine download URL based on platform
let (url, is_zip) = get_goose_download_url(GOOSE_VERSION)?;
println!(" Downloading from: {}", url);
// Download to temp file
let temp_dir = tempfile::tempdir()?;
let archive_path = temp_dir.path().join("goose-archive");
let status = std::process::Command::new("curl")
.args(["-L", "-o"])
.arg(&archive_path)
.arg(&url)
.status()?;
if !status.success() {
anyhow::bail!("Failed to download Goose");
}
// Extract
if is_zip {
let status = std::process::Command::new("unzip")
.args(["-o"])
.arg(&archive_path)
.arg("-d")
.arg(temp_dir.path())
.status()?;
if !status.success() {
anyhow::bail!("Failed to extract Goose zip");
}
} else {
let status = std::process::Command::new("tar")
.args(["-xjf"])
.arg(&archive_path)
.arg("-C")
.arg(temp_dir.path())
.status()?;
if !status.success() {
anyhow::bail!("Failed to extract Goose archive");
}
}
// Find the goose binary in extracted files
let extracted = find_file_recursive(temp_dir.path(), binary_name)?;
// Copy to destination
std::fs::copy(&extracted, &dest)?;
// Make executable on Unix
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(&dest)?.permissions();
perms.set_mode(0o755);
std::fs::set_permissions(&dest, perms)?;
}
println!(" Installed to: {}", dest.display());
Ok(dest)
}
fn get_goose_download_url(version: &str) -> Result<(String, bool)> {
let base = format!(
"https://github.com/block/goose/releases/download/v{}",
version
);
let (arch, os) = (std::env::consts::ARCH, std::env::consts::OS);
let (file, is_zip) = match (arch, os) {
("aarch64", "macos") => ("goose-aarch64-apple-darwin.tar.bz2", false),
("x86_64", "macos") => ("goose-x86_64-apple-darwin.tar.bz2", false),
("x86_64", "linux") => ("goose-x86_64-unknown-linux-gnu.tar.bz2", false),
("aarch64", "linux") => ("goose-aarch64-unknown-linux-gnu.tar.bz2", false),
("x86_64", "windows") => ("goose-x86_64-pc-windows-gnu.zip", true),
_ => anyhow::bail!("Unsupported platform: {} {}", arch, os),
};
Ok((format!("{}/{}", base, file), is_zip))
}
fn find_file_recursive(dir: &std::path::Path, name: &str) -> Result<std::path::PathBuf> {
// Check direct path
let direct = dir.join(name);
if direct.exists() {
return Ok(direct);
}
// Search subdirectories
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
if let Ok(found) = find_file_recursive(&path, name) {
return Ok(found);
}
} else if path.file_name().map(|n| n == name).unwrap_or(false) {
return Ok(path);
}
}
anyhow::bail!("Binary {} not found in {:?}", name, dir)
}
async fn detect_ollama_model() -> Option<String> {
// Check if Ollama is running
let client = reqwest::Client::new();
let resp = client
.get("http://localhost:11434/api/tags")
.timeout(std::time::Duration::from_secs(2))
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
#[derive(serde::Deserialize)]
struct OllamaModels {
models: Vec<OllamaModel>,
}
#[derive(serde::Deserialize)]
struct OllamaModel {
name: String,
size: u64,
}
let models: OllamaModels = resp.json().await.ok()?;
if models.models.is_empty() {
return None;
}
// Prefer larger models (likely better for agentic work)
// Sort by size descending and pick first
let mut sorted = models.models;
sorted.sort_by(|a, b| b.size.cmp(&a.size));
let best = &sorted[0];
println!(" Detected Ollama with {} model(s)", sorted.len());
Some(best.name.clone())
}

View file

@ -24,6 +24,7 @@ tower-http.workspace = true
reqwest.workspace = true reqwest.workspace = true
dirs.workspace = true dirs.workspace = true
semver.workspace = true semver.workspace = true
regex.workspace = true
[dev-dependencies] [dev-dependencies]
tower.workspace = true tower.workspace = true

View file

@ -103,6 +103,81 @@ pub struct Adr {
pub consequences: Vec<String>, pub consequences: Vec<String>,
} }
/// An Audit document - formal findings report
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Audit {
pub title: String,
pub status: String,
pub date: String,
pub audit_type: AuditType,
pub scope: String,
pub summary: Option<String>,
pub findings: Vec<AuditFinding>,
pub recommendations: Vec<String>,
}
/// Types of audits
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AuditType {
Repository,
Security,
RfcVerification,
AdrAdherence,
Custom,
}
impl AuditType {
pub fn as_str(&self) -> &'static str {
match self {
AuditType::Repository => "repository",
AuditType::Security => "security",
AuditType::RfcVerification => "rfc-verification",
AuditType::AdrAdherence => "adr-adherence",
AuditType::Custom => "custom",
}
}
pub fn from_str(s: &str) -> Option<Self> {
match s.to_lowercase().as_str() {
"repository" => Some(AuditType::Repository),
"security" => Some(AuditType::Security),
"rfc-verification" => Some(AuditType::RfcVerification),
"adr-adherence" => Some(AuditType::AdrAdherence),
"custom" => Some(AuditType::Custom),
_ => None,
}
}
}
/// A finding within an audit
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditFinding {
pub category: String,
pub title: String,
pub description: String,
pub severity: AuditSeverity,
}
/// Severity of an audit finding
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum AuditSeverity {
Error,
Warning,
Info,
}
impl AuditSeverity {
pub fn as_str(&self) -> &'static str {
match self {
AuditSeverity::Error => "error",
AuditSeverity::Warning => "warning",
AuditSeverity::Info => "info",
}
}
}
impl Rfc { impl Rfc {
/// Create a new RFC in draft status /// Create a new RFC in draft status
pub fn new(title: impl Into<String>) -> Self { pub fn new(title: impl Into<String>) -> Self {
@ -362,6 +437,75 @@ impl Decision {
} }
} }
impl Audit {
/// Create a new Audit
pub fn new(title: impl Into<String>, audit_type: AuditType, scope: impl Into<String>) -> Self {
Self {
title: title.into(),
status: "in-progress".to_string(),
date: today(),
audit_type,
scope: scope.into(),
summary: None,
findings: Vec::new(),
recommendations: Vec::new(),
}
}
/// Generate markdown content
pub fn to_markdown(&self) -> String {
let mut md = String::new();
md.push_str(&format!("# Audit: {}\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));
md.push_str(&format!(
"| **Type** | {} |\n",
to_title_case(self.audit_type.as_str())
));
md.push_str(&format!("| **Scope** | {} |\n", self.scope));
md.push_str("\n---\n\n");
if let Some(ref summary) = self.summary {
md.push_str("## Executive Summary\n\n");
md.push_str(summary);
md.push_str("\n\n");
}
if !self.findings.is_empty() {
md.push_str("## Findings\n\n");
for finding in &self.findings {
md.push_str(&format!(
"### {} ({})\n\n",
finding.title,
finding.severity.as_str()
));
md.push_str(&format!("**Category:** {}\n\n", finding.category));
md.push_str(&finding.description);
md.push_str("\n\n");
}
}
if !self.recommendations.is_empty() {
md.push_str("## Recommendations\n\n");
for rec in &self.recommendations {
md.push_str(&format!("- {}\n", rec));
}
md.push('\n');
}
md.push_str("---\n\n");
md.push_str("*Audited by Blue*\n");
md
}
}
/// Get current date in YYYY-MM-DD format /// Get current date in YYYY-MM-DD format
fn today() -> String { fn today() -> String {
chrono::Utc::now().format("%Y-%m-%d").to_string() chrono::Utc::now().format("%Y-%m-%d").to_string()
@ -381,6 +525,46 @@ fn to_title_case(s: &str) -> String {
.join(" ") .join(" ")
} }
/// Update status in a markdown file
///
/// Handles common status patterns:
/// - `| **Status** | Draft |` (table format)
/// - `**Status:** Draft` (inline format)
///
/// Returns Ok(true) if status was updated, Ok(false) if no match found.
pub fn update_markdown_status(
file_path: &std::path::Path,
new_status: &str,
) -> Result<bool, std::io::Error> {
use std::fs;
if !file_path.exists() {
return Ok(false);
}
let content = fs::read_to_string(file_path)?;
let display_status = to_title_case(new_status);
// Try table format: | **Status** | <anything> |
let table_pattern = regex::Regex::new(r"\| \*\*Status\*\* \| [^|]+ \|").unwrap();
let mut updated = table_pattern
.replace(&content, format!("| **Status** | {} |", display_status).as_str())
.to_string();
// Also try inline format: **Status:** <word>
let inline_pattern = regex::Regex::new(r"\*\*Status:\*\* \S+").unwrap();
updated = inline_pattern
.replace(&updated, format!("**Status:** {}", display_status).as_str())
.to_string();
let changed = updated != content;
if changed {
fs::write(file_path, updated)?;
}
Ok(changed)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -411,4 +595,41 @@ mod tests {
assert!(md.contains("# Spike: Test Investigation")); assert!(md.contains("# Spike: Test Investigation"));
assert!(md.contains("What should we do?")); assert!(md.contains("What should we do?"));
} }
#[test]
fn test_update_markdown_status_table_format() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.md");
let content = "# RFC\n\n| | |\n|---|---|\n| **Status** | Draft |\n| **Date** | 2026-01-24 |\n";
fs::write(&file, content).unwrap();
let changed = update_markdown_status(&file, "implemented").unwrap();
assert!(changed);
let updated = fs::read_to_string(&file).unwrap();
assert!(updated.contains("| **Status** | Implemented |"));
assert!(!updated.contains("Draft"));
}
#[test]
fn test_update_markdown_status_no_file() {
let path = std::path::Path::new("/nonexistent/file.md");
let changed = update_markdown_status(path, "implemented").unwrap();
assert!(!changed);
}
#[test]
fn test_update_markdown_status_no_status_field() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.md");
let content = "# Just a document\n\nNo status here.\n";
fs::write(&file, content).unwrap();
let changed = update_markdown_status(&file, "implemented").unwrap();
assert!(!changed);
}
} }

View file

@ -23,7 +23,7 @@ pub mod store;
pub mod voice; pub mod voice;
pub mod workflow; pub mod workflow;
pub use documents::*; pub use documents::{Adr, Audit, AuditFinding, AuditSeverity, AuditType, Decision, Rfc, Spike, SpikeOutcome, Status, Task, update_markdown_status};
pub use llm::{CompletionOptions, CompletionResult, LlmBackendChoice, LlmConfig, LlmError, LlmManager, LlmProvider, LlmProviderChoice, LocalLlmConfig, ApiLlmConfig, KeywordLlm, MockLlm, ProviderStatus}; pub use llm::{CompletionOptions, CompletionResult, LlmBackendChoice, LlmConfig, LlmError, LlmManager, LlmProvider, LlmProviderChoice, LocalLlmConfig, ApiLlmConfig, KeywordLlm, MockLlm, ProviderStatus};
pub use repo::{detect_blue, BlueHome, RepoError, WorktreeInfo}; pub use repo::{detect_blue, BlueHome, RepoError, WorktreeInfo};
pub use state::{ItemType, ProjectState, StateError, StatusSummary, WorkItem}; pub use state::{ItemType, ProjectState, StateError, StatusSummary, WorkItem};

View file

@ -185,6 +185,7 @@ pub enum DocType {
Postmortem, Postmortem,
Runbook, Runbook,
Dialogue, Dialogue,
Audit,
} }
impl DocType { impl DocType {
@ -198,6 +199,7 @@ impl DocType {
DocType::Postmortem => "postmortem", DocType::Postmortem => "postmortem",
DocType::Runbook => "runbook", DocType::Runbook => "runbook",
DocType::Dialogue => "dialogue", DocType::Dialogue => "dialogue",
DocType::Audit => "audit",
} }
} }
@ -211,6 +213,7 @@ impl DocType {
"postmortem" => Some(DocType::Postmortem), "postmortem" => Some(DocType::Postmortem),
"runbook" => Some(DocType::Runbook), "runbook" => Some(DocType::Runbook),
"dialogue" => Some(DocType::Dialogue), "dialogue" => Some(DocType::Dialogue),
"audit" => Some(DocType::Audit),
_ => None, _ => None,
} }
} }
@ -226,6 +229,7 @@ impl DocType {
DocType::Postmortem => "post-mortems", DocType::Postmortem => "post-mortems",
DocType::Runbook => "runbooks", DocType::Runbook => "runbooks",
DocType::Dialogue => "dialogues", DocType::Dialogue => "dialogues",
DocType::Audit => "audits",
} }
} }
} }

View file

@ -0,0 +1,199 @@
//! Audit document tool handlers
//!
//! Handles audit document creation and management.
//! Note: This is different from the health check (formerly blue_audit).
use std::fs;
use blue_core::{Audit, AuditType, DocType, Document, ProjectState};
use serde_json::{json, Value};
use crate::error::ServerError;
/// Handle blue_audit_create
pub fn handle_create(state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
let title = args
.get("title")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
let audit_type_str = args
.get("audit_type")
.and_then(|v| v.as_str())
.unwrap_or("custom");
let scope = args
.get("scope")
.and_then(|v| v.as_str())
.unwrap_or("Project audit");
let audit_type = AuditType::from_str(audit_type_str)
.unwrap_or(AuditType::Custom);
// Create the audit
let audit = Audit::new(title, audit_type, scope);
// Generate filename with date
let date = chrono::Utc::now().format("%Y-%m-%d").to_string();
let filename = format!("audits/{}-{}.md", date, title);
// Generate markdown
let markdown = audit.to_markdown();
// Write the file
let docs_path = state.home.docs_path.clone();
let audit_path = docs_path.join(&filename);
if let Some(parent) = audit_path.parent() {
fs::create_dir_all(parent).map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
}
fs::write(&audit_path, &markdown).map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
// Add to store
let mut doc = Document::new(DocType::Audit, title, "in-progress");
doc.file_path = Some(filename.clone());
let id = state
.store
.add_document(&doc)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
Ok(json!({
"status": "success",
"id": id,
"title": title,
"audit_type": audit_type_str,
"date": date,
"file": audit_path.display().to_string(),
"markdown": markdown,
"message": blue_core::voice::success(
&format!("Created audit '{}'", title),
Some("Document your findings.")
)
}))
}
/// Handle blue_audit_list
pub fn handle_list(state: &ProjectState) -> Result<Value, ServerError> {
let audits = state
.store
.list_documents(DocType::Audit)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
let items: Vec<Value> = audits
.iter()
.map(|doc| {
json!({
"id": doc.id,
"title": doc.title,
"status": doc.status,
"file_path": doc.file_path,
"created_at": doc.created_at,
})
})
.collect();
Ok(json!({
"status": "success",
"count": items.len(),
"audits": items,
"message": if items.is_empty() {
blue_core::voice::info("No audits found.", None::<&str>)
} else {
blue_core::voice::info(
&format!("Found {} audit(s).", items.len()),
None::<&str>
)
}
}))
}
/// Handle blue_audit_get
pub fn handle_get(state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
let title = args
.get("title")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
let doc = state
.store
.find_document(DocType::Audit, title)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
// Read the file content if it exists
let content = if let Some(ref file_path) = doc.file_path {
let full_path = state.home.docs_path.join(file_path);
fs::read_to_string(&full_path).ok()
} else {
None
};
Ok(json!({
"status": "success",
"id": doc.id,
"title": doc.title,
"doc_status": doc.status,
"file_path": doc.file_path,
"content": content,
"created_at": doc.created_at,
"updated_at": doc.updated_at,
}))
}
/// Handle blue_audit_complete
pub fn handle_complete(state: &ProjectState, args: &Value) -> Result<Value, ServerError> {
let title = args
.get("title")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
// Find the audit
let doc = state
.store
.find_document(DocType::Audit, title)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
// Update status in database
state
.store
.update_document_status(DocType::Audit, title, "complete")
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
// Update markdown file (RFC 0008)
if let Some(ref file_path) = doc.file_path {
let full_path = state.home.docs_path.join(file_path);
let _ = blue_core::update_markdown_status(&full_path, "complete");
}
Ok(json!({
"status": "success",
"title": title,
"new_status": "complete",
"message": blue_core::voice::success(
&format!("Completed audit '{}'", title),
Some("Findings documented.")
)
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_create_requires_title() {
let state = ProjectState::for_test();
let args = json!({});
let result = handle_create(&state, &args);
assert!(result.is_err());
}
#[test]
fn test_list_empty() {
let state = ProjectState::for_test();
let result = handle_list(&state).unwrap();
assert_eq!(result["status"], "success");
assert_eq!(result["count"], 0);
}
}

View file

@ -3,7 +3,8 @@
//! Each module handles a specific document type or workflow. //! Each module handles a specific document type or workflow.
pub mod adr; pub mod adr;
pub mod audit; pub mod audit; // Health check (blue_health_check)
pub mod audit_doc; // Audit documents (blue_audit_create, etc.)
pub mod decision; pub mod decision;
pub mod delete; pub mod delete;
pub mod dialogue; pub mod dialogue;

View file

@ -105,6 +105,12 @@ pub fn handle_complete(state: &ProjectState, args: &Value) -> Result<Value, Serv
.update_document_status(DocType::Rfc, title, "implemented") .update_document_status(DocType::Rfc, title, "implemented")
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
// Update markdown file (RFC 0008)
if let Some(ref file_path) = doc.file_path {
let full_path = state.home.docs_path.join(file_path);
let _ = blue_core::update_markdown_status(&full_path, "implemented");
}
// Determine follow-up needs // Determine follow-up needs
let followup_needed = percentage < 100; let followup_needed = percentage < 100;
let remaining_count = total - completed; let remaining_count = total - completed;

View file

@ -121,22 +121,10 @@ pub fn handle_complete(state: &ProjectState, args: &Value) -> Result<Value, Serv
.update_document_status(DocType::Spike, title, "complete") .update_document_status(DocType::Spike, title, "complete")
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
// Update the file if it exists // Update markdown file (RFC 0008: use shared helper)
if let Some(ref file_path) = doc.file_path { if let Some(ref file_path) = doc.file_path {
let docs_path = state.home.docs_path.clone(); let full_path = state.home.docs_path.join(file_path);
let spike_path = docs_path.join(file_path); let _ = blue_core::update_markdown_status(&full_path, "complete");
if spike_path.exists() {
let content = fs::read_to_string(&spike_path)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
let updated = content
.replace("| **Status** | In Progress |", "| **Status** | Complete |")
.replace("| **Status** | in-progress |", "| **Status** | Complete |");
fs::write(&spike_path, updated)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
}
} }
let hint = match outcome { let hint = match outcome {

View file

@ -920,8 +920,8 @@ impl BlueServer {
} }
}, },
{ {
"name": "blue_audit", "name": "blue_health_check",
"description": "Check project health and find issues. Returns stalled work, missing ADRs, and recommendations.", "description": "Check project health and find issues. Returns stalled work, missing ADRs, overdue reminders, and recommendations.",
"inputSchema": { "inputSchema": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -932,6 +932,82 @@ impl BlueServer {
} }
} }
}, },
{
"name": "blue_audit_create",
"description": "Create a new audit document (repository, security, rfc-verification, adr-adherence, or custom).",
"inputSchema": {
"type": "object",
"properties": {
"cwd": {
"type": "string",
"description": "Current working directory"
},
"title": {
"type": "string",
"description": "Audit title in kebab-case"
},
"audit_type": {
"type": "string",
"description": "Type of audit",
"enum": ["repository", "security", "rfc-verification", "adr-adherence", "custom"]
},
"scope": {
"type": "string",
"description": "What is being audited"
}
},
"required": ["title"]
}
},
{
"name": "blue_audit_list",
"description": "List all audit documents.",
"inputSchema": {
"type": "object",
"properties": {
"cwd": {
"type": "string",
"description": "Current working directory"
}
}
}
},
{
"name": "blue_audit_get",
"description": "Get an audit document by title.",
"inputSchema": {
"type": "object",
"properties": {
"cwd": {
"type": "string",
"description": "Current working directory"
},
"title": {
"type": "string",
"description": "Audit title"
}
},
"required": ["title"]
}
},
{
"name": "blue_audit_complete",
"description": "Mark an audit as complete.",
"inputSchema": {
"type": "object",
"properties": {
"cwd": {
"type": "string",
"description": "Current working directory"
},
"title": {
"type": "string",
"description": "Audit title"
}
},
"required": ["title"]
}
},
{ {
"name": "blue_rfc_complete", "name": "blue_rfc_complete",
"description": "Mark RFC as implemented based on plan progress. Requires at least 70% completion.", "description": "Mark RFC as implemented based on plan progress. Requires at least 70% completion.",
@ -1916,8 +1992,12 @@ impl BlueServer {
"blue_staging_status" => self.handle_staging_status(&call.arguments), "blue_staging_status" => self.handle_staging_status(&call.arguments),
"blue_staging_cleanup" => self.handle_staging_cleanup(&call.arguments), "blue_staging_cleanup" => self.handle_staging_cleanup(&call.arguments),
"blue_staging_deployments" => self.handle_staging_deployments(&call.arguments), "blue_staging_deployments" => self.handle_staging_deployments(&call.arguments),
// Phase 6: Audit and completion handlers // Phase 6: Health check, audit documents, and completion handlers
"blue_audit" => self.handle_audit(&call.arguments), "blue_health_check" => self.handle_health_check(&call.arguments),
"blue_audit_create" => self.handle_audit_create(&call.arguments),
"blue_audit_list" => self.handle_audit_list(&call.arguments),
"blue_audit_get" => self.handle_audit_get(&call.arguments),
"blue_audit_complete" => self.handle_audit_complete(&call.arguments),
"blue_rfc_complete" => self.handle_rfc_complete(&call.arguments), "blue_rfc_complete" => self.handle_rfc_complete(&call.arguments),
"blue_worktree_cleanup" => self.handle_worktree_cleanup(&call.arguments), "blue_worktree_cleanup" => self.handle_worktree_cleanup(&call.arguments),
// Phase 7: PRD handlers // Phase 7: PRD handlers
@ -2203,13 +2283,27 @@ impl BlueServer {
let state = self.ensure_state()?; let state = self.ensure_state()?;
// Find the document to get its file path
let doc = state.store.find_document(DocType::Rfc, title)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
// Update database
state.store.update_document_status(DocType::Rfc, title, status) state.store.update_document_status(DocType::Rfc, title, status)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
// Update markdown file (RFC 0008)
let file_updated = if let Some(ref file_path) = doc.file_path {
let full_path = state.home.docs_path.join(file_path);
blue_core::update_markdown_status(&full_path, status).unwrap_or(false)
} else {
false
};
Ok(json!({ Ok(json!({
"status": "success", "status": "success",
"title": title, "title": title,
"new_status": status, "new_status": status,
"file_updated": file_updated,
"message": blue_core::voice::success( "message": blue_core::voice::success(
&format!("Updated '{}' to {}", title, status), &format!("Updated '{}' to {}", title, status),
None None
@ -2626,13 +2720,36 @@ impl BlueServer {
crate::handlers::staging::handle_deployments(state, args) crate::handlers::staging::handle_deployments(state, args)
} }
// Phase 6: Audit and completion handlers // Phase 6: Health check, audit documents, and completion handlers
fn handle_audit(&mut self, _args: &Option<Value>) -> Result<Value, ServerError> { fn handle_health_check(&mut self, _args: &Option<Value>) -> Result<Value, ServerError> {
let state = self.ensure_state()?; let state = self.ensure_state()?;
crate::handlers::audit::handle_audit(state) crate::handlers::audit::handle_audit(state)
} }
fn handle_audit_create(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
let state = self.ensure_state()?;
crate::handlers::audit_doc::handle_create(state, args)
}
fn handle_audit_list(&mut self, _args: &Option<Value>) -> Result<Value, ServerError> {
let state = self.ensure_state()?;
crate::handlers::audit_doc::handle_list(state)
}
fn handle_audit_get(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
let state = self.ensure_state()?;
crate::handlers::audit_doc::handle_get(state, args)
}
fn handle_audit_complete(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
let state = self.ensure_state()?;
crate::handlers::audit_doc::handle_complete(state, args)
}
fn handle_rfc_complete(&mut self, args: &Option<Value>) -> Result<Value, ServerError> { fn handle_rfc_complete(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
let args = args.as_ref().ok_or(ServerError::InvalidParams)?; let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
let state = self.ensure_state()?; let state = self.ensure_state()?;