- 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>
837 lines
26 KiB
JavaScript
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;
|
|
}
|
|
}
|