From e8ec84668f85e0c9a165d90f3a928b88ee064d9e Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 18 Mar 2026 15:18:14 +0000 Subject: [PATCH] story-kit: merge 284_story_matrix_bot_status_command_shows_pipeline_and_agent_availability --- server/src/main.rs | 2 +- server/src/matrix/bot.rs | 296 +++++++++++++++++++++++++++++++++++++++ server/src/matrix/mod.rs | 4 +- 3 files changed, 300 insertions(+), 2 deletions(-) diff --git a/server/src/main.rs b/server/src/main.rs index cd2a3c4..32ea4ad 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -196,7 +196,7 @@ async fn main() -> Result<(), std::io::Error> { // Optional Matrix bot: connect to the homeserver and start listening for // messages if `.story_kit/bot.toml` is present and enabled. if let Some(ref root) = startup_root { - matrix::spawn_bot(root, watcher_tx_for_bot, perm_rx_for_bot); + matrix::spawn_bot(root, watcher_tx_for_bot, perm_rx_for_bot, Arc::clone(&startup_agents)); } // On startup: diff --git a/server/src/matrix/bot.rs b/server/src/matrix/bot.rs index 849e20f..0d9974c 100644 --- a/server/src/matrix/bot.rs +++ b/server/src/matrix/bot.rs @@ -1,3 +1,5 @@ +use crate::agents::{AgentPool, AgentStatus}; +use crate::config::ProjectConfig; use crate::http::context::{PermissionDecision, PermissionForward}; use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult}; use crate::slog; @@ -169,6 +171,8 @@ pub struct BotContext { /// responds to all messages rather than only addressed ones. This is /// in-memory only — the state does not survive a bot restart. pub ambient_rooms: Arc>>, + /// Agent pool for checking agent availability. + pub agents: Arc, } // --------------------------------------------------------------------------- @@ -183,6 +187,171 @@ pub fn format_startup_announcement(bot_name: &str) -> String { format!("{bot_name} is online.") } +// --------------------------------------------------------------------------- +// Command extraction +// --------------------------------------------------------------------------- + +/// Extract the command portion from a bot-addressed message. +/// +/// Strips the leading bot mention (full Matrix user ID, `@localpart`, or +/// display name) plus any trailing punctuation (`,`, `:`) and whitespace, +/// then returns the remainder in lowercase. Returns `None` when no +/// recognized mention prefix is found in the message. +pub fn extract_command(body: &str, bot_name: &str, bot_user_id: &OwnedUserId) -> Option { + let full_id = bot_user_id.as_str().to_lowercase(); + let at_localpart = format!("@{}", bot_user_id.localpart().to_lowercase()); + let bot_name_lower = bot_name.to_lowercase(); + let body_lower = body.trim().to_lowercase(); + + let stripped = if let Some(s) = body_lower.strip_prefix(&full_id) { + s + } else if let Some(s) = body_lower.strip_prefix(&at_localpart) { + // Guard against matching a longer @mention (e.g. "@timmybot" vs "@timmy"). + let next = s.chars().next(); + if next.is_some_and(|c| c.is_alphanumeric() || c == '-' || c == '_') { + return None; + } + s + } else if let Some(s) = body_lower.strip_prefix(&bot_name_lower) { + // Guard against matching a longer display-name prefix. + let next = s.chars().next(); + if next.is_some_and(|c| c.is_alphanumeric() || c == '-' || c == '_') { + return None; + } + s + } else { + return None; + }; + + // Strip leading separators (`,`, `:`) and whitespace after the mention. + let cmd = stripped.trim_start_matches(|c: char| c == ':' || c == ',' || c.is_whitespace()); + Some(cmd.trim().to_string()) +} + +// --------------------------------------------------------------------------- +// Pipeline status formatter +// --------------------------------------------------------------------------- + +/// Read all story IDs and names from a pipeline stage directory. +fn read_stage_items( + project_root: &std::path::Path, + stage_dir: &str, +) -> Vec<(String, Option)> { + let dir = project_root + .join(".story_kit") + .join("work") + .join(stage_dir); + if !dir.exists() { + return Vec::new(); + } + let mut items = Vec::new(); + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) != Some("md") { + continue; + } + if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) { + let name = std::fs::read_to_string(&path) + .ok() + .and_then(|contents| { + crate::io::story_metadata::parse_front_matter(&contents) + .ok() + .and_then(|m| m.name) + }); + items.push((stem.to_string(), name)); + } + } + } + items.sort_by(|a, b| a.0.cmp(&b.0)); + items +} + +/// Build the full pipeline status text formatted for Matrix (markdown). +pub fn build_pipeline_status(project_root: &std::path::Path, agents: &AgentPool) -> String { + // Build a map from story_id → active AgentInfo for quick lookup. + let active_agents = agents.list_agents().unwrap_or_default(); + let active_map: std::collections::HashMap = active_agents + .iter() + .filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending)) + .map(|a| (a.story_id.clone(), a)) + .collect(); + + let config = ProjectConfig::load(project_root).ok(); + + let mut out = String::from("**Pipeline Status**\n\n"); + + let stages = [ + ("1_upcoming", "Upcoming"), + ("2_current", "In Progress"), + ("3_qa", "QA"), + ("4_merge", "Merge"), + ("5_done", "Done"), + ]; + + for (dir, label) in &stages { + let items = read_stage_items(project_root, dir); + let count = items.len(); + out.push_str(&format!("**{label}** ({count})\n")); + if items.is_empty() { + out.push_str(" *(none)*\n"); + } else { + for (story_id, name) in &items { + let display = match name { + Some(n) => format!("{story_id} — {n}"), + None => story_id.clone(), + }; + if let Some(agent) = active_map.get(story_id) { + let model_str = config + .as_ref() + .and_then(|cfg| cfg.find_agent(&agent.agent_name)) + .and_then(|ac| ac.model.as_deref()) + .unwrap_or("?"); + out.push_str(&format!( + " • {display} — {} ({}) [{}]\n", + agent.agent_name, model_str, agent.status + )); + } else { + out.push_str(&format!(" • {display}\n")); + } + } + } + out.push('\n'); + } + + // Free agents: configured agents not currently running or pending. + out.push_str("**Free Agents**\n"); + if let Some(cfg) = &config { + let busy_names: std::collections::HashSet = active_agents + .iter() + .filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending)) + .map(|a| a.agent_name.clone()) + .collect(); + + let free: Vec = cfg + .agent + .iter() + .filter(|a| !busy_names.contains(&a.name)) + .map(|a| match &a.model { + Some(m) => format!("{} ({})", a.name, m), + None => a.name.clone(), + }) + .collect(); + + if free.is_empty() { + out.push_str(" *(none — all agents busy)*\n"); + } else { + for name in &free { + out.push_str(&format!(" • {name}\n")); + } + } + } else { + out.push_str(" *(no agent config found)*\n"); + } + + out +} + // --------------------------------------------------------------------------- // Bot entry point // --------------------------------------------------------------------------- @@ -195,6 +364,7 @@ pub async fn run_bot( project_root: PathBuf, watcher_rx: tokio::sync::broadcast::Receiver, perm_rx: Arc>>, + agents: Arc, ) -> Result<(), String> { let store_path = project_root.join(".story_kit").join("matrix_store"); let client = Client::builder() @@ -367,6 +537,7 @@ pub async fn run_bot( permission_timeout_secs: config.permission_timeout_secs, bot_name, ambient_rooms: Arc::new(TokioMutex::new(persisted_ambient)), + agents, }; slog!("[matrix-bot] Cryptographic identity verification is always ON — commands from unencrypted rooms or unverified devices are rejected"); @@ -891,6 +1062,22 @@ async fn handle_message( sender: String, user_message: String, ) { + // Handle built-in commands before invoking Claude. + if let Some(cmd) = extract_command(&user_message, &ctx.bot_name, &ctx.bot_user_id) + && cmd == "status" + { + let project_root = ctx.project_root.clone(); + let status_text = build_pipeline_status(&project_root, &ctx.agents); + let html = markdown_to_html(&status_text); + if let Ok(resp) = room + .send(RoomMessageEventContent::text_html(status_text, html)) + .await + { + ctx.bot_sent_event_ids.lock().await.insert(resp.event_id); + } + return; + } + // Look up the room's existing Claude Code session ID (if any) so we can // resume the conversation with structured API messages instead of // flattening history into a text prefix. @@ -1376,6 +1563,7 @@ mod tests { permission_timeout_secs: 120, bot_name: "Assistant".to_string(), ambient_rooms: Arc::new(TokioMutex::new(HashSet::new())), + agents: Arc::new(AgentPool::new_test(3000)), }; // Clone must work (required by Matrix SDK event handler injection). let _cloned = ctx.clone(); @@ -1832,6 +2020,114 @@ mod tests { assert_eq!(format_startup_announcement("Assistant"), "Assistant is online."); } + // -- extract_command (status trigger) ------------------------------------ + + #[test] + fn extract_command_returns_status_for_bot_name_prefix() { + let uid = make_user_id("@assistant:example.com"); + let result = extract_command("Assistant status", "Assistant", &uid); + assert_eq!(result.as_deref(), Some("status")); + } + + #[test] + fn extract_command_returns_status_for_at_localpart_prefix() { + let uid = make_user_id("@assistant:example.com"); + let result = extract_command("@assistant status", "Assistant", &uid); + assert_eq!(result.as_deref(), Some("status")); + } + + #[test] + fn extract_command_returns_status_for_full_id_prefix() { + let uid = make_user_id("@assistant:example.com"); + let result = extract_command("@assistant:example.com status", "Assistant", &uid); + assert_eq!(result.as_deref(), Some("status")); + } + + #[test] + fn extract_command_returns_none_when_no_bot_mention() { + let uid = make_user_id("@assistant:example.com"); + let result = extract_command("status", "Assistant", &uid); + assert!(result.is_none()); + } + + #[test] + fn extract_command_handles_punctuation_after_mention() { + let uid = make_user_id("@assistant:example.com"); + let result = extract_command("@assistant: status", "Assistant", &uid); + assert_eq!(result.as_deref(), Some("status")); + } + + // -- build_pipeline_status ----------------------------------------------- + + fn write_story_file(dir: &std::path::Path, stage: &str, filename: &str, name: &str) { + let stage_dir = dir.join(".story_kit").join("work").join(stage); + std::fs::create_dir_all(&stage_dir).unwrap(); + let content = format!("---\nname: \"{name}\"\n---\n\n# {name}\n"); + std::fs::write(stage_dir.join(filename), content).unwrap(); + } + + #[test] + fn build_pipeline_status_includes_all_stages() { + let dir = tempfile::tempdir().unwrap(); + let pool = AgentPool::new_test(3001); + let out = build_pipeline_status(dir.path(), &pool); + + assert!(out.contains("Upcoming"), "missing Upcoming: {out}"); + assert!(out.contains("In Progress"), "missing In Progress: {out}"); + assert!(out.contains("QA"), "missing QA: {out}"); + assert!(out.contains("Merge"), "missing Merge: {out}"); + assert!(out.contains("Done"), "missing Done: {out}"); + } + + #[test] + fn build_pipeline_status_shows_story_id_and_name() { + let dir = tempfile::tempdir().unwrap(); + write_story_file( + dir.path(), + "1_upcoming", + "42_story_do_something.md", + "Do Something", + ); + let pool = AgentPool::new_test(3001); + let out = build_pipeline_status(dir.path(), &pool); + + assert!( + out.contains("42_story_do_something"), + "missing story id: {out}" + ); + assert!(out.contains("Do Something"), "missing story name: {out}"); + } + + #[test] + fn build_pipeline_status_includes_free_agents_section() { + let dir = tempfile::tempdir().unwrap(); + let pool = AgentPool::new_test(3001); + let out = build_pipeline_status(dir.path(), &pool); + + assert!(out.contains("Free Agents"), "missing Free Agents section: {out}"); + } + + #[test] + fn build_pipeline_status_uses_markdown_bold_headings() { + let dir = tempfile::tempdir().unwrap(); + let pool = AgentPool::new_test(3001); + let out = build_pipeline_status(dir.path(), &pool); + + // Stages and headers should use markdown bold (**text**). + assert!(out.contains("**Pipeline Status**"), "missing bold title: {out}"); + assert!(out.contains("**Upcoming**"), "stage should use bold: {out}"); + } + + #[test] + fn build_pipeline_status_shows_none_for_empty_stages() { + let dir = tempfile::tempdir().unwrap(); + let pool = AgentPool::new_test(3001); + let out = build_pipeline_status(dir.path(), &pool); + + // Empty stages show *(none)* + assert!(out.contains("*(none)*"), "expected none marker: {out}"); + } + // -- bot_name / system prompt ------------------------------------------- #[test] diff --git a/server/src/matrix/mod.rs b/server/src/matrix/mod.rs index f272988..474164d 100644 --- a/server/src/matrix/mod.rs +++ b/server/src/matrix/mod.rs @@ -22,6 +22,7 @@ pub mod notifications; pub use config::BotConfig; +use crate::agents::AgentPool; use crate::http::context::PermissionForward; use crate::io::watcher::WatcherEvent; use std::path::Path; @@ -47,6 +48,7 @@ pub fn spawn_bot( project_root: &Path, watcher_tx: broadcast::Sender, perm_rx: Arc>>, + agents: Arc, ) { let config = match BotConfig::load(project_root) { Some(c) => c, @@ -65,7 +67,7 @@ pub fn spawn_bot( let root = project_root.to_path_buf(); let watcher_rx = watcher_tx.subscribe(); tokio::spawn(async move { - if let Err(e) = bot::run_bot(config, root, watcher_rx, perm_rx).await { + if let Err(e) = bot::run_bot(config, root, watcher_rx, perm_rx, agents).await { crate::slog!("[matrix-bot] Fatal error: {e}"); } });