Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ build/
.DS_Store
.coverage
outputs/

# Local planning docs
docs/plans/
19 changes: 17 additions & 2 deletions AI_USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,22 @@ This document tracks AI tool usage during development of prd-decomposer.
## AI-Generated vs Human-Written

### Fully AI-Generated (with human review)
- `src/prd_decomposer/models.py` - Pydantic model definitions
- `src/prd_decomposer/models.py` - Pydantic model definitions (includes AgentContext)
- `src/prd_decomposer/prompts.py` - LLM prompt templates
- `src/prd_decomposer/formatters.py` - Prompt rendering for AI agents
- `src/prd_decomposer/config.py` - Settings class with environment variable support
- `agent/session_state.py` - Agent session state management
- `tests/test_models.py` - Pydantic model unit tests
- `tests/test_server.py` - Server/tool tests with mocked LLM
- `tests/test_circuit_breaker.py` - Circuit breaker tests
- `tests/test_export.py` - Export format tests
- `tests/test_prompts.py` - Prompt template tests
- `tests/test_config.py` - Configuration validation tests
- `tests/test_agent.py` - Agent CLI tests
- `evals/eval_prd_tools.py` - Arcade eval suite (8 eval cases)
- `tests/integration/test_real_api.py` - Real API integration tests
- `docs/diagrams/architecture.*` - Architecture diagram (Excalidraw + SVG)
- `docs/plans/*-agent-executable-tickets-*.md` - Design and implementation plans
- `README.md` - Documentation

### Human-Written with AI Assistance
Expand Down Expand Up @@ -119,6 +123,17 @@ Claude Code addressed final code review feedback:
- **Test Reorganization**: Extracted circuit breaker tests to `test_circuit_breaker.py` and export tests to `test_export.py` to mirror source structure per CLAUDE.md conventions
- **Testing**: 243 tests (239 unit + 4 integration) after reorganization

### Session 10: Agent-Executable Tickets Feature
Claude Code implemented AI-optimized ticket output:
- **AgentContext Model**: Added `AgentContext` Pydantic model with `goal`, `exploration_paths`, `exploration_hints`, `known_patterns`, `verification_tests`, and `self_check` fields
- **Story Model Update**: Added optional `agent_context` field to `Story` model (backward compatible)
- **Prompt Update**: Updated `DECOMPOSE_TO_TICKETS_PROMPT` with guideline 8 for agent_context generation, including few-shot example
- **Prompt Renderer**: Created `src/prd_decomposer/formatters.py` with `render_agent_prompt()` function for copy-paste prompts
- **CLI Command**: Added `prompt N` command (with `copy N` and `show N` aliases) to agent CLI
- **CSV Export**: Added `agent_prompt` column to CSV export format
- **Architecture Fix**: Moved `render_agent_prompt` to server package to maintain proper dependency direction (agent imports from server, not vice versa)
- **Testing**: Expanded from 243 to 305 tests

## Prompts Used

