ralph_workflow/phases/
development.rs

1//! Development phase execution.
2//!
3//! This module handles the development phase of the Ralph pipeline, which consists
4//! of iterative planning and execution cycles. Each iteration:
5//! 1. Creates a PLAN.md from PROMPT.md
6//! 2. Executes the plan
7//! 3. Deletes PLAN.md
8//! 4. Optionally runs fast checks
9
10use crate::agents::AgentRole;
11use crate::checkpoint::{save_checkpoint, PipelineCheckpoint, PipelinePhase};
12use crate::files::{delete_plan_file, update_status};
13use crate::files::{extract_plan, extract_plan_from_logs_text};
14use crate::git_helpers::{git_snapshot, CommitResultFallback};
15use crate::logger::print_progress;
16use crate::phases::commit::commit_with_generated_message;
17use crate::phases::get_primary_commit_agent;
18use crate::phases::integrity::ensure_prompt_integrity;
19use crate::pipeline::{run_with_fallback, PipelineRuntime};
20use crate::prompts::{prompt_for_agent, Action, ContextLevel, PromptConfig, Role};
21use std::fs;
22use std::path::Path;
23use std::process::Command;
24
25use super::context::PhaseContext;
26
27/// Result of the development phase.
28pub struct DevelopmentResult {
29    /// Whether any errors occurred during the phase.
30    pub had_errors: bool,
31}
32
33/// Run the development phase.
34///
35/// This phase runs `developer_iters` iterations, each consisting of:
36/// 1. Planning: Create PLAN.md from PROMPT.md
37/// 2. Execution: Execute the plan
38/// 3. Cleanup: Delete PLAN.md
39///
40/// # Arguments
41///
42/// * `ctx` - The phase context containing shared state
43/// * `start_iter` - The iteration to start from (for resume support)
44/// * `resuming_from_development` - Whether we're resuming into the development step
45///
46/// # Returns
47///
48/// Returns `Ok(DevelopmentResult)` on success, or an error if a critical failure occurs.
49pub fn run_development_phase(
50    ctx: &mut PhaseContext<'_>,
51    start_iter: u32,
52    resuming_from_development: bool,
53) -> anyhow::Result<DevelopmentResult> {
54    let mut had_errors = false;
55    let mut prev_snap = git_snapshot()?;
56    let developer_context = ContextLevel::from(ctx.config.developer_context);
57
58    for i in start_iter..=ctx.config.developer_iters {
59        ctx.logger.subheader(&format!(
60            "Iteration {} of {}",
61            i, ctx.config.developer_iters
62        ));
63        print_progress(i, ctx.config.developer_iters, "Overall");
64
65        let resuming_into_development = resuming_from_development && i == start_iter;
66
67        // Step 1: Create PLAN from PROMPT (skip if resuming into development)
68        if resuming_into_development {
69            ctx.logger
70                .info("Resuming at development step; skipping plan generation");
71        } else {
72            run_planning_step(ctx, i)?;
73        }
74
75        // Verify PLAN.md was created (required)
76        let plan_ok = verify_plan_exists(ctx, i, resuming_into_development)?;
77        if !plan_ok {
78            anyhow::bail!("Planning phase did not create a non-empty .agent/PLAN.md");
79        }
80        ctx.logger.success("PLAN.md created");
81
82        // Save checkpoint at start of development phase (if enabled)
83        if ctx.config.features.checkpoint_enabled {
84            let _ = save_checkpoint(&PipelineCheckpoint::new(
85                PipelinePhase::Development,
86                i,
87                ctx.config.developer_iters,
88                0,
89                ctx.config.reviewer_reviews,
90                ctx.developer_agent,
91                ctx.reviewer_agent,
92            ));
93        }
94
95        // Step 2: Execute the PLAN
96        ctx.logger.info("Executing plan...");
97        update_status("Starting development iteration", ctx.config.isolation_mode)?;
98
99        // Read PROMPT.md and PLAN.md content directly to pass as context.
100        // This prevents agents from discovering these files through exploration,
101        // reducing the risk of accidental deletion.
102        let prompt_md = fs::read_to_string("PROMPT.md").unwrap_or_default();
103        let plan_md = fs::read_to_string(".agent/PLAN.md").unwrap_or_default();
104
105        let prompt = prompt_for_agent(
106            Role::Developer,
107            Action::Iterate,
108            developer_context,
109            ctx.template_context,
110            PromptConfig::new()
111                .with_iterations(i, ctx.config.developer_iters)
112                .with_prompt_and_plan(prompt_md, plan_md),
113        );
114
115        let mut runtime = PipelineRuntime {
116            timer: ctx.timer,
117            logger: ctx.logger,
118            colors: ctx.colors,
119            config: ctx.config,
120            #[cfg(any(test, feature = "test-utils"))]
121            agent_executor: None,
122        };
123        let exit_code = run_with_fallback(
124            AgentRole::Developer,
125            &format!("run #{i}"),
126            &prompt,
127            &format!(".agent/logs/developer_{i}"),
128            &mut runtime,
129            ctx.registry,
130            ctx.developer_agent,
131        )?;
132
133        if exit_code != 0 {
134            ctx.logger.error(&format!(
135                "Iteration {i} encountered an error but continuing"
136            ));
137            had_errors = true;
138        }
139
140        ctx.stats.developer_runs_completed += 1;
141        update_status("Completed progress step", ctx.config.isolation_mode)?;
142
143        let snap = git_snapshot()?;
144        if snap == prev_snap {
145            if snap.is_empty() {
146                ctx.logger
147                    .warn("No git-status change detected (repository is clean)");
148            } else {
149                ctx.logger.warn(&format!(
150                    "No git-status change detected (existing changes: {})",
151                    snap.lines().count()
152                ));
153            }
154        } else {
155            ctx.logger.success(&format!(
156                "Repository modified ({} file(s) changed)",
157                snap.lines().count()
158            ));
159            ctx.stats.changes_detected += 1;
160            handle_commit_after_development(ctx)?;
161        }
162        prev_snap = snap;
163
164        // Run fast check if configured
165        if let Some(ref fast_cmd) = ctx.config.fast_check_cmd {
166            run_fast_check(ctx, fast_cmd, i)?;
167        }
168
169        // Periodic restoration check - ensure PROMPT.md still exists
170        // This catches agent deletions and restores from backup
171        ensure_prompt_integrity(ctx.logger, "development", i);
172
173        // Step 3: Delete the PLAN
174        ctx.logger.info("Deleting PLAN.md...");
175        if let Err(err) = delete_plan_file() {
176            ctx.logger.warn(&format!("Failed to delete PLAN.md: {err}"));
177        }
178        ctx.logger.success("PLAN.md deleted");
179    }
180
181    Ok(DevelopmentResult { had_errors })
182}
183
184/// Run the planning step to create PLAN.md.
185///
186/// The orchestrator ALWAYS extracts and writes PLAN.md from agent JSON output.
187/// Agent file writes are ignored - the orchestrator is the sole writer.
188fn run_planning_step(ctx: &mut PhaseContext<'_>, iteration: u32) -> anyhow::Result<()> {
189    // Save checkpoint at start of planning phase (if enabled)
190    if ctx.config.features.checkpoint_enabled {
191        let _ = save_checkpoint(&PipelineCheckpoint::new(
192            PipelinePhase::Planning,
193            iteration,
194            ctx.config.developer_iters,
195            0,
196            ctx.config.reviewer_reviews,
197            ctx.developer_agent,
198            ctx.reviewer_agent,
199        ));
200    }
201
202    ctx.logger.info("Creating plan from PROMPT.md...");
203    update_status("Starting planning phase", ctx.config.isolation_mode)?;
204
205    // Read PROMPT.md content to include directly in the planning prompt
206    // This prevents agents from discovering PROMPT.md through file exploration,
207    // which reduces the risk of accidental deletion.
208    let prompt_md_content = std::fs::read_to_string("PROMPT.md").ok();
209
210    let plan_prompt = prompt_for_agent(
211        Role::Developer,
212        Action::Plan,
213        ContextLevel::Normal,
214        ctx.template_context,
215        prompt_md_content
216            .map(|content| PromptConfig::new().with_prompt_md(content))
217            .unwrap_or_default(),
218    );
219
220    let log_dir = format!(".agent/logs/planning_{iteration}");
221    let mut runtime = PipelineRuntime {
222        timer: ctx.timer,
223        logger: ctx.logger,
224        colors: ctx.colors,
225        config: ctx.config,
226        #[cfg(any(test, feature = "test-utils"))]
227        agent_executor: None,
228    };
229    let _exit_code = run_with_fallback(
230        AgentRole::Developer,
231        &format!("planning #{iteration}"),
232        &plan_prompt,
233        &log_dir,
234        &mut runtime,
235        ctx.registry,
236        ctx.developer_agent,
237    )?;
238
239    // ORCHESTRATOR-CONTROLLED FILE I/O:
240    // Prefer extraction from JSON log (orchestrator write), but fall back to
241    // agent-written file if extraction fails (legacy/test compatibility).
242    let plan_path = Path::new(".agent/PLAN.md");
243    let log_dir_path = Path::new(&log_dir);
244
245    // Ensure .agent directory exists
246    if let Some(parent) = plan_path.parent() {
247        fs::create_dir_all(parent)?;
248    }
249
250    let extraction = extract_plan(log_dir_path)?;
251
252    if let Some(content) = extraction.raw_content {
253        // Extraction succeeded - orchestrator writes the file
254        fs::write(plan_path, &content)?;
255
256        if extraction.is_valid {
257            ctx.logger
258                .success("Plan extracted from agent output (JSON)");
259        } else {
260            ctx.logger.warn(&format!(
261                "Plan written but validation failed: {}",
262                extraction.validation_warning.unwrap_or_default()
263            ));
264        }
265    } else {
266        // JSON extraction failed - try text-based fallback
267        ctx.logger
268            .info("No JSON result event found, trying text-based extraction...");
269
270        if let Some(text_plan) = extract_plan_from_logs_text(log_dir_path)? {
271            fs::write(plan_path, &text_plan)?;
272            ctx.logger
273                .success("Plan extracted from agent output (text fallback)");
274        } else {
275            // Text extraction also failed - check if agent wrote the file directly (legacy fallback)
276            let agent_wrote_file = plan_path
277                .exists()
278                .then(|| fs::read_to_string(plan_path).ok())
279                .flatten()
280                .is_some_and(|s| !s.trim().is_empty());
281
282            if agent_wrote_file {
283                ctx.logger.info("Using agent-written PLAN.md (legacy mode)");
284            } else {
285                // No content from any source - write placeholder and fail
286                // The placeholder serves as a recovery mechanism (file exists for debugging)
287                // but the pipeline should still fail because we can't proceed without a plan
288                let placeholder = "# Plan\n\nAgent produced no extractable plan content.\n";
289                fs::write(plan_path, placeholder)?;
290                ctx.logger
291                    .error("No plan content found in agent output - wrote placeholder");
292                anyhow::bail!(
293                    "Planning agent completed successfully but no plan was found in output"
294                );
295            }
296        }
297    }
298
299    Ok(())
300}
301
302/// Verify that PLAN.md exists and is non-empty.
303///
304/// With orchestrator-controlled file I/O, `run_planning_step` always writes
305/// PLAN.md (even if just a placeholder). This function checks if the file
306/// exists and has meaningful content. If resuming and plan is missing,
307/// re-run planning.
308fn verify_plan_exists(
309    ctx: &mut PhaseContext<'_>,
310    iteration: u32,
311    resuming_into_development: bool,
312) -> anyhow::Result<bool> {
313    let plan_path = Path::new(".agent/PLAN.md");
314
315    let plan_ok = plan_path
316        .exists()
317        .then(|| fs::read_to_string(plan_path).ok())
318        .flatten()
319        .is_some_and(|s| !s.trim().is_empty());
320
321    // If resuming and plan is missing, re-run planning to recover
322    if !plan_ok && resuming_into_development {
323        ctx.logger
324            .warn("Missing .agent/PLAN.md; rerunning plan generation to recover");
325        run_planning_step(ctx, iteration)?;
326
327        // Check again after rerunning - orchestrator guarantees file exists
328        let plan_ok = plan_path
329            .exists()
330            .then(|| fs::read_to_string(plan_path).ok())
331            .flatten()
332            .is_some_and(|s| !s.trim().is_empty());
333
334        return Ok(plan_ok);
335    }
336
337    Ok(plan_ok)
338}
339
340/// Run fast check command.
341fn run_fast_check(ctx: &PhaseContext<'_>, fast_cmd: &str, iteration: u32) -> anyhow::Result<()> {
342    let argv = crate::common::split_command(fast_cmd)
343        .map_err(|e| anyhow::anyhow!("FAST_CHECK_CMD parse error (iteration {iteration}): {e}"))?;
344    if argv.is_empty() {
345        ctx.logger
346            .warn("FAST_CHECK_CMD is empty; skipping fast check");
347        return Ok(());
348    }
349
350    let display_cmd = crate::common::format_argv_for_log(&argv);
351    ctx.logger.info(&format!(
352        "Running fast check: {}{}{}",
353        ctx.colors.dim(),
354        display_cmd,
355        ctx.colors.reset()
356    ));
357
358    let Some((program, cmd_args)) = argv.split_first() else {
359        ctx.logger
360            .warn("FAST_CHECK_CMD is empty after parsing; skipping fast check");
361        return Ok(());
362    };
363    let status = Command::new(program).args(cmd_args).status()?;
364
365    if status.success() {
366        ctx.logger.success("Fast check passed");
367    } else {
368        ctx.logger.warn("Fast check had issues (non-blocking)");
369    }
370
371    Ok(())
372}
373
374/// Handle commit creation after development changes are detected.
375///
376/// Creates a commit with an auto-generated message using the primary commit agent.
377/// This is done by the orchestrator, not the agent, using fallback-aware commit
378/// generation which tries multiple agents if needed.
379fn handle_commit_after_development(ctx: &mut PhaseContext<'_>) -> anyhow::Result<()> {
380    // Get the primary commit agent from the registry
381    let commit_agent = get_primary_commit_agent(ctx);
382
383    if let Some(agent) = commit_agent {
384        ctx.logger.info(&format!(
385            "Creating commit with auto-generated message (agent: {agent})..."
386        ));
387
388        // Get the diff for commit message generation
389        let diff = match crate::git_helpers::git_diff() {
390            Ok(d) => d,
391            Err(e) => {
392                ctx.logger
393                    .error(&format!("Failed to get diff for commit: {e}"));
394                return Err(anyhow::anyhow!(e));
395            }
396        };
397
398        // Get git identity from config
399        let git_name = ctx.config.git_user_name.as_deref();
400        let git_email = ctx.config.git_user_email.as_deref();
401
402        let result = commit_with_generated_message(&diff, &agent, git_name, git_email, ctx);
403
404        match result {
405            CommitResultFallback::Success(oid) => {
406                ctx.logger
407                    .success(&format!("Commit created successfully: {oid}"));
408                ctx.stats.commits_created += 1;
409            }
410            CommitResultFallback::NoChanges => {
411                // No meaningful changes to commit (already handled by has_meaningful_changes)
412                ctx.logger.info("No commit created (no meaningful changes)");
413            }
414            CommitResultFallback::Failed(err) => {
415                // Actual git operation failed - this is critical
416                ctx.logger.error(&format!(
417                    "Failed to create commit (git operation failed): {err}"
418                ));
419                // Don't continue - this is a real error that needs attention
420                return Err(anyhow::anyhow!(err));
421            }
422        }
423    } else {
424        ctx.logger
425            .warn("Unable to get primary commit agent for commit");
426    }
427
428    Ok(())
429}