diff --git a/server/src/chat/commands/triage.rs b/server/src/chat/commands/triage.rs index 067f83d6..c54621e1 100644 --- a/server/src/chat/commands/triage.rs +++ b/server/src/chat/commands/triage.rs @@ -1,8 +1,12 @@ //! Handler for the story triage dump subcommand of `status`. //! -//! Produces a triage dump for a story that is currently in-progress -//! (`work/2_current/`): metadata, acceptance criteria, worktree/branch state, -//! git diff, recent commits, and the tail of the agent log. +//! Produces a triage dump for a story: metadata, acceptance criteria, +//! worktree/branch state, git diff, recent commits, and the tail of the +//! agent log. +//! +//! Reads from the CRDT pipeline state and the in-memory content store — no +//! filesystem access for story content. Works for stories in any pipeline +//! stage, not just `2_current`. //! //! The command is handled entirely at the bot level — no LLM invocation. @@ -26,39 +30,31 @@ pub(super) fn handle_triage(ctx: &CommandContext) -> Option { )); } - let current_dir = ctx - .project_root - .join(".huskies") - .join("work") - .join("2_current"); - - match find_story_in_dir(¤t_dir, num_str) { - Some((path, stem)) => Some(build_triage_dump(ctx, &path, &stem, num_str)), + match find_story_by_number(num_str) { + Some((story_id, item)) => Some(build_triage_dump(ctx, &story_id, &item, num_str)), None => Some(format!( - "Story **{num_str}** is not currently in progress (not found in `work/2_current/`)." + "Story **{num_str}** not found in the pipeline." )), } } -/// Find a `.md` file whose numeric prefix matches `num_str` in `dir`. -/// -/// Returns `(path, file_stem)` for the first match. -fn find_story_in_dir(dir: &Path, num_str: &str) -> Option<(PathBuf, String)> { - let entries = std::fs::read_dir(dir).ok()?; - 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 file_num = stem - .split('_') - .next() - .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) - .unwrap_or(""); - if file_num == num_str { - return Some((path.clone(), stem.to_string())); - } +/// Find a pipeline item whose numeric prefix matches `num_str` by querying the +/// CRDT state. Returns `(story_id, PipelineItem)` for the first match. +fn find_story_by_number( + num_str: &str, +) -> Option<(String, crate::pipeline_state::PipelineItem)> { + let items = crate::pipeline_state::read_all_typed(); + for item in items { + let file_num = item + .story_id + .0 + .split('_') + .next() + .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) + .unwrap_or(""); + if file_num == num_str { + let story_id = item.story_id.0.clone(); + return Some((story_id, item)); } } None @@ -67,13 +63,13 @@ fn find_story_in_dir(dir: &Path, num_str: &str) -> Option<(PathBuf, String)> { /// Build the full triage dump for a story. fn build_triage_dump( ctx: &CommandContext, - story_path: &Path, story_id: &str, + item: &crate::pipeline_state::PipelineItem, num_str: &str, ) -> String { - let contents = match std::fs::read_to_string(story_path) { - Ok(c) => c, - Err(e) => return format!("Failed to read story {num_str}: {e}"), + let contents = match crate::db::read_content(story_id) { + Some(c) => c, + None => return format!("Story {num_str}: content not found in content store."), }; let meta = crate::io::story_metadata::parse_front_matter(&contents).ok(); @@ -83,7 +79,9 @@ fn build_triage_dump( // ---- Header ---- out.push_str(&format!("## Story {num_str} — {name}\n")); - out.push_str("**Stage:** In Progress (`2_current`)\n\n"); + let stage_name = crate::pipeline_state::stage_label(&item.stage); + let dir_name = crate::pipeline_state::stage_dir_name(&item.stage); + out.push_str(&format!("**Stage:** {stage_name} (`{dir_name}`)\n\n")); // ---- Front matter fields ---- if let Some(ref m) = meta { @@ -351,27 +349,24 @@ mod tests { // -- not found ---------------------------------------------------------- #[test] - fn whatsup_story_not_in_current_returns_friendly_message() { + fn whatsup_story_not_in_pipeline_returns_friendly_message() { + // Initialize the content store so read_all_typed() returns nothing for + // this number without panicking. + crate::db::ensure_content_store(); let tmp = tempfile::TempDir::new().unwrap(); - // Create the directory but put the story in backlog, not current - write_story_file( - tmp.path(), - "1_backlog", - "42_story_not_in_current.md", - "---\nname: Not in current\n---\n", - ); - let output = status_triage_cmd(tmp.path(), "42").unwrap(); + // Use a number unlikely to collide with other tests' CRDT entries. + let output = status_triage_cmd(tmp.path(), "99997").unwrap(); assert!( - output.contains("42"), + output.contains("99997"), "message should include story number: {output}" ); assert!( - output.contains("not") || output.contains("Not"), - "message should say not found/in progress: {output}" + output.contains("not found") || output.contains("Not found"), + "message should say not found: {output}" ); } - // -- found in 2_current ------------------------------------------------- + // -- found in any pipeline stage ---------------------------------------- #[test] fn whatsup_shows_story_name_and_stage() { @@ -389,11 +384,49 @@ mod tests { "should show story name: {output}" ); assert!( - output.contains("In Progress") || output.contains("2_current"), + output.contains("Coding") || output.contains("2_current"), "should show pipeline stage: {output}" ); } + #[test] + fn whatsup_works_for_story_in_backlog() { + let tmp = tempfile::TempDir::new().unwrap(); + write_story_file( + tmp.path(), + "1_backlog", + "9901_story_backlog_item.md", + "---\nname: Backlog Item\n---\n", + ); + let output = status_triage_cmd(tmp.path(), "9901").unwrap(); + assert!(output.contains("9901"), "should show story number: {output}"); + assert!( + output.contains("Backlog Item"), + "should show story name: {output}" + ); + assert!( + output.contains("Backlog") || output.contains("1_backlog"), + "should show backlog stage: {output}" + ); + } + + #[test] + fn whatsup_works_for_story_in_qa() { + let tmp = tempfile::TempDir::new().unwrap(); + write_story_file( + tmp.path(), + "3_qa", + "9902_story_qa_item.md", + "---\nname: QA Item\n---\n", + ); + let output = status_triage_cmd(tmp.path(), "9902").unwrap(); + assert!(output.contains("9902"), "should show story number: {output}"); + assert!( + output.contains("QA Item"), + "should show story name: {output}" + ); + } + #[test] fn whatsup_shows_acceptance_criteria() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/server/src/http/mcp/status_tools.rs b/server/src/http/mcp/status_tools.rs index 44fad140..a518eb2c 100644 --- a/server/src/http/mcp/status_tools.rs +++ b/server/src/http/mcp/status_tools.rs @@ -158,19 +158,13 @@ pub(super) async fn tool_status(args: &Value, ctx: &AppContext) -> Result