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