1use crate::error_bridge::IntoCoreResult;
2use crate::errors::{CoreError, CoreResult};
3use crate::harness::types::MAX_RETRIABLE_RETRIES;
4use crate::harness::{Harness, HarnessName};
5use crate::process::{ProcessRequest, ProcessRunner, SystemProcessRunner};
6use crate::ralph::duration::format_duration;
7use crate::ralph::prompt::{BuildPromptOptions, build_ralph_prompt};
8use crate::ralph::state::{
9 RalphHistoryEntry, RalphState, append_context, clear_context, load_context, load_state,
10 save_state,
11};
12use crate::ralph::validation;
13use crate::task_repository::FsTaskRepository;
14use ito_domain::changes::{
15 ChangeRepository as DomainChangeRepository, ChangeSummary, ChangeTargetResolution,
16 ChangeWorkStatus,
17};
18use ito_domain::modules::ModuleRepository as DomainModuleRepository;
19use ito_domain::tasks::TaskRepository as DomainTaskRepository;
20use std::collections::BTreeSet;
21use std::path::{Path, PathBuf};
22use std::time::{Duration, SystemTime, UNIX_EPOCH};
23
24#[derive(Debug, Clone, Default)]
26pub struct WorktreeConfig {
27 pub enabled: bool,
29 pub dir_name: String,
35}
36
37#[derive(Debug, Clone)]
38pub struct RalphOptions {
40 pub prompt: String,
42
43 pub change_id: Option<String>,
45
46 pub module_id: Option<String>,
48
49 pub model: Option<String>,
51
52 pub min_iterations: u32,
54
55 pub max_iterations: Option<u32>,
57
58 pub completion_promise: String,
60
61 pub allow_all: bool,
63
64 pub no_commit: bool,
66
67 pub interactive: bool,
69
70 pub status: bool,
72
73 pub add_context: Option<String>,
75
76 pub clear_context: bool,
78
79 pub verbose: bool,
81
82 pub continue_module: bool,
84
85 pub continue_ready: bool,
89
90 pub inactivity_timeout: Option<Duration>,
92
93 pub skip_validation: bool,
97
98 pub validation_command: Option<String>,
102
103 pub exit_on_error: bool,
107
108 pub error_threshold: u32,
112
113 pub worktree: WorktreeConfig,
115}
116
117pub const DEFAULT_ERROR_THRESHOLD: u32 = 10;
119
120#[derive(Debug, Clone)]
125pub struct ResolvedCwd {
126 pub path: PathBuf,
128 pub ito_path: PathBuf,
131}
132
133pub fn resolve_effective_cwd(
139 ito_path: &Path,
140 change_id: Option<&str>,
141 worktree: &WorktreeConfig,
142) -> ResolvedCwd {
143 let lookup = |branch: &str| crate::audit::worktree::find_worktree_for_branch(branch);
144 let fallback_path = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
145 resolve_effective_cwd_with(ito_path, change_id, worktree, fallback_path, lookup)
146}
147
148fn resolve_effective_cwd_with(
153 ito_path: &Path,
154 change_id: Option<&str>,
155 worktree: &WorktreeConfig,
156 fallback_path: PathBuf,
157 lookup: impl Fn(&str) -> Option<PathBuf>,
158) -> ResolvedCwd {
159 let fallback = ResolvedCwd {
160 path: fallback_path,
161 ito_path: ito_path.to_path_buf(),
162 };
163
164 let wt_path = if worktree.enabled {
165 change_id.and_then(lookup)
166 } else {
167 None
168 };
169
170 let Some(wt_path) = wt_path else {
171 return fallback;
172 };
173
174 let wt_ito_path = wt_path.join(".ito");
175 ResolvedCwd {
176 path: wt_path,
177 ito_path: wt_ito_path,
178 }
179}
180
181pub fn run_ralph(
196 ito_path: &Path,
197 change_repo: &(impl DomainChangeRepository + ?Sized),
198 task_repo: &dyn DomainTaskRepository,
199 module_repo: &(impl DomainModuleRepository + ?Sized),
200 opts: RalphOptions,
201 harness: &mut dyn Harness,
202) -> CoreResult<()> {
203 let process_runner = SystemProcessRunner;
204 if opts.continue_ready {
205 if opts.continue_module {
206 return Err(CoreError::Validation(
207 "--continue-ready cannot be used with --continue-module".into(),
208 ));
209 }
210 if opts.change_id.is_some() || opts.module_id.is_some() {
211 return Err(CoreError::Validation(
212 "--continue-ready cannot be used with --change or --module".into(),
213 ));
214 }
215 if opts.status || opts.add_context.is_some() || opts.clear_context {
216 return Err(CoreError::Validation(
217 "--continue-ready cannot be combined with --status, --add-context, or --clear-context".into(),
218 ));
219 }
220
221 loop {
222 let current_changes = repo_changes(change_repo)?;
223 let eligible_changes = repo_eligible_change_ids(¤t_changes);
224 print_eligible_changes(&eligible_changes);
225
226 if eligible_changes.is_empty() {
227 let incomplete = repo_incomplete_change_ids(¤t_changes);
228 if incomplete.is_empty() {
229 println!("\nAll changes are complete.");
230 return Ok(());
231 }
232
233 return Err(CoreError::Validation(format!(
234 "Repository has no eligible changes. Remaining non-complete changes: {}",
235 incomplete.join(", ")
236 )));
237 }
238
239 let mut next_change = eligible_changes[0].clone();
240
241 let preflight_changes = repo_changes(change_repo)?;
242 let preflight_eligible = repo_eligible_change_ids(&preflight_changes);
243 if preflight_eligible.is_empty() {
244 let incomplete = repo_incomplete_change_ids(&preflight_changes);
245 if incomplete.is_empty() {
246 println!("\nAll changes are complete.");
247 return Ok(());
248 }
249 return Err(CoreError::Validation(format!(
250 "Repository changed during selection and now has no eligible changes. Remaining non-complete changes: {}",
251 incomplete.join(", ")
252 )));
253 }
254 let preflight_first = preflight_eligible[0].clone();
255 if preflight_first != next_change {
256 println!(
257 "\nRepository state shifted before start; reorienting from {from} to {to}.",
258 from = next_change,
259 to = preflight_first
260 );
261 next_change = preflight_first;
262 }
263
264 println!(
265 "\nStarting change {change} (lowest eligible change id).",
266 change = next_change
267 );
268
269 let mut single_opts = opts.clone();
270 single_opts.continue_ready = false;
271 single_opts.change_id = Some(next_change);
272
273 run_ralph(
274 ito_path,
275 change_repo,
276 task_repo,
277 module_repo,
278 single_opts,
279 harness,
280 )?;
281 }
282 }
283
284 if opts.continue_module {
285 if opts.change_id.is_some() {
286 return Err(CoreError::Validation(
287 "--continue-module cannot be used with --change. Use --module only.".into(),
288 ));
289 }
290 let Some(module_id) = opts.module_id.clone() else {
291 return Err(CoreError::Validation(
292 "--continue-module requires --module".into(),
293 ));
294 };
295 if opts.status || opts.add_context.is_some() || opts.clear_context {
296 return Err(CoreError::Validation(
297 "--continue-module cannot be combined with --status, --add-context, or --clear-context".into()
298 ));
299 }
300
301 let mut processed: BTreeSet<String> = BTreeSet::new();
302
303 loop {
304 let current_changes = module_changes(change_repo, &module_id)?;
305 let ready_all = module_ready_change_ids(¤t_changes);
306 print_ready_changes(&module_id, &ready_all);
307
308 let ready_changes = unprocessed_change_ids(&ready_all, &processed);
310
311 if ready_changes.is_empty() {
312 if ready_all.is_empty() {
314 let incomplete = module_incomplete_change_ids(¤t_changes);
315
316 if incomplete.is_empty() {
317 println!("\nModule {module} is complete.", module = module_id);
318 return Ok(());
319 }
320
321 return Err(CoreError::Validation(format!(
322 "Module {module} has no ready changes. Remaining non-complete changes: {}",
323 incomplete.join(", "),
324 module = module_id
325 )));
326 }
327
328 println!(
331 "\nModule {module} has no additional ready changes (all ready changes were already processed in this run).",
332 module = module_id
333 );
334 return Ok(());
335 }
336
337 let mut next_change = ready_changes[0].clone();
338
339 let preflight_changes = module_changes(change_repo, &module_id)?;
340 let preflight_ready_all = module_ready_change_ids(&preflight_changes);
341 if preflight_ready_all.is_empty() {
342 let incomplete = module_incomplete_change_ids(&preflight_changes);
343 if incomplete.is_empty() {
344 println!("\nModule {module} is complete.", module = module_id);
345 return Ok(());
346 }
347 return Err(CoreError::Validation(format!(
348 "Module {module} changed during selection and now has no ready changes. Remaining non-complete changes: {}",
349 incomplete.join(", "),
350 module = module_id
351 )));
352 }
353
354 let preflight_ready = unprocessed_change_ids(&preflight_ready_all, &processed);
355
356 if preflight_ready.is_empty() {
357 println!(
358 "\nModule {module} has no additional ready changes (all ready changes were already processed in this run).",
359 module = module_id
360 );
361 return Ok(());
362 }
363
364 let preflight_first = preflight_ready[0].clone();
365 if preflight_first != next_change {
366 println!(
367 "\nModule state shifted before start; reorienting from {from} to {to}.",
368 from = next_change,
369 to = preflight_first
370 );
371 next_change = preflight_first;
372 }
373
374 println!(
375 "\nStarting module change {change} (lowest ready change id).",
376 change = next_change
377 );
378
379 let mut single_opts = opts.clone();
380 single_opts.continue_module = false;
381 single_opts.continue_ready = false;
382 single_opts.change_id = Some(next_change.clone());
383
384 run_ralph(
385 ito_path,
386 change_repo,
387 task_repo,
388 module_repo,
389 single_opts,
390 harness,
391 )?;
392
393 processed.insert(next_change);
395
396 let post_changes = module_changes(change_repo, &module_id)?;
397 let post_ready = module_ready_change_ids(&post_changes);
398 print_ready_changes(&module_id, &post_ready);
399 }
400 }
401
402 if opts.change_id.is_none()
403 && let Some(module_id) = opts.module_id.as_deref()
404 && !opts.status
405 && opts.add_context.is_none()
406 && !opts.clear_context
407 {
408 let module_changes = module_changes(change_repo, module_id)?;
409 let ready_changes = module_ready_change_ids(&module_changes);
410 print_ready_changes(module_id, &ready_changes);
411 }
412
413 let unscoped_target = opts.change_id.is_none() && opts.module_id.is_none();
414
415 let resolved_cwd = resolve_effective_cwd(ito_path, opts.change_id.as_deref(), &opts.worktree);
418 let effective_ito_path = &resolved_cwd.ito_path;
419
420 if opts.verbose {
421 if effective_ito_path != ito_path {
422 println!("Resolved worktree: {}", resolved_cwd.path.display());
423 } else {
424 println!(
425 "Using current working directory: {}",
426 resolved_cwd.path.display()
427 );
428 }
429 }
430
431 let (change_id, module_id) = if unscoped_target {
432 ("unscoped".to_string(), "unscoped".to_string())
433 } else {
434 resolve_target(
435 change_repo,
436 opts.change_id,
437 opts.module_id,
438 opts.interactive,
439 )?
440 };
441
442 if opts.status {
443 let state = load_state(effective_ito_path, &change_id)?;
444 if let Some(state) = state {
445 println!("\n=== Ralph Status for {id} ===\n", id = state.change_id);
446 println!("Iteration: {iter}", iter = state.iteration);
447 println!("History entries: {n}", n = state.history.len());
448 if !state.history.is_empty() {
449 println!("\nRecent iterations:");
450 let n = state.history.len();
451 let start = n.saturating_sub(5);
452 for (i, h) in state.history.iter().enumerate().skip(start) {
453 println!(
454 " {idx}: duration={dur}ms, changes={chg}, promise={p}",
455 idx = i + 1,
456 dur = h.duration,
457 chg = h.file_changes_count,
458 p = h.completion_promise_found
459 );
460 }
461 }
462 } else {
463 println!("\n=== Ralph Status for {id} ===\n", id = change_id);
464 println!("No state found");
465 }
466 return Ok(());
467 }
468
469 if let Some(text) = opts.add_context.as_deref() {
470 append_context(effective_ito_path, &change_id, text)?;
471 println!("Added context to {id}", id = change_id);
472 return Ok(());
473 }
474 if opts.clear_context {
475 clear_context(effective_ito_path, &change_id)?;
476 println!("Cleared Ralph context for {id}", id = change_id);
477 return Ok(());
478 }
479
480 let ito_dir_name = effective_ito_path
481 .file_name()
482 .map(|s| s.to_string_lossy().to_string())
483 .unwrap_or_else(|| ".ito".to_string());
484 let context_file = format!(
485 "{ito_dir}/.state/ralph/{change}/context.md",
486 ito_dir = ito_dir_name,
487 change = change_id
488 );
489
490 let mut state = load_state(effective_ito_path, &change_id)?.unwrap_or(RalphState {
491 change_id: change_id.clone(),
492 iteration: 0,
493 history: vec![],
494 context_file,
495 });
496
497 let max_iters = opts.max_iterations.unwrap_or(u32::MAX);
498 if max_iters == 0 {
499 return Err(CoreError::Validation(
500 "--max-iterations must be >= 1".into(),
501 ));
502 }
503 if opts.error_threshold == 0 {
504 return Err(CoreError::Validation(
505 "--error-threshold must be >= 1".into(),
506 ));
507 }
508
509 println!(
511 "\n=== Starting Ralph for {change} (harness: {harness}) ===",
512 change = change_id,
513 harness = harness.name()
514 );
515 if let Some(model) = &opts.model {
516 println!("Model: {model}");
517 }
518 if let Some(max) = opts.max_iterations {
519 println!("Max iterations: {max}");
520 }
521 if opts.allow_all {
522 println!("Mode: --yolo (auto-approve all)");
523 }
524 if let Some(timeout) = opts.inactivity_timeout {
525 println!("Inactivity timeout: {}", format_duration(timeout));
526 }
527 println!();
528
529 let mut last_validation_failure: Option<String> = None;
530 let mut harness_error_count: u32 = 0;
531 let mut retriable_retry_count: u32 = 0;
532
533 for _ in 0..max_iters {
534 let iteration = state.iteration.saturating_add(1);
535
536 println!("\n=== Ralph Loop Iteration {i} ===\n", i = iteration);
537
538 let context_content = load_context(effective_ito_path, &change_id)?;
539 let prompt = build_ralph_prompt(
540 effective_ito_path,
541 change_repo,
542 module_repo,
543 &opts.prompt,
544 BuildPromptOptions {
545 change_id: if unscoped_target {
546 None
547 } else {
548 Some(change_id.clone())
549 },
550 module_id: if unscoped_target {
551 None
552 } else {
553 Some(module_id.clone())
554 },
555 iteration: Some(iteration),
556 max_iterations: opts.max_iterations,
557 min_iterations: opts.min_iterations,
558 completion_promise: opts.completion_promise.clone(),
559 context_content: Some(context_content),
560 validation_failure: last_validation_failure.clone(),
561 },
562 )?;
563
564 if opts.verbose {
565 println!("--- Prompt sent to harness ---");
566 println!("{}", prompt);
567 println!("--- End of prompt ---\n");
568 }
569
570 let started = std::time::Instant::now();
571 let run = harness
572 .run(&crate::harness::HarnessRunConfig {
573 prompt,
574 model: opts.model.clone(),
575 cwd: resolved_cwd.path.clone(),
576 env: std::collections::BTreeMap::new(),
577 interactive: opts.interactive && !opts.allow_all,
578 allow_all: opts.allow_all,
579 inactivity_timeout: opts.inactivity_timeout,
580 })
581 .map_err(|e| CoreError::Process(format!("Harness execution failed: {e}")))?;
582
583 if !harness.streams_output() {
585 if !run.stdout.is_empty() {
586 print!("{}", run.stdout);
587 }
588 if !run.stderr.is_empty() {
589 eprint!("{}", run.stderr);
590 }
591 }
592
593 let completion_found = completion_promise_found(&run.stdout, &opts.completion_promise);
595
596 let file_changes_count = if harness.name() != HarnessName::Stub {
597 count_git_changes(&process_runner, &resolved_cwd.path)? as u32
598 } else {
599 0
600 };
601
602 if run.timed_out {
604 println!("\n=== Inactivity timeout reached. Restarting iteration... ===\n");
605 retriable_retry_count = 0;
606 continue;
608 }
609
610 if run.exit_code != 0 {
611 if run.is_retriable() {
612 retriable_retry_count = retriable_retry_count.saturating_add(1);
613 if retriable_retry_count > MAX_RETRIABLE_RETRIES {
614 return Err(CoreError::Process(format!(
615 "Harness '{name}' crashed {count} consecutive times (exit code {code}); giving up",
616 name = harness.name(),
617 count = retriable_retry_count,
618 code = run.exit_code
619 )));
620 }
621 println!(
622 "\n=== Harness process crashed (exit code {code}, attempt {count}/{max}). Retrying... ===\n",
623 code = run.exit_code,
624 count = retriable_retry_count,
625 max = MAX_RETRIABLE_RETRIES
626 );
627 continue;
628 }
629
630 retriable_retry_count = 0;
632
633 if opts.exit_on_error {
634 return Err(CoreError::Process(format!(
635 "Harness '{name}' exited with code {code}",
636 name = harness.name(),
637 code = run.exit_code
638 )));
639 }
640
641 harness_error_count = harness_error_count.saturating_add(1);
642 if harness_error_count >= opts.error_threshold {
643 return Err(CoreError::Process(format!(
644 "Harness '{name}' exceeded non-zero exit threshold ({count}/{threshold}); last exit code {code}",
645 name = harness.name(),
646 count = harness_error_count,
647 threshold = opts.error_threshold,
648 code = run.exit_code
649 )));
650 }
651
652 last_validation_failure = Some(render_harness_failure(
653 harness.name().as_str(),
654 run.exit_code,
655 &run.stdout,
656 &run.stderr,
657 ));
658 println!(
659 "\n=== Harness exited with code {code} ({count}/{threshold}). Continuing to let Ralph fix it... ===\n",
660 code = run.exit_code,
661 count = harness_error_count,
662 threshold = opts.error_threshold
663 );
664 continue;
665 }
666
667 retriable_retry_count = 0;
669
670 if !opts.no_commit {
671 if file_changes_count > 0 {
672 commit_iteration(&process_runner, iteration, &resolved_cwd.path)?;
673 } else {
674 println!(
675 "No git changes detected after iteration {iter}; skipping commit.",
676 iter = iteration
677 );
678 }
679 }
680
681 let timestamp = now_ms()?;
682 let duration = started.elapsed().as_millis() as i64;
683 state.history.push(RalphHistoryEntry {
684 timestamp,
685 duration,
686 completion_promise_found: completion_found,
687 file_changes_count,
688 });
689 state.iteration = iteration;
690 save_state(effective_ito_path, &change_id, &state)?;
691
692 if completion_found && iteration >= opts.min_iterations {
693 if opts.skip_validation {
694 println!("\n=== Warning: --skip-validation set. Completion is not verified. ===\n");
695 println!(
696 "\n=== Completion promise \"{p}\" detected. Loop complete. ===\n",
697 p = opts.completion_promise
698 );
699 return Ok(());
700 }
701
702 let change_id_opt = if unscoped_target {
703 None
704 } else {
705 Some(change_id.as_str())
706 };
707
708 let fs_task_repo;
711 let task_repo_for_validation: &dyn DomainTaskRepository =
712 if should_validate_tasks_from_effective_worktree(
713 change_id_opt,
714 ito_path,
715 effective_ito_path,
716 ) {
717 fs_task_repo = FsTaskRepository::new(effective_ito_path);
718 &fs_task_repo
719 } else {
720 task_repo
721 };
722
723 let report = validate_completion(
724 effective_ito_path,
725 task_repo_for_validation,
726 change_id_opt,
727 opts.validation_command.as_deref(),
728 )?;
729 if report.passed {
730 println!(
731 "\n=== Completion promise \"{p}\" detected (validated). Loop complete. ===\n",
732 p = opts.completion_promise
733 );
734 return Ok(());
735 }
736 last_validation_failure = Some(report.context_markdown);
737 println!(
738 "\n=== Completion promise detected, but validation failed. Continuing... ===\n"
739 );
740 }
741 }
742
743 Ok(())
744}
745
746fn module_changes(
747 change_repo: &(impl DomainChangeRepository + ?Sized),
748 module_id: &str,
749) -> CoreResult<Vec<ChangeSummary>> {
750 let changes = change_repo.list_by_module(module_id).into_core()?;
751 if changes.is_empty() {
752 return Err(CoreError::NotFound(format!(
753 "No changes found for module {module}",
754 module = module_id
755 )));
756 }
757 Ok(changes)
758}
759
760fn module_ready_change_ids(changes: &[ChangeSummary]) -> Vec<String> {
761 let mut ready_change_ids = Vec::new();
762 for change in changes {
763 if change.is_ready() {
764 ready_change_ids.push(change.id.clone());
765 }
766 }
767 ready_change_ids
768}
769
770fn unprocessed_change_ids(change_ids: &[String], processed: &BTreeSet<String>) -> Vec<String> {
771 let mut filtered = Vec::new();
772 for change_id in change_ids {
773 if !processed.contains(change_id) {
774 filtered.push(change_id.clone());
775 }
776 }
777 filtered
778}
779
780fn repo_changes(
781 change_repo: &(impl DomainChangeRepository + ?Sized),
782) -> CoreResult<Vec<ChangeSummary>> {
783 change_repo.list().into_core()
784}
785
786fn repo_eligible_change_ids(changes: &[ChangeSummary]) -> Vec<String> {
787 let mut eligible_change_ids = Vec::new();
788 for change in changes {
789 let work_status = change.work_status();
790 if work_status == ChangeWorkStatus::Ready || work_status == ChangeWorkStatus::InProgress {
791 eligible_change_ids.push(change.id.clone());
792 }
793 }
794 eligible_change_ids.sort();
795 eligible_change_ids
796}
797
798fn repo_incomplete_change_ids(changes: &[ChangeSummary]) -> Vec<String> {
799 let mut incomplete_change_ids = Vec::new();
800 for change in changes {
801 if change.work_status() != ChangeWorkStatus::Complete {
802 incomplete_change_ids.push(change.id.clone());
803 }
804 }
805 incomplete_change_ids.sort();
806 incomplete_change_ids
807}
808
809fn print_eligible_changes(eligible_changes: &[String]) {
810 println!("\nEligible changes (ready or in-progress):");
811 if eligible_changes.is_empty() {
812 println!(" (none)");
813 return;
814 }
815
816 for (idx, change_id) in eligible_changes.iter().enumerate() {
817 if idx == 0 {
818 println!(" - {change} (selected first)", change = change_id);
819 continue;
820 }
821 println!(" - {change}", change = change_id);
822 }
823}
824
825fn module_incomplete_change_ids(changes: &[ChangeSummary]) -> Vec<String> {
826 let mut incomplete_change_ids = Vec::new();
827 for change in changes {
828 if change.work_status() != ChangeWorkStatus::Complete {
829 incomplete_change_ids.push(change.id.clone());
830 }
831 }
832 incomplete_change_ids
833}
834
835fn print_ready_changes(module_id: &str, ready_changes: &[String]) {
836 println!("\nReady changes for module {module}:", module = module_id);
837 if ready_changes.is_empty() {
838 println!(" (none)");
839 return;
840 }
841
842 for (idx, change_id) in ready_changes.iter().enumerate() {
843 if idx == 0 {
844 println!(" - {change} (selected first)", change = change_id);
845 continue;
846 }
847 println!(" - {change}", change = change_id);
848 }
849}
850
851#[derive(Debug)]
852struct CompletionValidationReport {
853 passed: bool,
854 context_markdown: String,
855}
856
857fn validate_completion(
858 ito_path: &Path,
859 task_repo: &dyn DomainTaskRepository,
860 change_id: Option<&str>,
861 extra_command: Option<&str>,
862) -> CoreResult<CompletionValidationReport> {
863 let mut passed = true;
864 let mut sections: Vec<String> = Vec::new();
865
866 if let Some(change_id) = change_id {
867 let task = validation::check_task_completion(task_repo, change_id)?;
868 sections.push(render_validation_result("Ito task status", &task));
869 if !task.success {
870 passed = false;
871 }
872
873 let audit_report = crate::audit::run_reconcile(ito_path, Some(change_id), false);
875 if !audit_report.drifts.is_empty() {
876 let drift_lines: Vec<String> = audit_report
877 .drifts
878 .iter()
879 .map(|d| format!(" - {d}"))
880 .collect();
881 sections.push(format!(
882 "### Audit consistency\n\n- Result: WARN\n- Summary: {} drift items detected between audit log and file state\n\n{}",
883 audit_report.drifts.len(),
884 drift_lines.join("\n")
885 ));
886 }
887 } else {
888 sections.push(
889 "### Ito task status\n\n- Result: SKIP\n- Summary: No change selected; skipped task validation"
890 .to_string(),
891 );
892 }
893
894 let timeout = Duration::from_secs(5 * 60);
895 let project = validation::run_project_validation(ito_path, timeout)?;
896 sections.push(render_validation_result("Project validation", &project));
897 if !project.success {
898 passed = false;
899 }
900
901 if let Some(cmd) = extra_command {
902 let project_root = ito_path.parent().unwrap_or_else(|| Path::new("."));
903 let extra = validation::run_extra_validation(project_root, cmd, timeout)?;
904 sections.push(render_validation_result("Extra validation", &extra));
905 if !extra.success {
906 passed = false;
907 }
908 }
909
910 Ok(CompletionValidationReport {
911 passed,
912 context_markdown: sections.join("\n\n"),
913 })
914}
915
916fn should_validate_tasks_from_effective_worktree(
917 change_id: Option<&str>,
918 ito_path: &Path,
919 effective_ito_path: &Path,
920) -> bool {
921 change_id.is_some() && effective_ito_path != ito_path
922}
923
924fn render_validation_result(title: &str, r: &validation::ValidationResult) -> String {
925 let mut md = String::new();
926 md.push_str(&format!("### {title}\n\n"));
927 md.push_str(&format!(
928 "- Result: {}\n",
929 if r.success { "PASS" } else { "FAIL" }
930 ));
931 md.push_str(&format!("- Summary: {}\n", r.message.trim()));
932 if let Some(out) = r.output.as_deref() {
933 let out = out.trim();
934 if !out.is_empty() {
935 md.push_str("\nOutput:\n\n```text\n");
936 md.push_str(out);
937 md.push_str("\n```\n");
938 }
939 }
940 md
941}
942
943fn render_harness_failure(name: &str, exit_code: i32, stdout: &str, stderr: &str) -> String {
944 let mut md = String::new();
945 md.push_str("### Harness execution\n\n");
946 md.push_str("- Result: FAIL\n");
947 md.push_str(&format!("- Harness: {name}\n"));
948 md.push_str(&format!("- Exit code: {code}\n", code = exit_code));
949
950 let stdout = stdout.trim();
951 if !stdout.is_empty() {
952 md.push_str("\nStdout:\n\n```text\n");
953 md.push_str(stdout);
954 md.push_str("\n```\n");
955 }
956
957 let stderr = stderr.trim();
958 if !stderr.is_empty() {
959 md.push_str("\nStderr:\n\n```text\n");
960 md.push_str(stderr);
961 md.push_str("\n```\n");
962 }
963
964 md
965}
966
967fn completion_promise_found(stdout: &str, token: &str) -> bool {
968 let mut rest = stdout;
969 loop {
970 let Some(start) = rest.find("<promise>") else {
971 return false;
972 };
973 let after_start = &rest[start + "<promise>".len()..];
974 let Some(end) = after_start.find("</promise>") else {
975 return false;
976 };
977 let inner = &after_start[..end];
978 if inner.trim() == token {
979 return true;
980 }
981
982 rest = &after_start[end + "</promise>".len()..];
983 }
984}
985
986fn resolve_target(
987 change_repo: &(impl DomainChangeRepository + ?Sized),
988 change_id: Option<String>,
989 module_id: Option<String>,
990 interactive: bool,
991) -> CoreResult<(String, String)> {
992 if let Some(change) = change_id {
994 let change = match change_repo.resolve_target(&change) {
995 ChangeTargetResolution::Unique(id) => id,
996 ChangeTargetResolution::Ambiguous(matches) => {
997 return Err(CoreError::Validation(format!(
998 "Change '{change}' is ambiguous. Matches: {}",
999 matches.join(", ")
1000 )));
1001 }
1002 ChangeTargetResolution::NotFound => {
1003 return Err(CoreError::NotFound(format!("Change '{change}' not found")));
1004 }
1005 };
1006 let module = infer_module_from_change(&change)?;
1007 return Ok((change, module));
1008 }
1009
1010 if let Some(module) = module_id {
1011 let changes = change_repo.list_by_module(&module).into_core()?;
1012 if changes.is_empty() {
1013 return Err(CoreError::NotFound(format!(
1014 "No changes found for module {module}",
1015 module = module
1016 )));
1017 }
1018
1019 let ready_changes = module_ready_change_ids(&changes);
1020 if let Some(change_id) = ready_changes.first() {
1021 return Ok((change_id.clone(), infer_module_from_change(change_id)?));
1022 }
1023
1024 let incomplete = module_incomplete_change_ids(&changes);
1025
1026 if incomplete.is_empty() {
1027 return Err(CoreError::Validation(format!(
1028 "Module {module} has no ready changes because all changes are complete",
1029 module = module
1030 )));
1031 }
1032
1033 return Err(CoreError::Validation(format!(
1034 "Module {module} has no ready changes. Remaining non-complete changes: {}",
1035 incomplete.join(", "),
1036 module = module
1037 )));
1038 }
1039
1040 let msg = if interactive {
1041 "No change selected. Provide --change or --module (or run `ito ralph` interactively to select a change)."
1042 } else {
1043 "No change selected. Provide --change or --module."
1044 };
1045
1046 Err(CoreError::Validation(msg.into()))
1047}
1048
1049fn infer_module_from_change(change_id: &str) -> CoreResult<String> {
1050 let Some((module, _rest)) = change_id.split_once('-') else {
1051 return Err(CoreError::Validation(format!(
1052 "Invalid change ID format: {id}",
1053 id = change_id
1054 )));
1055 };
1056 Ok(module.to_string())
1057}
1058
1059fn now_ms() -> CoreResult<i64> {
1060 let dur = SystemTime::now()
1061 .duration_since(UNIX_EPOCH)
1062 .map_err(|e| CoreError::Process(format!("Clock error: {e}")))?;
1063 Ok(dur.as_millis() as i64)
1064}
1065
1066fn count_git_changes(runner: &dyn ProcessRunner, cwd: &Path) -> CoreResult<usize> {
1067 let request = ProcessRequest::new("git")
1068 .args(["status", "--porcelain"])
1069 .current_dir(cwd.to_path_buf());
1070 let out = runner
1071 .run(&request)
1072 .map_err(|e| CoreError::Process(format!("Failed to run git status: {e}")))?;
1073 if !out.success {
1074 let err = out.stderr;
1076 if !err.is_empty() {
1077 eprint!("{}", err);
1078 }
1079 return Ok(0);
1080 }
1081 let s = out.stdout;
1082 let mut line_count = 0;
1083 for line in s.lines() {
1084 if !line.trim().is_empty() {
1085 line_count += 1;
1086 }
1087 }
1088 Ok(line_count)
1089}
1090
1091fn commit_iteration(runner: &dyn ProcessRunner, iteration: u32, cwd: &Path) -> CoreResult<()> {
1092 let state_before_add = git_status_state(runner, cwd)?;
1093 if !state_before_add.has_working_tree_changes {
1094 return Ok(());
1095 }
1096
1097 let add_request = ProcessRequest::new("git")
1098 .args(["add", "-A"])
1099 .current_dir(cwd.to_path_buf());
1100 let add = runner
1101 .run(&add_request)
1102 .map_err(|e| CoreError::Process(format!("Failed to run git add: {e}")))?;
1103 if !add.success {
1104 let stdout = add.stdout.trim().to_string();
1105 let stderr = add.stderr.trim().to_string();
1106 let mut msg = String::from("git add failed");
1107 if !stdout.is_empty() {
1108 msg.push_str("\nstdout:\n");
1109 msg.push_str(&stdout);
1110 }
1111 if !stderr.is_empty() {
1112 msg.push_str("\nstderr:\n");
1113 msg.push_str(&stderr);
1114 }
1115 return Err(CoreError::Process(msg));
1116 }
1117
1118 let state_after_add = git_status_state(runner, cwd)?;
1119 if !state_after_add.has_staged_changes {
1120 return Ok(());
1121 }
1122
1123 let msg = format!("Ralph loop iteration {iteration}");
1124 let commit_request = ProcessRequest::new("git")
1125 .args(["commit", "-m", &msg])
1126 .current_dir(cwd.to_path_buf());
1127 let commit = runner
1128 .run(&commit_request)
1129 .map_err(|e| CoreError::Process(format!("Failed to run git commit: {e}")))?;
1130 if !commit.success {
1131 let stdout = commit.stdout.trim().to_string();
1132 let stderr = commit.stderr.trim().to_string();
1133
1134 let state_after_failed_commit = git_status_state(runner, cwd)?;
1135 if !state_after_failed_commit.has_staged_changes {
1136 return Ok(());
1137 }
1138
1139 let mut msg = format!("git commit failed for iteration {iteration}");
1140 if !stdout.is_empty() {
1141 msg.push_str("\nstdout:\n");
1142 msg.push_str(&stdout);
1143 }
1144 if !stderr.is_empty() {
1145 msg.push_str("\nstderr:\n");
1146 msg.push_str(&stderr);
1147 }
1148 return Err(CoreError::Process(msg));
1149 }
1150 Ok(())
1151}
1152
1153#[derive(Debug, Default, Clone, Copy)]
1154struct GitStatusState {
1155 has_staged_changes: bool,
1156 has_working_tree_changes: bool,
1157}
1158
1159fn git_status_state(runner: &dyn ProcessRunner, cwd: &Path) -> CoreResult<GitStatusState> {
1160 let request = ProcessRequest::new("git")
1161 .args(["status", "--porcelain"])
1162 .current_dir(cwd.to_path_buf());
1163 let out = runner
1164 .run(&request)
1165 .map_err(|e| CoreError::Process(format!("Failed to run git status: {e}")))?;
1166 if !out.success {
1167 let stdout = out.stdout.trim().to_string();
1168 let stderr = out.stderr.trim().to_string();
1169 let mut msg = String::from("git status failed");
1170 if !stdout.is_empty() {
1171 msg.push_str("\nstdout:\n");
1172 msg.push_str(&stdout);
1173 }
1174 if !stderr.is_empty() {
1175 msg.push_str("\nstderr:\n");
1176 msg.push_str(&stderr);
1177 }
1178 return Err(CoreError::Process(msg));
1179 }
1180
1181 let mut state = GitStatusState::default();
1182 for line in out.stdout.lines() {
1183 if line.trim().is_empty() {
1184 continue;
1185 }
1186
1187 state.has_working_tree_changes = true;
1188
1189 let mut chars = line.chars();
1190 let index_status = chars.next().unwrap_or(' ');
1191 if index_status != ' ' && index_status != '?' {
1192 state.has_staged_changes = true;
1193 }
1194 }
1195
1196 Ok(state)
1197}
1198
1199#[cfg(test)]
1200mod runner_tests;