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 {
481                                                // Non-conflict failure (already aborted)
482                                                warn!(
483                                                    story_id = %story.id,
484                                                    summary = %merge_result.summary,
485                                                    "Merge failed (not conflicts)"
486                                                );
487                                                // Cleanup worktree
488                                                let _ = mgr.cleanup(wt);
489                                            }
490                                        }
491                                        Err(e) => {
492                                            warn!(
493                                                story_id = %story.id,
494                                                error = %e,
495                                                "Failed to merge worktree"
496                                            );
497                                        }
498                                    }
499                                } else {
500                                    // No worktree, just mark passed
501                                    self.state.prd.mark_passed(&story.id);
502                                }
503                            } else {
504                                warn!("Story {} failed quality checks", story.id);
505                                // Cleanup worktree without merging
506                                if let (Some(wt), Some(mgr)) = (&worktree_info, &worktree_mgr) {
507                                    let _ = mgr.cleanup(wt);
508                                }
509                            }
510                        } else {
511                            // Failed - cleanup worktree without merging (keep for debugging)
512                            if let Some(ref wt) = worktree_info {
513                                info!(
514                                    story_id = %story.id,
515                                    worktree_path = %wt.path.display(),
516                                    "Keeping worktree for debugging (story failed)"
517                                );
518                            }
519                        }
520                    }
521                    Err(e) => {
522                        warn!("Story execution task failed: {}", e);
523                    }
524                }
525            }
526
527            // Save PRD after each stage
528            self.state.prd.save(&self.state.prd_path).await?;
529        }
530
531        Ok(())
532    }
533
534    /// Build prompt for a story (static version for parallel execution)
535    fn build_story_prompt(
536        story: &UserStory,
537        prd_info: &(String, String),
538        working_dir: &PathBuf,
539    ) -> String {
540        format!(
541            r#"# PRD: {} - {}
542
543## Working Directory: {}
544
545## Current Story: {} - {}
546
547{}
548
549### Acceptance Criteria:
550{}
551
552## WORKFLOW (follow this exactly):
553
5541. **EXPLORE** (2-4 tool calls): Use `glob` and `read` to understand existing code
5552. **IMPLEMENT** (5-15 tool calls): Use `write` or `edit` to make changes
5563. **VERIFY**: Run `bash` with command `cargo check 2>&1` to check for errors
5574. **FIX OR FINISH**:
558   - If no errors: Output `STORY_COMPLETE: {}` and STOP
559   - If errors: Parse the error, fix it, re-run cargo check (max 3 fix attempts)
560   - After 3 failed attempts: Output `STORY_BLOCKED: <error summary>` and STOP
561
562## UNDERSTANDING CARGO ERRORS:
563
564When `cargo check` fails, the output shows:
565```
566error[E0432]: unresolved import `crate::foo::bar`
567  --> src/file.rs:10:5
568   |
56910 | use crate::foo::bar;
570   |             ^^^ could not find `bar` in `foo`
571```
572
573Key parts:
574- `error[E0432]` = error code (search rustc --explain E0432 for details)
575- `src/file.rs:10:5` = file:line:column where error occurs
576- The message explains what's wrong
577
578COMMON FIXES:
579- "unresolved import" → module doesn't exist or isn't exported, check mod.rs
580- "cannot find" → typo in name or missing import
581- "mismatched types" → wrong type, check function signatures
582- "trait bound not satisfied" → missing impl or use statement
583
584## TOOL USAGE:
585- `read`: Read file content (always read before editing!)
586- `edit`: Modify files (MUST include 3+ lines before/after for unique context)
587- `write`: Create new files
588- `bash`: Run commands with `{{"command": "...", "cwd": "{}"}}`
589
590## CRITICAL RULES:
591- ALWAYS read a file before editing it
592- When edit fails with "ambiguous match", include MORE context lines
593- Do NOT add TODO/placeholder comments
594- Run `cargo check 2>&1` to see ALL errors including warnings
595- Count your fix attempts - STOP after 3 failures
596
597## TERMINATION:
598SUCCESS: Output `STORY_COMPLETE: {}`
599BLOCKED: Output `STORY_BLOCKED: <brief error description>`
600
601Do NOT keep iterating indefinitely. Stop when done or blocked.
602"#,
603            prd_info.0,
604            prd_info.1,
605            working_dir.display(),
606            story.id,
607            story.title,
608            story.description,
609            story
610                .acceptance_criteria
611                .iter()
612                .map(|c| format!("- {}", c))
613                .collect::<Vec<_>>()
614                .join("\n"),
615            story.id,
616            working_dir.display(),
617            story.id
618        )
619    }
620
621    /// Call LLM with agentic tool loop (static version for parallel execution)
622    async fn call_llm_static(
623        provider: &Arc<dyn Provider>,
624        model: &str,
625        prompt: &str,
626        working_dir: &PathBuf,
627    ) -> anyhow::Result<String> {
628        // Build system prompt with AGENTS.md
629        let system_prompt = crate::agent::builtin::build_system_prompt(working_dir);
630
631        // Create tool registry with provider for file operations
632        let tool_registry =
633            ToolRegistry::with_provider_arc(Arc::clone(provider), model.to_string());
634
635        // Filter out 'question' tool - sub-agents must be autonomous, not interactive
636        let tool_definitions: Vec<_> = tool_registry
637            .definitions()
638            .into_iter()
639            .filter(|t| t.name != "question")
640            .collect();
641
642        info!(
643            "Ralph sub-agent starting with {} tools in {:?}",
644            tool_definitions.len(),
645            working_dir
646        );
647
648        // Run the agentic loop with tools
649        let (output, steps, tool_calls) = run_agent_loop(
650            Arc::clone(provider),
651            model,
652            &system_prompt,
653            prompt,
654            tool_definitions,
655            tool_registry, // Already an Arc<ToolRegistry>
656            30,            // max steps per story (focused implementation)
657            180,           // 3 minute timeout per story
658        )
659        .await?;
660
661        info!(
662            "Ralph sub-agent completed: {} steps, {} tool calls",
663            steps, tool_calls
664        );
665
666        Ok(output)
667    }
668
669    /// Resolve merge conflicts using a dedicated sub-agent
670    async fn resolve_conflicts_static(
671        provider: &Arc<dyn Provider>,
672        model: &str,
673        working_dir: &PathBuf,
674        story: &UserStory,
675        conflicts: &[String],
676        conflict_diffs: &[(String, String)],
677    ) -> anyhow::Result<bool> {
678        info!(
679            story_id = %story.id,
680            num_conflicts = conflicts.len(),
681            "Starting conflict resolution sub-agent"
682        );
683
684        // Build prompt with conflict context
685        let conflict_info = conflict_diffs
686            .iter()
687            .map(|(file, diff)| format!("### File: {}\n```diff\n{}\n```", file, diff))
688            .collect::<Vec<_>>()
689            .join("\n\n");
690
691        let prompt = format!(
692            r#"# CONFLICT RESOLUTION TASK
693
694## Story Context: {} - {}
695{}
696
697## Conflicting Files
698The following files have merge conflicts that need resolution:
699{}
700
701## Conflict Details
702{}
703
704## Your Task
7051. Read each conflicting file to see the conflict markers
7062. Understand what BOTH sides are trying to do:
707   - HEAD (main branch): the current state
708   - The incoming branch: the sub-agent's changes for story {}
7093. Resolve each conflict by:
710   - Keeping BOTH changes if they don't actually conflict
711   - Merging the logic if they touch the same code
712   - Preferring the sub-agent's changes if they implement the story requirement
7134. Remove ALL conflict markers (<<<<<<<, =======, >>>>>>>)
7145. Ensure the final code compiles: run `cargo check`
715
716## CRITICAL RULES
717- Do NOT leave any conflict markers in files
718- Do NOT just pick one side - understand and merge the intent
719- MUST run `cargo check` after resolving to verify
720- Stage resolved files with `git add <file>`
721
722## Termination
723SUCCESS: Output `CONFLICTS_RESOLVED` when all files are resolved and compile
724FAILED: Output `CONFLICTS_UNRESOLVED: <reason>` if you cannot resolve
725
726Working directory: {}
727"#,
728            story.id,
729            story.title,
730            story.description,
731            conflicts
732                .iter()
733                .map(|f| format!("- {}", f))
734                .collect::<Vec<_>>()
735                .join("\n"),
736            conflict_info,
737            story.id,
738            working_dir.display()
739        );
740
741        // Build system prompt
742        let system_prompt = crate::agent::builtin::build_system_prompt(working_dir);
743
744        // Create tool registry
745        let tool_registry =
746            ToolRegistry::with_provider_arc(Arc::clone(provider), model.to_string());
747
748        let tool_definitions: Vec<_> = tool_registry
749            .definitions()
750            .into_iter()
751            .filter(|t| t.name != "question")
752            .collect();
753
754        info!(
755            "Conflict resolver starting with {} tools",
756            tool_definitions.len()
757        );
758
759        // Run the resolver with smaller limits (conflicts should be quick to resolve)
760        let (output, steps, tool_calls) = run_agent_loop(
761            Arc::clone(provider),
762            model,
763            &system_prompt,
764            &prompt,
765            tool_definitions,
766            tool_registry,
767            15,  // max 15 steps for conflict resolution
768            120, // 2 minute timeout
769        )
770        .await?;
771
772        info!(
773            story_id = %story.id,
774            steps = steps,
775            tool_calls = tool_calls,
776            "Conflict resolver completed"
777        );
778
779        // Check if resolution was successful
780        let resolved = output.contains("CONFLICTS_RESOLVED")
781            || (output.contains("resolved") && !output.contains("UNRESOLVED"));
782
783        if resolved {
784            info!(story_id = %story.id, "Conflicts resolved successfully");
785        } else {
786            warn!(
787                story_id = %story.id,
788                output = %output.chars().take(200).collect::<String>(),
789                "Conflict resolution may have failed"
790            );
791        }
792
793        Ok(resolved)
794    }
795
796    /// Extract learnings (static version)
797    fn extract_learnings_static(response: &str) -> Vec<String> {
798        response
799            .lines()
800            .filter(|line| {
801                line.contains("learned") || line.contains("Learning") || line.contains("# What")
802            })
803            .map(|line| line.trim().to_string())
804            .collect()
805    }
806
807    /// Commit changes in a specific directory
808    fn commit_in_dir(dir: &PathBuf, story: &UserStory) -> anyhow::Result<()> {
809        // Stage all changes
810        let _ = Command::new("git")
811            .args(["add", "-A"])
812            .current_dir(dir)
813            .output();
814
815        // Commit with story reference
816        let msg = format!("feat({}): {}", story.id.to_lowercase(), story.title);
817        let _ = Command::new("git")
818            .args(["commit", "-m", &msg])
819            .current_dir(dir)
820            .output();
821
822        Ok(())
823    }
824
825    /// Run quality gates in a specific directory
826    async fn run_quality_gates_in_dir(&self, dir: &PathBuf) -> anyhow::Result<bool> {
827        let checks = &self.state.prd.quality_checks;
828
829        for (name, cmd) in [
830            ("typecheck", &checks.typecheck),
831            ("lint", &checks.lint),
832            ("test", &checks.test),
833            ("build", &checks.build),
834        ] {
835            if let Some(command) = cmd {
836                debug!("Running {} check in {:?}: {}", name, dir, command);
837                let output = Command::new("/bin/sh")
838                    .arg("-c")
839                    .arg(command)
840                    .current_dir(dir)
841                    .output()
842                    .map_err(|e| {
843                        anyhow::anyhow!("Failed to run quality check '{}': {}", name, e)
844                    })?;
845
846                if !output.status.success() {
847                    // Parse output to separate errors from warnings
848                    let stderr = String::from_utf8_lossy(&output.stderr);
849                    let stdout = String::from_utf8_lossy(&output.stdout);
850                    let combined = format!("{}\n{}", stdout, stderr);
851
852                    // Count actual errors vs warnings
853                    let error_count = combined
854                        .lines()
855                        .filter(|line| {
856                            line.starts_with("error")
857                                || line.contains("error:")
858                                || line.contains("error[")
859                        })
860                        .count();
861                    let warning_count = combined
862                        .lines()
863                        .filter(|line| line.starts_with("warning") || line.contains("warning:"))
864                        .count();
865
866                    // Extract the actual error message (not warnings)
867                    let error_summary: String = combined
868                        .lines()
869                        .filter(|line| {
870                            line.starts_with("error")
871                                || line.contains("error:")
872                                || line.contains("error[")
873                        })
874                        .take(5) // First 5 error lines
875                        .collect::<Vec<_>>()
876                        .join("\n");
877
878                    warn!(
879                        check = %name,
880                        dir = %dir.display(),
881                        errors = error_count,
882                        warnings = warning_count,
883                        error_summary = %error_summary.chars().take(300).collect::<String>(),
884                        "{} check failed in {:?}",
885                        name,
886                        dir
887                    );
888                    return Ok(false);
889                }
890            }
891        }
892
893        Ok(true)
894    }
895
896    /// Build the prompt for a story
897    fn build_prompt(&self, story: &UserStory) -> String {
898        let progress = self.load_progress().unwrap_or_default();
899
900        format!(
901            r#"# PRD: {} - {}
902
903## Current Story: {} - {}
904
905{}
906
907### Acceptance Criteria:
908{}
909
910## Previous Progress:
911{}
912
913## Instructions:
9141. Implement the requirements for this story
9152. Write any necessary code changes
9163. Document what you learned
9174. End with `STORY_COMPLETE: {}` when done
918
919Respond with the implementation and any shell commands needed.
920"#,
921            self.state.prd.project,
922            self.state.prd.feature,
923            story.id,
924            story.title,
925            story.description,
926            story
927                .acceptance_criteria
928                .iter()
929                .map(|c| format!("- {}", c))
930                .collect::<Vec<_>>()
931                .join("\n"),
932            if progress.is_empty() {
933                "None yet".to_string()
934            } else {
935                progress
936            },
937            story.id
938        )
939    }
940
941    /// Call the LLM with a prompt using agentic tool loop
942    async fn call_llm(&self, prompt: &str) -> anyhow::Result<String> {
943        // Build system prompt with AGENTS.md
944        let system_prompt = crate::agent::builtin::build_system_prompt(&self.state.working_dir);
945
946        // Create tool registry with provider for file operations
947        let tool_registry =
948            ToolRegistry::with_provider_arc(Arc::clone(&self.provider), self.model.clone());
949
950        // Filter out 'question' tool - sub-agents must be autonomous, not interactive
951        let tool_definitions: Vec<_> = tool_registry
952            .definitions()
953            .into_iter()
954            .filter(|t| t.name != "question")
955            .collect();
956
957        info!(
958            "Ralph agent starting with {} tools in {:?}",
959            tool_definitions.len(),
960            self.state.working_dir
961        );
962
963        // Run the agentic loop with tools
964        let (output, steps, tool_calls) = run_agent_loop(
965            Arc::clone(&self.provider),
966            &self.model,
967            &system_prompt,
968            prompt,
969            tool_definitions,
970            tool_registry, // Already an Arc<ToolRegistry>
971            30,            // max steps per story (focused implementation)
972            180,           // 3 minute timeout per story
973        )
974        .await?;
975
976        info!(
977            "Ralph agent completed: {} steps, {} tool calls",
978            steps, tool_calls
979        );
980
981        Ok(output)
982    }
983
984    /// Run quality gates
985    async fn run_quality_gates(&self) -> anyhow::Result<bool> {
986        let checks = &self.state.prd.quality_checks;
987
988        for (name, cmd) in [
989            ("typecheck", &checks.typecheck),
990            ("lint", &checks.lint),
991            ("test", &checks.test),
992            ("build", &checks.build),
993        ] {
994            if let Some(command) = cmd {
995                info!(
996                    "Running {} check in {:?}: {}",
997                    name, self.state.working_dir, command
998                );
999                let output = Command::new("/bin/sh")
1000                    .arg("-c")
1001                    .arg(command)
1002                    .current_dir(&self.state.working_dir)
1003                    .output()
1004                    .map_err(|e| {
1005                        anyhow::anyhow!("Failed to run quality check '{}': {}", name, e)
1006                    })?;
1007
1008                if !output.status.success() {
1009                    // Parse output to separate errors from warnings
1010                    let stderr = String::from_utf8_lossy(&output.stderr);
1011                    let stdout = String::from_utf8_lossy(&output.stdout);
1012                    let combined = format!("{}\n{}", stdout, stderr);
1013
1014                    // Count actual errors vs warnings
1015                    let error_count = combined
1016                        .lines()
1017                        .filter(|line| {
1018                            line.starts_with("error")
1019                                || line.contains("error:")
1020                                || line.contains("error[")
1021                        })
1022                        .count();
1023                    let warning_count = combined
1024                        .lines()
1025                        .filter(|line| line.starts_with("warning") || line.contains("warning:"))
1026                        .count();
1027
1028                    // Extract the actual error message (not warnings)
1029                    let error_summary: String = combined
1030                        .lines()
1031                        .filter(|line| {
1032                            line.starts_with("error")
1033                                || line.contains("error:")
1034                                || line.contains("error[")
1035                        })
1036                        .take(5) // First 5 error lines
1037                        .collect::<Vec<_>>()
1038                        .join("\n");
1039
1040                    warn!(
1041                        check = %name,
1042                        errors = error_count,
1043                        warnings = warning_count,
1044                        error_summary = %error_summary.chars().take(300).collect::<String>(),
1045                        "{} check failed",
1046                        name
1047                    );
1048                    return Ok(false);
1049                }
1050            }
1051        }
1052
1053        Ok(true)
1054    }
1055
1056    /// Commit changes for a story
1057    fn commit_story(&self, story: &UserStory) -> anyhow::Result<()> {
1058        info!("Committing changes for story: {}", story.id);
1059
1060        // Stage all changes
1061        let _ = Command::new("git")
1062            .args(["add", "-A"])
1063            .current_dir(&self.state.working_dir)
1064            .output();
1065
1066        // Commit with story reference
1067        let msg = format!("feat({}): {}", story.id.to_lowercase(), story.title);
1068        match Command::new("git")
1069            .args(["commit", "-m", &msg])
1070            .current_dir(&self.state.working_dir)
1071            .output()
1072        {
1073            Ok(output) if output.status.success() => {
1074                info!("Committed: {}", msg);
1075            }
1076            Ok(output) => {
1077                warn!(
1078                    "Git commit had no changes or failed: {}",
1079                    String::from_utf8_lossy(&output.stderr)
1080                );
1081            }
1082            Err(e) => {
1083                warn!("Could not run git commit: {}", e);
1084            }
1085        }
1086
1087        Ok(())
1088    }
1089
1090    /// Git checkout
1091    fn git_checkout(&self, branch: &str) -> anyhow::Result<()> {
1092        // Try to checkout, create if doesn't exist
1093        let output = Command::new("git")
1094            .args(["checkout", branch])
1095            .current_dir(&self.state.working_dir)
1096            .output()?;
1097
1098        if !output.status.success() {
1099            Command::new("git")
1100                .args(["checkout", "-b", branch])
1101                .current_dir(&self.state.working_dir)
1102                .output()?;
1103        }
1104
1105        Ok(())
1106    }
1107
1108    /// Load progress file
1109    fn load_progress(&self) -> anyhow::Result<String> {
1110        let path = self.state.working_dir.join(&self.config.progress_path);
1111        Ok(std::fs::read_to_string(path).unwrap_or_default())
1112    }
1113
1114    /// Append to progress file
1115    fn append_progress(&self, entry: &ProgressEntry, response: &str) -> anyhow::Result<()> {
1116        let path = self.state.working_dir.join(&self.config.progress_path);
1117        let mut content = self.load_progress().unwrap_or_default();
1118
1119        content.push_str(&format!(
1120            "\n---\n\n## Iteration {} - {} ({})\n\n**Status:** {}\n\n### Summary\n{}\n",
1121            entry.iteration, entry.story_id, entry.timestamp, entry.status, response
1122        ));
1123
1124        std::fs::write(path, content)?;
1125        Ok(())
1126    }
1127
1128    /// Extract learnings from response
1129    fn extract_learnings(&self, response: &str) -> Vec<String> {
1130        let mut learnings = Vec::new();
1131
1132        for line in response.lines() {
1133            if line.contains("learned") || line.contains("Learning") || line.contains("# What") {
1134                learnings.push(line.trim().to_string());
1135            }
1136        }
1137
1138        learnings
1139    }
1140
1141    /// Get current status
1142    pub fn status(&self) -> &RalphState {
1143        &self.state
1144    }
1145
1146    /// Format status as markdown
1147    pub fn status_markdown(&self) -> String {
1148        let status = if self.state.prd.is_complete() {
1149            "# Ralph Complete!"
1150        } else {
1151            "# Ralph Status"
1152        };
1153
1154        let stories: Vec<String> = self
1155            .state
1156            .prd
1157            .user_stories
1158            .iter()
1159            .map(|s| {
1160                let check = if s.passes { "[x]" } else { "[ ]" };
1161                format!("- {} {}: {}", check, s.id, s.title)
1162            })
1163            .collect();
1164
1165        format!(
1166            "{}\n\n**Project:** {}\n**Feature:** {}\n**Progress:** {}/{} stories\n**Iterations:** {}/{}\n\n## Stories\n{}",
1167            status,
1168            self.state.prd.project,
1169            self.state.prd.feature,
1170            self.state.prd.passed_count(),
1171            self.state.prd.user_stories.len(),
1172            self.state.current_iteration,
1173            self.state.max_iterations,
1174            stories.join("\n")
1175        )
1176    }
1177}
1178
1179/// Create a sample PRD template
1180pub fn create_prd_template(project: &str, feature: &str) -> Prd {
1181    Prd {
1182        project: project.to_string(),
1183        feature: feature.to_string(),
1184        branch_name: format!("feature/{}", feature.to_lowercase().replace(' ', "-")),
1185        version: "1.0".to_string(),
1186        user_stories: vec![UserStory {
1187            id: "US-001".to_string(),
1188            title: "First user story".to_string(),
1189            description: "Description of what needs to be implemented".to_string(),
1190            acceptance_criteria: vec!["Criterion 1".to_string(), "Criterion 2".to_string()],
1191            passes: false,
1192            priority: 1,
1193            depends_on: Vec::new(),
1194            complexity: 3,
1195        }],
1196        technical_requirements: Vec::new(),
1197        quality_checks: QualityChecks {
1198            typecheck: Some("cargo check".to_string()),
1199            test: Some("cargo test".to_string()),
1200            lint: Some("cargo clippy".to_string()),
1201            build: Some("cargo build".to_string()),
1202        },
1203        created_at: chrono::Utc::now().to_rfc3339(),
1204        updated_at: chrono::Utc::now().to_rfc3339(),
1205    }
1206}