Skip to main content

ito_core/ralph/
runner.rs

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