Skip to main content

codetether_agent/tool/
prd.rs

1//! PRD Tool - Generate and validate PRD JSON
2//!
3//! This tool helps structure complex tasks into a PRD (Product Requirements
4//! Document) that can be executed 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, VerificationStep};
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
35    #[serde(default)]
36    task_description: Option<String>,
37
38    #[serde(default)]
39    project: Option<String>,
40
41    #[serde(default)]
42    feature: Option<String>,
43
44    #[serde(default)]
45    stories: Option<Vec<StoryInput>>,
46
47    #[serde(default)]
48    quality_checks: Option<QualityChecksInput>,
49
50    #[serde(default)]
51    prd_path: Option<String>,
52
53    /// Raw PRD JSON string (optional alternative to `prd_path`).
54    #[serde(default)]
55    prd_json: Option<String>,
56}
57
58#[derive(Deserialize)]
59struct StoryInput {
60    id: String,
61    title: String,
62    description: String,
63
64    #[serde(default)]
65    acceptance_criteria: Vec<String>,
66
67    /// Optional explicit story verification (BDD/TDD/E2E evidence).
68    #[serde(default)]
69    verification_steps: Vec<VerificationStep>,
70
71    #[serde(default)]
72    priority: Option<u8>,
73
74    #[serde(default)]
75    depends_on: Vec<String>,
76
77    #[serde(default)]
78    complexity: Option<u8>,
79}
80
81#[derive(Deserialize)]
82struct QualityChecksInput {
83    #[serde(default)]
84    typecheck: Option<String>,
85    #[serde(default)]
86    test: Option<String>,
87    #[serde(default)]
88    lint: Option<String>,
89    #[serde(default)]
90    build: Option<String>,
91}
92
93#[async_trait]
94impl Tool for PrdTool {
95    fn id(&self) -> &str {
96        "prd"
97    }
98
99    fn name(&self) -> &str {
100        "PRD Generator"
101    }
102
103    fn description(&self) -> &str {
104        r#"Generate and validate structured PRDs (Product Requirements Documents) for complex tasks.
105
106Actions:
107- analyze: Analyze a task description and return what questions need answering
108- generate: Generate a PRD JSON from provided answers
109- validate: Validate a PRD (schema, dependencies, etc.)
110- save: Save a PRD JSON to a file for ralph to execute
111"#
112    }
113
114    fn parameters(&self) -> Value {
115        // Keep `verification_steps` permissive; the enum is validated by serde when PRD JSON is parsed.
116        json!({
117            "type": "object",
118            "properties": {
119                "action": {
120                    "type": "string",
121                    "enum": ["analyze", "generate", "validate", "save"],
122                    "description": "Action to perform"
123                },
124                "task_description": {
125                    "type": "string",
126                    "description": "Description of the complex task (for analyze)"
127                },
128                "project": {
129                    "type": "string",
130                    "description": "Project name (for generate/save)"
131                },
132                "feature": {
133                    "type": "string",
134                    "description": "Feature name (for generate/save)"
135                },
136                "stories": {
137                    "type": "array",
138                    "description": "User stories (for generate/validate/save)",
139                    "items": {
140                        "type": "object",
141                        "properties": {
142                            "id": {"type": "string"},
143                            "title": {"type": "string"},
144                            "description": {"type": "string"},
145                            "acceptance_criteria": {"type": "array", "items": {"type": "string"}},
146                            "verification_steps": {"type": "array", "items": {"type": "object"}},
147                            "priority": {"type": "integer"},
148                            "depends_on": {"type": "array", "items": {"type": "string"}},
149                            "complexity": {"type": "integer"}
150                        },
151                        "required": ["id", "title", "description"]
152                    }
153                },
154                "quality_checks": {
155                    "type": "object",
156                    "properties": {
157                        "typecheck": {"type": "string"},
158                        "test": {"type": "string"},
159                        "lint": {"type": "string"},
160                        "build": {"type": "string"}
161                    }
162                },
163                "prd_path": {
164                    "type": "string",
165                    "description": "Path to PRD file (validate from file, or save destination)"
166                },
167                "prd_json": {
168                    "type": "string",
169                    "description": "Raw PRD JSON string to validate (alternative to prd_path)"
170                }
171            },
172            "required": ["action"]
173        })
174    }
175
176    async fn execute(&self, params: Value) -> Result<ToolResult> {
177        let p: Params = serde_json::from_value(params).context("Invalid params")?;
178
179        match p.action.as_str() {
180            "analyze" => {
181                let task = p.task_description.unwrap_or_default();
182                if task.is_empty() {
183                    return Ok(ToolResult::structured_error(
184                        "missing_field",
185                        self.id(),
186                        "task_description is required for analyze",
187                        Some(vec!["task_description"]),
188                        Some(json!({"action":"analyze","task_description":"..."})),
189                    ));
190                }
191
192                const VERIFICATION_EXAMPLE_JSON: &str = r#"{
193  \"verification_steps\": [
194    {
195      \"type\": \"shell\",
196      \"name\": \"cypress_e2e\",
197      \"command\": \"npx cypress run\",
198      \"expect_files_glob\": [\"cypress/videos/**/*.mp4\"]
199    },
200    {
201      \"type\": \"url\",
202      \"name\": \"deployment_live\",
203      \"url\": \"https://example.com/health\",
204      \"expect_status\": 200,
205      \"expect_body_contains\": [\"version:1.2.3\"],
206      \"timeout_secs\": 30
207    }
208  ]
209}"#;
210
211                let mut questions = String::new();
212                questions.push_str("# Task Analysis\n\n");
213                questions.push_str("## Task Description\n");
214                questions.push_str(&task);
215                questions.push_str("\n\n");
216                questions.push_str("## Questions to Answer\n\n");
217                questions.push_str("1. **Project Name**\n");
218                questions.push_str("2. **Feature Name**\n");
219                questions.push_str(
220                    "3. **User Stories**: break down the task into discrete, independently verifiable stories.\n",
221                );
222                questions.push_str("   For each story:\n");
223                questions.push_str("   - ID (e.g., US-001)\n");
224                questions.push_str("   - Title\n");
225                questions.push_str("   - Description\n");
226                questions.push_str("   - Acceptance Criteria\n");
227                questions.push_str(
228                    "   - Verification Steps (machine-verifiable evidence; BDD/TDD/E2E/artifacts/URLs)\n",
229                );
230                questions.push_str("   - Priority (1=highest)\n");
231                questions.push_str("   - Dependencies\n");
232                questions.push_str("   - Complexity (1-5)\n\n");
233                questions.push_str("4. **Quality Checks** (repo-level gates; optional)\n\n");
234
235                questions.push_str("### Example story verification steps\n\n");
236                questions.push_str("```json\n");
237                questions.push_str(VERIFICATION_EXAMPLE_JSON);
238                questions.push_str("\n```\n");
239
240                Ok(ToolResult::success(questions))
241            }
242
243            "generate" => {
244                let project = p.project.unwrap_or_else(|| "Project".to_string());
245                let feature = p.feature.unwrap_or_else(|| "Feature".to_string());
246
247                let stories: Vec<UserStory> = p
248                    .stories
249                    .unwrap_or_default()
250                    .into_iter()
251                    .map(|s| UserStory {
252                        id: s.id,
253                        title: s.title,
254                        description: s.description,
255                        acceptance_criteria: s.acceptance_criteria,
256                        verification_steps: s.verification_steps,
257                        passes: false,
258                        priority: s.priority.unwrap_or(1),
259                        depends_on: s.depends_on,
260                        complexity: s.complexity.unwrap_or(3),
261                    })
262                    .collect();
263
264                if stories.is_empty() {
265                    return Ok(ToolResult::structured_error(
266                        "missing_field",
267                        self.id(),
268                        "At least one story is required",
269                        Some(vec!["stories"]),
270                        None,
271                    ));
272                }
273
274                let quality_checks = match p.quality_checks {
275                    Some(qc) => QualityChecks {
276                        typecheck: qc.typecheck,
277                        test: qc.test,
278                        lint: qc.lint,
279                        build: qc.build,
280                    },
281                    None => QualityChecks::default(),
282                };
283
284                let prd = Prd {
285                    project: project.clone(),
286                    feature: feature.clone(),
287                    branch_name: format!("feature/{}", feature.to_lowercase().replace(' ', "-")),
288                    version: "1.0".to_string(),
289                    user_stories: stories,
290                    technical_requirements: Vec::new(),
291                    quality_checks,
292                    created_at: chrono::Utc::now().to_rfc3339(),
293                    updated_at: chrono::Utc::now().to_rfc3339(),
294                };
295
296                let json_str = serde_json::to_string_pretty(&prd)?;
297                Ok(
298                    ToolResult::success(format!("# Generated PRD\n\n```json\n{}\n```\n", json_str))
299                        .with_metadata("prd", serde_json::to_value(&prd)?),
300                )
301            }
302
303            "validate" => {
304                let prd: Prd = if let Some(json_str) = p.prd_json {
305                    serde_json::from_str(&json_str)
306                        .context("Failed to parse prd_json - invalid JSON")?
307                } else if let Some(path) = &p.prd_path {
308                    let prd_path = PathBuf::from(path);
309                    if !prd_path.exists() {
310                        return Ok(ToolResult::error(format!("PRD file not found: {path}")));
311                    }
312                    let content = tokio::fs::read_to_string(&prd_path).await?;
313                    serde_json::from_str(&content)
314                        .context("Failed to parse PRD file - invalid JSON")?
315                } else if let Some(stories) = p.stories {
316                    let project = p.project.clone().unwrap_or_else(|| "Project".to_string());
317                    let feature = p.feature.clone().unwrap_or_else(|| "Feature".to_string());
318
319                    let stories: Vec<UserStory> = stories
320                        .into_iter()
321                        .map(|s| UserStory {
322                            id: s.id,
323                            title: s.title,
324                            description: s.description,
325                            acceptance_criteria: s.acceptance_criteria,
326                            verification_steps: s.verification_steps,
327                            passes: false,
328                            priority: s.priority.unwrap_or(1),
329                            depends_on: s.depends_on,
330                            complexity: s.complexity.unwrap_or(3),
331                        })
332                        .collect();
333
334                    Prd {
335                        project,
336                        feature,
337                        branch_name: String::new(),
338                        version: "1.0".to_string(),
339                        user_stories: stories,
340                        technical_requirements: Vec::new(),
341                        quality_checks: QualityChecks::default(),
342                        created_at: String::new(),
343                        updated_at: String::new(),
344                    }
345                } else {
346                    return Ok(ToolResult::structured_error(
347                        "missing_field",
348                        self.id(),
349                        "validate requires one of: prd_json, prd_path, or stories",
350                        Some(vec!["prd_json|prd_path|stories"]),
351                        None,
352                    ));
353                };
354
355                let validation = validate_prd(&prd);
356
357                if validation.is_valid {
358                    Ok(ToolResult::success(format!(
359                        "# PRD Validation: PASSED\n\nProject: {}\nFeature: {}\nStories: {}\nExecution stages: {}\n\n{}",
360                        prd.project,
361                        prd.feature,
362                        prd.user_stories.len(),
363                        prd.stages().len(),
364                        validation.summary()
365                    )))
366                } else {
367                    Ok(ToolResult::error(format!(
368                        "# PRD Validation: FAILED\n\n{}",
369                        validation.summary()
370                    )))
371                }
372            }
373
374            "save" => {
375                let project = p.project.unwrap_or_else(|| "Project".to_string());
376                let feature = p.feature.unwrap_or_else(|| "Feature".to_string());
377                let prd_path = PathBuf::from(p.prd_path.unwrap_or_else(|| "prd.json".to_string()));
378
379                let stories: Vec<UserStory> = p
380                    .stories
381                    .unwrap_or_default()
382                    .into_iter()
383                    .map(|s| UserStory {
384                        id: s.id,
385                        title: s.title,
386                        description: s.description,
387                        acceptance_criteria: s.acceptance_criteria,
388                        verification_steps: s.verification_steps,
389                        passes: false,
390                        priority: s.priority.unwrap_or(1),
391                        depends_on: s.depends_on,
392                        complexity: s.complexity.unwrap_or(3),
393                    })
394                    .collect();
395
396                if stories.is_empty() {
397                    return Ok(ToolResult::structured_error(
398                        "missing_field",
399                        self.id(),
400                        "At least one story is required for save",
401                        Some(vec!["stories"]),
402                        None,
403                    ));
404                }
405
406                let quality_checks = match p.quality_checks {
407                    Some(qc) => QualityChecks {
408                        typecheck: qc.typecheck,
409                        test: qc.test,
410                        lint: qc.lint,
411                        build: qc.build,
412                    },
413                    None => QualityChecks::default(),
414                };
415
416                let prd = Prd {
417                    project: project.clone(),
418                    feature: feature.clone(),
419                    branch_name: format!("feature/{}", feature.to_lowercase().replace(' ', "-")),
420                    version: "1.0".to_string(),
421                    user_stories: stories,
422                    technical_requirements: Vec::new(),
423                    quality_checks,
424                    created_at: chrono::Utc::now().to_rfc3339(),
425                    updated_at: chrono::Utc::now().to_rfc3339(),
426                };
427
428                prd.save(&prd_path).await.context("Failed to save PRD")?;
429                Ok(ToolResult::success(format!(
430                    "PRD saved to: {}",
431                    prd_path.display()
432                )))
433            }
434
435            _ => Ok(ToolResult::error(format!(
436                "Unknown action: {}. Use analyze/generate/validate/save",
437                p.action
438            ))),
439        }
440    }
441}
442
443struct ValidationResult {
444    is_valid: bool,
445    errors: Vec<String>,
446    warnings: Vec<String>,
447}
448
449impl ValidationResult {
450    fn summary(&self) -> String {
451        let mut lines = Vec::new();
452
453        if !self.errors.is_empty() {
454            lines.push("## Errors".to_string());
455            for err in &self.errors {
456                lines.push(format!("- ❌ {err}"));
457            }
458        }
459
460        if !self.warnings.is_empty() {
461            if !lines.is_empty() {
462                lines.push(String::new());
463            }
464            lines.push("## Warnings".to_string());
465            for warn in &self.warnings {
466                lines.push(format!("- ⚠️ {warn}"));
467            }
468        }
469
470        if self.is_valid && self.warnings.is_empty() {
471            lines.push("✅ All checks passed".to_string());
472        }
473
474        lines.join("\n")
475    }
476}
477
478fn validate_prd(prd: &Prd) -> ValidationResult {
479    let mut errors = Vec::new();
480    let mut warnings = Vec::new();
481
482    if prd.project.is_empty() {
483        errors.push("Missing required field: project".to_string());
484    }
485    if prd.feature.is_empty() {
486        errors.push("Missing required field: feature".to_string());
487    }
488    if prd.user_stories.is_empty() {
489        errors.push("PRD must have at least one user story".to_string());
490    }
491
492    let story_ids: HashSet<String> = prd.user_stories.iter().map(|s| s.id.clone()).collect();
493
494    let mut seen_ids = HashSet::new();
495    for story in &prd.user_stories {
496        if seen_ids.contains(&story.id) {
497            errors.push(format!("Duplicate story ID: {}", story.id));
498        }
499        seen_ids.insert(story.id.clone());
500
501        if story.id.is_empty() {
502            errors.push("Story has empty ID".to_string());
503        }
504        if story.title.is_empty() {
505            errors.push(format!("Story {} has empty title", story.id));
506        }
507        if story.description.is_empty() {
508            warnings.push(format!("Story {} has empty description", story.id));
509        }
510        if story.acceptance_criteria.is_empty() {
511            warnings.push(format!("Story {} has no acceptance criteria", story.id));
512        }
513        if story.verification_steps.is_empty() {
514            warnings.push(format!(
515                "Story {} has no verification_steps; it may pass on quality checks alone",
516                story.id
517            ));
518        }
519
520        if story.priority == 0 {
521            warnings.push(format!("Story {} has priority 0 (should be 1+)", story.id));
522        }
523        if story.complexity == 0 || story.complexity > 5 {
524            warnings.push(format!(
525                "Story {} has complexity {} (should be 1-5)",
526                story.id, story.complexity
527            ));
528        }
529
530        for dep in &story.depends_on {
531            if !story_ids.contains(dep) {
532                errors.push(format!(
533                    "Story {} depends on non-existent story: {}",
534                    story.id, dep
535                ));
536            }
537            if dep == &story.id {
538                errors.push(format!("Story {} depends on itself", story.id));
539            }
540        }
541    }
542
543    if let Some(cycle) = detect_cycle(prd) {
544        errors.push(format!(
545            "Circular dependency detected: {}",
546            cycle.join(" → ")
547        ));
548    }
549
550    let qc = &prd.quality_checks;
551    if qc.typecheck.is_none() && qc.test.is_none() && qc.lint.is_none() && qc.build.is_none() {
552        warnings.push("No quality checks defined - ralph won't run repo-level gates".to_string());
553    }
554
555    ValidationResult {
556        is_valid: errors.is_empty(),
557        errors,
558        warnings,
559    }
560}
561
562fn detect_cycle(prd: &Prd) -> Option<Vec<String>> {
563    let story_map: std::collections::HashMap<&str, &UserStory> = prd
564        .user_stories
565        .iter()
566        .map(|s| (s.id.as_str(), s))
567        .collect();
568
569    fn dfs<'a>(
570        id: &'a str,
571        story_map: &std::collections::HashMap<&str, &'a UserStory>,
572        visiting: &mut HashSet<&'a str>,
573        visited: &mut HashSet<&'a str>,
574        path: &mut Vec<&'a str>,
575    ) -> Option<Vec<String>> {
576        if visiting.contains(id) {
577            let start_idx = path.iter().position(|&x| x == id).unwrap_or(0);
578            let mut cycle: Vec<String> = path[start_idx..].iter().map(|s| s.to_string()).collect();
579            cycle.push(id.to_string());
580            return Some(cycle);
581        }
582
583        if visited.contains(id) {
584            return None;
585        }
586
587        visiting.insert(id);
588        path.push(id);
589
590        if let Some(story) = story_map.get(id) {
591            for dep in &story.depends_on {
592                if let Some(cycle) = dfs(dep.as_str(), story_map, visiting, visited, path) {
593                    return Some(cycle);
594                }
595            }
596        }
597
598        path.pop();
599        visiting.remove(id);
600        visited.insert(id);
601        None
602    }
603
604    let mut visited = HashSet::new();
605    let mut visiting = HashSet::new();
606    let mut path = Vec::new();
607
608    for story in &prd.user_stories {
609        if let Some(cycle) = dfs(
610            story.id.as_str(),
611            &story_map,
612            &mut visiting,
613            &mut visited,
614            &mut path,
615        ) {
616            return Some(cycle);
617        }
618    }
619
620    None
621}