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