diff --git a/.blue/blue.db b/.blue/blue.db index 9848fa7..e516d36 100644 Binary files a/.blue/blue.db and b/.blue/blue.db differ diff --git a/.blue/docs/rfcs/0001-dialogue-sqlite-metadata.md b/.blue/docs/rfcs/0001-dialogue-sqlite-metadata.md index e9f60d1..1b99f09 100644 --- a/.blue/docs/rfcs/0001-dialogue-sqlite-metadata.md +++ b/.blue/docs/rfcs/0001-dialogue-sqlite-metadata.md @@ -2,7 +2,7 @@ | | | |---|---| -| **Status** | Draft | +| **Status** | Implemented | | **Date** | 2026-01-24 | | **Source Spike** | sqlite-storage-expansion | diff --git a/.blue/docs/rfcs/0002-runbook-action-lookup.md b/.blue/docs/rfcs/0002-runbook-action-lookup.md index 9d2c190..3bcb538 100644 --- a/.blue/docs/rfcs/0002-runbook-action-lookup.md +++ b/.blue/docs/rfcs/0002-runbook-action-lookup.md @@ -2,7 +2,7 @@ | | | |---|---| -| **Status** | Draft | +| **Status** | Implemented | | **Date** | 2026-01-24 | | **Source Spike** | runbook-driven-actions | diff --git a/.blue/docs/rfcs/0003-per-repo-blue-folders.md b/.blue/docs/rfcs/0003-per-repo-blue-folders.md index 2981dcc..d9b2910 100644 --- a/.blue/docs/rfcs/0003-per-repo-blue-folders.md +++ b/.blue/docs/rfcs/0003-per-repo-blue-folders.md @@ -2,7 +2,7 @@ | | | |---|---| -| **Status** | Draft | +| **Status** | Implemented | | **Date** | 2026-01-24 | | **Source Spike** | per-repo-blue-folder | diff --git a/.blue/docs/rfcs/0004-adr-adherence.md b/.blue/docs/rfcs/0004-adr-adherence.md index c198fa9..bf5d62b 100644 --- a/.blue/docs/rfcs/0004-adr-adherence.md +++ b/.blue/docs/rfcs/0004-adr-adherence.md @@ -2,7 +2,7 @@ | | | |---|---| -| **Status** | Draft | +| **Status** | Implemented | | **Date** | 2026-01-24 | | **Source Spike** | adr-adherence | | **ADRs** | 0004 (Evidence), 0007 (Integrity), 0008 (Honor) | diff --git a/.blue/docs/rfcs/0005-local-llm-integration.md b/.blue/docs/rfcs/0005-local-llm-integration.md index bb736ec..6fb6234 100644 --- a/.blue/docs/rfcs/0005-local-llm-integration.md +++ b/.blue/docs/rfcs/0005-local-llm-integration.md @@ -2,7 +2,7 @@ | | | |---|---| -| **Status** | Draft | +| **Status** | Implemented | | **Date** | 2026-01-24 | | **Source Spike** | local-llm-integration, agentic-cli-integration | @@ -307,53 +307,81 @@ blue_model_pull name="qwen2.5:7b" ### 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 -# 1. Start Blue (starts embedded Ollama) -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 +# Just run it - Blue handles everything blue agent -# Equivalent to: -# 1. Ensure Blue daemon running (Ollama ready) -# 2. Launch Goose with Blue extension -# 3. Model auto-pulled if missing +# What happens: +# 1. Uses bundled Goose binary (downloaded at build time) +# 2. Detects Ollama running on localhost:11434 +# 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:** | Without Blue | With Blue | |--------------|-----------| +| Install Goose separately | Blue bundles Goose | | Install Ollama separately | Blue bundles Ollama | -| Configure Goose manually | `blue agent` just works | -| Model loaded twice (Ollama + Goose) | One model instance | +| Configure Goose manually | `blue agent` auto-configures | +| Model loaded twice | One model instance | | 40GB RAM for two 32B models | 20GB for shared model | ### 6. Graceful Degradation diff --git a/.blue/docs/rfcs/0006-document-deletion-tools.md b/.blue/docs/rfcs/0006-document-deletion-tools.md index 6d47854..0d51982 100644 --- a/.blue/docs/rfcs/0006-document-deletion-tools.md +++ b/.blue/docs/rfcs/0006-document-deletion-tools.md @@ -2,7 +2,7 @@ | | | |---|---| -| **Status** | In-Progress | +| **Status** | Implemented | | **Date** | 2026-01-24 | | **Ported From** | coherence-mcp RFC 0050 | | **Alignment** | 94% (12 experts, 5 tensions resolved) | diff --git a/.blue/docs/rfcs/0008-status-update-file-sync.md b/.blue/docs/rfcs/0008-status-update-file-sync.md new file mode 100644 index 0000000..c8d1d92 --- /dev/null +++ b/.blue/docs/rfcs/0008-status-update-file-sync.md @@ -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 diff --git a/.blue/docs/rfcs/0009-audit-document-type.md b/.blue/docs/rfcs/0009-audit-document-type.md new file mode 100644 index 0000000..07117ec --- /dev/null +++ b/.blue/docs/rfcs/0009-audit-document-type.md @@ -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 diff --git a/.blue/docs/spikes/2026-01-24-audit-path-integration.md b/.blue/docs/spikes/2026-01-24-audit-path-integration.md new file mode 100644 index 0000000..cbd3713 --- /dev/null +++ b/.blue/docs/spikes/2026-01-24-audit-path-integration.md @@ -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 diff --git a/.blue/docs/spikes/2026-01-24-rfc-status-update-not-persisting.md b/.blue/docs/spikes/2026-01-24-rfc-status-update-not-persisting.md new file mode 100644 index 0000000..c6d54e2 --- /dev/null +++ b/.blue/docs/spikes/2026-01-24-rfc-status-update-not-persisting.md @@ -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 diff --git a/apps/blue-cli/Cargo.toml b/apps/blue-cli/Cargo.toml index 3c13b3e..1899611 100644 --- a/apps/blue-cli/Cargo.toml +++ b/apps/blue-cli/Cargo.toml @@ -4,6 +4,7 @@ version.workspace = true edition.workspace = true license.workspace = true description = "Blue CLI - Welcome home" +build = "build.rs" [[bin]] name = "blue" @@ -18,3 +19,8 @@ tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true chrono.workspace = true +reqwest.workspace = true +serde.workspace = true +dirs.workspace = true +tempfile.workspace = true +serde_yaml.workspace = true diff --git a/apps/blue-cli/build.rs b/apps/blue-cli/build.rs new file mode 100644 index 0000000..91eea5a --- /dev/null +++ b/apps/blue-cli/build.rs @@ -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> { + 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> { + 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> { + // 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> { + 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> { + // 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); + } + } +} diff --git a/apps/blue-cli/src/main.rs b/apps/blue-cli/src/main.rs index 50df04e..7c6b7b3 100644 --- a/apps/blue-cli/src/main.rs +++ b/apps/blue-cli/src/main.rs @@ -1135,26 +1135,15 @@ async fn handle_session_command(command: SessionCommands) -> Result<()> { async fn handle_agent_command(model: Option, extra_args: Vec) -> Result<()> { use std::process::Command; - // Check if Goose is installed - let goose_check = Command::new("goose") - .arg("--version") - .output(); + // Find Goose binary: bundled first, then system + let goose_path = find_goose_binary()?; - match goose_check { - Err(_) => { - println!("Goose not found. Install it first:"); - println!(" pipx install goose-ai"); - println!(" # or"); - 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(_) => {} - } + // Check if Ollama is running and get available models + let ollama_model = if model.is_none() { + detect_ollama_model().await + } else { + None + }; // Get the path to the blue binary let blue_binary = std::env::current_exe()?; @@ -1163,16 +1152,68 @@ async fn handle_agent_command(model: Option, extra_args: Vec) -> let extension_cmd = format!("{} mcp", blue_binary.display()); println!("Starting Goose with Blue extension..."); + println!(" Goose: {}", goose_path.display()); 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 - let mut cmd = Command::new("goose"); + let mut cmd = Command::new(&goose_path); cmd.arg("session"); cmd.arg("--with-extension").arg(&extension_cmd); - // Add model if specified - if let Some(m) = model { - cmd.arg("--model").arg(m); + // Configure via environment variables (more reliable than config file) + if !provider.is_empty() { + 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 @@ -1199,3 +1240,212 @@ async fn handle_agent_command(model: Option, extra_args: Vec) -> Ok(()) } } + + +fn find_goose_binary() -> Result { + 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 { + 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 { + // 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 { + // 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, + } + + #[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()) +} diff --git a/crates/blue-core/Cargo.toml b/crates/blue-core/Cargo.toml index fdc4d0a..ffff4d1 100644 --- a/crates/blue-core/Cargo.toml +++ b/crates/blue-core/Cargo.toml @@ -24,6 +24,7 @@ tower-http.workspace = true reqwest.workspace = true dirs.workspace = true semver.workspace = true +regex.workspace = true [dev-dependencies] tower.workspace = true diff --git a/crates/blue-core/src/documents.rs b/crates/blue-core/src/documents.rs index 8f48807..8336ead 100644 --- a/crates/blue-core/src/documents.rs +++ b/crates/blue-core/src/documents.rs @@ -103,6 +103,81 @@ pub struct Adr { pub consequences: Vec, } +/// 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, + pub findings: Vec, + pub recommendations: Vec, +} + +/// 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 { + 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 { /// Create a new RFC in draft status pub fn new(title: impl Into) -> Self { @@ -362,6 +437,75 @@ impl Decision { } } +impl Audit { + /// Create a new Audit + pub fn new(title: impl Into, audit_type: AuditType, scope: impl Into) -> 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 fn today() -> String { chrono::Utc::now().format("%Y-%m-%d").to_string() @@ -381,6 +525,46 @@ fn to_title_case(s: &str) -> String { .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 { + 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** | | + 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:** + 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)] mod tests { use super::*; @@ -411,4 +595,41 @@ mod tests { assert!(md.contains("# Spike: Test Investigation")); 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); + } } diff --git a/crates/blue-core/src/lib.rs b/crates/blue-core/src/lib.rs index 1a748af..650dfc4 100644 --- a/crates/blue-core/src/lib.rs +++ b/crates/blue-core/src/lib.rs @@ -23,7 +23,7 @@ pub mod store; pub mod voice; 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 repo::{detect_blue, BlueHome, RepoError, WorktreeInfo}; pub use state::{ItemType, ProjectState, StateError, StatusSummary, WorkItem}; diff --git a/crates/blue-core/src/store.rs b/crates/blue-core/src/store.rs index 8c9d48d..bc7a8f2 100644 --- a/crates/blue-core/src/store.rs +++ b/crates/blue-core/src/store.rs @@ -185,6 +185,7 @@ pub enum DocType { Postmortem, Runbook, Dialogue, + Audit, } impl DocType { @@ -198,6 +199,7 @@ impl DocType { DocType::Postmortem => "postmortem", DocType::Runbook => "runbook", DocType::Dialogue => "dialogue", + DocType::Audit => "audit", } } @@ -211,6 +213,7 @@ impl DocType { "postmortem" => Some(DocType::Postmortem), "runbook" => Some(DocType::Runbook), "dialogue" => Some(DocType::Dialogue), + "audit" => Some(DocType::Audit), _ => None, } } @@ -226,6 +229,7 @@ impl DocType { DocType::Postmortem => "post-mortems", DocType::Runbook => "runbooks", DocType::Dialogue => "dialogues", + DocType::Audit => "audits", } } } diff --git a/crates/blue-mcp/src/handlers/audit_doc.rs b/crates/blue-mcp/src/handlers/audit_doc.rs new file mode 100644 index 0000000..f44ef5e --- /dev/null +++ b/crates/blue-mcp/src/handlers/audit_doc.rs @@ -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 { + 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 { + let audits = state + .store + .list_documents(DocType::Audit) + .map_err(|e| ServerError::StateLoadFailed(e.to_string()))?; + + let items: Vec = 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 { + 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 { + 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); + } +} diff --git a/crates/blue-mcp/src/handlers/mod.rs b/crates/blue-mcp/src/handlers/mod.rs index 3f4fe94..d2597b3 100644 --- a/crates/blue-mcp/src/handlers/mod.rs +++ b/crates/blue-mcp/src/handlers/mod.rs @@ -3,7 +3,8 @@ //! Each module handles a specific document type or workflow. 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 delete; pub mod dialogue; diff --git a/crates/blue-mcp/src/handlers/rfc.rs b/crates/blue-mcp/src/handlers/rfc.rs index f72e438..a006bae 100644 --- a/crates/blue-mcp/src/handlers/rfc.rs +++ b/crates/blue-mcp/src/handlers/rfc.rs @@ -105,6 +105,12 @@ pub fn handle_complete(state: &ProjectState, args: &Value) -> Result Result self.handle_staging_status(&call.arguments), "blue_staging_cleanup" => self.handle_staging_cleanup(&call.arguments), "blue_staging_deployments" => self.handle_staging_deployments(&call.arguments), - // Phase 6: Audit and completion handlers - "blue_audit" => self.handle_audit(&call.arguments), + // Phase 6: Health check, audit documents, and completion handlers + "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_worktree_cleanup" => self.handle_worktree_cleanup(&call.arguments), // Phase 7: PRD handlers @@ -2203,13 +2283,27 @@ impl BlueServer { 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) .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!({ "status": "success", "title": title, "new_status": status, + "file_updated": file_updated, "message": blue_core::voice::success( &format!("Updated '{}' to {}", title, status), None @@ -2626,13 +2720,36 @@ impl BlueServer { 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) -> Result { + fn handle_health_check(&mut self, _args: &Option) -> Result { let state = self.ensure_state()?; crate::handlers::audit::handle_audit(state) } + fn handle_audit_create(&mut self, args: &Option) -> Result { + 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) -> Result { + let state = self.ensure_state()?; + crate::handlers::audit_doc::handle_list(state) + } + + fn handle_audit_get(&mut self, args: &Option) -> Result { + 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) -> Result { + 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) -> Result { let args = args.as_ref().ok_or(ServerError::InvalidParams)?; let state = self.ensure_state()?;