story-kit: merge 284_story_matrix_bot_status_command_shows_pipeline_and_agent_availability

This commit is contained in:
Dave
2026-03-18 15:18:14 +00:00
parent 8d9cf4b283
commit e8ec84668f
3 changed files with 300 additions and 2 deletions

View File

@@ -196,7 +196,7 @@ async fn main() -> Result<(), std::io::Error> {
// Optional Matrix bot: connect to the homeserver and start listening for // Optional Matrix bot: connect to the homeserver and start listening for
// messages if `.story_kit/bot.toml` is present and enabled. // messages if `.story_kit/bot.toml` is present and enabled.
if let Some(ref root) = startup_root { 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: // On startup:

View File

@@ -1,3 +1,5 @@
use crate::agents::{AgentPool, AgentStatus};
use crate::config::ProjectConfig;
use crate::http::context::{PermissionDecision, PermissionForward}; use crate::http::context::{PermissionDecision, PermissionForward};
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult}; use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
use crate::slog; use crate::slog;
@@ -169,6 +171,8 @@ pub struct BotContext {
/// responds to all messages rather than only addressed ones. This is /// responds to all messages rather than only addressed ones. This is
/// in-memory only — the state does not survive a bot restart. /// in-memory only — the state does not survive a bot restart.
pub ambient_rooms: Arc<TokioMutex<HashSet<OwnedRoomId>>>, pub ambient_rooms: Arc<TokioMutex<HashSet<OwnedRoomId>>>,
/// Agent pool for checking agent availability.
pub agents: Arc<AgentPool>,
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -183,6 +187,171 @@ pub fn format_startup_announcement(bot_name: &str) -> String {
format!("{bot_name} is online.") 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<String> {
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<String>)> {
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<String, &crate::agents::AgentInfo> = 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<String> = active_agents
.iter()
.filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending))
.map(|a| a.agent_name.clone())
.collect();
let free: Vec<String> = 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 // Bot entry point
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -195,6 +364,7 @@ pub async fn run_bot(
project_root: PathBuf, project_root: PathBuf,
watcher_rx: tokio::sync::broadcast::Receiver<crate::io::watcher::WatcherEvent>, watcher_rx: tokio::sync::broadcast::Receiver<crate::io::watcher::WatcherEvent>,
perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<PermissionForward>>>, perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<PermissionForward>>>,
agents: Arc<AgentPool>,
) -> Result<(), String> { ) -> Result<(), String> {
let store_path = project_root.join(".story_kit").join("matrix_store"); let store_path = project_root.join(".story_kit").join("matrix_store");
let client = Client::builder() let client = Client::builder()
@@ -367,6 +537,7 @@ pub async fn run_bot(
permission_timeout_secs: config.permission_timeout_secs, permission_timeout_secs: config.permission_timeout_secs,
bot_name, bot_name,
ambient_rooms: Arc::new(TokioMutex::new(persisted_ambient)), 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"); 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, sender: String,
user_message: 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 // Look up the room's existing Claude Code session ID (if any) so we can
// resume the conversation with structured API messages instead of // resume the conversation with structured API messages instead of
// flattening history into a text prefix. // flattening history into a text prefix.
@@ -1376,6 +1563,7 @@ mod tests {
permission_timeout_secs: 120, permission_timeout_secs: 120,
bot_name: "Assistant".to_string(), bot_name: "Assistant".to_string(),
ambient_rooms: Arc::new(TokioMutex::new(HashSet::new())), 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). // Clone must work (required by Matrix SDK event handler injection).
let _cloned = ctx.clone(); let _cloned = ctx.clone();
@@ -1832,6 +2020,114 @@ mod tests {
assert_eq!(format_startup_announcement("Assistant"), "Assistant is online."); 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 ------------------------------------------- // -- bot_name / system prompt -------------------------------------------
#[test] #[test]

View File

@@ -22,6 +22,7 @@ pub mod notifications;
pub use config::BotConfig; pub use config::BotConfig;
use crate::agents::AgentPool;
use crate::http::context::PermissionForward; use crate::http::context::PermissionForward;
use crate::io::watcher::WatcherEvent; use crate::io::watcher::WatcherEvent;
use std::path::Path; use std::path::Path;
@@ -47,6 +48,7 @@ pub fn spawn_bot(
project_root: &Path, project_root: &Path,
watcher_tx: broadcast::Sender<WatcherEvent>, watcher_tx: broadcast::Sender<WatcherEvent>,
perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<PermissionForward>>>, perm_rx: Arc<TokioMutex<mpsc::UnboundedReceiver<PermissionForward>>>,
agents: Arc<AgentPool>,
) { ) {
let config = match BotConfig::load(project_root) { let config = match BotConfig::load(project_root) {
Some(c) => c, Some(c) => c,
@@ -65,7 +67,7 @@ pub fn spawn_bot(
let root = project_root.to_path_buf(); let root = project_root.to_path_buf();
let watcher_rx = watcher_tx.subscribe(); let watcher_rx = watcher_tx.subscribe();
tokio::spawn(async move { 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}"); crate::slog!("[matrix-bot] Fatal error: {e}");
} }
}); });