1use 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
13pub struct RalphLoop {
15 state: RalphState,
16 provider: Arc<dyn Provider>,
17 model: String,
18 config: RalphConfig,
19}
20
21impl RalphLoop {
22 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 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 pub async fn run(&mut self) -> anyhow::Result<RalphState> {
69 self.state.status = RalphStatus::Running;
70
71 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 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 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 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 if self.state.prd.is_complete() {
126 info!("All stories complete!");
127 self.state.status = RalphStatus::Completed;
128 break;
129 }
130
131 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 let prompt = self.build_prompt(&story);
144
145 match self.call_llm(&prompt).await {
147 Ok(response) => {
148 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 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 if self.config.auto_commit {
168 self.commit_story(&story)?;
169 }
170
171 self.state.prd.save(&self.state.prd_path).await?;
173 } else {
174 warn!("Story {} failed quality checks", story.id);
175 }
176 } else {
177 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 async fn run_parallel(&mut self) -> anyhow::Result<()> {
202 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 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 let stories: Vec<UserStory> = stage_stories;
257
258 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 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 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 let prompt = Self::build_story_prompt(&story, &prd_info, &story_working_dir);
324
325 let result =
327 Self::call_llm_static(&provider, &model, &prompt, &story_working_dir).await;
328
329 let entry = match &result {
330 Ok(response) => {
331 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 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 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 if let Some(ref wt) = worktree_info {
390 let _ = Self::commit_in_dir(&wt.path, &story);
391 }
392
393 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 let _ = mgr.cleanup(wt);
406 } else if !merge_result.conflicts.is_empty() {
407 info!(
409 story_id = %story.id,
410 num_conflicts = merge_result.conflicts.len(),
411 "Spawning conflict resolver sub-agent"
412 );
413
414 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 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 let _ = mgr.cleanup(wt);
480 } else {
481 warn!(
483 story_id = %story.id,
484 summary = %merge_result.summary,
485 "Merge failed (not conflicts)"
486 );
487 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 self.state.prd.mark_passed(&story.id);
502 }
503 } else {
504 warn!("Story {} failed quality checks", story.id);
505 if let (Some(wt), Some(mgr)) = (&worktree_info, &worktree_mgr) {
507 let _ = mgr.cleanup(wt);
508 }
509 }
510 } else {
511 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 self.state.prd.save(&self.state.prd_path).await?;
529 }
530
531 Ok(())
532 }
533
534 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 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 let system_prompt = crate::agent::builtin::build_system_prompt(working_dir);
630
631 let tool_registry =
633 ToolRegistry::with_provider_arc(Arc::clone(provider), model.to_string());
634
635 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 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, 30, 180, )
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 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 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 let system_prompt = crate::agent::builtin::build_system_prompt(working_dir);
743
744 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 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, 120, )
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 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 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 fn commit_in_dir(dir: &PathBuf, story: &UserStory) -> anyhow::Result<()> {
809 let _ = Command::new("git")
811 .args(["add", "-A"])
812 .current_dir(dir)
813 .output();
814
815 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 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 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 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 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) .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 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 async fn call_llm(&self, prompt: &str) -> anyhow::Result<String> {
943 let system_prompt = crate::agent::builtin::build_system_prompt(&self.state.working_dir);
945
946 let tool_registry =
948 ToolRegistry::with_provider_arc(Arc::clone(&self.provider), self.model.clone());
949
950 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 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, 30, 180, )
974 .await?;
975
976 info!(
977 "Ralph agent completed: {} steps, {} tool calls",
978 steps, tool_calls
979 );
980
981 Ok(output)
982 }
983
984 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 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 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 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) .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 fn commit_story(&self, story: &UserStory) -> anyhow::Result<()> {
1058 info!("Committing changes for story: {}", story.id);
1059
1060 let _ = Command::new("git")
1062 .args(["add", "-A"])
1063 .current_dir(&self.state.working_dir)
1064 .output();
1065
1066 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 fn git_checkout(&self, branch: &str) -> anyhow::Result<()> {
1092 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 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 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 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 pub fn status(&self) -> &RalphState {
1143 &self.state
1144 }
1145
1146 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
1179pub 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}