//! Handler for the `status` command and pipeline status helpers. use crate::agents::{AgentPool, AgentStatus}; use crate::config::ProjectConfig; use std::collections::{HashMap, HashSet}; use super::CommandContext; pub(super) fn handle_status(ctx: &CommandContext) -> Option { if ctx.args.trim().is_empty() { Some(build_pipeline_status(ctx.project_root, ctx.agents)) } else { super::triage::handle_triage(ctx) } } /// Format a short display label for a work item. /// /// Extracts the leading numeric ID and optional type tag from the file stem /// (e.g. `"293"` and `"story"` from `"293_story_register_all_bot_commands"`) /// and combines them with the human-readable name from the front matter when /// available. Known types (`story`, `bug`, `spike`, `refactor`) are shown as /// bracketed labels; unknown or missing types are omitted silently. /// /// Examples: /// - `("293_story_foo", Some("Register all bot commands"))` → `"293 [story] — Register all bot commands"` /// - `("375_bug_foo", None)` → `"375 [bug]"` /// - `("293_story_foo", None)` → `"293 [story]"` /// - `("no_number_here", None)` → `"no_number_here"` pub(super) fn story_short_label(stem: &str, name: Option<&str>) -> String { let mut parts = stem.splitn(3, '_'); let first = parts.next().unwrap_or(stem); let (number, type_label) = if !first.is_empty() && first.chars().all(|c| c.is_ascii_digit()) { let t = parts.next().and_then(|t| match t { "story" | "bug" | "spike" | "refactor" => Some(t), _ => None, }); (first, t) } else { (stem, None) }; let prefix = match type_label { Some(t) => format!("{number} [{t}]"), None => number.to_string(), }; match name { Some(n) => format!("{prefix} — {n}"), None => prefix, } } /// Read the `blocked` flag from a story file's YAML front matter. /// /// Returns `true` when the story has `blocked: true` set (retry limit reached). fn read_story_blocked(project_root: &std::path::Path, stage_dir: &str, stem: &str) -> bool { let path = project_root .join(".storkit") .join("work") .join(stage_dir) .join(format!("{stem}.md")); std::fs::read_to_string(path) .ok() .and_then(|c| crate::io::story_metadata::parse_front_matter(&c).ok()) .and_then(|m| m.blocked) .unwrap_or(false) } /// Choose the traffic-light dot for a work item. /// /// Priority: blocked > throttled > running > idle. /// Uses compact Unicode characters (not large emoji) so the output stays /// readable in plain-text chat clients. /// /// - `●` running normally (active agent, no throttle) /// - `◑` throttled (rate-limit warning received) /// - `✗` hard-blocked (retry limit exceeded) /// - `○` idle / no active agent pub(super) fn traffic_light_dot(blocked: bool, throttled: bool, has_agent: bool) -> &'static str { if blocked { "\u{2717} " // ✗ — hard blocked } else if throttled { "\u{25D1} " // ◑ — throttled } else if has_agent { "\u{25CF} " // ● — running normally } else { "\u{25CB} " // ○ — idle / no agent } } /// 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(".storkit") .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 HTML `formatted_body` for the pipeline status with Matrix colour /// tags on the traffic-light dots. /// /// Converts the plain-text pipeline status (Markdown) to HTML via /// pulldown-cmark and wraps each traffic-light character in a /// `` tag so Matrix clients display them in /// colour. pub(super) fn build_pipeline_status_html(project_root: &std::path::Path, agents: &AgentPool) -> String { use pulldown_cmark::{Options, Parser, html}; let plain = build_pipeline_status(project_root, agents); let normalized = crate::chat::util::normalize_line_breaks(&plain); let options = Options::ENABLE_TABLES | Options::ENABLE_FOOTNOTES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS; let parser = Parser::new_ext(&normalized, options); let mut html_out = String::new(); html::push_html(&mut html_out, parser); // Wrap each traffic-light character with a Matrix colour tag. html_out .replace('\u{2717}', "\u{2717}") // ✗ blocked .replace('\u{25D1}', "\u{25D1}") // ◑ throttled .replace('\u{25CF}', "\u{25CF}") // ● running .replace('\u{25CB}', "\u{25CB}") // ○ idle } /// Build the full pipeline status text formatted for Matrix (markdown). pub(super) 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: HashMap = active_agents .iter() .filter(|a| matches!(a.status, AgentStatus::Running | AgentStatus::Pending)) .map(|a| (a.story_id.clone(), a)) .collect(); // Read token usage once for all stories to avoid repeated file I/O. let cost_by_story: HashMap = crate::agents::token_usage::read_all(project_root) .unwrap_or_default() .into_iter() .fold(HashMap::new(), |mut map, r| { *map.entry(r.story_id).or_insert(0.0) += r.usage.total_cost_usd; map }); let config = ProjectConfig::load(project_root).ok(); let mut out = String::from("**Pipeline Status**\n\n"); let stages = [ ("1_backlog", "Backlog"), ("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 = story_short_label(story_id, name.as_deref()); let cost_suffix = cost_by_story .get(story_id) .filter(|&&c| c > 0.0) .map(|c| format!(" — ${c:.2}")) .unwrap_or_default(); let blocked = read_story_blocked(project_root, dir, story_id); let agent = active_map.get(story_id); let throttled = agent.map(|a| a.throttled).unwrap_or(false); let dot = traffic_light_dot(blocked, throttled, agent.is_some()); if let Some(agent) = agent { 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!( " {dot}{display}{cost_suffix} — {} ({model_str})\n", agent.agent_name )); } else { out.push_str(&format!(" {dot}{display}{cost_suffix}\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: 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 } #[cfg(test)] mod tests { use super::*; use crate::agents::AgentPool; #[test] fn status_command_matches() { let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy status"); assert!(result.is_some(), "status command should match"); } #[test] fn status_command_returns_pipeline_text() { let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy status"); let output = result.unwrap(); assert!( output.contains("Pipeline Status"), "status output should contain pipeline info: {output}" ); } #[test] fn status_command_case_insensitive() { let result = super::super::tests::try_cmd_addressed("Timmy", "@timmy:homeserver.local", "@timmy STATUS"); assert!(result.is_some(), "STATUS should match case-insensitively"); } // -- story_short_label -------------------------------------------------- #[test] fn short_label_extracts_number_and_name() { let label = story_short_label("293_story_register_all_bot_commands", Some("Register all bot commands")); assert_eq!(label, "293 [story] — Register all bot commands"); } #[test] fn short_label_number_only_when_no_name() { let label = story_short_label("297_story_improve_bot_status_command_formatting", None); assert_eq!(label, "297 [story]"); } #[test] fn short_label_falls_back_to_stem_when_no_numeric_prefix() { let label = story_short_label("no_number_here", None); assert_eq!(label, "no_number_here"); } #[test] fn short_label_does_not_include_underscore_slug() { let label = story_short_label("293_story_register_all_bot_commands_in_the_command_registry", Some("Register all bot commands")); assert!( !label.contains("story_register"), "label should not contain the slug portion: {label}" ); } #[test] fn short_label_shows_bug_type() { let label = story_short_label("375_bug_default_project_toml", Some("Default project.toml issue")); assert_eq!(label, "375 [bug] — Default project.toml issue"); } #[test] fn short_label_shows_spike_type() { let label = story_short_label("61_spike_filesystem_watcher_architecture", Some("Filesystem watcher architecture")); assert_eq!(label, "61 [spike] — Filesystem watcher architecture"); } #[test] fn short_label_shows_refactor_type() { let label = story_short_label("260_refactor_upgrade_libsqlite3_sys", Some("Upgrade libsqlite3-sys")); assert_eq!(label, "260 [refactor] — Upgrade libsqlite3-sys"); } #[test] fn short_label_omits_unknown_type() { let label = story_short_label("42_task_do_something", Some("Do something")); assert_eq!(label, "42 — Do something"); } #[test] fn short_label_no_type_when_only_id() { // Stem with only a numeric ID and no type segment let label = story_short_label("42", Some("Some item")); assert_eq!(label, "42 — Some item"); } // -- build_pipeline_status formatting ----------------------------------- #[test] fn status_does_not_show_full_filename_stem() { use std::io::Write; use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let stage_dir = tmp.path().join(".storkit/work/2_current"); std::fs::create_dir_all(&stage_dir).unwrap(); // Write a story file with a front-matter name let story_path = stage_dir.join("293_story_register_all_bot_commands.md"); let mut f = std::fs::File::create(&story_path).unwrap(); writeln!(f, "---\nname: Register all bot commands\n---\n").unwrap(); let agents = AgentPool::new_test(3000); let output = build_pipeline_status(tmp.path(), &agents); assert!( !output.contains("293_story_register_all_bot_commands"), "output must not show full filename stem: {output}" ); assert!( output.contains("293 [story] — Register all bot commands"), "output must show number, type, and title: {output}" ); } // -- token cost in status output ---------------------------------------- #[test] fn status_shows_cost_when_token_usage_exists() { use std::io::Write; use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let stage_dir = tmp.path().join(".storkit/work/2_current"); std::fs::create_dir_all(&stage_dir).unwrap(); let story_path = stage_dir.join("293_story_register_all_bot_commands.md"); let mut f = std::fs::File::create(&story_path).unwrap(); writeln!(f, "---\nname: Register all bot commands\n---\n").unwrap(); // Write token usage for this story. let usage = crate::agents::TokenUsage { input_tokens: 100, output_tokens: 200, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, total_cost_usd: 0.29, }; let record = crate::agents::token_usage::build_record( "293_story_register_all_bot_commands", "coder-1", None, usage, ); crate::agents::token_usage::append_record(tmp.path(), &record).unwrap(); let agents = AgentPool::new_test(3000); let output = build_pipeline_status(tmp.path(), &agents); assert!( output.contains("293 [story] — Register all bot commands — $0.29"), "output must show cost next to story: {output}" ); } #[test] fn status_no_cost_when_no_usage() { use std::io::Write; use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let stage_dir = tmp.path().join(".storkit/work/2_current"); std::fs::create_dir_all(&stage_dir).unwrap(); let story_path = stage_dir.join("293_story_register_all_bot_commands.md"); let mut f = std::fs::File::create(&story_path).unwrap(); writeln!(f, "---\nname: Register all bot commands\n---\n").unwrap(); // No token usage written. let agents = AgentPool::new_test(3000); let output = build_pipeline_status(tmp.path(), &agents); assert!( !output.contains("$"), "output must not show cost when no usage exists: {output}" ); } #[test] fn status_aggregates_multiple_records_per_story() { use std::io::Write; use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let stage_dir = tmp.path().join(".storkit/work/2_current"); std::fs::create_dir_all(&stage_dir).unwrap(); let story_path = stage_dir.join("293_story_register_all_bot_commands.md"); let mut f = std::fs::File::create(&story_path).unwrap(); writeln!(f, "---\nname: Register all bot commands\n---\n").unwrap(); // Write two records for the same story — costs should be summed. for cost in [0.10, 0.19] { let usage = crate::agents::TokenUsage { input_tokens: 50, output_tokens: 100, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, total_cost_usd: cost, }; let record = crate::agents::token_usage::build_record( "293_story_register_all_bot_commands", "coder-1", None, usage, ); crate::agents::token_usage::append_record(tmp.path(), &record).unwrap(); } let agents = AgentPool::new_test(3000); let output = build_pipeline_status(tmp.path(), &agents); assert!( output.contains("293 [story] — Register all bot commands — $0.29"), "output must show aggregated cost: {output}" ); } // -- traffic_light_dot -------------------------------------------------- // -- build_pipeline_status_html (colored dots) -------------------------- #[test] fn html_status_colors_idle_dot_grey() { use std::io::Write; use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let stage_dir = tmp.path().join(".storkit/work/2_current"); std::fs::create_dir_all(&stage_dir).unwrap(); let story_path = stage_dir.join("42_story_idle.md"); let mut f = std::fs::File::create(&story_path).unwrap(); writeln!(f, "---\nname: Idle Story\n---\n").unwrap(); let agents = AgentPool::new_test(3000); let html = build_pipeline_status_html(tmp.path(), &agents); assert!( html.contains("\u{25CB}"), "idle dot should be grey (#888888): {html}" ); } #[test] fn html_status_colors_blocked_dot_red() { use std::io::Write; use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let stage_dir = tmp.path().join(".storkit/work/2_current"); std::fs::create_dir_all(&stage_dir).unwrap(); let story_path = stage_dir.join("42_story_blocked.md"); let mut f = std::fs::File::create(&story_path).unwrap(); writeln!(f, "---\nname: Blocked Story\nblocked: true\n---\n").unwrap(); let agents = AgentPool::new_test(3000); let html = build_pipeline_status_html(tmp.path(), &agents); assert!( html.contains("\u{2717}"), "blocked dot should be red (#cc0000): {html}" ); } #[test] fn html_status_plain_text_body_unchanged() { use std::io::Write; use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let stage_dir = tmp.path().join(".storkit/work/2_current"); std::fs::create_dir_all(&stage_dir).unwrap(); let story_path = stage_dir.join("42_story_idle.md"); let mut f = std::fs::File::create(&story_path).unwrap(); writeln!(f, "---\nname: Idle Story\n---\n").unwrap(); let agents = AgentPool::new_test(3000); let plain = build_pipeline_status(tmp.path(), &agents); // Plain text must still use bare Unicode dots (no HTML tags). assert!( plain.contains('\u{25CB}'), "plain text should have bare Unicode idle dot: {plain}" ); assert!( !plain.contains("data-mx-color"), "plain text must not contain HTML colour attributes: {plain}" ); } // -- traffic_light_dot -------------------------------------------------- #[test] fn dot_idle_when_no_agent() { assert_eq!(traffic_light_dot(false, false, false), "\u{25CB} "); // ○ } #[test] fn dot_running_when_agent_not_throttled() { assert_eq!(traffic_light_dot(false, false, true), "\u{25CF} "); // ● } #[test] fn dot_throttled_when_agent_throttled() { assert_eq!(traffic_light_dot(false, true, true), "\u{25D1} "); // ◑ } #[test] fn dot_blocked_takes_priority_over_throttled() { assert_eq!(traffic_light_dot(true, true, true), "\u{2717} "); // ✗ } #[test] fn dot_blocked_when_no_agent_but_blocked_flag() { assert_eq!(traffic_light_dot(true, false, false), "\u{2717} "); // ✗ } // -- read_story_blocked -------------------------------------------------- #[test] fn read_story_blocked_returns_true_when_blocked() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let stage_dir = tmp.path().join(".storkit/work/2_current"); std::fs::create_dir_all(&stage_dir).unwrap(); std::fs::write( stage_dir.join("42_story_foo.md"), "---\nname: Foo\nblocked: true\n---\n", ) .unwrap(); assert!(read_story_blocked(tmp.path(), "2_current", "42_story_foo")); } #[test] fn read_story_blocked_returns_false_when_not_blocked() { use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let stage_dir = tmp.path().join(".storkit/work/2_current"); std::fs::create_dir_all(&stage_dir).unwrap(); std::fs::write( stage_dir.join("42_story_foo.md"), "---\nname: Foo\n---\n", ) .unwrap(); assert!(!read_story_blocked(tmp.path(), "2_current", "42_story_foo")); } // -- status output shows idle dot for items with no active agent -------- #[test] fn status_shows_idle_dot_for_unassigned_story() { use std::io::Write; use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let stage_dir = tmp.path().join(".storkit/work/2_current"); std::fs::create_dir_all(&stage_dir).unwrap(); let story_path = stage_dir.join("42_story_idle.md"); let mut f = std::fs::File::create(&story_path).unwrap(); writeln!(f, "---\nname: Idle Story\n---\n").unwrap(); let agents = AgentPool::new_test(3000); let output = build_pipeline_status(tmp.path(), &agents); assert!( output.contains("\u{25CB} "), // ○ "idle story should show empty-circle dot: {output}" ); } #[test] fn status_shows_blocked_dot_for_blocked_story() { use std::io::Write; use tempfile::TempDir; let tmp = TempDir::new().unwrap(); let stage_dir = tmp.path().join(".storkit/work/2_current"); std::fs::create_dir_all(&stage_dir).unwrap(); let story_path = stage_dir.join("42_story_blocked.md"); let mut f = std::fs::File::create(&story_path).unwrap(); writeln!(f, "---\nname: Blocked Story\nblocked: true\n---\n").unwrap(); let agents = AgentPool::new_test(3000); let output = build_pipeline_status(tmp.path(), &agents); assert!( output.contains("\u{2717} "), // ✗ "blocked story should show X dot: {output}" ); } }