/** * MPC Plan Persistence * * Saves plan state to $(cwd)/.pi/mpc/.md so it survives session restarts. * Each file tracks the current phase and all phase outputs, allowing a new * session to resume from where the previous one left off. */ import * as fs from "fs"; import * as path from "path"; import type { MpcPhase } from "./utils.js"; export interface PhaseRecord { phase: MpcPhase; completedAt: string; // ISO timestamp content: string; // LLM output for this phase } export interface MpcPlanFile { version: 1; planFile: string; // relative path from cwd task: string; // original user task createdAt: string; updatedAt: string; currentPhase: MpcPhase; backtrackCount: number; phases: Partial>; } /** Convert a task string to a safe slug (max 40 chars) */ export function taskToSlug(task: string): string { return task .toLowerCase() .replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 40) .replace(/-+$/, ""); } /** Return the .pi/mpc dir for the given cwd, creating it if needed */ function getMpcDir(cwd: string): string { const dir = path.join(cwd, ".pi", "mpc"); fs.mkdirSync(dir, { recursive: true }); return dir; } /** Generate a timestamped plan filename */ export function makePlanFileName(task: string): string { const now = new Date(); const ts = now.toISOString().replace(/[:.]/g, "-").slice(0, 19); // 2026-04-21T14-32-00 const slug = taskToSlug(task) || "plan"; return `${ts}-${slug}.md`; } /** Serialize MpcPlanFile to Markdown with YAML-style frontmatter */ function serialize(plan: MpcPlanFile): string { const phaseOrder: MpcPhase[] = ["explore", "dryrun", "issues", "backtrack", "verified", "execute"]; const phaseLabels: Record = { idle: "Idle", explore: "Phase 1: Exploration", dryrun: "Phase 2: Dry Run", issues: "Phase 3: Issue Detection", backtrack: "Phase 4: Backtrack", verified: "Phase 5: Verified Plan", execute: "Executing", }; const lines: string[] = [ "", "", "## MPC Plan", "", `- **Task**: ${plan.task}`, `- **Created**: ${plan.createdAt}`, `- **Updated**: ${plan.updatedAt}`, `- **Current Phase**: \`${plan.currentPhase}\``, `- **Backtrack Count**: ${plan.backtrackCount}`, "", "---", "", ]; for (const ph of phaseOrder) { const record = plan.phases[ph]; if (!record) continue; lines.push(`## ${phaseLabels[ph]}`); lines.push(`_Completed: ${record.completedAt}_`); lines.push(""); lines.push(record.content.trim()); lines.push(""); lines.push("---"); lines.push(""); } // Append machine-readable JSON block at the end for reliable deserialization lines.push(""); return lines.join("\n"); } /** Deserialize MpcPlanFile from Markdown file content */ function deserialize(raw: string): MpcPlanFile | null { try { const match = raw.match(//); if (!match) return null; return JSON.parse(match[1].trim()) as MpcPlanFile; } catch { return null; } } export class MpcPlanPersister { private cwd: string; private plan: MpcPlanFile | null = null; private planPath: string | null = null; constructor(cwd: string) { this.cwd = cwd; } /** Start a new plan for the given task. Writes initial file immediately. */ start(task: string): string { const fileName = makePlanFileName(task); const dir = getMpcDir(this.cwd); this.planPath = path.join(dir, fileName); const now = new Date().toISOString(); this.plan = { version: 1, planFile: path.relative(this.cwd, this.planPath), task, createdAt: now, updatedAt: now, currentPhase: "explore", backtrackCount: 0, phases: {}, }; this.flush(); return this.planPath!; } /** * Rename the plan file when the real task description is known. * Moves the old file to a new path derived from the task slug. * No-op if the plan hasn't been started yet. */ renameWithTask(task: string): string | null { if (!this.plan || !this.planPath) return null; // Build new path const dir = path.dirname(this.planPath); // Preserve the original timestamp prefix (first 19 chars: 2026-04-21T14-32-00) const existingName = path.basename(this.planPath, ".md"); const tsPrefix = existingName.slice(0, 19); // e.g. "2026-04-21T14-32-00" const slug = taskToSlug(task) || "plan"; const newName = `${tsPrefix}-${slug}.md`; const newPath = path.join(dir, newName); if (newPath === this.planPath) return this.planPath; // no change try { fs.renameSync(this.planPath, newPath); this.planPath = newPath; this.plan.task = task; this.plan.planFile = path.relative(this.cwd, newPath); this.plan.updatedAt = new Date().toISOString(); this.flush(); return newPath; } catch { // Rename failed (e.g. cross-device) — just update task in-place this.plan.task = task; this.plan.updatedAt = new Date().toISOString(); this.flush(); return this.planPath!; } } /** Record the LLM output for a completed phase and advance currentPhase */ recordPhase(phase: MpcPhase, content: string, nextPhase: MpcPhase, backtrackCount: number): void { if (!this.plan) return; this.plan.phases[phase] = { phase, completedAt: new Date().toISOString(), content, }; this.plan.currentPhase = nextPhase; this.plan.backtrackCount = backtrackCount; this.plan.updatedAt = new Date().toISOString(); this.flush(); } /** Update just the current phase + backtrack count (e.g. on advance) */ updateState(currentPhase: MpcPhase, backtrackCount: number): void { if (!this.plan) return; this.plan.currentPhase = currentPhase; this.plan.backtrackCount = backtrackCount; this.plan.updatedAt = new Date().toISOString(); this.flush(); } /** Mark the plan as completed (execute done or aborted) */ complete(finalPhase: MpcPhase): void { if (!this.plan) return; this.plan.currentPhase = finalPhase; this.plan.updatedAt = new Date().toISOString(); this.flush(); } /** Current plan file path (null if not started) */ get filePath(): string | null { return this.planPath; } /** Current plan data (null if not started) */ get currentPlan(): MpcPlanFile | null { return this.plan; } /** Load an existing plan file and resume from it */ load(planPath: string): MpcPlanFile | null { try { const raw = fs.readFileSync(planPath, "utf-8"); const plan = deserialize(raw); if (!plan) return null; this.plan = plan; this.planPath = planPath; return plan; } catch { return null; } } /** List all .md plan files in the cwd's .pi/mpc dir, newest first */ static listPlans(cwd: string): Array<{ path: string; mtime: Date; plan: MpcPlanFile | null }> { const dir = path.join(cwd, ".pi", "mpc"); if (!fs.existsSync(dir)) return []; return fs .readdirSync(dir) .filter((f) => f.endsWith(".md")) .map((f) => { const fullPath = path.join(dir, f); const stat = fs.statSync(fullPath); try { const raw = fs.readFileSync(fullPath, "utf-8"); return { path: fullPath, mtime: stat.mtime, plan: deserialize(raw) }; } catch { return { path: fullPath, mtime: stat.mtime, plan: null }; } }) .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()); } /** Find the most recent in-progress plan (not idle/execute-completed) */ static findResumable(cwd: string): { path: string; plan: MpcPlanFile } | null { const plans = MpcPlanPersister.listPlans(cwd); for (const entry of plans) { if (!entry.plan) continue; const ph = entry.plan.currentPhase; // Resumable: any active phase (not idle, not cleanly finished execute) if (ph !== "idle" && ph !== "execute") { return { path: entry.path, plan: entry.plan }; } } return null; } private flush(): void { if (!this.plan || !this.planPath) return; try { fs.writeFileSync(this.planPath, serialize(this.plan), "utf-8"); } catch (e) { // Non-fatal — plan just won't persist console.error("[mpc] Failed to write plan file:", e); } } }