Key prompts used during development:
Expand Down Expand Up @@ -149,7 +164,7 @@ This prompt pattern is adapted from the [Agentic Code Reviewer](https://github.c
## Quality Assurance

- All AI-generated code was reviewed before committing
- 243 tests (239 unit + 4 integration) with comprehensive coverage
- 305 tests (301 unit + 4 integration) with comprehensive coverage
- Arcade evals validate tool selection (8 eval cases)
- Security review for path traversal and injection risks
- Error handling review for LLM failure scenarios
Expand Down
32 changes: 19 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,19 @@ Environment variables with `PRD_` prefix (via pydantic-settings):
- `PRD_CIRCUIT_BREAKER_FAILURE_THRESHOLD` - Failures before circuit opens, 1-20 (default: `5`)
- `PRD_CIRCUIT_BREAKER_RESET_TIMEOUT` - Seconds before half-open probe, 1-300 (default: `60`)

### AI-Executable Tickets

Stories include optional `agent_context` for AI coding assistants:

- **goal**: Why this work matters (the problem being solved)
- **exploration_paths**: Keywords to search in codebase
- **exploration_hints**: Specific files/modules to start with
- **known_patterns**: Libraries and conventions to follow
- **verification_tests**: Tests that should pass when done
- **self_check**: Questions to verify before completion

Use `prompt N` in the agent to get a copy-pasteable prompt for any story.

## Key Decisions

| Decision | Choice | Rationale | Alternative Considered |
Expand Down Expand Up @@ -205,7 +218,7 @@ uv run python src/prd_decomposer/server.py
### Run Tests

```bash
# Unit tests (243 tests, no API key required)
# Unit tests (305 tests, no API key required)
uv run pytest tests/ -v

# With coverage report
Expand Down Expand Up @@ -255,14 +268,17 @@ prd-decomposer/
├── src/prd_decomposer/
│ ├── __init__.py # Public exports
│ ├── server.py # MCP server + tool definitions
│ ├── models.py # Pydantic models
│ ├── models.py # Pydantic models (includes AgentContext)
│ ├── prompts.py # LLM prompt templates
│ ├── formatters.py # Prompt rendering for AI agents
│ ├── config.py # Settings via environment variables
│ ├── log.py # Structured JSON logging
│ ├── circuit_breaker.py # Circuit breaker + rate limiter
│ └── export.py # CSV/Jira/YAML export functions
├── agent/
│ └── agent.py # OpenAI Agents SDK consumer
│ ├── agent.py # OpenAI Agents SDK consumer
│ ├── session_state.py # Agent session state management
│ └── formatters.py # Re-exports from prd_decomposer.formatters
├── scripts/
│ └── run_all_prds.py # Batch processing script
├── tests/
Expand Down Expand Up @@ -311,16 +327,6 @@ Run agents continuously to keep PRDs and Jira in sync:
- **Jira → PRD**: When ticket status changes (in progress, blocked, complete), reflect it back in the PRD
- **Real-time status dashboard**: Product and non-technical stakeholders see live implementation status tied to the high-level requirements doc—no more "what's the status of X?" meetings

### Agent-Executable Tickets
Tickets today are written for human engineers who infer context, navigate ambiguity, and fill gaps. AI agents need more structure to execute reliably. Future ticket output could include:
- **Explicit file paths** - "Modify `src/auth/login.py`" not "update the login handler"
- **Testable completion criteria** - Machine-verifiable assertions, not prose descriptions
- **Dependency graph** - Which tickets must complete before this one can start
- **Context pointers** - Links to relevant code, prior tickets, design docs the agent should read
- **Execution hints** - Suggested approach, libraries to use, patterns to follow

The goal: tickets that work for both the human reviewing the PR and the agent writing it.

## License

MIT
44 changes: 37 additions & 7 deletions agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
format_requirements_table,
format_ticket_summary,
format_tickets_hierarchy,
render_agent_prompt,
)
from agent.session_state import SessionState

Expand All @@ -26,7 +27,8 @@
BACKOFF_MULTIPLIER = 2.0

# Agent instructions - kept as a constant for testability
AGENT_INSTRUCTIONS = """You help engineers convert Product Requirements Documents (PRDs) into actionable Jira tickets.
AGENT_INSTRUCTIONS = """You help engineers convert Product Requirements Documents (PRDs) into \
actionable Jira tickets.

## Available Tools

Expand Down Expand Up @@ -83,8 +85,8 @@ def parse_command(user_input: str) -> tuple[str | None, int | None, str | None]:

Returns:
Tuple of (command, index, argument) where:
- command: "accept", "dismiss", "clarify", "tickets", "ambiguities", or None
- index: 1-based index for accept/dismiss/clarify, or None
- command: "accept", "dismiss", "clarify", "tickets", "ambiguities", "prompt", or None
- index: 1-based index for accept/dismiss/clarify/prompt, or None
- argument: clarification text for "clarify", or None
"""
stripped = user_input.strip().lower()
Expand Down Expand Up @@ -114,6 +116,11 @@ def parse_command(user_input: str) -> tuple[str | None, int | None, str | None]:
if match:
return ("clarify", int(match.group(1)), match.group(2))

# prompt N (also copy N, show N)
match = re.match(r"(prompt|copy|show)\s+(\d+)", stripped)
if match:
return ("prompt", int(match.group(2)), None)

return (None, None, None)


Expand Down Expand Up @@ -167,6 +174,14 @@ def handle_command(
return f"Added clarification to {req_id}. {remaining} {noun} remaining."
return f"Invalid index: {index}. Use 'ambiguities' to see the list."

if command == "prompt":
if index is None:
return "Usage: prompt [n] - Show copy-paste prompt for story N"
story = session.get_story_by_index(index)
if story is None:
return f"Story #{index} not found. Run 'tickets' first."
return render_agent_prompt(story)

return f"Unknown command: {command}"


Expand Down Expand Up @@ -329,7 +344,10 @@ async def connect_mcp_server_with_retry(
print(f"[DEBUG] Connection attempt {attempt} failed: {e}")

if attempt < MAX_CONNECTION_RETRIES:
print(f"Connection failed, retrying in {delay:.1f}s... ({attempt}/{MAX_CONNECTION_RETRIES})")
print(
f"Connection failed, retrying in {delay:.1f}s... "
f"({attempt}/{MAX_CONNECTION_RETRIES})"
)
await asyncio.sleep(delay)
delay *= BACKOFF_MULTIPLIER

Expand Down Expand Up @@ -419,7 +437,8 @@ async def main() -> None:
print("=" * 40)
print("I help convert PRDs into Jira tickets.")
print("Paste your PRD or provide a file path to get started.")
print("\nCommands: accept [n], dismiss [n], clarify [n] \"text\", tickets, ambiguities")
print("\nCommands: accept [n], dismiss [n], clarify [n] \"text\", "
"tickets, ambiguities, prompt [n]")
print("Type 'quit' to exit.\n")

while True:
Expand Down Expand Up @@ -461,7 +480,7 @@ async def main() -> None:
)
user_input = decompose_request

elif command in ("accept", "dismiss", "clarify", "ambiguities"):
elif command in ("accept", "dismiss", "clarify", "ambiguities", "prompt"):
# Handle locally without LLM call
response = handle_command(command, index, argument, session)
print(f"\n{response}\n")
Expand All @@ -471,7 +490,10 @@ async def main() -> None:
current_input = [*conversation_history, {"role": "user", "content": user_input}]

if verbose:
print(f"[DEBUG] Sending request with {len(conversation_history)} history items...")
print(
f"[DEBUG] Sending request with "
f"{len(conversation_history)} history items..."
)

# Run with timeout handling
print("Thinking...", end="", flush=True)
Expand Down Expand Up @@ -508,6 +530,14 @@ async def main() -> None:
elif _is_tickets_response(final_output):
tickets = _extract_tickets_from_output(final_output)
if tickets:
session.store_tickets(tickets)
if verbose:
epics = tickets.get("epics", [])
total_stories = sum(
len(e.get("stories", [])) for e in epics
)
print(f"[DEBUG] Stored {len(epics)} epics, {total_stories} stories")

# Show formatted ticket summary below the prose
print("=" * 50)
print(format_ticket_summary(tickets))
Expand Down
7 changes: 7 additions & 0 deletions agent/formatters.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@

from typing import Any

# Re-export render_agent_prompt from the canonical location in prd_decomposer
from prd_decomposer.formatters import render_agent_prompt

__all__ = ["render_agent_prompt"]


def _pluralize(count: int, singular: str, plural: str) -> str:
"""Return singular or plural form based on count."""
Expand Down Expand Up @@ -178,3 +183,5 @@ def format_ticket_summary(tickets: dict[str, Any]) -> str:
]

return "\n".join(lines)


27 changes: 27 additions & 0 deletions agent/session_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,14 @@ class SessionState:
accepted_ambiguities: Set of ambiguity IDs the user has accepted
dismissed_ambiguities: Set of ambiguity IDs the user has dismissed
clarifications: Map of requirement ID -> clarification text
current_tickets: The most recent decompose_to_tickets result, or None
"""

current_requirements: dict[str, Any] | None = None
accepted_ambiguities: set[str] = field(default_factory=set)
dismissed_ambiguities: set[str] = field(default_factory=set)
clarifications: dict[str, str] = field(default_factory=dict)
current_tickets: dict[str, Any] | None = None

def store_requirements(self, requirements: dict[str, Any]) -> None:
"""Store requirements from analyze_prd and reset decisions."""
Expand Down Expand Up @@ -164,9 +166,34 @@ def format_ambiguities_display(self) -> str:

return "\n".join(lines)

def store_tickets(self, tickets: dict[str, Any]) -> None:
"""Store tickets from decompose_to_tickets."""
self.current_tickets = tickets

def get_story_by_index(self, index: int) -> dict[str, Any] | None:
"""Get a story by 1-based index across all epics.

Args:
index: 1-based story index

Returns:
Story dict if found, None if index is invalid
"""
if not self.current_tickets:
return None

story_num = 0
for epic in self.current_tickets.get("epics", []):
for story in epic.get("stories", []):
story_num += 1
if story_num == index:
return story
return None

def reset(self) -> None:
"""Reset all session state."""
self.current_requirements = None
self.accepted_ambiguities.clear()
self.dismissed_ambiguities.clear()
self.clarifications.clear()
self.current_tickets = None
Loading