/** * Materialize SQLite records to project-root markdown files (strategy A). */ import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import type { CompletedWorkItem, Decision, Handoff, ProjectState, SubgoalItem, } from "./types.ts"; export const STATE_FILENAME = "STATE.md"; export const DECISIONS_FILENAME = "DECISIONS.md"; export const HANDOFF_FILENAME = "HANDOFF.md"; function formatDate(ms: number): string { if (!ms) return "never"; const d = new Date(ms); const y = d.getFullYear(); const m = String(d.getMonth() + 1).padStart(2, "0"); const day = String(d.getDate()).padStart(2, "0"); return `${y}-${m}-${day}`; } function subgoalLines(items: SubgoalItem[], empty = "None."): string { if (!items.length) return `- ${empty}`; return items .map((item) => (item.date ? `- [${item.date}] ${item.text}` : `- ${item.text}`)) .join("\n"); } function completedWorkLines(items: CompletedWorkItem[], empty = "None."): string { if (!items.length) return `- ${empty}`; return items .map((item) => { const head = item.date ? `- [${item.date}] ${item.text}` : `- ${item.text}`; const why = item.why.trim() ? `\n - why: ${item.why.trim()}` : "\n - why: (missing)"; return head + why; }) .join("\n"); } export function renderStateMarkdown(state: ProjectState): string { const updated = formatDate(state.updatedAt); const howToRun = state.howToRun.trim() ? state.howToRun.trim() : "[add shortest build / test / run commands]"; const mainGoal = state.mainGoal.trim() || "[main project goal]"; const current = state.currentSubgoal.trim() || "[none / idle]"; const nextPlan = state.nextPlan.trim() || "[none — work complete or not planned]"; const nextWhy = state.nextPlan.trim() ? state.nextPlanWhy.trim() || "[why this serves main/current goal]" : "[n/a]"; return `# Project state Updated: ${updated} ## Main goal ${mainGoal} ## Current subgoal ${current} ## Completed work ${completedWorkLines(state.completedWork)} ## Next plan ${nextPlan} ### Why this next plan ${nextWhy} ## Completed subgoals ${subgoalLines(state.completedSubgoals)} ## Open subgoals ${subgoalLines(state.openSubgoals)} ## How to run \`\`\` ${howToRun} \`\`\` `; } export function renderDecisionsMarkdown(decisions: Decision[]): string { const body = decisions.length === 0 ? "\n_No decisions recorded yet._\n" : decisions .map((d) => { const lines = [ `## ${d.decidedOn} ${d.title}`, "", `- Decision: ${d.decision}`, ]; if (d.alternatives) lines.push(`- Alternatives: ${d.alternatives}`); if (d.reason) lines.push(`- Reason: ${d.reason}`); if (d.impact) lines.push(`- Impact: ${d.impact}`); return lines.join("\n"); }) .join("\n\n"); return `# Decisions ${body} `; } export function renderHandoffMarkdown(handoff: Handoff): string { const files = handoff.files.length === 0 ? "- (none)" : handoff.files.map((f) => `- ${f}`).join("\n"); return `# Handoff Updated: ${formatDate(handoff.updatedAt)} Status: ${handoff.status} Id: ${handoff.id} ## Goal ${handoff.goal.trim() || "[goal]"} ## Context ${handoff.context.trim() || "[context]"} ## Files ${files} ## Next task ${handoff.nextTask.trim() || "[next task]"} `; } function writeFileAtomic(path: string, content: string): void { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, content, "utf8"); } export interface ExportResult { statePath: string; decisionsPath: string; handoffPath: string | null; handoffRemoved: boolean; } /** * Write STATE.md + DECISIONS.md always; HANDOFF.md only when an open handoff exists. */ export function exportProjectMarkdown( projectRoot: string, args: { state: ProjectState; decisions: Decision[]; openHandoff: Handoff | null; }, ): ExportResult { const statePath = join(projectRoot, STATE_FILENAME); const decisionsPath = join(projectRoot, DECISIONS_FILENAME); const handoffPath = join(projectRoot, HANDOFF_FILENAME); writeFileAtomic(statePath, renderStateMarkdown(args.state)); writeFileAtomic(decisionsPath, renderDecisionsMarkdown(args.decisions)); let handoffRemoved = false; if (args.openHandoff && args.openHandoff.status === "open") { writeFileAtomic(handoffPath, renderHandoffMarkdown(args.openHandoff)); return { statePath, decisionsPath, handoffPath, handoffRemoved: false }; } if (existsSync(handoffPath)) { unlinkSync(handoffPath); handoffRemoved = true; } return { statePath, decisionsPath, handoffPath: null, handoffRemoved }; }