Skip to main content

ito_core/ralph/
runner.rs

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 ito_domain::changes::{
14    ChangeRepository as DomainChangeRepository, ChangeSummary, ChangeTargetResolution,
15    ChangeWorkStatus,
16};
17use ito_domain::modules::ModuleRepository as DomainModuleRepository;
18use ito_domain::tasks::TaskRepository as DomainTaskRepository;
19use std::path::{Path, PathBuf};
20use std::time::{Duration, SystemTime, UNIX_EPOCH};
21
22/// Worktree configuration subset needed for Ralph's working directory resolution.
23#[derive(Debug, Clone, Default)]
24pub struct WorktreeConfig {
25    /// Whether worktree-based workflows are enabled for this project.
26    pub enabled: bool,
27    /// The directory name where change worktrees live (e.g. `ito-worktrees`).
28    ///
29    /// Not currently used in resolution logic (branch lookup via `git worktree
30    /// list` does not need this), but carried for future use such as
31    /// constructing expected worktree paths without invoking git.
32    pub dir_name: String,
33}
34
35#[derive(Debug, Clone)]
36/// Runtime options for a single Ralph loop invocation.
37pub struct RalphOptions {
38    /// Base prompt content appended after any change/module context.
39    pub prompt: String,
40
41    /// Optional change id to scope the loop to.
42    pub change_id: Option<String>,
43
44    /// Optional module id to scope the loop to.
45    pub module_id: Option<String>,
46
47    /// Optional model override passed through to the harness.
48    pub model: Option<String>,
49
50    /// Minimum number of iterations required before a completion promise is honored.
51    pub min_iterations: u32,
52
53    /// Optional maximum iteration count.
54    pub max_iterations: Option<u32>,
55
56    /// Completion token that signals the loop is done (e.g. `COMPLETE`).
57    pub completion_promise: String,
58
59    /// Auto-approve all harness prompts and actions.
60    pub allow_all: bool,
61
62    /// Skip creating a git commit after each iteration.
63    pub no_commit: bool,
64
65    /// Enable interactive mode when supported by the harness.
66    pub interactive: bool,
67
68    /// Print the current saved state without running a new iteration.
69    pub status: bool,
70
71    /// Append additional markdown to the saved Ralph context and exit.
72    pub add_context: Option<String>,
73
74    /// Clear any saved Ralph context and exit.
75    pub clear_context: bool,
76
77    /// Print the full prompt sent to the harness.
78    pub verbose: bool,
79
80    /// When targeting a module, continue through ready changes until module work is complete.
81    pub continue_module: bool,
82
83    /// When set, continuously process eligible changes across the repo.
84    ///
85    /// Eligible changes are those whose derived work status is `Ready` or `InProgress`.
86    pub continue_ready: bool,
87
88    /// Inactivity timeout - restart iteration if no output for this duration.
89    pub inactivity_timeout: Option<Duration>,
90
91    /// Skip all completion validation.
92    ///
93    /// When set, the loop trusts the completion promise and exits immediately.
94    pub skip_validation: bool,
95
96    /// Additional validation command to run when a completion promise is detected.
97    ///
98    /// This runs after the project validation steps.
99    pub validation_command: Option<String>,
100
101    /// Exit immediately when the harness process returns non-zero.
102    ///
103    /// When false, Ralph captures the failure output and continues iterating.
104    pub exit_on_error: bool,
105
106    /// Maximum number of non-zero harness exits allowed before failing.
107    ///
108    /// Applies only when `exit_on_error` is false.
109    pub error_threshold: u32,
110
111    /// Worktree configuration for working directory resolution.
112    pub worktree: WorktreeConfig,
113}
114
115/// Default maximum number of non-zero harness exits Ralph tolerates.
116pub const DEFAULT_ERROR_THRESHOLD: u32 = 10;
117
118/// Resolved working directory for a Ralph invocation.
119///
120/// Bundles the effective working directory path with the `.ito` directory
121/// that should be used for state file writes.
122#[derive(Debug, Clone)]
123pub struct ResolvedCwd {
124    /// The directory where the harness and git commands should execute.
125    pub path: PathBuf,
126    /// The `.ito` directory for state file writes (may differ from the
127    /// process's `.ito` when a worktree is resolved).
128    pub ito_path: PathBuf,
129}
130
131/// Resolve the effective working directory for a Ralph invocation.
132///
133/// When worktrees are enabled and a matching worktree exists for
134/// `change_id`, returns the worktree path. Otherwise falls back to the
135/// process's current working directory.
136pub fn resolve_effective_cwd(
137    ito_path: &Path,
138    change_id: Option<&str>,
139    worktree: &WorktreeConfig,
140) -> ResolvedCwd {
141    let lookup = |branch: &str| crate::audit::worktree::find_worktree_for_branch(branch);
142    let fallback_path = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
143    resolve_effective_cwd_with(ito_path, change_id, worktree, fallback_path, lookup)
144}
145
146/// Testable core of [`resolve_effective_cwd`].
147///
148/// Accepts an explicit fallback path and a worktree lookup function so
149/// callers can inject test doubles.
150fn resolve_effective_cwd_with(
151    ito_path: &Path,
152    change_id: Option<&str>,
153    worktree: &WorktreeConfig,
154    fallback_path: PathBuf,
155    lookup: impl Fn(&str) -> Option<PathBuf>,
156) -> ResolvedCwd {
157    let fallback = ResolvedCwd {
158        path: fallback_path,
159        ito_path: ito_path.to_path_buf(),
160    };
161
162    let wt_path = if worktree.enabled {
163        change_id.and_then(lookup)
164    } else {
165        None
166    };
167
168    let Some(wt_path) = wt_path else {
169        return fallback;
170    };
171
172    let wt_ito_path = wt_path.join(".ito");
173    ResolvedCwd {
174        path: wt_path,
175        ito_path: wt_ito_path,
176    }
177}
178
179/// Run the Ralph loop for a change (or repository/module sequence) until the configured completion promise is detected.
180///
181/// Persists lightweight per-change state under `.ito/.state/ralph/<change>/` so iteration history and context are available for inspection.
182///
183/// # Examples
184///
185/// ```no_run
186/// use std::path::Path;
187///
188/// // Prepare repositories, options and a harness implementing the required traits,
189/// // then invoke run_ralph with the workspace path:
190/// // let ito = Path::new(".");
191/// // run_ralph(ito, &change_repo, &task_repo, &module_repo, opts, &mut harness)?;
192/// ```
193pub fn run_ralph(
194    ito_path: &Path,
195    change_repo: &impl DomainChangeRepository,
196    task_repo: &impl DomainTaskRepository,
197    module_repo: &impl DomainModuleRepository,
198    opts: RalphOptions,
199    harness: &mut dyn Harness,
200) -> CoreResult<()> {
201    let process_runner = SystemProcessRunner;
202
203    if opts.continue_ready {
204        if opts.continue_module {
205            return Err(CoreError::Validation(
206                "--continue-ready cannot be used with --continue-module".into(),
207            ));
208        }
209        if opts.change_id.is_some() || opts.module_id.is_some() {
210            return Err(CoreError::Validation(
211                "--continue-ready cannot be used with --change or --module".into(),
212            ));
213        }
214        if opts.status || opts.add_context.is_some() || opts.clear_context {
215            return Err(CoreError::Validation(
216                "--continue-ready cannot be combined with --status, --add-context, or --clear-context".into(),
217            ));
218        }
219
220        loop {
221            let current_changes = repo_changes(change_repo)?;
222            let eligible_changes = repo_eligible_change_ids(&current_changes);
223            print_eligible_changes(&eligible_changes);
224
225            if eligible_changes.is_empty() {
226                let incomplete = repo_incomplete_change_ids(&current_changes);
227                if incomplete.is_empty() {
228                    println!("\nAll changes are complete.");
229                    return Ok(());
230                }
231
232                return Err(CoreError::Validation(format!(
233                    "Repository has no eligible changes. Remaining non-complete changes: {}",
234                    incomplete.join(", ")
235                )));
236            }
237
238            let mut next_change = eligible_changes[0].clone();
239
240            let preflight_changes = repo_changes(change_repo)?;
241            let preflight_eligible = repo_eligible_change_ids(&preflight_changes);
242            if preflight_eligible.is_empty() {
243                let incomplete = repo_incomplete_change_ids(&preflight_changes);
244                if incomplete.is_empty() {
245                    println!("\nAll changes are complete.");
246                    return Ok(());
247                }
248                return Err(CoreError::Validation(format!(
249                    "Repository changed during selection and now has no eligible changes. Remaining non-complete changes: {}",
250                    incomplete.join(", ")
251                )));
252            }
253            let preflight_first = preflight_eligible[0].clone();
254            if preflight_first != next_change {
255                println!(
256                    "\nRepository state shifted before start; reorienting from {from} to {to}.",
257                    from = next_change,
258                    to = preflight_first
259                );
260                next_change = preflight_first;
261            }
262
263            println!(
264                "\nStarting change {change} (lowest eligible change id).",
265                change = next_change
266            );
267
268            let mut single_opts = opts.clone();
269            single_opts.continue_ready = false;
270            single_opts.change_id = Some(next_change);
271
272            run_ralph(
273                ito_path,
274                change_repo,
275                task_repo,
276                module_repo,
277                single_opts,
278                harness,
279            )?;
280        }
281    }
282
283    if opts.continue_module {
284        if opts.change_id.is_some() {
285            return Err(CoreError::Validation(
286                "--continue-module cannot be used with --change. Use --module only.".into(),
287            ));
288        }
289        let Some(module_id) = opts.module_id.clone() else {
290            return Err(CoreError::Validation(
291                "--continue-module requires --module".into(),
292            ));
293        };
294        if opts.status || opts.add_context.is_some() || opts.clear_context {
295            return Err(CoreError::Validation(
296                "--continue-module cannot be combined with --status, --add-context, or --clear-context".into()
297            ));
298        }
299
300        loop {
301            let current_changes = module_changes(change_repo, &module_id)?;
302            let ready_changes = module_ready_change_ids(&current_changes);
303            print_ready_changes(&module_id, &ready_changes);
304
305            if ready_changes.is_empty() {
306                let incomplete = module_incomplete_change_ids(&current_changes);
307
308                if incomplete.is_empty() {
309                    println!("\nModule {module} is complete.", module = module_id);
310                    return Ok(());
311                }
312
313                return Err(CoreError::Validation(format!(
314                    "Module {module} has no ready changes. Remaining non-complete changes: {}",
315                    incomplete.join(", "),
316                    module = module_id
317                )));
318            }
319
320            let mut next_change = ready_changes[0].clone();
321
322            let preflight_changes = module_changes(change_repo, &module_id)?;
323            let preflight_ready = module_ready_change_ids(&preflight_changes);
324            if preflight_ready.is_empty() {
325                let incomplete = module_incomplete_change_ids(&preflight_changes);
326                if incomplete.is_empty() {
327                    println!("\nModule {module} is complete.", module = module_id);
328                    return Ok(());
329                }
330                return Err(CoreError::Validation(format!(
331                    "Module {module} changed during selection and now has no ready changes. Remaining non-complete changes: {}",
332                    incomplete.join(", "),
333                    module = module_id
334                )));
335            }
336            let preflight_first = preflight_ready[0].clone();
337            if preflight_first != next_change {
338                println!(
339                    "\nModule state shifted before start; reorienting from {from} to {to}.",
340                    from = next_change,
341                    to = preflight_first
342                );
343                next_change = preflight_first;
344            }
345
346            println!(
347                "\nStarting module change {change} (lowest ready change id).",
348                change = next_change
349            );
350
351            let mut single_opts = opts.clone();
352            single_opts.continue_module = false;
353            single_opts.continue_ready = false;
354            single_opts.change_id = Some(next_change);
355
356            run_ralph(
357                ito_path,
358                change_repo,
359                task_repo,
360                module_repo,
361                single_opts,
362                harness,
363            )?;
364
365            let post_changes = module_changes(change_repo, &module_id)?;
366            let post_ready = module_ready_change_ids(&post_changes);
367            print_ready_changes(&module_id, &post_ready);
368        }
369    }
370
371    if opts.change_id.is_none()
372        && let Some(module_id) = opts.module_id.as_deref()
373        && !opts.status
374        && opts.add_context.is_none()
375        && !opts.clear_context
376    {
377        let module_changes = module_changes(change_repo, module_id)?;
378        let ready_changes = module_ready_change_ids(&module_changes);
379        print_ready_changes(module_id, &ready_changes);
380    }
381
382    let unscoped_target = opts.change_id.is_none() && opts.module_id.is_none();
383
384    // Resolve worktree-aware working directory (task 2.1).
385    // Done before target resolution so the change_id raw value can be used for lookup.
386    let resolved_cwd = resolve_effective_cwd(ito_path, opts.change_id.as_deref(), &opts.worktree);
387    let effective_ito_path = &resolved_cwd.ito_path;
388
389    if opts.verbose {
390        if effective_ito_path != ito_path {
391            println!("Resolved worktree: {}", resolved_cwd.path.display());
392        } else {
393            println!(
394                "Using current working directory: {}",
395                resolved_cwd.path.display()
396            );
397        }
398    }
399
400    let (change_id, module_id) = if unscoped_target {
401        ("unscoped".to_string(), "unscoped".to_string())
402    } else {
403        resolve_target(
404            change_repo,
405            opts.change_id,
406            opts.module_id,
407            opts.interactive,
408        )?
409    };
410
411    if opts.status {
412        let state = load_state(effective_ito_path, &change_id)?;
413        if let Some(state) = state {
414            println!("\n=== Ralph Status for {id} ===\n", id = state.change_id);
415            println!("Iteration: {iter}", iter = state.iteration);
416            println!("History entries: {n}", n = state.history.len());
417            if !state.history.is_empty() {
418                println!("\nRecent iterations:");
419                let n = state.history.len();
420                let start = n.saturating_sub(5);
421                for (i, h) in state.history.iter().enumerate().skip(start) {
422                    println!(
423                        "  {idx}: duration={dur}ms, changes={chg}, promise={p}",
424                        idx = i + 1,
425                        dur = h.duration,
426                        chg = h.file_changes_count,
427                        p = h.completion_promise_found
428                    );
429                }
430            }
431        } else {
432            println!("\n=== Ralph Status for {id} ===\n", id = change_id);
433            println!("No state found");
434        }
435        return Ok(());
436    }
437
438    if let Some(text) = opts.add_context.as_deref() {
439        append_context(effective_ito_path, &change_id, text)?;
440        println!("Added context to {id}", id = change_id);
441        return Ok(());
442    }
443    if opts.clear_context {
444        clear_context(effective_ito_path, &change_id)?;
445        println!("Cleared Ralph context for {id}", id = change_id);
446        return Ok(());
447    }
448
449    let ito_dir_name = effective_ito_path
450        .file_name()
451        .map(|s| s.to_string_lossy().to_string())
452        .unwrap_or_else(|| ".ito".to_string());
453    let context_file = format!(
454        "{ito_dir}/.state/ralph/{change}/context.md",
455        ito_dir = ito_dir_name,
456        change = change_id
457    );
458
459    let mut state = load_state(effective_ito_path, &change_id)?.unwrap_or(RalphState {
460        change_id: change_id.clone(),
461        iteration: 0,
462        history: vec![],
463        context_file,
464    });
465
466    let max_iters = opts.max_iterations.unwrap_or(u32::MAX);
467    if max_iters == 0 {
468        return Err(CoreError::Validation(
469            "--max-iterations must be >= 1".into(),
470        ));
471    }
472    if opts.error_threshold == 0 {
473        return Err(CoreError::Validation(
474            "--error-threshold must be >= 1".into(),
475        ));
476    }
477
478    // Print startup message so user knows something is happening
479    println!(
480        "\n=== Starting Ralph for {change} (harness: {harness}) ===",
481        change = change_id,
482        harness = harness.name()
483    );
484    if let Some(model) = &opts.model {
485        println!("Model: {model}");
486    }
487    if let Some(max) = opts.max_iterations {
488        println!("Max iterations: {max}");
489    }
490    if opts.allow_all {
491        println!("Mode: --yolo (auto-approve all)");
492    }
493    if let Some(timeout) = opts.inactivity_timeout {
494        println!("Inactivity timeout: {}", format_duration(timeout));
495    }
496    println!();
497
498    let mut last_validation_failure: Option<String> = None;
499    let mut harness_error_count: u32 = 0;
500    let mut retriable_retry_count: u32 = 0;
501
502    for _ in 0..max_iters {
503        let iteration = state.iteration.saturating_add(1);
504
505        println!("\n=== Ralph Loop Iteration {i} ===\n", i = iteration);
506
507        let context_content = load_context(effective_ito_path, &change_id)?;
508        let prompt = build_ralph_prompt(
509            effective_ito_path,
510            change_repo,
511            module_repo,
512            &opts.prompt,
513            BuildPromptOptions {
514                change_id: if unscoped_target {
515                    None
516                } else {
517                    Some(change_id.clone())
518                },
519                module_id: if unscoped_target {
520                    None
521                } else {
522                    Some(module_id.clone())
523                },
524                iteration: Some(iteration),
525                max_iterations: opts.max_iterations,
526                min_iterations: opts.min_iterations,
527                completion_promise: opts.completion_promise.clone(),
528                context_content: Some(context_content),
529                validation_failure: last_validation_failure.clone(),
530            },
531        )?;
532
533        if opts.verbose {
534            println!("--- Prompt sent to harness ---");
535            println!("{}", prompt);
536            println!("--- End of prompt ---\n");
537        }
538
539        let started = std::time::Instant::now();
540        let run = harness
541            .run(&crate::harness::HarnessRunConfig {
542                prompt,
543                model: opts.model.clone(),
544                cwd: resolved_cwd.path.clone(),
545                env: std::collections::BTreeMap::new(),
546                interactive: opts.interactive && !opts.allow_all,
547                allow_all: opts.allow_all,
548                inactivity_timeout: opts.inactivity_timeout,
549            })
550            .map_err(|e| CoreError::Process(format!("Harness execution failed: {e}")))?;
551
552        // Pass through output if harness didn't already stream it
553        if !harness.streams_output() {
554            if !run.stdout.is_empty() {
555                print!("{}", run.stdout);
556            }
557            if !run.stderr.is_empty() {
558                eprint!("{}", run.stderr);
559            }
560        }
561
562        // Mirror TS: completion promise is detected from stdout (not stderr).
563        let completion_found = completion_promise_found(&run.stdout, &opts.completion_promise);
564
565        let file_changes_count = if harness.name() != HarnessName::Stub {
566            count_git_changes(&process_runner, &resolved_cwd.path)? as u32
567        } else {
568            0
569        };
570
571        // Handle timeout - log and continue to next iteration
572        if run.timed_out {
573            println!("\n=== Inactivity timeout reached. Restarting iteration... ===\n");
574            retriable_retry_count = 0;
575            // Don't update state for timed out iterations, just retry
576            continue;
577        }
578
579        if run.exit_code != 0 {
580            if run.is_retriable() {
581                retriable_retry_count = retriable_retry_count.saturating_add(1);
582                if retriable_retry_count > MAX_RETRIABLE_RETRIES {
583                    return Err(CoreError::Process(format!(
584                        "Harness '{name}' crashed {count} consecutive times (exit code {code}); giving up",
585                        name = harness.name(),
586                        count = retriable_retry_count,
587                        code = run.exit_code
588                    )));
589                }
590                println!(
591                    "\n=== Harness process crashed (exit code {code}, attempt {count}/{max}). Retrying... ===\n",
592                    code = run.exit_code,
593                    count = retriable_retry_count,
594                    max = MAX_RETRIABLE_RETRIES
595                );
596                continue;
597            }
598
599            // Non-retriable non-zero exit: reset the consecutive crash counter.
600            retriable_retry_count = 0;
601
602            if opts.exit_on_error {
603                return Err(CoreError::Process(format!(
604                    "Harness '{name}' exited with code {code}",
605                    name = harness.name(),
606                    code = run.exit_code
607                )));
608            }
609
610            harness_error_count = harness_error_count.saturating_add(1);
611            if harness_error_count >= opts.error_threshold {
612                return Err(CoreError::Process(format!(
613                    "Harness '{name}' exceeded non-zero exit threshold ({count}/{threshold}); last exit code {code}",
614                    name = harness.name(),
615                    count = harness_error_count,
616                    threshold = opts.error_threshold,
617                    code = run.exit_code
618                )));
619            }
620
621            last_validation_failure = Some(render_harness_failure(
622                harness.name().as_str(),
623                run.exit_code,
624                &run.stdout,
625                &run.stderr,
626            ));
627            println!(
628                "\n=== Harness exited with code {code} ({count}/{threshold}). Continuing to let Ralph fix it... ===\n",
629                code = run.exit_code,
630                count = harness_error_count,
631                threshold = opts.error_threshold
632            );
633            continue;
634        }
635
636        // Successful exit: reset both counters.
637        retriable_retry_count = 0;
638
639        if !opts.no_commit {
640            commit_iteration(&process_runner, iteration, &resolved_cwd.path)?;
641        }
642
643        let timestamp = now_ms()?;
644        let duration = started.elapsed().as_millis() as i64;
645        state.history.push(RalphHistoryEntry {
646            timestamp,
647            duration,
648            completion_promise_found: completion_found,
649            file_changes_count,
650        });
651        state.iteration = iteration;
652        save_state(effective_ito_path, &change_id, &state)?;
653
654        if completion_found && iteration >= opts.min_iterations {
655            if opts.skip_validation {
656                println!("\n=== Warning: --skip-validation set. Completion is not verified. ===\n");
657                println!(
658                    "\n=== Completion promise \"{p}\" detected. Loop complete. ===\n",
659                    p = opts.completion_promise
660                );
661                return Ok(());
662            }
663
664            let report = validate_completion(
665                effective_ito_path,
666                task_repo,
667                if unscoped_target {
668                    None
669                } else {
670                    Some(change_id.as_str())
671                },
672                opts.validation_command.as_deref(),
673            )?;
674            if report.passed {
675                println!(
676                    "\n=== Completion promise \"{p}\" detected (validated). Loop complete. ===\n",
677                    p = opts.completion_promise
678                );
679                return Ok(());
680            }
681
682            last_validation_failure = Some(report.context_markdown);
683            println!(
684                "\n=== Completion promise detected, but validation failed. Continuing... ===\n"
685            );
686        }
687    }
688
689    Ok(())
690}
691
692fn module_changes(
693    change_repo: &impl DomainChangeRepository,
694    module_id: &str,
695) -> CoreResult<Vec<ChangeSummary>> {
696    let changes = change_repo.list_by_module(module_id).into_core()?;
697    if changes.is_empty() {
698        return Err(CoreError::NotFound(format!(
699            "No changes found for module {module}",
700            module = module_id
701        )));
702    }
703    Ok(changes)
704}
705
706fn module_ready_change_ids(changes: &[ChangeSummary]) -> Vec<String> {
707    let mut ready_change_ids = Vec::new();
708    for change in changes {
709        if change.is_ready() {
710            ready_change_ids.push(change.id.clone());
711        }
712    }
713    ready_change_ids
714}
715
716fn repo_changes(change_repo: &impl DomainChangeRepository) -> CoreResult<Vec<ChangeSummary>> {
717    change_repo.list().into_core()
718}
719
720fn repo_eligible_change_ids(changes: &[ChangeSummary]) -> Vec<String> {
721    let mut eligible_change_ids = Vec::new();
722    for change in changes {
723        let work_status = change.work_status();
724        if work_status == ChangeWorkStatus::Ready || work_status == ChangeWorkStatus::InProgress {
725            eligible_change_ids.push(change.id.clone());
726        }
727    }
728    eligible_change_ids.sort();
729    eligible_change_ids
730}
731
732fn repo_incomplete_change_ids(changes: &[ChangeSummary]) -> Vec<String> {
733    let mut incomplete_change_ids = Vec::new();
734    for change in changes {
735        if change.work_status() != ChangeWorkStatus::Complete {
736            incomplete_change_ids.push(change.id.clone());
737        }
738    }
739    incomplete_change_ids.sort();
740    incomplete_change_ids
741}
742
743fn print_eligible_changes(eligible_changes: &[String]) {
744    println!("\nEligible changes (ready or in-progress):");
745    if eligible_changes.is_empty() {
746        println!("  (none)");
747        return;
748    }
749
750    for (idx, change_id) in eligible_changes.iter().enumerate() {
751        if idx == 0 {
752            println!("  - {change} (selected first)", change = change_id);
753            continue;
754        }
755        println!("  - {change}", change = change_id);
756    }
757}
758
759fn module_incomplete_change_ids(changes: &[ChangeSummary]) -> Vec<String> {
760    let mut incomplete_change_ids = Vec::new();
761    for change in changes {
762        if change.work_status() != ChangeWorkStatus::Complete {
763            incomplete_change_ids.push(change.id.clone());
764        }
765    }
766    incomplete_change_ids
767}
768
769fn print_ready_changes(module_id: &str, ready_changes: &[String]) {
770    println!("\nReady changes for module {module}:", module = module_id);
771    if ready_changes.is_empty() {
772        println!("  (none)");
773        return;
774    }
775
776    for (idx, change_id) in ready_changes.iter().enumerate() {
777        if idx == 0 {
778            println!("  - {change} (selected first)", change = change_id);
779            continue;
780        }
781        println!("  - {change}", change = change_id);
782    }
783}
784
785#[derive(Debug)]
786struct CompletionValidationReport {
787    passed: bool,
788    context_markdown: String,
789}
790
791fn validate_completion(
792    ito_path: &Path,
793    task_repo: &impl DomainTaskRepository,
794    change_id: Option<&str>,
795    extra_command: Option<&str>,
796) -> CoreResult<CompletionValidationReport> {
797    let mut passed = true;
798    let mut sections: Vec<String> = Vec::new();
799
800    if let Some(change_id) = change_id {
801        let task = validation::check_task_completion(task_repo, change_id)?;
802        sections.push(render_validation_result("Ito task status", &task));
803        if !task.success {
804            passed = false;
805        }
806
807        // Audit consistency check (warning only, does not fail validation)
808        let audit_report = crate::audit::run_reconcile(ito_path, Some(change_id), false);
809        if !audit_report.drifts.is_empty() {
810            let drift_lines: Vec<String> = audit_report
811                .drifts
812                .iter()
813                .map(|d| format!("  - {d}"))
814                .collect();
815            sections.push(format!(
816                "### Audit consistency\n\n- Result: WARN\n- Summary: {} drift items detected between audit log and file state\n\n{}",
817                audit_report.drifts.len(),
818                drift_lines.join("\n")
819            ));
820        }
821    } else {
822        sections.push(
823            "### Ito task status\n\n- Result: SKIP\n- Summary: No change selected; skipped task validation"
824                .to_string(),
825        );
826    }
827
828    let timeout = Duration::from_secs(5 * 60);
829    let project = validation::run_project_validation(ito_path, timeout)?;
830    sections.push(render_validation_result("Project validation", &project));
831    if !project.success {
832        passed = false;
833    }
834
835    if let Some(cmd) = extra_command {
836        let project_root = ito_path.parent().unwrap_or_else(|| Path::new("."));
837        let extra = validation::run_extra_validation(project_root, cmd, timeout)?;
838        sections.push(render_validation_result("Extra validation", &extra));
839        if !extra.success {
840            passed = false;
841        }
842    }
843
844    Ok(CompletionValidationReport {
845        passed,
846        context_markdown: sections.join("\n\n"),
847    })
848}
849
850fn render_validation_result(title: &str, r: &validation::ValidationResult) -> String {
851    let mut md = String::new();
852    md.push_str(&format!("### {title}\n\n"));
853    md.push_str(&format!(
854        "- Result: {}\n",
855        if r.success { "PASS" } else { "FAIL" }
856    ));
857    md.push_str(&format!("- Summary: {}\n", r.message.trim()));
858    if let Some(out) = r.output.as_deref() {
859        let out = out.trim();
860        if !out.is_empty() {
861            md.push_str("\nOutput:\n\n```text\n");
862            md.push_str(out);
863            md.push_str("\n```\n");
864        }
865    }
866    md
867}
868
869fn render_harness_failure(name: &str, exit_code: i32, stdout: &str, stderr: &str) -> String {
870    let mut md = String::new();
871    md.push_str("### Harness execution\n\n");
872    md.push_str("- Result: FAIL\n");
873    md.push_str(&format!("- Harness: {name}\n"));
874    md.push_str(&format!("- Exit code: {code}\n", code = exit_code));
875
876    let stdout = stdout.trim();
877    if !stdout.is_empty() {
878        md.push_str("\nStdout:\n\n```text\n");
879        md.push_str(stdout);
880        md.push_str("\n```\n");
881    }
882
883    let stderr = stderr.trim();
884    if !stderr.is_empty() {
885        md.push_str("\nStderr:\n\n```text\n");
886        md.push_str(stderr);
887        md.push_str("\n```\n");
888    }
889
890    md
891}
892
893fn completion_promise_found(stdout: &str, token: &str) -> bool {
894    let mut rest = stdout;
895    loop {
896        let Some(start) = rest.find("<promise>") else {
897            return false;
898        };
899        let after_start = &rest[start + "<promise>".len()..];
900        let Some(end) = after_start.find("</promise>") else {
901            return false;
902        };
903        let inner = &after_start[..end];
904        if inner.trim() == token {
905            return true;
906        }
907
908        rest = &after_start[end + "</promise>".len()..];
909    }
910}
911
912fn resolve_target(
913    change_repo: &impl DomainChangeRepository,
914    change_id: Option<String>,
915    module_id: Option<String>,
916    interactive: bool,
917) -> CoreResult<(String, String)> {
918    // If change is provided, resolve canonical ID and infer module.
919    if let Some(change) = change_id {
920        let change = match change_repo.resolve_target(&change) {
921            ChangeTargetResolution::Unique(id) => id,
922            ChangeTargetResolution::Ambiguous(matches) => {
923                return Err(CoreError::Validation(format!(
924                    "Change '{change}' is ambiguous. Matches: {}",
925                    matches.join(", ")
926                )));
927            }
928            ChangeTargetResolution::NotFound => {
929                return Err(CoreError::NotFound(format!("Change '{change}' not found")));
930            }
931        };
932        let module = infer_module_from_change(&change)?;
933        return Ok((change, module));
934    }
935
936    if let Some(module) = module_id {
937        let changes = change_repo.list_by_module(&module).into_core()?;
938        if changes.is_empty() {
939            return Err(CoreError::NotFound(format!(
940                "No changes found for module {module}",
941                module = module
942            )));
943        }
944
945        let ready_changes = module_ready_change_ids(&changes);
946        if let Some(change_id) = ready_changes.first() {
947            return Ok((change_id.clone(), infer_module_from_change(change_id)?));
948        }
949
950        let incomplete = module_incomplete_change_ids(&changes);
951
952        if incomplete.is_empty() {
953            return Err(CoreError::Validation(format!(
954                "Module {module} has no ready changes because all changes are complete",
955                module = module
956            )));
957        }
958
959        return Err(CoreError::Validation(format!(
960            "Module {module} has no ready changes. Remaining non-complete changes: {}",
961            incomplete.join(", "),
962            module = module
963        )));
964    }
965
966    let msg = if interactive {
967        "No change selected. Provide --change or --module (or run `ito ralph` interactively to select a change)."
968    } else {
969        "No change selected. Provide --change or --module."
970    };
971
972    Err(CoreError::Validation(msg.into()))
973}
974
975fn infer_module_from_change(change_id: &str) -> CoreResult<String> {
976    let Some((module, _rest)) = change_id.split_once('-') else {
977        return Err(CoreError::Validation(format!(
978            "Invalid change ID format: {id}",
979            id = change_id
980        )));
981    };
982    Ok(module.to_string())
983}
984
985fn now_ms() -> CoreResult<i64> {
986    let dur = SystemTime::now()
987        .duration_since(UNIX_EPOCH)
988        .map_err(|e| CoreError::Process(format!("Clock error: {e}")))?;
989    Ok(dur.as_millis() as i64)
990}
991
992fn count_git_changes(runner: &dyn ProcessRunner, cwd: &Path) -> CoreResult<usize> {
993    let request = ProcessRequest::new("git")
994        .args(["status", "--porcelain"])
995        .current_dir(cwd.to_path_buf());
996    let out = runner
997        .run(&request)
998        .map_err(|e| CoreError::Process(format!("Failed to run git status: {e}")))?;
999    if !out.success {
1000        // Match TS behavior: the git error output is visible to the user.
1001        let err = out.stderr;
1002        if !err.is_empty() {
1003            eprint!("{}", err);
1004        }
1005        return Ok(0);
1006    }
1007    let s = out.stdout;
1008    let mut line_count = 0;
1009    for line in s.lines() {
1010        if !line.trim().is_empty() {
1011            line_count += 1;
1012        }
1013    }
1014    Ok(line_count)
1015}
1016
1017fn commit_iteration(runner: &dyn ProcessRunner, iteration: u32, cwd: &Path) -> CoreResult<()> {
1018    let add_request = ProcessRequest::new("git")
1019        .args(["add", "-A"])
1020        .current_dir(cwd.to_path_buf());
1021    let add = runner
1022        .run(&add_request)
1023        .map_err(|e| CoreError::Process(format!("Failed to run git add: {e}")))?;
1024    if !add.success {
1025        let stdout = add.stdout.trim().to_string();
1026        let stderr = add.stderr.trim().to_string();
1027        let mut msg = String::from("git add failed");
1028        if !stdout.is_empty() {
1029            msg.push_str("\nstdout:\n");
1030            msg.push_str(&stdout);
1031        }
1032        if !stderr.is_empty() {
1033            msg.push_str("\nstderr:\n");
1034            msg.push_str(&stderr);
1035        }
1036        return Err(CoreError::Process(msg));
1037    }
1038
1039    let msg = format!("Ralph loop iteration {iteration}");
1040    let commit_request = ProcessRequest::new("git")
1041        .args(["commit", "-m", &msg])
1042        .current_dir(cwd.to_path_buf());
1043    let commit = runner
1044        .run(&commit_request)
1045        .map_err(|e| CoreError::Process(format!("Failed to run git commit: {e}")))?;
1046    if !commit.success {
1047        let stdout = commit.stdout.trim().to_string();
1048        let stderr = commit.stderr.trim().to_string();
1049        let mut msg = format!("git commit failed for iteration {iteration}");
1050        if !stdout.is_empty() {
1051            msg.push_str("\nstdout:\n");
1052            msg.push_str(&stdout);
1053        }
1054        if !stderr.is_empty() {
1055            msg.push_str("\nstderr:\n");
1056            msg.push_str(&stderr);
1057        }
1058        return Err(CoreError::Process(msg));
1059    }
1060    Ok(())
1061}
1062
1063#[cfg(test)]
1064mod runner_tests;