1pub mod agent;
11pub mod hooks;
12pub mod monitor;
13pub mod terminal;
14pub mod tui;
15
16use anyhow::Result;
17use colored::Colorize;
18use std::path::PathBuf;
19use std::thread;
20use std::time::Duration;
21
22use crate::commands::helpers::{flatten_all_tasks, resolve_group_tag};
23use crate::models::task::{Task, TaskStatus};
24use crate::storage::Storage;
25
26use self::monitor::SpawnSession;
27use self::terminal::{parse_terminal, Terminal};
28
29struct TaskInfo<'a> {
31 task: &'a Task,
32 tag: String,
33}
34
35#[allow(clippy::too_many_arguments)]
37pub fn run(
38 project_root: Option<PathBuf>,
39 tag: Option<&str>,
40 limit: usize,
41 all_tags: bool,
42 terminal_arg: &str,
43 dry_run: bool,
44 session: Option<String>,
45 attach: bool,
46 monitor: bool,
47 claim: bool,
48) -> Result<()> {
49 let storage = Storage::new(project_root.clone());
50
51 if !storage.is_initialized() {
52 anyhow::bail!("SCUD not initialized. Run: scud init");
53 }
54
55 let all_phases = storage.load_tasks()?;
57 let all_tasks_flat = flatten_all_tasks(&all_phases);
58
59 let phase_tag = if all_tags {
61 "all".to_string()
62 } else {
63 resolve_group_tag(&storage, tag, true)?
64 };
65
66 let ready_tasks = get_ready_tasks(&all_phases, &all_tasks_flat, &phase_tag, limit, all_tags)?;
68
69 if ready_tasks.is_empty() {
70 println!("{}", "No ready tasks to spawn.".yellow());
71 println!("Check: scud list --status pending");
72 return Ok(());
73 }
74
75 let terminal = parse_terminal(terminal_arg)?;
77 terminal::check_terminal_available(&terminal)?;
78
79 let session_name = session.unwrap_or_else(|| format!("scud-{}", phase_tag));
81
82 println!("{}", "SCUD Spawn".cyan().bold());
84 println!("{}", "═".repeat(50));
85 println!("{:<20} {}", "Terminal:".dimmed(), terminal.name().green());
86 println!("{:<20} {}", "Session:".dimmed(), session_name.cyan());
87 println!("{:<20} {}", "Tasks:".dimmed(), ready_tasks.len());
88 println!();
89
90 for (i, info) in ready_tasks.iter().enumerate() {
91 println!(
92 " {} {} {} | {}",
93 format!("[{}]", i + 1).dimmed(),
94 info.tag.dimmed(),
95 info.task.id.cyan(),
96 info.task.title
97 );
98 }
99 println!();
100
101 if dry_run {
102 println!("{}", "Dry run - no terminals spawned.".yellow());
103 return Ok(());
104 }
105
106 let working_dir = project_root
108 .clone()
109 .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
110
111 if !hooks::hooks_installed(&working_dir) {
113 println!(
114 "{}",
115 "Installing Claude Code hooks for task completion...".dimmed()
116 );
117 if let Err(e) = hooks::install_hooks(&working_dir) {
118 println!(
119 " {} Hook installation: {}",
120 "!".yellow(),
121 e.to_string().dimmed()
122 );
123 } else {
124 println!(
125 " {} Hooks installed (tasks auto-complete on agent stop)",
126 "✓".green()
127 );
128 }
129 }
130
131 let mut spawn_session = SpawnSession::new(
133 &session_name,
134 &phase_tag,
135 terminal.name(),
136 &working_dir.to_string_lossy(),
137 );
138
139 println!("{}", "Spawning agents...".green());
141
142 let mut success_count = 0;
143 let mut claimed_tasks: Vec<(String, String)> = Vec::new(); for info in &ready_tasks {
146 let prompt = agent::generate_prompt(info.task, &info.tag);
147
148 match terminal::spawn_terminal(
149 &terminal,
150 &info.task.id,
151 &prompt,
152 &working_dir,
153 &session_name,
154 ) {
155 Ok(()) => {
156 println!(
157 " {} Spawned: {} | {}",
158 "✓".green(),
159 info.task.id.cyan(),
160 info.task.title.dimmed()
161 );
162 spawn_session.add_agent(&info.task.id, &info.task.title, &info.tag);
163 success_count += 1;
164
165 if claim {
167 claimed_tasks.push((info.task.id.clone(), info.tag.clone()));
168 }
169 }
170 Err(e) => {
171 println!(" {} Failed: {} - {}", "✗".red(), info.task.id.red(), e);
172 }
173 }
174
175 if success_count < ready_tasks.len() {
177 thread::sleep(Duration::from_millis(500));
178 }
179 }
180
181 if claim && !claimed_tasks.is_empty() {
183 println!();
184 println!("{}", "Claiming tasks...".dimmed());
185 for (task_id, task_tag) in &claimed_tasks {
186 match storage.load_group(task_tag) {
188 Ok(mut phase) => {
189 if let Some(task) = phase.get_task_mut(task_id) {
190 task.set_status(TaskStatus::InProgress);
191 if let Err(e) = storage.update_group(task_tag, &phase) {
192 println!(
193 " {} Claim failed: {} - {}",
194 "!".yellow(),
195 task_id,
196 e.to_string().dimmed()
197 );
198 } else {
199 println!(
200 " {} Claimed: {} → {}",
201 "✓".green(),
202 task_id.cyan(),
203 "in-progress".yellow()
204 );
205 }
206 }
207 }
208 Err(e) => {
209 println!(
210 " {} Claim failed: {} - {}",
211 "!".yellow(),
212 task_id,
213 e.to_string().dimmed()
214 );
215 }
216 }
217 }
218 }
219
220 if terminal == Terminal::Tmux {
222 if let Err(e) = terminal::setup_tmux_control_window(&session_name, &phase_tag) {
223 println!(
224 " {} Control window setup: {}",
225 "!".yellow(),
226 e.to_string().dimmed()
227 );
228 }
229 }
230
231 if let Err(e) = monitor::save_session(project_root.as_ref(), &spawn_session) {
233 println!(
234 " {} Session metadata: {}",
235 "!".yellow(),
236 e.to_string().dimmed()
237 );
238 }
239
240 println!();
242 println!(
243 "{} {} of {} agents spawned",
244 "Summary:".blue().bold(),
245 success_count,
246 ready_tasks.len()
247 );
248
249 if terminal == Terminal::Tmux {
250 println!();
251 println!(
252 "To attach: {}",
253 format!("tmux attach -t {}", session_name).cyan()
254 );
255 println!(
256 "To list: {}",
257 format!("tmux list-windows -t {}", session_name).dimmed()
258 );
259 }
260
261 if monitor {
263 println!();
264 println!("Starting monitor...");
265 thread::sleep(Duration::from_secs(1));
267 return tui::run(project_root, &session_name);
268 }
269
270 if attach && terminal == Terminal::Tmux {
272 println!();
273 println!("Attaching to session...");
274 terminal::tmux_attach(&session_name)?;
275 }
276
277 Ok(())
278}
279
280pub fn run_monitor(project_root: Option<PathBuf>, session: Option<String>) -> Result<()> {
282 use colored::Colorize;
283
284 let session_name = match session {
286 Some(s) => s,
287 None => {
288 let sessions = monitor::list_sessions(project_root.as_ref())?;
289 if sessions.is_empty() {
290 anyhow::bail!("No spawn sessions found. Run: scud spawn");
291 }
292 if sessions.len() == 1 {
293 sessions[0].clone()
294 } else {
295 println!("{}", "Available sessions:".cyan().bold());
296 for (i, s) in sessions.iter().enumerate() {
297 println!(" {} {}", format!("[{}]", i + 1).dimmed(), s);
298 }
299 anyhow::bail!("Multiple sessions found. Specify one with --session <name>");
300 }
301 }
302 };
303
304 tui::run(project_root, &session_name)
305}
306
307pub fn run_sessions(project_root: Option<PathBuf>, verbose: bool) -> Result<()> {
309 use colored::Colorize;
310
311 let sessions = monitor::list_sessions(project_root.as_ref())?;
312
313 if sessions.is_empty() {
314 println!("{}", "No spawn sessions found.".dimmed());
315 println!("Run: scud spawn -m --limit 3");
316 return Ok(());
317 }
318
319 println!("{}", "Spawn Sessions:".cyan().bold());
320 println!();
321
322 for session_name in &sessions {
323 if verbose {
324 match monitor::load_session(project_root.as_ref(), session_name) {
326 Ok(session) => {
327 let stats = monitor::SpawnStats::from(&session);
328 println!(
329 " {} {} agents ({} running, {} done)",
330 session_name.cyan(),
331 format!("[{}]", stats.total_agents).dimmed(),
332 stats.running.to_string().green(),
333 stats.completed.to_string().blue()
334 );
335 println!(
336 " {} Tag: {}, Terminal: {}",
337 "│".dimmed(),
338 session.tag,
339 session.terminal
340 );
341 println!(
342 " {} Created: {}",
343 "└".dimmed(),
344 session.created_at.dimmed()
345 );
346 println!();
347 }
348 Err(_) => {
349 println!(" {} {}", session_name, "(unable to load)".red());
350 }
351 }
352 } else {
353 println!(" {}", session_name);
354 }
355 }
356
357 if !verbose {
358 println!();
359 println!(
360 "{}",
361 "Use -v for details, or: scud monitor --session <name>".dimmed()
362 );
363 }
364
365 Ok(())
366}
367
368fn get_ready_tasks<'a>(
370 all_phases: &'a std::collections::HashMap<String, crate::models::phase::Phase>,
371 all_tasks_flat: &[&Task],
372 phase_tag: &str,
373 limit: usize,
374 all_tags: bool,
375) -> Result<Vec<TaskInfo<'a>>> {
376 let mut ready_tasks: Vec<TaskInfo<'a>> = Vec::new();
377
378 if all_tags {
379 for (tag, phase) in all_phases {
381 for task in &phase.tasks {
382 if is_task_ready(task, phase, all_tasks_flat) {
383 ready_tasks.push(TaskInfo {
384 task,
385 tag: tag.clone(),
386 });
387 }
388 }
389 }
390 } else {
391 let phase = all_phases
393 .get(phase_tag)
394 .ok_or_else(|| anyhow::anyhow!("Phase '{}' not found", phase_tag))?;
395
396 for task in &phase.tasks {
397 if is_task_ready(task, phase, all_tasks_flat) {
398 ready_tasks.push(TaskInfo {
399 task,
400 tag: phase_tag.to_string(),
401 });
402 }
403 }
404 }
405
406 ready_tasks.truncate(limit);
408
409 Ok(ready_tasks)
410}
411
412fn is_task_ready(
414 task: &Task,
415 phase: &crate::models::phase::Phase,
416 all_tasks_flat: &[&Task],
417) -> bool {
418 if task.status != TaskStatus::Pending {
420 return false;
421 }
422
423 if task.is_expanded() {
425 return false;
426 }
427
428 if let Some(ref parent_id) = task.parent_id {
430 let parent_expanded = phase
431 .get_task(parent_id)
432 .map(|p| p.is_expanded())
433 .unwrap_or(false);
434 if !parent_expanded {
435 return false;
436 }
437 }
438
439 task.has_dependencies_met_refs(all_tasks_flat)
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446 use crate::models::phase::Phase;
447 use crate::models::task::Task;
448
449 #[test]
450 fn test_is_task_ready_basic() {
451 let mut phase = Phase::new("test".to_string());
452 let task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
453 phase.add_task(task);
454
455 let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
456 assert!(is_task_ready(&phase.tasks[0], &phase, &all_tasks));
457 }
458
459 #[test]
460 fn test_is_task_ready_in_progress() {
461 let mut phase = Phase::new("test".to_string());
462 let mut task = Task::new("1".to_string(), "Test".to_string(), "Desc".to_string());
463 task.set_status(TaskStatus::InProgress);
464 phase.add_task(task);
465
466 let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
467 assert!(!is_task_ready(&phase.tasks[0], &phase, &all_tasks));
468 }
469
470 #[test]
471 fn test_is_task_ready_blocked_by_deps() {
472 let mut phase = Phase::new("test".to_string());
473
474 let task1 = Task::new("1".to_string(), "First".to_string(), "Desc".to_string());
475
476 let mut task2 = Task::new("2".to_string(), "Second".to_string(), "Desc".to_string());
477 task2.dependencies = vec!["1".to_string()];
478
479 phase.add_task(task1);
480 phase.add_task(task2);
481
482 let all_tasks: Vec<&Task> = phase.tasks.iter().collect();
483
484 assert!(is_task_ready(&phase.tasks[0], &phase, &all_tasks));
486 assert!(!is_task_ready(&phase.tasks[1], &phase, &all_tasks));
488 }
489}