#!/usr/bin/env python3 """ story.write - Convert decomposed items from epic.decompose into fully formatted user stories. Generates individual Markdown files for each story following standard user story format. Generated by meta.skill with Betty Framework certification """ import os import sys import json import yaml from pathlib import Path from typing import Dict, List, Any, Optional from datetime import datetime import hashlib from betty.config import BASE_DIR from betty.logging_utils import setup_logger from betty.certification import certified_skill logger = setup_logger(__name__) class StoryWrite: """ Convert decomposed items from epic.decompose into fully formatted user stories. Generates individual Markdown files for each story following standard user story format. """ def __init__(self, base_dir: str = BASE_DIR): """Initialize skill""" self.base_dir = Path(base_dir) @certified_skill("story.write") def execute(self, stories_file: Optional[str] = None, epic_reference: Optional[str] = None, output_dir: Optional[str] = None) -> Dict[str, Any]: """ Execute the skill Args: stories_file: Path to the stories.json file from epic.decompose epic_reference: Reference to the source Epic for traceability output_dir: Directory to save story files (default: ./stories/) Returns: Dict with execution results """ try: logger.info("Executing story.write...") # Validate required inputs if not stories_file: raise ValueError("stories_file is required") # Set defaults if not output_dir: output_dir = "./stories/" # Read stories JSON file stories_path = Path(stories_file) if not stories_path.exists(): raise FileNotFoundError(f"Stories file not found: {stories_file}") with open(stories_path, 'r') as f: stories = json.load(f) if not isinstance(stories, list): raise ValueError("Stories file must contain a JSON array") # Create output directory output_path = Path(output_dir) output_path.mkdir(parents=True, exist_ok=True) # Generate story files created_files = [] story_index = [] for i, story_data in enumerate(stories, start=1): story_id = self._generate_story_id(story_data, i) filename = f"story_{i:03d}.md" filepath = output_path / filename # Generate story content story_content = self._generate_story_markdown( story_data, story_id, i, epic_reference ) # Write story file filepath.write_text(story_content) created_files.append(str(filepath)) # Add to index story_index.append({ "id": story_id, "number": i, "title": story_data.get("title", f"Story {i}"), "file": filename }) logger.info(f"Created story {i}: {filename}") # Create index file index_file = output_path / "stories_index.md" index_content = self._generate_index(story_index, epic_reference) index_file.write_text(index_content) created_files.append(str(index_file)) logger.info(f"Created {len(stories)} story files in {output_dir}") result = { "ok": True, "status": "success", "message": f"Generated {len(stories)} user story documents", "output_dir": str(output_dir), "created_files": created_files, "story_count": len(stories), "artifact_type": "user-story", "index_file": str(index_file) } logger.info("Skill completed successfully") return result except Exception as e: logger.error(f"Error executing skill: {e}") return { "ok": False, "status": "failed", "error": str(e) } def _generate_story_id(self, story_data: Dict[str, Any], number: int) -> str: """ Generate unique story ID Args: story_data: Story data dictionary number: Story number Returns: Story ID string """ # Generate ID from story title hash + number title = story_data.get("title", f"Story {number}") title_hash = hashlib.md5(title.encode()).hexdigest()[:8] return f"STORY-{number:03d}-{title_hash.upper()}" def _generate_story_markdown(self, story_data: Dict[str, Any], story_id: str, number: int, epic_reference: Optional[str]) -> str: """ Generate markdown content for a user story Args: story_data: Story data dictionary story_id: Unique story ID number: Story number epic_reference: Reference to source Epic Returns: Formatted markdown content """ title = story_data.get("title", f"Story {number}") persona = story_data.get("persona", "As a User") goal = story_data.get("goal", "achieve a goal") benefit = story_data.get("benefit", "So that value is delivered") acceptance_criteria = story_data.get("acceptance_criteria", []) # Format acceptance criteria as checklist ac_list = '\n'.join(f"- [ ] {ac}" for ac in acceptance_criteria) # Build markdown content markdown = f"""# User Story: {title} ## Story ID {story_id} ## User Story {persona} I want to {goal} {benefit} ## Acceptance Criteria {ac_list} """ # Add Epic reference if provided if epic_reference: markdown += f""" ## Linked Epic {epic_reference} """ # Add metadata section markdown += f""" ## Metadata - **Story ID**: {story_id} - **Story Number**: {number} - **Created**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} - **Status**: Draft - **Priority**: TBD - **Estimate**: TBD ## INVEST Criteria Check - [ ] **Independent**: Can this story be developed independently? - [ ] **Negotiable**: Is there room for discussion on implementation? - [ ] **Valuable**: Does this deliver value to users/stakeholders? - [ ] **Estimable**: Can the team estimate the effort required? - [ ] **Small**: Is this small enough to fit in a sprint? - [ ] **Testable**: Can we define clear tests for this story? ## Notes _Add implementation notes, technical considerations, or dependencies here._ --- **Generated by**: Betty Framework - epic-to-story skill chain **Artifact Type**: user-story **Version**: 1.0 """ return markdown def _generate_index(self, story_index: List[Dict[str, Any]], epic_reference: Optional[str]) -> str: """ Generate index/summary file for all stories Args: story_index: List of story metadata epic_reference: Reference to source Epic Returns: Index markdown content """ markdown = f"""# User Stories Index **Generated**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} **Total Stories**: {len(story_index)} """ if epic_reference: markdown += f"**Source Epic**: {epic_reference}\n" markdown += """ ## Story List """ # Add table of stories markdown += "| # | Story ID | Title | File |\n" markdown += "|---|----------|-------|------|\n" for story in story_index: markdown += f"| {story['number']} | {story['id']} | {story['title']} | [{story['file']}](./{story['file']}) |\n" markdown += """ ## Progress Tracking - [ ] All stories reviewed and refined - [ ] Story priorities assigned - [ ] Story estimates completed - [ ] Stories added to backlog - [ ] Sprint planning completed ## Notes This index was automatically generated from the Epic decomposition process. Review each story to ensure it meets INVEST criteria and refine as needed before adding to the sprint backlog. --- **Generated by**: Betty Framework - epic-to-story skill chain """ return markdown def main(): """CLI entry point""" import argparse parser = argparse.ArgumentParser( description="Convert decomposed items from epic.decompose into fully formatted user stories. Generates individual Markdown files for each story following standard user story format." ) parser.add_argument( "--stories-file", required=True, help="Path to the stories.json file from epic.decompose" ) parser.add_argument( "--epic-reference", help="Reference to the source Epic for traceability" ) parser.add_argument( "--output-dir", default="./stories/", help="Directory to save story files (default: ./stories/)" ) parser.add_argument( "--output-format", choices=["json", "yaml"], default="json", help="Output format" ) args = parser.parse_args() # Create skill instance skill = StoryWrite() # Execute skill result = skill.execute( stories_file=args.stories_file, epic_reference=args.epic_reference, output_dir=args.output_dir, ) # Output result if args.output_format == "json": print(json.dumps(result, indent=2)) else: print(yaml.dump(result, default_flow_style=False)) # Exit with appropriate code sys.exit(0 if result.get("ok") else 1) if __name__ == "__main__": main()