/** * Handover document generation for plan mode. * * Runs a side LLM call over the current branch (does NOT add a turn to the * conversation), lets the user edit the result, then writes it to * `.pi/plans/-.md`. */ import type { AgentMessage } from "@earendil-works/pi-agent-core"; import { type Message, uuidv7 } from "@earendil-works/pi-ai"; import { BorderedLoader, CONFIG_DIR_NAME, convertToLlm, type ExtensionCommandContext, type SessionEntry, serializeConversation, } from "@earendil-works/pi-coding-agent"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; export const PLANS_DIR = "plans"; const SYSTEM_PROMPT = `You write handover documents. Another engineer (or agent) will pick up this work with NO access to the conversation you are summarizing. The document must stand alone. Output GitHub-flavoured markdown with exactly these sections: # ## Goal One paragraph: what we are trying to achieve and why. ## Findings What was learned while investigating. Cite concrete file paths and, where useful, line numbers or symbol names. Include things that were ruled out and why. ## Plan A numbered list of concrete, actionable steps in execution order. Each step names the files it touches. No vague steps like "improve error handling". ## Risks & open questions Bullets. Anything unverified, any decision the user still needs to make. Omit the section if genuinely empty. ## Files Bullet list of \`path\` — one-line reason it matters. Rules: - No preamble, no "Here is the handover". Start with the \`#\` heading. - Be specific over complete. Facts from the conversation only; never invent file paths. - Do not describe the conversation itself ("the user asked..."). Describe the work.`; function entryToMessage(entry: SessionEntry): AgentMessage | undefined { if (entry.type === "message") return entry.message; if (entry.type === "compaction") { return { role: "compactionSummary", summary: entry.summary, tokensBefore: entry.tokensBefore, timestamp: new Date(entry.timestamp).getTime(), }; } return undefined; } /** Branch messages, respecting the most recent compaction boundary. */ function collectMessages(branch: SessionEntry[]): AgentMessage[] { let compactionIndex = -1; for (let i = branch.length - 1; i >= 0; i--) { if (branch[i]?.type === "compaction") { compactionIndex = i; break; } } if (compactionIndex < 0) { return branch.map(entryToMessage).filter((m): m is AgentMessage => m !== undefined); } const compaction = branch[compactionIndex]; const firstKept = compaction?.type === "compaction" ? branch.findIndex((e) => e.id === compaction.firstKeptEntryId) : -1; return [ compaction, ...(firstKept >= 0 ? branch.slice(firstKept, compactionIndex) : []), ...branch.slice(compactionIndex + 1), ] .map((e) => (e ? entryToMessage(e) : undefined)) .filter((m): m is AgentMessage => m !== undefined); } function slugify(text: string): string { return ( text .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 50) .replace(/-+$/g, "") || "handover" ); } function timestamp(): string { const d = new Date(); const p = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}`; } /** Derive a filename slug from the document's first `# heading`. */ function slugFromDocument(markdown: string): string { const heading = markdown.match(/^#\s+(.+)$/m); return slugify(heading?.[1] ?? "handover"); } /** * Generate a handover document and write it to `.pi/plans/`. * Returns the written path, or undefined if cancelled or unavailable. */ export async function writeHandover(ctx: ExtensionCommandContext, focus: string): Promise { if (ctx.mode !== "tui") { ctx.ui.notify("/handover requires interactive mode", "error"); return; } if (!ctx.model) { ctx.ui.notify("No model selected", "error"); return; } const messages = collectMessages(ctx.sessionManager.getBranch()); if (messages.length === 0) { ctx.ui.notify("Nothing to hand over yet", "warning"); return; } const conversation = serializeConversation(convertToLlm(messages)); const model = ctx.model; const generated = await ctx.ui.custom((tui, theme, _kb, done) => { const loader = new BorderedLoader(tui, theme, "Writing handover document..."); loader.onAbort = () => done(null); void (async () => { const prompt = focus ? `## Conversation\n\n${conversation}\n\n## Focus\n\nThe handover should centre on: ${focus}` : `## Conversation\n\n${conversation}`; const userMessage: Message = { role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now(), }; const response = await ctx.modelRegistry.complete( model, { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, { signal: loader.signal, cacheRetention: "none", sessionId: uuidv7() }, ); if (response.stopReason === "aborted") return null; return response.content .filter((c): c is { type: "text"; text: string } => c.type === "text") .map((c) => c.text) .join("\n") .trim(); })() .then(done) .catch(() => done(null)); return loader; }); if (!generated) { ctx.ui.notify("Handover cancelled", "info"); return; } const edited = await ctx.ui.editor("Review handover (save to write, empty to cancel)", generated); if (edited === undefined || !edited.trim()) { ctx.ui.notify("Handover cancelled", "info"); return; } const dir = join(ctx.cwd, CONFIG_DIR_NAME, PLANS_DIR); const path = join(dir, `${timestamp()}-${slugFromDocument(edited)}.md`); try { await mkdir(dir, { recursive: true }); await writeFile(path, `${edited.trimEnd()}\n`, "utf8"); } catch (err) { ctx.ui.notify(`Failed to write handover: ${err instanceof Error ? err.message : String(err)}`, "error"); return; } ctx.ui.notify(`Handover written to ${path}`, "info"); return path; }