Skip to content

Commit 7fc6a53

Browse files
frankbriaTest User
andauthored
feat(planning): add PRD template system for customizable output formats (#316) (#321)
* feat(planning): add PRD template system for customizable output formats (#316) Implements a template system for PRD generation: - Add PrdTemplateSection and PrdTemplate dataclasses for template structure - Add PrdTemplateManager for template loading, validation, and rendering - Create 5 built-in templates: standard, lean, enterprise, user-story-map, technical-spec - Implement Jinja2-based template rendering with custom filters (bullet_list, numbered_list, table) - Add YAML serialization for template import/export CLI commands: - `cf prd templates list` - List available templates - `cf prd templates show <id>` - Show template details - `cf prd templates import <file>` - Import custom template - `cf prd templates export <id> <output>` - Export template to YAML - `cf prd generate --template <id>` - Generate PRD using specific template Templates support: - Global templates in ~/.codeframe/templates/prd/ - Project templates in .codeframe/templates/prd/ - Template validation (required fields, Jinja2 syntax) - Custom template functions for list formatting Tests: 44 new tests covering template system and CLI commands * fix(planning): integrate template_id into PRD generation (#316) The --template flag was validated but never passed to generate_prd(). Changes: - Add template_id parameter to PrdDiscoverySession.generate_prd() - Add _build_prd_prompt() to construct template-aware prompts - Pass template_id from CLI to session.generate_prd() - Store template_id in PRD metadata for traceability - Add tests for template integration The LLM prompt is now dynamically constructed based on the selected template's sections, ensuring the generated PRD follows the chosen format. * fix(planning): persist imported templates and use workspace scope (#316) Fixes three issues with template resolution and persistence: 1. CLI prd_templates_import now persists templates to disk - Added persist_template() method to PrdTemplateManager - Import with persist=True saves to .codeframe/templates/prd/ - Shows save location in CLI output 2. CLI template commands now include project templates - Pass workspace_path=Path.cwd() to PrdTemplateManager - Project templates in .codeframe/templates/prd/ are discovered 3. prd_discovery._build_prd_prompt uses workspace scope - Passes self.workspace.path to PrdTemplateManager - Project templates can be used in PRD generation Added 4 new tests for persist functionality. * fix(planning): use repo_path instead of path for Workspace attribute * fix(planning): track resolved vs requested template ID in PRD metadata When template_id is missing or invalid, _build_prd_prompt falls back to the default prompt. Previously, metadata would incorrectly record the original requested template ID. Changes: - _build_prd_prompt now returns tuple of (prompt, resolved_template_id) - Resolved ID is "default" when no template used or fallback occurred - Metadata stores resolved template_id for accurate tracking - If requested template differs from resolved, store requested_template_id - Logging now reflects actual template used * fix(planning): align docstring and use OS-agnostic test assertions - Update generate_prd docstring to match actual fallback behavior (uses "default" not "standard" when template not found) - Replace string path assertion with Path object comparison for cross-platform compatibility in test_persist_template_to_project * style(tests): remove unused Path import Ruff F401: Path import was redundant since tmp_path fixture already provides a Path object for division operations. * fix(planning): improve template validation and override handling 1. CLI template validation now includes project templates - Pass workspace_path to PrdTemplateManager during validation - Project templates in .codeframe/templates/prd/ are now discoverable 2. Template ID collision handling - Log warning when a template overrides an existing one - Helps users understand which template version is active Added test for override warning behavior. * security(planning): add input validation and XSS prevention 1. Jinja2 autoescape (XSS prevention) - Enable autoescape=True in Jinja2 Environment - Prevents HTML injection if PRDs are rendered downstream 2. Path traversal prevention - Validate file exists and is a YAML file in load_template_from_file() - Reject non-.yaml/.yml files 3. Improved error handling in render_template() - Catch TemplateSyntaxError separately with specific message - Re-raise KeyboardInterrupt/SystemExit - Log full traceback for unexpected errors 4. Template validation on import - Validate template before registering in import_template() - Raise ValueError with validation errors Added 4 security-focused tests covering all validations. * test(planning): use resolve() for robust path comparison Use .resolve() on both paths to handle Windows path normalization differences (e.g., case sensitivity, symlink resolution). * fix(planning): address PR review feedback Code quality improvements based on CodeRabbit review: 1. Empty YAML file handling - Add null check after yaml.safe_load() with clear error message 2. list_templates sorting robustness - Handle name=None with fallback to empty string 3. import_template docstring accuracy - Fix to match behavior (always saves to project directory) 4. Support both .yaml and .yml extensions - _load_from_directory now globs both patterns 5. Cross-platform test compatibility - Set both HOME and USERPROFILE for Path.home() tests 6. Section heading clarity - Change "Required Sections" to "Sections" in prompt Added test for empty YAML file rejection. --------- Co-authored-by: Test User <test@example.com>
1 parent 3203109 commit 7fc6a53

9 files changed

Lines changed: 2401 additions & 14 deletions

File tree

codeframe/cli/app.py

Lines changed: 164 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -586,6 +586,142 @@ def serve(
586586
no_args_is_help=True,
587587
)
588588

589+
# PRD templates subcommand group
590+
prd_templates_app = typer.Typer(
591+
name="templates",
592+
help="PRD template management for customizable output formats",
593+
no_args_is_help=True,
594+
)
595+
596+
597+
@prd_templates_app.command("list")
598+
def prd_templates_list() -> None:
599+
"""List available PRD templates.
600+
601+
Shows all built-in and custom PRD templates that can be used
602+
with 'codeframe prd generate --template'.
603+
604+
Example:
605+
codeframe prd templates list
606+
"""
607+
from codeframe.planning.prd_templates import PrdTemplateManager
608+
609+
# Pass workspace path to include project templates
610+
manager = PrdTemplateManager(workspace_path=Path.cwd())
611+
templates = manager.list_templates()
612+
613+
console.print("\n[bold]Available PRD Templates:[/bold]\n")
614+
for template in templates:
615+
section_count = len(template.sections)
616+
console.print(f" [green]{template.id}[/green] - {template.name}")
617+
console.print(f" {template.description}")
618+
console.print(f" Sections: {section_count} | Version: {template.version}")
619+
console.print()
620+
621+
622+
@prd_templates_app.command("show")
623+
def prd_templates_show(
624+
template_id: str = typer.Argument(..., help="Template ID to show"),
625+
) -> None:
626+
"""Show details of a specific PRD template.
627+
628+
Displays the template's sections and their configuration.
629+
630+
Example:
631+
codeframe prd templates show standard
632+
codeframe prd templates show lean
633+
"""
634+
from codeframe.planning.prd_templates import PrdTemplateManager
635+
636+
# Pass workspace path to include project templates
637+
manager = PrdTemplateManager(workspace_path=Path.cwd())
638+
template = manager.get_template(template_id)
639+
640+
if not template:
641+
console.print(f"[red]Error:[/red] Template '{template_id}' not found.")
642+
console.print("\nAvailable templates:")
643+
for t in manager.list_templates():
644+
console.print(f" {t.id}")
645+
raise typer.Exit(1)
646+
647+
console.print(f"\n[bold]{template.name}[/bold] ({template.id})\n")
648+
console.print(f"{template.description}\n")
649+
console.print(f"Version: {template.version}")
650+
651+
console.print("\n[bold]Sections:[/bold]\n")
652+
for i, section in enumerate(template.sections, 1):
653+
required = "[green]required[/green]" if section.required else "[dim]optional[/dim]"
654+
console.print(f" {i}. {section.title} ({section.id})")
655+
console.print(f" Source: {section.source} | {required}")
656+
console.print()
657+
658+
659+
@prd_templates_app.command("export")
660+
def prd_templates_export(
661+
template_id: str = typer.Argument(..., help="Template ID to export"),
662+
output_path: Path = typer.Argument(..., help="Output file path (.yaml)"),
663+
) -> None:
664+
"""Export a PRD template to a YAML file.
665+
666+
Exports the template configuration for backup or customization.
667+
668+
Example:
669+
codeframe prd templates export standard ./my-template.yaml
670+
codeframe prd templates export enterprise ./enterprise-prd.yaml
671+
"""
672+
from codeframe.planning.prd_templates import PrdTemplateManager
673+
674+
# Pass workspace path to include project templates
675+
manager = PrdTemplateManager(workspace_path=Path.cwd())
676+
677+
try:
678+
manager.export_template(template_id, output_path)
679+
console.print(f"[green]✓[/green] Exported template '{template_id}' to {output_path}")
680+
except ValueError as e:
681+
console.print(f"[red]Error:[/red] {e}")
682+
console.print("\nAvailable templates:")
683+
for t in manager.list_templates():
684+
console.print(f" {t.id}")
685+
raise typer.Exit(1)
686+
687+
688+
@prd_templates_app.command("import")
689+
def prd_templates_import(
690+
source_path: Path = typer.Argument(
691+
...,
692+
help="Path to template YAML file",
693+
exists=True,
694+
file_okay=True,
695+
dir_okay=False,
696+
),
697+
) -> None:
698+
"""Import a PRD template from a YAML file.
699+
700+
Imports a custom template that can be used with 'codeframe prd generate --template'.
701+
The template is saved to the project's .codeframe/templates/prd/ directory.
702+
703+
Example:
704+
codeframe prd templates import ./custom-template.yaml
705+
"""
706+
from codeframe.planning.prd_templates import PrdTemplateManager
707+
708+
# Pass workspace path for project template storage
709+
manager = PrdTemplateManager(workspace_path=Path.cwd())
710+
711+
try:
712+
# Import and persist to project directory
713+
template = manager.import_template(source_path, persist=True)
714+
console.print(f"[green]✓[/green] Imported template '{template.id}' ({template.name})")
715+
console.print(f"[dim]Sections: {len(template.sections)}[/dim]")
716+
console.print(f"[dim]Saved to: .codeframe/templates/prd/{template.id}.yaml[/dim]")
717+
except Exception as e:
718+
console.print(f"[red]Error:[/red] Failed to import template: {e}")
719+
raise typer.Exit(1)
720+
721+
722+
# Register prd_templates_app under prd_app
723+
prd_app.add_typer(prd_templates_app, name="templates")
724+
589725

590726
@prd_app.command("add")
591727
def prd_add(
@@ -1104,6 +1240,11 @@ def prd_generate(
11041240
"--resume", "-r",
11051241
help="Resume from a paused session (blocker ID)",
11061242
),
1243+
template: str = typer.Option(
1244+
"standard",
1245+
"--template", "-t",
1246+
help="PRD template to use (standard, lean, enterprise, user-story-map, technical-spec)",
1247+
),
11071248
) -> None:
11081249
"""Generate a PRD through AI-driven Socratic discovery.
11091250
@@ -1121,10 +1262,18 @@ def prd_generate(
11211262
/quit - Exit without saving
11221263
/help - Show available commands
11231264
1265+
Use --template to select the output format:
1266+
- standard: Full PRD with all sections (default)
1267+
- lean: Minimal PRD with problem, users, MVP features
1268+
- enterprise: Formal PRD with compliance and traceability
1269+
- user-story-map: Organized around user journeys
1270+
- technical-spec: Focused on technical specifications
1271+
11241272
Requires ANTHROPIC_API_KEY environment variable.
11251273
11261274
Example:
11271275
codeframe prd generate
1276+
codeframe prd generate --template lean
11281277
codeframe prd generate --resume abc123
11291278
"""
11301279
from codeframe.core.workspace import get_workspace
@@ -1137,11 +1286,24 @@ def prd_generate(
11371286
get_active_session,
11381287
)
11391288
from codeframe.core.events import emit_for_workspace, EventType
1289+
from codeframe.planning.prd_templates import PrdTemplateManager
11401290
from rich.panel import Panel
11411291
from rich.prompt import Prompt
11421292

11431293
workspace_path = repo_path or Path.cwd()
11441294

1295+
# Validate template exists (include project templates in search)
1296+
template_manager = PrdTemplateManager(workspace_path=workspace_path)
1297+
template_obj = template_manager.get_template(template)
1298+
if template_obj is None:
1299+
console.print(f"[red]Error:[/red] Template '{template}' not found.")
1300+
console.print("\nAvailable templates:")
1301+
for t in template_manager.list_templates():
1302+
console.print(f" {t.id} - {t.name}")
1303+
raise typer.Exit(1)
1304+
1305+
console.print(f"[dim]Using template: {template_obj.name}[/dim]")
1306+
11451307
try:
11461308
workspace = get_workspace(workspace_path)
11471309

@@ -1272,10 +1434,10 @@ def prd_generate(
12721434

12731435
# Generate PRD
12741436
console.print("\n[bold green]Discovery complete![/bold green]")
1275-
console.print("\nGenerating PRD from our conversation...")
1437+
console.print(f"\nGenerating PRD using '{template}' template...")
12761438

12771439
try:
1278-
prd_record = session.generate_prd()
1440+
prd_record = session.generate_prd(template_id=template)
12791441
except IncompleteSessionError as e:
12801442
console.print(f"[red]Error:[/red] {e}")
12811443
raise typer.Exit(1)

codeframe/core/prd_discovery.py

Lines changed: 83 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -589,11 +589,16 @@ def resume_discovery(self, blocker_id: str) -> None:
589589

590590
logger.info(f"Resumed session {session_id} from blocker {blocker_id}")
591591

592-
def generate_prd(self) -> prd.PrdRecord:
592+
def generate_prd(self, template_id: Optional[str] = None) -> prd.PrdRecord:
593593
"""Generate PRD from discovery conversation.
594594
595595
Uses AI to synthesize the conversation into a structured PRD.
596596
597+
Args:
598+
template_id: Optional PRD template ID to use for formatting.
599+
If not provided or not found, uses the default built-in
600+
prompt format (recorded as "default" in metadata).
601+
597602
Returns:
598603
Created PrdRecord
599604
@@ -606,10 +611,10 @@ def generate_prd(self) -> prd.PrdRecord:
606611
f"Current coverage: {self._coverage}"
607612
)
608613

609-
610614
qa_history = self._format_qa_history()
611615

612-
prompt = PRD_GENERATION_PROMPT.format(qa_history=qa_history)
616+
# Build prompt based on template and get the resolved template ID
617+
prompt, resolved_template_id = self._build_prd_prompt(qa_history, template_id)
613618

614619
response = self._llm_provider.complete(
615620
messages=[{"role": "user", "content": prompt}],
@@ -623,27 +628,93 @@ def generate_prd(self) -> prd.PrdRecord:
623628
# Extract title from PRD content
624629
title = self._extract_title_from_prd(content)
625630

626-
# Store PRD
631+
# Store PRD with both requested and resolved template IDs in metadata
632+
metadata: dict[str, Any] = {
633+
"source": "ai_discovery",
634+
"session_id": self.session_id,
635+
"questions_asked": len(self._qa_history),
636+
"coverage": self._coverage,
637+
"generated_at": _utc_now().isoformat(),
638+
"template_id": resolved_template_id,
639+
}
640+
# Track if a different template was requested but not found
641+
if template_id and template_id != resolved_template_id:
642+
metadata["requested_template_id"] = template_id
643+
627644
record = prd.store(
628645
self.workspace,
629646
content=content,
630647
title=title,
631-
metadata={
632-
"source": "ai_discovery",
633-
"session_id": self.session_id,
634-
"questions_asked": len(self._qa_history),
635-
"coverage": self._coverage,
636-
"generated_at": _utc_now().isoformat(),
637-
},
648+
metadata=metadata,
638649
)
639650

640651
# Update session state
641652
self.state = SessionState.COMPLETED
642653
self._save_session()
643654

644-
logger.info(f"Generated PRD {record.id} from session {self.session_id}")
655+
logger.info(f"Generated PRD {record.id} from session {self.session_id} using template '{resolved_template_id}'")
645656
return record
646657

658+
def _build_prd_prompt(
659+
self, qa_history: str, template_id: Optional[str] = None
660+
) -> tuple[str, str]:
661+
"""Build PRD generation prompt based on template.
662+
663+
Args:
664+
qa_history: Formatted Q&A history string
665+
template_id: Template ID to use (defaults to None, which uses default prompt)
666+
667+
Returns:
668+
Tuple of (prompt string, resolved template ID)
669+
The resolved template ID is "default" if no template was used,
670+
or the actual template ID that was successfully loaded.
671+
"""
672+
from codeframe.planning.prd_templates import PrdTemplateManager
673+
from pathlib import Path
674+
675+
# Use default prompt if no template specified
676+
if not template_id:
677+
return (PRD_GENERATION_PROMPT.format(qa_history=qa_history), "default")
678+
679+
# Pass workspace path to include project templates
680+
workspace_path = Path(self.workspace.repo_path) if self.workspace.repo_path else None
681+
manager = PrdTemplateManager(workspace_path=workspace_path)
682+
template = manager.get_template(template_id)
683+
684+
if template is None:
685+
logger.warning(f"Template '{template_id}' not found, falling back to default prompt")
686+
return (PRD_GENERATION_PROMPT.format(qa_history=qa_history), "default")
687+
688+
# Build dynamic prompt from template sections
689+
sections_spec = []
690+
for section in template.sections:
691+
required_note = " (required)" if section.required else " (optional)"
692+
sections_spec.append(f"## {section.title}{required_note}\n{section.source} - related content")
693+
694+
sections_text = "\n\n".join(sections_spec)
695+
696+
prompt = f"""Generate a Product Requirements Document based on the discovery conversation.
697+
698+
## Discovery Conversation
699+
{qa_history}
700+
701+
## Template: {template.name}
702+
{template.description}
703+
704+
## Sections
705+
Generate a markdown PRD with these sections in order:
706+
707+
# [Project Title - infer from conversation]
708+
709+
{sections_text}
710+
711+
---
712+
713+
Keep it concise but complete. Focus on actionable requirements.
714+
Follow the template structure exactly. This PRD should be sufficient to generate development tasks."""
715+
716+
return (prompt, template_id)
717+
647718
def _extract_title_from_prd(self, content: str) -> str:
648719
"""Extract project title from generated PRD content."""
649720
import re

codeframe/planning/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,27 @@
44
- Issue generation from PRD features
55
- Task decomposition from issues
66
- Work breakdown planning
7+
- PRD template system for customizable output formats
78
"""
89

910
from codeframe.planning.issue_generator import (
1011
IssueGenerator,
1112
parse_prd_features,
1213
assign_priority,
1314
)
15+
from codeframe.planning.prd_templates import (
16+
PrdTemplate,
17+
PrdTemplateSection,
18+
PrdTemplateManager,
19+
BUILTIN_TEMPLATES,
20+
)
1421

1522
__all__ = [
1623
"IssueGenerator",
1724
"parse_prd_features",
1825
"assign_priority",
26+
"PrdTemplate",
27+
"PrdTemplateSection",
28+
"PrdTemplateManager",
29+
"BUILTIN_TEMPLATES",
1930
]

0 commit comments

Comments
 (0)