Skip to main content

ralph_core/
handoff.rs

1//! Handoff prompt generation for session continuity.
2//!
3//! Generates `.ralph/agent/handoff.md` on loop completion, containing:
4//! - What was completed (closed tasks)
5//! - What remains (open tasks with dependencies)
6//! - Context (last commit, branch, key files)
7//! - Ready-to-paste prompt for next session
8//!
9//! This enables clean session boundaries and seamless handoffs between
10//! Ralph loops, supporting the "land the plane" pattern.
11
12use crate::git_ops::{get_commit_summary, get_current_branch, get_head_sha, get_recent_files};
13use crate::loop_context::LoopContext;
14use crate::task::{Task, TaskStatus};
15use crate::task_store::TaskStore;
16use std::io;
17use std::path::PathBuf;
18
19/// Result of generating a handoff file.
20#[derive(Debug, Clone)]
21pub struct HandoffResult {
22    /// Path to the generated handoff file.
23    pub path: PathBuf,
24
25    /// Number of completed tasks mentioned.
26    pub completed_tasks: usize,
27
28    /// Number of open tasks mentioned.
29    pub open_tasks: usize,
30
31    /// Whether a continuation prompt was included.
32    pub has_continuation_prompt: bool,
33}
34
35/// Errors that can occur during handoff generation.
36#[derive(Debug, thiserror::Error)]
37pub enum HandoffError {
38    /// IO error writing the handoff file.
39    #[error("IO error: {0}")]
40    Io(#[from] io::Error),
41}
42
43/// Generates handoff files for session continuity.
44pub struct HandoffWriter {
45    context: LoopContext,
46}
47
48impl HandoffWriter {
49    /// Creates a new handoff writer for the given loop context.
50    pub fn new(context: LoopContext) -> Self {
51        Self { context }
52    }
53
54    /// Generates the handoff file with session context.
55    ///
56    /// # Arguments
57    ///
58    /// * `original_prompt` - The prompt that started this loop
59    ///
60    /// # Returns
61    ///
62    /// Information about what was written, or an error if generation failed.
63    pub fn write(&self, original_prompt: &str) -> Result<HandoffResult, HandoffError> {
64        let path = self.context.handoff_path();
65
66        // Ensure parent directory exists
67        if let Some(parent) = path.parent() {
68            std::fs::create_dir_all(parent)?;
69        }
70
71        let content = self.generate_content(original_prompt);
72
73        // Count tasks for result
74        let (completed_tasks, open_tasks) = self.count_tasks();
75
76        std::fs::write(&path, &content)?;
77
78        Ok(HandoffResult {
79            path,
80            completed_tasks,
81            open_tasks,
82            has_continuation_prompt: open_tasks > 0,
83        })
84    }
85
86    /// Generates the handoff markdown content.
87    fn generate_content(&self, original_prompt: &str) -> String {
88        let mut content = String::new();
89
90        // Header
91        content.push_str("# Session Handoff\n\n");
92        content.push_str(&format!(
93            "_Generated: {}_\n\n",
94            chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
95        ));
96
97        // Git context section
98        content.push_str("## Git Context\n\n");
99        self.write_git_context(&mut content);
100
101        // Tasks section
102        content.push_str("\n## Tasks\n\n");
103        self.write_tasks_section(&mut content);
104
105        // Key files section
106        content.push_str("\n## Key Files\n\n");
107        self.write_key_files(&mut content);
108
109        // Continuation prompt section
110        content.push_str("\n## Next Session\n\n");
111        self.write_continuation_prompt(&mut content, original_prompt);
112
113        content
114    }
115
116    /// Writes git context (branch, commit, status).
117    fn write_git_context(&self, content: &mut String) {
118        let workspace = self.context.workspace();
119
120        // Branch
121        match get_current_branch(workspace) {
122            Ok(branch) => content.push_str(&format!("- **Branch:** `{}`\n", branch)),
123            Err(_) => content.push_str("- **Branch:** _(unknown)_\n"),
124        }
125
126        // Commit
127        match get_head_sha(workspace) {
128            Ok(sha) => {
129                let summary = get_commit_summary(workspace).unwrap_or_default();
130                if summary.is_empty() {
131                    content.push_str(&format!("- **HEAD:** `{}`\n", &sha[..7.min(sha.len())]));
132                } else {
133                    content.push_str(&format!("- **HEAD:** {}\n", summary));
134                }
135            }
136            Err(_) => content.push_str("- **HEAD:** _(no commits)_\n"),
137        }
138
139        // Loop ID if worktree
140        if let Some(loop_id) = self.context.loop_id() {
141            content.push_str(&format!("- **Loop ID:** `{}`\n", loop_id));
142        }
143    }
144
145    /// Writes the tasks section with completed and open tasks.
146    fn write_tasks_section(&self, content: &mut String) {
147        let tasks_path = self.context.tasks_path();
148        let store = match TaskStore::load(&tasks_path) {
149            Ok(s) => s,
150            Err(_) => {
151                content.push_str("_No task history available._\n");
152                return;
153            }
154        };
155
156        let tasks = store.all();
157        if tasks.is_empty() {
158            content.push_str("_No tasks tracked in this session._\n");
159            return;
160        }
161
162        // Completed tasks
163        let completed: Vec<&Task> = tasks
164            .iter()
165            .filter(|t| t.status == TaskStatus::Closed)
166            .collect();
167
168        if !completed.is_empty() {
169            content.push_str("### Completed\n\n");
170            for task in &completed {
171                content.push_str(&format!("- [x] {}\n", task.title));
172            }
173            content.push('\n');
174        }
175
176        // Open tasks (including failed)
177        let open: Vec<&Task> = tasks
178            .iter()
179            .filter(|t| t.status != TaskStatus::Closed)
180            .collect();
181
182        if !open.is_empty() {
183            content.push_str("### Remaining\n\n");
184            for task in &open {
185                let status_marker = match task.status {
186                    TaskStatus::Failed => "[~]",
187                    _ => "[ ]",
188                };
189                let blocked = if task.blocked_by.is_empty() {
190                    String::new()
191                } else {
192                    format!(" _(blocked by: {})_", task.blocked_by.join(", "))
193                };
194                content.push_str(&format!("- {} {}{}\n", status_marker, task.title, blocked));
195            }
196        }
197    }
198
199    /// Writes key files that were modified.
200    fn write_key_files(&self, content: &mut String) {
201        match get_recent_files(self.context.workspace(), 10) {
202            Ok(files) if !files.is_empty() => {
203                content.push_str("Recently modified:\n\n");
204                for file in files {
205                    content.push_str(&format!("- `{}`\n", file));
206                }
207            }
208            _ => {
209                content.push_str("_No recent file modifications tracked._\n");
210            }
211        }
212    }
213
214    /// Writes the continuation prompt for the next session.
215    fn write_continuation_prompt(&self, content: &mut String, original_prompt: &str) {
216        let tasks_path = self.context.tasks_path();
217        let store = TaskStore::load(&tasks_path).ok();
218
219        let open_tasks: Vec<String> = store
220            .as_ref()
221            .map(|s| {
222                s.all()
223                    .iter()
224                    .filter(|t| t.status != TaskStatus::Closed)
225                    .map(|t| t.title.clone())
226                    .collect()
227            })
228            .unwrap_or_default();
229
230        if open_tasks.is_empty() {
231            content.push_str("Session completed successfully. No pending work.\n\n");
232            content.push_str("**Original objective:**\n\n");
233            content.push_str("```\n");
234            content.push_str(&truncate_prompt(original_prompt, 500));
235            content.push_str("\n```\n");
236        } else {
237            content.push_str(
238                "The following prompt can be used to continue where this session left off:\n\n",
239            );
240            content.push_str("```\n");
241
242            // Build continuation prompt
243            content.push_str("Continue the previous work. ");
244            content.push_str(&format!("Remaining tasks ({}):\n", open_tasks.len()));
245            for task in &open_tasks {
246                content.push_str(&format!("- {}\n", task));
247            }
248            content.push_str("\nOriginal objective: ");
249            content.push_str(&truncate_prompt(original_prompt, 200));
250
251            content.push_str("\n```\n");
252        }
253    }
254
255    /// Counts completed and open tasks.
256    fn count_tasks(&self) -> (usize, usize) {
257        let tasks_path = self.context.tasks_path();
258        let store = match TaskStore::load(&tasks_path) {
259            Ok(s) => s,
260            Err(_) => return (0, 0),
261        };
262
263        let completed = store
264            .all()
265            .iter()
266            .filter(|t| t.status == TaskStatus::Closed)
267            .count();
268        let open = store
269            .all()
270            .iter()
271            .filter(|t| t.status != TaskStatus::Closed)
272            .count();
273
274        (completed, open)
275    }
276}
277
278/// Truncates a prompt to a maximum length, adding ellipsis if needed.
279fn truncate_prompt(prompt: &str, max_len: usize) -> String {
280    let prompt = prompt.trim();
281    if prompt.len() <= max_len {
282        prompt.to_string()
283    } else {
284        format!("{}...", &prompt[..max_len])
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use std::fs;
292    use tempfile::TempDir;
293
294    fn setup_test_context() -> (TempDir, LoopContext) {
295        let temp = TempDir::new().unwrap();
296        let ctx = LoopContext::primary(temp.path().to_path_buf());
297        ctx.ensure_directories().unwrap();
298        (temp, ctx)
299    }
300
301    #[test]
302    fn test_handoff_writer_creates_file() {
303        let (_temp, ctx) = setup_test_context();
304        let writer = HandoffWriter::new(ctx.clone());
305
306        let result = writer.write("Test prompt").unwrap();
307
308        assert!(result.path.exists());
309        assert_eq!(result.path, ctx.handoff_path());
310    }
311
312    #[test]
313    fn test_handoff_content_has_sections() {
314        let (_temp, ctx) = setup_test_context();
315        let writer = HandoffWriter::new(ctx.clone());
316
317        writer.write("Test prompt").unwrap();
318
319        let content = fs::read_to_string(ctx.handoff_path()).unwrap();
320
321        assert!(content.contains("# Session Handoff"));
322        assert!(content.contains("## Git Context"));
323        assert!(content.contains("## Tasks"));
324        assert!(content.contains("## Key Files"));
325        assert!(content.contains("## Next Session"));
326    }
327
328    #[test]
329    fn test_handoff_with_no_tasks() {
330        let (_temp, ctx) = setup_test_context();
331        let writer = HandoffWriter::new(ctx.clone());
332
333        let result = writer.write("Test prompt").unwrap();
334
335        assert_eq!(result.completed_tasks, 0);
336        assert_eq!(result.open_tasks, 0);
337        assert!(!result.has_continuation_prompt);
338    }
339
340    #[test]
341    fn test_handoff_with_tasks() {
342        let (_temp, ctx) = setup_test_context();
343
344        // Create some tasks
345        let mut store = TaskStore::load(&ctx.tasks_path()).unwrap();
346        let task1 = crate::task::Task::new("Completed task".to_string(), 1);
347        let id1 = task1.id.clone();
348        store.add(task1);
349        store.close(&id1);
350
351        let task2 = crate::task::Task::new("Open task".to_string(), 2);
352        store.add(task2);
353        store.save().unwrap();
354
355        let writer = HandoffWriter::new(ctx.clone());
356        let result = writer.write("Test prompt").unwrap();
357
358        assert_eq!(result.completed_tasks, 1);
359        assert_eq!(result.open_tasks, 1);
360        assert!(result.has_continuation_prompt);
361
362        let content = fs::read_to_string(ctx.handoff_path()).unwrap();
363        assert!(content.contains("[x] Completed task"));
364        assert!(content.contains("[ ] Open task"));
365        assert!(content.contains("Remaining tasks"));
366    }
367
368    #[test]
369    fn test_truncate_prompt_short() {
370        let result = truncate_prompt("short prompt", 100);
371        assert_eq!(result, "short prompt");
372    }
373
374    #[test]
375    fn test_truncate_prompt_long() {
376        let long_prompt = "a".repeat(200);
377        let result = truncate_prompt(&long_prompt, 50);
378        assert_eq!(result.len(), 53); // 50 + "..."
379        assert!(result.ends_with("..."));
380    }
381}