Files
dot/modules/pi/agent/extensions-common/plan-mode/handover.ts
Martin Pander 331b759b7d Update pi
2026-08-24 09:12:22 +02:00

197 lines
5.9 KiB
TypeScript

/**
* 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/<slug>-<timestamp>.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:
# <short imperative title, max 8 words>
## 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<string | undefined> {
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<string | null>((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;
}