singularity-forge/src/resources/extensions/sf/markdown-renderer.ts
Mikael Hugo d73a73d7f3 chore: node 24 native APIs, import.meta.dirname, parsers rename, dep updates
- Replace fileURLToPath(import.meta.url) with import.meta.dirname across
  scripts and extensions
- Rename parsers-legacy.ts → parsers.ts
- Remove deleted plan/spec docs (cicd-pipeline)
- Update package.json engines and deps across workspace packages
- Update web/package-lock.json

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-05-02 06:18:25 +02:00

1389 lines
38 KiB
TypeScript

// SF Markdown Renderer — DB → Markdown file generation
//
// Transforms DB state into correct markdown files on disk.
// Each render function reads from DB (with disk fallback),
// patches content to match DB status, writes atomically to disk,
// stores updated content in the artifacts table, and invalidates caches.
//
// Critical invariant: rendered markdown must round-trip through
// parseRoadmap(), parsePlan(), parseSummary() in files.ts.
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { join, relative } from "node:path";
import { clearParseCache, saveFile } from "./files.js";
import {
buildSliceFileName,
buildTaskFileName,
clearPathCache,
resolveMilestoneFile,
resolveSliceFile,
resolveSlicePath,
resolveTasksDir,
sfRoot,
} from "./paths.js";
import type { MilestoneRow, SliceRow, TaskRow } from "./sf-db.js";
import {
getAllMilestones,
getArtifact,
getGateResults,
getMilestone,
getMilestoneSlices,
getSlice,
getSliceTasks,
getTask,
insertArtifact,
} from "./sf-db.js";
import { invalidateStateCache } from "./state.js";
import { isClosedStatus } from "./status-guards.js";
import type { GateRow } from "./types.js";
import { logWarning } from "./workflow-logger.js";
import { parseRoadmap, parsePlan } from "./parsers.js";
const parsers = { parseRoadmap, parsePlan };
// ─── Helpers ──────────────────────────────────────────────────────────────
/**
* Convert an absolute file path to a .sf-relative artifact path.
* E.g. "/project/.sf/milestones/M001/M001-ROADMAP.md" → "milestones/M001/M001-ROADMAP.md"
*/
function toArtifactPath(absPath: string, basePath: string): string {
const root = sfRoot(basePath);
const rel = relative(root, absPath);
// Normalize to forward slashes for consistent DB keys
return rel.replace(/\\/g, "/");
}
/**
* Invalidate all caches after a disk write.
*/
function invalidateCaches(): void {
invalidateStateCache();
clearPathCache();
clearParseCache();
}
/**
* Load artifact content from DB first, falling back to reading from disk.
* On disk fallback, stores the content in the artifacts table for future use.
* Returns null if content is unavailable from both sources.
*/
function loadArtifactContent(
artifactPath: string,
absPath: string | null,
opts: {
artifact_type: string;
milestone_id: string;
slice_id?: string;
task_id?: string;
},
): string | null {
// Try DB first
const artifact = getArtifact(artifactPath);
if (artifact && artifact.full_content) {
return artifact.full_content;
}
// Fall back to disk
if (!absPath) {
process.stderr.write(
`markdown-renderer: artifact not found in DB or on disk: ${artifactPath}\n`,
);
return null;
}
let content: string;
try {
content = readFileSync(absPath, "utf-8");
} catch {
logWarning("renderer", `cannot read file from disk: ${absPath}`);
return null;
}
// Store in DB for future use (graceful degradation path)
try {
insertArtifact({
path: artifactPath,
artifact_type: opts.artifact_type,
milestone_id: opts.milestone_id,
slice_id: opts.slice_id ?? null,
task_id: opts.task_id ?? null,
full_content: content,
});
} catch {
// Non-fatal: we have the content, DB storage is best-effort
logWarning(
"renderer",
`failed to store disk fallback in DB: ${artifactPath}`,
);
}
return content;
}
/**
* Write rendered content to disk and update the artifacts table.
*/
async function writeAndStore(
absPath: string,
artifactPath: string,
content: string,
opts: {
artifact_type: string;
milestone_id: string;
slice_id?: string;
task_id?: string;
},
): Promise<void> {
await saveFile(absPath, content);
try {
insertArtifact({
path: artifactPath,
artifact_type: opts.artifact_type,
milestone_id: opts.milestone_id,
slice_id: opts.slice_id ?? null,
task_id: opts.task_id ?? null,
full_content: content,
});
} catch {
// Non-fatal: file is on disk, DB is best-effort
logWarning("renderer", `failed to update artifact in DB: ${artifactPath}`);
}
invalidateCaches();
}
function renderRoadmapMarkdown(
milestone: MilestoneRow,
slices: SliceRow[],
): string {
const lines: string[] = [];
lines.push(`# ${milestone.id}: ${milestone.title || milestone.id}`);
lines.push("");
lines.push(`**Vision:** ${milestone.vision}`);
lines.push("");
if (milestone.vision_meeting) {
lines.push("## Vision Alignment Meeting");
lines.push("");
lines.push("### Trigger");
lines.push("");
lines.push(milestone.vision_meeting.trigger.trim());
lines.push("");
lines.push("### Product Manager");
lines.push("");
lines.push(milestone.vision_meeting.pm.trim());
lines.push("");
lines.push("### User Advocate");
lines.push("");
lines.push(milestone.vision_meeting.userAdvocate.trim());
lines.push("");
lines.push("### Customer Panel");
lines.push("");
lines.push(milestone.vision_meeting.customerPanel.trim());
lines.push("");
lines.push("### Business");
lines.push("");
lines.push(milestone.vision_meeting.business.trim());
lines.push("");
lines.push("### Researcher");
lines.push("");
lines.push(milestone.vision_meeting.researcher.trim());
lines.push("");
lines.push("### Delivery Lead");
lines.push("");
lines.push(milestone.vision_meeting.deliveryLead.trim());
lines.push("");
lines.push("### Partner");
lines.push("");
lines.push(milestone.vision_meeting.partner.trim());
lines.push("");
lines.push("### Combatant");
lines.push("");
lines.push(milestone.vision_meeting.combatant.trim());
lines.push("");
lines.push("### Architect");
lines.push("");
lines.push(milestone.vision_meeting.architect.trim());
lines.push("");
lines.push("### Moderator");
lines.push("");
lines.push(milestone.vision_meeting.moderator.trim());
lines.push("");
lines.push("### Weighted Synthesis");
lines.push("");
lines.push(milestone.vision_meeting.weightedSynthesis.trim());
lines.push("");
lines.push("### Confidence By Area");
lines.push("");
lines.push(milestone.vision_meeting.confidenceByArea.trim());
lines.push("");
lines.push("### Recommended Route");
lines.push("");
lines.push(milestone.vision_meeting.recommendedRoute);
lines.push("");
}
if (milestone.success_criteria.length > 0) {
lines.push("## Success Criteria");
lines.push("");
for (const criterion of milestone.success_criteria) {
lines.push(`- ${criterion}`);
}
lines.push("");
}
lines.push("## Slices");
lines.push("");
for (const slice of slices) {
const done = slice.status === "complete" ? "x" : " ";
const depends = `[${(slice.depends ?? []).join(",")}]`;
lines.push(
`- [${done}] **${slice.id}: ${slice.title}** \`risk:${slice.risk}\` \`depends:${depends}\``,
);
lines.push(` > After this: ${slice.demo}`);
lines.push("");
}
if (milestone.boundary_map_markdown.trim()) {
lines.push("## Boundary Map");
lines.push("");
lines.push(milestone.boundary_map_markdown.trim());
lines.push("");
}
return `${lines.join("\n").trimEnd()}\n`;
}
function renderTaskPlanMarkdown(
task: TaskRow,
taskGates: GateRow[] = [],
): string {
const estimatedSteps = Math.max(
1,
task.description.trim().split(/\n+/).filter(Boolean).length || 1,
);
const estimatedFiles =
task.files.length > 0
? task.files.length
: task.expected_output.length > 0
? task.expected_output.length
: task.inputs.length > 0
? task.inputs.length
: 1;
const lines: string[] = [];
lines.push("---");
lines.push(`estimated_steps: ${estimatedSteps}`);
lines.push(`estimated_files: ${estimatedFiles}`);
lines.push("skills_used: []");
lines.push("---");
lines.push("");
lines.push(`# ${task.id}: ${task.title || task.id}`);
lines.push("");
if (task.description.trim()) {
lines.push(task.description.trim());
lines.push("");
}
lines.push("## Inputs");
lines.push("");
if (task.inputs.length > 0) {
for (const input of task.inputs) {
lines.push(`- \`${input}\``);
}
} else {
lines.push("- None specified.");
}
lines.push("");
lines.push("## Expected Output");
lines.push("");
if (task.expected_output.length > 0) {
for (const output of task.expected_output) {
lines.push(`- \`${output}\``);
}
} else if (task.files.length > 0) {
for (const file of task.files) {
lines.push(`- \`${file}\``);
}
} else {
lines.push(
"- Update the implementation and proof artifacts needed for this task.",
);
}
lines.push("");
lines.push("## Verification");
lines.push("");
lines.push(
task.verify.trim() ||
"- Verify the task outcome with the slice-level checks.",
);
lines.push("");
if (task.observability_impact.trim()) {
lines.push("## Observability Impact");
lines.push("");
lines.push(task.observability_impact.trim());
lines.push("");
}
// ── Quality Gate Sections (Q5/Q6/Q7) ──────────────────────────────────
const gateLabels: Record<string, string> = {
Q5: "Failure Modes",
Q6: "Load Profile",
Q7: "Negative Tests",
};
for (const [gid, label] of Object.entries(gateLabels)) {
const gate = taskGates.find(
(g) => g.gate_id === gid && g.status === "complete",
);
if (gate && gate.verdict !== "omitted") {
lines.push(`## ${label}`);
lines.push("");
lines.push(
gate.findings.trim() ||
`- **Verdict:** ${gate.verdict}\n- **Rationale:** ${gate.rationale}`,
);
lines.push("");
}
}
return `${lines.join("\n").trimEnd()}\n`;
}
function renderSlicePlanMarkdown(
slice: SliceRow,
tasks: TaskRow[],
gates: GateRow[] = [],
): string {
const lines: string[] = [];
lines.push(`# ${slice.id}: ${slice.title || slice.id}`);
lines.push("");
lines.push(`**Goal:** ${slice.goal}`);
lines.push(`**Demo:** ${slice.demo}`);
lines.push("");
lines.push("## Must-Haves");
lines.push("");
if (slice.success_criteria.trim()) {
for (const line of slice.success_criteria
.split(/\n+/)
.map((entry) => entry.trim())
.filter(Boolean)) {
lines.push(line.startsWith("-") ? line : `- ${line}`);
}
} else {
lines.push("- Complete the planned slice outcomes.");
}
lines.push("");
// ── Quality Gate Sections (Q3/Q4) ────────────────────────────────────
const q3 = gates.find((g) => g.gate_id === "Q3" && g.status === "complete");
if (q3 && q3.verdict !== "omitted") {
lines.push("## Threat Surface");
lines.push("");
lines.push(
q3.findings.trim() ||
`- **Verdict:** ${q3.verdict}\n- **Rationale:** ${q3.rationale}`,
);
lines.push("");
}
const q4 = gates.find((g) => g.gate_id === "Q4" && g.status === "complete");
if (q4 && q4.verdict !== "omitted") {
lines.push("## Requirement Impact");
lines.push("");
lines.push(
q4.findings.trim() ||
`- **Verdict:** ${q4.verdict}\n- **Rationale:** ${q4.rationale}`,
);
lines.push("");
}
lines.push("## Adversarial Review");
lines.push("");
lines.push("### Partner Review");
lines.push("");
lines.push(slice.adversarial_partner?.trim() || "Missing partner review.");
lines.push("");
lines.push("### Combatant Review");
lines.push("");
lines.push(
slice.adversarial_combatant?.trim() || "Missing combatant review.",
);
lines.push("");
lines.push("### Architect Review");
lines.push("");
lines.push(
slice.adversarial_architect?.trim() || "Missing architect review.",
);
lines.push("");
if (slice.planning_meeting) {
lines.push("## Planning Meeting");
lines.push("");
lines.push("### Trigger");
lines.push("");
lines.push(slice.planning_meeting.trigger.trim());
lines.push("");
lines.push("### Product Manager");
lines.push("");
lines.push(slice.planning_meeting.pm.trim());
lines.push("");
if (slice.planning_meeting.userAdvocate?.trim()) {
lines.push("### User Advocate");
lines.push("");
lines.push(slice.planning_meeting.userAdvocate.trim());
lines.push("");
}
if (slice.planning_meeting.customerPanel?.trim()) {
lines.push("### Customer Panel");
lines.push("");
lines.push(slice.planning_meeting.customerPanel.trim());
lines.push("");
}
if (slice.planning_meeting.business?.trim()) {
lines.push("### Business");
lines.push("");
lines.push(slice.planning_meeting.business.trim());
lines.push("");
}
lines.push("### Researcher");
lines.push("");
lines.push(slice.planning_meeting.researcher.trim());
lines.push("");
if (slice.planning_meeting.deliveryLead?.trim()) {
lines.push("### Delivery Lead");
lines.push("");
lines.push(slice.planning_meeting.deliveryLead.trim());
lines.push("");
}
lines.push("### Partner");
lines.push("");
lines.push(slice.planning_meeting.partner.trim());
lines.push("");
lines.push("### Combatant");
lines.push("");
lines.push(slice.planning_meeting.combatant.trim());
lines.push("");
lines.push("### Architect");
lines.push("");
lines.push(slice.planning_meeting.architect.trim());
lines.push("");
lines.push("### Moderator");
lines.push("");
lines.push(slice.planning_meeting.moderator.trim());
lines.push("");
lines.push("### Recommended Route");
lines.push("");
lines.push(slice.planning_meeting.recommendedRoute);
lines.push("");
lines.push("### Confidence");
lines.push("");
lines.push(slice.planning_meeting.confidenceSummary.trim());
lines.push("");
}
if (slice.proof_level.trim()) {
lines.push("## Proof Level");
lines.push("");
lines.push(`- This slice proves: ${slice.proof_level.trim()}`);
lines.push("");
}
if (slice.integration_closure.trim()) {
lines.push("## Integration Closure");
lines.push("");
lines.push(slice.integration_closure.trim());
lines.push("");
}
lines.push("## Verification");
lines.push("");
if (slice.observability_impact.trim()) {
const verificationLines = slice.observability_impact
.split(/\n+/)
.map((entry) => entry.trim())
.filter(Boolean);
for (const line of verificationLines) {
lines.push(line.startsWith("-") ? line : `- ${line}`);
}
} else {
lines.push("- Run the task and slice verification checks for this slice.");
}
lines.push("");
lines.push("## Tasks");
lines.push("");
for (const task of tasks) {
const done = isClosedStatus(task.status) ? "x" : " ";
const estimate = task.estimate.trim()
? ` \`est:${task.estimate.trim()}\``
: "";
lines.push(
`- [${done}] **${task.id}: ${task.title || task.id}**${estimate}`,
);
if (task.description.trim()) {
lines.push(` ${task.description.trim()}`);
}
if (task.files.length > 0) {
lines.push(
` - Files: ${task.files.map((file) => `\`${file}\``).join(", ")}`,
);
}
if (task.verify.trim()) {
lines.push(` - Verify: ${task.verify.trim()}`);
}
lines.push("");
}
const filesLikelyTouched = Array.from(
new Set(tasks.flatMap((task) => task.files)),
);
lines.push("## Files Likely Touched");
lines.push("");
if (filesLikelyTouched.length === 0) {
lines.push("- (none)");
} else {
for (const file of filesLikelyTouched) {
lines.push(`- ${file}`);
}
}
lines.push("");
return `${lines.join("\n").trimEnd()}\n`;
}
export async function renderPlanFromDb(
basePath: string,
milestoneId: string,
sliceId: string,
): Promise<{ planPath: string; taskPlanPaths: string[]; content: string }> {
const slice = getSlice(milestoneId, sliceId);
if (!slice) {
throw new Error(`slice ${milestoneId}/${sliceId} not found`);
}
const tasks = getSliceTasks(milestoneId, sliceId);
if (tasks.length === 0) {
throw new Error(`no tasks found for ${milestoneId}/${sliceId}`);
}
const slicePath =
resolveSlicePath(basePath, milestoneId, sliceId) ??
join(sfRoot(basePath), "milestones", milestoneId, "slices", sliceId);
const absPath =
resolveSliceFile(basePath, milestoneId, sliceId, "PLAN") ??
join(slicePath, `${sliceId}-PLAN.md`);
const artifactPath = toArtifactPath(absPath, basePath);
const sliceGates = getGateResults(milestoneId, sliceId, "slice");
const content = renderSlicePlanMarkdown(slice, tasks, sliceGates);
await writeAndStore(absPath, artifactPath, content, {
artifact_type: "PLAN",
milestone_id: milestoneId,
slice_id: sliceId,
});
const taskPlanPaths: string[] = [];
for (const task of tasks) {
const rendered = await renderTaskPlanFromDb(
basePath,
milestoneId,
sliceId,
task.id,
);
taskPlanPaths.push(rendered.taskPlanPath);
}
return { planPath: absPath, taskPlanPaths, content };
}
export async function renderTaskPlanFromDb(
basePath: string,
milestoneId: string,
sliceId: string,
taskId: string,
): Promise<{ taskPlanPath: string; content: string }> {
const task = getTask(milestoneId, sliceId, taskId);
if (!task) {
throw new Error(`task ${milestoneId}/${sliceId}/${taskId} not found`);
}
const tasksDir =
resolveTasksDir(basePath, milestoneId, sliceId) ??
join(
sfRoot(basePath),
"milestones",
milestoneId,
"slices",
sliceId,
"tasks",
);
mkdirSync(tasksDir, { recursive: true });
const absPath = join(tasksDir, buildTaskFileName(taskId, "PLAN"));
const artifactPath = toArtifactPath(absPath, basePath);
const taskGates = getGateResults(milestoneId, sliceId, "task").filter(
(g) => g.task_id === taskId,
);
const content = task.full_plan_md.trim()
? task.full_plan_md
: renderTaskPlanMarkdown(task, taskGates);
await writeAndStore(absPath, artifactPath, content, {
artifact_type: "PLAN",
milestone_id: milestoneId,
slice_id: sliceId,
task_id: taskId,
});
return { taskPlanPath: absPath, content };
}
export async function renderRoadmapFromDb(
basePath: string,
milestoneId: string,
): Promise<{ roadmapPath: string; content: string }> {
const milestone = getMilestone(milestoneId);
if (!milestone) {
throw new Error(`milestone ${milestoneId} not found`);
}
const slices = getMilestoneSlices(milestoneId);
const absPath =
resolveMilestoneFile(basePath, milestoneId, "ROADMAP") ??
join(
sfRoot(basePath),
"milestones",
milestoneId,
`${milestoneId}-ROADMAP.md`,
);
const artifactPath = toArtifactPath(absPath, basePath);
const content = renderRoadmapMarkdown(milestone, slices);
await writeAndStore(absPath, artifactPath, content, {
artifact_type: "ROADMAP",
milestone_id: milestoneId,
});
return { roadmapPath: absPath, content };
}
// ─── Roadmap Checkbox Rendering ───────────────────────────────────────────
/**
* Render roadmap checkbox states from DB.
*
* For each slice in the milestone, sets [x] if status === 'complete',
* [ ] otherwise. Handles bidirectional updates (can uncheck previously
* checked slices if DB says pending).
*
* @returns true if the roadmap was written, false on skip/error
*/
export async function renderRoadmapCheckboxes(
basePath: string,
milestoneId: string,
): Promise<boolean> {
const slices = getMilestoneSlices(milestoneId);
if (slices.length === 0) {
process.stderr.write(
`markdown-renderer: no slices found for milestone ${milestoneId}\n`,
);
return false;
}
const absPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP");
const artifactPath = absPath ? toArtifactPath(absPath, basePath) : null;
// Load content from DB (with disk fallback)
let content: string | null = null;
if (artifactPath) {
content = loadArtifactContent(artifactPath, absPath, {
artifact_type: "ROADMAP",
milestone_id: milestoneId,
});
}
if (!content) {
process.stderr.write(
`markdown-renderer: no roadmap content available for ${milestoneId}\n`,
);
return false;
}
// Apply checkbox patches for each slice
let updated = content;
for (const slice of slices) {
const isDone = slice.status === "complete";
const sid = slice.id;
if (isDone) {
// Set [x]: replace "- [ ] **S01:" with "- [x] **S01:"
updated = updated.replace(
new RegExp(`^(\\s*-\\s+)\\[ \\]\\s+\\*\\*${sid}:`, "m"),
`$1[x] **${sid}:`,
);
} else {
// Set [ ]: replace "- [x] **S01:" with "- [ ] **S01:"
updated = updated.replace(
new RegExp(`^(\\s*-\\s+)\\[x\\]\\s+\\*\\*${sid}:`, "mi"),
`$1[ ] **${sid}:`,
);
}
}
if (!absPath) return false;
await writeAndStore(absPath, artifactPath!, updated, {
artifact_type: "ROADMAP",
milestone_id: milestoneId,
});
return true;
}
// ─── Plan Checkbox Rendering ──────────────────────────────────────────────
/**
* Render plan checkbox states from DB.
*
* For each task in the slice, sets [x] if status === 'done',
* [ ] otherwise. Bidirectional.
*
* @returns true if the plan was written, false on skip/error
*/
export async function renderPlanCheckboxes(
basePath: string,
milestoneId: string,
sliceId: string,
): Promise<boolean> {
const tasks = getSliceTasks(milestoneId, sliceId);
if (tasks.length === 0) {
process.stderr.write(
`markdown-renderer: no tasks found for ${milestoneId}/${sliceId}\n`,
);
return false;
}
const absPath = resolveSliceFile(basePath, milestoneId, sliceId, "PLAN");
const artifactPath = absPath ? toArtifactPath(absPath, basePath) : null;
let content: string | null = null;
if (artifactPath) {
content = loadArtifactContent(artifactPath, absPath, {
artifact_type: "PLAN",
milestone_id: milestoneId,
slice_id: sliceId,
});
}
if (!content) {
process.stderr.write(
`markdown-renderer: no plan content available for ${milestoneId}/${sliceId}\n`,
);
return false;
}
// Apply checkbox patches for each task
let updated = content;
for (const task of tasks) {
const isDone = isClosedStatus(task.status);
const tid = task.id;
if (isDone) {
// Set [x]
updated = updated.replace(
new RegExp(`^(\\s*-\\s+)\\[ \\]\\s+\\*\\*${tid}:`, "m"),
`$1[x] **${tid}:`,
);
} else {
// Set [ ]
updated = updated.replace(
new RegExp(`^(\\s*-\\s+)\\[x\\]\\s+\\*\\*${tid}:`, "mi"),
`$1[ ] **${tid}:`,
);
}
}
if (!absPath) return false;
await writeAndStore(absPath, artifactPath!, updated, {
artifact_type: "PLAN",
milestone_id: milestoneId,
slice_id: sliceId,
});
return true;
}
// ─── Task Summary Rendering ───────────────────────────────────────────────
/**
* Render a task summary from DB to disk.
* Reads full_summary_md from the tasks table and writes it to the appropriate file.
*
* @returns true if the summary was written, false on skip/error
*/
export async function renderTaskSummary(
basePath: string,
milestoneId: string,
sliceId: string,
taskId: string,
): Promise<boolean> {
const task = getTask(milestoneId, sliceId, taskId);
if (!task || !task.full_summary_md) {
return false; // No summary to render — skip silently
}
// Resolve the tasks directory, creating path if needed
const slicePath = resolveSlicePath(basePath, milestoneId, sliceId);
if (!slicePath) {
process.stderr.write(
`markdown-renderer: cannot resolve slice path for ${milestoneId}/${sliceId}\n`,
);
return false;
}
const tasksDir = join(slicePath, "tasks");
const fileName = buildTaskFileName(taskId, "SUMMARY");
const absPath = join(tasksDir, fileName);
const artifactPath = toArtifactPath(absPath, basePath);
await writeAndStore(absPath, artifactPath, task.full_summary_md, {
artifact_type: "SUMMARY",
milestone_id: milestoneId,
slice_id: sliceId,
task_id: taskId,
});
return true;
}
// ─── Slice Summary Rendering ──────────────────────────────────────────────
/**
* Render slice summary and UAT files from DB to disk.
* Reads full_summary_md and full_uat_md from the slices table.
*
* @returns true if at least one file was written, false on skip/error
*/
export async function renderSliceSummary(
basePath: string,
milestoneId: string,
sliceId: string,
): Promise<boolean> {
const slice = getSlice(milestoneId, sliceId);
if (!slice) {
return false; // No slice data — skip silently
}
const slicePath = resolveSlicePath(basePath, milestoneId, sliceId);
if (!slicePath) {
process.stderr.write(
`markdown-renderer: cannot resolve slice path for ${milestoneId}/${sliceId}\n`,
);
return false;
}
let wrote = false;
// Write SUMMARY
if (slice.full_summary_md) {
const summaryName = buildSliceFileName(sliceId, "SUMMARY");
const summaryAbs = join(slicePath, summaryName);
const summaryArtifact = toArtifactPath(summaryAbs, basePath);
await writeAndStore(summaryAbs, summaryArtifact, slice.full_summary_md, {
artifact_type: "SUMMARY",
milestone_id: milestoneId,
slice_id: sliceId,
});
wrote = true;
}
// Write UAT
if (slice.full_uat_md) {
const uatName = buildSliceFileName(sliceId, "UAT");
const uatAbs = join(slicePath, uatName);
const uatArtifact = toArtifactPath(uatAbs, basePath);
await writeAndStore(uatAbs, uatArtifact, slice.full_uat_md, {
artifact_type: "UAT",
milestone_id: milestoneId,
slice_id: sliceId,
});
wrote = true;
}
return wrote;
}
// ─── Render All From DB ───────────────────────────────────────────────────
export interface RenderAllResult {
rendered: number;
skipped: number;
errors: string[];
}
/**
* Iterate all milestones, slices, and tasks in the DB and render each artifact to disk.
* Returns structured result for inspection.
*/
export async function renderAllFromDb(
basePath: string,
): Promise<RenderAllResult> {
const result: RenderAllResult = { rendered: 0, skipped: 0, errors: [] };
const milestones = getAllMilestones();
for (const milestone of milestones) {
// Render roadmap checkboxes
try {
const ok = await renderRoadmapCheckboxes(basePath, milestone.id);
if (ok) result.rendered++;
else result.skipped++;
} catch (err) {
result.errors.push(`roadmap ${milestone.id}: ${(err as Error).message}`);
}
// Iterate slices
const slices = getMilestoneSlices(milestone.id);
for (const slice of slices) {
// Render plan checkboxes
try {
const ok = await renderPlanCheckboxes(basePath, milestone.id, slice.id);
if (ok) result.rendered++;
else result.skipped++;
} catch (err) {
result.errors.push(
`plan ${milestone.id}/${slice.id}: ${(err as Error).message}`,
);
}
// Render slice summary
try {
const ok = await renderSliceSummary(basePath, milestone.id, slice.id);
if (ok) result.rendered++;
else result.skipped++;
} catch (err) {
result.errors.push(
`slice summary ${milestone.id}/${slice.id}: ${(err as Error).message}`,
);
}
// Iterate tasks
const tasks = getSliceTasks(milestone.id, slice.id);
for (const task of tasks) {
try {
const ok = await renderTaskSummary(
basePath,
milestone.id,
slice.id,
task.id,
);
if (ok) result.rendered++;
else result.skipped++;
} catch (err) {
result.errors.push(
`task summary ${milestone.id}/${slice.id}/${task.id}: ${(err as Error).message}`,
);
}
}
}
}
return result;
}
// ─── Stale Detection ──────────────────────────────────────────────────────
export interface StaleEntry {
path: string;
reason: string;
}
/**
* Detect stale renders by comparing DB state against file content.
*
* Checks:
* 1. Roadmap checkbox states vs DB slice statuses
* 2. Plan checkbox states vs DB task statuses
* 3. Missing SUMMARY.md files for complete tasks with full_summary_md
* 4. Missing SUMMARY.md/UAT.md files for complete slices with content
*
* Returns a list of stale entries with file path and reason.
* Logs to stderr when stale files are detected.
*/
export function detectStaleRenders(basePath: string): StaleEntry[] {
// Parsers are statically imported at module level; they were previously
// lazy-loaded via require() but vitest/Vite doesn't resolve .ts through
// Node's require() pipeline.
const { parseRoadmap, parsePlan } = parsers;
const stale: StaleEntry[] = [];
const milestones = getAllMilestones();
for (const milestone of milestones) {
const slices = getMilestoneSlices(milestone.id);
// ── Check roadmap checkbox state ──────────────────────────────────
const roadmapPath = resolveMilestoneFile(basePath, milestone.id, "ROADMAP");
if (roadmapPath && existsSync(roadmapPath)) {
try {
const content = readFileSync(roadmapPath, "utf-8");
const parsed = parseRoadmap(content);
for (const slice of slices) {
const isCompleteInDb = slice.status === "complete";
const roadmapSlice = parsed.slices.find(
(s: { id: string }) => s.id === slice.id,
);
if (!roadmapSlice) continue;
if (isCompleteInDb && !roadmapSlice.done) {
stale.push({
path: roadmapPath,
reason: `${slice.id} is complete in DB but unchecked in roadmap`,
});
} else if (!isCompleteInDb && roadmapSlice.done) {
stale.push({
path: roadmapPath,
reason: `${slice.id} is not complete in DB but checked in roadmap`,
});
}
}
} catch (e) {
logWarning("renderer", `roadmap parse failed: ${(e as Error).message}`);
}
}
// ── Check plan checkbox state and summaries for each slice ────────
for (const slice of slices) {
const tasks = getSliceTasks(milestone.id, slice.id);
// Check plan checkboxes
const planPath = resolveSliceFile(
basePath,
milestone.id,
slice.id,
"PLAN",
);
if (planPath && existsSync(planPath)) {
try {
const content = readFileSync(planPath, "utf-8");
const parsed = parsePlan(content);
for (const task of tasks) {
const isDoneInDb = isClosedStatus(task.status);
const planTask = parsed.tasks.find(
(t: { id: string }) => t.id === task.id,
);
if (!planTask) continue;
if (isDoneInDb && !planTask.done) {
stale.push({
path: planPath,
reason: `${task.id} is done in DB but unchecked in plan`,
});
} else if (!isDoneInDb && planTask.done) {
stale.push({
path: planPath,
reason: `${task.id} is not done in DB but checked in plan`,
});
}
}
} catch (e) {
logWarning("renderer", `plan parse failed: ${(e as Error).message}`);
}
}
// Check missing task summary files
for (const task of tasks) {
if (isClosedStatus(task.status) && task.full_summary_md) {
const slicePath = resolveSlicePath(basePath, milestone.id, slice.id);
if (slicePath) {
const tasksDir = join(slicePath, "tasks");
const fileName = buildTaskFileName(task.id, "SUMMARY");
const summaryAbsPath = join(tasksDir, fileName);
if (!existsSync(summaryAbsPath)) {
stale.push({
path: summaryAbsPath,
reason: `${task.id} is complete with summary in DB but SUMMARY.md missing on disk`,
});
}
}
}
}
// Check missing slice summary/UAT files
const sliceRow = getSlice(milestone.id, slice.id);
if (sliceRow && sliceRow.status === "complete") {
const slicePath = resolveSlicePath(basePath, milestone.id, slice.id);
if (slicePath) {
if (sliceRow.full_summary_md) {
const summaryName = buildSliceFileName(slice.id, "SUMMARY");
const summaryAbsPath = join(slicePath, summaryName);
if (!existsSync(summaryAbsPath)) {
stale.push({
path: summaryAbsPath,
reason: `${slice.id} is complete with summary in DB but SUMMARY.md missing on disk`,
});
}
}
if (sliceRow.full_uat_md) {
const uatName = buildSliceFileName(slice.id, "UAT");
const uatAbsPath = join(slicePath, uatName);
if (!existsSync(uatAbsPath)) {
stale.push({
path: uatAbsPath,
reason: `${slice.id} is complete with UAT in DB but UAT.md missing on disk`,
});
}
}
}
}
}
}
if (stale.length > 0) {
process.stderr.write(
`markdown-renderer: detected ${stale.length} stale render(s):\n`,
);
for (const entry of stale) {
process.stderr.write(` - ${entry.path}: ${entry.reason}\n`);
}
}
return stale;
}
// ─── Stale Repair ─────────────────────────────────────────────────────────
/**
* Repair all stale renders detected by `detectStaleRenders()`.
*
* For each stale entry, calls the appropriate render function:
* - Roadmap checkbox mismatches → renderRoadmapCheckboxes()
* - Plan checkbox mismatches → renderPlanCheckboxes()
* - Missing task summaries → renderTaskSummary()
* - Missing slice summaries/UATs → renderSliceSummary()
*
* Idempotent: calling twice with no DB changes produces zero repairs on the second call.
*
* @returns the number of files repaired
*/
export async function repairStaleRenders(basePath: string): Promise<number> {
const staleEntries = detectStaleRenders(basePath);
if (staleEntries.length === 0) return 0;
// Deduplicate: a single roadmap/plan file might appear multiple times
// (once per mismatched checkbox). We only need to re-render it once.
const repairedPaths = new Set<string>();
let repairCount = 0;
for (const entry of staleEntries) {
if (repairedPaths.has(entry.path)) continue;
// Normalize path separators for cross-platform regex matching
const normPath = entry.path.replace(/\\/g, "/");
try {
// Determine repair action from the reason
if (entry.reason.includes("in roadmap")) {
// Roadmap checkbox mismatch — extract milestone ID from path
const milestoneMatch = normPath.match(/milestones\/([^/]+)\//);
if (milestoneMatch) {
const ok = await renderRoadmapCheckboxes(basePath, milestoneMatch[1]);
if (ok) {
repairedPaths.add(entry.path);
repairCount++;
}
}
} else if (entry.reason.includes("in plan")) {
// Plan checkbox mismatch — extract milestone + slice IDs from path
const pathMatch = normPath.match(
/milestones\/([^/]+)\/slices\/([^/]+)\//,
);
if (pathMatch) {
const ok = await renderPlanCheckboxes(
basePath,
pathMatch[1],
pathMatch[2],
);
if (ok) {
repairedPaths.add(entry.path);
repairCount++;
}
}
} else if (
entry.reason.includes("SUMMARY.md missing") &&
entry.reason.match(/^T\d+/)
) {
// Missing task summary — extract IDs from path
const pathMatch = normPath.match(
/milestones\/([^/]+)\/slices\/([^/]+)\/tasks\//,
);
const taskMatch = entry.reason.match(/^(T\d+)/);
if (pathMatch && taskMatch) {
const ok = await renderTaskSummary(
basePath,
pathMatch[1],
pathMatch[2],
taskMatch[1],
);
if (ok) {
repairedPaths.add(entry.path);
repairCount++;
}
}
} else if (
entry.reason.includes("SUMMARY.md missing") &&
entry.reason.match(/^S\d+/)
) {
// Missing slice summary — extract IDs from path
const pathMatch = normPath.match(
/milestones\/([^/]+)\/slices\/([^/]+)\//,
);
if (pathMatch) {
const ok = await renderSliceSummary(
basePath,
pathMatch[1],
pathMatch[2],
);
if (ok) {
repairedPaths.add(entry.path);
repairCount++;
}
}
} else if (entry.reason.includes("UAT.md missing")) {
// Missing slice UAT — renderSliceSummary handles both SUMMARY + UAT
const pathMatch = normPath.match(
/milestones\/([^/]+)\/slices\/([^/]+)\//,
);
if (pathMatch) {
const ok = await renderSliceSummary(
basePath,
pathMatch[1],
pathMatch[2],
);
if (ok) {
repairedPaths.add(entry.path);
repairCount++;
}
}
}
} catch (err) {
logWarning(
"renderer",
`repair failed for ${entry.path}: ${(err as Error).message}`,
);
}
}
if (repairCount > 0) {
process.stderr.write(
`markdown-renderer: repaired ${repairCount} stale render(s)\n`,
);
}
return repairCount;
}
// ─── Replan & Assessment Renderers ────────────────────────────────────────
export interface ReplanData {
blockerTaskId: string;
blockerDescription: string;
whatChanged: string;
}
export interface AssessmentData {
verdict: string;
assessment: string;
completedSliceId?: string;
}
export async function renderReplanFromDb(
basePath: string,
milestoneId: string,
sliceId: string,
replanData: ReplanData,
): Promise<{ replanPath: string; content: string }> {
const slicePath =
resolveSlicePath(basePath, milestoneId, sliceId) ??
join(sfRoot(basePath), "milestones", milestoneId, "slices", sliceId);
const absPath = join(slicePath, `${sliceId}-REPLAN.md`);
const artifactPath = toArtifactPath(absPath, basePath);
const lines: string[] = [];
lines.push(`# ${sliceId} Replan`);
lines.push("");
lines.push(`**Milestone:** ${milestoneId}`);
lines.push(`**Slice:** ${sliceId}`);
lines.push(`**Blocker Task:** ${replanData.blockerTaskId}`);
lines.push(`**Created:** ${new Date().toISOString()}`);
lines.push("");
lines.push("## Blocker Description");
lines.push("");
lines.push(replanData.blockerDescription);
lines.push("");
lines.push("## What Changed");
lines.push("");
lines.push(replanData.whatChanged);
lines.push("");
const content = `${lines.join("\n").trimEnd()}\n`;
await writeAndStore(absPath, artifactPath, content, {
artifact_type: "REPLAN",
milestone_id: milestoneId,
slice_id: sliceId,
});
return { replanPath: absPath, content };
}
export async function renderAssessmentFromDb(
basePath: string,
milestoneId: string,
sliceId: string,
assessmentData: AssessmentData,
): Promise<{ assessmentPath: string; content: string }> {
const slicePath =
resolveSlicePath(basePath, milestoneId, sliceId) ??
join(sfRoot(basePath), "milestones", milestoneId, "slices", sliceId);
const absPath = join(slicePath, `${sliceId}-ASSESSMENT.md`);
const artifactPath = toArtifactPath(absPath, basePath);
const lines: string[] = [];
lines.push(`# ${sliceId} Assessment`);
lines.push("");
lines.push(`**Milestone:** ${milestoneId}`);
lines.push(`**Slice:** ${sliceId}`);
if (assessmentData.completedSliceId) {
lines.push(`**Completed Slice:** ${assessmentData.completedSliceId}`);
}
lines.push(`**Verdict:** ${assessmentData.verdict}`);
lines.push(`**Created:** ${new Date().toISOString()}`);
lines.push("");
lines.push("## Assessment");
lines.push("");
lines.push(assessmentData.assessment);
lines.push("");
const content = `${lines.join("\n").trimEnd()}\n`;
await writeAndStore(absPath, artifactPath, content, {
artifact_type: "ASSESSMENT",
milestone_id: milestoneId,
slice_id: sliceId,
});
return { assessmentPath: absPath, content };
}