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 urgent_steer_path(&self) -> PathBuf {
183 self.ralph_dir().join("urgent-steer.json")
184 }
185
186 pub fn tasks_path(&self) -> PathBuf {
190 self.agent_dir().join("tasks.jsonl")
191 }
192
193 pub fn scratchpad_path(&self) -> PathBuf {
197 self.agent_dir().join("scratchpad.md")
198 }
199
200 pub fn memories_path(&self) -> PathBuf {
205 self.agent_dir().join("memories.md")
206 }
207
208 pub fn main_memories_path(&self) -> PathBuf {
212 self.repo_root
213 .join(".ralph")
214 .join("agent")
215 .join("memories.md")
216 }
217
218 pub fn context_path(&self) -> PathBuf {
223 self.agent_dir().join("context.md")
224 }
225
226 pub fn specs_dir(&self) -> PathBuf {
231 self.ralph_dir().join("specs")
232 }
233
234 pub fn code_tasks_dir(&self) -> PathBuf {
240 self.ralph_dir().join("tasks")
241 }
242
243 pub fn main_specs_dir(&self) -> PathBuf {
247 self.repo_root.join(".ralph").join("specs")
248 }
249
250 pub fn main_code_tasks_dir(&self) -> PathBuf {
254 self.repo_root.join(".ralph").join("tasks")
255 }
256
257 pub fn summary_path(&self) -> PathBuf {
261 self.agent_dir().join("summary.md")
262 }
263
264 pub fn handoff_path(&self) -> PathBuf {
269 self.agent_dir().join("handoff.md")
270 }
271
272 pub fn diagnostics_dir(&self) -> PathBuf {
276 self.ralph_dir().join("diagnostics")
277 }
278
279 pub fn history_path(&self) -> PathBuf {
283 self.ralph_dir().join("history.jsonl")
284 }
285
286 pub fn loop_lock_path(&self) -> PathBuf {
288 self.repo_root.join(".ralph").join("loop.lock")
290 }
291
292 pub fn merge_queue_path(&self) -> PathBuf {
296 self.repo_root.join(".ralph").join("merge-queue.jsonl")
297 }
298
299 pub fn loop_registry_path(&self) -> PathBuf {
303 self.repo_root.join(".ralph").join("loops.json")
304 }
305
306 pub fn planning_sessions_dir(&self) -> PathBuf {
310 self.ralph_dir().join("planning-sessions")
311 }
312
313 pub fn planning_session_dir(&self, id: &str) -> PathBuf {
319 self.planning_sessions_dir().join(id)
320 }
321
322 pub fn planning_conversation_path(&self, id: &str) -> PathBuf {
328 self.planning_session_dir(id).join("conversation.jsonl")
329 }
330
331 pub fn planning_session_metadata_path(&self, id: &str) -> PathBuf {
337 self.planning_session_dir(id).join("session.json")
338 }
339
340 pub fn planning_artifacts_dir(&self, id: &str) -> PathBuf {
346 self.planning_session_dir(id).join("artifacts")
347 }
348
349 pub fn ensure_ralph_dir(&self) -> std::io::Result<()> {
355 std::fs::create_dir_all(self.ralph_dir())
356 }
357
358 pub fn ensure_agent_dir(&self) -> std::io::Result<()> {
360 std::fs::create_dir_all(self.agent_dir())
361 }
362
363 pub fn ensure_specs_dir(&self) -> std::io::Result<()> {
365 std::fs::create_dir_all(self.specs_dir())
366 }
367
368 pub fn ensure_code_tasks_dir(&self) -> std::io::Result<()> {
370 std::fs::create_dir_all(self.code_tasks_dir())
371 }
372
373 pub fn ensure_directories(&self) -> std::io::Result<()> {
375 self.ensure_ralph_dir()?;
376 self.ensure_agent_dir()?;
377 Ok(())
379 }
380
381 #[cfg(unix)]
392 pub fn setup_memory_symlink(&self) -> std::io::Result<bool> {
393 if self.is_primary {
394 return Ok(false);
395 }
396
397 let memories_path = self.memories_path();
398 let main_memories = self.main_memories_path();
399
400 if memories_path.exists() || memories_path.is_symlink() {
402 return Ok(false);
403 }
404
405 self.ensure_agent_dir()?;
407
408 std::os::unix::fs::symlink(&main_memories, &memories_path)?;
410 Ok(true)
411 }
412
413 #[cfg(not(unix))]
415 pub fn setup_memory_symlink(&self) -> std::io::Result<bool> {
416 Ok(false)
419 }
420
421 #[cfg(unix)]
432 pub fn setup_specs_symlink(&self) -> std::io::Result<bool> {
433 if self.is_primary {
434 return Ok(false);
435 }
436
437 let specs_path = self.specs_dir();
438 let main_specs = self.main_specs_dir();
439
440 if specs_path.exists() || specs_path.is_symlink() {
442 return Ok(false);
443 }
444
445 self.ensure_ralph_dir()?;
447
448 std::os::unix::fs::symlink(&main_specs, &specs_path)?;
450 Ok(true)
451 }
452
453 #[cfg(not(unix))]
455 pub fn setup_specs_symlink(&self) -> std::io::Result<bool> {
456 Ok(false)
457 }
458
459 #[cfg(unix)]
470 pub fn setup_code_tasks_symlink(&self) -> std::io::Result<bool> {
471 if self.is_primary {
472 return Ok(false);
473 }
474
475 let tasks_path = self.code_tasks_dir();
476 let main_tasks = self.main_code_tasks_dir();
477
478 if tasks_path.exists() || tasks_path.is_symlink() {
480 return Ok(false);
481 }
482
483 self.ensure_ralph_dir()?;
485
486 std::os::unix::fs::symlink(&main_tasks, &tasks_path)?;
488 Ok(true)
489 }
490
491 #[cfg(not(unix))]
493 pub fn setup_code_tasks_symlink(&self) -> std::io::Result<bool> {
494 Ok(false)
495 }
496
497 pub fn generate_context_file(&self, branch: &str, prompt: &str) -> std::io::Result<bool> {
513 if self.is_primary {
514 return Ok(false);
515 }
516
517 let context_path = self.context_path();
518
519 if context_path.exists() {
521 return Ok(false);
522 }
523
524 self.ensure_agent_dir()?;
526
527 let loop_id = self.loop_id().unwrap_or("unknown");
528 let created = chrono::Utc::now().to_rfc3339();
529
530 let prompt_preview = truncate_with_ellipsis(prompt, 200);
532
533 let content = format!(
534 r#"# Worktree Context
535
536- **Loop ID**: {}
537- **Workspace**: {}
538- **Main Repo**: {}
539- **Branch**: {}
540- **Created**: {}
541- **Prompt**: "{}"
542
543## Notes
544
545This is a worktree-based parallel loop. The following resources are symlinked
546to the main repository:
547
548- `.ralph/agent/memories.md` → shared memories
549- `.ralph/specs/` → shared specifications
550- `.ralph/tasks/` → shared code task files
551
552Local state (scratchpad, runtime tasks, events) is isolated to this worktree.
553"#,
554 loop_id,
555 self.workspace.display(),
556 self.repo_root.display(),
557 branch,
558 created,
559 prompt_preview
560 );
561
562 std::fs::write(&context_path, content)?;
563 Ok(true)
564 }
565
566 #[cfg(unix)]
571 pub fn setup_worktree_symlinks(&self) -> std::io::Result<()> {
572 self.setup_memory_symlink()?;
573 self.setup_specs_symlink()?;
574 self.setup_code_tasks_symlink()?;
575 Ok(())
576 }
577
578 #[cfg(not(unix))]
580 pub fn setup_worktree_symlinks(&self) -> std::io::Result<()> {
581 Ok(())
582 }
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588 use tempfile::TempDir;
589
590 #[test]
591 fn test_primary_context() {
592 let ctx = LoopContext::primary(PathBuf::from("/project"));
593
594 assert!(ctx.is_primary());
595 assert!(ctx.loop_id().is_none());
596 assert_eq!(ctx.workspace(), Path::new("/project"));
597 assert_eq!(ctx.repo_root(), Path::new("/project"));
598 }
599
600 #[test]
601 fn test_worktree_context() {
602 let ctx = LoopContext::worktree(
603 "loop-1234-abcd",
604 PathBuf::from("/project/.worktrees/loop-1234-abcd"),
605 PathBuf::from("/project"),
606 );
607
608 assert!(!ctx.is_primary());
609 assert_eq!(ctx.loop_id(), Some("loop-1234-abcd"));
610 assert_eq!(
611 ctx.workspace(),
612 Path::new("/project/.worktrees/loop-1234-abcd")
613 );
614 assert_eq!(ctx.repo_root(), Path::new("/project"));
615 }
616
617 #[test]
618 fn test_primary_path_resolution() {
619 let ctx = LoopContext::primary(PathBuf::from("/project"));
620
621 assert_eq!(ctx.ralph_dir(), PathBuf::from("/project/.ralph"));
622 assert_eq!(ctx.agent_dir(), PathBuf::from("/project/.ralph/agent"));
623 assert_eq!(
624 ctx.events_path(),
625 PathBuf::from("/project/.ralph/events.jsonl")
626 );
627 assert_eq!(
628 ctx.tasks_path(),
629 PathBuf::from("/project/.ralph/agent/tasks.jsonl")
630 );
631 assert_eq!(
632 ctx.scratchpad_path(),
633 PathBuf::from("/project/.ralph/agent/scratchpad.md")
634 );
635 assert_eq!(
636 ctx.memories_path(),
637 PathBuf::from("/project/.ralph/agent/memories.md")
638 );
639 assert_eq!(
640 ctx.summary_path(),
641 PathBuf::from("/project/.ralph/agent/summary.md")
642 );
643 assert_eq!(
644 ctx.handoff_path(),
645 PathBuf::from("/project/.ralph/agent/handoff.md")
646 );
647 assert_eq!(ctx.specs_dir(), PathBuf::from("/project/.ralph/specs"));
648 assert_eq!(ctx.code_tasks_dir(), PathBuf::from("/project/.ralph/tasks"));
649 assert_eq!(
650 ctx.diagnostics_dir(),
651 PathBuf::from("/project/.ralph/diagnostics")
652 );
653 assert_eq!(
654 ctx.history_path(),
655 PathBuf::from("/project/.ralph/history.jsonl")
656 );
657 }
658
659 #[test]
660 fn test_worktree_path_resolution() {
661 let ctx = LoopContext::worktree(
662 "loop-1234-abcd",
663 PathBuf::from("/project/.worktrees/loop-1234-abcd"),
664 PathBuf::from("/project"),
665 );
666
667 assert_eq!(
669 ctx.ralph_dir(),
670 PathBuf::from("/project/.worktrees/loop-1234-abcd/.ralph")
671 );
672 assert_eq!(
673 ctx.agent_dir(),
674 PathBuf::from("/project/.worktrees/loop-1234-abcd/.ralph/agent")
675 );
676 assert_eq!(
677 ctx.events_path(),
678 PathBuf::from("/project/.worktrees/loop-1234-abcd/.ralph/events.jsonl")
679 );
680 assert_eq!(
681 ctx.tasks_path(),
682 PathBuf::from("/project/.worktrees/loop-1234-abcd/.ralph/agent/tasks.jsonl")
683 );
684 assert_eq!(
685 ctx.scratchpad_path(),
686 PathBuf::from("/project/.worktrees/loop-1234-abcd/.ralph/agent/scratchpad.md")
687 );
688
689 assert_eq!(
691 ctx.memories_path(),
692 PathBuf::from("/project/.worktrees/loop-1234-abcd/.ralph/agent/memories.md")
693 );
694
695 assert_eq!(
697 ctx.main_memories_path(),
698 PathBuf::from("/project/.ralph/agent/memories.md")
699 );
700
701 assert_eq!(
703 ctx.specs_dir(),
704 PathBuf::from("/project/.worktrees/loop-1234-abcd/.ralph/specs")
705 );
706 assert_eq!(
707 ctx.code_tasks_dir(),
708 PathBuf::from("/project/.worktrees/loop-1234-abcd/.ralph/tasks")
709 );
710 assert_eq!(ctx.main_specs_dir(), PathBuf::from("/project/.ralph/specs"));
711 assert_eq!(
712 ctx.main_code_tasks_dir(),
713 PathBuf::from("/project/.ralph/tasks")
714 );
715
716 assert_eq!(
718 ctx.loop_lock_path(),
719 PathBuf::from("/project/.ralph/loop.lock")
720 );
721 assert_eq!(
722 ctx.merge_queue_path(),
723 PathBuf::from("/project/.ralph/merge-queue.jsonl")
724 );
725 assert_eq!(
726 ctx.loop_registry_path(),
727 PathBuf::from("/project/.ralph/loops.json")
728 );
729 }
730
731 #[test]
732 fn test_ensure_directories() {
733 let temp = TempDir::new().unwrap();
734 let ctx = LoopContext::primary(temp.path().to_path_buf());
735
736 assert!(!ctx.ralph_dir().exists());
738 assert!(!ctx.agent_dir().exists());
739
740 ctx.ensure_directories().unwrap();
742
743 assert!(ctx.ralph_dir().exists());
745 assert!(ctx.agent_dir().exists());
746 }
747
748 #[cfg(unix)]
749 #[test]
750 fn test_memory_symlink_primary_noop() {
751 let temp = TempDir::new().unwrap();
752 let ctx = LoopContext::primary(temp.path().to_path_buf());
753
754 let created = ctx.setup_memory_symlink().unwrap();
756 assert!(!created);
757 }
758
759 #[cfg(unix)]
760 #[test]
761 fn test_memory_symlink_worktree() {
762 let temp = TempDir::new().unwrap();
763 let repo_root = temp.path().to_path_buf();
764 let worktree_path = repo_root.join(".worktrees/loop-1234");
765
766 std::fs::create_dir_all(repo_root.join(".ralph/agent")).unwrap();
768 std::fs::write(repo_root.join(".ralph/agent/memories.md"), "# Memories\n").unwrap();
769
770 let ctx = LoopContext::worktree("loop-1234", worktree_path.clone(), repo_root.clone());
771
772 ctx.ensure_agent_dir().unwrap();
774 let created = ctx.setup_memory_symlink().unwrap();
775 assert!(created);
776
777 let memories = ctx.memories_path();
779 assert!(memories.is_symlink());
780 assert_eq!(
781 std::fs::read_link(&memories).unwrap(),
782 ctx.main_memories_path()
783 );
784
785 let created_again = ctx.setup_memory_symlink().unwrap();
787 assert!(!created_again);
788 }
789
790 #[test]
791 fn test_current_events_marker() {
792 let ctx = LoopContext::primary(PathBuf::from("/project"));
793 assert_eq!(
794 ctx.current_events_marker(),
795 PathBuf::from("/project/.ralph/current-events")
796 );
797 }
798
799 #[test]
800 fn test_planning_sessions_paths() {
801 let ctx = LoopContext::primary(PathBuf::from("/project"));
802
803 assert_eq!(
804 ctx.planning_sessions_dir(),
805 PathBuf::from("/project/.ralph/planning-sessions")
806 );
807 }
808
809 #[test]
810 fn test_planning_session_paths() {
811 let ctx = LoopContext::primary(PathBuf::from("/project"));
812 let session_id = "20260127-143022-a7f2";
813
814 assert_eq!(
815 ctx.planning_session_dir(session_id),
816 PathBuf::from("/project/.ralph/planning-sessions/20260127-143022-a7f2")
817 );
818 assert_eq!(
819 ctx.planning_conversation_path(session_id),
820 PathBuf::from(
821 "/project/.ralph/planning-sessions/20260127-143022-a7f2/conversation.jsonl"
822 )
823 );
824 assert_eq!(
825 ctx.planning_session_metadata_path(session_id),
826 PathBuf::from("/project/.ralph/planning-sessions/20260127-143022-a7f2/session.json")
827 );
828 assert_eq!(
829 ctx.planning_artifacts_dir(session_id),
830 PathBuf::from("/project/.ralph/planning-sessions/20260127-143022-a7f2/artifacts")
831 );
832 }
833
834 #[test]
835 fn test_planning_session_directory_creation() {
836 let temp = TempDir::new().unwrap();
837 let ctx = LoopContext::primary(temp.path().to_path_buf());
838 let session_id = "test-session";
839
840 std::fs::create_dir_all(ctx.planning_session_dir(session_id)).unwrap();
842
843 assert!(ctx.planning_session_dir(session_id).exists());
845 assert!(ctx.planning_sessions_dir().exists());
846 }
847
848 #[test]
849 fn test_context_path() {
850 let ctx = LoopContext::primary(PathBuf::from("/project"));
851 assert_eq!(
852 ctx.context_path(),
853 PathBuf::from("/project/.ralph/agent/context.md")
854 );
855 }
856
857 #[cfg(unix)]
858 #[test]
859 fn test_specs_symlink_worktree() {
860 let temp = TempDir::new().unwrap();
861 let repo_root = temp.path().to_path_buf();
862 let worktree_path = repo_root.join(".worktrees/loop-1234");
863
864 std::fs::create_dir_all(repo_root.join(".ralph/specs")).unwrap();
866 std::fs::write(repo_root.join(".ralph/specs/test.spec.md"), "# Test Spec\n").unwrap();
867
868 let ctx = LoopContext::worktree("loop-1234", worktree_path.clone(), repo_root.clone());
869
870 ctx.ensure_ralph_dir().unwrap();
872
873 let created = ctx.setup_specs_symlink().unwrap();
875 assert!(created);
876
877 let specs = ctx.specs_dir();
879 assert!(specs.is_symlink());
880 assert_eq!(std::fs::read_link(&specs).unwrap(), ctx.main_specs_dir());
881
882 let created_again = ctx.setup_specs_symlink().unwrap();
884 assert!(!created_again);
885 }
886
887 #[cfg(unix)]
888 #[test]
889 fn test_code_tasks_symlink_worktree() {
890 let temp = TempDir::new().unwrap();
891 let repo_root = temp.path().to_path_buf();
892 let worktree_path = repo_root.join(".worktrees/loop-1234");
893
894 std::fs::create_dir_all(repo_root.join(".ralph/tasks")).unwrap();
896 std::fs::write(
897 repo_root.join(".ralph/tasks/test.code-task.md"),
898 "# Test Task\n",
899 )
900 .unwrap();
901
902 let ctx = LoopContext::worktree("loop-1234", worktree_path.clone(), repo_root.clone());
903
904 ctx.ensure_ralph_dir().unwrap();
906
907 let created = ctx.setup_code_tasks_symlink().unwrap();
909 assert!(created);
910
911 let tasks = ctx.code_tasks_dir();
913 assert!(tasks.is_symlink());
914 assert_eq!(
915 std::fs::read_link(&tasks).unwrap(),
916 ctx.main_code_tasks_dir()
917 );
918
919 let created_again = ctx.setup_code_tasks_symlink().unwrap();
921 assert!(!created_again);
922 }
923
924 #[test]
925 fn test_generate_context_file_primary_noop() {
926 let temp = TempDir::new().unwrap();
927 let ctx = LoopContext::primary(temp.path().to_path_buf());
928
929 let created = ctx.generate_context_file("main", "test prompt").unwrap();
931 assert!(!created);
932 }
933
934 #[test]
935 fn test_generate_context_file_worktree() {
936 let temp = TempDir::new().unwrap();
937 let repo_root = temp.path().to_path_buf();
938 let worktree_path = repo_root.join(".worktrees/loop-1234");
939
940 let ctx = LoopContext::worktree("loop-1234", worktree_path.clone(), repo_root.clone());
941
942 ctx.ensure_agent_dir().unwrap();
944 let created = ctx
945 .generate_context_file("ralph/loop-1234", "Add footer")
946 .unwrap();
947 assert!(created);
948
949 let context_path = ctx.context_path();
951 assert!(context_path.exists());
952
953 let content = std::fs::read_to_string(&context_path).unwrap();
954 assert!(content.contains("# Worktree Context"));
955 assert!(content.contains("loop-1234"));
956 assert!(content.contains("Add footer"));
957 assert!(content.contains("ralph/loop-1234"));
958
959 let created_again = ctx
961 .generate_context_file("ralph/loop-1234", "Add footer")
962 .unwrap();
963 assert!(!created_again);
964 }
965
966 #[cfg(unix)]
967 #[test]
968 fn test_setup_worktree_symlinks() {
969 let temp = TempDir::new().unwrap();
970 let repo_root = temp.path().to_path_buf();
971 let worktree_path = repo_root.join(".worktrees/loop-1234");
972
973 std::fs::create_dir_all(repo_root.join(".ralph/agent")).unwrap();
975 std::fs::create_dir_all(repo_root.join(".ralph/specs")).unwrap();
976 std::fs::create_dir_all(repo_root.join(".ralph/tasks")).unwrap();
977 std::fs::write(repo_root.join(".ralph/agent/memories.md"), "# Memories\n").unwrap();
978
979 let ctx = LoopContext::worktree("loop-1234", worktree_path.clone(), repo_root.clone());
980
981 ctx.ensure_directories().unwrap();
983 ctx.setup_worktree_symlinks().unwrap();
984
985 assert!(ctx.memories_path().is_symlink());
987 assert!(ctx.specs_dir().is_symlink());
988 assert!(ctx.code_tasks_dir().is_symlink());
989 }
990}