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