Skip to main content

codetether_agent/tool/
prd.rs

1//! PRD Tool - Generate PRD JSON from requirements via Q&A
2//!
3//! When a task is complex, this tool asks clarifying questions
4//! to generate a structured PRD that can be used by ralph.
5
6use anyhow::{Context, Result};
7use async_trait::async_trait;
8use serde::Deserialize;
9use serde_json::{json, Value};
10use std::path::PathBuf;
11
12use super::{Tool, ToolResult};
13use crate::ralph::{Prd, UserStory, QualityChecks};
14
15/// Tool for generating PRDs from requirements
16pub struct PrdTool;
17
18impl Default for PrdTool {
19    fn default() -> Self { Self::new() }
20}
21
22impl PrdTool {
23    pub fn new() -> Self { Self }
24}
25
26#[derive(Deserialize)]
27struct Params {
28    action: String,
29    #[serde(default)]
30    task_description: Option<String>,
31    #[serde(default)]
32    project: Option<String>,
33    #[serde(default)]
34    feature: Option<String>,
35    #[serde(default)]
36    stories: Option<Vec<StoryInput>>,
37    #[serde(default)]
38    quality_checks: Option<QualityChecksInput>,
39    #[serde(default)]
40    prd_path: Option<String>,
41}
42
43#[derive(Deserialize)]
44struct StoryInput {
45    id: String,
46    title: String,
47    description: String,
48    #[serde(default)]
49    acceptance_criteria: Vec<String>,
50    #[serde(default)]
51    priority: Option<u8>,
52    #[serde(default)]
53    depends_on: Vec<String>,
54    #[serde(default)]
55    complexity: Option<u8>,
56}
57
58#[derive(Deserialize)]
59struct QualityChecksInput {
60    #[serde(default)]
61    typecheck: Option<String>,
62    #[serde(default)]
63    test: Option<String>,
64    #[serde(default)]
65    lint: Option<String>,
66    #[serde(default)]
67    build: Option<String>,
68}
69
70#[async_trait]
71impl Tool for PrdTool {
72    fn id(&self) -> &str { "prd" }
73    fn name(&self) -> &str { "PRD Generator" }
74    
75    fn description(&self) -> &str {
76        r#"Generate a structured PRD (Product Requirements Document) for complex tasks.
77
78Use this tool when you recognize a task is complex and needs to be broken down into user stories.
79
80Actions:
81- analyze: Analyze a task description and return what questions need answering
82- generate: Generate a PRD JSON from provided answers
83- save: Save a PRD to a file for ralph to execute
84
85The workflow is:
861. Call analyze with the task_description to get questions
872. Answer the questions and call generate with your answers
883. Call save to write the PRD to prd.json
894. Invoke ralph to execute the PRD
90"#
91    }
92
93    fn parameters(&self) -> Value {
94        json!({
95            "type": "object",
96            "properties": {
97                "action": {
98                    "type": "string",
99                    "enum": ["analyze", "generate", "save"],
100                    "description": "Action to perform"
101                },
102                "task_description": {
103                    "type": "string",
104                    "description": "Description of the complex task (for analyze)"
105                },
106                "project": {
107                    "type": "string",
108                    "description": "Project name (for generate)"
109                },
110                "feature": {
111                    "type": "string",
112                    "description": "Feature name (for generate)"
113                },
114                "stories": {
115                    "type": "array",
116                    "description": "User stories (for generate)",
117                    "items": {
118                        "type": "object",
119                        "properties": {
120                            "id": {"type": "string"},
121                            "title": {"type": "string"},
122                            "description": {"type": "string"},
123                            "acceptance_criteria": {"type": "array", "items": {"type": "string"}},
124                            "priority": {"type": "integer"},
125                            "depends_on": {"type": "array", "items": {"type": "string"}},
126                            "complexity": {"type": "integer"}
127                        },
128                        "required": ["id", "title", "description"]
129                    }
130                },
131                "quality_checks": {
132                    "type": "object",
133                    "properties": {
134                        "typecheck": {"type": "string"},
135                        "test": {"type": "string"},
136                        "lint": {"type": "string"},
137                        "build": {"type": "string"}
138                    }
139                },
140                "prd_path": {
141                    "type": "string",
142                    "description": "Path to save PRD (default: prd.json)"
143                }
144            },
145            "required": ["action"]
146        })
147    }
148
149    async fn execute(&self, params: Value) -> Result<ToolResult> {
150        let p: Params = serde_json::from_value(params).context("Invalid params")?;
151
152        match p.action.as_str() {
153            "analyze" => {
154                let task = p.task_description.unwrap_or_default();
155                if task.is_empty() {
156                    return Ok(ToolResult::error("task_description is required for analyze"));
157                }
158
159                let questions = format!(r#"# Task Analysis
160
161## Task Description
162{task}
163
164## Questions to Answer
165
166To generate a proper PRD for this task, please provide:
167
1681. **Project Name**: What is the name of this project?
169
1702. **Feature Name**: What specific feature or capability is being implemented?
171
1723. **User Stories**: Break down the task into discrete user stories. For each:
173   - ID (e.g., US-001)
174   - Title (short description)
175   - Description (detailed requirements)
176   - Acceptance Criteria (how to verify it's done)
177   - Priority (1=highest)
178   - Dependencies (which stories must complete first)
179   - Complexity (1-5)
180
1814. **Quality Checks**: What commands verify the work?
182   - Typecheck command (e.g., `cargo check`)
183   - Test command (e.g., `cargo test`)
184   - Lint command (e.g., `cargo clippy`)
185   - Build command (e.g., `cargo build`)
186
187## Example Response Format
188
189```json
190{{
191  "project": "codetether",
192  "feature": "LSP Integration", 
193  "stories": [
194    {{
195      "id": "US-001",
196      "title": "Add lsp-types dependency",
197      "description": "Add lsp-types crate to Cargo.toml",
198      "acceptance_criteria": ["Cargo.toml has lsp-types", "cargo check passes"],
199      "priority": 1,
200      "depends_on": [],
201      "complexity": 1
202    }},
203    {{
204      "id": "US-002",
205      "title": "Implement LSP client",
206      "description": "Create LSP client that can spawn language servers",
207      "acceptance_criteria": ["Can spawn rust-analyzer", "Can send initialize request"],
208      "priority": 2,
209      "depends_on": ["US-001"],
210      "complexity": 4
211    }}
212  ],
213  "quality_checks": {{
214    "typecheck": "cargo check",
215    "test": "cargo test",
216    "lint": "cargo clippy",
217    "build": "cargo build --release"
218  }}
219}}
220```
221
222Once you have the answers, call `prd({{action: 'generate', ...}})` with the data.
223"#);
224
225                Ok(ToolResult::success(questions))
226            }
227
228            "generate" => {
229                let project = p.project.unwrap_or_else(|| "Project".to_string());
230                let feature = p.feature.unwrap_or_else(|| "Feature".to_string());
231                
232                let stories: Vec<UserStory> = p.stories.unwrap_or_default()
233                    .into_iter()
234                    .map(|s| UserStory {
235                        id: s.id,
236                        title: s.title,
237                        description: s.description,
238                        acceptance_criteria: s.acceptance_criteria,
239                        passes: false,
240                        priority: s.priority.unwrap_or(1),
241                        depends_on: s.depends_on,
242                        complexity: s.complexity.unwrap_or(3),
243                    })
244                    .collect();
245
246                if stories.is_empty() {
247                    return Ok(ToolResult::error("At least one story is required"));
248                }
249
250                let quality_checks = match p.quality_checks {
251                    Some(qc) => QualityChecks {
252                        typecheck: qc.typecheck,
253                        test: qc.test,
254                        lint: qc.lint,
255                        build: qc.build,
256                    },
257                    None => QualityChecks::default(),
258                };
259
260                let prd = Prd {
261                    project: project.clone(),
262                    feature: feature.clone(),
263                    branch_name: format!("feature/{}", feature.to_lowercase().replace(' ', "-")),
264                    version: "1.0".to_string(),
265                    user_stories: stories,
266                    technical_requirements: Vec::new(),
267                    quality_checks,
268                    created_at: chrono::Utc::now().to_rfc3339(),
269                    updated_at: chrono::Utc::now().to_rfc3339(),
270                };
271
272                let json = serde_json::to_string_pretty(&prd)?;
273                
274                Ok(ToolResult::success(format!(
275                    "# Generated PRD\n\n```json\n{}\n```\n\nCall `prd({{action: 'save'}})` to write to file, then `ralph({{action: 'run'}})` to execute.",
276                    json
277                )).with_metadata("prd", serde_json::to_value(&prd)?))
278            }
279
280            "save" => {
281                let project = p.project.unwrap_or_else(|| "Project".to_string());
282                let feature = p.feature.unwrap_or_else(|| "Feature".to_string());
283                let prd_path = PathBuf::from(p.prd_path.unwrap_or_else(|| "prd.json".to_string()));
284                
285                let stories: Vec<UserStory> = p.stories.unwrap_or_default()
286                    .into_iter()
287                    .map(|s| UserStory {
288                        id: s.id,
289                        title: s.title,
290                        description: s.description,
291                        acceptance_criteria: s.acceptance_criteria,
292                        passes: false,
293                        priority: s.priority.unwrap_or(1),
294                        depends_on: s.depends_on,
295                        complexity: s.complexity.unwrap_or(3),
296                    })
297                    .collect();
298
299                if stories.is_empty() {
300                    return Ok(ToolResult::error("At least one story is required for save"));
301                }
302
303                let quality_checks = match p.quality_checks {
304                    Some(qc) => QualityChecks {
305                        typecheck: qc.typecheck,
306                        test: qc.test,
307                        lint: qc.lint,
308                        build: qc.build,
309                    },
310                    None => QualityChecks {
311                        typecheck: Some("cargo check".to_string()),
312                        test: Some("cargo test".to_string()),
313                        lint: Some("cargo clippy".to_string()),
314                        build: Some("cargo build".to_string()),
315                    },
316                };
317
318                let prd = Prd {
319                    project: project.clone(),
320                    feature: feature.clone(),
321                    branch_name: format!("feature/{}", feature.to_lowercase().replace(' ', "-")),
322                    version: "1.0".to_string(),
323                    user_stories: stories,
324                    technical_requirements: Vec::new(),
325                    quality_checks,
326                    created_at: chrono::Utc::now().to_rfc3339(),
327                    updated_at: chrono::Utc::now().to_rfc3339(),
328                };
329
330                prd.save(&prd_path).await.context("Failed to save PRD")?;
331
332                Ok(ToolResult::success(format!(
333                    "PRD saved to: {}\n\nRun with: ralph({{action: 'run', prd_path: '{}'}})",
334                    prd_path.display(),
335                    prd_path.display()
336                )))
337            }
338
339            _ => Ok(ToolResult::error(format!(
340                "Unknown action: {}. Use 'analyze', 'generate', or 'save'",
341                p.action
342            ))),
343        }
344    }
345}