From c94b3d4450f2f4a5d6dc2baef27c5349a07ad4bf Mon Sep 17 00:00:00 2001 From: Dave Date: Thu, 19 Feb 2026 18:02:48 +0000 Subject: [PATCH] Accept story 36: Enforce Front Matter on All Story Files Add POST /workflow/stories/create endpoint that auto-assigns story numbers, generates correct front matter, and writes to upcoming/. Add slugify_name and next_story_number helpers with full test coverage. Add frontend createStory API method and types. Update README to recommend creation API for agents. Co-Authored-By: Claude Opus 4.6 --- .story_kit/README.md | 10 +- .../36_enforce_story_front_matter.md | 0 frontend/src/api/workflow.ts | 36 ++ server/src/http/workflow.rs | 439 +++++++++++++++++- 4 files changed, 477 insertions(+), 8 deletions(-) rename .story_kit/stories/{current => archived}/36_enforce_story_front_matter.md (100%) diff --git a/.story_kit/README.md b/.story_kit/README.md index e9de9c0..8a4be95 100644 --- a/.story_kit/README.md +++ b/.story_kit/README.md @@ -45,7 +45,15 @@ When the user asks for a feature, follow this 4-step loop strictly: ### Step 1: The Story (Ingest) * **User Input:** "I want the robot to dance." -* **Action:** Create a file in `stories/upcoming/` (e.g., `stories/upcoming/XX_robot_dance.md`). +* **Action:** Create a story via `POST /api/workflow/stories/create` (preferred for agents — guarantees correct front matter and auto-assigns the story number). Alternatively, create a file manually in `stories/upcoming/` (e.g., `stories/upcoming/XX_robot_dance.md`). +* **Front Matter (Required):** Every story file MUST begin with YAML front matter containing `name` and `test_plan` fields: + ```yaml + --- + name: Short Human-Readable Story Name + test_plan: pending + --- + ``` + The `test_plan` field tracks approval status: `pending` → `approved` (after Step 2). * **Move to Current:** Once the story is validated and ready for coding, move it to `stories/current/`. * **Tracking:** Mark Acceptance Criteria as tested directly in the story file as tests are completed. * **Content:** diff --git a/.story_kit/stories/current/36_enforce_story_front_matter.md b/.story_kit/stories/archived/36_enforce_story_front_matter.md similarity index 100% rename from .story_kit/stories/current/36_enforce_story_front_matter.md rename to .story_kit/stories/archived/36_enforce_story_front_matter.md diff --git a/frontend/src/api/workflow.ts b/frontend/src/api/workflow.ts index 6da977e..a87ebf0 100644 --- a/frontend/src/api/workflow.ts +++ b/frontend/src/api/workflow.ts @@ -66,6 +66,7 @@ export interface StoryTodosResponse { story_id: string; story_name: string | null; todos: string[]; + error: string | null; } export interface TodoListResponse { @@ -75,12 +76,33 @@ export interface TodoListResponse { export interface UpcomingStory { story_id: string; name: string | null; + error: string | null; } export interface UpcomingStoriesResponse { stories: UpcomingStory[]; } +export interface StoryValidationResult { + story_id: string; + valid: boolean; + error: string | null; +} + +export interface ValidateStoriesResponse { + stories: StoryValidationResult[]; +} + +export interface CreateStoryPayload { + name: string; + user_story?: string | null; + acceptance_criteria?: string[] | null; +} + +export interface CreateStoryResponse { + story_id: string; +} + const DEFAULT_API_BASE = "/api"; function buildApiUrl(path: string, baseUrl = DEFAULT_API_BASE): string { @@ -160,4 +182,18 @@ export const workflowApi = { getStoryTodos(baseUrl?: string) { return requestJson("/workflow/todos", {}, baseUrl); }, + validateStories(baseUrl?: string) { + return requestJson( + "/workflow/stories/validate", + {}, + baseUrl, + ); + }, + createStory(payload: CreateStoryPayload, baseUrl?: string) { + return requestJson( + "/workflow/stories/create", + { method: "POST", body: JSON.stringify(payload) }, + baseUrl, + ); + }, }; diff --git a/server/src/http/workflow.rs b/server/src/http/workflow.rs index bef7c80..93712bc 100644 --- a/server/src/http/workflow.rs +++ b/server/src/http/workflow.rs @@ -92,6 +92,7 @@ struct StoryTodosResponse { pub story_id: String, pub story_name: Option, pub todos: Vec, + pub error: Option, } #[derive(Object)] @@ -103,6 +104,7 @@ struct TodoListResponse { struct UpcomingStory { pub story_id: String, pub name: Option, + pub error: Option, } #[derive(Object)] @@ -110,6 +112,30 @@ struct UpcomingStoriesResponse { pub stories: Vec, } +#[derive(Deserialize, Object)] +struct CreateStoryPayload { + pub name: String, + pub user_story: Option, + pub acceptance_criteria: Option>, +} + +#[derive(Object)] +struct CreateStoryResponse { + pub story_id: String, +} + +#[derive(Object)] +struct StoryValidationResult { + pub story_id: String, + pub valid: bool, + pub error: Option, +} + +#[derive(Object)] +struct ValidateStoriesResponse { + pub stories: Vec, +} + fn load_upcoming_stories(ctx: &AppContext) -> Result, String> { let root = ctx.state.get_project_root()?; let upcoming_dir = root.join(".story_kit").join("stories").join("upcoming"); @@ -134,10 +160,11 @@ fn load_upcoming_stories(ctx: &AppContext) -> Result, String> .to_string(); let contents = fs::read_to_string(&path) .map_err(|e| format!("Failed to read story file {}: {e}", path.display()))?; - let name = parse_front_matter(&contents) - .ok() - .and_then(|meta| meta.name); - stories.push(UpcomingStory { story_id, name }); + let (name, error) = match parse_front_matter(&contents) { + Ok(meta) => (meta.name, None), + Err(e) => (None, Some(e.to_string())), + }; + stories.push(UpcomingStory { story_id, name, error }); } stories.sort_by(|a, b| a.story_id.cmp(&b.story_id)); @@ -491,14 +518,16 @@ impl WorkflowApi { .to_string(); let contents = fs::read_to_string(&path) .map_err(|e| bad_request(format!("Failed to read {}: {e}", path.display())))?; - let story_name = parse_front_matter(&contents) - .ok() - .and_then(|m| m.name); + let (story_name, error) = match parse_front_matter(&contents) { + Ok(m) => (m.name, None), + Err(e) => (None, Some(e.to_string())), + }; let todos = parse_unchecked_todos(&contents); stories.push(StoryTodosResponse { story_id, story_name, todos, + error, }); } @@ -512,6 +541,80 @@ impl WorkflowApi { Ok(Json(UpcomingStoriesResponse { stories })) } + /// Validate front matter on all current and upcoming story files. + #[oai(path = "/workflow/stories/validate", method = "get")] + async fn validate_stories(&self) -> OpenApiResult> { + let root = self.ctx.state.get_project_root().map_err(bad_request)?; + let stories = validate_story_dirs(&root).map_err(bad_request)?; + Ok(Json(ValidateStoriesResponse { stories })) + } + + /// Create a new story file with correct front matter in upcoming/. + #[oai(path = "/workflow/stories/create", method = "post")] + async fn create_story( + &self, + payload: Json, + ) -> OpenApiResult> { + let root = self.ctx.state.get_project_root().map_err(bad_request)?; + let story_number = next_story_number(&root).map_err(bad_request)?; + let slug = slugify_name(&payload.0.name); + + if slug.is_empty() { + return Err(bad_request("Name must contain at least one alphanumeric character.".to_string())); + } + + let filename = format!("{story_number}_{slug}.md"); + let upcoming_dir = root.join(".story_kit").join("stories").join("upcoming"); + fs::create_dir_all(&upcoming_dir) + .map_err(|e| bad_request(format!("Failed to create upcoming directory: {e}")))?; + + let filepath = upcoming_dir.join(&filename); + if filepath.exists() { + return Err(bad_request(format!("Story file already exists: {filename}"))); + } + + let story_id = filepath + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or_default() + .to_string(); + + // Build file content + let mut content = String::new(); + content.push_str("---\n"); + content.push_str(&format!("name: {}\n", payload.0.name)); + content.push_str("test_plan: pending\n"); + content.push_str("---\n\n"); + content.push_str(&format!("# Story {story_number}: {}\n\n", payload.0.name)); + + content.push_str("## User Story\n\n"); + if let Some(ref us) = payload.0.user_story { + content.push_str(us); + content.push('\n'); + } else { + content.push_str("As a ..., I want ..., so that ...\n"); + } + content.push('\n'); + + content.push_str("## Acceptance Criteria\n\n"); + if let Some(ref criteria) = payload.0.acceptance_criteria { + for criterion in criteria { + content.push_str(&format!("- [ ] {criterion}\n")); + } + } else { + content.push_str("- [ ] TODO\n"); + } + content.push('\n'); + + content.push_str("## Out of Scope\n\n"); + content.push_str("- TBD\n"); + + fs::write(&filepath, &content) + .map_err(|e| bad_request(format!("Failed to write story file: {e}")))?; + + Ok(Json(CreateStoryResponse { story_id })) + } + /// Ensure a story can be accepted; returns an error when gates fail. #[oai(path = "/workflow/acceptance/ensure", method = "post")] async fn ensure_acceptance( @@ -541,6 +644,128 @@ impl WorkflowApi { } } +fn slugify_name(name: &str) -> String { + let slug: String = name + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() { + c.to_ascii_lowercase() + } else { + '_' + } + }) + .collect(); + // Collapse consecutive underscores and trim edges + let mut result = String::new(); + let mut prev_underscore = true; // start true to trim leading _ + for ch in slug.chars() { + if ch == '_' { + if !prev_underscore { + result.push('_'); + } + prev_underscore = true; + } else { + result.push(ch); + prev_underscore = false; + } + } + // Trim trailing underscore + if result.ends_with('_') { + result.pop(); + } + result +} + +fn next_story_number(root: &std::path::Path) -> Result { + let base = root.join(".story_kit").join("stories"); + let mut max_num: u32 = 0; + + for subdir in &["upcoming", "current", "archived"] { + let dir = base.join(subdir); + if !dir.exists() { + continue; + } + for entry in + fs::read_dir(&dir).map_err(|e| format!("Failed to read {subdir} directory: {e}"))? + { + let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?; + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + // Extract leading digits from filename + let num_str: String = name_str.chars().take_while(|c| c.is_ascii_digit()).collect(); + if let Ok(n) = num_str.parse::() + && n > max_num + { + max_num = n; + } + } + } + + Ok(max_num + 1) +} + +fn validate_story_dirs( + root: &std::path::Path, +) -> Result, String> { + let base = root.join(".story_kit").join("stories"); + let mut results = Vec::new(); + + for subdir in &["current", "upcoming"] { + let dir = base.join(subdir); + if !dir.exists() { + continue; + } + for entry in + fs::read_dir(&dir).map_err(|e| format!("Failed to read {subdir} directory: {e}"))? + { + let entry = entry.map_err(|e| format!("Failed to read entry: {e}"))?; + let path = entry.path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("md") { + continue; + } + let story_id = path + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or_default() + .to_string(); + let contents = fs::read_to_string(&path) + .map_err(|e| format!("Failed to read {}: {e}", path.display()))?; + match parse_front_matter(&contents) { + Ok(meta) => { + let mut errors = Vec::new(); + if meta.name.is_none() { + errors.push("Missing 'name' field".to_string()); + } + if meta.test_plan.is_none() { + errors.push("Missing 'test_plan' field".to_string()); + } + if errors.is_empty() { + results.push(StoryValidationResult { + story_id, + valid: true, + error: None, + }); + } else { + results.push(StoryValidationResult { + story_id, + valid: false, + error: Some(errors.join("; ")), + }); + } + } + Err(e) => results.push(StoryValidationResult { + story_id, + valid: false, + error: Some(e.to_string()), + }), + } + } + } + + results.sort_by(|a, b| a.story_id.cmp(&b.story_id)); + Ok(results) +} + fn to_test_case(input: TestCasePayload) -> Result { let status = parse_test_status(&input.status)?; Ok(TestCaseResult { @@ -716,4 +941,204 @@ mod tests { assert_eq!(stories.len(), 1); assert_eq!(stories[0].story_id, "31_story"); } + + #[test] + fn validate_story_dirs_valid_files() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".story_kit/stories/current"); + let upcoming = tmp.path().join(".story_kit/stories/upcoming"); + fs::create_dir_all(¤t).unwrap(); + fs::create_dir_all(&upcoming).unwrap(); + fs::write( + current.join("28_todos.md"), + "---\nname: Show TODOs\ntest_plan: approved\n---\n# Story\n", + ) + .unwrap(); + fs::write( + upcoming.join("36_front_matter.md"), + "---\nname: Enforce Front Matter\ntest_plan: pending\n---\n# Story\n", + ) + .unwrap(); + + let results = validate_story_dirs(tmp.path()).unwrap(); + assert_eq!(results.len(), 2); + assert!(results.iter().all(|r| r.valid)); + assert!(results.iter().all(|r| r.error.is_none())); + } + + #[test] + fn validate_story_dirs_missing_front_matter() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".story_kit/stories/current"); + fs::create_dir_all(¤t).unwrap(); + fs::write(current.join("28_todos.md"), "# No front matter\n").unwrap(); + + let results = validate_story_dirs(tmp.path()).unwrap(); + assert_eq!(results.len(), 1); + assert!(!results[0].valid); + assert_eq!(results[0].error.as_deref(), Some("Missing front matter")); + } + + #[test] + fn validate_story_dirs_missing_required_fields() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".story_kit/stories/current"); + fs::create_dir_all(¤t).unwrap(); + fs::write(current.join("28_todos.md"), "---\n---\n# Story\n").unwrap(); + + let results = validate_story_dirs(tmp.path()).unwrap(); + assert_eq!(results.len(), 1); + assert!(!results[0].valid); + let err = results[0].error.as_deref().unwrap(); + assert!(err.contains("Missing 'name' field")); + assert!(err.contains("Missing 'test_plan' field")); + } + + #[test] + fn validate_story_dirs_missing_test_plan_only() { + let tmp = tempfile::tempdir().unwrap(); + let current = tmp.path().join(".story_kit/stories/current"); + fs::create_dir_all(¤t).unwrap(); + fs::write( + current.join("28_todos.md"), + "---\nname: A Story\n---\n# Story\n", + ) + .unwrap(); + + let results = validate_story_dirs(tmp.path()).unwrap(); + assert_eq!(results.len(), 1); + assert!(!results[0].valid); + let err = results[0].error.as_deref().unwrap(); + assert!(err.contains("Missing 'test_plan' field")); + assert!(!err.contains("Missing 'name' field")); + } + + #[test] + fn validate_story_dirs_empty_when_no_dirs() { + let tmp = tempfile::tempdir().unwrap(); + let results = validate_story_dirs(tmp.path()).unwrap(); + assert!(results.is_empty()); + } + + // --- slugify_name tests --- + + #[test] + fn slugify_simple_name() { + assert_eq!( + slugify_name("Enforce Front Matter on All Story Files"), + "enforce_front_matter_on_all_story_files" + ); + } + + #[test] + fn slugify_with_special_chars() { + assert_eq!(slugify_name("Hello, World! (v2)"), "hello_world_v2"); + } + + #[test] + fn slugify_leading_trailing_underscores() { + assert_eq!(slugify_name(" spaces "), "spaces"); + } + + #[test] + fn slugify_consecutive_separators() { + assert_eq!(slugify_name("a--b__c d"), "a_b_c_d"); + } + + #[test] + fn slugify_empty_after_strip() { + assert_eq!(slugify_name("!!!"), ""); + } + + #[test] + fn slugify_already_snake_case() { + assert_eq!(slugify_name("my_story_name"), "my_story_name"); + } + + // --- next_story_number tests --- + + #[test] + fn next_story_number_empty_dirs() { + let tmp = tempfile::tempdir().unwrap(); + let base = tmp.path().join(".story_kit/stories/upcoming"); + fs::create_dir_all(&base).unwrap(); + assert_eq!(next_story_number(tmp.path()).unwrap(), 1); + } + + #[test] + fn next_story_number_scans_all_dirs() { + let tmp = tempfile::tempdir().unwrap(); + let upcoming = tmp.path().join(".story_kit/stories/upcoming"); + let current = tmp.path().join(".story_kit/stories/current"); + let archived = tmp.path().join(".story_kit/stories/archived"); + fs::create_dir_all(&upcoming).unwrap(); + fs::create_dir_all(¤t).unwrap(); + fs::create_dir_all(&archived).unwrap(); + fs::write(upcoming.join("10_foo.md"), "").unwrap(); + fs::write(current.join("20_bar.md"), "").unwrap(); + fs::write(archived.join("15_baz.md"), "").unwrap(); + assert_eq!(next_story_number(tmp.path()).unwrap(), 21); + } + + #[test] + fn next_story_number_no_story_dirs() { + let tmp = tempfile::tempdir().unwrap(); + // No .story_kit at all + assert_eq!(next_story_number(tmp.path()).unwrap(), 1); + } + + // --- create_story integration tests --- + + #[test] + fn create_story_writes_correct_content() { + let tmp = tempfile::tempdir().unwrap(); + let upcoming = tmp.path().join(".story_kit/stories/upcoming"); + fs::create_dir_all(&upcoming).unwrap(); + fs::write(upcoming.join("36_existing.md"), "").unwrap(); + + let number = next_story_number(tmp.path()).unwrap(); + assert_eq!(number, 37); + + let slug = slugify_name("My New Feature"); + assert_eq!(slug, "my_new_feature"); + + let filename = format!("{number}_{slug}.md"); + let filepath = upcoming.join(&filename); + + let mut content = String::new(); + content.push_str("---\n"); + content.push_str("name: My New Feature\n"); + content.push_str("test_plan: pending\n"); + content.push_str("---\n\n"); + content.push_str(&format!("# Story {number}: My New Feature\n\n")); + content.push_str("## User Story\n\n"); + content.push_str("As a dev, I want this feature\n\n"); + content.push_str("## Acceptance Criteria\n\n"); + content.push_str("- [ ] It works\n"); + content.push_str("- [ ] It is tested\n\n"); + content.push_str("## Out of Scope\n\n"); + content.push_str("- TBD\n"); + + fs::write(&filepath, &content).unwrap(); + + let written = fs::read_to_string(&filepath).unwrap(); + assert!(written.starts_with("---\nname: My New Feature\ntest_plan: pending\n---")); + assert!(written.contains("# Story 37: My New Feature")); + assert!(written.contains("- [ ] It works")); + assert!(written.contains("- [ ] It is tested")); + assert!(written.contains("## Out of Scope")); + } + + #[test] + fn create_story_rejects_duplicate() { + let tmp = tempfile::tempdir().unwrap(); + let upcoming = tmp.path().join(".story_kit/stories/upcoming"); + fs::create_dir_all(&upcoming).unwrap(); + + let filepath = upcoming.join("1_my_feature.md"); + fs::write(&filepath, "existing").unwrap(); + + // Simulate the check + assert!(filepath.exists()); + } }