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 <noreply@anthropic.com>
This commit is contained in:
Dave
2026-02-19 18:02:48 +00:00
parent 5e5cdd9b2f
commit c94b3d4450
4 changed files with 477 additions and 8 deletions

View File

@@ -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:**

View File

@@ -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<TodoListResponse>("/workflow/todos", {}, baseUrl);
},
validateStories(baseUrl?: string) {
return requestJson<ValidateStoriesResponse>(
"/workflow/stories/validate",
{},
baseUrl,
);
},
createStory(payload: CreateStoryPayload, baseUrl?: string) {
return requestJson<CreateStoryResponse>(
"/workflow/stories/create",
{ method: "POST", body: JSON.stringify(payload) },
baseUrl,
);
},
};

View File

@@ -92,6 +92,7 @@ struct StoryTodosResponse {
pub story_id: String,
pub story_name: Option<String>,
pub todos: Vec<String>,
pub error: Option<String>,
}
#[derive(Object)]
@@ -103,6 +104,7 @@ struct TodoListResponse {
struct UpcomingStory {
pub story_id: String,
pub name: Option<String>,
pub error: Option<String>,
}
#[derive(Object)]
@@ -110,6 +112,30 @@ struct UpcomingStoriesResponse {
pub stories: Vec<UpcomingStory>,
}
#[derive(Deserialize, Object)]
struct CreateStoryPayload {
pub name: String,
pub user_story: Option<String>,
pub acceptance_criteria: Option<Vec<String>>,
}
#[derive(Object)]
struct CreateStoryResponse {
pub story_id: String,
}
#[derive(Object)]
struct StoryValidationResult {
pub story_id: String,
pub valid: bool,
pub error: Option<String>,
}
#[derive(Object)]
struct ValidateStoriesResponse {
pub stories: Vec<StoryValidationResult>,
}
fn load_upcoming_stories(ctx: &AppContext) -> Result<Vec<UpcomingStory>, 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<Vec<UpcomingStory>, 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<Json<ValidateStoriesResponse>> {
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<CreateStoryPayload>,
) -> OpenApiResult<Json<CreateStoryResponse>> {
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<u32, String> {
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::<u32>()
&& n > max_num
{
max_num = n;
}
}
}
Ok(max_num + 1)
}
fn validate_story_dirs(
root: &std::path::Path,
) -> Result<Vec<StoryValidationResult>, 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<TestCaseResult, String> {
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(&current).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(&current).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(&current).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(&current).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(&current).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());
}
}