singularity-forge/src/resources/extensions/sf/workflow-projections.js
Mikael Hugo d33e30e885 feat(notifications): NOTICE_KIND enum, schema v2 dedup, sf-db cleanup
- notification-store: schema v2 — repeatCount/lastTs merge for non-blocking
  notices; NOTICE_KIND enum (SYSTEM_NOTICE, TOOL_NOTICE, BLOCKING_NOTICE,
  USER_VISIBLE) for renderer classification without message parsing
- sf-db: remove gate_runs and audit_events tables (replaced by uok audit.js
  and trace-writer); schema reduced by ~370 lines
- notify-interceptor: tag auto-mode system notices with NOTICE_KIND.SYSTEM_NOTICE
- auto-prompts, guided-flow, system-context: use NOTICE_KIND on emit calls
- cli-status: expanded headless status surface + test coverage
- headless-types: new status fields
- Makefile/justfile: dev workflow improvements
- record-promoter, requirement-promoter: minor cleanup
- sf-db-migration tests: updated for dropped tables
- uok-gate-runner, uok-metrics, uok-outcome, uok-status tests: updated

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-10 20:13:58 +02:00

837 lines
26 KiB
JavaScript

// SF Extension — Projection Renderers (DB -> Markdown)
// Renders PLAN.md, ROADMAP.md, SUMMARY.md, and STATE.md from database rows.
// Projections are read-only views of engine state (Layer 3 of the architecture).
import { existsSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { atomicWriteSync } from "./atomic-write.js";
import { writeRoadmapJsonProjection } from "./roadmap-json-projection.js";
import {
_getAdapter,
getMilestone,
getMilestoneSlices,
getSliceTasks,
getVerificationEvidence,
isDbAvailable,
} from "./sf-db.js";
import { deriveState } from "./state.js";
import { isClosedStatus } from "./status-guards.js";
import { logWarning } from "./workflow-logger.js";
// ─── Helpers ─────────────────────────────────────────────────────────────
/**
* Strip a leading ID prefix (e.g. "M001: " or "S04: ") from a title
* to prevent double-prefixing when the renderer adds its own prefix.
* Handles repeated prefixes (e.g. "M001: M001: M001: Title" → "Title").
*/
/**
* Strip leading ID prefix from a title to prevent double-prefixing.
* Handles repeated prefixes (e.g., "M001: M001: Title" → "Title").
*/
export function stripIdPrefix(title, id) {
const prefix = `${id}: `;
let result = title;
while (result.startsWith(prefix)) {
result = result.slice(prefix.length);
}
return result.trim() || title;
}
/**
* Render a model-provided list entry without corrupting ordered lists.
*
* Purpose: projection fallback output remains valid Markdown when planning
* rows contain numbered success criteria from the LLM.
* Consumer: renderPlanContent when writing PLAN.md projections.
*/
function renderListEntry(entry) {
const trimmed = entry.trim();
const orderedBullet = trimmed.match(/^[-*+]\s+(\d+)[.)]\s+(.+)$/);
if (orderedBullet) {
return `${orderedBullet[1]}. ${orderedBullet[2].trim()}`;
}
const ordered = trimmed.match(/^(\d+)[.)]\s+(.+)$/);
if (ordered) {
return `${ordered[1]}. ${ordered[2].trim()}`;
}
if (/^[-*+]\s+\S/.test(trimmed)) {
return trimmed;
}
return `- ${trimmed}`;
}
/**
* Surround ATX headings in model-provided markdown with blank lines.
*
* Purpose: generated PLAN.md projections pass content validation even when
* task descriptions contain LLM-authored step headings.
* Consumer: renderPlanContent task entries.
*/
function normalizeMarkdownBlockSpacing(text) {
const sourceLines = text.trim().replace(/\r\n/g, "\n").split("\n");
const output = [];
let inFence = false;
for (let i = 0; i < sourceLines.length; i++) {
const line = sourceLines[i];
const trimmed = line.trim();
const fence = /^(```|~~~)/.test(trimmed);
const heading = !inFence && /^#{1,6}\s+\S/.test(trimmed);
if (heading && output.length > 0 && output[output.length - 1]?.trim()) {
output.push("");
}
output.push(line);
if (fence) {
inFence = !inFence;
}
const next = sourceLines[i + 1];
if (heading && next !== undefined && next.trim()) {
output.push("");
}
}
return output.join("\n").trim();
}
/**
* Append model-provided markdown as an indented child block.
*
* Purpose: task description subsections stay nested under their task instead
* of becoming top-level PLAN.md headings.
* Consumer: renderPlanContent task entries.
*/
function appendIndentedMarkdownBlock(lines, text, indent = " ") {
for (const line of normalizeMarkdownBlockSpacing(text).split("\n")) {
lines.push(line.trim() ? `${indent}${line}` : "");
}
}
// ─── PLAN.md Projection ──────────────────────────────────────────────────
/**
* Render PLAN.md content from a slice row and its task rows.
* Pure function — no side effects.
*/
/**
* Render PLAN.md content from a slice row and its task rows.
* Pure function with no side effects.
*/
export function renderPlanContent(sliceRow, taskRows) {
const lines = [];
const displayTitle = stripIdPrefix(sliceRow.title, sliceRow.id);
lines.push(`# ${sliceRow.id}: ${displayTitle}`);
lines.push("");
// #2945: never use full_summary_md/full_uat_md as display fallbacks —
// they contain multi-line rendered markdown that corrupts single-line fields.
lines.push(`**Goal:** ${sliceRow.goal || "TBD"}`);
lines.push(`**Demo:** After this: ${sliceRow.demo || "TBD"}`);
lines.push("");
lines.push("## Must-Haves");
lines.push("");
if (sliceRow.success_criteria.trim()) {
for (const line of sliceRow.success_criteria
.split(/\n+/)
.map((entry) => entry.trim())
.filter(Boolean)) {
lines.push(renderListEntry(line));
}
} else {
lines.push("- Complete the planned slice outcomes.");
}
lines.push("");
lines.push("## Adversarial Review");
lines.push("");
lines.push("### Partner Review");
lines.push("");
lines.push(sliceRow.adversarial_partner?.trim() || "Missing partner review.");
lines.push("");
lines.push("### Combatant Review");
lines.push("");
lines.push(
sliceRow.adversarial_combatant?.trim() || "Missing combatant review.",
);
lines.push("");
lines.push("### Architect Review");
lines.push("");
lines.push(
sliceRow.adversarial_architect?.trim() || "Missing architect review.",
);
lines.push("");
if (sliceRow.planning_meeting) {
lines.push("## Planning Meeting");
lines.push("");
lines.push("### Trigger");
lines.push("");
lines.push(sliceRow.planning_meeting.trigger.trim());
lines.push("");
lines.push("### Product Manager");
lines.push("");
lines.push(sliceRow.planning_meeting.pm.trim());
lines.push("");
if (sliceRow.planning_meeting.userAdvocate?.trim()) {
lines.push("### User Advocate");
lines.push("");
lines.push(sliceRow.planning_meeting.userAdvocate.trim());
lines.push("");
}
if (sliceRow.planning_meeting.customerPanel?.trim()) {
lines.push("### Customer Panel");
lines.push("");
lines.push(sliceRow.planning_meeting.customerPanel.trim());
lines.push("");
}
if (sliceRow.planning_meeting.business?.trim()) {
lines.push("### Business");
lines.push("");
lines.push(sliceRow.planning_meeting.business.trim());
lines.push("");
}
lines.push("### Researcher");
lines.push("");
lines.push(sliceRow.planning_meeting.researcher.trim());
lines.push("");
if (sliceRow.planning_meeting.deliveryLead?.trim()) {
lines.push("### Delivery Lead");
lines.push("");
lines.push(sliceRow.planning_meeting.deliveryLead.trim());
lines.push("");
}
lines.push("### Partner");
lines.push("");
lines.push(sliceRow.planning_meeting.partner.trim());
lines.push("");
lines.push("### Combatant");
lines.push("");
lines.push(sliceRow.planning_meeting.combatant.trim());
lines.push("");
lines.push("### Architect");
lines.push("");
lines.push(sliceRow.planning_meeting.architect.trim());
lines.push("");
lines.push("### Moderator");
lines.push("");
lines.push(sliceRow.planning_meeting.moderator.trim());
lines.push("");
lines.push("### Recommended Route");
lines.push("");
lines.push(sliceRow.planning_meeting.recommendedRoute);
lines.push("");
lines.push("### Confidence");
lines.push("");
lines.push(sliceRow.planning_meeting.confidenceSummary.trim());
lines.push("");
}
if (sliceRow.proof_level.trim()) {
lines.push("## Proof Level");
lines.push("");
lines.push(`- This slice proves: ${sliceRow.proof_level.trim()}`);
lines.push("");
}
if (sliceRow.integration_closure.trim()) {
lines.push("## Integration Closure");
lines.push("");
lines.push(sliceRow.integration_closure.trim());
lines.push("");
}
if (sliceRow.observability_impact.trim()) {
lines.push("## Observability / Diagnostics");
lines.push("");
lines.push(sliceRow.observability_impact.trim());
lines.push("");
}
const verificationCommands = taskRows
.map((task) => task.verify?.trim())
.filter((verify) => Boolean(verify));
if (verificationCommands.length > 0) {
lines.push("## Verification");
lines.push("");
for (const command of verificationCommands) {
lines.push(command.startsWith("-") ? command : `- ${command}`);
}
lines.push("");
}
lines.push("## Tasks");
for (const task of taskRows) {
const checkbox = isClosedStatus(task.status) ? "[x]" : "[ ]";
lines.push(`- ${checkbox} **${task.id}: ${task.title}**`);
if (task.description.trim()) {
appendIndentedMarkdownBlock(lines, task.description);
}
// Estimate subline (always present if non-empty)
if (task.estimate) {
lines.push(` - Estimate: ${task.estimate}`);
}
// Files subline (only if non-empty array)
if (task.files && task.files.length > 0) {
lines.push(` - Files: ${task.files.join(", ")}`);
}
// Verify subline (only if non-null)
if (task.verify) {
lines.push(` - Verify: ${task.verify}`);
}
// Duration subline (only if recorded)
if (task.duration) {
lines.push(` - Duration: ${task.duration}`);
}
// Blocker subline (if discovered)
if (task.blocker_discovered && task.known_issues) {
lines.push(` - Blocker: ${task.known_issues}`);
}
}
lines.push("");
return lines.join("\n");
}
/**
* Render PLAN.md projection to disk for a specific slice.
* Queries DB via helper functions, renders content, writes via atomicWriteSync.
*/
/**
* Render and write PLAN.md projection to disk for a slice.
* Queries DB, renders content, and writes via atomic write.
*/
export function renderPlanProjection(basePath, milestoneId, sliceId) {
const sliceRows = getMilestoneSlices(milestoneId);
const sliceRow = sliceRows.find((s) => s.id === sliceId);
if (!sliceRow) return;
const taskRows = getSliceTasks(milestoneId, sliceId);
const content = renderPlanContent(sliceRow, taskRows);
const dir = join(
basePath,
".sf",
"milestones",
milestoneId,
"slices",
sliceId,
);
mkdirSync(dir, { recursive: true });
atomicWriteSync(join(dir, `${sliceId}-PLAN.md`), content);
}
// ─── ROADMAP.md Projection ───────────────────────────────────────────────
/**
* Render ROADMAP.md content from a milestone row and its slice rows.
* Pure function — no side effects.
*/
export function renderRoadmapContent(milestoneRow, sliceRows) {
const lines = [];
const displayTitle = stripIdPrefix(milestoneRow.title, milestoneRow.id);
lines.push(`# ${milestoneRow.id}: ${displayTitle}`);
lines.push("");
lines.push("## Vision");
lines.push(milestoneRow.vision || milestoneRow.title || "TBD");
lines.push("");
if (milestoneRow.product_research) {
lines.push(...renderProductResearchLines(milestoneRow.product_research));
lines.push("");
}
if (milestoneRow.vision_meeting) {
lines.push("## Vision Alignment Meeting");
lines.push("");
lines.push("### Trigger");
lines.push(milestoneRow.vision_meeting.trigger);
lines.push("");
lines.push("### Product Manager");
lines.push(milestoneRow.vision_meeting.pm);
lines.push("");
lines.push("### User Advocate");
lines.push(milestoneRow.vision_meeting.userAdvocate);
lines.push("");
lines.push("### Customer Panel");
lines.push(milestoneRow.vision_meeting.customerPanel);
lines.push("");
lines.push("### Business");
lines.push(milestoneRow.vision_meeting.business);
lines.push("");
lines.push("### Researcher");
lines.push(milestoneRow.vision_meeting.researcher);
lines.push("");
lines.push("### Delivery Lead");
lines.push(milestoneRow.vision_meeting.deliveryLead);
lines.push("");
lines.push("### Partner");
lines.push(milestoneRow.vision_meeting.partner);
lines.push("");
lines.push("### Combatant");
lines.push(milestoneRow.vision_meeting.combatant);
lines.push("");
lines.push("### Architect");
lines.push(milestoneRow.vision_meeting.architect);
lines.push("");
lines.push("### Moderator");
lines.push(milestoneRow.vision_meeting.moderator);
lines.push("");
lines.push("### Weighted Synthesis");
lines.push(milestoneRow.vision_meeting.weightedSynthesis);
lines.push("");
lines.push("### Confidence By Area");
lines.push(milestoneRow.vision_meeting.confidenceByArea);
lines.push("");
lines.push("### Recommended Route");
lines.push(milestoneRow.vision_meeting.recommendedRoute);
lines.push("");
}
lines.push("## Slice Overview");
lines.push("| ID | Slice | Risk | Depends | Done | After this |");
lines.push("|----|-------|------|---------|------|------------|");
for (const slice of sliceRows) {
const done = isClosedStatus(slice.status) ? "\u2705" : "\u2B1C";
// depends is already parsed to string[] by rowToSlice
let depends = "\u2014";
if (slice.depends && slice.depends.length > 0) {
depends = slice.depends.join(", ");
}
const risk = (slice.risk || "low").toLowerCase();
// #2945 Bug 1: never use full_uat_md as a table cell fallback — it contains
// multi-line UAT content (preconditions, steps, expected results) that
// corrupts the markdown table and makes subsequent slices invisible.
const demo = slice.demo || "TBD";
lines.push(
`| ${slice.id} | ${slice.title} | ${risk} | ${depends} | ${done} | ${demo} |`,
);
}
lines.push("");
return lines.join("\n");
}
function renderProductResearchLines(research) {
const lines = [
"## Product / Competitor Research",
"",
"### Purpose Contract",
"",
`- Purpose: ${research.purpose}`,
`- Consumer: ${research.consumer}`,
`- Contract: ${research.contract}`,
`- Failure boundary: ${research.failureBoundary}`,
`- Evidence: ${research.evidence}`,
`- Non-goals: ${research.nonGoals}`,
`- Invariants: ${research.invariants}`,
`- Assumptions: ${research.assumptions}`,
"",
"### Category Findings",
"",
`- Category / job: ${research.category}${research.targetJob}`,
];
for (const item of research.comparables ?? []) {
lines.push(`- Comparable: ${item}`);
}
for (const item of research.tableStakes ?? []) {
lines.push(`- Table stake: ${item}`);
}
for (const item of research.differentiators ?? []) {
lines.push(`- Differentiator: ${item}`);
}
for (const item of research.antiPatterns ?? []) {
lines.push(`- Do not copy: ${item}`);
}
return lines;
}
/**
* Render ROADMAP.md projection to disk for a specific milestone.
* Queries DB via helper functions, renders content, writes via atomicWriteSync.
*/
export function renderRoadmapProjection(basePath, milestoneId) {
const milestoneRow = getMilestone(milestoneId);
if (!milestoneRow) return;
const sliceRows = getMilestoneSlices(milestoneId);
const content = renderRoadmapContent(milestoneRow, sliceRows);
const dir = join(basePath, ".sf", "milestones", milestoneId);
mkdirSync(dir, { recursive: true });
atomicWriteSync(join(dir, `${milestoneId}-ROADMAP.md`), content);
writeRoadmapJsonProjection(basePath, milestoneId, milestoneRow, sliceRows);
}
// ─── SUMMARY.md Projection ──────────────────────────────────────────────
/**
* Render SUMMARY.md content from a task row.
* Single source of truth for summary rendering — used both at completion
* time and at projection regeneration time (#2720).
*
* @param evidence - Optional verification evidence rows. When called from
* complete-task, these are passed directly. When called from projection
* regeneration, they are queried from the DB by renderSummaryProjection.
*/
export function renderSummaryContent(taskRow, sliceId, milestoneId, evidence) {
// If the task already has a fully rendered summary (written by handleCompleteTask's
// renderSummaryMarkdown), use it as-is. That content already includes frontmatter,
// heading, and all sections. Re-wrapping it inside a second frontmatter/heading
// envelope produces double frontmatter and duplicate sections.
if (
taskRow.full_summary_md &&
taskRow.full_summary_md.trimStart().startsWith("---")
) {
return taskRow.full_summary_md;
}
// ── Frontmatter (YAML list format, matches parseSummary() expectations) ──
const keyFilesYaml =
taskRow.key_files && taskRow.key_files.length > 0
? taskRow.key_files.map((f) => ` - ${f}`).join("\n")
: " - (none)";
const keyDecisionsYaml =
taskRow.key_decisions && taskRow.key_decisions.length > 0
? taskRow.key_decisions.map((d) => ` - ${d}`).join("\n")
: " - (none)";
// Derive verification_result from evidence if available
const evidenceList = evidence ?? [];
const allPassed =
evidenceList.length > 0 &&
evidenceList.every((e) => {
const code = e.exitCode ?? e.exit_code ?? -1;
return (
code === 0 ||
e.verdict.includes("\u2705") ||
e.verdict.toLowerCase().includes("pass")
);
});
const verificationResult = taskRow.verification_result
? allPassed
? "passed"
: evidenceList.length === 0
? "untested"
: "mixed"
: allPassed
? "passed"
: evidenceList.length === 0
? "untested"
: "mixed";
// Build verification evidence table
let evidenceTable =
"| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n";
if (evidenceList.length > 0) {
evidenceList.forEach((e, i) => {
const code = e.exitCode ?? e.exit_code ?? 0;
const dur = e.durationMs ?? e.duration_ms ?? 0;
evidenceTable += `| ${i + 1} | \`${e.command}\` | ${code} | ${e.verdict} | ${dur}ms |\n`;
});
} else {
evidenceTable +=
"| \u2014 | No verification commands discovered | \u2014 | \u2014 | \u2014 |\n";
}
const title = taskRow.one_liner || taskRow.title || taskRow.id;
return `---
id: ${taskRow.id}
parent: ${sliceId}
milestone: ${milestoneId}
key_files:
${keyFilesYaml}
key_decisions:
${keyDecisionsYaml}
duration: ${taskRow.duration || ""}
verification_result: ${verificationResult}
completed_at: ${taskRow.completed_at || ""}
blocker_discovered: ${taskRow.blocker_discovered ? "true" : "false"}
---
# ${taskRow.id}: ${title}
**${taskRow.one_liner || ""}**
## What Happened
${taskRow.narrative || "No summary recorded."}
## Verification
${taskRow.verification_result || "No verification recorded."}
## Verification Evidence
${evidenceTable}
## Deviations
${taskRow.deviations || "None."}
## Known Issues
${taskRow.known_issues || "None."}
## Files Created/Modified
${taskRow.key_files && taskRow.key_files.length > 0 ? taskRow.key_files.map((f) => `- \`${f}\``).join("\n") : "None."}
`;
}
/**
* Render SUMMARY.md projection to disk for a specific task.
* Queries DB via helper functions, renders content, writes via atomicWriteSync.
*/
export function renderSummaryProjection(
basePath,
milestoneId,
sliceId,
taskId,
) {
const taskRows = getSliceTasks(milestoneId, sliceId);
const taskRow = taskRows.find((t) => t.id === taskId);
if (!taskRow) return;
const evidenceRows = getVerificationEvidence(milestoneId, sliceId, taskId);
const content = renderSummaryContent(
taskRow,
sliceId,
milestoneId,
evidenceRows,
);
const dir = join(
basePath,
".sf",
"milestones",
milestoneId,
"slices",
sliceId,
"tasks",
);
mkdirSync(dir, { recursive: true });
atomicWriteSync(join(dir, `${taskId}-SUMMARY.md`), content);
}
// ─── STATE.md Projection ────────────────────────────────────────────────
/**
* Render STATE.md content from SFState.
* Matches the buildStateMarkdown output format from doctor.ts exactly.
* Pure function — no side effects.
*/
export function renderStateContent(state) {
const lines = [];
lines.push("# SF State", "");
const activeSlice = state.activeSlice
? `${state.activeSlice.id}: ${stripIdPrefix(state.activeSlice.title, state.activeSlice.id)}`
: "None";
if (state.phase === "complete" && state.lastCompletedMilestone) {
lines.push(
`**Last Completed Milestone:** ${state.lastCompletedMilestone.id}: ${state.lastCompletedMilestone.title}`,
);
} else {
const activeMilestone = state.activeMilestone
? `${state.activeMilestone.id}: ${stripIdPrefix(state.activeMilestone.title, state.activeMilestone.id)}`
: "None";
lines.push(`**Active Milestone:** ${activeMilestone}`);
}
lines.push(`**Active Slice:** ${activeSlice}`);
lines.push(`**Phase:** ${state.phase}`);
if (state.requirements) {
lines.push(
`**Requirements Status:** ${state.requirements.active} active \u00b7 ${state.requirements.validated} validated \u00b7 ${state.requirements.deferred} deferred \u00b7 ${state.requirements.outOfScope} out of scope`,
);
}
lines.push("");
lines.push("## Milestone Registry");
for (const entry of state.registry) {
const glyph =
entry.status === "complete"
? "\u2705"
: entry.status === "active"
? "\uD83D\uDD04"
: entry.status === "parked"
? "\u23F8\uFE0F"
: "\u2B1C";
lines.push(
`- ${glyph} **${entry.id}:** ${stripIdPrefix(entry.title, entry.id)}`,
);
}
lines.push("");
lines.push("## Recent Decisions");
if (state.recentDecisions.length > 0) {
for (const decision of state.recentDecisions) lines.push(`- ${decision}`);
} else {
lines.push("- None recorded");
}
lines.push("");
lines.push("## Blockers");
if (state.blockers.length > 0) {
for (const blocker of state.blockers) lines.push(`- ${blocker}`);
} else {
lines.push("- None");
}
lines.push("");
lines.push("## Next Action");
lines.push(state.nextAction || "None");
lines.push("");
return lines.join("\n");
}
/**
* Render STATE.md projection to disk.
* Derives state from DB, renders content, writes via atomicWriteSync.
*/
export async function renderStateProjection(basePath) {
try {
if (!isDbAvailable()) return;
// Probe DB handle — adapter may be set but underlying handle closed
const adapter = _getAdapter();
if (!adapter) return;
try {
adapter.prepare("SELECT 1").get();
} catch (err) {
logWarning(
"projection",
"renderStateProjection: DB handle probe failed, skipping render",
{
error: err.message,
},
);
return;
}
await deriveState(basePath); // update DB-backed state caches
} catch (err) {
logWarning("projection", `renderStateProjection failed: ${err.message}`);
}
}
// ─── renderAllProjections ───────────────────────────────────────────────
/**
* Regenerate all projection files for a milestone from DB state.
* All calls are wrapped in try/catch — projection failure is non-fatal per D-02.
*/
export async function renderAllProjections(basePath, milestoneId) {
// Render ROADMAP.md for the milestone
try {
renderRoadmapProjection(basePath, milestoneId);
} catch (err) {
logWarning(
"projection",
`renderRoadmapProjection failed for ${milestoneId}: ${err.message}`,
);
}
// Query all slices for this milestone
const sliceRows = getMilestoneSlices(milestoneId);
for (const slice of sliceRows) {
// PLAN.md is rendered by the authoritative markdown-renderer.js in
// plan-slice/replan-slice tools. Do NOT overwrite it here — the simplified
// projection is missing key sections (Must-Haves, Verification, Files
// Likely Touched) and corrupts multi-line task descriptions (#3651).
// Render SUMMARY.md for each completed task
const taskRows = getSliceTasks(milestoneId, slice.id);
const doneTasks = taskRows.filter(
(t) => t.status === "done" || t.status === "complete",
);
for (const task of doneTasks) {
try {
renderSummaryProjection(basePath, milestoneId, slice.id, task.id);
} catch (err) {
logWarning(
"projection",
`renderSummaryProjection failed for ${milestoneId}/${slice.id}/${task.id}: ${err.message}`,
);
}
}
}
// Render STATE.md
try {
await renderStateProjection(basePath);
} catch (err) {
logWarning("projection", `renderStateProjection failed: ${err.message}`);
}
}
// ─── regenerateIfMissing ────────────────────────────────────────────────
/**
* Check if a projection file exists on disk. If missing, regenerate it from DB.
* Returns true if the file was regenerated, false if it already existed.
* Satisfies PROJ-05 (corrupted/deleted projections regenerate on demand).
*/
export function regenerateIfMissing(basePath, milestoneId, sliceId, fileType) {
let filePath;
switch (fileType) {
case "PLAN":
filePath = join(
basePath,
".sf",
"milestones",
milestoneId,
"slices",
sliceId,
`${sliceId}-PLAN.md`,
);
break;
case "ROADMAP":
filePath = join(
basePath,
".sf",
"milestones",
milestoneId,
`${milestoneId}-ROADMAP.md`,
);
break;
case "SUMMARY":
// For SUMMARY, we regenerate all task summaries in the slice
filePath = join(
basePath,
".sf",
"milestones",
milestoneId,
"slices",
sliceId,
"tasks",
);
break;
case "STATE":
filePath = join(basePath, ".sf", "STATE.md");
break;
}
if (fileType === "SUMMARY") {
// Check each completed task's SUMMARY file individually (not just the directory)
const taskRows = getSliceTasks(milestoneId, sliceId);
const doneTasks = taskRows.filter(
(t) => t.status === "done" || t.status === "complete",
);
let regenerated = 0;
for (const task of doneTasks) {
const summaryPath = join(
basePath,
".sf",
"milestones",
milestoneId,
"slices",
sliceId,
"tasks",
`${task.id}-SUMMARY.md`,
);
if (!existsSync(summaryPath)) {
try {
renderSummaryProjection(basePath, milestoneId, sliceId, task.id);
regenerated++;
} catch (err) {
logWarning(
"projection",
`regenerateIfMissing SUMMARY failed for ${task.id}: ${err.message}`,
);
}
}
}
return regenerated > 0;
}
if (
fileType === "ROADMAP" &&
existsSync(filePath) &&
!existsSync(
join(
basePath,
".sf",
"milestones",
milestoneId,
`${milestoneId}-ROADMAP.json`,
),
)
) {
try {
renderRoadmapProjection(basePath, milestoneId);
return true;
} catch (err) {
logWarning(
"projection",
`regenerateIfMissing ROADMAP.json failed for ${milestoneId}: ${err.message}`,
);
return false;
}
}
if (existsSync(filePath)) {
return false;
}
// Regenerate the missing file
try {
switch (fileType) {
case "PLAN":
renderPlanProjection(basePath, milestoneId, sliceId);
break;
case "ROADMAP":
renderRoadmapProjection(basePath, milestoneId);
break;
case "STATE":
// renderStateProjection is async — fire-and-forget.
// Return false since the file isn't written yet; it will appear
// on the next post-mutation hook cycle.
void renderStateProjection(basePath);
return false;
}
return true;
} catch (err) {
logWarning(
"projection",
`regenerateIfMissing ${fileType} failed: ${err.message}`,
);
return false;
}
}