2026-05-04 23:27:20 +02:00
// 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" ;
2026-05-05 23:08:03 +02:00
import { writeRoadmapJsonProjection } from "./roadmap-json-projection.js" ;
2026-05-05 14:31:16 +02:00
import {
_getAdapter ,
getMilestone ,
getMilestoneSlices ,
getSliceTasks ,
getVerificationEvidence ,
isDbAvailable ,
} from "./sf-db.js" ;
2026-05-04 23:27:20 +02:00
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 ) {
2026-05-05 14:31:16 +02:00
const prefix = ` ${ id } : ` ;
let result = title ;
while ( result . startsWith ( prefix ) ) {
result = result . slice ( prefix . length ) ;
}
return result . trim ( ) || title ;
2026-05-04 23:27:20 +02:00
}
/ * *
* 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 ) {
2026-05-05 14:31:16 +02:00
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 } ` ;
2026-05-04 23:27:20 +02:00
}
/ * *
* 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 ) {
2026-05-05 14:31:16 +02:00
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 ( ) ;
2026-05-04 23:27:20 +02:00
}
/ * *
* 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 = " " ) {
2026-05-05 14:31:16 +02:00
for ( const line of normalizeMarkdownBlockSpacing ( text ) . split ( "\n" ) ) {
lines . push ( line . trim ( ) ? ` ${ indent } ${ line } ` : "" ) ;
}
2026-05-04 23:27:20 +02:00
}
// ─── 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 ) {
2026-05-05 14:31:16 +02:00
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" ) ;
2026-05-04 23:27:20 +02:00
}
/ * *
* 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 ) {
2026-05-05 14:31:16 +02:00
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 ) ;
2026-05-04 23:27:20 +02:00
}
// ─── 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 ) {
2026-05-05 14:31:16 +02:00
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 ( "" ) ;
2026-05-08 00:17:47 +02:00
if ( milestoneRow . product _research ) {
lines . push ( ... renderProductResearchLines ( milestoneRow . product _research ) ) ;
lines . push ( "" ) ;
}
2026-05-05 14:31:16 +02:00
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" ) ;
2026-05-04 23:27:20 +02:00
}
2026-05-08 00:17:47 +02:00
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 ;
}
2026-05-04 23:27:20 +02:00
/ * *
* 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 ) {
2026-05-05 14:31:16 +02:00
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 ) ;
2026-05-05 23:08:03 +02:00
writeRoadmapJsonProjection ( basePath , milestoneId , milestoneRow , sliceRows ) ;
2026-05-04 23:27:20 +02:00
}
// ─── 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 ) {
2026-05-05 14:31:16 +02:00
// 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 ` ---
2026-05-04 23:27:20 +02:00
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 .
* /
2026-05-05 14:31:16 +02:00
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 ) ;
2026-05-04 23:27:20 +02:00
}
// ─── 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 ) {
2026-05-05 14:31:16 +02:00
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 \u 00b7 ${ state . requirements . validated } validated \u 00b7 ${ state . requirements . deferred } deferred \u 00b7 ${ 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" ) ;
2026-05-04 23:27:20 +02:00
}
/ * *
* Render STATE . md projection to disk .
* Derives state from DB , renders content , writes via atomicWriteSync .
* /
export async function renderStateProjection ( basePath ) {
2026-05-05 14:31:16 +02:00
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 ;
}
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
await deriveState ( basePath ) ; // update DB-backed state caches
2026-05-05 14:31:16 +02:00
} catch ( err ) {
logWarning ( "projection" , ` renderStateProjection failed: ${ err . message } ` ) ;
}
2026-05-04 23:27:20 +02:00
}
// ─── renderAllProjections ───────────────────────────────────────────────
/ * *
* Regenerate all projection files for a milestone from DB state .
* All calls are wrapped in try / c a t c h — p r o j e c t i o n f a i l u r e i s n o n - f a t a l p e r D - 0 2 .
* /
export async function renderAllProjections ( basePath , milestoneId ) {
2026-05-05 14:31:16 +02:00
// 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 } ` ) ;
}
2026-05-04 23:27:20 +02:00
}
// ─── 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 ) {
2026-05-05 14:31:16 +02:00
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 ;
}
2026-05-05 23:08:03 +02:00
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 ;
}
}
2026-05-05 14:31:16 +02:00
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 ;
}
2026-05-04 23:27:20 +02:00
}