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