Initial commit
This commit is contained in:
375
skills/epic.decompose/epic_decompose.py
Executable file
375
skills/epic.decompose/epic_decompose.py
Executable file
@@ -0,0 +1,375 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
epic.decompose - Take an Epic (as Markdown) and decompose it into user stories. Analyzes Epic document and identifies major deliverables, grouping them by persona or capability.
|
||||
|
||||
Generated by meta.skill with Betty Framework certification
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import yaml
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any, Optional
|
||||
|
||||
|
||||
from betty.config import BASE_DIR
|
||||
from betty.logging_utils import setup_logger
|
||||
from betty.certification import certified_skill
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
class EpicDecompose:
|
||||
"""
|
||||
Take an Epic (as Markdown) and decompose it into user stories. Analyzes Epic document and identifies major deliverables, grouping them by persona or capability.
|
||||
"""
|
||||
|
||||
def __init__(self, base_dir: str = BASE_DIR):
|
||||
"""Initialize skill"""
|
||||
self.base_dir = Path(base_dir)
|
||||
|
||||
@certified_skill("epic.decompose")
|
||||
def execute(self, epic_file: Optional[str] = None, max_stories: Optional[int] = None,
|
||||
output_path: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute the skill
|
||||
|
||||
Args:
|
||||
epic_file: Path to the epic.md file to decompose
|
||||
max_stories: Maximum number of stories to generate (default: 5)
|
||||
output_path: Where to save the stories.json file (default: ./stories.json)
|
||||
|
||||
Returns:
|
||||
Dict with execution results
|
||||
"""
|
||||
try:
|
||||
logger.info("Executing epic.decompose...")
|
||||
|
||||
# Validate required inputs
|
||||
if not epic_file:
|
||||
raise ValueError("epic_file is required")
|
||||
|
||||
# Set defaults
|
||||
if max_stories is None:
|
||||
max_stories = 5
|
||||
if not output_path:
|
||||
output_path = "./stories.json"
|
||||
|
||||
# Read Epic file
|
||||
epic_path = Path(epic_file)
|
||||
if not epic_path.exists():
|
||||
raise FileNotFoundError(f"Epic file not found: {epic_file}")
|
||||
|
||||
epic_content = epic_path.read_text()
|
||||
|
||||
# Parse Epic and extract information
|
||||
epic_data = self._parse_epic(epic_content)
|
||||
|
||||
# Generate user stories
|
||||
stories = self._generate_stories(epic_data, max_stories)
|
||||
|
||||
# Write stories to JSON file
|
||||
output_file = Path(output_path)
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(output_file, 'w') as f:
|
||||
json.dump(stories, f, indent=2)
|
||||
|
||||
logger.info(f"Generated {len(stories)} stories and saved to {output_path}")
|
||||
|
||||
result = {
|
||||
"ok": True,
|
||||
"status": "success",
|
||||
"message": f"Decomposed Epic into {len(stories)} user stories",
|
||||
"output_file": str(output_path),
|
||||
"story_count": len(stories),
|
||||
"artifact_type": "user-stories-list",
|
||||
"next_step": "Use story.write to generate formatted story documents"
|
||||
}
|
||||
|
||||
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 _parse_epic(self, content: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse Epic markdown to extract components
|
||||
|
||||
Args:
|
||||
content: Epic markdown content
|
||||
|
||||
Returns:
|
||||
Dict with parsed Epic data
|
||||
"""
|
||||
epic_data = {
|
||||
"title": "",
|
||||
"summary": "",
|
||||
"background": "",
|
||||
"acceptance_criteria": [],
|
||||
"stakeholders": []
|
||||
}
|
||||
|
||||
# Extract title
|
||||
title_match = re.search(r'^# Epic: (.+)$', content, re.MULTILINE)
|
||||
if title_match:
|
||||
epic_data["title"] = title_match.group(1).strip()
|
||||
|
||||
# Extract summary
|
||||
summary_match = re.search(r'## Summary\s+(.+?)(?=##|$)', content, re.DOTALL)
|
||||
if summary_match:
|
||||
epic_data["summary"] = summary_match.group(1).strip()
|
||||
|
||||
# Extract background
|
||||
background_match = re.search(r'## Background\s+(.+?)(?=##|$)', content, re.DOTALL)
|
||||
if background_match:
|
||||
epic_data["background"] = background_match.group(1).strip()
|
||||
|
||||
# Extract acceptance criteria
|
||||
ac_match = re.search(r'## Acceptance Criteria\s+(.+?)(?=##|$)', content, re.DOTALL)
|
||||
if ac_match:
|
||||
ac_text = ac_match.group(1).strip()
|
||||
# Extract checkbox items
|
||||
criteria = re.findall(r'- \[ \] (.+)', ac_text)
|
||||
epic_data["acceptance_criteria"] = criteria
|
||||
|
||||
# Extract stakeholders
|
||||
stakeholders_match = re.search(r'## Stakeholders\s+(.+?)(?=##|$)', content, re.DOTALL)
|
||||
if stakeholders_match:
|
||||
sh_text = stakeholders_match.group(1).strip()
|
||||
# Extract bullet points
|
||||
stakeholders = re.findall(r'- \*\*(.+?)\*\*', sh_text)
|
||||
epic_data["stakeholders"] = stakeholders
|
||||
|
||||
return epic_data
|
||||
|
||||
def _generate_stories(self, epic_data: Dict[str, Any], max_stories: int) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Generate user stories from Epic data
|
||||
|
||||
Args:
|
||||
epic_data: Parsed Epic data
|
||||
max_stories: Maximum number of stories to generate
|
||||
|
||||
Returns:
|
||||
List of user story objects
|
||||
"""
|
||||
stories = []
|
||||
|
||||
# Generate stories based on acceptance criteria and epic content
|
||||
# This is a simplified approach - in a real system, you might use NLP/LLM
|
||||
|
||||
# Default personas based on stakeholders
|
||||
personas = self._extract_personas(epic_data)
|
||||
|
||||
# Generate stories from acceptance criteria
|
||||
for i, criterion in enumerate(epic_data.get("acceptance_criteria", [])[:max_stories]):
|
||||
persona = personas[i % len(personas)] if personas else "User"
|
||||
|
||||
story = {
|
||||
"title": f"{persona} can {self._generate_story_title(criterion)}",
|
||||
"persona": f"As a {persona}",
|
||||
"goal": self._extract_goal(criterion),
|
||||
"benefit": self._generate_benefit(criterion, epic_data),
|
||||
"acceptance_criteria": [
|
||||
criterion,
|
||||
f"The implementation is tested and verified",
|
||||
f"Documentation is updated to reflect changes"
|
||||
]
|
||||
}
|
||||
|
||||
stories.append(story)
|
||||
|
||||
# If we don't have enough stories from AC, generate from epic content
|
||||
if len(stories) < max_stories:
|
||||
additional_stories = self._generate_additional_stories(
|
||||
epic_data,
|
||||
max_stories - len(stories),
|
||||
personas
|
||||
)
|
||||
stories.extend(additional_stories)
|
||||
|
||||
return stories[:max_stories]
|
||||
|
||||
def _extract_personas(self, epic_data: Dict[str, Any]) -> List[str]:
|
||||
"""Extract or infer personas from Epic data"""
|
||||
personas = []
|
||||
|
||||
# Use stakeholders as personas
|
||||
stakeholders = epic_data.get("stakeholders", [])
|
||||
for sh in stakeholders:
|
||||
# Extract persona from stakeholder name
|
||||
# e.g., "Product Team" -> "Product Manager"
|
||||
if "team" in sh.lower():
|
||||
personas.append(sh.replace(" Team", " Member").replace(" team", " member"))
|
||||
else:
|
||||
personas.append(sh)
|
||||
|
||||
# Default personas if none found
|
||||
if not personas:
|
||||
personas = ["User", "Administrator", "Developer"]
|
||||
|
||||
return personas
|
||||
|
||||
def _generate_story_title(self, criterion: str) -> str:
|
||||
"""Generate a story title from acceptance criterion"""
|
||||
# Remove common prefixes and convert to action
|
||||
title = criterion.lower()
|
||||
title = re.sub(r'^(all|the|a|an)\s+', '', title)
|
||||
|
||||
# Limit length
|
||||
words = title.split()[:8]
|
||||
return ' '.join(words)
|
||||
|
||||
def _extract_goal(self, criterion: str) -> str:
|
||||
"""Extract the goal from acceptance criterion"""
|
||||
# Simple extraction - in real system, use NLP
|
||||
return criterion
|
||||
|
||||
def _generate_benefit(self, criterion: str, epic_data: Dict[str, Any]) -> str:
|
||||
"""Generate benefit statement from criterion and epic context"""
|
||||
# Use summary as context for benefit
|
||||
summary = epic_data.get("summary", "")
|
||||
if summary:
|
||||
# Extract first key phrase
|
||||
benefit_phrases = summary.split('.')
|
||||
if benefit_phrases:
|
||||
return f"So that {benefit_phrases[0].strip().lower()}"
|
||||
|
||||
return "So that the business objectives are met"
|
||||
|
||||
def _generate_additional_stories(self, epic_data: Dict[str, Any],
|
||||
count: int, personas: List[str]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Generate additional stories if we don't have enough from acceptance criteria
|
||||
|
||||
Args:
|
||||
epic_data: Parsed Epic data
|
||||
count: Number of additional stories needed
|
||||
personas: Available personas
|
||||
|
||||
Returns:
|
||||
List of additional user stories
|
||||
"""
|
||||
stories = []
|
||||
|
||||
# Generic story templates based on epic title and background
|
||||
title = epic_data.get("title", "feature")
|
||||
background = epic_data.get("background", "")
|
||||
|
||||
# Extract key capabilities from background
|
||||
capabilities = self._extract_capabilities(background)
|
||||
|
||||
for i in range(min(count, len(capabilities))):
|
||||
capability = capabilities[i]
|
||||
persona = personas[i % len(personas)] if personas else "User"
|
||||
|
||||
story = {
|
||||
"title": f"{persona} can {capability}",
|
||||
"persona": f"As a {persona}",
|
||||
"goal": capability,
|
||||
"benefit": f"So that {title.lower()} is achieved",
|
||||
"acceptance_criteria": [
|
||||
f"{capability} functionality is implemented",
|
||||
f"User can successfully {capability.lower()}",
|
||||
f"Changes are tested and documented"
|
||||
]
|
||||
}
|
||||
|
||||
stories.append(story)
|
||||
|
||||
return stories
|
||||
|
||||
def _extract_capabilities(self, text: str) -> List[str]:
|
||||
"""Extract key capabilities from text"""
|
||||
# Simple heuristic: look for verb phrases
|
||||
capabilities = []
|
||||
|
||||
# Common capability patterns
|
||||
patterns = [
|
||||
r'enable.+?to (\w+\s+\w+)',
|
||||
r'allow.+?to (\w+\s+\w+)',
|
||||
r'provide (\w+\s+\w+)',
|
||||
r'implement (\w+\s+\w+)',
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
matches = re.findall(pattern, text, re.IGNORECASE)
|
||||
capabilities.extend(matches)
|
||||
|
||||
# Default capabilities if none found
|
||||
if not capabilities:
|
||||
capabilities = [
|
||||
"access the system",
|
||||
"view their data",
|
||||
"manage their account",
|
||||
"receive notifications",
|
||||
"generate reports"
|
||||
]
|
||||
|
||||
return capabilities
|
||||
|
||||
|
||||
def main():
|
||||
"""CLI entry point"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Take an Epic (as Markdown) and decompose it into user stories. Analyzes Epic document and identifies major deliverables, grouping them by persona or capability."
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--epic-file",
|
||||
required=True,
|
||||
help="Path to the epic.md file to decompose"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-stories",
|
||||
type=int,
|
||||
default=5,
|
||||
help="Maximum number of stories to generate (default: 5)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-path",
|
||||
default="./stories.json",
|
||||
help="Where to save the stories.json file (default: ./stories.json)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-format",
|
||||
choices=["json", "yaml"],
|
||||
default="json",
|
||||
help="Output format"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Create skill instance
|
||||
skill = EpicDecompose()
|
||||
|
||||
# Execute skill
|
||||
result = skill.execute(
|
||||
epic_file=args.epic_file,
|
||||
max_stories=args.max_stories,
|
||||
output_path=args.output_path,
|
||||
)
|
||||
|
||||
# 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()
|
||||
Reference in New Issue
Block a user