1use 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#[derive(Debug, Clone)]
42pub struct GenerateOptions {
43 pub project_root: Option<PathBuf>,
45 pub file: PathBuf,
47 pub tag: String,
49 pub num_tasks: u32,
51 pub no_expand: bool,
53 pub no_check_deps: bool,
55 pub append: bool,
57 pub no_guidance: bool,
59 pub id_format: String,
61 pub model: Option<String>,
63 pub dry_run: bool,
65 pub verbose: bool,
67}
68
69impl GenerateOptions {
70 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
113pub 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#[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 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 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, false, Some(tag), no_guidance,
253 model,
254 )
255 .await?;
256 }
257
258 if verbose {
259 println!(" {} Expand phase completed", "✓".green());
260 }
261 }
262 println!();
263
264 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 let check_result = check_deps::run(
293 project_root.clone(),
294 Some(tag), false, Some(file), true, model,
299 )
300 .await;
301
302 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 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#[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
385fn 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 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
463async fn run_interview(
465 interviewer: &dyn Interviewer,
466 prd_first_line: &str,
467) -> Result<(String, String, String, String, String)> {
468 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 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 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 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 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#[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 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 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 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 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 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 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 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 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 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); 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 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 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")); assert!(prompt.contains("Build user management API")); assert!(prompt.contains("Iterative with test-fix loops")); assert!(prompt.contains("cargo test")); assert!(prompt.contains("handler_type")); assert!(prompt.contains("codergen")); assert!(prompt.contains("wait.human")); assert!(prompt.contains("model_stylesheet")); }
882}