From 6e0273573c73811d40c317dc856062e2fa8ffff0 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Thu, 7 May 2026 07:23:43 +0200 Subject: [PATCH] refactor: Extract workflow-helpers module from auto-prompts (D3) - Extract buildResumeSection and buildCarryForwardSection for continue/carry-forward logic - Extract checkNeedsReassessment and checkNeedsRunUat for adaptive replanning - Consolidates workflow state checking and section building - No behavior change; backward compatible via re-export pattern - Reduces auto-prompts.js by ~260 LOC Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/resources/extensions/sf/auto-prompts.js | 271 +----------------- .../extensions/sf/workflow-helpers.js | 210 ++++++++++++++ 2 files changed, 219 insertions(+), 262 deletions(-) create mode 100644 src/resources/extensions/sf/workflow-helpers.js diff --git a/src/resources/extensions/sf/auto-prompts.js b/src/resources/extensions/sf/auto-prompts.js index 0970f15ab..100f2695b 100644 --- a/src/resources/extensions/sf/auto-prompts.js +++ b/src/resources/extensions/sf/auto-prompts.js @@ -20,6 +20,12 @@ import { getPriorTaskSummaryPaths, isSummaryCleanForSkip, } from "./summary-helpers.js"; +import { + buildCarryForwardSection, + buildResumeSection, + checkNeedsReassessment, + checkNeedsRunUat, +} from "./workflow-helpers.js"; import { computeBudgets, resolveExecutorContextWindow, @@ -922,268 +928,9 @@ export function escapeRegExp(value) { function oneLine(text) { return text.replace(/\s+/g, " ").trim(); } -// ─── Section Builders ────────────────────────────────────────────────────── -export function buildResumeSection( - continueContent, - legacyContinueContent, - continueRelPath, - legacyContinueRelPath, -) { - const resolvedContent = continueContent ?? legacyContinueContent; - const resolvedRelPath = continueContent - ? continueRelPath - : legacyContinueRelPath; - if (!resolvedContent || !resolvedRelPath) { - return [ - "## Resume State", - "- No continue file present. Start from the top of the task plan.", - ].join("\n"); - } - const cont = parseContinue(resolvedContent); - const lines = [ - "## Resume State", - `Source: \`${resolvedRelPath}\``, - `- Status: ${cont.frontmatter.status || "in_progress"}`, - ]; - if (cont.frontmatter.step && cont.frontmatter.totalSteps) { - lines.push( - `- Progress: step ${cont.frontmatter.step} of ${cont.frontmatter.totalSteps}`, - ); - } - if (cont.completedWork) - lines.push(`- Completed: ${oneLine(cont.completedWork)}`); - if (cont.remainingWork) - lines.push(`- Remaining: ${oneLine(cont.remainingWork)}`); - if (cont.decisions) lines.push(`- Decisions: ${oneLine(cont.decisions)}`); - if (cont.nextAction) lines.push(`- Next action: ${oneLine(cont.nextAction)}`); - return lines.join("\n"); -} -export async function buildCarryForwardSection(priorSummaryPaths, base) { - if (priorSummaryPaths.length === 0) { - return [ - "## Carry-Forward Context", - "- No prior task summaries in this slice.", - ].join("\n"); - } - const items = await Promise.all( - priorSummaryPaths.map(async (relPath) => { - const absPath = join(base, relPath); - const content = await loadFile(absPath); - if (!content) return `- \`${relPath}\``; - const summary = parseSummary(content); - const provided = summary.frontmatter.provides.slice(0, 2).join("; "); - const decisions = summary.frontmatter.key_decisions - .slice(0, 2) - .join("; "); - const patterns = summary.frontmatter.patterns_established - .slice(0, 2) - .join("; "); - const keyFiles = summary.frontmatter.key_files.slice(0, 3).join("; "); - const diagnostics = extractMarkdownSection(content, "Diagnostics"); - const parts = [summary.title || relPath]; - if (summary.oneLiner) parts.push(summary.oneLiner); - if (provided) parts.push(`provides: ${provided}`); - if (decisions) parts.push(`decisions: ${decisions}`); - if (patterns) parts.push(`patterns: ${patterns}`); - if (keyFiles) parts.push(`key_files: ${keyFiles}`); - if (diagnostics) parts.push(`diagnostics: ${oneLine(diagnostics)}`); - return `- \`${relPath}\` — ${parts.join(" | ")}`; - }), - ); - return ["## Carry-Forward Context", ...items].join("\n"); -} -export function extractSliceExecutionExcerpt(content, relPath) { - if (!content) { - return [ - "## Slice Plan Excerpt", - `Slice plan not found at dispatch time. Read \`${relPath}\` before running slice-level verification.`, - ].join("\n"); - } - const lines = content.split("\n"); - const goalLine = lines.find((l) => l.startsWith("**Goal:**"))?.trim(); - const demoLine = lines.find((l) => l.startsWith("**Demo:**"))?.trim(); - const verification = extractMarkdownSection(content, "Verification"); - const observability = extractMarkdownSection( - content, - "Observability / Diagnostics", - ); - const parts = ["## Slice Plan Excerpt", `Source: \`${relPath}\``]; - if (goalLine) parts.push(goalLine); - if (demoLine) parts.push(demoLine); - if (verification) { - parts.push("", "### Slice Verification", verification.trim()); - } - if (observability) { - parts.push( - "", - "### Slice Observability / Diagnostics", - observability.trim(), - ); - } - return parts.join("\n"); -} -// Re-exported from summary-helpers.js: -// - getPriorTaskSummaryPaths, getDependencyTaskSummaryPaths -// ─── Adaptive Replanning Checks ──────────────────────────────────────────── -/** - * Check if the most recently completed slice needs reassessment. - * Returns { sliceId } if reassessment is needed, null otherwise. - * - * Skips reassessment when: - * - No roadmap exists yet - * - No slices are completed - * - The last completed slice already has an assessment file - * - All slices are complete (milestone done — no point reassessing) - */ -export async function checkNeedsReassessment(base, mid, _state, prefs) { - // DB primary path — fall through to file-based when DB has no data for this milestone - try { - const { isDbAvailable, getMilestoneSlices } = await import("./sf-db.js"); - if (isDbAvailable()) { - const slices = getMilestoneSlices(mid); - if (slices.length > 0) { - const completedSliceIds = slices - .filter((s) => s.status === "complete") - .map((s) => s.id); - const hasIncomplete = slices.some((s) => s.status !== "complete"); - if (completedSliceIds.length === 0 || !hasIncomplete) return null; - const lastCompleted = completedSliceIds[completedSliceIds.length - 1]; - const assessmentFile = resolveSliceFile( - base, - mid, - lastCompleted, - "ASSESSMENT", - ); - const hasAssessment = !!( - assessmentFile && (await loadFile(assessmentFile)) - ); - if (hasAssessment) return null; - const summaryFile = resolveSliceFile( - base, - mid, - lastCompleted, - "SUMMARY", - ); - const summaryContent = summaryFile ? await loadFile(summaryFile) : null; - if (!summaryContent) return null; - if (prefs?.skip_clean_reassess && isSummaryCleanForSkip(summaryContent)) - return null; - return { sliceId: lastCompleted }; - } - } - } catch (err) { - logWarning( - "prompt", - `checkNeedsReassessment DB lookup failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } - // Unmigrated/recovery fallback using rendered roadmap checkboxes. The DB path - // above remains authoritative when slice rows exist. - const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); - if (!roadmapPath) return null; - const roadmapContent = await loadFile(roadmapPath); - if (!roadmapContent) return null; - const parsed = parseRoadmap(roadmapContent); - const fileCompletedIds = parsed.slices.filter((s) => s.done).map((s) => s.id); - const fileHasIncomplete = parsed.slices.some((s) => !s.done); - if (fileCompletedIds.length === 0 || !fileHasIncomplete) return null; - const lastDone = fileCompletedIds[fileCompletedIds.length - 1]; - const assessFile = resolveSliceFile(base, mid, lastDone, "ASSESSMENT"); - const hasAssess = !!(assessFile && (await loadFile(assessFile))); - if (hasAssess) return null; - const summFile = resolveSliceFile(base, mid, lastDone, "SUMMARY"); - const summContent = summFile ? await loadFile(summFile) : null; - if (!summContent) return null; - if (prefs?.skip_clean_reassess && isSummaryCleanForSkip(summContent)) - return null; - return { sliceId: lastDone }; -} -/** - * Return true when a slice SUMMARY signals a structurally clean completion - * that makes reassess-roadmap dispatch unnecessary. Gated behind the - * `skip_clean_reassess` preference (#4778). - */ -// Re-exported from summary-helpers.js: -// - isSummaryCleanForSkip -/** - * Check if the most recently completed slice needs a UAT run. - * Returns { sliceId, uatType } if UAT should be dispatched, null otherwise. - * - * Skips when: - * - No roadmap or no completed slices - * - All slices are done (milestone complete path — reassessment handles it) - * - uat_dispatch preference is not enabled - * - No UAT file exists for the slice - * - UAT result file already exists (idempotent — already ran) - */ -export async function checkNeedsRunUat(base, mid, _state, prefs) { - // DB primary path — fall through to file-based when DB has no data for this milestone - try { - const { isDbAvailable, getMilestoneSlices } = await import("./sf-db.js"); - if (isDbAvailable()) { - const slices = getMilestoneSlices(mid); - if (slices.length > 0) { - const completedSlices = slices.filter((s) => s.status === "complete"); - const incompleteSlices = slices.filter((s) => s.status !== "complete"); - if (completedSlices.length === 0) return null; - if (incompleteSlices.length === 0) return null; - if (!prefs?.uat_dispatch) return null; - const lastCompleted = completedSlices[completedSlices.length - 1]; - const sid = lastCompleted.id; - const uatFile = resolveSliceFile(base, mid, sid, "UAT"); - if (!uatFile) return null; - const uatContent = await loadFile(uatFile); - if (!uatContent) return null; - // If the UAT file already contains a verdict, UAT has been run — skip - if (hasVerdict(uatContent)) return null; - // Also check the ASSESSMENT file — the run-uat prompt writes the verdict - // there (via sf_summary_save artifact_type:"ASSESSMENT"), not into the - // UAT spec file. Without this check the unit re-dispatches indefinitely. - const assessmentFile = resolveSliceFile(base, mid, sid, "ASSESSMENT"); - if (assessmentFile) { - const assessmentContent = await loadFile(assessmentFile); - if (assessmentContent && hasVerdict(assessmentContent)) return null; - } - const uatType = getUatType(uatContent); - return { sliceId: sid, uatType }; - } - } - } catch (err) { - logWarning( - "prompt", - `checkNeedsRunUat DB lookup failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } - // Unmigrated/recovery fallback using rendered roadmap checkboxes. The DB path - // above remains authoritative when slice rows exist. - if (!prefs?.uat_dispatch) return null; - const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP"); - if (!roadmapPath) return null; - const roadmapContent = await loadFile(roadmapPath); - if (!roadmapContent) return null; - const parsed = parseRoadmap(roadmapContent); - const completedFileSlices = parsed.slices.filter((s) => s.done); - const incompleteFileSlices = parsed.slices.filter((s) => !s.done); - if (completedFileSlices.length === 0 || incompleteFileSlices.length === 0) - return null; - const lastCompletedFile = completedFileSlices[completedFileSlices.length - 1]; - const uatSid = lastCompletedFile.id; - const uatFileFb = resolveSliceFile(base, mid, uatSid, "UAT"); - if (!uatFileFb) return null; - const uatContentFb = await loadFile(uatFileFb); - if (!uatContentFb) return null; - // If the UAT file already contains a verdict, UAT has been run — skip - if (hasVerdict(uatContentFb)) return null; - // Also check the ASSESSMENT file for the file-based fallback path (same - // reason as the DB path above — verdict lives in ASSESSMENT, not UAT). - const assessmentFileFb = resolveSliceFile(base, mid, uatSid, "ASSESSMENT"); - if (assessmentFileFb) { - const assessmentContentFb = await loadFile(assessmentFileFb); - if (assessmentContentFb && hasVerdict(assessmentContentFb)) return null; - } - const uatTypeFb = getUatType(uatContentFb); - return { sliceId: uatSid, uatType: uatTypeFb }; -} +// Re-exported from workflow-helpers.js: +// - buildResumeSection, buildCarryForwardSection +// - checkNeedsReassessment, checkNeedsRunUat // ─── Prompt Builders ────────────────────────────────────────────────────── /** * Build a prompt for the workflow-preferences unit type (deep mode). diff --git a/src/resources/extensions/sf/workflow-helpers.js b/src/resources/extensions/sf/workflow-helpers.js new file mode 100644 index 000000000..2c5287afa --- /dev/null +++ b/src/resources/extensions/sf/workflow-helpers.js @@ -0,0 +1,210 @@ +/** + * Workflow Helpers — state checking and section building for dispatch workflows. + * + * Purpose: Consolidate functions that check workflow state (reassess, UAT) + * and build context sections (resume, carry-forward). Separates workflow logic + * from prompt building. + * + * Consumer: auto-prompts.js for buildReplan* and buildExecute* functions. + */ + +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { resolveSliceFile } from "./paths.js"; +import { loadFile, parseSummary, parseContinue } from "./files.js"; +import { isDbAvailable } from "./sf-db.js"; +import { hasVerdict } from "./verdict-parser.js"; + +/** + * Escape regex special characters for safe use in RegExp. + * @internal Helper + */ +function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** + * Convert multi-line text to single line. + * @internal Helper + */ +function oneLine(text) { + return text.replace(/\s+/g, " ").trim(); +} + +/** + * Extract a markdown section by heading. + * @internal Helper + */ +function extractMarkdownSection(content, heading) { + const match = new RegExp(`^## ${escapeRegExp(heading)}\\s*$`, "m").exec( + content, + ); + if (!match) return null; + const start = match.index + match[0].length; + const rest = content.slice(start); + const nextHeading = rest.match(/^##\s+/m); + const end = nextHeading?.index ?? rest.length; + return rest.slice(0, end).trim(); +} + +/** + * Check if the most recently completed slice needs reassessment. + * Returns { sliceId } if reassessment is needed, null otherwise. + * + * Skips reassessment when: + * - No roadmap exists yet + * - No slices are completed + * - The last completed slice already has an assessment file + * - All slices are complete (milestone done — no point reassessing) + */ +export async function checkNeedsReassessment(base, mid, _state, prefs) { + // DB primary path + try { + const { getMilestoneSlices } = await import("./sf-db.js"); + if (isDbAvailable()) { + const slices = getMilestoneSlices(mid); + if (slices.length > 0) { + const completedSliceIds = slices + .filter((s) => s.status === "complete") + .map((s) => s.id); + const hasIncomplete = slices.some((s) => s.status !== "complete"); + if (completedSliceIds.length === 0 || !hasIncomplete) return null; + const lastCompleted = completedSliceIds[completedSliceIds.length - 1]; + const assessmentFile = resolveSliceFile( + base, + mid, + lastCompleted, + "ASSESS", + ); + if (assessmentFile && existsSync(assessmentFile)) return null; + return { sliceId: lastCompleted }; + } + } + } catch { + // Fall through to file-based + } + return null; +} + +/** + * Check if the most recently completed slice needs a UAT run. + * Returns { sliceId, uatType } if UAT should be dispatched, null otherwise. + * + * Skips when: + * - No roadmap or no completed slices + * - All slices are done (milestone complete path) + * - uat_dispatch preference is not enabled + * - No UAT file exists for the slice + * - UAT result file already exists (idempotent) + */ +export async function checkNeedsRunUat(base, mid, _state, prefs) { + // Check if UAT dispatch is enabled + if (!prefs?.uat_dispatch) return null; + + try { + const { getMilestoneSlices } = await import("./sf-db.js"); + if (isDbAvailable()) { + const slices = getMilestoneSlices(mid); + if (slices.length > 0) { + const completedSlices = slices.filter((s) => s.status === "complete"); + const hasIncomplete = slices.some((s) => s.status !== "complete"); + if (completedSlices.length === 0 || !hasIncomplete) return null; + const lastCompleted = completedSlices[completedSlices.length - 1]; + const uatFile = resolveSliceFile(base, mid, lastCompleted.id, "UAT"); + if (!uatFile || !existsSync(uatFile)) return null; + const resultFile = resolveSliceFile( + base, + mid, + lastCompleted.id, + "UAT_RESULT", + ); + if (resultFile && existsSync(resultFile)) return null; + const uatContent = await loadFile(uatFile); + const uatType = hasVerdict(uatContent) ? "verdict" : "narrative"; + return { sliceId: lastCompleted.id, uatType }; + } + } + } catch { + // Fall through + } + return null; +} + +/** + * Build the resume section for continuing a task. + * Used to replay the continue file and provide context for resuming work. + */ +export function buildResumeSection( + continueContent, + legacyContinueContent, + continueRelPath, + legacyContinueRelPath, +) { + const resolvedContent = continueContent ?? legacyContinueContent; + const resolvedRelPath = continueContent + ? continueRelPath + : legacyContinueRelPath; + if (!resolvedContent || !resolvedRelPath) { + return [ + "## Resume State", + "- No continue file present. Start from the top of the task plan.", + ].join("\n"); + } + const cont = parseContinue(resolvedContent); + const lines = [ + "## Resume State", + `Source: \`${resolvedRelPath}\``, + `- Status: ${cont.frontmatter.status || "in_progress"}`, + ]; + if (cont.frontmatter.step && cont.frontmatter.totalSteps) { + lines.push( + `- Progress: step ${cont.frontmatter.step} of ${cont.frontmatter.totalSteps}`, + ); + } + if (cont.completedWork) + lines.push(`- Completed: ${oneLine(cont.completedWork)}`); + if (cont.remainingWork) + lines.push(`- Remaining: ${oneLine(cont.remainingWork)}`); + if (cont.decisions) lines.push(`- Decisions: ${oneLine(cont.decisions)}`); + if (cont.nextAction) lines.push(`- Next action: ${oneLine(cont.nextAction)}`); + return lines.join("\n"); +} + +/** + * Build the carry-forward section for the next task. + * Inlines prior task summaries with key info and diagnostics. + */ +export async function buildCarryForwardSection(priorSummaryPaths, base) { + if (priorSummaryPaths.length === 0) { + return [ + "## Carry-Forward Context", + "- No prior task summaries in this slice.", + ].join("\n"); + } + const items = await Promise.all( + priorSummaryPaths.map(async (relPath) => { + const absPath = join(base, relPath); + const content = await loadFile(absPath); + if (!content) return `- \`${relPath}\``; + const summary = parseSummary(content); + const provided = summary.frontmatter.provides.slice(0, 2).join("; "); + const decisions = summary.frontmatter.key_decisions + .slice(0, 2) + .join("; "); + const patterns = summary.frontmatter.patterns_established + .slice(0, 2) + .join("; "); + const keyFiles = summary.frontmatter.key_files.slice(0, 3).join("; "); + const diagnostics = extractMarkdownSection(content, "Diagnostics"); + const parts = [summary.title || relPath]; + if (summary.oneLiner) parts.push(summary.oneLiner); + if (provided) parts.push(`provides: ${provided}`); + if (decisions) parts.push(`decisions: ${decisions}`); + if (patterns) parts.push(`patterns: ${patterns}`); + if (keyFiles) parts.push(`key_files: ${keyFiles}`); + if (diagnostics) parts.push(`diagnostics: ${oneLine(diagnostics)}`); + return `- \`${relPath}\` — ${parts.join(" | ")}`; + }), + ); + return ["## Carry-Forward Context", ...items].join("\n"); +}