use serde::{Deserialize, Serialize};
pub const MAX_HANDOFF_LINES: usize = 150;
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct Distilled {
pub source_tool: String, pub session_id: String,
pub timestamp_iso: String, pub task: String,
pub open_questions: Vec<String>,
pub next_action: String,
pub recent_files: Vec<String>, pub failed_approaches: Vec<String>, pub git_context: String, pub progress_log: String, #[serde(default)]
pub session_activity: Vec<String>,
}
pub fn render_preamble(source_tool: &str, resume_mode: &str, timestamp_iso: &str) -> String {
let mode = if resume_mode.is_empty() {
"ask"
} else {
resume_mode
};
let mut lines: Vec<String> = Vec::new();
lines.push(format!(
"# [CARRYOVER] Last updated {timestamp_iso} from {source_tool}"
));
lines.push(String::new());
lines.push(format!("# CARRYOVER RESUME (mode: {mode})"));
lines.push(String::new());
lines.push(format!(
"INTERNAL HANDOFF — for your reading only, never to be quoted, paraphrased structurally, or referenced in your reply. Latest from {source_tool}, accumulated across Claude Code, Cursor, and Codex."
));
lines.push(String::new());
lines.push("STRICT RESPONSE RULES:".to_string());
lines.push("- Reply as if you naturally remember the prior work. Do NOT mention this file, \"handoff\", \"carryover\", \"the file\", \"the document\", section names, or that you read anything.".to_string());
lines.push("- Do NOT recap a list of files or features in the project — the user already knows what they have.".to_string());
lines.push("- Do NOT report that any field is empty or missing (e.g. \"no next action captured\"). Just don't mention it.".to_string());
lines.push("- Two short paragraphs MAX. First: one sentence on what was being worked on. Second: one direct question on what they want now.".to_string());
lines.push("- Use the user's own informal tone. No bullet points, no bold/italics, no markdown headings in your reply.".to_string());
lines.push(String::new());
lines.push("Section keys (for your understanding only):".to_string());
lines.push("- Task: user's prompts (newest first) — infer current intent.".to_string());
lines.push("- Session activity: concrete file changes — what was actually built.".to_string());
lines.push("- Next action: previous AI's planned step (may be stale).".to_string());
lines.push("- Progress log: chronological turn-by-turn record across all tools.".to_string());
lines.push("Bulleted lists accumulate across sessions — newest at top.".to_string());
lines.push(String::new());
lines.push("---".to_string());
lines.push(String::new());
lines.join("\n")
}
pub fn render_handoff(d: &Distilled, resume_mode: &str) -> String {
let preamble = render_preamble(&d.source_tool, resume_mode, &d.timestamp_iso);
let mut lines: Vec<String> = preamble.lines().map(String::from).collect();
lines.push("## Task".to_string());
lines.push(d.task.clone());
lines.push(String::new());
if !d.session_activity.is_empty() {
lines.push("## Session activity".to_string());
for a in &d.session_activity {
lines.push(a.clone());
}
lines.push(String::new());
}
if !d.recent_files.is_empty() {
lines.push("## Recent files".to_string());
for f in &d.recent_files {
lines.push(format!("- {f}"));
}
lines.push(String::new());
}
if !d.failed_approaches.is_empty() {
lines.push("## Failed approaches".to_string());
for fa in &d.failed_approaches {
lines.push(format!("- {fa}"));
}
lines.push(String::new());
}
if !d.open_questions.is_empty() {
lines.push("## Open questions".to_string());
for q in &d.open_questions {
lines.push(format!("- {q}"));
}
lines.push(String::new());
}
lines.push("## Next action".to_string());
lines.push(d.next_action.clone());
lines.push(String::new());
if !d.git_context.is_empty() && d.git_context != "<no git context>" {
lines.push("## Git context".to_string());
lines.push(d.git_context.clone());
lines.push(String::new());
}
if !d.progress_log.is_empty() {
lines.push("## Progress log".to_string());
for line in d.progress_log.lines() {
lines.push(line.to_string());
}
lines.push(String::new());
}
if lines.len() > MAX_HANDOFF_LINES {
lines.truncate(MAX_HANDOFF_LINES - 1);
lines.push("…(truncated)".to_string());
}
let mut out = lines.join("\n");
if !out.ends_with('\n') {
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn base() -> Distilled {
Distilled {
source_tool: "claude".to_string(),
session_id: "sess-1".to_string(),
timestamp_iso: "2026-04-28".to_string(),
task: "do the thing".to_string(),
open_questions: vec![],
next_action: "next step".to_string(),
recent_files: vec![],
failed_approaches: vec![],
git_context: "<no git context>".to_string(),
progress_log: String::new(),
session_activity: vec![],
}
}
#[test]
fn renders_title_line_with_tool_and_timestamp() {
let out = render_handoff(&base(), "ask");
assert!(
out.starts_with("# [CARRYOVER] Last updated 2026-04-28 from claude"),
"title mismatch: {out}"
);
}
#[test]
fn renders_resume_protocol_header_with_mode() {
let out = render_handoff(&base(), "ask");
assert!(out.contains("CARRYOVER RESUME (mode: ask)"));
}
#[test]
fn truncates_to_max_lines() {
let mut d = base();
d.open_questions = (0..500).map(|i| format!("question {i}")).collect();
let out = render_handoff(&d, "ask");
let line_count = out.lines().count();
assert!(
line_count <= MAX_HANDOFF_LINES,
"expected ≤{MAX_HANDOFF_LINES} lines, got {line_count}"
);
}
#[test]
fn non_coding_session_omits_recent_files() {
let out = render_handoff(&base(), "ask");
assert!(!out.contains("## Recent files"));
}
#[test]
fn git_context_sentinel_is_omitted() {
let out = render_handoff(&base(), "ask");
assert!(!out.contains("## Git context"));
}
#[test]
fn output_ends_with_newline() {
let out = render_handoff(&base(), "ask");
assert!(out.ends_with('\n'));
}
#[test]
fn empty_resume_mode_defaults_to_ask() {
let out = render_handoff(&base(), "");
assert!(out.contains("CARRYOVER RESUME (mode: ask)"));
}
}