import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs"; import path from "node:path"; import type { ProjectContextInfo } from "./context.ts"; export interface PlanRecord { id: string; status: string; title: string; path: string; updatedAt: string; content: string; } export interface CurrentPlanRef { v: 1; planID: string; planPath: string; status: string; updatedAt: string; } function slugify(input: string): string { return input.replace(/[^a-z0-9._-]+/gi, "-").replace(/-+/g, "-").replace(/^-|-$/g, "").toLowerCase().slice(0, 80); } function today(): string { return new Date().toISOString().slice(0, 10); } function stripMd(id: string): string { return id.replace(/\.md$/i, ""); } function ensurePlanDirs(info: ProjectContextInfo) { const active = path.join(info.plans, "active"); const archive = path.join(info.plans, "archive"); mkdirSync(active, { recursive: true }); mkdirSync(archive, { recursive: true }); return { active, archive }; } function planFilePath(info: ProjectContextInfo, status: string, id: string) { const dirs = ensurePlanDirs(info); return path.join(status === "archive" ? dirs.archive : dirs.active, `${stripMd(id)}.md`); } function uniquePlanID(info: ProjectContextInfo, title: string) { const base = `${today()}-${slugify(title) || "plan"}`; let id = base; let index = 2; while (existsSync(planFilePath(info, "active", id)) || existsSync(planFilePath(info, "archive", id))) id = `${base}-${index++}`; return id; } function titleFromContent(content: string, fallback: string) { return content.match(/^#\s+(?:Plan:\s*)?(.+)$/im)?.[1]?.trim() || fallback; } function readPlanAt(file: string, status: string): PlanRecord { const content = readFileSync(file, "utf8"); const stats = statSync(file); const id = stripMd(path.basename(file)); return { id, status, title: titleFromContent(content, id), path: file, updatedAt: stats.mtime.toISOString(), content }; } function listDirPlans(dir: string, status: string) { if (!existsSync(dir)) return []; return readdirSync(dir).filter((name) => name.endsWith(".md")).map((name) => readPlanAt(path.join(dir, name), status)).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); } function matchesCurrentPath(plan: PlanRecord, ref: CurrentPlanRef | undefined) { return Boolean(ref && ((typeof ref.planPath === "string" && plan.path === ref.planPath) || (typeof ref.planID === "string" && plan.id === stripMd(ref.planID)))); } export function listPlans(info: ProjectContextInfo, status = "active"): PlanRecord[] { const dirs = ensurePlanDirs(info); if (status === "all") return [...listDirPlans(dirs.active, "active"), ...listDirPlans(dirs.archive, "archive")]; if (status === "archive") return listDirPlans(dirs.archive, "archive"); return listDirPlans(dirs.active, "active"); } export function currentPlanRef(plan: PlanRecord): CurrentPlanRef { return { v: 1, planID: plan.id, planPath: plan.path, status: plan.status || "active", updatedAt: new Date().toISOString() }; } export function resolveCurrentPlan(info: ProjectContextInfo, status = "active", ref?: CurrentPlanRef) { if (!ref) return undefined; return listPlans(info, status === "all" ? "all" : status).find((plan) => matchesCurrentPath(plan, ref)); } export function resolvePlan(info: ProjectContextInfo, id?: string, status = "active"): PlanRecord { const target = id ? stripMd(id.trim()) : ""; const candidates = listPlans(info, status); if (target) { const found = candidates.find((plan) => plan.id === target || plan.path === id || plan.title === id); if (!found) throw new Error(`No ${status} plan found for: ${id}`); return found; } const active = status === "archive" ? candidates : listPlans(info, "active"); if (active.length === 1) return active[0]; if (!active.length) throw new Error("No active plans found."); throw new Error(`Multiple active plans found: ${active.map((plan) => plan.id).join(", ")}`); } export function setCurrentPlan(info: ProjectContextInfo, input: string) { return resolvePlan(info, input, "all"); } export function createPlan(info: ProjectContextInfo, input: { title: string; body: string; task?: string }) { const title = input.title.trim(); const id = uniquePlanID(info, title); const file = planFilePath(info, "active", id); const created = new Date().toISOString(); const body = input.body.trim(); const content = [`# Plan: ${title}`, "", "Status: active", `Plan ID: ${id}`, `Created: ${created}`, `Updated: ${created}`, `Scope: ${info.scope}`, `Context: ${info.id}`, `Root: ${info.root}`, "", "Plan rule: use this file for intended route. Use session-local todos/checklists for live execution progress. Do not edit this plan just to mark checklist progress.", "", body.startsWith("#") ? body.replace(/^#\s+.+\n+/, "") : body, ""].join("\n"); writeFileSync(file, content, "utf8"); return readPlanAt(file, "active"); } export function updatePlan(info: ProjectContextInfo, input: { id?: string; title?: string; body: string; reason?: string }) { const plan = resolvePlan(info, input.id, "active"); const updated = new Date().toISOString(); const body = input.body.trim(); const created = plan.content.match(/^Created:\s*(.+)$/im)?.[1]; const content = [`# Plan: ${input.title?.trim() || plan.title}`, "", "Status: active", `Plan ID: ${plan.id}`, created ? `Created: ${created}` : undefined, `Updated: ${updated}`, `Scope: ${info.scope}`, `Context: ${info.id}`, `Root: ${info.root}`, "", "Plan rule: use this file for intended route. Use session-local todos/checklists for live execution progress. Do not edit this plan just to mark checklist progress.", "", body.startsWith("#") ? body.replace(/^#\s+.+\n+/, "") : body, ""].filter((line) => line !== undefined).join("\n"); writeFileSync(plan.path, content, "utf8"); return readPlanAt(plan.path, "active"); } export function archivePlan(info: ProjectContextInfo, input: { id?: string; result?: string } = {}) { const plan = resolvePlan(info, input.id, "active"); const archived = new Date().toISOString(); const archivePath = planFilePath(info, "archive", plan.id); const content = plan.content.replace(/^Status:\s*active$/im, "Status: archived").replace(/^Updated:\s*.+$/im, `Updated: ${archived}`); writeFileSync(plan.path, `${content.trimEnd()}\n\n## Completion notes\n\nArchived: ${archived}\n\n${input.result?.trim() || ""}\n`, "utf8"); renameSync(plan.path, archivePath); return readPlanAt(archivePath, "archive"); } export function formatPlanList(info: ProjectContextInfo, plans: PlanRecord[], ref?: CurrentPlanRef) { if (!plans.length) return "No plans found."; const current = resolveCurrentPlan(info, "all", ref); return plans.map((plan) => `${plan.status}: ${plan.title} (${plan.id})${current?.id === plan.id ? " [current]" : ""}\n${plan.path}`).join("\n\n"); } export function planWorkflowContext(info: ProjectContextInfo, ref?: CurrentPlanRef) { const active = listPlans(info, "active"); const current = resolveCurrentPlan(info, "all", ref); return ["Pi scoped plan workflow is available.", `Plan scope: ${info.scope}`, `Plan storage: ${info.plans}`, "Plan = intended route; live todos/checklists stay in-session.", "When working from a plan: read it first; update a plan only for route/scope changes, not checklist progress.", "Before pi_plan_create: use pi_plan_approve for final plan approval. Do not use the generic question tool for final plan approval gates; pi_plan_approve owns the exact approval options.", `Current plan: ${current ? `${current.title} (${current.id})\n ${current.path}` : "none"}`, "Active plans:", active.length ? active.map((plan) => `- ${plan.title} (${plan.id})\n ${plan.path}`).join("\n") : "- none"].join("\n"); }