/** * Plan markdown contract: ordered sections per goal-harness design. */ export type GoalKind = "code-change" | "analysis" | "research"; export interface VerificationStep { text: string; tag: "gating" | "evidence"; } export interface PlanSubgoal { title: string; criteria?: string[]; } export interface GoalPlan { headline: string; goalKind: GoalKind; acceptanceCriteria: string[]; verificationPlan: VerificationStep[]; nonGoals: string[]; assumedScope: string[]; subgoals: PlanSubgoal[]; implementationApproach: string[]; taskChecklist: Array<{ text: string; done: boolean }>; risks: string[]; deviations: string[]; /** Optional free-form notes (not a judged section). */ objective?: string; } const SECTION_ORDER = [ "Goal kind", "Acceptance criteria", "Verification plan", "Non-goals", "Assumed scope", "Subgoals", "Implementation approach", "Task checklist", "Risks / Contradictions", "Deviations", ] as const; function escapeMdLine(s: string): string { return s.replace(/\r\n/g, "\n").trim(); } /** Render a full plan.md document. */ export function renderPlan(plan: GoalPlan): string { const lines: string[] = []; lines.push(`# Plan: ${escapeMdLine(plan.headline)}`); lines.push(""); lines.push("## Goal kind"); lines.push(plan.goalKind); lines.push(""); lines.push("## Acceptance criteria"); for (const c of plan.acceptanceCriteria) { lines.push(`- [ ] ${escapeMdLine(c)}`); } if (plan.acceptanceCriteria.length === 0) lines.push("- [ ] (none)"); lines.push(""); lines.push("## Verification plan"); for (const step of plan.verificationPlan) { lines.push(`- (${step.tag}) ${escapeMdLine(step.text)}`); } if (plan.verificationPlan.length === 0) { lines.push("- (gating) Confirm acceptance criteria are met"); } lines.push(""); lines.push("## Non-goals"); for (const n of plan.nonGoals) lines.push(`- ${escapeMdLine(n)}`); if (plan.nonGoals.length === 0) lines.push("- (none listed)"); lines.push(""); lines.push("## Assumed scope"); for (const a of plan.assumedScope) lines.push(`- ${escapeMdLine(a)}`); if (plan.assumedScope.length === 0) lines.push("- (none listed)"); lines.push(""); lines.push("## Subgoals"); if (plan.subgoals.length === 0) { lines.push("- (none)"); } else { for (const sg of plan.subgoals) { lines.push(`- ${escapeMdLine(sg.title)}`); for (const c of sg.criteria ?? []) { lines.push(` - ${escapeMdLine(c)}`); } } } lines.push(""); lines.push("## Implementation approach"); for (const step of plan.implementationApproach) { lines.push(`- ${escapeMdLine(step)}`); } if (plan.implementationApproach.length === 0) { lines.push("- (to be refined)"); } lines.push(""); lines.push("## Task checklist"); for (const t of plan.taskChecklist) { lines.push(`- [${t.done ? "x" : " "}] ${escapeMdLine(t.text)}`); } if (plan.taskChecklist.length === 0) { lines.push("- [ ] (none)"); } lines.push(""); lines.push("## Risks / Contradictions"); for (const r of plan.risks) lines.push(`- ${escapeMdLine(r)}`); if (plan.risks.length === 0) lines.push("- (none listed)"); lines.push(""); lines.push("## Deviations"); if (plan.deviations.length === 0) { lines.push("_None yet._"); } else { for (const d of plan.deviations) lines.push(`- ${escapeMdLine(d)}`); } lines.push(""); return lines.join("\n"); } function sectionBody(md: string, heading: string): string | null { const re = new RegExp( `^##\\s+${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "im", ); const match = re.exec(md); if (!match || match.index === undefined) return null; const start = match.index + match[0].length; const rest = md.slice(start); const next = /^##\s+/im.exec(rest); const body = next ? rest.slice(0, next.index) : rest; return body.trim(); } function bulletLines(body: string): string[] { return body .split("\n") .map((l) => l.trim()) .filter((l) => l.startsWith("- ")) .map((l) => l.slice(2).trim()) .filter((l) => l.length > 0 && l !== "(none)" && l !== "(none listed)"); } function parseChecklistItem(line: string): { text: string; done: boolean } | null { const m = line.match(/^\[([ xX])\]\s*(.*)$/); if (!m) return null; return { done: m[1]!.toLowerCase() === "x", text: m[2]!.trim() }; } function parseVerification(line: string): VerificationStep { const m = line.match(/^\((gating|evidence)\)\s*(.*)$/i); if (m) { return { tag: m[1]!.toLowerCase() as "gating" | "evidence", text: m[2]!.trim(), }; } return { tag: "gating", text: line }; } /** Best-effort parse of plan markdown into a partial GoalPlan. */ export function parsePlan(md: string): Partial { const out: Partial = {}; const text = md.replace(/\r\n/g, "\n"); const title = text.match(/^#\s+Plan:\s*(.+)$/m); if (title) out.headline = title[1]!.trim(); const kindBody = sectionBody(text, "Goal kind"); if (kindBody) { const k = kindBody.split("\n")[0]?.trim().toLowerCase(); if (k === "code-change" || k === "analysis" || k === "research") { out.goalKind = k; } } const acBody = sectionBody(text, "Acceptance criteria"); if (acBody) { out.acceptanceCriteria = bulletLines(acBody) .map((l) => parseChecklistItem(l)?.text ?? l.replace(/^\[[ xX]\]\s*/, "")) .filter((t) => t && t !== "(none)"); } const vpBody = sectionBody(text, "Verification plan"); if (vpBody) { out.verificationPlan = bulletLines(vpBody).map(parseVerification); } const ngBody = sectionBody(text, "Non-goals"); if (ngBody) out.nonGoals = bulletLines(ngBody); const asBody = sectionBody(text, "Assumed scope"); if (asBody) out.assumedScope = bulletLines(asBody); const sgBody = sectionBody(text, "Subgoals"); if (sgBody) { out.subgoals = bulletLines(sgBody) .filter((l) => !l.startsWith(" ")) .map((titleLine) => ({ title: titleLine })); } const iaBody = sectionBody(text, "Implementation approach"); if (iaBody) { out.implementationApproach = bulletLines(iaBody).filter( (l) => l !== "(to be refined)", ); } const tcBody = sectionBody(text, "Task checklist"); if (tcBody) { out.taskChecklist = bulletLines(tcBody) .map((l) => parseChecklistItem(l) ?? { text: l, done: false }) .filter((t) => t.text && t.text !== "(none)"); } const riskBody = sectionBody(text, "Risks / Contradictions") ?? sectionBody(text, "Risks"); if (riskBody) out.risks = bulletLines(riskBody); const devBody = sectionBody(text, "Deviations"); if (devBody) { const cleaned = devBody.replace(/^_None yet\._$/im, "").trim(); out.deviations = cleaned ? bulletLines(cleaned) : []; } return out; } export { SECTION_ORDER };