diff --git a/server/src/chat/commands/depends.rs b/server/src/chat/commands/depends.rs index d7ab03ca..6af13d7f 100644 --- a/server/src/chat/commands/depends.rs +++ b/server/src/chat/commands/depends.rs @@ -9,16 +9,6 @@ use super::CommandContext; use crate::io::story_metadata::{parse_front_matter, write_depends_on}; -/// All pipeline stage directories searched when finding a work item by number. -const SEARCH_DIRS: &[&str] = &[ - "1_backlog", - "2_current", - "3_qa", - "4_merge", - "5_done", - "6_archived", -]; - /// Handle the `depends` command. /// /// Syntax: `depends [dep1 dep2 ...]` @@ -60,69 +50,18 @@ pub(super) fn handle_depends(ctx: &CommandContext) -> Option { } } - // Find the story file across all pipeline stages by numeric prefix. - // Try the content store / CRDT state first, then fall back to filesystem. - let mut found: Option<(std::path::PathBuf, String)> = None; - - // --- DB-first lookup --- - for id in crate::db::all_content_ids() { - let file_num = id.split('_').next().unwrap_or(""); - if file_num == num_str && let Ok(Some(item)) = crate::pipeline_state::read_typed(&id) { - let path = ctx - .project_root - .join(".huskies") - .join("work") - .join(item.stage.dir_name()) - .join(format!("{id}.md")); - found = Some((path, id)); - break; - } - } - - // --- Filesystem fallback --- - if found.is_none() { - 'outer: for stage_dir in SEARCH_DIRS { - let dir = ctx - .project_root - .join(".huskies") - .join("work") - .join(stage_dir); - if !dir.exists() { - continue; + // Find the story by numeric prefix: CRDT → content store → filesystem. + let (story_id, _stage_dir, path, content) = + match crate::chat::lookup::find_story_by_number(ctx.project_root, num_str) { + Some(found) => found, + None => { + return Some(format!( + "No story, bug, or spike with number **{num_str}** found." + )); } - 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 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 { - found = Some((path.to_path_buf(), stem.to_string())); - break 'outer; - } - } - } - } - } - } + }; - let (path, story_id) = match found { - Some(f) => f, - None => { - return Some(format!( - "No story, bug, or spike with number **{num_str}** found." - )); - } - }; - - // Try the content store first, then fall back to reading from disk. - let story_name = crate::db::read_content(&story_id) + let story_name = content .or_else(|| std::fs::read_to_string(&path).ok()) .and_then(|c| parse_front_matter(&c).ok()) .and_then(|m| m.name) diff --git a/server/src/chat/commands/move_story.rs b/server/src/chat/commands/move_story.rs index a523f3ec..4c3c42bd 100644 --- a/server/src/chat/commands/move_story.rs +++ b/server/src/chat/commands/move_story.rs @@ -10,16 +10,6 @@ use crate::agents::move_story_to_stage; /// Valid stage names accepted by the move command. const VALID_STAGES: &[&str] = &["backlog", "current", "qa", "merge", "done"]; -/// All pipeline stage directories to search when finding a work item by number. -const SEARCH_DIRS: &[&str] = &[ - "1_backlog", - "2_current", - "3_qa", - "4_merge", - "5_done", - "6_archived", -]; - /// Handle the `move` command. /// /// Parses ` ` from `ctx.args`, locates the work item by its @@ -55,55 +45,20 @@ pub(super) fn handle_move(ctx: &CommandContext) -> Option { )); } - // Find the story file across all pipeline stages by numeric prefix. - let mut found_story_id: Option = None; - let mut found_name: Option = None; - - 'outer: for stage_dir in SEARCH_DIRS { - let dir = ctx - .project_root - .join(".huskies") - .join("work") - .join(stage_dir); - if !dir.exists() { - continue; - } - 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 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 { - found_story_id = Some(stem.to_string()); - found_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) - }); - break 'outer; - } - } + // Find the story by numeric prefix: CRDT → content store → filesystem. + let (story_id, _stage_dir, _path, content) = + match crate::chat::lookup::find_story_by_number(ctx.project_root, num_str) { + Some(found) => found, + None => { + return Some(format!( + "No story, bug, or spike with number **{num_str}** found." + )); } - } - } + }; - let story_id = match found_story_id { - Some(id) => id, - None => { - return Some(format!( - "No story, bug, or spike with number **{num_str}** found." - )); - } - }; + let found_name = content + .and_then(|c| crate::io::story_metadata::parse_front_matter(&c).ok()) + .and_then(|m| m.name); let display_name = found_name.as_deref().unwrap_or(&story_id); diff --git a/server/src/chat/commands/show.rs b/server/src/chat/commands/show.rs index 6b424f2d..ac328c22 100644 --- a/server/src/chat/commands/show.rs +++ b/server/src/chat/commands/show.rs @@ -4,9 +4,8 @@ use super::CommandContext; /// Display the full markdown text of a work item identified by its numeric ID. /// -/// Searches all pipeline stages in order and returns the raw file contents of -/// the first matching story, bug, or spike. Returns a friendly message when -/// no match is found. +/// Lookup priority: CRDT → content store → filesystem (Story 512). +/// Returns a friendly message when no match is found. pub(super) fn handle_show(ctx: &CommandContext) -> Option { let num_str = ctx.args.trim(); if num_str.is_empty() { @@ -22,50 +21,27 @@ pub(super) fn handle_show(ctx: &CommandContext) -> Option { )); } - let stages = [ - "1_backlog", - "2_current", - "3_qa", - "4_merge", - "5_done", - "6_archived", - ]; - - for stage in &stages { - let dir = ctx - .project_root - .join(".huskies") - .join("work") - .join(stage); - if !dir.exists() { - continue; - } - 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 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 match std::fs::read_to_string(&path) { - Ok(contents) => Some(contents), - Err(e) => Some(format!("Failed to read story {num_str}: {e}")), - }; - } - } + // Find the story by numeric prefix: CRDT → content store → filesystem. + let (story_id, _stage_dir, path, content) = + match crate::chat::lookup::find_story_by_number(ctx.project_root, num_str) { + Some(found) => found, + None => { + return Some(format!( + "No story, bug, or spike with number **{num_str}** found." + )); } - } - } + }; - Some(format!( - "No story, bug, or spike with number **{num_str}** found." - )) + // `content` is populated from the content store (CRDT/DB path) or read + // from disk during the filesystem fallback. If it is None (story found in + // CRDT but no content-store entry yet), attempt a direct disk read. + Some( + content + .or_else(|| std::fs::read_to_string(&path).ok()) + .unwrap_or_else(|| { + format!("Story {story_id} found in pipeline but its content is unavailable.") + }), + ) } #[cfg(test)] diff --git a/server/src/chat/commands/unblock.rs b/server/src/chat/commands/unblock.rs index 6f277162..8082652a 100644 --- a/server/src/chat/commands/unblock.rs +++ b/server/src/chat/commands/unblock.rs @@ -8,16 +8,6 @@ use super::CommandContext; use crate::io::story_metadata::{clear_front_matter_field, clear_front_matter_field_in_content, parse_front_matter, set_front_matter_field}; use std::path::Path; -/// All pipeline stage directories to search when finding a work item by number. -const SEARCH_DIRS: &[&str] = &[ - "1_backlog", - "2_current", - "3_qa", - "4_merge", - "5_done", - "6_archived", -]; - /// Handle the `unblock` command. /// /// Parses `` from `ctx.args`, locates the work item, checks that it is @@ -40,61 +30,28 @@ pub(super) fn handle_unblock(ctx: &CommandContext) -> Option { /// /// Returns a Markdown-formatted response string suitable for all transports. /// Also used by the MCP `unblock` tool. +/// +/// Lookup priority: CRDT → content store → filesystem (Story 512). pub(crate) fn unblock_by_number(project_root: &Path, story_number: &str) -> String { - // Try content store / CRDT first to find story by numeric prefix. - let mut found_id: Option = None; - - // Check content store IDs. - for id in crate::db::all_content_ids() { - let num = id.split('_').next().unwrap_or(""); - if num == story_number { - found_id = Some(id); - break; - } - } - - // If found in content store, use DB-backed unblock. - if let Some(story_id) = found_id { - return unblock_by_story_id(&story_id); - } - - // Fallback: find the story file across all pipeline stages on filesystem. - let mut found: Option<(std::path::PathBuf, String)> = None; - - 'outer: for stage_dir in SEARCH_DIRS { - let dir = project_root.join(".huskies").join("work").join(stage_dir); - if !dir.exists() { - continue; - } - 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 file_num = stem - .split('_') - .next() - .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) - .unwrap_or(""); - if file_num == story_number { - found = Some((path.to_path_buf(), stem.to_string())); - break 'outer; - } - } + let (story_id, _stage_dir, path, _content) = + match crate::chat::lookup::find_story_by_number(project_root, story_number) { + Some(found) => found, + None => { + return format!( + "No story, bug, or spike with number **{story_number}** found." + ); } - } + }; + + // Prefer DB-backed unblock when the story is in the content store. + // Note: `content` may have come from the filesystem fallback in + // `find_story_by_number`, so we must re-check the DB rather than + // relying on `content.is_some()` alone. + if crate::db::read_content(&story_id).is_some() { + unblock_by_story_id(&story_id) + } else { + unblock_by_path(&path, &story_id) } - - let (path, story_id) = match found { - Some(f) => f, - None => { - return format!("No story, bug, or spike with number **{story_number}** found."); - } - }; - - unblock_by_path(&path, &story_id) } /// Unblock a story using the content store (DB-backed). diff --git a/server/src/chat/lookup.rs b/server/src/chat/lookup.rs new file mode 100644 index 00000000..15027394 --- /dev/null +++ b/server/src/chat/lookup.rs @@ -0,0 +1,223 @@ +//! Shared story-lookup helper for chat commands. +//! +//! All chat commands that need to find a work item by its numeric prefix +//! use [`find_story_by_number`]. The lookup priority matches the MCP +//! `move_story` tool (which already worked correctly post-491/492 migration): +//! +//! 1. **CRDT** — authoritative in-memory state, works even when the +//! filesystem shadow does not exist. +//! 2. **Content store / pipeline_items** — in-memory mirror of the +//! `pipeline_items` table; catches items that are in the DB but whose +//! CRDT entry hasn't been synced yet. +//! 3. **Filesystem** — backward-compatible fallback for stories that have +//! not yet been imported into the DB (pre-migration window). +//! +//! **Why this module exists (Story 512):** before this change, `move` and +//! `show` used pure filesystem lookups, causing them to silently fail with +//! "No story found" for any story whose filesystem shadow didn't exist — even +//! when the story was fully present in CRDT and `pipeline_items`. + +use std::path::{Path, PathBuf}; + +/// Pipeline stage directories searched by the filesystem fallback, in order. +pub(crate) const STAGES: &[&str] = &[ + "1_backlog", + "2_current", + "3_qa", + "4_merge", + "5_done", + "6_archived", +]; + +/// Locate a work item by its numeric ID prefix. +/// +/// Returns `(story_id, stage_dir, path, content)` where: +/// - `story_id` — full stem, e.g. `"503_story_some_feature"` +/// - `stage_dir` — pipeline directory name, e.g. `"4_merge"` +/// - `path` — canonical path under `.huskies/work/`; may not exist on disk +/// for CRDT-only stories +/// - `content` — markdown body from the content store when available; +/// for the filesystem fallback this is the file contents read from disk; +/// `None` only when the story was found in CRDT but has no content entry +/// (callers may fall back to `fs::read_to_string(&path)` in that case) +/// +/// Returns `None` when no matching work item is found by any mechanism. +pub(crate) fn find_story_by_number( + project_root: &Path, + number: &str, +) -> Option<(String, String, PathBuf, Option)> { + // ── 1. CRDT (authoritative) ────────────────────────────────────────── + // `read_all_items` returns None only when the CRDT layer is not yet + // initialised (e.g. in unit tests or very early startup). + if let Some(items) = crate::crdt_state::read_all_items() { + for item in items { + if item.story_id.split('_').next().unwrap_or("") == number { + let path = project_root + .join(".huskies") + .join("work") + .join(&item.stage) + .join(format!("{}.md", item.story_id)); + let content = crate::db::read_content(&item.story_id); + return Some((item.story_id, item.stage, path, content)); + } + } + } + + // ── 2. Content store + CRDT stage lookup ──────────────────────────── + // Handles the edge case where an item is in the content store but was + // somehow missing from the CRDT iteration above (e.g. concurrent write). + for id in crate::db::all_content_ids() { + if id.split('_').next().unwrap_or("") == number + && let Some(view) = crate::crdt_state::read_item(&id) + { + let stage_dir = view.stage; + let path = project_root + .join(".huskies") + .join("work") + .join(&stage_dir) + .join(format!("{id}.md")); + let content = crate::db::read_content(&id); + return Some((id, stage_dir, path, content)); + } + } + + // ── 3. Filesystem (backward-compat for pre-migration stories) ──────── + for stage in STAGES { + let dir = project_root.join(".huskies").join("work").join(stage); + if !dir.exists() { + continue; + } + 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 file_num = stem + .split('_') + .next() + .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) + .unwrap_or(""); + if file_num == number { + let content = std::fs::read_to_string(&path).ok(); + return Some(( + stem.to_string(), + stage.to_string(), + path, + content, + )); + } + } + } + } + } + + None +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::chat::test_helpers::write_story_file; + + #[test] + fn not_found_returns_none() { + let tmp = tempfile::TempDir::new().unwrap(); + // Create the pipeline directories so the search runs. + for stage in STAGES { + std::fs::create_dir_all(tmp.path().join(".huskies/work").join(stage)).unwrap(); + } + let result = find_story_by_number(tmp.path(), "999"); + assert!(result.is_none(), "should return None when story is not found"); + } + + #[test] + fn finds_story_in_backlog_via_filesystem() { + let tmp = tempfile::TempDir::new().unwrap(); + write_story_file( + tmp.path(), + "1_backlog", + "42_story_some_feature.md", + "---\nname: Some Feature\n---\n\n# Story 42\n", + ); + let (story_id, stage_dir, path, content) = + find_story_by_number(tmp.path(), "42").expect("should find story 42"); + assert_eq!(story_id, "42_story_some_feature"); + assert_eq!(stage_dir, "1_backlog"); + assert!( + path.ends_with("1_backlog/42_story_some_feature.md"), + "unexpected path: {path:?}" + ); + assert!( + content.as_deref().unwrap_or("").contains("Some Feature"), + "content should include story text" + ); + } + + #[test] + fn finds_story_in_any_stage_via_filesystem() { + let tmp = tempfile::TempDir::new().unwrap(); + write_story_file( + tmp.path(), + "3_qa", + "55_story_inqa.md", + "---\nname: In QA\n---\n", + ); + let (story_id, stage_dir, _, _) = + find_story_by_number(tmp.path(), "55").expect("should find story 55"); + assert_eq!(story_id, "55_story_inqa"); + assert_eq!(stage_dir, "3_qa"); + } + + #[test] + fn finds_bug_by_number_via_filesystem() { + let tmp = tempfile::TempDir::new().unwrap(); + write_story_file( + tmp.path(), + "2_current", + "7_bug_crash_on_login.md", + "---\nname: Crash on login\n---\n", + ); + let (story_id, stage_dir, _, _) = + find_story_by_number(tmp.path(), "7").expect("should find bug 7"); + assert_eq!(story_id, "7_bug_crash_on_login"); + assert_eq!(stage_dir, "2_current"); + } + + #[test] + fn numeric_prefix_must_match_exactly() { + let tmp = tempfile::TempDir::new().unwrap(); + // Story 1 exists; searching for "10" must not match "1_story_foo". + write_story_file( + tmp.path(), + "1_backlog", + "1_story_foo.md", + "---\nname: Foo\n---\n", + ); + let result = find_story_by_number(tmp.path(), "10"); + assert!(result.is_none(), "number 10 should not match story 1"); + } + + #[test] + fn returned_path_points_into_project_root() { + let tmp = tempfile::TempDir::new().unwrap(); + write_story_file( + tmp.path(), + "4_merge", + "503_story_migration.md", + "---\nname: Migration\n---\n", + ); + let (_, _, path, _) = + find_story_by_number(tmp.path(), "503").expect("should find story 503"); + assert!( + path.starts_with(tmp.path()), + "path should be under the project root" + ); + assert!(path.exists(), "filesystem-fallback path should exist on disk"); + } +} diff --git a/server/src/chat/mod.rs b/server/src/chat/mod.rs index c46e1a4b..5a859752 100644 --- a/server/src/chat/mod.rs +++ b/server/src/chat/mod.rs @@ -5,6 +5,7 @@ //! notifications) to work against any chat platform — Matrix, WhatsApp, etc. pub mod commands; +pub(crate) mod lookup; pub mod timer; pub mod transport; pub mod util; diff --git a/server/src/chat/transport/matrix/assign.rs b/server/src/chat/transport/matrix/assign.rs index b6c423a5..1c4e9166 100644 --- a/server/src/chat/transport/matrix/assign.rs +++ b/server/src/chat/transport/matrix/assign.rs @@ -13,16 +13,6 @@ use crate::chat::util::strip_bot_mention; use crate::io::story_metadata::{parse_front_matter, set_front_matter_field}; use std::path::Path; -/// All pipeline stage directories to search when finding a work item by number. -const STAGES: &[&str] = &[ - "1_backlog", - "2_current", - "3_qa", - "4_merge", - "5_done", - "6_archived", -]; - /// A parsed assign command from a Matrix message body. #[derive(Debug, PartialEq)] pub enum AssignCommand { @@ -101,76 +91,20 @@ pub async fn handle_assign( project_root: &Path, agents: &AgentPool, ) -> String { - // Find the story file across all pipeline stages. - // Try the content store / CRDT state first, then fall back to filesystem. - let mut found: Option<(std::path::PathBuf, String)> = None; - - // --- DB-first lookup --- - for id in crate::db::all_content_ids() { - let file_num = id.split('_').next().unwrap_or(""); - if file_num == story_number && let Ok(Some(item)) = crate::pipeline_state::read_typed(&id) { - let path = project_root - .join(".huskies") - .join("work") - .join(item.stage.dir_name()) - .join(format!("{id}.md")); - found = Some((path, id)); - break; - } - } - - // --- Filesystem fallback --- - if found.is_none() { - 'outer: for stage in STAGES { - let dir = project_root.join(".huskies").join("work").join(stage); - if !dir.exists() { - continue; + // Find the story by numeric prefix: CRDT → content store → filesystem. + let (story_id, _stage_dir, path, content) = + match crate::chat::lookup::find_story_by_number(project_root, story_number) { + Some(found) => found, + None => { + return format!( + "No story, bug, or spike with number **{story_number}** found." + ); } - 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()) - .map(|s| s.to_string()) - { - let file_num = stem - .split('_') - .next() - .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) - .unwrap_or("") - .to_string(); - if file_num == story_number { - found = Some((path, stem)); - break 'outer; - } - } - } - } - } - } + }; - let (path, story_id) = match found { - Some(f) => f, - None => { - return format!( - "No story, bug, or spike with number **{story_number}** found." - ); - } - }; - - // Read the human-readable name from front matter for the response. - // Try the content store first, then fall back to reading from disk. - let story_name = crate::db::read_content(&story_id) + let story_name = content .or_else(|| std::fs::read_to_string(&path).ok()) - .and_then(|contents| { - parse_front_matter(&contents) - .ok() - .and_then(|m| m.name) - }) + .and_then(|contents| parse_front_matter(&contents).ok().and_then(|m| m.name)) .unwrap_or_else(|| story_id.clone()); let agent_name = resolve_agent_name(model_str); @@ -370,6 +304,7 @@ mod tests { // -- handle_assign (no running coder) ------------------------------------ + use crate::chat::lookup::STAGES; use crate::chat::test_helpers::write_story_file; #[tokio::test] diff --git a/server/src/chat/transport/matrix/delete.rs b/server/src/chat/transport/matrix/delete.rs index dbaca187..fc00d4b4 100644 --- a/server/src/chat/transport/matrix/delete.rs +++ b/server/src/chat/transport/matrix/delete.rs @@ -60,77 +60,18 @@ pub async fn handle_delete( project_root: &Path, agents: &AgentPool, ) -> String { - const STAGES: &[&str] = &[ - "1_backlog", - "2_current", - "3_qa", - "4_merge", - "5_done", - "6_archived", - ]; - - // Find the story file across all pipeline stages. - // Try the content store / CRDT state first, then fall back to filesystem. - let mut found: Option<(std::path::PathBuf, String, String)> = None; // (path, stage, story_id) - - // --- DB-first lookup --- - for id in crate::db::all_content_ids() { - let file_num = id.split('_').next().unwrap_or(""); - if file_num == story_number && let Ok(Some(item)) = crate::pipeline_state::read_typed(&id) { - let path = project_root - .join(".huskies") - .join("work") - .join(item.stage.dir_name()) - .join(format!("{id}.md")); - found = Some((path, item.stage.dir_name().to_string(), id)); - break; - } - } - - // --- Filesystem fallback --- - if found.is_none() { - 'outer: for stage in STAGES { - let dir = project_root.join(".huskies").join("work").join(stage); - if !dir.exists() { - continue; + // Find the story by numeric prefix: CRDT → content store → filesystem. + let (story_id, stage, path, content) = + match crate::chat::lookup::find_story_by_number(project_root, story_number) { + Some(found) => found, + None => { + return format!( + "No story, bug, or spike with number **{story_number}** found." + ); } - 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()) - .map(|s| s.to_string()) - { - let file_num = stem - .split('_') - .next() - .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) - .unwrap_or("") - .to_string(); - if file_num == story_number { - found = Some((path, stage.to_string(), stem)); - break 'outer; - } - } - } - } - } - } + }; - let (path, stage, story_id) = match found { - Some(f) => f, - None => { - return format!("No story, bug, or spike with number **{story_number}** found."); - } - }; - - // Read the human-readable name from front matter for the confirmation message. - // Try the content store first, then fall back to reading from disk. - let story_name = crate::db::read_content(&story_id) + let story_name = content .or_else(|| std::fs::read_to_string(&path).ok()) .and_then(|contents| { crate::io::story_metadata::parse_front_matter(&contents) diff --git a/server/src/chat/transport/matrix/start.rs b/server/src/chat/transport/matrix/start.rs index ab6c35d8..8f1e703e 100644 --- a/server/src/chat/transport/matrix/start.rs +++ b/server/src/chat/transport/matrix/start.rs @@ -79,79 +79,18 @@ pub async fn handle_start( project_root: &Path, agents: &AgentPool, ) -> String { - const STAGES: &[&str] = &[ - "1_backlog", - "2_current", - "3_qa", - "4_merge", - "5_done", - "6_archived", - ]; - - // Find the story file across all pipeline stages. - // Try the content store / CRDT state first, then fall back to filesystem. - let mut found: Option<(std::path::PathBuf, String)> = None; // (path, story_id) - - // --- DB-first lookup --- - for id in crate::db::all_content_ids() { - let file_num = id.split('_').next().unwrap_or(""); - if file_num == story_number && let Ok(Some(item)) = crate::pipeline_state::read_typed(&id) { - let path = project_root - .join(".huskies") - .join("work") - .join(item.stage.dir_name()) - .join(format!("{id}.md")); - found = Some((path, id)); - break; - } - } - - // --- Filesystem fallback --- - if found.is_none() { - 'outer: for stage in STAGES { - let dir = project_root.join(".huskies").join("work").join(stage); - if !dir.exists() { - continue; + // Find the story by numeric prefix: CRDT → content store → filesystem. + let (story_id, _stage_dir, path, content) = + match crate::chat::lookup::find_story_by_number(project_root, story_number) { + Some(found) => found, + None => { + return format!( + "No story, bug, or spike with number **{story_number}** found." + ); } - 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()) - .map(|s| s.to_string()) - { - let file_num = stem - .split('_') - .next() - .filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())) - .unwrap_or("") - .to_string(); - if file_num == story_number { - found = Some((path, stem)); - break 'outer; - } - } - } - } - } - } + }; - let (path, story_id) = match found { - Some(f) => f, - None => { - return format!( - "No story, bug, or spike with number **{story_number}** found." - ); - } - }; - - // Read the human-readable name from front matter for the response. - // Try the content store first, then fall back to reading from disk. - let story_name = crate::db::read_content(&story_id) + let story_name = content .or_else(|| std::fs::read_to_string(&path).ok()) .and_then(|contents| { crate::io::story_metadata::parse_front_matter(&contents)