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