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