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 = std::fs::read_to_string(file)
564        .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!(
589            "  {} Tools: {}",
590            "→".cyan(),
591            if tool_steps.is_empty() {
592                "(none)"
593            } else {
594                &tool_steps
595            }
596        );
597        println!("  {} Model: {}", "→".cyan(), model_tier);
598    }
599    println!();
600
601    if dry_run {
602        println!(
603            "{} Would generate pipeline with LLM...",
604            "Phase 2:".yellow().bold()
605        );
606        println!(
607            "  {} Would write to: {}",
608            "→".cyan(),
609            output
610                .as_deref()
611                .unwrap_or_else(|| Path::new(".scud/tasks/tasks.scg"))
612                .display()
613        );
614        println!();
615        println!("{}", "Dry run - no changes were made.".yellow());
616        return Ok(());
617    }
618
619    // LLM generation
620    println!(
621        "{} Generating pipeline via LLM...",
622        "Phase 2:".yellow().bold()
623    );
624
625    let prompt = Prompts::generate_pipeline(
626        &prd_content,
627        &goal,
628        &shape,
629        &human_checkpoints,
630        &tool_steps,
631        &model_tier,
632    );
633
634    let client = if let Some(ref root) = project_root {
635        LLMClient::new_with_project_root(root.clone())?
636    } else {
637        LLMClient::new()?
638    };
639    let parsed: ParsedPipeline = client.complete_json_smart(&prompt, model).await?;
640
641    if verbose {
642        println!(
643            "  {} Generated {} nodes, {} edges",
644            "✓".green(),
645            parsed.nodes.len(),
646            parsed.edges.len()
647        );
648    }
649    println!();
650
651    // Convert to SCG
652    println!("{} Converting to SCG format...", "Phase 3:".yellow().bold());
653
654    let result = parsed_pipeline_to_scg(&parsed, tag);
655    let scg_output = serialize_scg_pipeline(&result);
656
657    // Determine output path
658    let output_path = output.unwrap_or_else(|| {
659        let root = project_root.unwrap_or_else(|| PathBuf::from("."));
660        root.join(".scud/tasks/tasks.scg")
661    });
662
663    // Ensure parent directory exists
664    if let Some(parent) = output_path.parent() {
665        std::fs::create_dir_all(parent)
666            .with_context(|| format!("creating directory: {}", parent.display()))?;
667    }
668
669    std::fs::write(&output_path, &scg_output)
670        .with_context(|| format!("writing pipeline: {}", output_path.display()))?;
671
672    if verbose {
673        println!("  {} Written to {}", "✓".green(), output_path.display());
674    }
675    println!();
676
677    // Summary
678    println!("{}", "━".repeat(50).green());
679    println!("{}", "Pipeline generated successfully!".green().bold());
680    println!("{}", "━".repeat(50).green());
681    println!();
682    println!(
683        "  {} {} nodes, {} edges",
684        "→".cyan(),
685        result.phase.tasks.len(),
686        result
687            .pipeline
688            .as_ref()
689            .map(|p| p.edge_attrs.len())
690            .unwrap_or(0)
691    );
692    println!("  {} Output: {}", "→".cyan(), output_path.display());
693    println!();
694    println!("{}", "Next steps:".blue());
695    println!(
696        "  1. Validate: scud attractor validate {}",
697        output_path.display()
698    );
699    println!("  2. Run: scud attractor run {}", output_path.display());
700    println!();
701
702    Ok(())
703}
704
705#[cfg(test)]
706mod pipeline_tests {
707    use super::*;
708    use crate::formats::parse_scg_result;
709
710    fn sample_parsed_pipeline() -> ParsedPipeline {
711        ParsedPipeline {
712            name: "test-pipe".to_string(),
713            goal: "Build something".to_string(),
714            model_stylesheet: Some(
715                r#"* { model: "claude-3-haiku"; reasoning_effort: "medium" }"#.to_string(),
716            ),
717            nodes: vec![
718                ParsedNode {
719                    id: "start".to_string(),
720                    title: "Start".to_string(),
721                    handler_type: "start".to_string(),
722                    max_retries: 0,
723                    goal_gate: false,
724                    retry_target: None,
725                    timeout: None,
726                    prompt: None,
727                    tool_command: None,
728                },
729                ParsedNode {
730                    id: "design".to_string(),
731                    title: "Design API".to_string(),
732                    handler_type: "codergen".to_string(),
733                    max_retries: 3,
734                    goal_gate: false,
735                    retry_target: None,
736                    timeout: None,
737                    prompt: Some("Design the REST API schema".to_string()),
738                    tool_command: None,
739                },
740                ParsedNode {
741                    id: "test".to_string(),
742                    title: "Run Tests".to_string(),
743                    handler_type: "tool".to_string(),
744                    max_retries: 0,
745                    goal_gate: false,
746                    retry_target: None,
747                    timeout: None,
748                    prompt: None,
749                    tool_command: Some("cargo test".to_string()),
750                },
751                ParsedNode {
752                    id: "finish".to_string(),
753                    title: "Done".to_string(),
754                    handler_type: "exit".to_string(),
755                    max_retries: 0,
756                    goal_gate: true,
757                    retry_target: Some("design".to_string()),
758                    timeout: None,
759                    prompt: None,
760                    tool_command: None,
761                },
762            ],
763            edges: vec![
764                ParsedEdge {
765                    from: "start".to_string(),
766                    to: "design".to_string(),
767                    label: None,
768                    condition: None,
769                    weight: 0,
770                },
771                ParsedEdge {
772                    from: "design".to_string(),
773                    to: "test".to_string(),
774                    label: None,
775                    condition: None,
776                    weight: 0,
777                },
778                ParsedEdge {
779                    from: "test".to_string(),
780                    to: "finish".to_string(),
781                    label: None,
782                    condition: Some("outcome=success".to_string()),
783                    weight: 0,
784                },
785                ParsedEdge {
786                    from: "test".to_string(),
787                    to: "design".to_string(),
788                    label: Some("Fix".to_string()),
789                    condition: Some("outcome=failure".to_string()),
790                    weight: 0,
791                },
792            ],
793        }
794    }
795
796    #[test]
797    fn test_parsed_pipeline_to_scg_conversion() {
798        let parsed = sample_parsed_pipeline();
799        let result = parsed_pipeline_to_scg(&parsed, "test-pipe");
800
801        assert_eq!(result.phase.name, "test-pipe");
802        assert_eq!(result.phase.tasks.len(), 4);
803        assert!(result.pipeline.is_some());
804
805        let pipeline = result.pipeline.as_ref().unwrap();
806        assert_eq!(pipeline.goal.as_deref(), Some("Build something"));
807        assert_eq!(pipeline.node_attrs.len(), 4);
808        assert_eq!(pipeline.edge_attrs.len(), 4);
809
810        // Check node attrs
811        let design_attrs = &pipeline.node_attrs["design"];
812        assert_eq!(design_attrs.handler_type, "codergen");
813        assert_eq!(design_attrs.max_retries, 3);
814
815        let finish_attrs = &pipeline.node_attrs["finish"];
816        assert_eq!(finish_attrs.handler_type, "exit");
817        assert!(finish_attrs.goal_gate);
818        assert_eq!(finish_attrs.retry_target.as_deref(), Some("design"));
819
820        // Check task fields
821        let design_task = result
822            .phase
823            .tasks
824            .iter()
825            .find(|t| t.id == "design")
826            .unwrap();
827        assert_eq!(design_task.description, "Design the REST API schema");
828        assert_eq!(design_task.complexity, 5); // codergen
829
830        let test_task = result.phase.tasks.iter().find(|t| t.id == "test").unwrap();
831        assert_eq!(test_task.details.as_deref(), Some("cargo test"));
832    }
833
834    #[test]
835    fn test_pipeline_round_trip() {
836        let parsed = sample_parsed_pipeline();
837        let result = parsed_pipeline_to_scg(&parsed, "test-pipe");
838        let serialized = serialize_scg_pipeline(&result);
839
840        // Parse it back
841        let reparsed = parse_scg_result(&serialized).expect("should parse serialized pipeline");
842
843        assert_eq!(reparsed.phase.name, "test-pipe");
844        assert_eq!(reparsed.phase.tasks.len(), 4);
845        assert!(reparsed.pipeline.is_some());
846
847        let pipeline = reparsed.pipeline.as_ref().unwrap();
848        assert_eq!(pipeline.goal.as_deref(), Some("Build something"));
849        assert_eq!(pipeline.node_attrs.len(), 4);
850        assert_eq!(pipeline.edge_attrs.len(), 4);
851
852        // Verify key attributes survived round-trip
853        let design_attrs = &pipeline.node_attrs["design"];
854        assert_eq!(design_attrs.handler_type, "codergen");
855        assert_eq!(design_attrs.max_retries, 3);
856
857        let finish_attrs = &pipeline.node_attrs["finish"];
858        assert!(finish_attrs.goal_gate);
859        assert_eq!(finish_attrs.retry_target.as_deref(), Some("design"));
860    }
861
862    #[test]
863    fn test_prompt_builder_contains_key_markers() {
864        let prompt = Prompts::generate_pipeline(
865            "Build a REST API for users",
866            "Build user management API",
867            "Iterative with test-fix loops",
868            "Yes, include human review gates",
869            "cargo test",
870            "Balanced (Sonnet - recommended)",
871        );
872
873        assert!(prompt.contains("Build a REST API for users")); // PRD content
874        assert!(prompt.contains("Build user management API")); // goal
875        assert!(prompt.contains("Iterative with test-fix loops")); // shape
876        assert!(prompt.contains("cargo test")); // tool steps
877        assert!(prompt.contains("handler_type")); // format teaching
878        assert!(prompt.contains("codergen")); // handler type example
879        assert!(prompt.contains("wait.human")); // handler type
880        assert!(prompt.contains("model_stylesheet")); // stylesheet
881    }
882}