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