1use std::path::{Path, PathBuf};
58
59#[derive(Debug, Clone)]
64pub struct LoopContext {
65 loop_id: Option<String>,
67
68 workspace: PathBuf,
72
73 repo_root: PathBuf,
76
77 is_primary: bool,
79}
80
81impl LoopContext {
82 pub fn primary(workspace: PathBuf) -> Self {
87 Self {
88 loop_id: None,
89 repo_root: workspace.clone(),
90 workspace,
91 is_primary: true,
92 }
93 }
94
95 pub fn worktree(
106 loop_id: impl Into<String>,
107 worktree_path: PathBuf,
108 repo_root: PathBuf,
109 ) -> Self {
110 Self {
111 loop_id: Some(loop_id.into()),
112 workspace: worktree_path,
113 repo_root,
114 is_primary: false,
115 }
116 }
117
118 pub fn loop_id(&self) -> Option<&str> {
122 self.loop_id.as_deref()
123 }
124
125 pub fn is_primary(&self) -> bool {
127 self.is_primary
128 }
129
130 pub fn workspace(&self) -> &Path {
136 &self.workspace
137 }
138
139 pub fn repo_root(&self) -> &Path {
144 &self.repo_root
145 }
146
147 pub fn ralph_dir(&self) -> PathBuf {
153 self.workspace.join(".ralph")
154 }
155
156 pub fn agent_dir(&self) -> PathBuf {
160 self.ralph_dir().join("agent")
161 }
162
163 pub fn events_path(&self) -> PathBuf {
167 self.ralph_dir().join("events.jsonl")
168 }
169
170 pub fn current_events_marker(&self) -> PathBuf {
174 self.ralph_dir().join("current-events")
175 }
176
177 pub fn tasks_path(&self) -> PathBuf {
181 self.agent_dir().join("tasks.jsonl")
182 }
183
184 pub fn scratchpad_path(&self) -> PathBuf {
188 self.agent_dir().join("scratchpad.md")
189 }
190
191 pub fn memories_path(&self) -> PathBuf {
196 self.agent_dir().join("memories.md")
197 }
198
199 pub fn main_memories_path(&self) -> PathBuf {
203 self.repo_root
204 .join(".ralph")
205 .join("agent")
206 .join("memories.md")
207 }
208
209 pub fn context_path(&self) -> PathBuf {
214 self.agent_dir().join("context.md")
215 }
216
217 pub fn specs_dir(&self) -> PathBuf {
222 self.ralph_dir().join("specs")
223 }
224
225 pub fn code_tasks_dir(&self) -> PathBuf {
231 self.ralph_dir().join("tasks")
232 }
233
234 pub fn main_specs_dir(&self) -> PathBuf {
238 self.repo_root.join(".ralph").join("specs")
239 }
240
241 pub fn main_code_tasks_dir(&self) -> PathBuf {
245 self.repo_root.join(".ralph").join("tasks")
246 }
247
248 pub fn summary_path(&self) -> PathBuf {
252 self.agent_dir().join("summary.md")
253 }
254
255 pub fn handoff_path(&self) -> PathBuf {
260 self.agent_dir().join("handoff.md")
261 }
262
263 pub fn diagnostics_dir(&self) -> PathBuf {
267 self.ralph_dir().join("diagnostics")
268 }
269
270 pub fn history_path(&self) -> PathBuf {
274 self.ralph_dir().join("history.jsonl")
275 }
276
277 pub fn loop_lock_path(&self) -> PathBuf {
279 self.repo_root.join(".ralph").join("loop.lock")
281 }
282
283 pub fn merge_queue_path(&self) -> PathBuf {
287 self.repo_root.join(".ralph").join("merge-queue.jsonl")
288 }
289
290 pub fn loop_registry_path(&self) -> PathBuf {
294 self.repo_root.join(".ralph").join("loops.json")
295 }
296
297 pub fn planning_sessions_dir(&self) -> PathBuf {
301 self.ralph_dir().join("planning-sessions")
302 }
303
304 pub fn planning_session_dir(&self, id: &str) -> PathBuf {
310 self.planning_sessions_dir().join(id)
311 }
312
313 pub fn planning_conversation_path(&self, id: &str) -> PathBuf {
319 self.planning_session_dir(id).join("conversation.jsonl")
320 }
321
322 pub fn planning_session_metadata_path(&self, id: &str) -> PathBuf {
328 self.planning_session_dir(id).join("session.json")
329 }
330
331 pub fn planning_artifacts_dir(&self, id: &str) -> PathBuf {
337 self.planning_session_dir(id).join("artifacts")
338 }
339
340 pub fn ensure_ralph_dir(&self) -> std::io::Result<()> {
346 std::fs::create_dir_all(self.ralph_dir())
347 }
348
349 pub fn ensure_agent_dir(&self) -> std::io::Result<()> {
351 std::fs::create_dir_all(self.agent_dir())
352 }
353
354 pub fn ensure_specs_dir(&self) -> std::io::Result<()> {
356 std::fs::create_dir_all(self.specs_dir())
357 }
358
359 pub fn ensure_code_tasks_dir(&self) -> std::io::Result<()> {
361 std::fs::create_dir_all(self.code_tasks_dir())
362 }
363
364 pub fn ensure_directories(&self) -> std::io::Result<()> {
366 self.ensure_ralph_dir()?;
367 self.ensure_agent_dir()?;
368 Ok(())
370 }
371
372 #[cfg(unix)]
383 pub fn setup_memory_symlink(&self) -> std::io::Result<bool> {
384 if self.is_primary {
385 return Ok(false);
386 }
387
388 let memories_path = self.memories_path();
389 let main_memories = self.main_memories_path();
390
391 if memories_path.exists() || memories_path.is_symlink() {
393 return Ok(false);
394 }
395
396 self.ensure_agent_dir()?;
398
399 std::os::unix::fs::symlink(&main_memories, &memories_path)?;
401 Ok(true)
402 }
403
404 #[cfg(not(unix))]
406 pub fn setup_memory_symlink(&self) -> std::io::Result<bool> {
407 Ok(false)
410 }
411
412 #[cfg(unix)]
423 pub fn setup_specs_symlink(&self) -> std::io::Result<bool> {
424 if self.is_primary {
425 return Ok(false);
426 }
427
428 let specs_path = self.specs_dir();
429 let main_specs = self.main_specs_dir();
430
431 if specs_path.exists() || specs_path.is_symlink() {
433 return Ok(false);
434 }
435
436 self.ensure_ralph_dir()?;
438
439 std::os::unix::fs::symlink(&main_specs, &specs_path)?;
441 Ok(true)
442 }
443
444 #[cfg(not(unix))]
446 pub fn setup_specs_symlink(&self) -> std::io::Result<bool> {
447 Ok(false)
448 }
449
450 #[cfg(unix)]
461 pub fn setup_code_tasks_symlink(&self) -> std::io::Result<bool> {
462 if self.is_primary {
463 return Ok(false);
464 }
465
466 let tasks_path = self.code_tasks_dir();
467 let main_tasks = self.main_code_tasks_dir();
468
469 if tasks_path.exists() || tasks_path.is_symlink() {
471 return Ok(false);
472 }
473
474 self.ensure_ralph_dir()?;
476
477 std::os::unix::fs::symlink(&main_tasks, &tasks_path)?;
479 Ok(true)
480 }
481
482 #[cfg(not(unix))]
484 pub fn setup_code_tasks_symlink(&self) -> std::io::Result<bool> {
485 Ok(false)
486 }
487
488 pub fn generate_context_file(&self, branch: &str, prompt: &str) -> std::io::Result<bool> {
504 if self.is_primary {
505 return Ok(false);
506 }
507
508 let context_path = self.context_path();
509
510 if context_path.exists() {
512 return Ok(false);
513 }
514
515 self.ensure_agent_dir()?;
517
518 let loop_id = self.loop_id().unwrap_or("unknown");
519 let created = chrono::Utc::now().to_rfc3339();
520
521 let prompt_preview = if prompt.len() > 200 {
523 format!("{}...", &prompt[..200])
524 } else {
525 prompt.to_string()
526 };
527
528 let content = format!(
529 r#"# Worktree Context
530
531- **Loop ID**: {}
532- **Workspace**: {}
533- **Main Repo**: {}
534- **Branch**: {}
535- **Created**: {}
536- **Prompt**: "{}"
537
538## Notes
539
540This is a worktree-based parallel loop. The following resources are symlinked
541to the main repository:
542
543- `.ralph/agent/memories.md` → shared memories
544- `.ralph/specs/` → shared specifications
545- `.ralph/tasks/` → shared code task files
546
547Local state (scratchpad, runtime tasks, events) is isolated to this worktree.
548"#,
549 loop_id,
550 self.workspace.display(),
551 self.repo_root.display(),
552 branch,
553 created,
554 prompt_preview
555 );
556
557 std::fs::write(&context_path, content)?;
558 Ok(true)
559 }
560
561 #[cfg(unix)]
566 pub fn setup_worktree_symlinks(&self) -> std::io::Result<()> {
567 self.setup_memory_symlink()?;
568 self.setup_specs_symlink()?;
569 self.setup_code_tasks_symlink()?;
570 Ok(())
571 }
572
573 #[cfg(not(unix))]
575 pub fn setup_worktree_symlinks(&self) -> std::io::Result<()> {
576 Ok(())
577 }
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583 use tempfile::TempDir;
584
585 #[test]
586 fn test_primary_context() {
587 let ctx = LoopContext::primary(PathBuf::from("/project"));
588
589 assert!(ctx.is_primary());
590 assert!(ctx.loop_id().is_none());
591 assert_eq!(ctx.workspace(), Path::new("/project"));
592 assert_eq!(ctx.repo_root(), Path::new("/project"));
593 }
594
595 #[test]
596 fn test_worktree_context() {
597 let ctx = LoopContext::worktree(
598 "loop-1234-abcd",
599 PathBuf::from("/project/.worktrees/loop-1234-abcd"),
600 PathBuf::from("/project"),
601 );
602
603 assert!(!ctx.is_primary());
604 assert_eq!(ctx.loop_id(), Some("loop-1234-abcd"));
605 assert_eq!(
606 ctx.workspace(),
607 Path::new("/project/.worktrees/loop-1234-abcd")
608 );
609 assert_eq!(ctx.repo_root(), Path::new("/project"));
610 }
611
612 #[test]
613 fn test_primary_path_resolution() {
614 let ctx = LoopContext::primary(PathBuf::from("/project"));
615
616 assert_eq!(ctx.ralph_dir(), PathBuf::from("/project/.ralph"));
617 assert_eq!(ctx.agent_dir(), PathBuf::from("/project/.ralph/agent"));
618 assert_eq!(
619 ctx.events_path(),
620 PathBuf::from("/project/.ralph/events.jsonl")
621 );
622 assert_eq!(
623 ctx.tasks_path(),
624 PathBuf::from("/project/.ralph/agent/tasks.jsonl")
625 );
626 assert_eq!(
627 ctx.scratchpad_path(),
628 PathBuf::from("/project/.ralph/agent/scratchpad.md")
629 );
630 assert_eq!(
631 ctx.memories_path(),
632 PathBuf::from("/project/.ralph/agent/memories.md")
633 );
634 assert_eq!(
635 ctx.summary_path(),
636 PathBuf::from("/project/.ralph/agent/summary.md")
637 );
638 assert_eq!(
639 ctx.handoff_path(),
640 PathBuf::from("/project/.ralph/agent/handoff.md")
641 );
642 assert_eq!(ctx.specs_dir(), PathBuf::from("/project/.ralph/specs"));
643 assert_eq!(ctx.code_tasks_dir(), PathBuf::from("/project/.ralph/tasks"));
644 assert_eq!(
645 ctx.diagnostics_dir(),
646 PathBuf::from("/project/.ralph/diagnostics")
647 );
648 assert_eq!(
649 ctx.history_path(),
650 PathBuf::from("/project/.ralph/history.jsonl")
651 );
652 }
653
654 #[test]
655 fn test_worktree_path_resolution() {
656 let ctx = LoopContext::worktree(
657 "loop-1234-abcd",
658 PathBuf::from("/project/.worktrees/loop-1234-abcd"),
659 PathBuf::from("/project"),
660 );
661
662 assert_eq!(
664 ctx.ralph_dir(),
665 PathBuf::from("/project/.worktrees/loop-1234-abcd/.ralph")
666 );
667 assert_eq!(
668 ctx.agent_dir(),
669 PathBuf::from("/project/.worktrees/loop-1234-abcd/.ralph/agent")
670 );
671 assert_eq!(
672 ctx.events_path(),
673 PathBuf::from("/project/.worktrees/loop-1234-abcd/.ralph/events.jsonl")
674 );
675 assert_eq!(
676 ctx.tasks_path(),
677 PathBuf::from("/project/.worktrees/loop-1234-abcd/.ralph/agent/tasks.jsonl")
678 );
679 assert_eq!(
680 ctx.scratchpad_path(),
681 PathBuf::from("/project/.worktrees/loop-1234-abcd/.ralph/agent/scratchpad.md")
682 );
683
684 assert_eq!(
686 ctx.memories_path(),
687 PathBuf::from("/project/.worktrees/loop-1234-abcd/.ralph/agent/memories.md")
688 );
689
690 assert_eq!(
692 ctx.main_memories_path(),
693 PathBuf::from("/project/.ralph/agent/memories.md")
694 );
695
696 assert_eq!(
698 ctx.specs_dir(),
699 PathBuf::from("/project/.worktrees/loop-1234-abcd/.ralph/specs")
700 );
701 assert_eq!(
702 ctx.code_tasks_dir(),
703 PathBuf::from("/project/.worktrees/loop-1234-abcd/.ralph/tasks")
704 );
705 assert_eq!(ctx.main_specs_dir(), PathBuf::from("/project/.ralph/specs"));
706 assert_eq!(
707 ctx.main_code_tasks_dir(),
708 PathBuf::from("/project/.ralph/tasks")
709 );
710
711 assert_eq!(
713 ctx.loop_lock_path(),
714 PathBuf::from("/project/.ralph/loop.lock")
715 );
716 assert_eq!(
717 ctx.merge_queue_path(),
718 PathBuf::from("/project/.ralph/merge-queue.jsonl")
719 );
720 assert_eq!(
721 ctx.loop_registry_path(),
722 PathBuf::from("/project/.ralph/loops.json")
723 );
724 }
725
726 #[test]
727 fn test_ensure_directories() {
728 let temp = TempDir::new().unwrap();
729 let ctx = LoopContext::primary(temp.path().to_path_buf());
730
731 assert!(!ctx.ralph_dir().exists());
733 assert!(!ctx.agent_dir().exists());
734
735 ctx.ensure_directories().unwrap();
737
738 assert!(ctx.ralph_dir().exists());
740 assert!(ctx.agent_dir().exists());
741 }
742
743 #[cfg(unix)]
744 #[test]
745 fn test_memory_symlink_primary_noop() {
746 let temp = TempDir::new().unwrap();
747 let ctx = LoopContext::primary(temp.path().to_path_buf());
748
749 let created = ctx.setup_memory_symlink().unwrap();
751 assert!(!created);
752 }
753
754 #[cfg(unix)]
755 #[test]
756 fn test_memory_symlink_worktree() {
757 let temp = TempDir::new().unwrap();
758 let repo_root = temp.path().to_path_buf();
759 let worktree_path = repo_root.join(".worktrees/loop-1234");
760
761 std::fs::create_dir_all(repo_root.join(".ralph/agent")).unwrap();
763 std::fs::write(repo_root.join(".ralph/agent/memories.md"), "# Memories\n").unwrap();
764
765 let ctx = LoopContext::worktree("loop-1234", worktree_path.clone(), repo_root.clone());
766
767 ctx.ensure_agent_dir().unwrap();
769 let created = ctx.setup_memory_symlink().unwrap();
770 assert!(created);
771
772 let memories = ctx.memories_path();
774 assert!(memories.is_symlink());
775 assert_eq!(
776 std::fs::read_link(&memories).unwrap(),
777 ctx.main_memories_path()
778 );
779
780 let created_again = ctx.setup_memory_symlink().unwrap();
782 assert!(!created_again);
783 }
784
785 #[test]
786 fn test_current_events_marker() {
787 let ctx = LoopContext::primary(PathBuf::from("/project"));
788 assert_eq!(
789 ctx.current_events_marker(),
790 PathBuf::from("/project/.ralph/current-events")
791 );
792 }
793
794 #[test]
795 fn test_planning_sessions_paths() {
796 let ctx = LoopContext::primary(PathBuf::from("/project"));
797
798 assert_eq!(
799 ctx.planning_sessions_dir(),
800 PathBuf::from("/project/.ralph/planning-sessions")
801 );
802 }
803
804 #[test]
805 fn test_planning_session_paths() {
806 let ctx = LoopContext::primary(PathBuf::from("/project"));
807 let session_id = "20260127-143022-a7f2";
808
809 assert_eq!(
810 ctx.planning_session_dir(session_id),
811 PathBuf::from("/project/.ralph/planning-sessions/20260127-143022-a7f2")
812 );
813 assert_eq!(
814 ctx.planning_conversation_path(session_id),
815 PathBuf::from(
816 "/project/.ralph/planning-sessions/20260127-143022-a7f2/conversation.jsonl"
817 )
818 );
819 assert_eq!(
820 ctx.planning_session_metadata_path(session_id),
821 PathBuf::from("/project/.ralph/planning-sessions/20260127-143022-a7f2/session.json")
822 );
823 assert_eq!(
824 ctx.planning_artifacts_dir(session_id),
825 PathBuf::from("/project/.ralph/planning-sessions/20260127-143022-a7f2/artifacts")
826 );
827 }
828
829 #[test]
830 fn test_planning_session_directory_creation() {
831 let temp = TempDir::new().unwrap();
832 let ctx = LoopContext::primary(temp.path().to_path_buf());
833 let session_id = "test-session";
834
835 std::fs::create_dir_all(ctx.planning_session_dir(session_id)).unwrap();
837
838 assert!(ctx.planning_session_dir(session_id).exists());
840 assert!(ctx.planning_sessions_dir().exists());
841 }
842
843 #[test]
844 fn test_context_path() {
845 let ctx = LoopContext::primary(PathBuf::from("/project"));
846 assert_eq!(
847 ctx.context_path(),
848 PathBuf::from("/project/.ralph/agent/context.md")
849 );
850 }
851
852 #[cfg(unix)]
853 #[test]
854 fn test_specs_symlink_worktree() {
855 let temp = TempDir::new().unwrap();
856 let repo_root = temp.path().to_path_buf();
857 let worktree_path = repo_root.join(".worktrees/loop-1234");
858
859 std::fs::create_dir_all(repo_root.join(".ralph/specs")).unwrap();
861 std::fs::write(repo_root.join(".ralph/specs/test.spec.md"), "# Test Spec\n").unwrap();
862
863 let ctx = LoopContext::worktree("loop-1234", worktree_path.clone(), repo_root.clone());
864
865 ctx.ensure_ralph_dir().unwrap();
867
868 let created = ctx.setup_specs_symlink().unwrap();
870 assert!(created);
871
872 let specs = ctx.specs_dir();
874 assert!(specs.is_symlink());
875 assert_eq!(std::fs::read_link(&specs).unwrap(), ctx.main_specs_dir());
876
877 let created_again = ctx.setup_specs_symlink().unwrap();
879 assert!(!created_again);
880 }
881
882 #[cfg(unix)]
883 #[test]
884 fn test_code_tasks_symlink_worktree() {
885 let temp = TempDir::new().unwrap();
886 let repo_root = temp.path().to_path_buf();
887 let worktree_path = repo_root.join(".worktrees/loop-1234");
888
889 std::fs::create_dir_all(repo_root.join(".ralph/tasks")).unwrap();
891 std::fs::write(
892 repo_root.join(".ralph/tasks/test.code-task.md"),
893 "# Test Task\n",
894 )
895 .unwrap();
896
897 let ctx = LoopContext::worktree("loop-1234", worktree_path.clone(), repo_root.clone());
898
899 ctx.ensure_ralph_dir().unwrap();
901
902 let created = ctx.setup_code_tasks_symlink().unwrap();
904 assert!(created);
905
906 let tasks = ctx.code_tasks_dir();
908 assert!(tasks.is_symlink());
909 assert_eq!(
910 std::fs::read_link(&tasks).unwrap(),
911 ctx.main_code_tasks_dir()
912 );
913
914 let created_again = ctx.setup_code_tasks_symlink().unwrap();
916 assert!(!created_again);
917 }
918
919 #[test]
920 fn test_generate_context_file_primary_noop() {
921 let temp = TempDir::new().unwrap();
922 let ctx = LoopContext::primary(temp.path().to_path_buf());
923
924 let created = ctx.generate_context_file("main", "test prompt").unwrap();
926 assert!(!created);
927 }
928
929 #[test]
930 fn test_generate_context_file_worktree() {
931 let temp = TempDir::new().unwrap();
932 let repo_root = temp.path().to_path_buf();
933 let worktree_path = repo_root.join(".worktrees/loop-1234");
934
935 let ctx = LoopContext::worktree("loop-1234", worktree_path.clone(), repo_root.clone());
936
937 ctx.ensure_agent_dir().unwrap();
939 let created = ctx
940 .generate_context_file("ralph/loop-1234", "Add footer")
941 .unwrap();
942 assert!(created);
943
944 let context_path = ctx.context_path();
946 assert!(context_path.exists());
947
948 let content = std::fs::read_to_string(&context_path).unwrap();
949 assert!(content.contains("# Worktree Context"));
950 assert!(content.contains("loop-1234"));
951 assert!(content.contains("Add footer"));
952 assert!(content.contains("ralph/loop-1234"));
953
954 let created_again = ctx
956 .generate_context_file("ralph/loop-1234", "Add footer")
957 .unwrap();
958 assert!(!created_again);
959 }
960
961 #[cfg(unix)]
962 #[test]
963 fn test_setup_worktree_symlinks() {
964 let temp = TempDir::new().unwrap();
965 let repo_root = temp.path().to_path_buf();
966 let worktree_path = repo_root.join(".worktrees/loop-1234");
967
968 std::fs::create_dir_all(repo_root.join(".ralph/agent")).unwrap();
970 std::fs::create_dir_all(repo_root.join(".ralph/specs")).unwrap();
971 std::fs::create_dir_all(repo_root.join(".ralph/tasks")).unwrap();
972 std::fs::write(repo_root.join(".ralph/agent/memories.md"), "# Memories\n").unwrap();
973
974 let ctx = LoopContext::worktree("loop-1234", worktree_path.clone(), repo_root.clone());
975
976 ctx.ensure_directories().unwrap();
978 ctx.setup_worktree_symlinks().unwrap();
979
980 assert!(ctx.memories_path().is_symlink());
982 assert!(ctx.specs_dir().is_symlink());
983 assert!(ctx.code_tasks_dir().is_symlink());
984 }
985}