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::{Value, json};
10use std::collections::HashSet;
11use std::path::PathBuf;
12
13use super::{Tool, ToolResult};
14use crate::ralph::{Prd, QualityChecks, UserStory};
15
16/// Tool for generating PRDs from requirements
17pub struct PrdTool;
18
19impl Default for PrdTool {
20    fn default() -> Self {
21        Self::new()
22    }
23}
24
25impl PrdTool {
26    pub fn new() -> Self {
27        Self
28    }
29}
30
31#[derive(Deserialize)]
32struct Params {
33    action: String,
34    #[serde(default)]
35    task_description: Option<String>,
36    #[serde(default)]
37    project: Option<String>,
38    #[serde(default)]
39    feature: Option<String>,
40    #[serde(default)]
41    stories: Option<Vec<StoryInput>>,
42    #[serde(default)]
43    quality_checks: Option<QualityChecksInput>,
44    #[serde(default)]
45    prd_path: Option<String>,
46    #[serde(default)]
47    prd_json: Option<String>,
48}
49
50#[derive(Deserialize)]
51struct StoryInput {
52    id: String,
53    title: String,
54    description: String,
55    #[serde(default)]
56    acceptance_criteria: Vec<String>,
57    #[serde(default)]
58    priority: Option<u8>,
59    #[serde(default)]
60    depends_on: Vec<String>,
61    #[serde(default)]
62    complexity: Option<u8>,
63}
64
65#[derive(Deserialize)]
66struct QualityChecksInput {
67    #[serde(default)]
68    typecheck: Option<String>,
69    #[serde(default)]
70    test: Option<String>,
71    #[serde(default)]
72    lint: Option<String>,
73    #[serde(default)]
74    build: Option<String>,
75}
76
77#[async_trait]
78impl Tool for PrdTool {
79    fn id(&self) -> &str {
80        "prd"
81    }
82    fn name(&self) -> &str {
83        "PRD Generator"
84    }
85
86    fn description(&self) -> &str {
87        r#"Generate and validate structured PRDs (Product Requirements Documents) for complex tasks.
88
89Use this tool when you recognize a task is complex and needs to be broken down into user stories.
90
91Actions:
92- analyze: Analyze a task description and return what questions need answering
93- generate: Generate a PRD JSON from provided answers
94- validate: Validate a PRD before ralph execution (checks schema, dependencies, etc.)
95- save: Save a PRD to a file for ralph to execute
96
97The workflow is:
981. Call analyze with the task_description to get questions
992. Answer the questions and call generate with your answers
1003. Call validate to ensure the PRD is valid before saving
1014. Call save to write the PRD to prd.json
1025. Invoke ralph to execute the PRD
103"#
104    }
105
106    fn parameters(&self) -> Value {
107        json!({
108            "type": "object",
109            "properties": {
110                "action": {
111                    "type": "string",
112                    "enum": ["analyze", "generate", "validate", "save"],
113                    "description": "Action to perform"
114                },
115                "task_description": {
116                    "type": "string",
117                    "description": "Description of the complex task (for analyze)"
118                },
119                "project": {
120                    "type": "string",
121                    "description": "Project name (for generate/validate)"
122                },
123                "feature": {
124                    "type": "string",
125                    "description": "Feature name (for generate/validate)"
126                },
127                "stories": {
128                    "type": "array",
129                    "description": "User stories (for generate/validate)",
130                    "items": {
131                        "type": "object",
132                        "properties": {
133                            "id": {"type": "string"},
134                            "title": {"type": "string"},
135                            "description": {"type": "string"},
136                            "acceptance_criteria": {"type": "array", "items": {"type": "string"}},
137                            "priority": {"type": "integer"},
138                            "depends_on": {"type": "array", "items": {"type": "string"}},
139                            "complexity": {"type": "integer"}
140                        },
141                        "required": ["id", "title", "description"]
142                    }
143                },
144                "quality_checks": {
145                    "type": "object",
146                    "properties": {
147                        "typecheck": {"type": "string"},
148                        "test": {"type": "string"},
149                        "lint": {"type": "string"},
150                        "build": {"type": "string"}
151                    }
152                },
153                "prd_path": {
154                    "type": "string",
155                    "description": "Path to PRD file (for validate from file, or save destination)"
156                },
157                "prd_json": {
158                    "type": "string",
159                    "description": "Raw PRD JSON string to validate (alternative to prd_path)"
160                }
161            },
162            "required": ["action"]
163        })
164    }
165
166    async fn execute(&self, params: Value) -> Result<ToolResult> {
167        let p: Params = serde_json::from_value(params).context("Invalid params")?;
168
169        match p.action.as_str() {
170            "analyze" => {
171                let task = p.task_description.unwrap_or_default();
172                if task.is_empty() {
173                    return Ok(ToolResult::error(
174                        "task_description is required for analyze",
175                    ));
176                }
177
178                let questions = format!(
179                    r#"# Task Analysis
180
181## Task Description
182{task}
183
184## Questions to Answer
185
186To generate a proper PRD for this task, please provide:
187
1881. **Project Name**: What is the name of this project?
189
1902. **Feature Name**: What specific feature or capability is being implemented?
191
1923. **User Stories**: Break down the task into discrete user stories. For each:
193   - ID (e.g., US-001)
194   - Title (short description)
195   - Description (detailed requirements)
196   - Acceptance Criteria (how to verify it's done)
197   - Priority (1=highest)
198   - Dependencies (which stories must complete first)
199   - Complexity (1-5)
200
2014. **Quality Checks**: What commands verify the work?
202   - Typecheck command (e.g., `cargo check`)
203   - Test command (e.g., `cargo test`)
204   - Lint command (e.g., `cargo clippy`)
205   - Build command (e.g., `cargo build`)
206
207## Example Response Format
208
209```json
210{{
211  "project": "codetether",
212  "feature": "LSP Integration", 
213  "stories": [
214    {{
215      "id": "US-001",
216      "title": "Add lsp-types dependency",
217      "description": "Add lsp-types crate to Cargo.toml",
218      "acceptance_criteria": ["Cargo.toml has lsp-types", "cargo check passes"],
219      "priority": 1,
220      "depends_on": [],
221      "complexity": 1
222    }},
223    {{
224      "id": "US-002",
225      "title": "Implement LSP client",
226      "description": "Create LSP client that can spawn language servers",
227      "acceptance_criteria": ["Can spawn rust-analyzer", "Can send initialize request"],
228      "priority": 2,
229      "depends_on": ["US-001"],
230      "complexity": 4
231    }}
232  ],
233  "quality_checks": {{
234    "typecheck": "cargo check",
235    "test": "cargo test",
236    "lint": "cargo clippy",
237    "build": "cargo build --release"
238  }}
239}}
240```
241
242Once you have the answers, call `prd({{action: 'generate', ...}})` with the data.
243"#
244                );
245
246                Ok(ToolResult::success(questions))
247            }
248
249            "generate" => {
250                let project = p.project.unwrap_or_else(|| "Project".to_string());
251                let feature = p.feature.unwrap_or_else(|| "Feature".to_string());
252
253                let stories: Vec<UserStory> = p
254                    .stories
255                    .unwrap_or_default()
256                    .into_iter()
257                    .map(|s| UserStory {
258                        id: s.id,
259                        title: s.title,
260                        description: s.description,
261                        acceptance_criteria: s.acceptance_criteria,
262                        passes: false,
263                        priority: s.priority.unwrap_or(1),
264                        depends_on: s.depends_on,
265                        complexity: s.complexity.unwrap_or(3),
266                    })
267                    .collect();
268
269                if stories.is_empty() {
270                    return Ok(ToolResult::error("At least one story is required"));
271                }
272
273                let quality_checks = match p.quality_checks {
274                    Some(qc) => QualityChecks {
275                        typecheck: qc.typecheck,
276                        test: qc.test,
277                        lint: qc.lint,
278                        build: qc.build,
279                    },
280                    None => QualityChecks::default(),
281                };
282
283                let prd = Prd {
284                    project: project.clone(),
285                    feature: feature.clone(),
286                    branch_name: format!("feature/{}", feature.to_lowercase().replace(' ', "-")),
287                    version: "1.0".to_string(),
288                    user_stories: stories,
289                    technical_requirements: Vec::new(),
290                    quality_checks,
291                    created_at: chrono::Utc::now().to_rfc3339(),
292                    updated_at: chrono::Utc::now().to_rfc3339(),
293                };
294
295                let json = serde_json::to_string_pretty(&prd)?;
296
297                Ok(ToolResult::success(format!(
298                    "# Generated PRD\n\n```json\n{}\n```\n\nCall `prd({{action: 'validate'}})` to check for errors, then `prd({{action: 'save'}})` to write to file.",
299                    json
300                )).with_metadata("prd", serde_json::to_value(&prd)?))
301            }
302
303            "validate" => {
304                // Get PRD from: 1) prd_json string, 2) prd_path file, or 3) inline stories
305                let prd: Prd = if let Some(json_str) = p.prd_json {
306                    serde_json::from_str(&json_str)
307                        .context("Failed to parse prd_json - invalid JSON")?
308                } else if let Some(path) = &p.prd_path {
309                    let prd_path = PathBuf::from(path);
310                    if !prd_path.exists() {
311                        return Ok(ToolResult::error(format!("PRD file not found: {}", path)));
312                    }
313                    let content = tokio::fs::read_to_string(&prd_path).await?;
314                    serde_json::from_str(&content)
315                        .context("Failed to parse PRD file - invalid JSON")?
316                } else if p.stories.is_some() {
317                    // Build from inline params
318                    let project = p.project.clone().unwrap_or_else(|| "Project".to_string());
319                    let feature = p.feature.clone().unwrap_or_else(|| "Feature".to_string());
320                    let stories: Vec<UserStory> = p
321                        .stories
322                        .unwrap_or_default()
323                        .into_iter()
324                        .map(|s| UserStory {
325                            id: s.id,
326                            title: s.title,
327                            description: s.description,
328                            acceptance_criteria: s.acceptance_criteria,
329                            passes: false,
330                            priority: s.priority.unwrap_or(1),
331                            depends_on: s.depends_on,
332                            complexity: s.complexity.unwrap_or(3),
333                        })
334                        .collect();
335                    Prd {
336                        project,
337                        feature,
338                        branch_name: String::new(),
339                        version: "1.0".to_string(),
340                        user_stories: stories,
341                        technical_requirements: Vec::new(),
342                        quality_checks: QualityChecks::default(),
343                        created_at: String::new(),
344                        updated_at: String::new(),
345                    }
346                } else {
347                    return Ok(ToolResult::error(
348                        "validate requires one of: prd_json, prd_path, or stories",
349                    ));
350                };
351
352                // Run validation
353                let validation = validate_prd(&prd);
354
355                if validation.is_valid {
356                    Ok(ToolResult::success(format!(
357                        "# PRD Validation: PASSED\n\n\
358                        Project: {}\n\
359                        Feature: {}\n\
360                        Stories: {}\n\
361                        Execution stages: {}\n\n\
362                        {}\n\n\
363                        Ready for: `prd({{action: 'save'}})` then `ralph({{action: 'run'}})`",
364                        prd.project,
365                        prd.feature,
366                        prd.user_stories.len(),
367                        prd.stages().len(),
368                        validation.summary()
369                    )))
370                } else {
371                    Ok(ToolResult::error(format!(
372                        "# PRD Validation: FAILED\n\n{}\n\n\
373                        Fix these issues before saving.",
374                        validation.summary()
375                    )))
376                }
377            }
378
379            "save" => {
380                let project = p.project.unwrap_or_else(|| "Project".to_string());
381                let feature = p.feature.unwrap_or_else(|| "Feature".to_string());
382                let prd_path = PathBuf::from(p.prd_path.unwrap_or_else(|| "prd.json".to_string()));
383
384                let stories: Vec<UserStory> = p
385                    .stories
386                    .unwrap_or_default()
387                    .into_iter()
388                    .map(|s| UserStory {
389                        id: s.id,
390                        title: s.title,
391                        description: s.description,
392                        acceptance_criteria: s.acceptance_criteria,
393                        passes: false,
394                        priority: s.priority.unwrap_or(1),
395                        depends_on: s.depends_on,
396                        complexity: s.complexity.unwrap_or(3),
397                    })
398                    .collect();
399
400                if stories.is_empty() {
401                    return Ok(ToolResult::error("At least one story is required for save"));
402                }
403
404                let quality_checks = match p.quality_checks {
405                    Some(qc) => QualityChecks {
406                        typecheck: qc.typecheck,
407                        test: qc.test,
408                        lint: qc.lint,
409                        build: qc.build,
410                    },
411                    None => QualityChecks {
412                        typecheck: Some("cargo check".to_string()),
413                        test: Some("cargo test".to_string()),
414                        lint: Some("cargo clippy".to_string()),
415                        build: Some("cargo build".to_string()),
416                    },
417                };
418
419                let prd = Prd {
420                    project: project.clone(),
421                    feature: feature.clone(),
422                    branch_name: format!("feature/{}", feature.to_lowercase().replace(' ', "-")),
423                    version: "1.0".to_string(),
424                    user_stories: stories,
425                    technical_requirements: Vec::new(),
426                    quality_checks,
427                    created_at: chrono::Utc::now().to_rfc3339(),
428                    updated_at: chrono::Utc::now().to_rfc3339(),
429                };
430
431                prd.save(&prd_path).await.context("Failed to save PRD")?;
432
433                Ok(ToolResult::success(format!(
434                    "PRD saved to: {}\n\nRun with: ralph({{action: 'run', prd_path: '{}'}})",
435                    prd_path.display(),
436                    prd_path.display()
437                )))
438            }
439
440            _ => Ok(ToolResult::error(format!(
441                "Unknown action: {}. Use 'analyze', 'generate', 'validate', or 'save'",
442                p.action
443            ))),
444        }
445    }
446}
447
448/// Validation result for a PRD
449struct ValidationResult {
450    is_valid: bool,
451    errors: Vec<String>,
452    warnings: Vec<String>,
453}
454
455impl ValidationResult {
456    fn summary(&self) -> String {
457        let mut lines = Vec::new();
458
459        if !self.errors.is_empty() {
460            lines.push("## Errors".to_string());
461            for err in &self.errors {
462                lines.push(format!("- ❌ {}", err));
463            }
464        }
465
466        if !self.warnings.is_empty() {
467            if !lines.is_empty() {
468                lines.push(String::new());
469            }
470            lines.push("## Warnings".to_string());
471            for warn in &self.warnings {
472                lines.push(format!("- ⚠️ {}", warn));
473            }
474        }
475
476        if self.is_valid && self.warnings.is_empty() {
477            lines.push("✅ All checks passed".to_string());
478        }
479
480        lines.join("\n")
481    }
482}
483
484/// Validate a PRD before ralph execution
485fn validate_prd(prd: &Prd) -> ValidationResult {
486    let mut errors = Vec::new();
487    let mut warnings = Vec::new();
488
489    // 1. Required fields
490    if prd.project.is_empty() {
491        errors.push("Missing required field: project".to_string());
492    }
493    if prd.feature.is_empty() {
494        errors.push("Missing required field: feature".to_string());
495    }
496    if prd.user_stories.is_empty() {
497        errors.push("PRD must have at least one user story".to_string());
498    }
499
500    // Collect all story IDs for reference checks
501    let story_ids: HashSet<String> = prd.user_stories.iter().map(|s| s.id.clone()).collect();
502
503    // 2. Story-level validation
504    let mut seen_ids = HashSet::new();
505    for story in &prd.user_stories {
506        // Unique IDs
507        if seen_ids.contains(&story.id) {
508            errors.push(format!("Duplicate story ID: {}", story.id));
509        }
510        seen_ids.insert(story.id.clone());
511
512        // ID format
513        if story.id.is_empty() {
514            errors.push("Story has empty ID".to_string());
515        }
516
517        // Title required
518        if story.title.is_empty() {
519            errors.push(format!("Story {} has empty title", story.id));
520        }
521
522        // Description required
523        if story.description.is_empty() {
524            warnings.push(format!("Story {} has empty description", story.id));
525        }
526
527        // Acceptance criteria
528        if story.acceptance_criteria.is_empty() {
529            warnings.push(format!("Story {} has no acceptance criteria", story.id));
530        }
531
532        // Priority range
533        if story.priority == 0 {
534            warnings.push(format!("Story {} has priority 0 (should be 1+)", story.id));
535        }
536
537        // Complexity range
538        if story.complexity == 0 || story.complexity > 5 {
539            warnings.push(format!(
540                "Story {} has complexity {} (should be 1-5)",
541                story.id, story.complexity
542            ));
543        }
544
545        // Dependencies reference valid stories
546        for dep in &story.depends_on {
547            if !story_ids.contains(dep) {
548                errors.push(format!(
549                    "Story {} depends on non-existent story: {}",
550                    story.id, dep
551                ));
552            }
553            if dep == &story.id {
554                errors.push(format!("Story {} depends on itself", story.id));
555            }
556        }
557    }
558
559    // 3. Check for circular dependencies
560    if let Some(cycle) = detect_cycle(prd) {
561        errors.push(format!(
562            "Circular dependency detected: {}",
563            cycle.join(" → ")
564        ));
565    }
566
567    // 4. Quality checks
568    let qc = &prd.quality_checks;
569    if qc.typecheck.is_none() && qc.test.is_none() && qc.lint.is_none() && qc.build.is_none() {
570        warnings.push("No quality checks defined - ralph won't verify work".to_string());
571    }
572
573    // 5. Branch name
574    if prd.branch_name.is_empty() {
575        warnings.push("No branch_name specified - will auto-generate from feature".to_string());
576    }
577
578    ValidationResult {
579        is_valid: errors.is_empty(),
580        errors,
581        warnings,
582    }
583}
584
585/// Detect circular dependencies using DFS
586fn detect_cycle(prd: &Prd) -> Option<Vec<String>> {
587    let story_map: std::collections::HashMap<&str, &UserStory> = prd
588        .user_stories
589        .iter()
590        .map(|s| (s.id.as_str(), s))
591        .collect();
592
593    fn dfs<'a>(
594        id: &'a str,
595        story_map: &std::collections::HashMap<&str, &'a UserStory>,
596        visiting: &mut HashSet<&'a str>,
597        visited: &mut HashSet<&'a str>,
598        path: &mut Vec<&'a str>,
599    ) -> Option<Vec<String>> {
600        if visiting.contains(id) {
601            // Found cycle - extract it from path
602            let start_idx = path.iter().position(|&x| x == id).unwrap_or(0);
603            let mut cycle: Vec<String> = path[start_idx..].iter().map(|s| s.to_string()).collect();
604            cycle.push(id.to_string());
605            return Some(cycle);
606        }
607
608        if visited.contains(id) {
609            return None;
610        }
611
612        visiting.insert(id);
613        path.push(id);
614
615        if let Some(story) = story_map.get(id) {
616            for dep in &story.depends_on {
617                if let Some(cycle) = dfs(dep.as_str(), story_map, visiting, visited, path) {
618                    return Some(cycle);
619                }
620            }
621        }
622
623        path.pop();
624        visiting.remove(id);
625        visited.insert(id);
626
627        None
628    }
629
630    let mut visited = HashSet::new();
631
632    for story in &prd.user_stories {
633        let mut visiting = HashSet::new();
634        let mut path = Vec::new();
635        if let Some(cycle) = dfs(
636            &story.id,
637            &story_map,
638            &mut visiting,
639            &mut visited,
640            &mut path,
641        ) {
642            return Some(cycle);
643        }
644    }
645
646    None
647}