feat: Phase 9 - post-mortem and runbook tools

Add 4 tools:
- blue_postmortem_create: Create post-mortem documents for incident tracking
- blue_postmortem_action_to_rfc: Convert post-mortem action items to RFCs
- blue_runbook_create: Create runbook documents for operations
- blue_runbook_update: Update runbooks with new operations/troubleshooting

Also adds DocType::Postmortem and DocType::Runbook to blue-core.

Total: 54 tools ported from coherence-mcp

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eric Garcia 2026-01-24 04:31:16 -05:00
parent f186a470c8
commit 6969a9caff
5 changed files with 1002 additions and 0 deletions

View file

@ -162,6 +162,8 @@ pub enum DocType {
Adr, Adr,
Decision, Decision,
Prd, Prd,
Postmortem,
Runbook,
} }
impl DocType { impl DocType {
@ -172,6 +174,8 @@ impl DocType {
DocType::Adr => "adr", DocType::Adr => "adr",
DocType::Decision => "decision", DocType::Decision => "decision",
DocType::Prd => "prd", DocType::Prd => "prd",
DocType::Postmortem => "postmortem",
DocType::Runbook => "runbook",
} }
} }
@ -182,6 +186,8 @@ impl DocType {
"adr" => Some(DocType::Adr), "adr" => Some(DocType::Adr),
"decision" => Some(DocType::Decision), "decision" => Some(DocType::Decision),
"prd" => Some(DocType::Prd), "prd" => Some(DocType::Prd),
"postmortem" => Some(DocType::Postmortem),
"runbook" => Some(DocType::Runbook),
_ => None, _ => None,
} }
} }
@ -194,6 +200,8 @@ impl DocType {
DocType::Adr => "ADRs", DocType::Adr => "ADRs",
DocType::Decision => "decisions", DocType::Decision => "decisions",
DocType::Prd => "PRDs", DocType::Prd => "PRDs",
DocType::Postmortem => "post-mortems",
DocType::Runbook => "runbooks",
} }
} }
} }

View file

@ -11,11 +11,13 @@ pub mod env;
pub mod guide; pub mod guide;
pub mod lint; pub mod lint;
pub mod playwright; pub mod playwright;
pub mod postmortem;
pub mod pr; pub mod pr;
pub mod prd; pub mod prd;
pub mod release; pub mod release;
pub mod reminder; pub mod reminder;
pub mod rfc; pub mod rfc;
pub mod runbook;
pub mod session; pub mod session;
pub mod spike; pub mod spike;
pub mod staging; pub mod staging;

View file

