diff --git a/.claude/settings.json b/.claude/settings.json index 41f8f0fd..fc28af23 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,76 +1,28 @@ { - "enabledMcpjsonServers": [ - "huskies" - ], "permissions": { "allow": [ - "Bash(./server/target/debug/huskies:*)", - "Bash(./target/debug/huskies:*)", - "Bash(HUSKIES_PORT=*)", "Bash(cargo build:*)", "Bash(cargo check:*)", - "Bash(cargo clippy:*)", - "Bash(cargo doc:*)", - "Bash(cargo llvm-cov:*)", - "Bash(cargo nextest run:*)", - "Bash(cargo run:*)", - "Bash(cargo test:*)", - "Bash(cargo watch:*)", - "Bash(cd *)", - "Bash(claude:*)", - "Bash(curl:*)", - "Bash(echo:*)", - "Bash(env:*)", "Bash(git *)", - "Bash(grep:*)", - "Bash(kill *)", "Bash(ls *)", - "Bash(lsof *)", "Bash(mkdir *)", "Bash(mv *)", - "Bash(npm run build:*)", - "Bash(npx @biomejs/biome check:*)", - "Bash(npx @playwright/test test:*)", - "Bash(npx biome check:*)", - "Bash(npx playwright test:*)", - "Bash(npx tsc:*)", - "Bash(npx vitest:*)", - "Bash(pnpm add:*)", - "Bash(pnpm build:*)", - "Bash(pnpm dev:*)", - "Bash(pnpm install:*)", - "Bash(pnpm run build:*)", - "Bash(pnpm run test:*)", - "Bash(pnpm test:*)", - "Bash(printf:*)", - "Bash(ps *)", - "Bash(python3:*)", - "Bash(pwd *)", "Bash(rm *)", - "Bash(sleep *)", "Bash(touch *)", - "Bash(xargs:*)", - "WebFetch(domain:crates.io)", - "WebFetch(domain:docs.rs)", - "WebFetch(domain:github.com)", - "WebFetch(domain:portkey.ai)", - "WebFetch(domain:www.shuttle.dev)", - "WebSearch", - "mcp__huskies__*", - "Edit", - "Write", + "Bash(echo:*)", + "Bash(pwd *)", + "Bash(grep:*)", "Bash(find *)", - "Bash(sqlite3 *)", - "Bash(cat <<:*)", - "Bash(cat <<'ENDJSON:*)", - "Bash(make release:*)", - "Bash(npm test:*)", "Bash(head *)", "Bash(tail *)", "Bash(wc *)", - "Bash(npx vite:*)", - "Bash(npm run dev:*)", - "Bash(stat *)" + "Bash(cat *)", + "Edit", + "Write", + "mcp__huskies__*" ] - } -} \ No newline at end of file + }, + "enabledMcpjsonServers": [ + "huskies" + ] +} diff --git a/frontend/src/slashCommands.ts b/frontend/src/slashCommands.ts index 6209dc7c..f19b76c7 100644 --- a/frontend/src/slashCommands.ts +++ b/frontend/src/slashCommands.ts @@ -8,6 +8,10 @@ export const SLASH_COMMANDS: SlashCommand[] = [ name: "/help", description: "Show this list of available slash commands.", }, + { + name: "/backlog", + description: "Show all items in the backlog with dependency satisfaction status.", + }, { name: "/status", description: diff --git a/server/src/agents/gates.rs b/server/src/agents/gates.rs index fd7b33a6..352c4a80 100644 --- a/server/src/agents/gates.rs +++ b/server/src/agents/gates.rs @@ -193,10 +193,18 @@ pub(crate) fn run_coverage_gate(path: &Path) -> Result<(bool, String), String> { } let mut output = String::from("=== script/test_coverage ===\n"); - let result = Command::new(&script) - .current_dir(path) - .output() - .map_err(|e| format!("Failed to run script/test_coverage: {e}"))?; + let result = match Command::new(&script).current_dir(path).output() { + Ok(r) => r, + Err(e) if e.raw_os_error() == Some(26) => { + // ETXTBSY — retry once after a brief pause. + std::thread::sleep(std::time::Duration::from_millis(50)); + Command::new(&script) + .current_dir(path) + .output() + .map_err(|e| format!("Failed to run script/test_coverage: {e}"))? + } + Err(e) => return Err(format!("Failed to run script/test_coverage: {e}")), + }; let combined = format!( "{}{}", diff --git a/server/src/chat/commands/backlog.rs b/server/src/chat/commands/backlog.rs new file mode 100644 index 00000000..75f10547 --- /dev/null +++ b/server/src/chat/commands/backlog.rs @@ -0,0 +1,206 @@ +//! Handler for the `backlog` command — shows only Stage::Backlog items. + +use crate::pipeline_state::{PipelineItem, Stage}; +use super::CommandContext; +use super::status::{story_short_label, unmet_deps_from_items}; + +pub(super) fn handle_backlog(_ctx: &CommandContext) -> Option { + Some(build_backlog_output()) +} + +/// Build the backlog listing text. +/// +/// Reads all items from CRDT via `read_all_typed`, filters to `Stage::Backlog`, +/// and renders each with number, type, name, and dependency satisfaction status. +pub(super) fn build_backlog_output() -> String { + let items = crate::pipeline_state::read_all_typed(); + build_backlog_from_items(&items) +} + +/// Inner implementation that accepts pre-loaded items for testability. +pub(super) fn build_backlog_from_items(items: &[PipelineItem]) -> String { + let mut backlog: Vec<&PipelineItem> = items + .iter() + .filter(|i| matches!(i.stage, Stage::Backlog)) + .collect(); + backlog.sort_by(|a, b| a.story_id.0.cmp(&b.story_id.0)); + + let count = backlog.len(); + let mut out = format!("**Backlog** ({count})\n"); + + if backlog.is_empty() { + out.push_str(" *(none)*\n"); + return out; + } + + for item in &backlog { + out.push_str(&render_backlog_line(item, items)); + } + out +} + +/// Render a single backlog line: `⚪ [type] — *(waiting on: X, Y)*` +fn render_backlog_line(item: &PipelineItem, all_items: &[PipelineItem]) -> String { + let story_id = &item.story_id.0; + let name_opt = if item.name.is_empty() { + None + } else { + Some(item.name.as_str()) + }; + let display = story_short_label(story_id, name_opt); + let unmet = unmet_deps_from_items(item, all_items); + let dep_suffix = if unmet.is_empty() { + String::new() + } else { + let nums: Vec = unmet.iter().map(|n| n.to_string()).collect(); + format!(" *(waiting on: {})*", nums.join(", ")) + }; + format!(" \u{26AA} {display}{dep_suffix}\n") // ⚪ +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pipeline_state::{Stage, StoryId}; + use chrono::Utc; + + fn make_item(id: &str, name: &str, stage: Stage) -> PipelineItem { + PipelineItem { + story_id: StoryId(id.to_string()), + name: name.to_string(), + stage, + depends_on: Vec::new(), + retry_count: 0, + } + } + + fn make_item_with_deps(id: &str, name: &str, stage: Stage, deps: Vec) -> PipelineItem { + PipelineItem { + story_id: StoryId(id.to_string()), + name: name.to_string(), + stage, + depends_on: deps.iter().map(|n| StoryId(n.to_string())).collect(), + retry_count: 0, + } + } + + // -- AC: shows only Stage::Backlog items ---------------------------------- + + #[test] + fn backlog_shows_only_backlog_stage_items() { + let items = vec![ + make_item("10_story_in_backlog", "In Backlog", Stage::Backlog), + make_item("20_story_in_progress", "In Progress", Stage::Coding), + make_item("30_story_in_qa", "In QA", Stage::Qa), + ]; + let output = build_backlog_from_items(&items); + assert!(output.contains("In Backlog"), "should show backlog item: {output}"); + assert!(!output.contains("In Progress"), "should not show coding items: {output}"); + assert!(!output.contains("In QA"), "should not show QA items: {output}"); + } + + // -- AC: shows number, type, name ----------------------------------------- + + #[test] + fn backlog_shows_number_type_and_name() { + let items = vec![make_item("42_story_my_feature", "My Feature", Stage::Backlog)]; + let output = build_backlog_from_items(&items); + assert!( + output.contains("42 [story] — My Feature"), + "should show number, type, and name: {output}" + ); + } + + // -- AC: shows depends_on with satisfaction status ------------------------ + + #[test] + fn backlog_shows_waiting_on_for_unmet_deps() { + let items = vec![ + make_item_with_deps("10_story_waiting", "Waiting Story", Stage::Backlog, vec![999]), + make_item("999_story_dep", "Dep Story", Stage::Backlog), + ]; + let output = build_backlog_from_items(&items); + assert!( + output.contains("waiting on: 999"), + "should show unmet dep: {output}" + ); + } + + #[test] + fn backlog_no_waiting_on_when_dep_is_done() { + let items = vec![ + make_item_with_deps("10_story_ready", "Ready Story", Stage::Backlog, vec![999]), + make_item( + "999_story_done", + "Done Dep", + Stage::Done { + merged_at: Utc::now(), + merge_commit: crate::pipeline_state::GitSha("abc".to_string()), + }, + ), + ]; + let output = build_backlog_from_items(&items); + assert!( + !output.contains("waiting on"), + "should not show waiting-on when dep is done: {output}" + ); + } + + #[test] + fn backlog_no_waiting_on_when_no_deps() { + let items = vec![make_item("5_story_nodeps", "No Deps", Stage::Backlog)]; + let output = build_backlog_from_items(&items); + assert!(!output.contains("waiting on"), "no dep suffix when no deps: {output}"); + } + + // -- AC: command is registered in the registry ---------------------------- + + #[test] + fn backlog_command_in_registry() { + let found = super::super::commands() + .iter() + .any(|c| c.name == "backlog"); + assert!(found, "backlog must be registered in commands()"); + } + + #[test] + fn backlog_command_appears_in_help() { + let result = super::super::tests::try_cmd_addressed( + "Timmy", + "@timmy:homeserver.local", + "@timmy help", + ); + let output = result.unwrap_or_default(); + assert!(output.contains("backlog"), "backlog should appear in help output: {output}"); + } + + #[test] + fn backlog_command_matches() { + let result = super::super::tests::try_cmd_addressed( + "Timmy", + "@timmy:homeserver.local", + "@timmy backlog", + ); + assert!(result.is_some(), "backlog command should match and return Some"); + } + + #[test] + fn backlog_output_contains_backlog_header() { + let result = super::super::tests::try_cmd_addressed( + "Timmy", + "@timmy:homeserver.local", + "@timmy backlog", + ); + let output = result.unwrap_or_default(); + assert!(output.contains("Backlog"), "backlog output should contain Backlog header: {output}"); + } + + // -- empty backlog -------------------------------------------------------- + + #[test] + fn backlog_shows_none_when_empty() { + let items = vec![make_item("1_story_done", "Done", Stage::Coding)]; + let output = build_backlog_from_items(&items); + assert!(output.contains("*(none)*"), "should show none when no backlog items: {output}"); + } +} diff --git a/server/src/chat/commands/mod.rs b/server/src/chat/commands/mod.rs index c1128954..d6f72e06 100644 --- a/server/src/chat/commands/mod.rs +++ b/server/src/chat/commands/mod.rs @@ -7,6 +7,7 @@ mod ambient; mod assign; +mod backlog; mod cost; mod coverage; mod depends; @@ -91,6 +92,11 @@ pub fn commands() -> &'static [BotCommand] { description: "Pre-assign a model to a story: `assign ` (e.g. `assign 42 opus`)", handler: assign::handle_assign, }, + BotCommand { + name: "backlog", + description: "Show all items in the backlog with dependency satisfaction status", + handler: backlog::handle_backlog, + }, BotCommand { name: "help", description: "Show this list of available commands", diff --git a/server/src/chat/commands/status.rs b/server/src/chat/commands/status.rs index 368185a5..4a2cb92a 100644 --- a/server/src/chat/commands/status.rs +++ b/server/src/chat/commands/status.rs @@ -77,7 +77,7 @@ pub(super) fn traffic_light_dot(blocked: bool, throttled: bool, has_agent: bool) /// A dependency is considered met if the dep is in `Done` or `Archived` stage /// in `all_items`. If the dep is not found in `all_items` at all (e.g. it was /// archived before the CRDT migration and has no row), it is treated as met. -fn unmet_deps_from_items(item: &PipelineItem, all_items: &[PipelineItem]) -> Vec { +pub(super) fn unmet_deps_from_items(item: &PipelineItem, all_items: &[PipelineItem]) -> Vec { item.depends_on .iter() .filter_map(|dep_id| {