Skip to main content

codetether_agent/ralph/
ralph_loop.rs

1//! Ralph loop - the core autonomous execution loop
2
3use super::types::*;
4use crate::provider::Provider;
5use crate::swarm::run_agent_loop;
6use crate::tool::ToolRegistry;
7use crate::worktree::WorktreeManager;
8use std::path::PathBuf;
9use std::process::Command;
10use std::sync::Arc;
11use tracing::{debug, info, warn};
12
13/// The main Ralph executor
14pub struct RalphLoop {
15    state: RalphState,
16    provider: Arc<dyn Provider>,
17    model: String,
18    config: RalphConfig,
19}
20
21impl RalphLoop {
22    /// Create a new Ralph loop
23    pub async fn new(
24        prd_path: PathBuf,
25        provider: Arc<dyn Provider>,
26        model: String,
27        config: RalphConfig,
28    ) -> anyhow::Result<Self> {
29        let prd = Prd::load(&prd_path).await?;
30
31        // Get working directory - use parent of prd_path or current directory
32        let working_dir = if let Some(parent) = prd_path.parent() {
33            if parent.as_os_str().is_empty() {
34                std::env::current_dir()?
35            } else {
36                parent.to_path_buf()
37            }
38        } else {
39            std::env::current_dir()?
40        };
41
42        info!(
43            "Loaded PRD: {} - {} ({} stories)",
44            prd.project,
45            prd.feature,
46            prd.user_stories.len()
47        );
48
49        let state = RalphState {
50            prd,
51            current_iteration: 0,
52            max_iterations: config.max_iterations,
53            status: RalphStatus::Pending,
54            progress_log: Vec::new(),
55            prd_path: prd_path.clone(),
56            working_dir,
57        };
58
59        Ok(Self {
60            state,
61            provider,
62            model,
63            config,
64        })
65    }
66
67    /// Run the Ralph loop until completion or max iterations
68    pub async fn run(&mut self) -> anyhow::Result<RalphState> {
69        self.state.status = RalphStatus::Running;
70
71        // Switch to feature branch
72        if !self.state.prd.branch_name.is_empty() {
73            info!("Switching to branch: {}", self.state.prd.branch_name);
74            self.git_checkout(&self.state.prd.branch_name)?;
75        }
76
77        // Choose execution mode
78        if self.config.parallel_enabled {
79            self.run_parallel().await?;
80        } else {
81            self.run_sequential().await?;
82        }
83
84        if self.state.status != RalphStatus::Completed
85            && self.state.current_iteration >= self.state.max_iterations
86        {
87            self.state.status = RalphStatus::MaxIterations;
88        }
89
90        // Clean up orphaned worktrees and branches
91        if self.config.worktree_enabled {
92            if let Ok(mgr) = WorktreeManager::new(&self.state.working_dir) {
93                match mgr.cleanup_all() {
94                    Ok(count) if count > 0 => {
95                        info!(cleaned = count, "Cleaned up orphaned worktrees/branches");
96                    }
97                    Ok(_) => {}
98                    Err(e) => {
99                        warn!(error = %e, "Failed to cleanup orphaned worktrees");
100                    }
101                }
102            }
103        }
104
105        info!(
106            "Ralph finished: {:?}, {}/{} stories passed",
107            self.state.status,
108            self.state.prd.passed_count(),
109            self.state.prd.user_stories.len()
110        );
111
112        Ok(self.state.clone())
113    }
114
115    /// Run stories sequentially (original behavior)
116    async fn run_sequential(&mut self) -> anyhow::Result<()> {
117        while self.state.current_iteration < self.state.max_iterations {
118            self.state.current_iteration += 1;
119            info!(
120                "=== Ralph iteration {} of {} ===",
121                self.state.current_iteration, self.state.max_iterations
122            );
123
124            // Check if all stories are complete
125            if self.state.prd.is_complete() {
126                info!("All stories complete!");
127                self.state.status = RalphStatus::Completed;
128                break;
129            }
130
131            // Get next story to work on
132            let story = match self.state.prd.next_story() {
133                Some(s) => s.clone(),
134                None => {
135                    warn!("No available stories (dependencies not met)");
136                    break;
137                }
138            };
139
140            info!("Working on story: {} - {}", story.id, story.title);
141
142            // Build the prompt
143            let prompt = self.build_prompt(&story);
144
145            // Call the LLM
146            match self.call_llm(&prompt).await {
147                Ok(response) => {
148                    // Log progress
149                    let entry = ProgressEntry {
150                        story_id: story.id.clone(),
151                        iteration: self.state.current_iteration,
152                        status: "completed".to_string(),
153                        learnings: self.extract_learnings(&response),
154                        files_changed: Vec::new(),
155                        timestamp: chrono::Utc::now().to_rfc3339(),
156                    };
157                    self.append_progress(&entry, &response)?;
158                    self.state.progress_log.push(entry);
159
160                    // Run quality gates
161                    if self.config.quality_checks_enabled {
162                        if self.run_quality_gates().await? {
163                            info!("Story {} passed quality checks!", story.id);
164                            self.state.prd.mark_passed(&story.id);
165
166                            // Commit changes
167                            if self.config.auto_commit {
168                                self.commit_story(&story)?;
169                            }
170
171                            // Save updated PRD
172                            self.state.prd.save(&self.state.prd_path).await?;
173                        } else {
174                            warn!("Story {} failed quality checks", story.id);
175                        }
176                    } else {
177                        // No quality checks, just mark as passed
178                        self.state.prd.mark_passed(&story.id);
179                        self.state.prd.save(&self.state.prd_path).await?;
180                    }
181                }
182                Err(e) => {
183                    warn!("LLM call failed: {}", e);
184                    let entry = ProgressEntry {
185                        story_id: story.id.clone(),
186                        iteration: self.state.current_iteration,
187                        status: format!("failed: {}", e),
188                        learnings: Vec::new(),
189                        files_changed: Vec::new(),
190                        timestamp: chrono::Utc::now().to_rfc3339(),
191                    };
192                    self.state.progress_log.push(entry);
193                }
194            }
195        }
196
197        Ok(())
198    }
199
200    /// Run stories in parallel by stage with worktree isolation
201    async fn run_parallel(&mut self) -> anyhow::Result<()> {
202        // Clone stages upfront to avoid borrow issues
203        let stages: Vec<Vec<UserStory>> = self
204            .state
205            .prd
206            .stages()
207            .into_iter()
208            .map(|stage| stage.into_iter().cloned().collect())
209            .collect();
210        let total_stages = stages.len();
211
212        info!(
213            "Parallel execution: {} stages, {} max concurrent stories",
214            total_stages, self.config.max_concurrent_stories
215        );
216
217        // Create worktree manager if enabled
218        let worktree_mgr = if self.config.worktree_enabled {
219            match WorktreeManager::new(&self.state.working_dir) {
220                Ok(mgr) => {
221                    info!("Worktree isolation enabled for parallel stories");
222                    Some(Arc::new(mgr))
223                }
224                Err(e) => {
225                    warn!(
226                        "Failed to create worktree manager: {}, falling back to sequential within stages",
227                        e
228                    );
229                    None
230                }
231            }
232        } else {
233            None
234        };
235
236        for (stage_idx, stage_stories) in stages.into_iter().enumerate() {
237            if self.state.prd.is_complete() {
238                info!("All stories complete!");
239                self.state.status = RalphStatus::Completed;
240                break;
241            }
242
243            if self.state.current_iteration >= self.state.max_iterations {
244                break;
245            }
246
247            let story_count = stage_stories.len();
248            info!(
249                "=== Stage {}/{}: {} stories in parallel ===",
250                stage_idx + 1,
251                total_stages,
252                story_count
253            );
254
255            // Stories are already cloned from stages()
256            let stories: Vec<UserStory> = stage_stories;
257
258            // Execute stories in parallel
259            let semaphore = Arc::new(tokio::sync::Semaphore::new(
260                self.config.max_concurrent_stories,
261            ));
262            let provider = Arc::clone(&self.provider);
263            let model = self.model.clone();
264            let prd_info = (
265                self.state.prd.project.clone(),
266                self.state.prd.feature.clone(),
267            );
268            let working_dir = self.state.working_dir.clone();
269            let progress_path = self.config.progress_path.clone();
270
271            let mut handles = Vec::new();
272
273            for story in stories {
274                let sem = Arc::clone(&semaphore);
275                let provider = Arc::clone(&provider);
276                let model = model.clone();
277                let prd_info = prd_info.clone();
278                let working_dir = working_dir.clone();
279                let worktree_mgr = worktree_mgr.clone();
280                let progress_path = progress_path.clone();
281
282                let handle = tokio::spawn(async move {
283                    let _permit = sem.acquire().await.expect("semaphore closed");
284
285                    // Create worktree for this story if enabled
286                    let (story_working_dir, worktree_info) = if let Some(ref mgr) = worktree_mgr {
287                        match mgr.create(&story.id.to_lowercase().replace("-", "_")) {
288                            Ok(wt) => {
289                                // Inject [workspace] stub for hermetic isolation
290                                if let Err(e) = mgr.inject_workspace_stub(&wt.path) {
291                                    warn!(
292                                        story_id = %story.id,
293                                        error = %e,
294                                        "Failed to inject workspace stub"
295                                    );
296                                }
297                                info!(
298                                    story_id = %story.id,
299                                    worktree_path = %wt.path.display(),
300                                    "Created worktree for story"
301                                );
302                                (wt.path.clone(), Some(wt))
303                            }
304                            Err(e) => {
305                                warn!(
306                                    story_id = %story.id,
307                                    error = %e,
308                                    "Failed to create worktree, using main directory"
309                                );
310                                (working_dir.clone(), None)
311                            }
312                        }
313                    } else {
314                        (working_dir.clone(), None)
315                    };
316
317                    info!(
318                        "Working on story: {} - {} (in {:?})",
319                        story.id, story.title, story_working_dir
320                    );
321
322                    // Build the prompt with worktree awareness
323                    let prompt = Self::build_story_prompt(&story, &prd_info, &story_working_dir);
324
325                    // Call the LLM
326                    let result =
327                        Self::call_llm_static(&provider, &model, &prompt, &story_working_dir).await;
328
329                    let entry = match &result {
330                        Ok(response) => {
331                            // Append progress to worktree-local progress file
332                            let progress_file = story_working_dir.join(&progress_path);
333                            let _ = std::fs::write(&progress_file, response);
334
335                            ProgressEntry {
336                                story_id: story.id.clone(),
337                                iteration: 1,
338                                status: "completed".to_string(),
339                                learnings: Self::extract_learnings_static(response),
340                                files_changed: Vec::new(),
341                                timestamp: chrono::Utc::now().to_rfc3339(),
342                            }
343                        }
344                        Err(e) => {
345                            warn!("LLM call failed for story {}: {}", story.id, e);
346                            ProgressEntry {
347                                story_id: story.id.clone(),
348                                iteration: 1,
349                                status: format!("failed: {}", e),
350                                learnings: Vec::new(),
351                                files_changed: Vec::new(),
352                                timestamp: chrono::Utc::now().to_rfc3339(),
353                            }
354                        }
355                    };
356
357                    (story, result.is_ok(), entry, worktree_info, worktree_mgr)
358                });
359
360                handles.push(handle);
361            }
362
363            // Wait for all stories in this stage
364            for handle in handles {
365                match handle.await {
366                    Ok((story, success, entry, worktree_info, worktree_mgr)) => {
367                        self.state.current_iteration += 1;
368                        self.state.progress_log.push(entry);
369
370                        if success {
371                            // Run quality gates in the worktree (or main dir)
372                            let check_dir = worktree_info
373                                .as_ref()
374                                .map(|wt| wt.path.clone())
375                                .unwrap_or_else(|| self.state.working_dir.clone());
376
377                            let quality_passed = if self.config.quality_checks_enabled {
378                                self.run_quality_gates_in_dir(&check_dir)
379                                    .await
380                                    .unwrap_or(false)
381                            } else {
382                                true
383                            };
384
385                            if quality_passed {
386                                info!("Story {} passed quality checks!", story.id);
387
388                                // Commit in worktree first
389                                if let Some(ref wt) = worktree_info {
390                                    let _ = Self::commit_in_dir(&wt.path, &story);
391                                }
392
393                                // Merge worktree back to main
394                                if let (Some(wt), Some(mgr)) = (&worktree_info, &worktree_mgr) {
395                                    match mgr.merge(wt) {
396                                        Ok(merge_result) => {
397                                            if merge_result.success {
398                                                info!(
399                                                    story_id = %story.id,
400                                                    files_changed = merge_result.files_changed,
401                                                    "Merged story changes successfully"
402                                                );
403                                                self.state.prd.mark_passed(&story.id);
404                                                // Cleanup worktree
405                                                let _ = mgr.cleanup(wt);
406                                            } else if !merge_result.conflicts.is_empty() {
407                                                // Real conflicts - spawn conflict resolver
408                                                info!(
409                                                    story_id = %story.id,
410                                                    num_conflicts = merge_result.conflicts.len(),
411                                                    "Spawning conflict resolver sub-agent"
412                                                );
413
414                                                // Try to resolve conflicts
415                                                match Self::resolve_conflicts_static(
416                                                    &provider,
417                                                    &model,
418                                                    &working_dir,
419                                                    &story,
420                                                    &merge_result.conflicts,
421                                                    &merge_result.conflict_diffs,
422                                                )
423                                                .await
424                                                {
425                                                    Ok(resolved) => {
426                                                        if resolved {
427                                                            // Complete the merge after resolution
428                                                            let commit_msg = format!(
429                                                                "Merge: resolved conflicts for {}",
430                                                                story.id
431                                                            );
432                                                            match mgr
433                                                                .complete_merge(wt, &commit_msg)
434                                                            {
435                                                                Ok(final_result) => {
436                                                                    if final_result.success {
437                                                                        info!(
438                                                                            story_id = %story.id,
439                                                                            "Merge completed after conflict resolution"
440                                                                        );
441                                                                        self.state
442                                                                            .prd
443                                                                            .mark_passed(&story.id);
444                                                                    } else {
445                                                                        warn!(
446                                                                            story_id = %story.id,
447                                                                            "Merge failed even after resolution"
448                                                                        );
449                                                                        let _ = mgr.abort_merge();
450                                                                    }
451                                                                }
452                                                                Err(e) => {
453                                                                    warn!(
454                                                                        story_id = %story.id,
455                                                                        error = %e,
456                                                                        "Failed to complete merge after resolution"
457                                                                    );
458                                                                    let _ = mgr.abort_merge();
459                                                                }
460                                                            }
461                                                        } else {
462                                                            warn!(
463                                                                story_id = %story.id,
464                                                                "Conflict resolver could not resolve all conflicts"
465                                                            );
466                                                            let _ = mgr.abort_merge();
467                                                        }
468                                                    }
469                                                    Err(e) => {
470                                                        warn!(
471                                                            story_id = %story.id,
472                                                            error = %e,
473                                                            "Conflict resolver failed"
474                                                        );
475                                                        let _ = mgr.abort_merge();
476                                                    }
477                                                }
478                                                // Cleanup worktree
479                                                let _ = mgr.cleanup(wt);
480                                            } else if merge_result.aborted {
481                                                // Non-conflict failure that was already aborted
482                                                warn!(
483                                                    story_id = %story.id,
484                                                    summary = %merge_result.summary,
485                                                    "Merge was aborted due to non-conflict failure"
486                                                );
487                                                // Cleanup worktree
488                                                let _ = mgr.cleanup(wt);
489                                            } else {
490                                                // Merge in progress state (should not reach here)
491                                                warn!(
492                                                    story_id = %story.id,
493                                                    summary = %merge_result.summary,
494                                                    "Merge failed but not aborted - manual intervention may be needed"
495                                                );
496                                                // Don't cleanup - leave for debugging
497                                            }
498                                        }
499                                        Err(e) => {
500                                            warn!(
501                                                story_id = %story.id,
502                                                error = %e,
503                                                "Failed to merge worktree"
504                                            );
505                                        }
506                                    }
507                                } else {
508                                    // No worktree, just mark passed
509                                    self.state.prd.mark_passed(&story.id);
510                                }
511                            } else {
512                                warn!("Story {} failed quality checks", story.id);
513                                // Cleanup worktree without merging
514                                if let (Some(wt), Some(mgr)) = (&worktree_info, &worktree_mgr) {
515                                    let _ = mgr.cleanup(wt);
516                                }
517                            }
518                        } else {
519                            // Failed - cleanup worktree without merging (keep for debugging)
520                            if let Some(ref wt) = worktree_info {
521                                info!(
522                                    story_id = %story.id,
523                                    worktree_path = %wt.path.display(),
524                                    "Keeping worktree for debugging (story failed)"
525                                );
526                            }
527                        }
528                    }
529                    Err(e) => {
530                        warn!("Story execution task failed: {}", e);
531                    }
532                }
533            }
534
535            // Save PRD after each stage
536            self.state.prd.save(&self.state.prd_path).await?;
537        }
538
539        Ok(())
540    }
541
542    /// Build prompt for a story (static version for parallel execution)
543    fn build_story_prompt(
544        story: &UserStory,
545        prd_info: &(String, String),
546        working_dir: &PathBuf,
547    ) -> String {
548        format!(
549            r#"# PRD: {} - {}
550
551## Working Directory: {}
552
553## Current Story: {} - {}
554
555{}
556
557### Acceptance Criteria:
558{}
559
560## WORKFLOW (follow this exactly):
561
5621. **EXPLORE** (2-4 tool calls): Use `glob` and `read` to understand existing code
5632. **IMPLEMENT** (5-15 tool calls): Use `write` or `edit` to make changes
5643. **VERIFY**: Run `bash` with command `cargo check 2>&1` to check for errors
5654. **FIX OR FINISH**:
566   - If no errors: Output `STORY_COMPLETE: {}` and STOP
567   - If errors: Parse the error, fix it, re-run cargo check (max 3 fix attempts)
568   - After 3 failed attempts: Output `STORY_BLOCKED: <error summary>` and STOP
569
570## UNDERSTANDING CARGO ERRORS:
571
572When `cargo check` fails, the output shows:
573```
574error[E0432]: unresolved import `crate::foo::bar`
575  --> src/file.rs:10:5
576   |
57710 | use crate::foo::bar;
578   |             ^^^ could not find `bar` in `foo`
579```
580
581Key parts:
582- `error[E0432]` = error code (search rustc --explain E0432 for details)
583- `src/file.rs:10:5` = file:line:column where error occurs
584- The message explains what's wrong
585
586COMMON FIXES:
587- "unresolved import" → module doesn't exist or isn't exported, check mod.rs
588- "cannot find" → typo in name or missing import
589- "mismatched types" → wrong type, check function signatures
590- "trait bound not satisfied" → missing impl or use statement
591
592## TOOL USAGE:
593- `read`: Read file content (always read before editing!)
594- `edit`: Modify files (MUST include 3+ lines before/after for unique context)
595- `write`: Create new files
596- `bash`: Run commands with `{{"command": "...", "cwd": "{}"}}`
597
598## CRITICAL RULES:
599- ALWAYS read a file before editing it
600- When edit fails with "ambiguous match", include MORE context lines
601- Do NOT add TODO/placeholder comments
602- Run `cargo check 2>&1` to see ALL errors including warnings
603- Count your fix attempts - STOP after 3 failures
604
605## TERMINATION:
606SUCCESS: Output `STORY_COMPLETE: {}`
607BLOCKED: Output `STORY_BLOCKED: <brief error description>`
608
609Do NOT keep iterating indefinitely. Stop when done or blocked.
610"#,
611            prd_info.0,
612            prd_info.1,
613            working_dir.display(),
614            story.id,
615            story.title,
616            story.description,
617            story
618                .acceptance_criteria
619                .iter()
620                .map(|c| format!("- {}", c))
621                .collect::<Vec<_>>()
622                .join("\n"),
623            story.id,
624            working_dir.display(),
625            story.id
626        )
627    }
628
629    /// Call LLM with agentic tool loop (static version for parallel execution)
630    async fn call_llm_static(
631        provider: &Arc<dyn Provider>,
632        model: &str,
633        prompt: &str,
634        working_dir: &PathBuf,
635    ) -> anyhow::Result<String> {
636        // Build system prompt with AGENTS.md
637        let system_prompt = crate::agent::builtin::build_system_prompt(working_dir);
638
639        // Create tool registry with provider for file operations
640        let tool_registry =
641            ToolRegistry::with_provider_arc(Arc::clone(provider), model.to_string());
642
643        // Filter out 'question' tool - sub-agents must be autonomous, not interactive
644        let tool_definitions: Vec<_> = tool_registry
645            .definitions()
646            .into_iter()
647            .filter(|t| t.name != "question")
648            .collect();
649
650        info!(
651            "Ralph sub-agent starting with {} tools in {:?}",
652            tool_definitions.len(),
653            working_dir
654        );
655
656        // Run the agentic loop with tools
657        let (output, steps, tool_calls) = run_agent_loop(
658            Arc::clone(provider),
659            model,
660            &system_prompt,
661            prompt,
662            tool_definitions,
663            tool_registry, // Already an Arc<ToolRegistry>
664            30,            // max steps per story (focused implementation)
665            180,           // 3 minute timeout per story
666        )
667        .await?;
668
669        info!(
670            "Ralph sub-agent completed: {} steps, {} tool calls",
671            steps, tool_calls
672        );
673
674        Ok(output)
675    }
676
677    /// Resolve merge conflicts using a dedicated sub-agent
678    async fn resolve_conflicts_static(
679        provider: &Arc<dyn Provider>,
680        model: &str,
681        working_dir: &PathBuf,
682        story: &UserStory,
683        conflicts: &[String],
684        conflict_diffs: &[(String, String)],
685    ) -> anyhow::Result<bool> {
686        info!(
687            story_id = %story.id,
688            num_conflicts = conflicts.len(),
689            "Starting conflict resolution sub-agent"
690        );
691
692        // Build prompt with conflict context
693        let conflict_info = conflict_diffs
694            .iter()
695            .map(|(file, diff)| format!("### File: {}\n```diff\n{}\n```", file, diff))
696            .collect::<Vec<_>>()
697            .join("\n\n");
698
699        let prompt = format!(
700            r#"# CONFLICT RESOLUTION TASK
701
702## Story Context: {} - {}
703{}
704
705## Conflicting Files
706The following files have merge conflicts that need resolution:
707{}
708
709## Conflict Details
710{}
711
712## Your Task
7131. Read each conflicting file to see the conflict markers
7142. Understand what BOTH sides are trying to do:
715   - HEAD (main branch): the current state
716   - The incoming branch: the sub-agent's changes for story {}
7173. Resolve each conflict by:
718   - Keeping BOTH changes if they don't actually conflict
719   - Merging the logic if they touch the same code
720   - Preferring the sub-agent's changes if they implement the story requirement
7214. Remove ALL conflict markers (<<<<<<<, =======, >>>>>>>)
7225. Ensure the final code compiles: run `cargo check`
723
724## CRITICAL RULES
725- Do NOT leave any conflict markers in files
726- Do NOT just pick one side - understand and merge the intent
727- MUST run `cargo check` after resolving to verify
728- Stage resolved files with `git add <file>`
729
730## Termination
731SUCCESS: Output `CONFLICTS_RESOLVED` when all files are resolved and compile
732FAILED: Output `CONFLICTS_UNRESOLVED: <reason>` if you cannot resolve
733
734Working directory: {}
735"#,
736            story.id,
737            story.title,
738            story.description,
739            conflicts
740                .iter()
741                .map(|f| format!("- {}", f))
742                .collect::<Vec<_>>()
743                .join("\n"),
744            conflict_info,
745            story.id,
746            working_dir.display()
747        );
748
749        // Build system prompt
750        let system_prompt = crate::agent::builtin::build_system_prompt(working_dir);
751
752        // Create tool registry
753        let tool_registry =
754            ToolRegistry::with_provider_arc(Arc::clone(provider), model.to_string());
755
756        let tool_definitions: Vec<_> = tool_registry
757            .definitions()
758            .into_iter()
759            .filter(|t| t.name != "question")
760            .collect();
761
762        info!(
763            "Conflict resolver starting with {} tools",
764            tool_definitions.len()
765        );
766
767        // Run the resolver with smaller limits (conflicts should be quick to resolve)
768        let (output, steps, tool_calls) = run_agent_loop(
769            Arc::clone(provider),
770            model,
771            &system_prompt,
772            &prompt,
773            tool_definitions,
774            tool_registry,
775            15,  // max 15 steps for conflict resolution
776            120, // 2 minute timeout
777        )
778        .await?;
779
780        info!(
781            story_id = %story.id,
782            steps = steps,
783            tool_calls = tool_calls,
784            "Conflict resolver completed"
785        );
786
787        // Check if resolution was successful
788        let resolved = output.contains("CONFLICTS_RESOLVED")
789            || (output.contains("resolved") && !output.contains("UNRESOLVED"));
790
791        if resolved {
792            info!(story_id = %story.id, "Conflicts resolved successfully");
793        } else {
794            warn!(
795                story_id = %story.id,
796                output = %output.chars().take(200).collect::<String>(),
797                "Conflict resolution may have failed"
798            );
799        }
800
801        Ok(resolved)
802    }
803
804    /// Extract learnings (static version)
805    fn extract_learnings_static(response: &str) -> Vec<String> {
806        response
807            .lines()
808            .filter(|line| {
809                line.contains("learned") || line.contains("Learning") || line.contains("# What")
810            })
811            .map(|line| line.trim().to_string())
812            .collect()
813    }
814
815    /// Commit changes in a specific directory
816    fn commit_in_dir(dir: &PathBuf, story: &UserStory) -> anyhow::Result<()> {
817        // Stage all changes
818        let _ = Command::new("git")
819            .args(["add", "-A"])
820            .current_dir(dir)
821            .output();
822
823        // Commit with story reference
824        let msg = format!("feat({}): {}", story.id.to_lowercase(), story.title);
825        let _ = Command::new("git")
826            .args(["commit", "-m", &msg])
827            .current_dir(dir)
828            .output();
829
830        Ok(())
831    }
832
833    /// Run quality gates in a specific directory
834    async fn run_quality_gates_in_dir(&self, dir: &PathBuf) -> anyhow::Result<bool> {
835        let checks = &self.state.prd.quality_checks;
836
837        for (name, cmd) in [
838            ("typecheck", &checks.typecheck),
839            ("lint", &checks.lint),
840            ("test", &checks.test),
841            ("build", &checks.build),
842        ] {
843            if let Some(command) = cmd {
844                debug!("Running {} check in {:?}: {}", name, dir, command);
845                let output = Command::new("/bin/sh")
846                    .arg("-c")
847                    .arg(command)
848                    .current_dir(dir)
849                    .output()
850                    .map_err(|e| {
851                        anyhow::anyhow!("Failed to run quality check '{}': {}", name, e)
852                    })?;
853
854                if !output.status.success() {
855                    // Parse output to separate errors from warnings
856                    let stderr = String::from_utf8_lossy(&output.stderr);
857                    let stdout = String::from_utf8_lossy(&output.stdout);
858                    let combined = format!("{}\n{}", stdout, stderr);
859
860                    // Count actual errors vs warnings
861                    let error_count = combined
862                        .lines()
863                        .filter(|line| {
864                            line.starts_with("error")
865                                || line.contains("error:")
866                                || line.contains("error[")
867                        })
868                        .count();
869                    let warning_count = combined
870                        .lines()
871                        .filter(|line| line.starts_with("warning") || line.contains("warning:"))
872                        .count();
873
874                    // Extract the actual error message (not warnings)
875                    let error_summary: String = combined
876                        .lines()
877                        .filter(|line| {
878                            line.starts_with("error")
879                                || line.contains("error:")
880                                || line.contains("error[")
881                        })
882                        .take(5) // First 5 error lines
883                        .collect::<Vec<_>>()
884                        .join("\n");
885
886                    warn!(
887                        check = %name,
888                        dir = %dir.display(),
889                        errors = error_count,
890                        warnings = warning_count,
891                        error_summary = %error_summary.chars().take(300).collect::<String>(),
892                        "{} check failed in {:?}",
893                        name,
894                        dir
895                    );
896                    return Ok(false);
897                }
898            }
899        }
900
901        Ok(true)
902    }
903
904    /// Build the prompt for a story
905    fn build_prompt(&self, story: &UserStory) -> String {
906        let progress = self.load_progress().unwrap_or_default();
907
908        format!(
909            r#"# PRD: {} - {}
910
911## Current Story: {} - {}
912
913{}
914
915### Acceptance Criteria:
916{}
917
918## Previous Progress:
919{}
920
921## Instructions:
9221. Implement the requirements for this story
9232. Write any necessary code changes
9243. Document what you learned
9254. End with `STORY_COMPLETE: {}` when done
926
927Respond with the implementation and any shell commands needed.
928"#,
929            self.state.prd.project,
930            self.state.prd.feature,
931            story.id,
932            story.title,
933            story.description,
934            story
935                .acceptance_criteria
936                .iter()
937                .map(|c| format!("- {}", c))
938                .collect::<Vec<_>>()
939                .join("\n"),
940            if progress.is_empty() {
941                "None yet".to_string()
942            } else {
943                progress
944            },
945            story.id
946        )
947    }
948
949    /// Call the LLM with a prompt using agentic tool loop
950    async fn call_llm(&self, prompt: &str) -> anyhow::Result<String> {
951        // Build system prompt with AGENTS.md
952        let system_prompt = crate::agent::builtin::build_system_prompt(&self.state.working_dir);
953
954        // Create tool registry with provider for file operations
955        let tool_registry =
956            ToolRegistry::with_provider_arc(Arc::clone(&self.provider), self.model.clone());
957
958        // Filter out 'question' tool - sub-agents must be autonomous, not interactive
959        let tool_definitions: Vec<_> = tool_registry
960            .definitions()
961            .into_iter()
962            .filter(|t| t.name != "question")
963            .collect();
964
965        info!(
966            "Ralph agent starting with {} tools in {:?}",
967            tool_definitions.len(),
968            self.state.working_dir
969        );
970
971        // Run the agentic loop with tools
972        let (output, steps, tool_calls) = run_agent_loop(
973            Arc::clone(&self.provider),
974            &self.model,
975            &system_prompt,
976            prompt,
977            tool_definitions,
978            tool_registry, // Already an Arc<ToolRegistry>
979            30,            // max steps per story (focused implementation)
980            180,           // 3 minute timeout per story
981        )
982        .await?;
983
984        info!(
985            "Ralph agent completed: {} steps, {} tool calls",
986            steps, tool_calls
987        );
988
989        Ok(output)
990    }
991
992    /// Run quality gates
993    async fn run_quality_gates(&self) -> anyhow::Result<bool> {
994        let checks = &self.state.prd.quality_checks;
995
996        for (name, cmd) in [
997            ("typecheck", &checks.typecheck),
998            ("lint", &checks.lint),
999            ("test", &checks.test),
1000            ("build", &checks.build),
1001        ] {
1002            if let Some(command) = cmd {
1003                info!(
1004                    "Running {} check in {:?}: {}",
1005                    name, self.state.working_dir, command
1006                );
1007                let output = Command::new("/bin/sh")
1008                    .arg("-c")
1009                    .arg(command)
1010                    .current_dir(&self.state.working_dir)
1011                    .output()
1012                    .map_err(|e| {
1013                        anyhow::anyhow!("Failed to run quality check '{}': {}", name, e)
1014                    })?;
1015
1016                if !output.status.success() {
1017                    // Parse output to separate errors from warnings
1018                    let stderr = String::from_utf8_lossy(&output.stderr);
1019                    let stdout = String::from_utf8_lossy(&output.stdout);
1020                    let combined = format!("{}\n{}", stdout, stderr);
1021
1022                    // Count actual errors vs warnings
1023                    let error_count = combined
1024                        .lines()
1025                        .filter(|line| {
1026                            line.starts_with("error")
1027                                || line.contains("error:")
1028                                || line.contains("error[")
1029                        })
1030                        .count();
1031                    let warning_count = combined
1032                        .lines()
1033                        .filter(|line| line.starts_with("warning") || line.contains("warning:"))
1034                        .count();
1035
1036                    // Extract the actual error message (not warnings)
1037                    let error_summary: String = combined
1038                        .lines()
1039                        .filter(|line| {
1040                            line.starts_with("error")
1041                                || line.contains("error:")
1042                                || line.contains("error[")
1043                        })
1044                        .take(5) // First 5 error lines
1045                        .collect::<Vec<_>>()
1046                        .join("\n");
1047
1048                    warn!(
1049                        check = %name,
1050                        errors = error_count,
1051                        warnings = warning_count,
1052                        error_summary = %error_summary.chars().take(300).collect::<String>(),
1053                        "{} check failed",
1054                        name
1055                    );
1056                    return Ok(false);
1057                }
1058            }
1059        }
1060
1061        Ok(true)
1062    }
1063
1064    /// Commit changes for a story
1065    fn commit_story(&self, story: &UserStory) -> anyhow::Result<()> {
1066        info!("Committing changes for story: {}", story.id);
1067
1068        // Stage all changes
1069        let _ = Command::new("git")
1070            .args(["add", "-A"])
1071            .current_dir(&self.state.working_dir)
1072            .output();
1073
1074        // Commit with story reference
1075        let msg = format!("feat({}): {}", story.id.to_lowercase(), story.title);
1076        match Command::new("git")
1077            .args(["commit", "-m", &msg])
1078            .current_dir(&self.state.working_dir)
1079            .output()
1080        {
1081            Ok(output) if output.status.success() => {
1082                info!("Committed: {}", msg);
1083            }
1084            Ok(output) => {
1085                warn!(
1086                    "Git commit had no changes or failed: {}",
1087                    String::from_utf8_lossy(&output.stderr)
1088                );
1089            }
1090            Err(e) => {
1091                warn!("Could not run git commit: {}", e);
1092            }
1093        }
1094
1095        Ok(())
1096    }
1097
1098    /// Git checkout
1099    fn git_checkout(&self, branch: &str) -> anyhow::Result<()> {
1100        // Try to checkout, create if doesn't exist
1101        let output = Command::new("git")
1102            .args(["checkout", branch])
1103            .current_dir(&self.state.working_dir)
1104            .output()?;
1105
1106        if !output.status.success() {
1107            Command::new("git")
1108                .args(["checkout", "-b", branch])
1109                .current_dir(&self.state.working_dir)
1110                .output()?;
1111        }
1112
1113        Ok(())
1114    }
1115
1116    /// Load progress file
1117    fn load_progress(&self) -> anyhow::Result<String> {
1118        let path = self.state.working_dir.join(&self.config.progress_path);
1119        Ok(std::fs::read_to_string(path).unwrap_or_default())
1120    }
1121
1122    /// Append to progress file
1123    fn append_progress(&self, entry: &ProgressEntry, response: &str) -> anyhow::Result<()> {
1124        let path = self.state.working_dir.join(&self.config.progress_path);
1125        let mut content = self.load_progress().unwrap_or_default();
1126
1127        content.push_str(&format!(
1128            "\n---\n\n## Iteration {} - {} ({})\n\n**Status:** {}\n\n### Summary\n{}\n",
1129            entry.iteration, entry.story_id, entry.timestamp, entry.status, response
1130        ));
1131
1132        std::fs::write(path, content)?;
1133        Ok(())
1134    }
1135
1136    /// Extract learnings from response
1137    fn extract_learnings(&self, response: &str) -> Vec<String> {
1138        let mut learnings = Vec::new();
1139
1140        for line in response.lines() {
1141            if line.contains("learned") || line.contains("Learning") || line.contains("# What") {
1142                learnings.push(line.trim().to_string());
1143            }
1144        }
1145
1146        learnings
1147    }
1148
1149    /// Get current status
1150    pub fn status(&self) -> &RalphState {
1151        &self.state
1152    }
1153
1154    /// Format status as markdown
1155    pub fn status_markdown(&self) -> String {
1156        let status = if self.state.prd.is_complete() {
1157            "# Ralph Complete!"
1158        } else {
1159            "# Ralph Status"
1160        };
1161
1162        let stories: Vec<String> = self
1163            .state
1164            .prd
1165            .user_stories
1166            .iter()
1167            .map(|s| {
1168                let check = if s.passes { "[x]" } else { "[ ]" };
1169                format!("- {} {}: {}", check, s.id, s.title)
1170            })
1171            .collect();
1172
1173        format!(
1174            "{}\n\n**Project:** {}\n**Feature:** {}\n**Progress:** {}/{} stories\n**Iterations:** {}/{}\n\n## Stories\n{}",
1175            status,
1176            self.state.prd.project,
1177            self.state.prd.feature,
1178            self.state.prd.passed_count(),
1179            self.state.prd.user_stories.len(),
1180            self.state.current_iteration,
1181            self.state.max_iterations,
1182            stories.join("\n")
1183        )
1184    }
1185}
1186
1187/// Create a sample PRD template
1188pub fn create_prd_template(project: &str, feature: &str) -> Prd {
1189    Prd {
1190        project: project.to_string(),
1191        feature: feature.to_string(),
1192        branch_name: format!("feature/{}", feature.to_lowercase().replace(' ', "-")),
1193        version: "1.0".to_string(),
1194        user_stories: vec![UserStory {
1195            id: "US-001".to_string(),
1196            title: "First user story".to_string(),
1197            description: "Description of what needs to be implemented".to_string(),
1198            acceptance_criteria: vec!["Criterion 1".to_string(), "Criterion 2".to_string()],
1199            passes: false,
1200            priority: 1,
1201            depends_on: Vec::new(),
1202            complexity: 3,
1203        }],
1204        technical_requirements: Vec::new(),
1205        quality_checks: QualityChecks {
1206            typecheck: Some("cargo check".to_string()),
1207            test: Some("cargo test".to_string()),
1208            lint: Some("cargo clippy".to_string()),
1209            build: Some("cargo build".to_string()),
1210        },
1211        created_at: chrono::Utc::now().to_rfc3339(),
1212        updated_at: chrono::Utc::now().to_rfc3339(),
1213    }
1214}