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 exit_code = {
116            let mut runtime = PipelineRuntime {
117                timer: ctx.timer,
118                logger: ctx.logger,
119                colors: ctx.colors,
120                config: ctx.config,
121            };
122            run_with_fallback(
123                AgentRole::Developer,
124                &format!("run #{i}"),
125                &prompt,
126                &format!(".agent/logs/developer_{i}"),
127                &mut runtime,
128                ctx.registry,
129                ctx.developer_agent,
130            )?
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            ctx.logger.warn("No git-status change detected");
146        } else {
147            ctx.logger.success("Repository modified");
148            ctx.stats.changes_detected += 1;
149            handle_commit_after_development(ctx)?;
150        }
151        prev_snap = snap;
152
153        // Run fast check if configured
154        if let Some(ref fast_cmd) = ctx.config.fast_check_cmd {
155            run_fast_check(ctx, fast_cmd, i)?;
156        }
157
158        // Periodic restoration check - ensure PROMPT.md still exists
159        // This catches agent deletions and restores from backup
160        ensure_prompt_integrity(ctx.logger, "development", i);
161
162        // Step 3: Delete the PLAN
163        ctx.logger.info("Deleting PLAN.md...");
164        if let Err(err) = delete_plan_file() {
165            ctx.logger.warn(&format!("Failed to delete PLAN.md: {err}"));
166        }
167        ctx.logger.success("PLAN.md deleted");
168    }
169
170    Ok(DevelopmentResult { had_errors })
171}
172
173/// Run the planning step to create PLAN.md.
174///
175/// The orchestrator ALWAYS extracts and writes PLAN.md from agent JSON output.
176/// Agent file writes are ignored - the orchestrator is the sole writer.
177fn run_planning_step(ctx: &mut PhaseContext<'_>, iteration: u32) -> anyhow::Result<()> {
178    // Save checkpoint at start of planning phase (if enabled)
179    if ctx.config.features.checkpoint_enabled {
180        let _ = save_checkpoint(&PipelineCheckpoint::new(
181            PipelinePhase::Planning,
182            iteration,
183            ctx.config.developer_iters,
184            0,
185            ctx.config.reviewer_reviews,
186            ctx.developer_agent,
187            ctx.reviewer_agent,
188        ));
189    }
190
191    ctx.logger.info("Creating plan from PROMPT.md...");
192    update_status("Starting planning phase", ctx.config.isolation_mode)?;
193
194    // Read PROMPT.md content to include directly in the planning prompt
195    // This prevents agents from discovering PROMPT.md through file exploration,
196    // which reduces the risk of accidental deletion.
197    let prompt_md_content = std::fs::read_to_string("PROMPT.md").ok();
198
199    let plan_prompt = prompt_for_agent(
200        Role::Developer,
201        Action::Plan,
202        ContextLevel::Normal,
203        ctx.template_context,
204        prompt_md_content
205            .map(|content| PromptConfig::new().with_prompt_md(content))
206            .unwrap_or_default(),
207    );
208
209    let log_dir = format!(".agent/logs/planning_{iteration}");
210    let _exit_code = {
211        let mut runtime = PipelineRuntime {
212            timer: ctx.timer,
213            logger: ctx.logger,
214            colors: ctx.colors,
215            config: ctx.config,
216        };
217        run_with_fallback(
218            AgentRole::Developer,
219            &format!("planning #{iteration}"),
220            &plan_prompt,
221            &log_dir,
222            &mut runtime,
223            ctx.registry,
224            ctx.developer_agent,
225        )
226    }?;
227
228    // ORCHESTRATOR-CONTROLLED FILE I/O:
229    // Prefer extraction from JSON log (orchestrator write), but fall back to
230    // agent-written file if extraction fails (legacy/test compatibility).
231    let plan_path = Path::new(".agent/PLAN.md");
232    let log_dir_path = Path::new(&log_dir);
233
234    // Ensure .agent directory exists
235    if let Some(parent) = plan_path.parent() {
236        fs::create_dir_all(parent)?;
237    }
238
239    let extraction = extract_plan(log_dir_path)?;
240
241    if let Some(content) = extraction.raw_content {
242        // Extraction succeeded - orchestrator writes the file
243        fs::write(plan_path, &content)?;
244
245        if extraction.is_valid {
246            ctx.logger
247                .success("Plan extracted from agent output (JSON)");
248        } else {
249            ctx.logger.warn(&format!(
250                "Plan written but validation failed: {}",
251                extraction.validation_warning.unwrap_or_default()
252            ));
253        }
254    } else {
255        // JSON extraction failed - try text-based fallback
256        ctx.logger
257            .info("No JSON result event found, trying text-based extraction...");
258
259        if let Some(text_plan) = extract_plan_from_logs_text(log_dir_path)? {
260            fs::write(plan_path, &text_plan)?;
261            ctx.logger
262                .success("Plan extracted from agent output (text fallback)");
263        } else {
264            // Text extraction also failed - check if agent wrote the file directly (legacy fallback)
265            let agent_wrote_file = plan_path
266                .exists()
267                .then(|| fs::read_to_string(plan_path).ok())
268                .flatten()
269                .is_some_and(|s| !s.trim().is_empty());
270
271            if agent_wrote_file {
272                ctx.logger.info("Using agent-written PLAN.md (legacy mode)");
273            } else {
274                // No content from any source - write placeholder and fail
275                // The placeholder serves as a recovery mechanism (file exists for debugging)
276                // but the pipeline should still fail because we can't proceed without a plan
277                let placeholder = "# Plan\n\nAgent produced no extractable plan content.\n";
278                fs::write(plan_path, placeholder)?;
279                ctx.logger
280                    .error("No plan content found in agent output - wrote placeholder");
281                anyhow::bail!(
282                    "Planning agent completed successfully but no plan was found in output"
283                );
284            }
285        }
286    }
287
288    Ok(())
289}
290
291/// Verify that PLAN.md exists and is non-empty.
292///
293/// With orchestrator-controlled file I/O, `run_planning_step` always writes
294/// PLAN.md (even if just a placeholder). This function checks if the file
295/// exists and has meaningful content. If resuming and plan is missing,
296/// re-run planning.
297fn verify_plan_exists(
298    ctx: &mut PhaseContext<'_>,
299    iteration: u32,
300    resuming_into_development: bool,
301) -> anyhow::Result<bool> {
302    let plan_path = Path::new(".agent/PLAN.md");
303
304    let plan_ok = plan_path
305        .exists()
306        .then(|| fs::read_to_string(plan_path).ok())
307        .flatten()
308        .is_some_and(|s| !s.trim().is_empty());
309
310    // If resuming and plan is missing, re-run planning to recover
311    if !plan_ok && resuming_into_development {
312        ctx.logger
313            .warn("Missing .agent/PLAN.md; rerunning plan generation to recover");
314        run_planning_step(ctx, iteration)?;
315
316        // Check again after rerunning - orchestrator guarantees file exists
317        let plan_ok = plan_path
318            .exists()
319            .then(|| fs::read_to_string(plan_path).ok())
320            .flatten()
321            .is_some_and(|s| !s.trim().is_empty());
322
323        return Ok(plan_ok);
324    }
325
326    Ok(plan_ok)
327}
328
329/// Run fast check command.
330fn run_fast_check(ctx: &PhaseContext<'_>, fast_cmd: &str, iteration: u32) -> anyhow::Result<()> {
331    let argv = crate::common::split_command(fast_cmd)
332        .map_err(|e| anyhow::anyhow!("FAST_CHECK_CMD parse error (iteration {iteration}): {e}"))?;
333    if argv.is_empty() {
334        ctx.logger
335            .warn("FAST_CHECK_CMD is empty; skipping fast check");
336        return Ok(());
337    }
338
339    let display_cmd = crate::common::format_argv_for_log(&argv);
340    ctx.logger.info(&format!(
341        "Running fast check: {}{}{}",
342        ctx.colors.dim(),
343        display_cmd,
344        ctx.colors.reset()
345    ));
346
347    let Some((program, cmd_args)) = argv.split_first() else {
348        ctx.logger
349            .warn("FAST_CHECK_CMD is empty after parsing; skipping fast check");
350        return Ok(());
351    };
352    let status = Command::new(program).args(cmd_args).status()?;
353
354    if status.success() {
355        ctx.logger.success("Fast check passed");
356    } else {
357        ctx.logger.warn("Fast check had issues (non-blocking)");
358    }
359
360    Ok(())
361}
362
363/// Handle commit creation after development changes are detected.
364///
365/// Creates a commit with an auto-generated message using the primary commit agent.
366/// This is done by the orchestrator, not the agent, using fallback-aware commit
367/// generation which tries multiple agents if needed.
368fn handle_commit_after_development(ctx: &mut PhaseContext<'_>) -> anyhow::Result<()> {
369    // Get the primary commit agent from the registry
370    let commit_agent = get_primary_commit_agent(ctx);
371
372    if let Some(agent) = commit_agent {
373        ctx.logger.info(&format!(
374            "Creating commit with auto-generated message (agent: {agent})..."
375        ));
376
377        // Get the diff for commit message generation
378        let diff = match crate::git_helpers::git_diff() {
379            Ok(d) => d,
380            Err(e) => {
381                ctx.logger
382                    .error(&format!("Failed to get diff for commit: {e}"));
383                return Err(anyhow::anyhow!(e));
384            }
385        };
386
387        // Get git identity from config
388        let git_name = ctx.config.git_user_name.as_deref();
389        let git_email = ctx.config.git_user_email.as_deref();
390
391        match commit_with_generated_message(&diff, &agent, git_name, git_email, ctx) {
392            CommitResultFallback::Success(oid) => {
393                ctx.logger
394                    .success(&format!("Commit created successfully: {oid}"));
395                ctx.stats.commits_created += 1;
396            }
397            CommitResultFallback::NoChanges => {
398                // No meaningful changes to commit (already handled by has_meaningful_changes)
399                ctx.logger.info("No commit created (no meaningful changes)");
400            }
401            CommitResultFallback::Failed(err) => {
402                // Actual git operation failed - this is critical
403                ctx.logger.error(&format!(
404                    "Failed to create commit (git operation failed): {err}"
405                ));
406                // Don't continue - this is a real error that needs attention
407                return Err(anyhow::anyhow!(err));
408            }
409        }
410    } else {
411        ctx.logger
412            .warn("Unable to get primary commit agent for commit");
413    }
414
415    Ok(())
416}