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 =
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 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 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 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 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 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 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 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 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); 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 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 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")); 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")); }
875}