@ -0,0 +1,486 @@
//! Post-Mortem tool handlers
//!
//! Handles post-mortem creation and action item tracking.
use std::fs;
use std::path::PathBuf;
use blue_core::{DocType, Document, ProjectState, Rfc};
use serde_json::{json, Value};
use crate::error::ServerError;
/// Severity levels for post-mortems
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Severity {
P1, // Critical - major outage
P2, // High - significant impact
P3, // Medium - moderate impact
P4, // Low - minor impact
}
impl Severity {
pub fn from_str(s: &str) -> Option<Self> {
match s.to_uppercase().as_str() {
"P1" | "CRITICAL" => Some(Severity::P1),
"P2" | "HIGH" => Some(Severity::P2),
"P3" | "MEDIUM" => Some(Severity::P3),
"P4" | "LOW" => Some(Severity::P4),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Severity::P1 => "P1",
Severity::P2 => "P2",
Severity::P3 => "P3",
Severity::P4 => "P4",
}
}
}
/// Handle blue_postmortem_create
pub fn handle_create(state: &mut ProjectState, args: &Value) -> Result<Value, ServerError> {
let title = args
.get("title")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
let severity_str = args
.get("severity")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
let severity = Severity::from_str(severity_str).ok_or_else(|| {
ServerError::CommandFailed(format!(
"Invalid severity '{}'. Use P1, P2, P3, or P4.",
severity_str
))
})?;
let summary = args.get("summary").and_then(|v| v.as_str());
let root_cause = args.get("root_cause").and_then(|v| v.as_str());
let duration = args.get("duration").and_then(|v| v.as_str());
let impact: Vec<String> = args
.get("impact")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
// Get next postmortem number
let pm_number = state
.store
.next_number(DocType::Postmortem)
.map_err(|e| ServerError::CommandFailed(e.to_string()))?;
// Generate file path with date prefix
let date = chrono::Local::now().format("%Y-%m-%d").to_string();
let file_name = format!("{}-{}.md", date, to_kebab_case(title));
let file_path = PathBuf::from("postmortems").join(&file_name);
let docs_path = state.home.docs_path(&state.project);
let pm_path = docs_path.join(&file_path);
// Generate markdown content
let markdown = generate_postmortem_markdown(title, severity, summary, root_cause, duration, &impact);
// Create document in SQLite store
let doc = Document {
id: None,
doc_type: DocType::Postmortem,
number: Some(pm_number),
title: title.to_string(),
status: "open".to_string(),
file_path: Some(file_path.to_string_lossy().to_string()),
created_at: None,
updated_at: None,
};
state
.store
.add_document(&doc)
.map_err(|e| ServerError::CommandFailed(e.to_string()))?;
// Create postmortems directory if it doesn't exist
if let Some(parent) = pm_path.parent() {
fs::create_dir_all(parent).map_err(|e| ServerError::CommandFailed(e.to_string()))?;
}
fs::write(&pm_path, &markdown).map_err(|e| ServerError::CommandFailed(e.to_string()))?;
let hint = "Post-mortem created. Fill in the timeline and lessons learned sections.";
Ok(json!({
"status": "success",
"message": blue_core::voice::info(
&format!("Post-mortem created: {}", title),
Some(hint)
),
"title": title,
"severity": severity.as_str(),
"file": pm_path.display().to_string(),
"content": markdown,
}))
}
/// Handle blue_postmortem_action_to_rfc
pub fn handle_action_to_rfc(state: &mut ProjectState, args: &Value) -> Result<Value, ServerError> {
let postmortem_title = args
.get("postmortem_title")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
let action = args
.get("action")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
let rfc_title_override = args.get("rfc_title").and_then(|v| v.as_str());
// Find the post-mortem
let pm_doc = state
.store
.find_document(DocType::Postmortem, postmortem_title)
.map_err(|_| {
ServerError::NotFound(format!("Post-mortem '{}' not found", postmortem_title))
})?;
let pm_file_path = pm_doc.file_path.as_ref().ok_or_else(|| {
ServerError::CommandFailed("Post-mortem has no file path".to_string())
})?;
let docs_path = state.home.docs_path(&state.project);
let pm_path = docs_path.join(pm_file_path);
let pm_content = fs::read_to_string(&pm_path)
.map_err(|e| ServerError::CommandFailed(format!("Failed to read post-mortem: {}", e)))?;
// Find the action item
let (action_idx, action_description) = find_action_item(&pm_content, action)?;
// Generate RFC title from action item if not provided
let rfc_title = rfc_title_override
.map(String::from)
.unwrap_or_else(|| {
action_description
.chars()
.take(50)
.collect::<String>()
.trim()
.to_string()
});
// Create RFC with post-mortem reference
let mut rfc = Rfc::new(&rfc_title);
rfc.problem = Some(format!(
"From post-mortem: {}\n\nAction item: {}",
postmortem_title, action_description
));
// Get next RFC number
let rfc_number = state
.store
.next_number(DocType::Rfc)
.map_err(|e| ServerError::CommandFailed(e.to_string()))?;
// Generate file path
let rfc_file_name = format!("{:04}-{}.md", rfc_number, to_kebab_case(&rfc_title));
let rfc_file_path = PathBuf::from("rfcs").join(&rfc_file_name);
let rfc_path = docs_path.join(&rfc_file_path);
// Generate RFC markdown with post-mortem link
let mut markdown = rfc.to_markdown(rfc_number as u32);
// Add source post-mortem link
let pm_link = format!(
"| **Source Post-Mortem** | [{}](../postmortems/{}) |",
postmortem_title,
pm_file_path.replace("postmortems/", "")
);
markdown = markdown.replace(
"| **Status** | Draft |",
&format!("| **Status** | Draft |\n{}", pm_link),
);
// Create RFC document in store
let rfc_doc = Document {
id: None,
doc_type: DocType::Rfc,
number: Some(rfc_number),
title: rfc_title.clone(),
status: "draft".to_string(),
file_path: Some(rfc_file_path.to_string_lossy().to_string()),
created_at: None,
updated_at: None,
};
state
.store
.add_document(&rfc_doc)
.map_err(|e| ServerError::CommandFailed(e.to_string()))?;
// Write RFC file
if let Some(parent) = rfc_path.parent() {
fs::create_dir_all(parent).map_err(|e| ServerError::CommandFailed(e.to_string()))?;
}
fs::write(&rfc_path, &markdown).map_err(|e| ServerError::CommandFailed(e.to_string()))?;
// Update post-mortem action item with RFC link
let updated_pm_content = update_action_item_with_rfc(
&pm_content,
action_idx,
&format!("RFC {:04}: {}", rfc_number, rfc_title),
);
fs::write(&pm_path, updated_pm_content)
.map_err(|e| ServerError::CommandFailed(format!("Failed to update post-mortem: {}", e)))?;
let hint = format!(
"RFC created from post-mortem action item. Review and expand the design: {}",
rfc_path.display()
);
Ok(json!({
"status": "success",
"message": blue_core::voice::info(
&format!("RFC {:04} created from post-mortem", rfc_number),
Some(&hint)
),
"rfc_title": rfc_title,
"rfc_number": rfc_number,
"rfc_file": rfc_path.display().to_string(),
"source_postmortem": postmortem_title,
"action_item": action_description,
"action_index": action_idx,
}))
}
/// Find action item by index or substring
fn find_action_item(content: &str, identifier: &str) -> Result<(usize, String), ServerError> {
let actions = parse_all_actions(content);
// Try to parse as index first
if let Ok(idx) = identifier.parse::<usize>() {
if idx > 0 && idx <= actions.len() {
return Ok((idx, actions[idx - 1].clone()));
}
return Err(ServerError::NotFound(format!(
"Action item #{} not found. Found {} action items.",
idx,
actions.len()
)));
}
// Try substring match
for (i, action) in actions.iter().enumerate() {
if action.to_lowercase().contains(&identifier.to_lowercase()) {
return Ok((i + 1, action.clone()));
}
}
Err(ServerError::NotFound(format!(
"No action item matching '{}' found",
identifier
)))
}
/// Parse all action items from post-mortem content
fn parse_all_actions(content: &str) -> Vec<String> {
let mut actions = Vec::new();
let mut in_actions_section = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with('#') && trimmed.to_lowercase().contains("action item") {
in_actions_section = true;
continue;
}
if in_actions_section && trimmed.starts_with('#') {
break;
}
if in_actions_section && trimmed.starts_with('|') && !trimmed.contains("---") {
let parts: Vec<&str> = trimmed.split('|').map(|s| s.trim()).collect();
if parts.len() >= 2 && !parts[1].is_empty() && parts[1] != "Item" {
actions.push(parts[1].to_string());
}
}
}
actions
}
/// Update action item row with RFC link
fn update_action_item_with_rfc(content: &str, action_idx: usize, rfc_ref: &str) -> String {
let mut lines: Vec<String> = content.lines().map(String::from).collect();
let mut in_actions_section = false;
let mut current_action = 0;
for line in lines.iter_mut() {
let trimmed = line.trim();
if trimmed.starts_with('#') && trimmed.to_lowercase().contains("action item") {
in_actions_section = true;
continue;
}
if in_actions_section && trimmed.starts_with('#') {
break;
}
if in_actions_section && trimmed.starts_with('|') && !trimmed.contains("---") {
let parts: Vec<&str> = trimmed.split('|').map(|s| s.trim()).collect();
if parts.len() >= 2 && !parts[1].is_empty() && parts[1] != "Item" {
current_action += 1;
if current_action == action_idx {
// Update the RFC column (last column)
let mut new_parts: Vec<String> = parts.iter().map(|s| s.to_string()).collect();
if new_parts.len() > 5 {
new_parts[5] = rfc_ref.to_string();
} else {
while new_parts.len() <= 5 {
new_parts.push(String::new());
}
new_parts[5] = rfc_ref.to_string();
}
*line = format!("| {} |", new_parts[1..].join(" | "));
}
}
}
}
lines.join("\n")
}
/// Generate post-mortem markdown content
fn generate_postmortem_markdown(
title: &str,
severity: Severity,
summary: Option<&str>,
root_cause: Option<&str>,
duration: Option<&str>,
impact: &[String],
) -> String {
let mut md = String::new();
let date = chrono::Local::now().format("%Y-%m-%d").to_string();
// Title
md.push_str(&format!(
"# Post-Mortem: {}\n\n",
to_title_case(title)
));
// Metadata table
md.push_str("| | |\n|---|---|\n");
md.push_str(&format!("| **Date** | {} |\n", date));
md.push_str(&format!("| **Severity** | {} |\n", severity.as_str()));
if let Some(dur) = duration {
md.push_str(&format!("| **Duration** | {} |\n", dur));
}
md.push_str("| **Author** | [Name] |\n");
md.push_str("\n---\n\n");
// Summary
md.push_str("## Summary\n\n");
if let Some(sum) = summary {
md.push_str(sum);
} else {
md.push_str("[One paragraph summary of the incident]");
}
md.push_str("\n\n");
// Timeline
md.push_str("## Timeline\n\n");
md.push_str("| Time | Event |\n");
md.push_str("|------|-------|\n");
md.push_str("| HH:MM | [Event] |\n");
md.push_str("\n");
// Root Cause
md.push_str("## Root Cause\n\n");
if let Some(rc) = root_cause {
md.push_str(rc);
} else {
md.push_str("[What actually caused the incident]");
}
md.push_str("\n\n");
// Impact
md.push_str("## Impact\n\n");
if !impact.is_empty() {
for item in impact {
md.push_str(&format!("- {}\n", item));
}
} else {
md.push_str("- [Impact 1]\n");
}
md.push_str("\n");
// What Went Well
md.push_str("## What Went Well\n\n");
md.push_str("- [Item 1]\n\n");
// What Went Wrong
md.push_str("## What Went Wrong\n\n");
md.push_str("- [Item 1]\n\n");
// Action Items
md.push_str("## Action Items\n\n");
md.push_str("| Item | Owner | Due | Status | RFC |\n");
md.push_str("|------|-------|-----|--------|-----|\n");
md.push_str("| [Action 1] | [Name] | [Date] | Open | |\n");
md.push_str("\n");
// Lessons Learned
md.push_str("## Lessons Learned\n\n");
md.push_str("[Key takeaways from this incident]\n");
md
}
/// Convert a title to kebab-case for filenames
fn to_kebab_case(s: &str) -> String {
s.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
/// Convert slug to title case
fn to_title_case(s: &str) -> String {
s.split('-')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_severity_from_str() {
assert_eq!(Severity::from_str("P1"), Some(Severity::P1));
assert_eq!(Severity::from_str("critical"), Some(Severity::P1));
assert_eq!(Severity::from_str("P4"), Some(Severity::P4));
assert_eq!(Severity::from_str("invalid"), None);
}
#[test]
fn test_to_kebab_case() {
assert_eq!(to_kebab_case("Database Outage"), "database-outage");
assert_eq!(to_kebab_case("API failure"), "api-failure");
}
}

View file

@ -0,0 +1,343 @@
//! Runbook tool handlers
//!
//! Handles runbook creation and updates with RFC linking.
use std::fs;
use std::path::PathBuf;
use blue_core::{DocType, Document, ProjectState};
use serde_json::{json, Value};
use crate::error::ServerError;
/// Handle blue_runbook_create
pub fn handle_create(state: &mut ProjectState, args: &Value) -> Result<Value, ServerError> {
let title = args
.get("title")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
let source_rfc = args.get("source_rfc").and_then(|v| v.as_str());
let service_name = args.get("service_name").and_then(|v| v.as_str());
let owner = args.get("owner").and_then(|v| v.as_str());
let operations: Vec<String> = args
.get("operations")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
// Validate source RFC exists if provided
let source_rfc_doc = if let Some(rfc_title) = source_rfc {
Some(
state
.store
.find_document(DocType::Rfc, rfc_title)
.map_err(|_| {
ServerError::NotFound(format!("Source RFC '{}' not found", rfc_title))
})?,
)
} else {
None
};
// Get next runbook number
let runbook_number = state
.store
.next_number(DocType::Runbook)
.map_err(|e| ServerError::CommandFailed(e.to_string()))?;
// Generate file path
let file_name = format!("{}.md", to_kebab_case(title));
let file_path = PathBuf::from("runbooks").join(&file_name);
let docs_path = state.home.docs_path(&state.project);
let runbook_path = docs_path.join(&file_path);
// Generate markdown content
let markdown = generate_runbook_markdown(title, &source_rfc_doc, service_name, owner, &operations);
// Create document in SQLite store
let doc = Document {
id: None,
doc_type: DocType::Runbook,
number: Some(runbook_number),
title: title.to_string(),
status: "active".to_string(),
file_path: Some(file_path.to_string_lossy().to_string()),
created_at: None,
updated_at: None,
};
state
.store
.add_document(&doc)
.map_err(|e| ServerError::CommandFailed(e.to_string()))?;
// Write the markdown file
if let Some(parent) = runbook_path.parent() {
fs::create_dir_all(parent).map_err(|e| ServerError::CommandFailed(e.to_string()))?;
}
fs::write(&runbook_path, &markdown).map_err(|e| ServerError::CommandFailed(e.to_string()))?;
// Update source RFC with runbook link if provided
if let Some(ref rfc_doc) = source_rfc_doc {
if let Some(ref rfc_file_path) = rfc_doc.file_path {
let rfc_path = docs_path.join(rfc_file_path);
if rfc_path.exists() {
if let Ok(rfc_content) = fs::read_to_string(&rfc_path) {
let runbook_link = format!(
"| **Runbook** | [{}](../runbooks/{}) |",
title, file_name
);
// Insert after Status line if not already present
if !rfc_content.contains("| **Runbook** |") {
let updated_rfc = if rfc_content.contains("| **Status** | Implemented |") {
rfc_content.replace(
"| **Status** | Implemented |",
&format!("| **Status** | Implemented |\n{}", runbook_link),
)
} else {
rfc_content
};
let _ = fs::write(&rfc_path, updated_rfc);
}
}
}
}
}
let hint = "Runbook created. Fill in the operation procedures and troubleshooting sections.";
Ok(json!({
"status": "success",
"message": blue_core::voice::info(
&format!("Runbook created: {}", title),
Some(hint)
),
"title": title,
"file": runbook_path.display().to_string(),
"source_rfc": source_rfc,
"content": markdown,
}))
}
/// Handle blue_runbook_update
pub fn handle_update(state: &mut ProjectState, args: &Value) -> Result<Value, ServerError> {
let title = args
.get("title")
.and_then(|v| v.as_str())
.ok_or(ServerError::InvalidParams)?;
let add_operation = args.get("add_operation").and_then(|v| v.as_str());
let add_troubleshooting = args.get("add_troubleshooting").and_then(|v| v.as_str());
// Find the runbook
let doc = state
.store
.find_document(DocType::Runbook, title)
.map_err(|_| ServerError::NotFound(format!("Runbook '{}' not found", title)))?;
let runbook_file_path = doc.file_path.as_ref().ok_or_else(|| {
ServerError::CommandFailed("Runbook has no file path".to_string())
})?;
let docs_path = state.home.docs_path(&state.project);
let runbook_path = docs_path.join(runbook_file_path);
let mut content = fs::read_to_string(&runbook_path)
.map_err(|e| ServerError::CommandFailed(format!("Failed to read runbook: {}", e)))?;
let mut changes = Vec::new();
// Add new operation if provided
if let Some(operation) = add_operation {
let operation_section = format!(
"\n### Operation: {}\n\n**When to use**: [Describe trigger condition]\n\n**Steps**:\n1. [Step 1]\n\n**Verification**:\n```bash\n# Verify success\n```\n\n**Rollback**:\n```bash\n# Rollback if needed\n```\n",
operation
);
// Insert before Troubleshooting section or at end
if content.contains("## Troubleshooting") {
content = content.replace(
"## Troubleshooting",
&format!("{}\n## Troubleshooting", operation_section),
);
} else {
content.push_str(&operation_section);
}
changes.push(format!("Added operation: {}", operation));
}
// Add troubleshooting if provided
if let Some(troubleshooting) = add_troubleshooting {
let troubleshooting_section = format!(
"\n### Symptom: {}\n\n**Possible causes**:\n1. [Cause 1]\n\n**Resolution**:\n1. [Step 1]\n",
troubleshooting
);
// Insert into Troubleshooting section or create one
if content.contains("## Troubleshooting") {
if content.contains("## Escalation") {
content = content.replace(
"## Escalation",
&format!("{}\n## Escalation", troubleshooting_section),
);
} else {
content.push_str(&troubleshooting_section);
}
} else {
content.push_str(&format!(
"\n## Troubleshooting\n{}",
troubleshooting_section
));
}
changes.push(format!("Added troubleshooting: {}", troubleshooting));
}
// Write updated content
fs::write(&runbook_path, &content)
.map_err(|e| ServerError::CommandFailed(format!("Failed to write runbook: {}", e)))?;
let hint = "Runbook updated. Review the changes and fill in details.";
Ok(json!({
"status": "success",
"message": blue_core::voice::info(
&format!("Runbook updated: {}", title),
Some(hint)
),
"title": title,
"file": runbook_path.display().to_string(),
"changes": changes,
}))
}
/// Generate runbook markdown content
fn generate_runbook_markdown(
title: &str,
source_rfc: &Option<Document>,
service_name: Option<&str>,
owner: Option<&str>,
operations: &[String],
) -> String {
let mut md = String::new();
// Title
md.push_str(&format!(
"# Runbook: {}\n\n",
to_title_case(title)
));
// Metadata table
md.push_str("| | |\n|---|---|\n");
md.push_str("| **Status** | Active |\n");
if let Some(o) = owner {
md.push_str(&format!("| **Owner** | {} |\n", o));
}
let date = chrono::Local::now().format("%Y-%m-%d").to_string();
md.push_str(&format!("| **Created** | {} |\n", date));
if let Some(ref rfc_doc) = source_rfc {
if let Some(ref rfc_file_path) = rfc_doc.file_path {
md.push_str(&format!(
"| **Source RFC** | [{}](../rfcs/{}) |\n",
rfc_doc.title, rfc_file_path.replace("rfcs/", "")
));
}
}
md.push_str("\n---\n\n");
// Overview
md.push_str("## Overview\n\n");
if let Some(svc) = service_name {
md.push_str(&format!(
"This runbook covers operational procedures for **{}**.\n\n",
svc
));
} else {
md.push_str("[Describe what this runbook covers]\n\n");
}
// Prerequisites
md.push_str("## Prerequisites\n\n");
md.push_str("- [ ] Access to [system]\n");
md.push_str("- [ ] Permissions for [action]\n\n");
// Common Operations
md.push_str("## Common Operations\n\n");
if !operations.is_empty() {
for op in operations {
md.push_str(&format!(
"### Operation: {}\n\n**When to use**: [Trigger condition]\n\n**Steps**:\n1. [Step 1]\n\n**Verification**:\n```bash\n# Command to verify success\n```\n\n**Rollback**:\n```bash\n# Command to rollback if needed\n```\n\n",
op
));
}
} else {
md.push_str("### Operation 1: [Name]\n\n**When to use**: [Trigger condition]\n\n**Steps**:\n1. [Step 1]\n\n**Verification**:\n```bash\n# Command to verify success\n```\n\n**Rollback**:\n```bash\n# Command to rollback if needed\n```\n\n");
}
// Troubleshooting
md.push_str("## Troubleshooting\n\n");
md.push_str("### Symptom: [Description]\n\n**Possible causes**:\n1. [Cause 1]\n\n**Resolution**:\n1. [Step 1]\n\n");
// Escalation
md.push_str("## Escalation\n\n");
md.push_str("| Level | Contact | When |\n");
md.push_str("|-------|---------|------|\n");
md.push_str("| L1 | [Team] | [Condition] |\n");
md.push_str("| L2 | [Team] | [Condition] |\n\n");
// Related Documents
md.push_str("## Related Documents\n\n");
if source_rfc.is_some() {
md.push_str("- Source RFC (linked above)\n");
}
md.push_str("- [Link to architecture]\n");
md.push_str("- [Link to monitoring dashboard]\n");
md
}
/// Convert a title to kebab-case for filenames
fn to_kebab_case(s: &str) -> String {
s.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-")
}
/// Convert slug to title case
fn to_title_case(s: &str) -> String {
s.split('-')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().chain(chars).collect(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_to_kebab_case() {
assert_eq!(to_kebab_case("Deploy Service"), "deploy-service");
assert_eq!(to_kebab_case("API Gateway Runbook"), "api-gateway-runbook");
}
}

View file

@ -46,6 +46,23 @@ impl BlueServer {
self.state.as_ref().ok_or(ServerError::BlueNotDetected) self.state.as_ref().ok_or(ServerError::BlueNotDetected)
} }
fn ensure_state_mut(&mut self) -> Result<&mut ProjectState, ServerError> {
if self.state.is_none() {
let cwd = self.cwd.as_ref().ok_or(ServerError::BlueNotDetected)?;
let home = detect_blue(cwd).map_err(|_| ServerError::BlueNotDetected)?;
// Try to get project name from the current path
let project = home.project_name.clone().unwrap_or_else(|| "default".to_string());
let state = ProjectState::load(home, &project)
.map_err(|e| ServerError::StateLoadFailed(e.to_string()))?;
self.state = Some(state);
}
self.state.as_mut().ok_or(ServerError::BlueNotDetected)
}
/// Handle a JSON-RPC request /// Handle a JSON-RPC request
pub fn handle_request(&mut self, request: &str) -> String { pub fn handle_request(&mut self, request: &str) -> String {
let result = self.handle_request_inner(request); let result = self.handle_request_inner(request);
@ -1202,6 +1219,118 @@ impl BlueServer {
}, },
"required": ["task", "base_url"] "required": ["task", "base_url"]
} }
},
// Phase 9: Post-mortem tools
{
"name": "blue_postmortem_create",
"description": "Create a post-mortem document for incident tracking.",
"inputSchema": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Post-mortem title"
},
"severity": {
"type": "string",
"description": "Severity level (P1, P2, P3, P4)"
},
"summary": {
"type": "string",
"description": "Brief incident summary"
},
"root_cause": {
"type": "string",
"description": "Root cause of the incident"
},
"duration": {
"type": "string",
"description": "Incident duration"
},
"impact": {
"type": "array",
"items": { "type": "string" },
"description": "Impact items"
}
},
"required": ["title", "severity"]
}
},
{
"name": "blue_postmortem_action_to_rfc",
"description": "Convert a post-mortem action item into an RFC with bidirectional linking.",
"inputSchema": {
"type": "object",
"properties": {
"postmortem_title": {
"type": "string",
"description": "Title of the post-mortem"
},
"action": {
"type": "string",
"description": "Action item index (1-based) or substring to match"
},
"rfc_title": {
"type": "string",
"description": "Optional RFC title (defaults to action item text)"
}
},
"required": ["postmortem_title", "action"]
}
},
// Phase 9: Runbook tools
{
"name": "blue_runbook_create",
"description": "Create a runbook document for operational procedures.",
"inputSchema": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Runbook title"
},
"source_rfc": {
"type": "string",
"description": "Source RFC title to link"
},
"service_name": {
"type": "string",
"description": "Service or feature name"
},
"owner": {
"type": "string",
"description": "Owner team or person"
},
"operations": {
"type": "array",
"items": { "type": "string" },
"description": "Initial operations to document"
}
},
"required": ["title"]
}
},
{
"name": "blue_runbook_update",
"description": "Update an existing runbook with new operations or troubleshooting.",
"inputSchema": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Runbook title"
},
"add_operation": {
"type": "string",
"description": "New operation to add"
},
"add_troubleshooting": {
"type": "string",
"description": "Troubleshooting section to add"
}
},
"required": ["title"]
}
} }
] ]
})) }))
@ -1284,6 +1413,12 @@ impl BlueServer {
"blue_extract_dialogue" => self.handle_extract_dialogue(&call.arguments), "blue_extract_dialogue" => self.handle_extract_dialogue(&call.arguments),
// Phase 8: Playwright handler // Phase 8: Playwright handler
"blue_playwright_verify" => self.handle_playwright_verify(&call.arguments), "blue_playwright_verify" => self.handle_playwright_verify(&call.arguments),
// Phase 9: Post-mortem handlers
"blue_postmortem_create" => self.handle_postmortem_create(&call.arguments),
"blue_postmortem_action_to_rfc" => self.handle_postmortem_action_to_rfc(&call.arguments),
// Phase 9: Runbook handlers
"blue_runbook_create" => self.handle_runbook_create(&call.arguments),
"blue_runbook_update" => self.handle_runbook_update(&call.arguments),
_ => Err(ServerError::ToolNotFound(call.name)), _ => Err(ServerError::ToolNotFound(call.name)),
}?; }?;
@ -1945,6 +2080,34 @@ impl BlueServer {
let args = args.as_ref().ok_or(ServerError::InvalidParams)?; let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
crate::handlers::playwright::handle_verify(args) crate::handlers::playwright::handle_verify(args)
} }
// Phase 9: Post-mortem handlers
fn handle_postmortem_create(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
let state = self.ensure_state_mut()?;
crate::handlers::postmortem::handle_create(state, args)
}
fn handle_postmortem_action_to_rfc(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
let state = self.ensure_state_mut()?;
crate::handlers::postmortem::handle_action_to_rfc(state, args)
}
// Phase 9: Runbook handlers
fn handle_runbook_create(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
let state = self.ensure_state_mut()?;
crate::handlers::runbook::handle_create(state, args)
}
fn handle_runbook_update(&mut self, args: &Option<Value>) -> Result<Value, ServerError> {
let args = args.as_ref().ok_or(ServerError::InvalidParams)?;
let state = self.ensure_state_mut()?;
crate::handlers::runbook::handle_update(state, args)
}
} }
impl Default for BlueServer { impl Default for BlueServer {