diff --git a/codeframe/cli/app.py b/codeframe/cli/app.py
index 40e1b611..837d68ed 100644
--- a/codeframe/cli/app.py
+++ b/codeframe/cli/app.py
@@ -586,6 +586,142 @@ def serve(
no_args_is_help=True,
)
+# PRD templates subcommand group
+prd_templates_app = typer.Typer(
+ name="templates",
+ help="PRD template management for customizable output formats",
+ no_args_is_help=True,
+)
+
+
+@prd_templates_app.command("list")
+def prd_templates_list() -> None:
+ """List available PRD templates.
+
+ Shows all built-in and custom PRD templates that can be used
+ with 'codeframe prd generate --template'.
+
+ Example:
+ codeframe prd templates list
+ """
+ from codeframe.planning.prd_templates import PrdTemplateManager
+
+ # Pass workspace path to include project templates
+ manager = PrdTemplateManager(workspace_path=Path.cwd())
+ templates = manager.list_templates()
+
+ console.print("\n[bold]Available PRD Templates:[/bold]\n")
+ for template in templates:
+ section_count = len(template.sections)
+ console.print(f" [green]{template.id}[/green] - {template.name}")
+ console.print(f" {template.description}")
+ console.print(f" Sections: {section_count} | Version: {template.version}")
+ console.print()
+
+
+@prd_templates_app.command("show")
+def prd_templates_show(
+ template_id: str = typer.Argument(..., help="Template ID to show"),
+) -> None:
+ """Show details of a specific PRD template.
+
+ Displays the template's sections and their configuration.
+
+ Example:
+ codeframe prd templates show standard
+ codeframe prd templates show lean
+ """
+ from codeframe.planning.prd_templates import PrdTemplateManager
+
+ # Pass workspace path to include project templates
+ manager = PrdTemplateManager(workspace_path=Path.cwd())
+ template = manager.get_template(template_id)
+
+ if not template:
+ console.print(f"[red]Error:[/red] Template '{template_id}' not found.")
+ console.print("\nAvailable templates:")
+ for t in manager.list_templates():
+ console.print(f" {t.id}")
+ raise typer.Exit(1)
+
+ console.print(f"\n[bold]{template.name}[/bold] ({template.id})\n")
+ console.print(f"{template.description}\n")
+ console.print(f"Version: {template.version}")
+
+ console.print("\n[bold]Sections:[/bold]\n")
+ for i, section in enumerate(template.sections, 1):
+ required = "[green]required[/green]" if section.required else "[dim]optional[/dim]"
+ console.print(f" {i}. {section.title} ({section.id})")
+ console.print(f" Source: {section.source} | {required}")
+ console.print()
+
+
+@prd_templates_app.command("export")
+def prd_templates_export(
+ template_id: str = typer.Argument(..., help="Template ID to export"),
+ output_path: Path = typer.Argument(..., help="Output file path (.yaml)"),
+) -> None:
+ """Export a PRD template to a YAML file.
+
+ Exports the template configuration for backup or customization.
+
+ Example:
+ codeframe prd templates export standard ./my-template.yaml
+ codeframe prd templates export enterprise ./enterprise-prd.yaml
+ """
+ from codeframe.planning.prd_templates import PrdTemplateManager
+
+ # Pass workspace path to include project templates
+ manager = PrdTemplateManager(workspace_path=Path.cwd())
+
+ try:
+ manager.export_template(template_id, output_path)
+ console.print(f"[green]✓[/green] Exported template '{template_id}' to {output_path}")
+ except ValueError as e:
+ console.print(f"[red]Error:[/red] {e}")
+ console.print("\nAvailable templates:")
+ for t in manager.list_templates():
+ console.print(f" {t.id}")
+ raise typer.Exit(1)
+
+
+@prd_templates_app.command("import")
+def prd_templates_import(
+ source_path: Path = typer.Argument(
+ ...,
+ help="Path to template YAML file",
+ exists=True,
+ file_okay=True,
+ dir_okay=False,
+ ),
+) -> None:
+ """Import a PRD template from a YAML file.
+
+ Imports a custom template that can be used with 'codeframe prd generate --template'.
+ The template is saved to the project's .codeframe/templates/prd/ directory.
+
+ Example:
+ codeframe prd templates import ./custom-template.yaml
+ """
+ from codeframe.planning.prd_templates import PrdTemplateManager
+
+ # Pass workspace path for project template storage
+ manager = PrdTemplateManager(workspace_path=Path.cwd())
+
+ try:
+ # Import and persist to project directory
+ template = manager.import_template(source_path, persist=True)
+ console.print(f"[green]✓[/green] Imported template '{template.id}' ({template.name})")
+ console.print(f"[dim]Sections: {len(template.sections)}[/dim]")
+ console.print(f"[dim]Saved to: .codeframe/templates/prd/{template.id}.yaml[/dim]")
+ except Exception as e:
+ console.print(f"[red]Error:[/red] Failed to import template: {e}")
+ raise typer.Exit(1)
+
+
+# Register prd_templates_app under prd_app
+prd_app.add_typer(prd_templates_app, name="templates")
+
@prd_app.command("add")
def prd_add(
@@ -1104,6 +1240,11 @@ def prd_generate(
"--resume", "-r",
help="Resume from a paused session (blocker ID)",
),
+ template: str = typer.Option(
+ "standard",
+ "--template", "-t",
+ help="PRD template to use (standard, lean, enterprise, user-story-map, technical-spec)",
+ ),
) -> None:
"""Generate a PRD through AI-driven Socratic discovery.
@@ -1121,10 +1262,18 @@ def prd_generate(
/quit - Exit without saving
/help - Show available commands
+ Use --template to select the output format:
+ - standard: Full PRD with all sections (default)
+ - lean: Minimal PRD with problem, users, MVP features
+ - enterprise: Formal PRD with compliance and traceability
+ - user-story-map: Organized around user journeys
+ - technical-spec: Focused on technical specifications
+
Requires ANTHROPIC_API_KEY environment variable.
Example:
codeframe prd generate
+ codeframe prd generate --template lean
codeframe prd generate --resume abc123
"""
from codeframe.core.workspace import get_workspace
@@ -1137,11 +1286,24 @@ def prd_generate(
get_active_session,
)
from codeframe.core.events import emit_for_workspace, EventType
+ from codeframe.planning.prd_templates import PrdTemplateManager
from rich.panel import Panel
from rich.prompt import Prompt
workspace_path = repo_path or Path.cwd()
+ # Validate template exists (include project templates in search)
+ template_manager = PrdTemplateManager(workspace_path=workspace_path)
+ template_obj = template_manager.get_template(template)
+ if template_obj is None:
+ console.print(f"[red]Error:[/red] Template '{template}' not found.")
+ console.print("\nAvailable templates:")
+ for t in template_manager.list_templates():
+ console.print(f" {t.id} - {t.name}")
+ raise typer.Exit(1)
+
+ console.print(f"[dim]Using template: {template_obj.name}[/dim]")
+
try:
workspace = get_workspace(workspace_path)
@@ -1272,10 +1434,10 @@ def prd_generate(
# Generate PRD
console.print("\n[bold green]Discovery complete![/bold green]")
- console.print("\nGenerating PRD from our conversation...")
+ console.print(f"\nGenerating PRD using '{template}' template...")
try:
- prd_record = session.generate_prd()
+ prd_record = session.generate_prd(template_id=template)
except IncompleteSessionError as e:
console.print(f"[red]Error:[/red] {e}")
raise typer.Exit(1)
diff --git a/codeframe/core/prd_discovery.py b/codeframe/core/prd_discovery.py
index b78b7c85..a6dbc546 100644
--- a/codeframe/core/prd_discovery.py
+++ b/codeframe/core/prd_discovery.py
@@ -589,11 +589,16 @@ def resume_discovery(self, blocker_id: str) -> None:
logger.info(f"Resumed session {session_id} from blocker {blocker_id}")
- def generate_prd(self) -> prd.PrdRecord:
+ def generate_prd(self, template_id: Optional[str] = None) -> prd.PrdRecord:
"""Generate PRD from discovery conversation.
Uses AI to synthesize the conversation into a structured PRD.
+ Args:
+ template_id: Optional PRD template ID to use for formatting.
+ If not provided or not found, uses the default built-in
+ prompt format (recorded as "default" in metadata).
+
Returns:
Created PrdRecord
@@ -606,10 +611,10 @@ def generate_prd(self) -> prd.PrdRecord:
f"Current coverage: {self._coverage}"
)
-
qa_history = self._format_qa_history()
- prompt = PRD_GENERATION_PROMPT.format(qa_history=qa_history)
+ # Build prompt based on template and get the resolved template ID
+ prompt, resolved_template_id = self._build_prd_prompt(qa_history, template_id)
response = self._llm_provider.complete(
messages=[{"role": "user", "content": prompt}],
@@ -623,27 +628,93 @@ def generate_prd(self) -> prd.PrdRecord:
# Extract title from PRD content
title = self._extract_title_from_prd(content)
- # Store PRD
+ # Store PRD with both requested and resolved template IDs in metadata
+ metadata: dict[str, Any] = {
+ "source": "ai_discovery",
+ "session_id": self.session_id,
+ "questions_asked": len(self._qa_history),
+ "coverage": self._coverage,
+ "generated_at": _utc_now().isoformat(),
+ "template_id": resolved_template_id,
+ }
+ # Track if a different template was requested but not found
+ if template_id and template_id != resolved_template_id:
+ metadata["requested_template_id"] = template_id
+
record = prd.store(
self.workspace,
content=content,
title=title,
- metadata={
- "source": "ai_discovery",
- "session_id": self.session_id,
- "questions_asked": len(self._qa_history),
- "coverage": self._coverage,
- "generated_at": _utc_now().isoformat(),
- },
+ metadata=metadata,
)
# Update session state
self.state = SessionState.COMPLETED
self._save_session()
- logger.info(f"Generated PRD {record.id} from session {self.session_id}")
+ logger.info(f"Generated PRD {record.id} from session {self.session_id} using template '{resolved_template_id}'")
return record
+ def _build_prd_prompt(
+ self, qa_history: str, template_id: Optional[str] = None
+ ) -> tuple[str, str]:
+ """Build PRD generation prompt based on template.
+
+ Args:
+ qa_history: Formatted Q&A history string
+ template_id: Template ID to use (defaults to None, which uses default prompt)
+
+ Returns:
+ Tuple of (prompt string, resolved template ID)
+ The resolved template ID is "default" if no template was used,
+ or the actual template ID that was successfully loaded.
+ """
+ from codeframe.planning.prd_templates import PrdTemplateManager
+ from pathlib import Path
+
+ # Use default prompt if no template specified
+ if not template_id:
+ return (PRD_GENERATION_PROMPT.format(qa_history=qa_history), "default")
+
+ # Pass workspace path to include project templates
+ workspace_path = Path(self.workspace.repo_path) if self.workspace.repo_path else None
+ manager = PrdTemplateManager(workspace_path=workspace_path)
+ template = manager.get_template(template_id)
+
+ if template is None:
+ logger.warning(f"Template '{template_id}' not found, falling back to default prompt")
+ return (PRD_GENERATION_PROMPT.format(qa_history=qa_history), "default")
+
+ # Build dynamic prompt from template sections
+ sections_spec = []
+ for section in template.sections:
+ required_note = " (required)" if section.required else " (optional)"
+ sections_spec.append(f"## {section.title}{required_note}\n{section.source} - related content")
+
+ sections_text = "\n\n".join(sections_spec)
+
+ prompt = f"""Generate a Product Requirements Document based on the discovery conversation.
+
+## Discovery Conversation
+{qa_history}
+
+## Template: {template.name}
+{template.description}
+
+## Sections
+Generate a markdown PRD with these sections in order:
+
+# [Project Title - infer from conversation]
+
+{sections_text}
+
+---
+
+Keep it concise but complete. Focus on actionable requirements.
+Follow the template structure exactly. This PRD should be sufficient to generate development tasks."""
+
+ return (prompt, template_id)
+
def _extract_title_from_prd(self, content: str) -> str:
"""Extract project title from generated PRD content."""
import re
diff --git a/codeframe/planning/__init__.py b/codeframe/planning/__init__.py
index a69aa988..d697fe4c 100644
--- a/codeframe/planning/__init__.py
+++ b/codeframe/planning/__init__.py
@@ -4,6 +4,7 @@
- Issue generation from PRD features
- Task decomposition from issues
- Work breakdown planning
+- PRD template system for customizable output formats
"""
from codeframe.planning.issue_generator import (
@@ -11,9 +12,19 @@
parse_prd_features,
assign_priority,
)
+from codeframe.planning.prd_templates import (
+ PrdTemplate,
+ PrdTemplateSection,
+ PrdTemplateManager,
+ BUILTIN_TEMPLATES,
+)
__all__ = [
"IssueGenerator",
"parse_prd_features",
"assign_priority",
+ "PrdTemplate",
+ "PrdTemplateSection",
+ "PrdTemplateManager",
+ "BUILTIN_TEMPLATES",
]
diff --git a/codeframe/planning/prd_template_functions.py b/codeframe/planning/prd_template_functions.py
new file mode 100644
index 00000000..7dbfff29
--- /dev/null
+++ b/codeframe/planning/prd_template_functions.py
@@ -0,0 +1,137 @@
+"""Jinja2 template functions for PRD rendering.
+
+This module provides custom filters and functions for use in PRD templates:
+- bullet_list: Convert list to markdown bullets
+- numbered_list: Convert list to numbered list
+- table: Generate markdown table
+- summarize: Summarize long text (placeholder for LLM call)
+"""
+
+from typing import Any
+
+
+def bullet_list(items: list[str]) -> str:
+ """Convert a list to markdown bullet points.
+
+ Args:
+ items: List of strings to convert
+
+ Returns:
+ Markdown formatted bullet list
+ """
+ if not items:
+ return ""
+ return "\n".join(f"- {item}" for item in items)
+
+
+def numbered_list(items: list[str]) -> str:
+ """Convert a list to a numbered markdown list.
+
+ Args:
+ items: List of strings to convert
+
+ Returns:
+ Markdown formatted numbered list
+ """
+ if not items:
+ return ""
+ return "\n".join(f"{i}. {item}" for i, item in enumerate(items, 1))
+
+
+def table(items: list[dict[str, Any]], columns: list[str]) -> str:
+ """Generate a markdown table from a list of dictionaries.
+
+ Args:
+ items: List of dictionaries with data
+ columns: Column names to include in table
+
+ Returns:
+ Markdown formatted table
+ """
+ if not items or not columns:
+ return ""
+
+ # Header row
+ header = "| " + " | ".join(columns) + " |"
+
+ # Separator row
+ separator = "| " + " | ".join("---" for _ in columns) + " |"
+
+ # Data rows
+ rows = []
+ for item in items:
+ row_values = [str(item.get(col, "")) for col in columns]
+ rows.append("| " + " | ".join(row_values) + " |")
+
+ return "\n".join([header, separator] + rows)
+
+
+def summarize(text: str, max_words: int = 50) -> str:
+ """Summarize long text (placeholder - truncates for now).
+
+ In a future version, this could call an LLM for proper summarization.
+
+ Args:
+ text: Text to summarize
+ max_words: Maximum words in summary
+
+ Returns:
+ Summarized text
+ """
+ if not text:
+ return ""
+
+ words = text.split()
+ if len(words) <= max_words:
+ return text
+
+ return " ".join(words[:max_words]) + "..."
+
+
+def join_list(items: list[str], separator: str = ", ") -> str:
+ """Join a list into a string with separator.
+
+ Args:
+ items: List of strings to join
+ separator: Separator between items
+
+ Returns:
+ Joined string
+ """
+ if not items:
+ return ""
+ return separator.join(str(item) for item in items)
+
+
+def format_constraints(constraints: dict[str, Any]) -> str:
+ """Format constraints dictionary as markdown.
+
+ Args:
+ constraints: Dictionary of constraint types to values
+
+ Returns:
+ Markdown formatted constraints
+ """
+ if not constraints:
+ return "No specific constraints defined."
+
+ lines = []
+ for key, value in constraints.items():
+ if isinstance(value, list):
+ value_str = ", ".join(str(v) for v in value)
+ else:
+ value_str = str(value)
+ lines.append(f"- **{key.title()}**: {value_str}")
+
+ return "\n".join(lines)
+
+
+# Registry of all template functions
+TEMPLATE_FUNCTIONS = {
+ "bullet_list": bullet_list,
+ "numbered_list": numbered_list,
+ "table": table,
+ "summarize": summarize,
+ "join_list": join_list,
+ "format_constraints": format_constraints,
+}
diff --git a/codeframe/planning/prd_templates.py b/codeframe/planning/prd_templates.py
new file mode 100644
index 00000000..6ebe87ec
--- /dev/null
+++ b/codeframe/planning/prd_templates.py
@@ -0,0 +1,975 @@
+"""PRD Template System for CodeFRAME.
+
+This module provides a template system for customizable PRD output formats:
+- PrdTemplateSection: Represents a single section with rendering template
+- PrdTemplate: Contains template metadata and list of sections
+- PrdTemplateManager: Manage and render templates
+- BUILTIN_TEMPLATES: Predefined templates (standard, lean, enterprise, etc.)
+"""
+
+import logging
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Optional
+
+import yaml
+from jinja2 import Environment, BaseLoader, TemplateSyntaxError
+
+from codeframe.planning.prd_template_functions import TEMPLATE_FUNCTIONS
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class PrdTemplateSection:
+ """Individual section within a PRD template.
+
+ Attributes:
+ id: Unique identifier for the section
+ title: Human-readable section title
+ source: Discovery data category to draw from (problem, users, features, etc.)
+ format_template: Jinja2 template string for rendering
+ required: Whether this section must be included (default: True)
+ """
+
+ id: str
+ title: str
+ source: str
+ format_template: str
+ required: bool = True
+
+
+@dataclass
+class PrdTemplate:
+ """Template for generating a PRD.
+
+ Attributes:
+ id: Unique identifier for the template
+ name: Human-readable name
+ version: Template version number
+ description: Description of when to use this template
+ sections: List of PrdTemplateSection objects
+ """
+
+ id: str
+ name: str
+ version: int
+ description: str
+ sections: list[PrdTemplateSection]
+
+ @property
+ def section_ids(self) -> list[str]:
+ """Get list of section IDs."""
+ return [s.id for s in self.sections]
+
+
+# Built-in templates
+BUILTIN_TEMPLATES: list[PrdTemplate] = [
+ PrdTemplate(
+ id="standard",
+ name="Standard PRD",
+ version=1,
+ description="Default PRD format with executive summary, problem statement, "
+ "user personas, features, technical architecture, success metrics, and timeline.",
+ sections=[
+ PrdTemplateSection(
+ id="executive_summary",
+ title="Executive Summary",
+ source="problem",
+ format_template="""## Executive Summary
+
+{{ problem | default('Project overview not yet defined.') }}
+""",
+ ),
+ PrdTemplateSection(
+ id="problem_statement",
+ title="Problem Statement",
+ source="problem",
+ format_template="""## Problem Statement
+
+{{ problem | default('Problem statement not yet defined.') }}
+
+### Business Justification
+
+This solution addresses a critical need in the target market by solving the core problem outlined above.
+""",
+ ),
+ PrdTemplateSection(
+ id="user_personas",
+ title="User Personas",
+ source="users",
+ format_template="""## User Personas
+
+### Target Users
+
+{{ users | bullet_list if users else 'User personas not yet defined.' }}
+""",
+ ),
+ PrdTemplateSection(
+ id="features",
+ title="Features & Requirements",
+ source="features",
+ format_template="""## Features & Requirements
+
+### Core Features
+
+{{ features | numbered_list if features else 'Features not yet defined.' }}
+
+### Functional Requirements
+
+Each feature listed above represents a functional requirement that must be implemented to meet user needs.
+""",
+ ),
+ PrdTemplateSection(
+ id="technical_architecture",
+ title="Technical Architecture",
+ source="tech_stack",
+ format_template="""## Technical Architecture
+
+### Technology Stack
+
+{{ tech_stack | bullet_list if tech_stack else 'Technology stack not yet defined.' }}
+
+### Constraints
+
+{{ constraints | format_constraints if constraints else 'No specific constraints defined.' }}
+""",
+ ),
+ PrdTemplateSection(
+ id="success_metrics",
+ title="Success Metrics",
+ source="features",
+ format_template="""## Success Metrics
+
+### Key Performance Indicators
+
+- User adoption rate
+- Feature completion rate
+- System performance metrics
+- User satisfaction scores
+
+### Success Criteria
+
+The project will be considered successful when all core features are implemented and user adoption targets are met.
+""",
+ ),
+ PrdTemplateSection(
+ id="timeline",
+ title="Timeline & Milestones",
+ source="features",
+ format_template="""## Timeline & Milestones
+
+### Project Phases
+
+1. **Discovery & Planning** - Requirements gathering and architecture design
+2. **Development Phase 1** - Core feature implementation
+3. **Development Phase 2** - Additional features and refinements
+4. **Testing & QA** - Comprehensive testing and bug fixes
+5. **Launch** - Deployment and user onboarding
+
+### Milestones
+
+Specific dates and milestones to be determined based on team capacity and priorities.
+""",
+ ),
+ ],
+ ),
+ PrdTemplate(
+ id="lean",
+ name="Lean PRD",
+ version=1,
+ description="Minimal viable PRD with only problem, users, and MVP features. "
+ "Best for quick iterations and early-stage projects.",
+ sections=[
+ PrdTemplateSection(
+ id="problem",
+ title="Problem",
+ source="problem",
+ format_template="""## Problem
+
+{{ problem | default('Problem not yet defined.') }}
+""",
+ ),
+ PrdTemplateSection(
+ id="users",
+ title="Target Users",
+ source="users",
+ format_template="""## Target Users
+
+{{ users | bullet_list if users else 'Users not yet defined.' }}
+""",
+ ),
+ PrdTemplateSection(
+ id="mvp_features",
+ title="MVP Features",
+ source="features",
+ format_template="""## MVP Features
+
+{{ features | numbered_list if features else 'Features not yet defined.' }}
+""",
+ ),
+ ],
+ ),
+ PrdTemplate(
+ id="enterprise",
+ name="Enterprise PRD",
+ version=1,
+ description="Full formal PRD with compliance sections, traceability matrix, "
+ "stakeholder analysis, and risk assessment. Best for large organizations.",
+ sections=[
+ PrdTemplateSection(
+ id="executive_summary",
+ title="Executive Summary",
+ source="problem",
+ format_template="""## Executive Summary
+
+### Overview
+
+{{ problem | default('Project overview not yet defined.') }}
+
+### Document Purpose
+
+This Product Requirements Document (PRD) defines the requirements, scope, and specifications for the project.
+""",
+ ),
+ PrdTemplateSection(
+ id="stakeholder_analysis",
+ title="Stakeholder Analysis",
+ source="users",
+ format_template="""## Stakeholder Analysis
+
+### Primary Stakeholders
+
+{{ users | bullet_list if users else 'Stakeholders not yet defined.' }}
+
+### Stakeholder Responsibilities
+
+| Stakeholder | Role | Responsibility |
+|-------------|------|----------------|
+| Product Owner | Decision Maker | Final approval on requirements |
+| Development Team | Implementer | Technical implementation |
+| QA Team | Validator | Quality assurance |
+""",
+ ),
+ PrdTemplateSection(
+ id="problem_statement",
+ title="Problem Statement",
+ source="problem",
+ format_template="""## Problem Statement
+
+### Current State
+
+{{ problem | default('Current state analysis not yet completed.') }}
+
+### Desired State
+
+The solution will address the identified problems and deliver value to stakeholders.
+
+### Gap Analysis
+
+Detailed gap analysis between current and desired states to be documented during discovery.
+""",
+ ),
+ PrdTemplateSection(
+ id="user_personas",
+ title="User Personas",
+ source="users",
+ format_template="""## User Personas
+
+{{ users | bullet_list if users else 'User personas not yet defined.' }}
+""",
+ ),
+ PrdTemplateSection(
+ id="requirements",
+ title="Requirements",
+ source="features",
+ format_template="""## Requirements
+
+### Functional Requirements
+
+{% if features %}
+| ID | Requirement | Priority | Source |
+|----|-------------|----------|--------|
+{% for feature in features %}
+| FR-{{ loop.index }} | {{ feature }} | P1 | Discovery |
+{% endfor %}
+{% else %}
+Requirements not yet defined.
+{% endif %}
+
+### Non-Functional Requirements
+
+| ID | Category | Requirement |
+|----|----------|-------------|
+| NFR-1 | Performance | System response time < 2 seconds |
+| NFR-2 | Availability | 99.9% uptime SLA |
+| NFR-3 | Security | Data encryption at rest and in transit |
+""",
+ ),
+ PrdTemplateSection(
+ id="technical_architecture",
+ title="Technical Architecture",
+ source="tech_stack",
+ format_template="""## Technical Architecture
+
+### Technology Stack
+
+{{ tech_stack | bullet_list if tech_stack else 'Technology stack not yet defined.' }}
+
+### System Constraints
+
+{{ constraints | format_constraints if constraints else 'No specific constraints defined.' }}
+
+### Integration Points
+
+Integration requirements to be documented during technical design phase.
+""",
+ ),
+ PrdTemplateSection(
+ id="risk_assessment",
+ title="Risk Assessment",
+ source="constraints",
+ format_template="""## Risk Assessment
+
+### Identified Risks
+
+| Risk | Impact | Likelihood | Mitigation |
+|------|--------|------------|------------|
+| Technical complexity | High | Medium | Phased implementation |
+| Resource constraints | Medium | Medium | Priority-based scheduling |
+| Scope creep | High | High | Strict change control |
+
+### Compliance Considerations
+
+{{ constraints | format_constraints if constraints else 'Compliance requirements to be documented.' }}
+""",
+ required=False,
+ ),
+ PrdTemplateSection(
+ id="success_metrics",
+ title="Success Metrics",
+ source="features",
+ format_template="""## Success Metrics
+
+### Key Performance Indicators
+
+| KPI | Target | Measurement Method |
+|-----|--------|-------------------|
+| User Adoption | 80% | Active user tracking |
+| Feature Completion | 100% | Sprint tracking |
+| Defect Rate | < 5% | Bug tracking |
+| User Satisfaction | > 4.0/5.0 | User surveys |
+""",
+ ),
+ PrdTemplateSection(
+ id="timeline",
+ title="Timeline & Milestones",
+ source="features",
+ format_template="""## Timeline & Milestones
+
+### Project Phases
+
+| Phase | Description | Duration |
+|-------|-------------|----------|
+| Discovery | Requirements gathering | 2 weeks |
+| Design | Technical architecture | 2 weeks |
+| Development | Implementation | 8 weeks |
+| Testing | QA and UAT | 2 weeks |
+| Deployment | Production release | 1 week |
+
+### Approval Gates
+
+| Gate | Criteria | Approver |
+|------|----------|----------|
+| Requirements Sign-off | All requirements documented | Product Owner |
+| Design Approval | Architecture approved | Tech Lead |
+| Release Approval | All tests passing | QA Lead |
+""",
+ ),
+ PrdTemplateSection(
+ id="traceability",
+ title="Traceability Matrix",
+ source="features",
+ format_template="""## Traceability Matrix
+
+This section maps requirements to test cases and implementation components.
+
+| Requirement ID | Feature | Test Case | Component |
+|----------------|---------|-----------|-----------|
+{% if features %}
+{% for feature in features %}
+| FR-{{ loop.index }} | {{ feature }} | TC-{{ loop.index }} | TBD |
+{% endfor %}
+{% else %}
+| - | Requirements not yet defined | - | - |
+{% endif %}
+""",
+ required=False,
+ ),
+ ],
+ ),
+ PrdTemplate(
+ id="user-story-map",
+ name="User Story Map PRD",
+ version=1,
+ description="Organized around user journeys with story mapping structure. "
+ "Best for agile teams focused on user experience.",
+ sections=[
+ PrdTemplateSection(
+ id="overview",
+ title="Overview",
+ source="problem",
+ format_template="""## Overview
+
+{{ problem | default('Project overview not yet defined.') }}
+""",
+ ),
+ PrdTemplateSection(
+ id="user_activities",
+ title="User Activities",
+ source="users",
+ format_template="""## User Activities
+
+### Primary Users
+
+{{ users | bullet_list if users else 'Users not yet defined.' }}
+
+### User Goals
+
+Users want to accomplish specific goals efficiently and effectively.
+""",
+ ),
+ PrdTemplateSection(
+ id="user_stories",
+ title="User Stories",
+ source="features",
+ format_template="""## User Stories
+
+{% if features %}
+{% for feature in features %}
+### Story {{ loop.index }}: {{ feature }}
+
+**As a** user
+**I want to** {{ feature }}
+**So that** I can accomplish my goals effectively
+
+**Acceptance Criteria:**
+- [ ] Feature is implemented as specified
+- [ ] Feature is tested and working
+- [ ] Feature is documented
+
+{% endfor %}
+{% else %}
+User stories not yet defined.
+{% endif %}
+""",
+ ),
+ PrdTemplateSection(
+ id="release_plan",
+ title="Release Plan",
+ source="features",
+ format_template="""## Release Plan
+
+### MVP (Release 1)
+
+Core features to be included in initial release.
+
+{{ features | bullet_list if features else 'Features not yet prioritized.' }}
+
+### Future Releases
+
+Additional features and enhancements for subsequent releases.
+""",
+ ),
+ ],
+ ),
+ PrdTemplate(
+ id="technical-spec",
+ name="Technical Specification",
+ version=1,
+ description="Focused on technical requirements, architecture diagrams, "
+ "API specifications, and data models. Best for technical audiences.",
+ sections=[
+ PrdTemplateSection(
+ id="overview",
+ title="Technical Overview",
+ source="problem",
+ format_template="""## Technical Overview
+
+### Purpose
+
+{{ problem | default('Technical purpose not yet defined.') }}
+
+### Scope
+
+This document defines the technical specifications for the system.
+""",
+ ),
+ PrdTemplateSection(
+ id="architecture",
+ title="System Architecture",
+ source="tech_stack",
+ format_template="""## System Architecture
+
+### Technology Stack
+
+{{ tech_stack | bullet_list if tech_stack else 'Technology stack not yet defined.' }}
+
+### Architecture Overview
+
+```
++-------------------+
+| Frontend UI |
++-------------------+
+ |
++-------------------+
+| API Gateway |
++-------------------+
+ |
++-------------------+
+| Business Logic |
++-------------------+
+ |
++-------------------+
+| Data Layer |
++-------------------+
+```
+
+### Component Diagram
+
+Components to be detailed during technical design.
+""",
+ ),
+ PrdTemplateSection(
+ id="api_specification",
+ title="API Specification",
+ source="features",
+ format_template="""## API Specification
+
+### Endpoints
+
+{% if features %}
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+{% for feature in features %}
+| /api/{{ feature | lower | replace(' ', '-') }} | GET/POST | {{ feature }} |
+{% endfor %}
+{% else %}
+API endpoints not yet defined.
+{% endif %}
+
+### Authentication
+
+API authentication method to be determined.
+
+### Rate Limiting
+
+Rate limiting policies to be defined based on usage patterns.
+""",
+ ),
+ PrdTemplateSection(
+ id="data_models",
+ title="Data Models",
+ source="features",
+ format_template="""## Data Models
+
+### Entity Relationship
+
+Core entities and their relationships to be documented.
+
+### Database Schema
+
+Schema design to be completed during technical design phase.
+
+{{ constraints | format_constraints if constraints else 'Database constraints not yet defined.' }}
+""",
+ ),
+ PrdTemplateSection(
+ id="security",
+ title="Security Considerations",
+ source="constraints",
+ format_template="""## Security Considerations
+
+### Authentication & Authorization
+
+- Authentication method: TBD
+- Authorization model: Role-based access control (RBAC)
+
+### Data Protection
+
+- Encryption at rest
+- Encryption in transit (TLS 1.3)
+- Data retention policies
+
+### Compliance
+
+{{ constraints | format_constraints if constraints else 'Compliance requirements to be documented.' }}
+""",
+ ),
+ PrdTemplateSection(
+ id="performance",
+ title="Performance Requirements",
+ source="constraints",
+ format_template="""## Performance Requirements
+
+### Response Time
+
+- API response time: < 200ms (p95)
+- Page load time: < 2 seconds
+
+### Throughput
+
+- Target: 1000 requests/second
+
+### Scalability
+
+- Horizontal scaling capability
+- Auto-scaling based on load
+
+{{ constraints | format_constraints if constraints else 'Additional performance constraints to be defined.' }}
+""",
+ ),
+ ],
+ ),
+]
+
+
+def get_global_template_dir() -> Path:
+ """Get the global PRD template directory.
+
+ Returns:
+ Path to ~/.codeframe/templates/prd/
+ """
+ return Path.home() / ".codeframe" / "templates" / "prd"
+
+
+def get_project_template_dir(workspace_path: Optional[Path] = None) -> Path:
+ """Get the project-level PRD template directory.
+
+ Args:
+ workspace_path: Optional workspace path (defaults to current directory)
+
+ Returns:
+ Path to .codeframe/templates/prd/
+ """
+ base = workspace_path or Path.cwd()
+ return base / ".codeframe" / "templates" / "prd"
+
+
+def save_template_to_file(template: PrdTemplate, path: Path) -> None:
+ """Save a template to a YAML file.
+
+ Args:
+ template: Template to save
+ path: File path to save to
+ """
+ # Convert to dictionary for YAML serialization
+ data = {
+ "id": template.id,
+ "name": template.name,
+ "version": template.version,
+ "description": template.description,
+ "sections": [
+ {
+ "id": s.id,
+ "title": s.title,
+ "source": s.source,
+ "format_template": s.format_template,
+ "required": s.required,
+ }
+ for s in template.sections
+ ],
+ }
+
+ # Ensure parent directory exists
+ path.parent.mkdir(parents=True, exist_ok=True)
+
+ with open(path, "w", encoding="utf-8") as f:
+ yaml.safe_dump(data, f, default_flow_style=False, allow_unicode=True)
+
+ logger.debug(f"Saved template to {path}")
+
+
+def load_template_from_file(path: Path) -> PrdTemplate:
+ """Load a template from a YAML file.
+
+ Args:
+ path: File path to load from
+
+ Returns:
+ Loaded PrdTemplate
+
+ Raises:
+ FileNotFoundError: If file doesn't exist
+ ValueError: If file is not a YAML file
+ yaml.YAMLError: If file is not valid YAML
+ """
+ # Resolve to absolute path and validate
+ path = path.resolve()
+ if not path.is_file():
+ raise FileNotFoundError(f"Template file not found: {path}")
+
+ if path.suffix.lower() not in [".yaml", ".yml"]:
+ raise ValueError(f"Template must be a YAML file, got: {path.suffix}")
+
+ with open(path, encoding="utf-8") as f:
+ data = yaml.safe_load(f)
+
+ if not data:
+ raise ValueError(f"Template file is empty or invalid: {path}")
+
+ sections = [
+ PrdTemplateSection(
+ id=s["id"],
+ title=s["title"],
+ source=s["source"],
+ format_template=s["format_template"],
+ required=s.get("required", True),
+ )
+ for s in data.get("sections", [])
+ ]
+
+ return PrdTemplate(
+ id=data["id"],
+ name=data["name"],
+ version=data.get("version", 1),
+ description=data.get("description", ""),
+ sections=sections,
+ )
+
+
+class PrdTemplateManager:
+ """Manager for PRD templates.
+
+ Handles:
+ - Loading and storing templates
+ - Retrieving templates by ID
+ - Validating template structure
+ - Rendering templates with discovery data
+ - Importing/exporting templates
+ """
+
+ def __init__(self, workspace_path: Optional[Path] = None):
+ """Initialize with built-in templates.
+
+ Args:
+ workspace_path: Optional workspace path for project templates
+ """
+ self.templates: dict[str, PrdTemplate] = {}
+ self.workspace_path = workspace_path
+
+ # Load built-in templates
+ for template in BUILTIN_TEMPLATES:
+ self.templates[template.id] = template
+
+ # Load custom templates from global directory
+ self._load_from_directory(get_global_template_dir())
+
+ # Load project templates (override global)
+ if workspace_path:
+ self._load_from_directory(get_project_template_dir(workspace_path))
+
+ # Set up Jinja2 environment with autoescape for defense-in-depth
+ # PRDs are markdown but could be rendered to HTML downstream
+ self._env = Environment(loader=BaseLoader(), autoescape=True)
+ for name, func in TEMPLATE_FUNCTIONS.items():
+ self._env.filters[name] = func
+
+ logger.info(f"PrdTemplateManager initialized with {len(self.templates)} templates")
+
+ def _load_from_directory(self, directory: Path) -> None:
+ """Load templates from a directory.
+
+ Args:
+ directory: Directory containing YAML template files
+ """
+ if not directory.exists():
+ return
+
+ # Support both .yaml and .yml extensions
+ for pattern in ("*.yaml", "*.yml"):
+ for path in directory.glob(pattern):
+ try:
+ template = load_template_from_file(path)
+ if template.id in self.templates:
+ existing = self.templates[template.id]
+ logger.warning(
+ f"Template '{template.id}' from {path} overrides "
+ f"existing template '{existing.name}'"
+ )
+ self.templates[template.id] = template
+ logger.debug(f"Loaded template from {path}")
+ except Exception as e:
+ logger.warning(f"Failed to load template from {path}: {e}")
+
+ def get_template(self, template_id: str) -> Optional[PrdTemplate]:
+ """Get a template by ID.
+
+ Args:
+ template_id: Template identifier
+
+ Returns:
+ PrdTemplate if found, None otherwise
+ """
+ return self.templates.get(template_id)
+
+ def list_templates(self) -> list[PrdTemplate]:
+ """List all available templates.
+
+ Returns:
+ List of PrdTemplate objects sorted by name
+ """
+ return sorted(self.templates.values(), key=lambda t: t.name or "")
+
+ def validate_template(self, template: PrdTemplate) -> list[str]:
+ """Validate a template structure.
+
+ Args:
+ template: Template to validate
+
+ Returns:
+ List of validation error messages (empty if valid)
+ """
+ errors = []
+
+ # Check required fields
+ if not template.id:
+ errors.append("Template ID is required")
+
+ if not template.name:
+ errors.append("Template name is required")
+
+ if not template.sections:
+ errors.append("Template must have at least one section")
+
+ # Validate each section's Jinja2 syntax
+ for section in template.sections:
+ try:
+ self._env.from_string(section.format_template)
+ except TemplateSyntaxError as e:
+ errors.append(f"Section '{section.id}' has invalid Jinja2 syntax: {e}")
+
+ return errors
+
+ def render_template(
+ self, template: PrdTemplate, discovery_data: dict[str, Any]
+ ) -> str:
+ """Render a template with discovery data.
+
+ Args:
+ template: Template to render
+ discovery_data: Discovery data dictionary with keys like
+ problem, users, features, constraints, tech_stack
+
+ Returns:
+ Rendered PRD content as markdown string
+ """
+ sections = []
+
+ for section in template.sections:
+ try:
+ jinja_template = self._env.from_string(section.format_template)
+ rendered = jinja_template.render(**discovery_data)
+ sections.append(rendered.strip())
+ except TemplateSyntaxError as e:
+ logger.error(f"Template syntax error in section {section.id}: {e}")
+ sections.append(f"## {section.title}\n\n*Template syntax error: {e}*")
+ except (KeyboardInterrupt, SystemExit):
+ raise # Don't catch these
+ except Exception as e:
+ logger.error(f"Failed to render section {section.id}: {e}", exc_info=True)
+ sections.append(f"## {section.title}\n\n*Error rendering section. Check logs for details.*")
+
+ # Join with double newlines for proper markdown separation
+ return "\n\n".join(sections)
+
+ def import_template(self, source_path: Path, persist: bool = False) -> PrdTemplate:
+ """Import a template from a file.
+
+ Args:
+ source_path: Path to YAML template file
+ persist: If True, save template to the project template directory
+
+ Returns:
+ Imported template
+
+ Raises:
+ FileNotFoundError: If source file doesn't exist
+ ValueError: If template validation fails
+ """
+ template = load_template_from_file(source_path)
+
+ # Validate before registering
+ errors = self.validate_template(template)
+ if errors:
+ raise ValueError(f"Invalid template: {', '.join(errors)}")
+
+ self.templates[template.id] = template
+
+ if persist:
+ self.persist_template(template)
+
+ logger.info(f"Imported template: {template.id}")
+ return template
+
+ def persist_template(self, template: PrdTemplate, to_project: bool = True) -> Path:
+ """Persist a template to disk.
+
+ Saves the template to either the project template directory (.codeframe/templates/prd/)
+ or the global template directory (~/.codeframe/templates/prd/).
+
+ Args:
+ template: Template to persist
+ to_project: If True, save to project directory; if False, save to global
+
+ Returns:
+ Path where template was saved
+
+ Raises:
+ ValueError: If to_project=True but no workspace_path configured
+ """
+ if to_project:
+ if self.workspace_path is None:
+ raise ValueError(
+ "Cannot persist to project: no workspace_path configured. "
+ "Either set to_project=False or initialize PrdTemplateManager with workspace_path."
+ )
+ target_dir = get_project_template_dir(self.workspace_path)
+ else:
+ target_dir = get_global_template_dir()
+
+ # Ensure directory exists
+ target_dir.mkdir(parents=True, exist_ok=True)
+
+ # Save template
+ output_path = target_dir / f"{template.id}.yaml"
+ save_template_to_file(template, output_path)
+
+ logger.info(f"Persisted template '{template.id}' to {output_path}")
+ return output_path
+
+ def export_template(self, template_id: str, output_path: Path) -> None:
+ """Export a template to a file.
+
+ Args:
+ template_id: ID of template to export
+ output_path: Path to save the template
+
+ Raises:
+ ValueError: If template not found
+ """
+ template = self.get_template(template_id)
+ if template is None:
+ raise ValueError(f"Template '{template_id}' not found")
+
+ save_template_to_file(template, output_path)
+ logger.info(f"Exported template '{template_id}' to {output_path}")
+
+ def register_template(self, template: PrdTemplate) -> None:
+ """Register a custom template.
+
+ Args:
+ template: Template to register
+ """
+ self.templates[template.id] = template
+ logger.info(f"Registered template: {template.id}")
diff --git a/pyproject.toml b/pyproject.toml
index e18bcb19..0bdeca05 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -55,6 +55,7 @@ dependencies = [
"python-jose[cryptography]>=3.4.0",
"passlib[argon2]>=1.7.4",
"keyring>=24.0.0",
+ "jinja2>=3.1.6",
]
[project.optional-dependencies]
diff --git a/tests/cli/test_prd_template_commands.py b/tests/cli/test_prd_template_commands.py
new file mode 100644
index 00000000..001e6ec2
--- /dev/null
+++ b/tests/cli/test_prd_template_commands.py
@@ -0,0 +1,215 @@
+"""Tests for PRD template CLI commands.
+
+This module tests:
+- cf prd templates list
+- cf prd templates show
+- cf prd templates import
+- cf prd templates export
+- cf prd generate --template
+"""
+
+import pytest
+from typer.testing import CliRunner
+
+from codeframe.cli.app import app
+
+runner = CliRunner()
+
+pytestmark = pytest.mark.v2
+
+
+class TestPrdTemplatesList:
+ """Tests for 'cf prd templates list' command."""
+
+ def test_list_templates_shows_builtins(self):
+ """List command shows built-in templates."""
+ result = runner.invoke(app, ["prd", "templates", "list"])
+ assert result.exit_code == 0
+ assert "standard" in result.output.lower()
+ assert "lean" in result.output.lower()
+ assert "enterprise" in result.output.lower()
+
+ def test_list_templates_shows_template_info(self):
+ """List command shows template names and descriptions."""
+ result = runner.invoke(app, ["prd", "templates", "list"])
+ assert result.exit_code == 0
+ # Check for expected template information
+ assert "Standard PRD" in result.output or "standard" in result.output.lower()
+
+
+class TestPrdTemplatesShow:
+ """Tests for 'cf prd templates show' command."""
+
+ def test_show_template_details(self):
+ """Show command displays template details."""
+ result = runner.invoke(app, ["prd", "templates", "show", "standard"])
+ assert result.exit_code == 0
+ assert "standard" in result.output.lower()
+ # Should show sections
+ assert "section" in result.output.lower() or "executive" in result.output.lower()
+
+ def test_show_template_not_found(self):
+ """Show command handles missing template."""
+ result = runner.invoke(app, ["prd", "templates", "show", "nonexistent"])
+ assert result.exit_code == 1
+ assert "not found" in result.output.lower() or "error" in result.output.lower()
+
+ def test_show_lean_template(self):
+ """Show command works for lean template."""
+ result = runner.invoke(app, ["prd", "templates", "show", "lean"])
+ assert result.exit_code == 0
+ assert "lean" in result.output.lower()
+
+ def test_show_enterprise_template(self):
+ """Show command works for enterprise template."""
+ result = runner.invoke(app, ["prd", "templates", "show", "enterprise"])
+ assert result.exit_code == 0
+ assert "enterprise" in result.output.lower()
+
+
+class TestPrdTemplatesExport:
+ """Tests for 'cf prd templates export' command."""
+
+ def test_export_template_to_file(self, tmp_path):
+ """Export command saves template to file."""
+ output_file = tmp_path / "exported.yaml"
+ result = runner.invoke(
+ app, ["prd", "templates", "export", "standard", str(output_file)]
+ )
+ assert result.exit_code == 0
+ assert output_file.exists()
+ content = output_file.read_text()
+ assert "standard" in content
+ assert "sections" in content
+
+ def test_export_template_not_found(self, tmp_path):
+ """Export command handles missing template."""
+ output_file = tmp_path / "output.yaml"
+ result = runner.invoke(
+ app, ["prd", "templates", "export", "nonexistent", str(output_file)]
+ )
+ assert result.exit_code == 1
+ assert "not found" in result.output.lower() or "error" in result.output.lower()
+
+
+class TestPrdTemplatesImport:
+ """Tests for 'cf prd templates import' command."""
+
+ def test_import_template_from_file(self, tmp_path):
+ """Import command loads template from file."""
+ # Create a valid template file
+ template_file = tmp_path / "custom.yaml"
+ template_file.write_text("""
+id: custom-template
+name: Custom Template
+version: 1
+description: A custom PRD template
+sections:
+ - id: intro
+ title: Introduction
+ source: problem
+ format_template: "## Introduction\\n\\n{{ problem }}"
+ required: true
+""")
+ result = runner.invoke(
+ app, ["prd", "templates", "import", str(template_file)]
+ )
+ assert result.exit_code == 0
+ assert "imported" in result.output.lower() or "custom" in result.output.lower()
+
+ def test_import_template_file_not_found(self, tmp_path):
+ """Import command handles missing file."""
+ nonexistent = tmp_path / "missing.yaml"
+ result = runner.invoke(
+ app, ["prd", "templates", "import", str(nonexistent)]
+ )
+ # Typer returns exit code 2 for file validation failures
+ assert result.exit_code != 0
+
+
+class TestPrdGenerateWithTemplate:
+ """Tests for 'cf prd generate --template' option."""
+
+ def test_generate_accepts_template_option(self, tmp_path):
+ """Generate command accepts --template option."""
+ # Initialize a workspace first
+ workspace_path = tmp_path / "test-project"
+ workspace_path.mkdir()
+ (workspace_path / ".codeframe").mkdir()
+
+ # This will fail due to no API key, but should at least parse the option
+ result = runner.invoke(
+ app,
+ [
+ "prd", "generate",
+ "--template", "lean",
+ "-w", str(workspace_path),
+ ],
+ input="n\n", # Say no to prompts
+ )
+ # The command may fail due to missing API key, but --template should be recognized
+ # Check that it doesn't fail with "unknown option" error
+ assert "unknown option" not in result.output.lower()
+ assert "--template" not in result.output.lower() or "template" in result.output.lower()
+
+ def test_generate_invalid_template_shows_error(self, tmp_path):
+ """Generate command shows error for invalid template."""
+ workspace_path = tmp_path / "test-project"
+ workspace_path.mkdir()
+ (workspace_path / ".codeframe").mkdir()
+
+ result = runner.invoke(
+ app,
+ [
+ "prd", "generate",
+ "--template", "nonexistent-template",
+ "-w", str(workspace_path),
+ ],
+ input="n\n",
+ )
+ # Should indicate template not found
+ assert "not found" in result.output.lower() or "nonexistent" in result.output.lower() or result.exit_code != 0
+
+
+class TestPrdGenerateBuildPrompt:
+ """Tests for _build_prd_prompt integration."""
+
+ def test_build_prd_prompt_uses_template_sections(self):
+ """Verify _build_prd_prompt includes template section titles."""
+
+ # Create a mock session to access the method
+ class MockSession:
+ pass
+
+ # Access the unbound method from the class
+ qa_history = "Q: What problem does this solve?\nA: Testing template integration."
+
+ # Use a real template manager to build the prompt
+ from codeframe.planning.prd_templates import PrdTemplateManager
+
+ manager = PrdTemplateManager()
+ lean_template = manager.get_template("lean")
+
+ # Build prompt manually following the same logic
+ sections_spec = []
+ for section in lean_template.sections:
+ required_note = " (required)" if section.required else " (optional)"
+ sections_spec.append(f"## {section.title}{required_note}")
+
+ # Verify lean template has 3 sections
+ assert len(lean_template.sections) == 3
+ assert "Problem" in sections_spec[0]
+ assert "Target Users" in sections_spec[1]
+ assert "MVP Features" in sections_spec[2]
+
+ def test_generate_prd_stores_template_id_in_metadata(self):
+ """Verify template_id is stored in PRD metadata."""
+ # This tests the signature change
+ from codeframe.core.prd_discovery import PrdDiscoverySession
+ import inspect
+
+ sig = inspect.signature(PrdDiscoverySession.generate_prd)
+ params = list(sig.parameters.keys())
+
+ # Should have template_id parameter
+ assert "template_id" in params
diff --git a/tests/planning/test_prd_templates.py b/tests/planning/test_prd_templates.py
new file mode 100644
index 00000000..07ec1c8f
--- /dev/null
+++ b/tests/planning/test_prd_templates.py
@@ -0,0 +1,727 @@
+"""Tests for PRD Template System.
+
+This module tests the PRD template system including:
+- PrdTemplateSection and PrdTemplate data structures
+- PrdTemplateManager operations (load, list, get, validate, render)
+- Built-in template completeness
+- Template function execution
+- Integration with LeadAgent.generate_prd()
+"""
+
+import pytest
+
+from codeframe.planning.prd_templates import (
+ PrdTemplateSection,
+ PrdTemplate,
+ PrdTemplateManager,
+ BUILTIN_TEMPLATES,
+ load_template_from_file,
+)
+
+
+class TestPrdTemplateSection:
+ """Tests for PrdTemplateSection dataclass."""
+
+ def test_section_creation_with_required_fields(self):
+ """Section can be created with required fields."""
+ section = PrdTemplateSection(
+ id="exec_summary",
+ title="Executive Summary",
+ source="problem",
+ format_template="## Executive Summary\n\n{{ problem }}",
+ )
+ assert section.id == "exec_summary"
+ assert section.title == "Executive Summary"
+ assert section.source == "problem"
+ assert section.required is True # default
+
+ def test_section_creation_with_optional_fields(self):
+ """Section can be created with optional required flag."""
+ section = PrdTemplateSection(
+ id="risks",
+ title="Risk Assessment",
+ source="constraints",
+ format_template="## Risks\n\n{{ constraints }}",
+ required=False,
+ )
+ assert section.required is False
+
+
+class TestPrdTemplate:
+ """Tests for PrdTemplate dataclass."""
+
+ def test_template_creation(self):
+ """Template can be created with all fields."""
+ sections = [
+ PrdTemplateSection(
+ id="summary",
+ title="Summary",
+ source="problem",
+ format_template="## Summary\n\n{{ problem }}",
+ )
+ ]
+ template = PrdTemplate(
+ id="test-template",
+ name="Test Template",
+ version=1,
+ description="A test template",
+ sections=sections,
+ )
+ assert template.id == "test-template"
+ assert template.name == "Test Template"
+ assert template.version == 1
+ assert len(template.sections) == 1
+
+ def test_template_section_ids(self):
+ """Template exposes section IDs for easy access."""
+ sections = [
+ PrdTemplateSection(
+ id="section1", title="S1", source="problem", format_template=""
+ ),
+ PrdTemplateSection(
+ id="section2", title="S2", source="users", format_template=""
+ ),
+ ]
+ template = PrdTemplate(
+ id="multi", name="Multi", version=1, description="", sections=sections
+ )
+ assert template.section_ids == ["section1", "section2"]
+
+
+class TestPrdTemplateManager:
+ """Tests for PrdTemplateManager."""
+
+ def test_manager_initialized_with_builtins(self):
+ """Manager initializes with built-in templates."""
+ manager = PrdTemplateManager()
+ templates = manager.list_templates()
+ assert len(templates) >= 5 # standard, lean, enterprise, user-story-map, technical-spec
+
+ def test_get_template_by_id(self):
+ """Can retrieve template by ID."""
+ manager = PrdTemplateManager()
+ template = manager.get_template("standard")
+ assert template is not None
+ assert template.id == "standard"
+ assert template.name is not None
+
+ def test_get_template_not_found(self):
+ """Returns None for unknown template ID."""
+ manager = PrdTemplateManager()
+ template = manager.get_template("nonexistent-template")
+ assert template is None
+
+ def test_list_templates_all(self):
+ """Can list all templates."""
+ manager = PrdTemplateManager()
+ templates = manager.list_templates()
+ assert isinstance(templates, list)
+ assert all(isinstance(t, PrdTemplate) for t in templates)
+
+ def test_validate_template_valid(self):
+ """Valid template passes validation."""
+ manager = PrdTemplateManager()
+ template = manager.get_template("standard")
+ errors = manager.validate_template(template)
+ assert errors == []
+
+ def test_validate_template_missing_id(self):
+ """Template without ID fails validation."""
+ manager = PrdTemplateManager()
+ template = PrdTemplate(
+ id="", # empty ID
+ name="Invalid",
+ version=1,
+ description="",
+ sections=[],
+ )
+ errors = manager.validate_template(template)
+ assert len(errors) > 0
+ assert any("id" in e.lower() for e in errors)
+
+ def test_validate_template_no_sections(self):
+ """Template without sections fails validation."""
+ manager = PrdTemplateManager()
+ template = PrdTemplate(
+ id="empty",
+ name="Empty",
+ version=1,
+ description="",
+ sections=[],
+ )
+ errors = manager.validate_template(template)
+ assert len(errors) > 0
+ assert any("section" in e.lower() for e in errors)
+
+ def test_validate_template_invalid_jinja(self):
+ """Template with invalid Jinja2 syntax fails validation."""
+ manager = PrdTemplateManager()
+ template = PrdTemplate(
+ id="bad-jinja",
+ name="Bad Jinja",
+ version=1,
+ description="",
+ sections=[
+ PrdTemplateSection(
+ id="broken",
+ title="Broken",
+ source="problem",
+ format_template="{{ unclosed", # Invalid Jinja2
+ )
+ ],
+ )
+ errors = manager.validate_template(template)
+ assert len(errors) > 0
+ assert any("jinja" in e.lower() or "syntax" in e.lower() for e in errors)
+
+
+class TestPrdTemplateRendering:
+ """Tests for template rendering."""
+
+ def test_render_simple_template(self):
+ """Can render a simple template with discovery data."""
+ manager = PrdTemplateManager()
+ template = PrdTemplate(
+ id="simple",
+ name="Simple",
+ version=1,
+ description="",
+ sections=[
+ PrdTemplateSection(
+ id="problem",
+ title="Problem",
+ source="problem",
+ format_template="## Problem\n\n{{ problem }}",
+ )
+ ],
+ )
+
+ discovery_data = {
+ "problem": "Users need a better way to manage tasks",
+ "users": ["developers", "managers"],
+ "features": ["task creation", "assignment"],
+ "constraints": {"database": "PostgreSQL"},
+ }
+
+ rendered = manager.render_template(template, discovery_data)
+ assert "## Problem" in rendered
+ assert "Users need a better way to manage tasks" in rendered
+
+ def test_render_with_bullet_list(self):
+ """Can render template using bullet_list function."""
+ manager = PrdTemplateManager()
+ template = PrdTemplate(
+ id="with-list",
+ name="With List",
+ version=1,
+ description="",
+ sections=[
+ PrdTemplateSection(
+ id="users",
+ title="Users",
+ source="users",
+ format_template="## Users\n\n{{ users | bullet_list }}",
+ )
+ ],
+ )
+
+ discovery_data = {
+ "problem": "",
+ "users": ["developers", "managers", "admins"],
+ "features": [],
+ "constraints": {},
+ }
+
+ rendered = manager.render_template(template, discovery_data)
+ assert "- developers" in rendered
+ assert "- managers" in rendered
+ assert "- admins" in rendered
+
+ def test_render_with_numbered_list(self):
+ """Can render template using numbered_list function."""
+ manager = PrdTemplateManager()
+ template = PrdTemplate(
+ id="numbered",
+ name="Numbered",
+ version=1,
+ description="",
+ sections=[
+ PrdTemplateSection(
+ id="features",
+ title="Features",
+ source="features",
+ format_template="## Features\n\n{{ features | numbered_list }}",
+ )
+ ],
+ )
+
+ discovery_data = {
+ "problem": "",
+ "users": [],
+ "features": ["login", "dashboard", "reports"],
+ "constraints": {},
+ }
+
+ rendered = manager.render_template(template, discovery_data)
+ assert "1. login" in rendered
+ assert "2. dashboard" in rendered
+ assert "3. reports" in rendered
+
+ def test_render_with_default_filter(self):
+ """Missing data uses default value."""
+ manager = PrdTemplateManager()
+ template = PrdTemplate(
+ id="defaults",
+ name="Defaults",
+ version=1,
+ description="",
+ sections=[
+ PrdTemplateSection(
+ id="tech",
+ title="Tech",
+ source="tech_stack",
+ format_template="## Tech\n\n{{ tech_stack | default('Not specified') }}",
+ )
+ ],
+ )
+
+ discovery_data = {
+ "problem": "",
+ "users": [],
+ "features": [],
+ "constraints": {},
+ # Note: tech_stack is missing
+ }
+
+ rendered = manager.render_template(template, discovery_data)
+ assert "Not specified" in rendered
+
+ def test_render_multiple_sections(self):
+ """Can render template with multiple sections."""
+ manager = PrdTemplateManager()
+ template = manager.get_template("standard")
+ assert template is not None
+
+ discovery_data = {
+ "problem": "Test problem statement",
+ "users": ["users", "admins"],
+ "features": ["feature1", "feature2"],
+ "constraints": {"database": "PostgreSQL"},
+ "tech_stack": ["Python", "FastAPI"],
+ }
+
+ rendered = manager.render_template(template, discovery_data)
+ # Should have multiple sections rendered
+ assert "Test problem statement" in rendered
+ assert len(rendered) > 100 # Should be substantial
+
+
+class TestBuiltinTemplates:
+ """Tests for built-in templates."""
+
+ def test_standard_template_exists(self):
+ """Standard template is available."""
+ manager = PrdTemplateManager()
+ template = manager.get_template("standard")
+ assert template is not None
+ assert template.id == "standard"
+
+ def test_lean_template_exists(self):
+ """Lean template is available."""
+ manager = PrdTemplateManager()
+ template = manager.get_template("lean")
+ assert template is not None
+ assert template.id == "lean"
+
+ def test_enterprise_template_exists(self):
+ """Enterprise template is available."""
+ manager = PrdTemplateManager()
+ template = manager.get_template("enterprise")
+ assert template is not None
+ assert template.id == "enterprise"
+
+ def test_user_story_map_template_exists(self):
+ """User story map template is available."""
+ manager = PrdTemplateManager()
+ template = manager.get_template("user-story-map")
+ assert template is not None
+ assert template.id == "user-story-map"
+
+ def test_technical_spec_template_exists(self):
+ """Technical spec template is available."""
+ manager = PrdTemplateManager()
+ template = manager.get_template("technical-spec")
+ assert template is not None
+ assert template.id == "technical-spec"
+
+ def test_all_builtin_templates_valid(self):
+ """All built-in templates pass validation."""
+ manager = PrdTemplateManager()
+ for template in BUILTIN_TEMPLATES:
+ errors = manager.validate_template(template)
+ assert errors == [], f"Template {template.id} failed validation: {errors}"
+
+ def test_standard_template_has_required_sections(self):
+ """Standard template has expected sections."""
+ manager = PrdTemplateManager()
+ template = manager.get_template("standard")
+ section_ids = template.section_ids
+
+ # Expected sections from the issue spec
+ expected = [
+ "executive_summary",
+ "problem_statement",
+ "user_personas",
+ "features",
+ "technical_architecture",
+ "success_metrics",
+ "timeline",
+ ]
+ for section_id in expected:
+ assert section_id in section_ids, f"Missing section: {section_id}"
+
+
+class TestTemplateFunctions:
+ """Tests for template helper functions."""
+
+ def test_bullet_list_function(self):
+ """bullet_list creates markdown bullets."""
+ from codeframe.planning.prd_template_functions import bullet_list
+
+ result = bullet_list(["one", "two", "three"])
+ assert result == "- one\n- two\n- three"
+
+ def test_bullet_list_empty(self):
+ """bullet_list handles empty list."""
+ from codeframe.planning.prd_template_functions import bullet_list
+
+ result = bullet_list([])
+ assert result == ""
+
+ def test_numbered_list_function(self):
+ """numbered_list creates numbered items."""
+ from codeframe.planning.prd_template_functions import numbered_list
+
+ result = numbered_list(["first", "second"])
+ assert result == "1. first\n2. second"
+
+ def test_table_function(self):
+ """table creates markdown table."""
+ from codeframe.planning.prd_template_functions import table
+
+ items = [
+ {"name": "Feature A", "priority": "P1"},
+ {"name": "Feature B", "priority": "P2"},
+ ]
+ result = table(items, ["name", "priority"])
+ assert "| name | priority |" in result
+ assert "| Feature A | P1 |" in result
+ assert "| Feature B | P2 |" in result
+
+
+class TestSecurityValidation:
+ """Tests for security-related validations."""
+
+ def test_load_rejects_non_yaml_files(self, tmp_path):
+ """load_template_from_file rejects non-YAML files."""
+ txt_file = tmp_path / "template.txt"
+ txt_file.write_text("not a yaml file")
+
+ with pytest.raises(ValueError, match="must be a YAML file"):
+ load_template_from_file(txt_file)
+
+ def test_load_rejects_nonexistent_file(self, tmp_path):
+ """load_template_from_file raises FileNotFoundError for missing files."""
+ missing = tmp_path / "missing.yaml"
+
+ with pytest.raises(FileNotFoundError):
+ load_template_from_file(missing)
+
+ def test_import_validates_template(self, tmp_path):
+ """import_template validates template before registering."""
+ # Create invalid template (missing sections)
+ invalid_template = tmp_path / "invalid.yaml"
+ invalid_template.write_text("""
+id: invalid
+name: Invalid Template
+version: 1
+description: Missing sections
+sections: []
+""")
+
+ manager = PrdTemplateManager()
+ with pytest.raises(ValueError, match="Invalid template"):
+ manager.import_template(invalid_template)
+
+ # Template should not be registered
+ assert manager.get_template("invalid") is None
+
+ def test_load_rejects_empty_yaml_file(self, tmp_path):
+ """load_template_from_file rejects empty YAML files."""
+ empty_file = tmp_path / "empty.yaml"
+ empty_file.write_text("")
+
+ with pytest.raises(ValueError, match="empty or invalid"):
+ load_template_from_file(empty_file)
+
+ def test_autoescape_prevents_html_injection(self):
+ """Jinja2 autoescape prevents HTML injection in rendered output."""
+ manager = PrdTemplateManager()
+ template = PrdTemplate(
+ id="xss-test",
+ name="XSS Test",
+ version=1,
+ description="Test XSS prevention",
+ sections=[
+ PrdTemplateSection(
+ id="problem",
+ title="Problem",
+ source="problem",
+ format_template="## Problem\n\n{{ problem }}",
+ )
+ ],
+ )
+
+ # Discovery data with HTML injection attempt
+ discovery_data = {
+ "problem": "",
+ "users": [],
+ "features": [],
+ "constraints": {},
+ }
+
+ rendered = manager.render_template(template, discovery_data)
+
+ # HTML should be escaped
+ assert "