//! Prompt construction for Ralph loop iterations.
//!
//! The Ralph loop assembles a single prompt string that includes optional Ito
//! context (change proposal + module), the user's base prompt, and a fixed
//! preamble describing the iteration rules.
use crate::errors::{CoreError, CoreResult};
use crate::tasks::{get_next_task_from_summary, get_task_status_from_repository};
use crate::validate;
use ito_domain::changes::{ChangeRepository as DomainChangeRepository, ChangeTargetResolution};
use ito_domain::modules::ModuleRepository as DomainModuleRepository;
use ito_domain::tasks::TaskRepository as DomainTaskRepository;
use std::path::Path;
use ito_common::paths;
/// Options that control which context is embedded into a Ralph prompt.
pub struct BuildPromptOptions {
/// Optional change id (e.g. `014-01_add-rust-crate-documentation`).
pub change_id: Option<String>,
/// Optional module id (e.g. `014`).
pub module_id: Option<String>,
/// Iteration number to display in the preamble.
pub iteration: Option<u32>,
/// Optional maximum number of iterations (used only for display).
pub max_iterations: Option<u32>,
/// Minimum iteration count required before a completion promise is honored.
pub min_iterations: u32,
/// The completion promise token (e.g. `COMPLETE`).
pub completion_promise: String,
/// Optional additional context injected mid-loop.
pub context_content: Option<String>,
/// Optional validation failure output from the previous iteration.
///
/// When present, the prompt includes a section explaining completion was rejected.
pub validation_failure: Option<String>,
}
/// Build the standard Ralph preamble for a given iteration.
///
/// This is the outer wrapper around the task content; it communicates the loop
/// rules and the completion promise the harness must emit.
pub fn build_prompt_preamble(
iteration: u32,
max_iterations: Option<u32>,
min_iterations: u32,
completion_promise: &str,
context_content: Option<&str>,
validation_failure: Option<&str>,
task: &str,
) -> String {
let has_finite_max = max_iterations.is_some_and(|v| v > 0);
let normalized_context = context_content.unwrap_or("").trim();
let context_section = if normalized_context.is_empty() {
String::new()
} else {
format!(
"\n## Additional Context (added by user mid-loop)\n\n{c}\n\n---\n",
c = normalized_context
)
};
let normalized_validation = validation_failure.unwrap_or("").trim();
let validation_section = if normalized_validation.is_empty() {
String::new()
} else {
format!(
"\n## Validation Failure (completion rejected)\n\nRalph detected a completion promise, but it was rejected because validation failed. Fix the issues below and try again.\n\n{v}\n\n---\n",
v = normalized_validation
)
};
let max_str = if has_finite_max {
format!(" / {}", max_iterations.unwrap())
} else {
" (unlimited)".to_string()
};
format!(
"# Ralph Wiggum Loop - Iteration {iteration}\n\nYou are in an iterative development loop. Work on the task below until you can genuinely complete it.\n\nImportant: Ralph validates completion promises before exiting (tasks + project checks/tests).\n{context_section}{validation_section}## Your Task\n\n{task}\n\n## Instructions\n\n1. Read the current state of files to understand what's been done\n2. **Update your todo list** - Use the TodoWrite tool to track progress and plan remaining work\n3. Make progress on the task\n4. Run tests/verification if applicable\n5. When the task is GENUINELY COMPLETE, output:\n <promise>{completion_promise}</promise>\n\n## Critical Rules\n\n- ONLY output <promise>{completion_promise}</promise> when the task is truly done\n- Do NOT lie or output false promises to exit the loop\n- If stuck, try a different approach\n- Check your work before claiming completion\n- The loop will continue until you succeed\n- **IMPORTANT**: Update your todo list at the start of each iteration to show progress\n\n## AUTONOMY REQUIREMENTS (CRITICAL)\n\n- **DO NOT ASK QUESTIONS** - This is an autonomous loop with no human interaction\n- **DO NOT USE THE QUESTION TOOL** - Work independently without prompting for input\n- Make reasonable assumptions when information is missing\n- Use your best judgment to resolve ambiguities\n- If multiple approaches exist, choose the most reasonable one and proceed\n- The orchestrator cannot respond to questions - you must be self-sufficient\n- Trust your training and make decisions autonomously\n\n## Current Iteration: {iteration}{max_str} (min: {min_iterations})\n\nNow, work on the task autonomously. Good luck!",
iteration = iteration,
context_section = context_section,
validation_section = validation_section,
task = task,
completion_promise = completion_promise,
max_str = max_str,
min_iterations = min_iterations,
)
}
/// Build a full Ralph prompt with optional change/module context.
///
/// When `options.iteration` is set, this includes the iteration preamble.
pub fn build_ralph_prompt(
ito_path: &Path,
change_repo: &(impl DomainChangeRepository + ?Sized),
task_repo: &(impl DomainTaskRepository + ?Sized),
module_repo: &(impl DomainModuleRepository + ?Sized),
user_prompt: &str,
options: BuildPromptOptions,
) -> CoreResult<String> {
let mut sections: Vec<String> = Vec::new();
if let Some(change_id) = options.change_id.as_deref()
&& let Some(ctx) = load_change_context(ito_path, change_repo, change_id)?
{
sections.push(ctx);
}
if let Some(change_id) = options.change_id.as_deref()
&& let Some(ctx) = load_task_context(task_repo, change_id)?
{
sections.push(ctx);
}
if let Some(module_id) = options.module_id.as_deref()
&& let Some(ctx) = load_module_context(ito_path, module_repo, module_id)?
{
sections.push(ctx);
}
sections.push(user_prompt.to_string());
let task = sections.join("\n\n---\n\n");
if let Some(iteration) = options.iteration {
Ok(build_prompt_preamble(
iteration,
options.max_iterations,
options.min_iterations,
&options.completion_promise,
options.context_content.as_deref(),
options.validation_failure.as_deref(),
&task,
)
.trim()
.to_string())
} else {
Ok(task)
}
}
fn load_task_context(
task_repo: &(impl DomainTaskRepository + ?Sized),
change_id: &str,
) -> CoreResult<Option<String>> {
let summary = match get_task_status_from_repository(task_repo, change_id) {
Ok(summary) => summary,
Err(CoreError::NotFound(_)) => return Ok(None),
Err(err) => return Err(err),
};
let next_task = get_next_task_from_summary(&summary, "tasks.md")?;
let ready_tasks = summary
.ready
.iter()
.take(3)
.map(|task| format!("- {} {}", task.id, task.name))
.collect::<Vec<_>>();
let next_task_section = match next_task {
Some(task) => format!(
"## Next Actionable Task\n\n- Current task: {} {}\n- Action: {}\n",
task.id, task.name, task.action
),
None => "## Next Actionable Task\n\n- No ready task found. Finish remaining in-progress work or repair the task file if it is blocked.\n"
.to_string(),
};
let ready_section = if ready_tasks.is_empty() {
String::new()
} else {
format!("\nReady queue:\n{}\n", ready_tasks.join("\n"))
};
Ok(Some(format!(
"## Change Task Status ({change_id})\n\n- Progress: {complete}/{total} complete, {in_progress} in progress, {pending} pending, {shelved} shelved\n{ready_section}\n{next_task_section}\n## Execution Guidance\n\n- Work the next actionable task first and keep the change tasks/specs aligned with the code.\n- Use `ito tasks` commands when task state needs to change.\n- Before claiming completion, make sure project validation and change task validation will pass.\n",
change_id = change_id,
complete = summary.progress.complete,
total = summary.progress.total,
in_progress = summary.progress.in_progress,
pending = summary.progress.pending,
shelved = summary.progress.shelved,
ready_section = ready_section,
next_task_section = next_task_section,
)))
}
fn load_change_context(
ito_path: &Path,
change_repo: &(impl DomainChangeRepository + ?Sized),
change_id: &str,
) -> CoreResult<Option<String>> {
let changes_dir = paths::changes_dir(ito_path);
let resolved = resolve_change_id(change_repo, change_id)?;
let Some(resolved) = resolved else {
return Ok(None);
};
let proposal_path = changes_dir.join(&resolved).join("proposal.md");
if !proposal_path.exists() {
return Ok(None);
}
let proposal = ito_common::io::read_to_string_std(&proposal_path)
.map_err(|e| CoreError::io(format!("reading {}", proposal_path.display()), e))?;
Ok(Some(format!(
"## Change Proposal ({id})\n\n{proposal}",
id = resolved,
proposal = proposal
)))
}
fn resolve_change_id(
change_repo: &(impl DomainChangeRepository + ?Sized),
input: &str,
) -> CoreResult<Option<String>> {
match change_repo.resolve_target(input) {
ChangeTargetResolution::Unique(id) => Ok(Some(id)),
ChangeTargetResolution::NotFound => Ok(None),
ChangeTargetResolution::Ambiguous(matches) => Err(CoreError::Validation(format!(
"Ambiguous change id '{input}'. Matches: {matches}",
input = input,
matches = matches.join(", ")
))),
}
}
fn load_module_context(
ito_path: &Path,
module_repo: &(impl DomainModuleRepository + ?Sized),
module_id: &str,
) -> CoreResult<Option<String>> {
let resolved = validate::resolve_module(module_repo, ito_path, module_id)?;
let Some(resolved) = resolved else {
return Ok(None);
};
if !resolved.module_md.exists() {
return Ok(None);
}
let module_content = ito_common::io::read_to_string_std(&resolved.module_md)
.map_err(|e| CoreError::io(format!("reading {}", resolved.module_md.display()), e))?;
Ok(Some(format!(
"## Module ({id})\n\n{content}",
id = resolved.id,
content = module_content
)))
}
#[cfg(test)]
#[path = "prompt_tests.rs"]
mod prompt_tests;