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>
This commit is contained in:
Mikael Hugo 2026-05-07 07:23:43 +02:00
parent e99d50fbc1
commit 6e0273573c
2 changed files with 219 additions and 262 deletions

View file

@ -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).

View file

@ -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");
}