Skip to main content

scud/commands/
generate.rs

1//! Generate command - combines parse, expand, and check-deps into a single pipeline.
2
3use anyhow::{Context, Result};
4use colored::Colorize;
5use serde::Deserialize;
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9use crate::attractor::interviewer::{ConsoleInterviewer, Interviewer, Question};
10use crate::commands::{ai, check_deps};
11use crate::formats::{
12    serialize_scg_pipeline, PipelineNodeAttrs, ScgEdgeAttrs, ScgParseResult, ScgPipeline,
13};
14use crate::llm::{LLMClient, Prompts};
15use crate::models::{Phase, Priority, Task, TaskStatus};
16
17/// Options for the task generation pipeline.
18///
19/// This struct configures the multi-phase task generation process:
20/// 1. **Parse**: Convert a PRD document into initial tasks
21/// 2. **Expand**: Break down complex tasks into subtasks
22/// 3. **Check Dependencies**: Validate and fix task dependencies
23///
24/// # Example
25///
26/// ```no_run
27/// use scud::commands::generate::{generate, GenerateOptions};
28/// use std::path::PathBuf;
29///
30/// #[tokio::main]
31/// async fn main() -> anyhow::Result<()> {
32///     let options = GenerateOptions::new(
33///         PathBuf::from("docs/prd.md"),
34///         "my-feature".to_string(),
35///     );
36///
37///     generate(options).await?;
38///     Ok(())
39/// }
40/// ```
41#[derive(Debug, Clone)]
42pub struct GenerateOptions {
43    /// Project root directory (None for current directory)
44    pub project_root: Option<PathBuf>,
45    /// Path to the PRD/spec document to parse
46    pub file: PathBuf,
47    /// Tag name for generated tasks
48    pub tag: String,
49    /// Number of tasks to generate (default: 10)
50    pub num_tasks: u32,
51    /// Skip task expansion phase
52    pub no_expand: bool,
53    /// Skip dependency validation phase
54    pub no_check_deps: bool,
55    /// Append tasks to existing tag instead of replacing
56    pub append: bool,
57    /// Skip loading guidance from .scud/guidance/
58    pub no_guidance: bool,
59    /// Task ID format: "sequential" (default) or "uuid"
60    pub id_format: String,
61    /// Model to use for AI operations (overrides config)
62    pub model: Option<String>,
63    /// Show what would be done without making changes
64    pub dry_run: bool,
65    /// Verbose output showing each phase's details
66    pub verbose: bool,
67}
68
69impl GenerateOptions {
70    /// Create new options with required fields and sensible defaults.
71    ///
72    /// # Arguments
73    ///
74    /// * `file` - Path to the PRD/spec document
75    /// * `tag` - Tag name for the generated tasks
76    pub fn new(file: PathBuf, tag: String) -> Self {
77        Self {
78            project_root: None,
79            file,
80            tag,
81            num_tasks: 10,
82            no_expand: false,
83            no_check_deps: false,
84            append: false,
85            no_guidance: false,
86            id_format: "sequential".to_string(),
87            model: None,
88            dry_run: false,
89            verbose: false,
90        }
91    }
92}
93
94impl Default for GenerateOptions {
95    fn default() -> Self {
96        Self {
97            project_root: None,
98            file: PathBuf::new(),
99            tag: String::new(),
100            num_tasks: 10,
101            no_expand: false,
102            no_check_deps: false,
103            append: false,
104            no_guidance: false,
105            id_format: "sequential".to_string(),
106            model: None,
107            dry_run: false,
108            verbose: false,
109        }
110    }
111}
112
113/// Run the task generation pipeline with the given options.
114///
115/// This is the main entry point for programmatic task generation.
116/// It orchestrates the parse → expand → check-deps pipeline.
117///
118/// # Example
119///
120/// ```no_run
121/// use scud::commands::generate::{generate, GenerateOptions};
122/// use std::path::PathBuf;
123///
124/// #[tokio::main]
125/// async fn main() -> anyhow::Result<()> {
126///     let mut options = GenerateOptions::new(
127///         PathBuf::from("requirements.md"),
128///         "api".to_string(),
129///     );
130///     options.num_tasks = 15;
131///     options.verbose = true;
132///
133///     generate(options).await?;
134///     Ok(())
135/// }
136/// ```
137pub async fn generate(options: GenerateOptions) -> Result<()> {
138    run(
139        options.project_root,
140        &options.file,
141        &options.tag,
142        options.num_tasks,
143        options.no_expand,
144        options.no_check_deps,
145        options.append,
146        options.no_guidance,
147        &options.id_format,
148        options.model.as_deref(),
149        options.dry_run,
150        options.verbose,
151    )
152    .await
153}
154
155/// Run the generate pipeline: parse PRD → expand tasks → validate dependencies
156///
157/// This is the internal implementation used by the CLI. For programmatic usage,
158/// prefer the [`generate`] function with [`GenerateOptions`].
159#[allow(clippy::too_many_arguments)]
160pub async fn run(
161    project_root: Option<PathBuf>,
162    file: &Path,
163    tag: &str,
164    num_tasks: u32,
165    no_expand: bool,
166    no_check_deps: bool,
167    append: bool,
168    no_guidance: bool,
169    id_format: &str,
170    model: Option<&str>,
171    dry_run: bool,
172    verbose: bool,
173) -> Result<()> {
174    println!("{}", "━".repeat(50).blue());
175    println!(
176        "{} {}",
177        "Generate Pipeline".blue().bold(),
178        format!("(tag: {})", tag).cyan()
179    );
180    println!("{}", "━".repeat(50).blue());
181    println!();
182
183    if dry_run {
184        println!("{} Dry run mode - no changes will be made", "ℹ".blue());
185        println!();
186    }
187
188    // ═══════════════════════════════════════════════════════════════════════
189    // Phase 1: Parse PRD into tasks
190    // ═══════════════════════════════════════════════════════════════════════
191    println!("{} Parsing PRD into tasks...", "Phase 1:".yellow().bold());
192
193    if dry_run {
194        println!(
195            "  {} Would parse {} into tag '{}'",
196            "→".cyan(),
197            file.display(),
198            tag
199        );
200        println!(
201            "  {} Would create ~{} tasks (append: {})",
202            "→".cyan(),
203            num_tasks,
204            append
205        );
206    } else {
207        ai::parse_prd::run(
208            project_root.clone(),
209            file,
210            tag,
211            num_tasks,
212            append,
213            no_guidance,
214            id_format,
215            model,
216        )
217        .await?;
218    }
219
220    if verbose {
221        println!("  {} Parse phase completed", "✓".green());
222    }
223    println!();
224
225    // ═══════════════════════════════════════════════════════════════════════
226    // Phase 2: Expand complex tasks into subtasks
227    // ═══════════════════════════════════════════════════════════════════════
228    if no_expand {
229        println!(
230            "{} Skipping expansion {}",
231            "Phase 2:".yellow().bold(),
232            "(--no-expand)".dimmed()
233        );
234    } else {
235        println!(
236            "{} Expanding complex tasks into subtasks...",
237            "Phase 2:".yellow().bold()
238        );
239
240        if dry_run {
241            println!(
242                "  {} Would expand tasks with complexity >= 5 in tag '{}'",
243                "→".cyan(),
244                tag
245            );
246        } else {
247            ai::expand::run(
248                project_root.clone(),
249                None,      // task_id - expand all
250                false,     // all_tags - only current tag
251                Some(tag), // tag
252                no_guidance,
253                model,
254            )
255            .await?;
256        }
257
258        if verbose {
259            println!("  {} Expand phase completed", "✓".green());
260        }
261    }
262    println!();
263
264    // ═══════════════════════════════════════════════════════════════════════
265    // Phase 3: Validate dependencies
266    // ═══════════════════════════════════════════════════════════════════════
267    if no_check_deps {
268        println!(
269            "{} Skipping dependency validation {}",
270            "Phase 3:".yellow().bold(),
271            "(--no-check-deps)".dimmed()
272        );
273    } else {
274        println!(
275            "{} Validating task dependencies...",
276            "Phase 3:".yellow().bold()
277        );
278
279        if dry_run {
280            println!(
281                "  {} Would validate dependencies in tag '{}' against PRD",
282                "→".cyan(),
283                tag
284            );
285            println!(
286                "  {} Would auto-fix issues including agent type assignments",
287                "→".cyan()
288            );
289        } else {
290            // Run check-deps with PRD validation and fix mode enabled
291            // This validates against the PRD and auto-fixes issues including agent assignments
292            let check_result = check_deps::run(
293                project_root.clone(),
294                Some(tag),  // tag
295                false,      // all_tags
296                Some(file), // prd_file - validate against PRD
297                true,       // fix - auto-fix issues
298                model,
299            )
300            .await;
301
302            // Log but don't fail the pipeline on dep issues
303            if let Err(e) = check_result {
304                println!(
305                    "  {} Dependency check encountered issues: {}",
306                    "⚠".yellow(),
307                    e
308                );
309                println!(
310                    "  {} Run '{}' to see details",
311                    "ℹ".blue(),
312                    "scud check-deps".green()
313                );
314            }
315        }
316
317        if verbose {
318            println!("  {} Check-deps phase completed", "✓".green());
319        }
320    }
321    println!();
322
323    // ═══════════════════════════════════════════════════════════════════════
324    // Summary
325    // ═══════════════════════════════════════════════════════════════════════
326    println!("{}", "━".repeat(50).green());
327    println!("{}", "✅ Generate pipeline complete!".green().bold());
328    println!("{}", "━".repeat(50).green());
329    println!();
330
331    if dry_run {
332        println!("{}", "Dry run - no changes were made.".yellow());
333        println!("Run without --dry-run to execute the pipeline.");
334    } else {
335        println!("{}", "Next steps:".blue());
336        println!("  1. Review tasks: scud list --tag {}", tag);
337        println!("  2. View execution waves: scud waves --tag {}", tag);
338        println!("  3. Start working: scud next --tag {}", tag);
339    }
340    println!();
341
342    Ok(())
343}
344
345// ═══════════════════════════════════════════════════════════════════════════
346// Pipeline generation from PRD
347// ═══════════════════════════════════════════════════════════════════════════
348
349#[derive(Debug, Deserialize)]
350struct ParsedPipeline {
351    name: String,
352    goal: String,
353    model_stylesheet: Option<String>,
354    nodes: Vec<ParsedNode>,
355    edges: Vec<ParsedEdge>,
356}
357
358#[derive(Debug, Deserialize)]
359struct ParsedNode {
360    id: String,
361    title: String,
362    handler_type: String,
363    #[serde(default)]
364    max_retries: u32,
365    #[serde(default)]
366    goal_gate: bool,
367    retry_target: Option<String>,
368    timeout: Option<String>,
369    prompt: Option<String>,
370    tool_command: Option<String>,
371}
372
373#[derive(Debug, Deserialize)]
374struct ParsedEdge {
375    from: String,
376    to: String,
377    #[serde(default)]
378    label: Option<String>,
379    #[serde(default)]
380    condition: Option<String>,
381    #[serde(default)]
382    weight: i32,
383}
384
385/// Convert a ParsedPipeline into an ScgParseResult ready for serialization.
386fn parsed_pipeline_to_scg(parsed: &ParsedPipeline, tag: &str) -> ScgParseResult {
387    let mut phase = Phase::new(tag.to_string());
388
389    let mut node_attrs = HashMap::new();
390
391    for node in &parsed.nodes {
392        // Map prompt to description, tool_command to details
393        let description = node.prompt.clone().unwrap_or_default();
394        let details = node.tool_command.clone();
395
396        let complexity = match node.handler_type.as_str() {
397            "start" | "exit" => 0,
398            "wait.human" => 0,
399            "tool" => 3,
400            "codergen" => 5,
401            _ => 0,
402        };
403
404        let priority = match node.handler_type.as_str() {
405            "codergen" => Priority::High,
406            _ => Priority::Medium,
407        };
408
409        let task = Task {
410            id: node.id.clone(),
411            title: node.title.clone(),
412            description,
413            status: TaskStatus::Pending,
414            complexity,
415            priority,
416            dependencies: Vec::new(),
417            parent_id: None,
418            subtasks: Vec::new(),
419            details,
420            test_strategy: None,
421            created_at: None,
422            updated_at: None,
423            assigned_to: None,
424            agent_type: None,
425        };
426        phase.tasks.push(task);
427
428        node_attrs.insert(
429            node.id.clone(),
430            PipelineNodeAttrs {
431                handler_type: node.handler_type.clone(),
432                max_retries: node.max_retries,
433                retry_target: node.retry_target.clone(),
434                goal_gate: node.goal_gate,
435                timeout: node.timeout.clone(),
436            },
437        );
438    }
439
440    let edge_attrs: Vec<ScgEdgeAttrs> = parsed
441        .edges
442        .iter()
443        .map(|e| ScgEdgeAttrs {
444            from: e.from.clone(),
445            to: e.to.clone(),
446            label: e.label.clone().unwrap_or_default(),
447            condition: e.condition.clone().unwrap_or_default(),
448            weight: e.weight,
449        })
450        .collect();
451
452    ScgParseResult {
453        phase,
454        pipeline: Some(ScgPipeline {
455            goal: Some(parsed.goal.clone()),
456            model_stylesheet: parsed.model_stylesheet.clone(),
457            node_attrs,
458            edge_attrs,
459        }),
460    }
461}
462
463/// Run interview questions and return (goal, shape, human_checkpoints, tool_steps, model_tier).
464async fn run_interview(
465    interviewer: &dyn Interviewer,
466    prd_first_line: &str,
467) -> Result<(String, String, String, String, String)> {
468    // Q1: Goal
469    let goal_answer = interviewer
470        .ask(Question {
471            text: format!(
472                "What is the high-level goal for this pipeline? (default: {})",
473                prd_first_line
474            ),
475            choices: vec![],
476            default: None,
477        })
478        .await;
479    let goal = if goal_answer.text.is_empty() {
480        prd_first_line.to_string()
481    } else {
482        goal_answer.text
483    };
484
485    // Q2: Workflow shape
486    let shape_answer = interviewer
487        .ask(Question {
488            text: "What workflow shape best describes this pipeline?".to_string(),
489            choices: vec![
490                "Linear (A->B->C->done)".to_string(),
491                "Branching with human review gates".to_string(),
492                "Iterative with test-fix loops".to_string(),
493                "Custom (describe in PRD)".to_string(),
494            ],
495            default: Some(0),
496        })
497        .await;
498
499    // Q3: Human checkpoints
500    let human_answer = interviewer
501        .ask(Question {
502            text: "Include human review gates?".to_string(),
503            choices: vec![
504                "Yes, include human review gates".to_string(),
505                "No, fully automated".to_string(),
506            ],
507            default: Some(0),
508        })
509        .await;
510
511    // Q4: Tool steps
512    let tool_answer = interviewer
513        .ask(Question {
514            text: "Any shell commands to include? (e.g., 'cargo test', 'npm run build') - leave empty for none".to_string(),
515            choices: vec![],
516            default: None,
517        })
518        .await;
519
520    // Q5: Model tier
521    let model_answer = interviewer
522        .ask(Question {
523            text: "Which model tier for LLM steps?".to_string(),
524            choices: vec![
525                "Fast (Haiku - cheap, quick)".to_string(),
526                "Balanced (Sonnet - recommended)".to_string(),
527                "Powerful (Opus - best quality)".to_string(),
528            ],
529            default: Some(1),
530        })
531        .await;
532
533    Ok((
534        goal,
535        shape_answer.text,
536        human_answer.text,
537        tool_answer.text,
538        model_answer.text,
539    ))
540}
541
542/// Generate an Attractor pipeline SCG from a PRD document.
543#[allow(clippy::too_many_arguments)]
544pub async fn run_pipeline(
545    project_root: Option<PathBuf>,
546    file: &Path,
547    tag: &str,
548    model: Option<&str>,
549    output: Option<PathBuf>,
550    dry_run: bool,
551    verbose: bool,
552) -> Result<()> {
553    println!("{}", "━".repeat(50).blue());
554    println!(
555        "{} {}",
556        "Generate Attractor Pipeline".blue().bold(),
557        format!("(tag: {})", tag).cyan()
558    );
559    println!("{}", "━".repeat(50).blue());
560    println!();
561
562    // Read PRD
563    let prd_content =
564        std::fs::read_to_string(file).with_context(|| format!("reading PRD: {}", file.display()))?;
565
566    let prd_first_line = prd_content
567        .lines()
568        .find(|l| !l.trim().is_empty() && !l.starts_with('#'))
569        .or_else(|| {
570            prd_content
571                .lines()
572                .find(|l| !l.trim().is_empty())
573                .map(|l| l.trim_start_matches('#').trim())
574        })
575        .unwrap_or("Build something great");
576
577    // Interview
578    println!("{} Interview", "Phase 1:".yellow().bold());
579    let interviewer = ConsoleInterviewer;
580    let (goal, shape, human_checkpoints, tool_steps, model_tier) =
581        run_interview(&interviewer, prd_first_line).await?;
582
583    if verbose {
584        println!();
585        println!("  {} Goal: {}", "→".cyan(), goal);
586        println!("  {} Shape: {}", "→".cyan(), shape);
587        println!("  {} Human gates: {}", "→".cyan(), human_checkpoints);
588        println!("  {} Tools: {}", "→".cyan(), if tool_steps.is_empty() { "(none)" } else { &tool_steps });
589        println!("  {} Model: {}", "→".cyan(), model_tier);
590    }
591    println!();
592
593    if dry_run {
594        println!("{} Would generate pipeline with LLM...", "Phase 2:".yellow().bold());
595        println!(
596            "  {} Would write to: {}",
597            "→".cyan(),
598            output
599                .as_deref()
600                .unwrap_or_else(|| Path::new(".scud/tasks/tasks.scg"))
601                .display()
602        );
603        println!();
604        println!("{}", "Dry run - no changes were made.".yellow());
605        return Ok(());
606    }
607
608    // LLM generation
609    println!(
610        "{} Generating pipeline via LLM...",
611        "Phase 2:".yellow().bold()
612    );
613
614    let prompt = Prompts::generate_pipeline(
615        &prd_content,
616        &goal,
617        &shape,
618        &human_checkpoints,
619        &tool_steps,
620        &model_tier,
621    );
622
623    let client = if let Some(ref root) = project_root {
624        LLMClient::new_with_project_root(root.clone())?
625    } else {
626        LLMClient::new()?
627    };
628    let parsed: ParsedPipeline = client.complete_json_smart(&prompt, model).await?;
629
630    if verbose {
631        println!(
632            "  {} Generated {} nodes, {} edges",
633            "✓".green(),
634            parsed.nodes.len(),
635            parsed.edges.len()
636        );
637    }
638    println!();
639
640    // Convert to SCG
641    println!(
642        "{} Converting to SCG format...",
643        "Phase 3:".yellow().bold()
644    );
645
646    let result = parsed_pipeline_to_scg(&parsed, tag);
647    let scg_output = serialize_scg_pipeline(&result);
648
649    // Determine output path
650    let output_path = output.unwrap_or_else(|| {
651        let root = project_root.unwrap_or_else(|| PathBuf::from("."));
652        root.join(".scud/tasks/tasks.scg")
653    });
654
655    // Ensure parent directory exists
656    if let Some(parent) = output_path.parent() {
657        std::fs::create_dir_all(parent)
658            .with_context(|| format!("creating directory: {}", parent.display()))?;
659    }
660
661    std::fs::write(&output_path, &scg_output)
662        .with_context(|| format!("writing pipeline: {}", output_path.display()))?;
663
664    if verbose {
665        println!("  {} Written to {}", "✓".green(), output_path.display());
666    }
667    println!();
668
669    // Summary
670    println!("{}", "━".repeat(50).green());
671    println!(
672        "{}",
673        "Pipeline generated successfully!".green().bold()
674    );
675    println!("{}", "━".repeat(50).green());
676    println!();
677    println!(
678        "  {} {} nodes, {} edges",
679        "→".cyan(),
680        result.phase.tasks.len(),
681        result
682            .pipeline
683            .as_ref()
684            .map(|p| p.edge_attrs.len())
685            .unwrap_or(0)
686    );
687    println!("  {} Output: {}", "→".cyan(), output_path.display());
688    println!();
689    println!("{}", "Next steps:".blue());
690    println!(
691        "  1. Validate: scud attractor validate {}",
692        output_path.display()
693    );
694    println!(
695        "  2. Run: scud attractor run {}",
696        output_path.display()
697    );
698    println!();
699
700    Ok(())
701}
702
703#[cfg(test)]
704mod pipeline_tests {
705    use super::*;
706    use crate::formats::parse_scg_result;
707
708    fn sample_parsed_pipeline() -> ParsedPipeline {
709        ParsedPipeline {
710            name: "test-pipe".to_string(),
711            goal: "Build something".to_string(),
712            model_stylesheet: Some(
713                r#"* { model: "claude-3-haiku"; reasoning_effort: "medium" }"#.to_string(),
714            ),
715            nodes: vec![
716                ParsedNode {
717                    id: "start".to_string(),
718                    title: "Start".to_string(),
719                    handler_type: "start".to_string(),
720                    max_retries: 0,
721                    goal_gate: false,
722                    retry_target: None,
723                    timeout: None,
724                    prompt: None,
725                    tool_command: None,
726                },
727                ParsedNode {
728                    id: "design".to_string(),
729                    title: "Design API".to_string(),
730                    handler_type: "codergen".to_string(),
731                    max_retries: 3,
732                    goal_gate: false,
733                    retry_target: None,
734                    timeout: None,
735                    prompt: Some("Design the REST API schema".to_string()),
736                    tool_command: None,
737                },
738                ParsedNode {
739                    id: "test".to_string(),
740                    title: "Run Tests".to_string(),
741                    handler_type: "tool".to_string(),
742                    max_retries: 0,
743                    goal_gate: false,
744                    retry_target: None,
745                    timeout: None,
746                    prompt: None,
747                    tool_command: Some("cargo test".to_string()),
748                },
749                ParsedNode {
750                    id: "finish".to_string(),
751                    title: "Done".to_string(),
752                    handler_type: "exit".to_string(),
753                    max_retries: 0,
754                    goal_gate: true,
755                    retry_target: Some("design".to_string()),
756                    timeout: None,
757                    prompt: None,
758                    tool_command: None,
759                },
760            ],
761            edges: vec![
762                ParsedEdge {
763                    from: "start".to_string(),
764                    to: "design".to_string(),
765                    label: None,
766                    condition: None,
767                    weight: 0,
768                },
769                ParsedEdge {
770                    from: "design".to_string(),
771                    to: "test".to_string(),
772                    label: None,
773                    condition: None,
774                    weight: 0,
775                },
776                ParsedEdge {
777                    from: "test".to_string(),
778                    to: "finish".to_string(),
779                    label: None,
780                    condition: Some("outcome=success".to_string()),
781                    weight: 0,
782                },
783                ParsedEdge {
784                    from: "test".to_string(),
785                    to: "design".to_string(),
786                    label: Some("Fix".to_string()),
787                    condition: Some("outcome=failure".to_string()),
788                    weight: 0,
789                },
790            ],
791        }
792    }
793
794    #[test]
795    fn test_parsed_pipeline_to_scg_conversion() {
796        let parsed = sample_parsed_pipeline();
797        let result = parsed_pipeline_to_scg(&parsed, "test-pipe");
798
799        assert_eq!(result.phase.name, "test-pipe");
800        assert_eq!(result.phase.tasks.len(), 4);
801        assert!(result.pipeline.is_some());
802
803        let pipeline = result.pipeline.as_ref().unwrap();
804        assert_eq!(pipeline.goal.as_deref(), Some("Build something"));
805        assert_eq!(pipeline.node_attrs.len(), 4);
806        assert_eq!(pipeline.edge_attrs.len(), 4);
807
808        // Check node attrs
809        let design_attrs = &pipeline.node_attrs["design"];
810        assert_eq!(design_attrs.handler_type, "codergen");
811        assert_eq!(design_attrs.max_retries, 3);
812
813        let finish_attrs = &pipeline.node_attrs["finish"];
814        assert_eq!(finish_attrs.handler_type, "exit");
815        assert!(finish_attrs.goal_gate);
816        assert_eq!(finish_attrs.retry_target.as_deref(), Some("design"));
817
818        // Check task fields
819        let design_task = result.phase.tasks.iter().find(|t| t.id == "design").unwrap();
820        assert_eq!(design_task.description, "Design the REST API schema");
821        assert_eq!(design_task.complexity, 5); // codergen
822
823        let test_task = result.phase.tasks.iter().find(|t| t.id == "test").unwrap();
824        assert_eq!(test_task.details.as_deref(), Some("cargo test"));
825    }
826
827    #[test]
828    fn test_pipeline_round_trip() {
829        let parsed = sample_parsed_pipeline();
830        let result = parsed_pipeline_to_scg(&parsed, "test-pipe");
831        let serialized = serialize_scg_pipeline(&result);
832
833        // Parse it back
834        let reparsed = parse_scg_result(&serialized).expect("should parse serialized pipeline");
835
836        assert_eq!(reparsed.phase.name, "test-pipe");
837        assert_eq!(reparsed.phase.tasks.len(), 4);
838        assert!(reparsed.pipeline.is_some());
839
840        let pipeline = reparsed.pipeline.as_ref().unwrap();
841        assert_eq!(pipeline.goal.as_deref(), Some("Build something"));
842        assert_eq!(pipeline.node_attrs.len(), 4);
843        assert_eq!(pipeline.edge_attrs.len(), 4);
844
845        // Verify key attributes survived round-trip
846        let design_attrs = &pipeline.node_attrs["design"];
847        assert_eq!(design_attrs.handler_type, "codergen");
848        assert_eq!(design_attrs.max_retries, 3);
849
850        let finish_attrs = &pipeline.node_attrs["finish"];
851        assert!(finish_attrs.goal_gate);
852        assert_eq!(finish_attrs.retry_target.as_deref(), Some("design"));
853    }
854
855    #[test]
856    fn test_prompt_builder_contains_key_markers() {
857        let prompt = Prompts::generate_pipeline(
858            "Build a REST API for users",
859            "Build user management API",
860            "Iterative with test-fix loops",
861            "Yes, include human review gates",
862            "cargo test",
863            "Balanced (Sonnet - recommended)",
864        );
865
866        assert!(prompt.contains("Build a REST API for users")); // PRD content
867        assert!(prompt.contains("Build user management API")); // goal
868        assert!(prompt.contains("Iterative with test-fix loops")); // shape
869        assert!(prompt.contains("cargo test")); // tool steps
870        assert!(prompt.contains("handler_type")); // format teaching
871        assert!(prompt.contains("codergen")); // handler type example
872        assert!(prompt.contains("wait.human")); // handler type
873        assert!(prompt.contains("model_stylesheet")); // stylesheet
874    }
875}