feat(sf/prompts): Phase 3 v2 — migrate milestone+slice builders to composeUnitContext
Migrate buildPlanMilestonePrompt, buildValidateMilestonePrompt, buildCompleteMilestonePrompt, buildReplanSlicePrompt, buildResearchSlicePrompt, and renderSlicePrompt (plan-slice + refine-slice) from imperative inlined[] push loops to the v2 composeUnitContext API (manifest-driven, prepend/computed support). Changes: - unit-context-manifest.js: add 7 new ARTIFACT_KEYS (slice-summaries, blocker-summaries, queue, verification-classes, outstanding-items, previous-validation, prior-milestone-summary); update 7 manifests with correct prepend/inline/computed declarations - auto-prompts.js: import composeUnitContext; migrate all 6 builders; remove orphaned old buildValidateMilestonePrompt tail left by partial prior edit - tests: add auto-prompts-phase3.test.mjs with 7 contract tests covering plan-milestone, replan-slice, validate-milestone, and research-slice prompt generation Pre-computation pattern: complex async logic (blocker scan, slice aggregation, verification classes, prior validation) is computed imperatively before composeUnitContext, then returned from resolveArtifact. This preserves parallel execution of other artifacts. buildPlanMilestonePrompt keeps framingBlock imperative: the framing check wraps the composed inlinedContext rather than going inside the composer boundary. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
ca5d869e34
commit
3b83d09692
3 changed files with 728 additions and 343 deletions
|
|
@ -81,7 +81,7 @@ import {
|
|||
getDependencyTaskSummaryPaths,
|
||||
getPriorTaskSummaryPaths,
|
||||
} from "./summary-helpers.js";
|
||||
import { composeInlinedContext } from "./unit-context-composer.js";
|
||||
import { composeInlinedContext, composeUnitContext } from "./unit-context-composer.js";
|
||||
import { getUatType } from "./verdict-parser.js";
|
||||
import {
|
||||
buildCarryForwardSection,
|
||||
|
|
@ -1197,71 +1197,80 @@ export async function buildPlanMilestonePrompt(mid, midTitle, base, level) {
|
|||
const contextRel = relMilestoneFile(base, mid, "CONTEXT");
|
||||
const researchPath = resolveMilestoneFile(base, mid, "RESEARCH");
|
||||
const researchRel = relMilestoneFile(base, mid, "RESEARCH");
|
||||
const inlined = [];
|
||||
// Inject phase handoff anchor from research phase (if available)
|
||||
const researchAnchor = readPhaseAnchor(base, mid, "research-milestone");
|
||||
if (researchAnchor) inlined.push(formatAnchorForPrompt(researchAnchor));
|
||||
inlined.push(await inlineFile(contextPath, contextRel, "Milestone Context"));
|
||||
const researchInline = await inlineFileOptional(
|
||||
researchPath,
|
||||
researchRel,
|
||||
"Milestone Research",
|
||||
);
|
||||
if (researchInline) inlined.push(researchInline);
|
||||
const { inlinePriorMilestoneSummary } = await import("./files.js");
|
||||
const priorSummaryInline = await inlinePriorMilestoneSummary(mid, base);
|
||||
if (priorSummaryInline) inlined.push(priorSummaryInline);
|
||||
if (inlineLevel !== "minimal") {
|
||||
const projectInline = await inlineProjectFromDb(base);
|
||||
if (projectInline) inlined.push(projectInline);
|
||||
const requirementsInline = await inlineRequirementsFromDb(
|
||||
base,
|
||||
mid,
|
||||
undefined,
|
||||
inlineLevel,
|
||||
);
|
||||
if (requirementsInline) inlined.push(requirementsInline);
|
||||
const decisionsInline = await inlineDecisionsFromDb(
|
||||
base,
|
||||
mid,
|
||||
undefined,
|
||||
inlineLevel,
|
||||
);
|
||||
if (decisionsInline) inlined.push(decisionsInline);
|
||||
}
|
||||
const queuePath = resolveSfRootFile(base, "QUEUE");
|
||||
if (existsSync(queuePath)) {
|
||||
const queueInline = await inlineFileSmart(
|
||||
queuePath,
|
||||
relSfRootFile("QUEUE"),
|
||||
"Project Queue",
|
||||
`${mid} ${midTitle}`,
|
||||
);
|
||||
inlined.push(queueInline);
|
||||
}
|
||||
// Scoped + budgeted — see issue #4719
|
||||
const knowledgeInlinePM = await inlineKnowledgeBudgeted(
|
||||
const keywords = extractKeywords(midTitle);
|
||||
const { inlinePriorMilestoneSummary } = await import("./files.js");
|
||||
const { prepend, inline } = await composeUnitContext("plan-milestone", {
|
||||
base,
|
||||
extractKeywords(midTitle),
|
||||
);
|
||||
if (knowledgeInlinePM) inlined.push(knowledgeInlinePM);
|
||||
const graphBlockPM = await inlineGraphSubgraph(base, `${mid} ${midTitle}`, {
|
||||
budget: 3000,
|
||||
resolveArtifact: async (key) => {
|
||||
switch (key) {
|
||||
case "milestone-context":
|
||||
return inlineFile(contextPath, contextRel, "Milestone Context");
|
||||
case "milestone-research":
|
||||
return inlineFileOptional(
|
||||
researchPath,
|
||||
researchRel,
|
||||
"Milestone Research",
|
||||
);
|
||||
case "prior-milestone-summary":
|
||||
return inlinePriorMilestoneSummary(mid, base);
|
||||
case "project":
|
||||
if (inlineLevel === "minimal") return null;
|
||||
return inlineProjectFromDb(base);
|
||||
case "requirements":
|
||||
if (inlineLevel === "minimal") return null;
|
||||
return inlineRequirementsFromDb(base, mid, undefined, inlineLevel);
|
||||
case "decisions":
|
||||
if (inlineLevel === "minimal") return null;
|
||||
return inlineDecisionsFromDb(base, mid, undefined, inlineLevel);
|
||||
case "queue":
|
||||
if (!existsSync(queuePath)) return null;
|
||||
return inlineFileSmart(
|
||||
queuePath,
|
||||
relSfRootFile("QUEUE"),
|
||||
"Project Queue",
|
||||
`${mid} ${midTitle}`,
|
||||
);
|
||||
case "templates": {
|
||||
const tplParts = [inlineTemplate("roadmap", "Roadmap")];
|
||||
if (inlineLevel === "full" || inlineLevel === "standard") {
|
||||
tplParts.push(inlineTemplate("decisions", "Decisions"));
|
||||
tplParts.push(inlineTemplate("plan", "Slice Plan"));
|
||||
tplParts.push(inlineTemplate("task-plan", "Task Plan"));
|
||||
}
|
||||
if (inlineLevel === "full") {
|
||||
tplParts.push(
|
||||
inlineTemplate("secrets-manifest", "Secrets Manifest"),
|
||||
);
|
||||
}
|
||||
return tplParts.join("\n\n---\n\n");
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
"phase-anchor": {
|
||||
build: async (_, b) => {
|
||||
const anchor = readPhaseAnchor(b, mid, "research-milestone");
|
||||
return anchor ? formatAnchorForPrompt(anchor) : null;
|
||||
},
|
||||
inputs: {},
|
||||
},
|
||||
knowledge: {
|
||||
build: async ({ kw }, b) => inlineKnowledgeBudgeted(b, kw),
|
||||
inputs: { kw: keywords },
|
||||
},
|
||||
graph: {
|
||||
build: async ({ query }, b) =>
|
||||
inlineGraphSubgraph(b, query, { budget: 3000 }),
|
||||
inputs: { query: `${mid} ${midTitle}` },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (graphBlockPM) inlined.push(graphBlockPM);
|
||||
inlined.push(inlineTemplate("roadmap", "Roadmap"));
|
||||
if (inlineLevel === "full") {
|
||||
inlined.push(inlineTemplate("decisions", "Decisions"));
|
||||
inlined.push(inlineTemplate("plan", "Slice Plan"));
|
||||
inlined.push(inlineTemplate("task-plan", "Task Plan"));
|
||||
inlined.push(inlineTemplate("secrets-manifest", "Secrets Manifest"));
|
||||
} else if (inlineLevel === "standard") {
|
||||
inlined.push(inlineTemplate("decisions", "Decisions"));
|
||||
inlined.push(inlineTemplate("plan", "Slice Plan"));
|
||||
inlined.push(inlineTemplate("task-plan", "Task Plan"));
|
||||
}
|
||||
const parts = [prepend, inline].filter(Boolean);
|
||||
const inlinedContext = capPreamble(
|
||||
`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`,
|
||||
`## Inlined Context (preloaded — do not re-read these files)\n\n${parts.join("\n\n---\n\n")}`,
|
||||
);
|
||||
// Milestone framing check — surfaces anti-goal violations and vision-alignment
|
||||
// concerns in the planning context. Non-blocking: the agent reads and decides.
|
||||
|
|
@ -1321,62 +1330,68 @@ export async function buildResearchSlicePrompt(
|
|||
const milestoneResearchRel = relMilestoneFile(base, mid, "RESEARCH");
|
||||
const sliceContextPath = resolveSliceFile(base, mid, sid, "CONTEXT");
|
||||
const sliceContextRel = relSliceFile(base, mid, sid, "CONTEXT");
|
||||
const inlined = [];
|
||||
// Use roadmap excerpt instead of full roadmap for context reduction
|
||||
const roadmapExcerptRS = await inlineRoadmapExcerpt(base, mid, sid);
|
||||
if (roadmapExcerptRS) {
|
||||
inlined.push(roadmapExcerptRS);
|
||||
} else {
|
||||
// Fall back to full roadmap if excerpt fails
|
||||
inlined.push(
|
||||
await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap"),
|
||||
);
|
||||
}
|
||||
const contextInline = await inlineFileOptional(
|
||||
contextPath,
|
||||
contextRel,
|
||||
"Milestone Context",
|
||||
);
|
||||
if (contextInline) inlined.push(contextInline);
|
||||
const sliceCtxInline = await inlineFileOptional(
|
||||
sliceContextPath,
|
||||
sliceContextRel,
|
||||
"Slice Context (from discussion)",
|
||||
);
|
||||
if (sliceCtxInline) inlined.push(sliceCtxInline);
|
||||
const researchInline = await inlineFileOptional(
|
||||
milestoneResearchPath,
|
||||
milestoneResearchRel,
|
||||
"Milestone Research",
|
||||
);
|
||||
if (researchInline) inlined.push(researchInline);
|
||||
// Derive scope from slice title for decision filtering (R005)
|
||||
const derivedScope = deriveSliceScope(sTitle);
|
||||
const decisionsInline = await inlineDecisionsFromDb(base, mid, derivedScope);
|
||||
if (decisionsInline) inlined.push(decisionsInline);
|
||||
const requirementsInline = await inlineRequirementsFromDb(base, mid, sid);
|
||||
if (requirementsInline) inlined.push(requirementsInline);
|
||||
// Use scoped knowledge based on slice title keywords
|
||||
const keywords = extractKeywords(sTitle);
|
||||
const knowledgeInlineRS = await inlineKnowledgeScoped(base, keywords);
|
||||
if (knowledgeInlineRS) inlined.push(knowledgeInlineRS);
|
||||
// Knowledge graph: subgraph for this slice (graceful — skipped if no graph.json)
|
||||
const graphBlockRS = await inlineGraphSubgraph(base, `${sid} ${sTitle}`, {
|
||||
budget: 3000,
|
||||
});
|
||||
if (graphBlockRS) inlined.push(graphBlockRS);
|
||||
inlined.push(inlineTemplate("research", "Research"));
|
||||
// depContent stays as a separate template variable (not part of inlinedContext).
|
||||
const depContent = await inlineDependencySummaries(
|
||||
mid,
|
||||
sid,
|
||||
base,
|
||||
resolveSummaryBudgetChars(),
|
||||
);
|
||||
const activeOverrides = await loadActiveOverrides(base);
|
||||
const overridesInline = formatOverridesSection(activeOverrides);
|
||||
if (overridesInline) inlined.unshift(overridesInline);
|
||||
const keywords = extractKeywords(sTitle);
|
||||
const { prepend, inline } = await composeUnitContext("research-slice", {
|
||||
base,
|
||||
resolveArtifact: async (key) => {
|
||||
switch (key) {
|
||||
case "roadmap": {
|
||||
// Excerpt with full-roadmap fallback for context reduction.
|
||||
const excerpt = await inlineRoadmapExcerpt(base, mid, sid);
|
||||
return excerpt ?? inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap");
|
||||
}
|
||||
case "milestone-context":
|
||||
return inlineFileOptional(contextPath, contextRel, "Milestone Context");
|
||||
case "slice-context":
|
||||
return inlineFileOptional(
|
||||
sliceContextPath,
|
||||
sliceContextRel,
|
||||
"Slice Context (from discussion)",
|
||||
);
|
||||
case "milestone-research":
|
||||
return inlineFileOptional(
|
||||
milestoneResearchPath,
|
||||
milestoneResearchRel,
|
||||
"Milestone Research",
|
||||
);
|
||||
case "decisions":
|
||||
// Derive scope from slice title for decision filtering (R005).
|
||||
return inlineDecisionsFromDb(base, mid, deriveSliceScope(sTitle));
|
||||
case "requirements":
|
||||
return inlineRequirementsFromDb(base, mid, sid);
|
||||
case "templates":
|
||||
return inlineTemplate("research", "Research");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
overrides: {
|
||||
build: async (_, b) =>
|
||||
formatOverridesSection(await loadActiveOverrides(b)),
|
||||
inputs: {},
|
||||
},
|
||||
knowledge: {
|
||||
build: async ({ kw }, b) => inlineKnowledgeScoped(b, kw),
|
||||
inputs: { kw: keywords },
|
||||
},
|
||||
graph: {
|
||||
build: async ({ query }, b) =>
|
||||
inlineGraphSubgraph(b, query, { budget: 3000 }),
|
||||
inputs: { query: `${sid} ${sTitle}` },
|
||||
},
|
||||
},
|
||||
});
|
||||
const parts = [prepend, inline].filter(Boolean);
|
||||
const inlinedContext = capPreamble(
|
||||
`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`,
|
||||
`## Inlined Context (preloaded — do not re-read these files)\n\n${parts.join("\n\n---\n\n")}`,
|
||||
);
|
||||
const outputRelPath = relSliceFile(base, mid, sid, "RESEARCH");
|
||||
return loadPrompt("research-slice", {
|
||||
|
|
@ -1408,9 +1423,9 @@ export async function buildResearchSlicePrompt(
|
|||
* requirements, knowledge, graph subgraph, templates, dependency summaries,
|
||||
* overrides). Extracted to prevent drift between the two sites.
|
||||
*
|
||||
* `prependBlocks` are pushed onto the start of the inlined array BEFORE any
|
||||
* shared content, so callers can add unit-specific headers (e.g., the refine
|
||||
* sketch-scope constraint).
|
||||
* `prependBlocks` are inserted between the overrides prepend and the first
|
||||
* inline artifact, so callers can add unit-specific headers (e.g., the refine
|
||||
* sketch-scope constraint or plan-slice pre-exec failure context).
|
||||
*/
|
||||
async function renderSlicePrompt(options) {
|
||||
const {
|
||||
|
|
@ -1431,74 +1446,79 @@ async function renderSlicePrompt(options) {
|
|||
const researchRel = relSliceFile(base, mid, sid, "RESEARCH");
|
||||
const sliceContextPath = resolveSliceFile(base, mid, sid, "CONTEXT");
|
||||
const sliceContextRel = relSliceFile(base, mid, sid, "CONTEXT");
|
||||
const inlined = [...prependBlocks];
|
||||
// Phase handoff anchor from research phase (if available)
|
||||
// Phase handoff anchor from research phase (if available).
|
||||
// Combined with caller prependBlocks to form prefixContent inserted between
|
||||
// the overrides prepend and the inline artifacts.
|
||||
const researchSliceAnchor = readPhaseAnchor(base, mid, "research-slice");
|
||||
if (researchSliceAnchor)
|
||||
inlined.push(formatAnchorForPrompt(researchSliceAnchor));
|
||||
// Roadmap excerpt with full-roadmap fallback
|
||||
const roadmapExcerpt = await inlineRoadmapExcerpt(base, mid, sid);
|
||||
if (roadmapExcerpt) {
|
||||
inlined.push(roadmapExcerpt);
|
||||
} else {
|
||||
inlined.push(
|
||||
await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap"),
|
||||
);
|
||||
}
|
||||
const sliceCtxInline = await inlineFileOptional(
|
||||
sliceContextPath,
|
||||
sliceContextRel,
|
||||
"Slice Context (from discussion)",
|
||||
);
|
||||
if (sliceCtxInline) inlined.push(sliceCtxInline);
|
||||
const researchInline = await inlineFileOptional(
|
||||
researchPath,
|
||||
researchRel,
|
||||
"Slice Research",
|
||||
);
|
||||
if (researchInline) inlined.push(researchInline);
|
||||
if (level !== "minimal") {
|
||||
const derivedScope = deriveSliceScope(sTitle);
|
||||
const decisionsInline = await inlineDecisionsFromDb(
|
||||
base,
|
||||
mid,
|
||||
derivedScope,
|
||||
level,
|
||||
);
|
||||
if (decisionsInline) inlined.push(decisionsInline);
|
||||
const requirementsInline = await inlineRequirementsFromDb(
|
||||
base,
|
||||
mid,
|
||||
sid,
|
||||
level,
|
||||
);
|
||||
if (requirementsInline) inlined.push(requirementsInline);
|
||||
}
|
||||
const knowledgeInline = await inlineKnowledgeScoped(
|
||||
base,
|
||||
extractKeywords(sTitle),
|
||||
);
|
||||
if (knowledgeInline) inlined.push(knowledgeInline);
|
||||
const graphBlock = await inlineGraphSubgraph(base, `${sid} ${sTitle}`, {
|
||||
budget: 3000,
|
||||
});
|
||||
if (graphBlock) inlined.push(graphBlock);
|
||||
inlined.push(inlineTemplate("plan", "Slice Plan"));
|
||||
if (level === "full") {
|
||||
inlined.push(inlineTemplate("task-plan", "Task Plan"));
|
||||
}
|
||||
const prefixBlocks = [...prependBlocks];
|
||||
if (researchSliceAnchor) prefixBlocks.push(formatAnchorForPrompt(researchSliceAnchor));
|
||||
const prefixContent =
|
||||
prefixBlocks.length > 0 ? prefixBlocks.join("\n\n---\n\n") : null;
|
||||
const depContent = await inlineDependencySummaries(
|
||||
mid,
|
||||
sid,
|
||||
base,
|
||||
resolveSummaryBudgetChars(),
|
||||
);
|
||||
const overridesInline = formatOverridesSection(
|
||||
await loadActiveOverrides(base),
|
||||
);
|
||||
if (overridesInline) inlined.unshift(overridesInline);
|
||||
const { prepend, inline } = await composeUnitContext(promptTemplate, {
|
||||
base,
|
||||
resolveArtifact: async (key) => {
|
||||
switch (key) {
|
||||
case "roadmap": {
|
||||
// Excerpt with full-roadmap fallback for context reduction.
|
||||
const excerpt = await inlineRoadmapExcerpt(base, mid, sid);
|
||||
return excerpt ?? inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap");
|
||||
}
|
||||
case "slice-context":
|
||||
return inlineFileOptional(
|
||||
sliceContextPath,
|
||||
sliceContextRel,
|
||||
"Slice Context (from discussion)",
|
||||
);
|
||||
case "slice-research":
|
||||
return inlineFileOptional(researchPath, researchRel, "Slice Research");
|
||||
case "decisions":
|
||||
if (level === "minimal") return null;
|
||||
return inlineDecisionsFromDb(
|
||||
base,
|
||||
mid,
|
||||
deriveSliceScope(sTitle),
|
||||
level,
|
||||
);
|
||||
case "requirements":
|
||||
if (level === "minimal") return null;
|
||||
return inlineRequirementsFromDb(base, mid, sid, level);
|
||||
case "templates": {
|
||||
const tplParts = [inlineTemplate("plan", "Slice Plan")];
|
||||
if (level === "full") tplParts.push(inlineTemplate("task-plan", "Task Plan"));
|
||||
return tplParts.join("\n\n---\n\n");
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
overrides: {
|
||||
build: async (_, b) =>
|
||||
formatOverridesSection(await loadActiveOverrides(b)),
|
||||
inputs: {},
|
||||
},
|
||||
knowledge: {
|
||||
build: async ({ keywords }, b) =>
|
||||
inlineKnowledgeScoped(b, keywords),
|
||||
inputs: { keywords: extractKeywords(sTitle) },
|
||||
},
|
||||
graph: {
|
||||
build: async ({ query }, b) =>
|
||||
inlineGraphSubgraph(b, query, { budget: 3000 }),
|
||||
inputs: { query: `${sid} ${sTitle}` },
|
||||
},
|
||||
},
|
||||
});
|
||||
// Combine: overrides prepend → caller prefix (prependBlocks + phase anchor) → inline artifacts + computed
|
||||
const allParts = [prepend, prefixContent, inline].filter(Boolean);
|
||||
const inlinedContext = capPreamble(
|
||||
`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`,
|
||||
`## Inlined Context (preloaded — do not re-read these files)\n\n${allParts.join("\n\n---\n\n")}`,
|
||||
);
|
||||
const executorContextConstraints = formatExecutorConstraints(
|
||||
sessionContextWindow,
|
||||
|
|
@ -2119,9 +2139,8 @@ export async function buildCompleteMilestonePrompt(mid, midTitle, base, level) {
|
|||
const inlineLevel = level ?? resolveInlineLevel();
|
||||
const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
|
||||
const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
|
||||
const inlined = [];
|
||||
inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap"));
|
||||
// Inline all slice summaries (deduplicated by slice ID)
|
||||
// Pre-compute slice summaries (parallel DB + file reads).
|
||||
// This is a complex aggregation that feeds resolveArtifact("slice-summaries").
|
||||
let sliceIds = [];
|
||||
try {
|
||||
const { isDbAvailable, getMilestoneSlices } = await import("./sf-db.js");
|
||||
|
|
@ -2136,82 +2155,84 @@ export async function buildCompleteMilestonePrompt(mid, midTitle, base, level) {
|
|||
`buildCompleteMilestonePrompt DB lookup failed: ${getErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
// File-based fallback: parse roadmap for slice IDs when DB has no data
|
||||
// File-based fallback: parse roadmap for slice IDs when DB has no data.
|
||||
if (sliceIds.length === 0 && roadmapPath) {
|
||||
const roadmapContent = await loadFile(roadmapPath);
|
||||
if (roadmapContent) {
|
||||
sliceIds = parseRoadmap(roadmapContent).slices.map((s) => s.id);
|
||||
}
|
||||
}
|
||||
// Deduplicate slice IDs while preserving order.
|
||||
const uniqueSliceIds = [...new Set(sliceIds)];
|
||||
// Load all slice summary excerpts in parallel — independent reads.
|
||||
// Parallel slice summary excerpts — independent reads.
|
||||
const sliceSummaryResults = await Promise.all(
|
||||
uniqueSliceIds.map(async (sid) => {
|
||||
const summaryPath = resolveSliceFile(base, mid, sid, "SUMMARY");
|
||||
const summaryRel = relSliceFile(base, mid, sid, "SUMMARY");
|
||||
// Compact excerpt instead of full inline (#4780). Closer Reads the
|
||||
// full file on-demand when synthesizing LEARNINGS narrative.
|
||||
const excerpt = await buildSliceSummaryExcerpt(
|
||||
summaryPath,
|
||||
summaryRel,
|
||||
sid,
|
||||
);
|
||||
return { sid, summaryRel, excerpt };
|
||||
return { summaryRel, excerpt };
|
||||
}),
|
||||
);
|
||||
const summaryRelPaths = [];
|
||||
for (const { summaryRel, excerpt } of sliceSummaryResults) {
|
||||
summaryRelPaths.push(summaryRel);
|
||||
inlined.push(excerpt);
|
||||
const summaryRelPaths = sliceSummaryResults.map((r) => r.summaryRel);
|
||||
const excerptBlocks = sliceSummaryResults.map((r) => r.excerpt);
|
||||
let sliceSummariesBlock = null;
|
||||
if (excerptBlocks.length > 0) {
|
||||
const parts = [...excerptBlocks];
|
||||
if (summaryRelPaths.length > 0) {
|
||||
const pathList = summaryRelPaths.map((p) => `- \`${p}\``).join("\n");
|
||||
parts.push(
|
||||
`### On-demand Slice Summaries\n\nExcerpted above. Read the full file for any slice when the excerpt's section heads don't carry enough narrative for the milestone summary you're drafting:\n\n${pathList}`,
|
||||
);
|
||||
}
|
||||
sliceSummariesBlock = parts.join("\n\n---\n\n");
|
||||
}
|
||||
if (summaryRelPaths.length > 0) {
|
||||
const pathList = summaryRelPaths.map((p) => `- \`${p}\``).join("\n");
|
||||
inlined.push(
|
||||
`### On-demand Slice Summaries\n\nExcerpted above. Read the full file for any slice when the excerpt's section heads don't carry enough narrative for the milestone summary you're drafting:\n\n${pathList}`,
|
||||
);
|
||||
}
|
||||
// Inline root SF files (skip for minimal — completion can read these if needed)
|
||||
if (inlineLevel !== "minimal") {
|
||||
const requirementsInline = await inlineRequirementsFromDb(
|
||||
base,
|
||||
mid,
|
||||
undefined,
|
||||
inlineLevel,
|
||||
);
|
||||
if (requirementsInline) inlined.push(requirementsInline);
|
||||
const decisionsInline = await inlineDecisionsFromDb(
|
||||
base,
|
||||
mid,
|
||||
undefined,
|
||||
inlineLevel,
|
||||
);
|
||||
if (decisionsInline) inlined.push(decisionsInline);
|
||||
const projectInline = await inlineProjectFromDb(base);
|
||||
if (projectInline) inlined.push(projectInline);
|
||||
}
|
||||
// Scoped + budgeted — see issue #4719
|
||||
const knowledgeInlineCM = await inlineKnowledgeBudgeted(
|
||||
base,
|
||||
extractKeywords(midTitle),
|
||||
);
|
||||
if (knowledgeInlineCM) inlined.push(knowledgeInlineCM);
|
||||
const graphBlockCM = await inlineGraphSubgraph(base, `${mid} ${midTitle}`, {
|
||||
budget: 3000,
|
||||
});
|
||||
if (graphBlockCM) inlined.push(graphBlockCM);
|
||||
// Inline milestone context file (milestone-level, not SF root)
|
||||
const contextPath = resolveMilestoneFile(base, mid, "CONTEXT");
|
||||
const contextRel = relMilestoneFile(base, mid, "CONTEXT");
|
||||
const contextInline = await inlineFileOptional(
|
||||
contextPath,
|
||||
contextRel,
|
||||
"Milestone Context",
|
||||
);
|
||||
if (contextInline) inlined.push(contextInline);
|
||||
inlined.push(inlineTemplate("milestone-summary", "Milestone Summary"));
|
||||
const keywords = extractKeywords(midTitle);
|
||||
const { prepend, inline } = await composeUnitContext("complete-milestone", {
|
||||
base,
|
||||
resolveArtifact: async (key) => {
|
||||
switch (key) {
|
||||
case "roadmap":
|
||||
return inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap");
|
||||
case "slice-summaries":
|
||||
return sliceSummariesBlock;
|
||||
case "requirements":
|
||||
if (inlineLevel === "minimal") return null;
|
||||
return inlineRequirementsFromDb(base, mid, undefined, inlineLevel);
|
||||
case "decisions":
|
||||
if (inlineLevel === "minimal") return null;
|
||||
return inlineDecisionsFromDb(base, mid, undefined, inlineLevel);
|
||||
case "project":
|
||||
if (inlineLevel === "minimal") return null;
|
||||
return inlineProjectFromDb(base);
|
||||
case "milestone-context":
|
||||
return inlineFileOptional(contextPath, contextRel, "Milestone Context");
|
||||
case "templates":
|
||||
return inlineTemplate("milestone-summary", "Milestone Summary");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
knowledge: {
|
||||
build: async ({ keywords: kw }, b) =>
|
||||
inlineKnowledgeBudgeted(b, kw),
|
||||
inputs: { keywords },
|
||||
},
|
||||
graph: {
|
||||
build: async ({ query }, b) =>
|
||||
inlineGraphSubgraph(b, query, { budget: 3000 }),
|
||||
inputs: { query: `${mid} ${midTitle}` },
|
||||
},
|
||||
},
|
||||
});
|
||||
const parts = [prepend, inline].filter(Boolean);
|
||||
const inlinedContext = capPreamble(
|
||||
`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`,
|
||||
`## Inlined Context (preloaded — do not re-read these files)\n\n${parts.join("\n\n---\n\n")}`,
|
||||
);
|
||||
const milestoneSummaryPath = join(
|
||||
base,
|
||||
|
|
@ -2248,9 +2269,8 @@ export async function buildValidateMilestonePrompt(mid, midTitle, base, level) {
|
|||
const inlineLevel = level ?? resolveInlineLevel();
|
||||
const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
|
||||
const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
|
||||
const inlined = [];
|
||||
inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap"));
|
||||
// Inline verification classes from planning (if available in DB)
|
||||
// Pre-compute verification classes from DB.
|
||||
let verificationClassesBlock = null;
|
||||
try {
|
||||
const { isDbAvailable, getMilestone } = await import("./sf-db.js");
|
||||
if (isDbAvailable()) {
|
||||
|
|
@ -2270,9 +2290,7 @@ export async function buildValidateMilestonePrompt(mid, midTitle, base, level) {
|
|||
if (milestone.verification_uat)
|
||||
classes.push(`- **UAT:** ${milestone.verification_uat}`);
|
||||
if (classes.length > 0) {
|
||||
inlined.push(
|
||||
`### Verification Classes (from planning)\n\nThese verification tiers were defined during milestone planning. Each non-empty class must be checked for evidence during validation.\n\n${classes.join("\n")}`,
|
||||
);
|
||||
verificationClassesBlock = `### Verification Classes (from planning)\n\nThese verification tiers were defined during milestone planning. Each non-empty class must be checked for evidence during validation.\n\n${classes.join("\n")}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2282,7 +2300,7 @@ export async function buildValidateMilestonePrompt(mid, midTitle, base, level) {
|
|||
`buildValidateMilestonePrompt verification classes lookup failed: ${getErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
// Inline all slice summaries and assessment results
|
||||
// Pre-compute slice summaries, assessments, and outstanding items.
|
||||
let valSliceIds = [];
|
||||
try {
|
||||
const { isDbAvailable, getMilestoneSlices } = await import("./sf-db.js");
|
||||
|
|
@ -2297,17 +2315,16 @@ export async function buildValidateMilestonePrompt(mid, midTitle, base, level) {
|
|||
`buildValidateMilestonePrompt slice IDs lookup failed: ${getErrorMessage(err)}`,
|
||||
);
|
||||
}
|
||||
// File-based fallback: parse roadmap for slice IDs when DB has no data
|
||||
// File-based fallback: parse roadmap for slice IDs when DB has no data.
|
||||
if (valSliceIds.length === 0 && roadmapPath) {
|
||||
const roadmapContent = await loadFile(roadmapPath);
|
||||
if (roadmapContent) {
|
||||
valSliceIds = parseRoadmap(roadmapContent).slices.map((s) => s.id);
|
||||
}
|
||||
}
|
||||
// Single parallel pass per slice: load summary + assessment, derive inline
|
||||
// blocks AND outstanding-items extraction in one read (previously two loops
|
||||
// that each called loadFile on every SUMMARY).
|
||||
const uniqueValSliceIds = [...new Set(valSliceIds)];
|
||||
// Single parallel pass per slice: load summary + assessment, derive inline
|
||||
// blocks AND outstanding-items extraction in one read.
|
||||
const valSliceResults = await Promise.all(
|
||||
uniqueValSliceIds.map(async (sid) => {
|
||||
const summaryPath = resolveSliceFile(base, mid, sid, "SUMMARY");
|
||||
|
|
@ -2321,7 +2338,6 @@ export async function buildValidateMilestonePrompt(mid, midTitle, base, level) {
|
|||
const summaryInline = summaryContent
|
||||
? `### ${sid} Summary\nSource: \`${summaryRel}\`\n\n${summaryContent.trim()}`
|
||||
: `### ${sid} Summary\nSource: \`${summaryRel}\`\n\n_(not found — file does not exist yet)_`;
|
||||
// Derive outstanding items from the same content we just loaded.
|
||||
const outstandingLines = [];
|
||||
if (summaryContent) {
|
||||
try {
|
||||
|
|
@ -2341,86 +2357,87 @@ export async function buildValidateMilestonePrompt(mid, midTitle, base, level) {
|
|||
return { summaryInline, assessmentInline, outstandingLines };
|
||||
}),
|
||||
);
|
||||
// Push inline blocks in order; collect outstanding items across all slices.
|
||||
// Assemble slice-summaries block (summaries + assessments interleaved).
|
||||
const sliceSummariesParts = [];
|
||||
const outstandingItems = [];
|
||||
for (const {
|
||||
summaryInline,
|
||||
assessmentInline,
|
||||
outstandingLines,
|
||||
} of valSliceResults) {
|
||||
inlined.push(summaryInline);
|
||||
if (assessmentInline) inlined.push(assessmentInline);
|
||||
for (const { summaryInline, assessmentInline, outstandingLines } of valSliceResults) {
|
||||
sliceSummariesParts.push(summaryInline);
|
||||
if (assessmentInline) sliceSummariesParts.push(assessmentInline);
|
||||
outstandingItems.push(...outstandingLines);
|
||||
}
|
||||
if (outstandingItems.length > 0) {
|
||||
inlined.push(
|
||||
`### Outstanding Items (aggregated from slice summaries)\n\nThese follow-ups and known limitations were documented during slice completion but have not been resolved.\n\n${outstandingItems.join("\n")}`,
|
||||
);
|
||||
}
|
||||
// Inline existing VALIDATION file if this is a re-validation round
|
||||
const sliceSummariesBlock =
|
||||
sliceSummariesParts.length > 0
|
||||
? sliceSummariesParts.join("\n\n---\n\n")
|
||||
: null;
|
||||
const outstandingItemsBlock =
|
||||
outstandingItems.length > 0
|
||||
? `### Outstanding Items (aggregated from slice summaries)\n\nThese follow-ups and known limitations were documented during slice completion but have not been resolved.\n\n${outstandingItems.join("\n")}`
|
||||
: null;
|
||||
// Pre-compute previous validation for re-validation rounds.
|
||||
const validationPath = resolveMilestoneFile(base, mid, "VALIDATION");
|
||||
const validationRel = relMilestoneFile(base, mid, "VALIDATION");
|
||||
const validationContent = validationPath
|
||||
? await loadFile(validationPath)
|
||||
: null;
|
||||
const validationContent = validationPath ? await loadFile(validationPath) : null;
|
||||
let remediationRound = 0;
|
||||
let previousValidationBlock = null;
|
||||
if (validationContent) {
|
||||
const roundMatch = validationContent.match(/remediation_round:\s*(\d+)/);
|
||||
remediationRound = roundMatch ? parseInt(roundMatch[1], 10) + 1 : 1;
|
||||
inlined.push(
|
||||
`### Previous Validation (re-validation round ${remediationRound})\nSource: \`${validationRel}\`\n\n${validationContent.trim()}`,
|
||||
);
|
||||
previousValidationBlock = `### Previous Validation (re-validation round ${remediationRound})\nSource: \`${validationRel}\`\n\n${validationContent.trim()}`;
|
||||
}
|
||||
// Inline root SF files
|
||||
if (inlineLevel !== "minimal") {
|
||||
const requirementsInline = await inlineRequirementsFromDb(
|
||||
base,
|
||||
mid,
|
||||
undefined,
|
||||
inlineLevel,
|
||||
);
|
||||
if (requirementsInline) inlined.push(requirementsInline);
|
||||
const decisionsInline = await inlineDecisionsFromDb(
|
||||
base,
|
||||
mid,
|
||||
undefined,
|
||||
inlineLevel,
|
||||
);
|
||||
if (decisionsInline) inlined.push(decisionsInline);
|
||||
const projectInline = await inlineProjectFromDb(base);
|
||||
if (projectInline) inlined.push(projectInline);
|
||||
}
|
||||
// Scoped + budgeted — see issue #4719
|
||||
const knowledgeInline = await inlineKnowledgeBudgeted(
|
||||
base,
|
||||
extractKeywords(midTitle),
|
||||
);
|
||||
if (knowledgeInline) inlined.push(knowledgeInline);
|
||||
const graphBlockVM = await inlineGraphSubgraph(base, `${mid} ${midTitle}`, {
|
||||
budget: 3000,
|
||||
});
|
||||
if (graphBlockVM) inlined.push(graphBlockVM);
|
||||
// Inline milestone context file
|
||||
const contextPath = resolveMilestoneFile(base, mid, "CONTEXT");
|
||||
const contextRel = relMilestoneFile(base, mid, "CONTEXT");
|
||||
const contextInline = await inlineFileOptional(
|
||||
contextPath,
|
||||
contextRel,
|
||||
"Milestone Context",
|
||||
);
|
||||
if (contextInline) inlined.push(contextInline);
|
||||
const keywords = extractKeywords(midTitle);
|
||||
const { prepend, inline } = await composeUnitContext("validate-milestone", {
|
||||
base,
|
||||
resolveArtifact: async (key) => {
|
||||
switch (key) {
|
||||
case "roadmap":
|
||||
return inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap");
|
||||
case "verification-classes":
|
||||
return verificationClassesBlock;
|
||||
case "slice-summaries":
|
||||
return sliceSummariesBlock;
|
||||
case "outstanding-items":
|
||||
return outstandingItemsBlock;
|
||||
case "previous-validation":
|
||||
return previousValidationBlock;
|
||||
case "requirements":
|
||||
if (inlineLevel === "minimal") return null;
|
||||
return inlineRequirementsFromDb(base, mid, undefined, inlineLevel);
|
||||
case "decisions":
|
||||
if (inlineLevel === "minimal") return null;
|
||||
return inlineDecisionsFromDb(base, mid, undefined, inlineLevel);
|
||||
case "project":
|
||||
if (inlineLevel === "minimal") return null;
|
||||
return inlineProjectFromDb(base);
|
||||
case "milestone-context":
|
||||
return inlineFileOptional(contextPath, contextRel, "Milestone Context");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
knowledge: {
|
||||
build: async ({ keywords: kw }, b) =>
|
||||
inlineKnowledgeBudgeted(b, kw),
|
||||
inputs: { keywords },
|
||||
},
|
||||
graph: {
|
||||
build: async ({ query }, b) =>
|
||||
inlineGraphSubgraph(b, query, { budget: 3000 }),
|
||||
inputs: { query: `${mid} ${midTitle}` },
|
||||
},
|
||||
},
|
||||
});
|
||||
const parts = [prepend, inline].filter(Boolean);
|
||||
const inlinedContext = capPreamble(
|
||||
`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`,
|
||||
`## Inlined Context (preloaded — do not re-read these files)\n\n${parts.join("\n\n---\n\n")}`,
|
||||
);
|
||||
const validationOutputPath = join(
|
||||
base,
|
||||
`${relMilestonePath(base, mid)}/${mid}-VALIDATION.md`,
|
||||
);
|
||||
const roadmapOutputPath = `${relMilestonePath(base, mid)}/${mid}-ROADMAP.md`;
|
||||
// Every milestone validation turn owns MV01–MV04 unconditionally: the
|
||||
// registry is the source of truth for which gates the validator must
|
||||
// address, and the block below is what the template renders so the
|
||||
// assistant can never accidentally skip one.
|
||||
const mvGates = getGatesForTurn("validate-milestone");
|
||||
const gatesToEvaluate = renderGatesToCloseBlock(mvGates, {
|
||||
pending: new Set(mvGates.map((g) => g.id)),
|
||||
|
|
@ -2451,19 +2468,10 @@ export async function buildReplanSlicePrompt(mid, midTitle, sid, sTitle, base) {
|
|||
const slicePlanRel = relSliceFile(base, mid, sid, "PLAN");
|
||||
const sliceContextPath = resolveSliceFile(base, mid, sid, "CONTEXT");
|
||||
const sliceContextRel = relSliceFile(base, mid, sid, "CONTEXT");
|
||||
const inlined = [];
|
||||
inlined.push(await inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap"));
|
||||
const sliceCtxInline = await inlineFileOptional(
|
||||
sliceContextPath,
|
||||
sliceContextRel,
|
||||
"Slice Context (from discussion)",
|
||||
);
|
||||
if (sliceCtxInline) inlined.push(sliceCtxInline);
|
||||
inlined.push(
|
||||
await inlineFile(slicePlanPath, slicePlanRel, "Current Slice Plan"),
|
||||
);
|
||||
// Find the blocker task summary — the completed task with blocker_discovered: true
|
||||
// Pre-compute blocker task summaries (scan task files for blocker_discovered).
|
||||
// Must run before composeUnitContext since the result feeds resolveArtifact.
|
||||
let blockerTaskId = "";
|
||||
const blockerBlocks = [];
|
||||
const tDir = resolveTasksDir(base, mid, sid);
|
||||
if (tDir) {
|
||||
const summaryFiles = resolveTaskFiles(tDir, "SUMMARY").sort();
|
||||
|
|
@ -2477,20 +2485,47 @@ export async function buildReplanSlicePrompt(mid, midTitle, sid, sTitle, base) {
|
|||
if (summary.frontmatter.blocker_discovered) {
|
||||
blockerTaskId =
|
||||
summary.frontmatter.id || file.replace(/-SUMMARY\.md$/i, "");
|
||||
inlined.push(
|
||||
blockerBlocks.push(
|
||||
`### Blocker Task Summary: ${blockerTaskId}\nSource: \`${relPath}\`\n\n${content.trim()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Inline decisions
|
||||
const decisionsInline = await inlineDecisionsFromDb(base, mid);
|
||||
if (decisionsInline) inlined.push(decisionsInline);
|
||||
const replanActiveOverrides = await loadActiveOverrides(base);
|
||||
const replanOverridesInline = formatOverridesSection(replanActiveOverrides);
|
||||
if (replanOverridesInline) inlined.unshift(replanOverridesInline);
|
||||
const blockerSummariesBlock =
|
||||
blockerBlocks.length > 0 ? blockerBlocks.join("\n\n---\n\n") : null;
|
||||
const { prepend, inline } = await composeUnitContext("replan-slice", {
|
||||
base,
|
||||
resolveArtifact: async (key) => {
|
||||
switch (key) {
|
||||
case "roadmap":
|
||||
return inlineFile(roadmapPath, roadmapRel, "Milestone Roadmap");
|
||||
case "slice-context":
|
||||
return inlineFileOptional(
|
||||
sliceContextPath,
|
||||
sliceContextRel,
|
||||
"Slice Context (from discussion)",
|
||||
);
|
||||
case "slice-plan":
|
||||
return inlineFile(slicePlanPath, slicePlanRel, "Current Slice Plan");
|
||||
case "blocker-summaries":
|
||||
return blockerSummariesBlock;
|
||||
case "decisions":
|
||||
return inlineDecisionsFromDb(base, mid);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
overrides: {
|
||||
build: async (_, b) =>
|
||||
formatOverridesSection(await loadActiveOverrides(b)),
|
||||
inputs: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
const parts = [prepend, inline].filter(Boolean);
|
||||
const inlinedContext = capPreamble(
|
||||
`## Inlined Context (preloaded — do not re-read these files)\n\n${inlined.join("\n\n---\n\n")}`,
|
||||
`## Inlined Context (preloaded — do not re-read these files)\n\n${parts.join("\n\n---\n\n")}`,
|
||||
);
|
||||
const replanPath = join(
|
||||
base,
|
||||
|
|
|
|||
286
src/resources/extensions/sf/tests/auto-prompts-phase3.test.mjs
Normal file
286
src/resources/extensions/sf/tests/auto-prompts-phase3.test.mjs
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
/**
|
||||
* auto-prompts-phase3.test.mjs — Phase 3 v2 builder migration contracts.
|
||||
*
|
||||
* Purpose: prove that the composeUnitContext v2 migration of plan-milestone,
|
||||
* validate-milestone, complete-milestone, replan-slice, and research-slice
|
||||
* produces prompts containing the expected artifact sections and does not
|
||||
* regress the ordering or content of prior implementations.
|
||||
*/
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import {
|
||||
buildPlanMilestonePrompt,
|
||||
buildReplanSlicePrompt,
|
||||
buildResearchSlicePrompt,
|
||||
buildValidateMilestonePrompt,
|
||||
} from "../auto-prompts.js";
|
||||
import {
|
||||
closeDatabase,
|
||||
insertMilestone,
|
||||
insertSlice,
|
||||
insertTask,
|
||||
openDatabase,
|
||||
} from "../sf-db.js";
|
||||
|
||||
let tempDirs = [];
|
||||
|
||||
function makeProject(opts = {}) {
|
||||
const dir = mkdtempSync(join(tmpdir(), "sf-phase3-prompts-"));
|
||||
tempDirs.push(dir);
|
||||
const mid = opts.mid ?? "M910";
|
||||
const sid = opts.sid ?? "S01";
|
||||
mkdirSync(join(dir, ".sf", "milestones", mid, "slices", sid, "tasks"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(
|
||||
join(dir, ".sf", "milestones", mid, `${mid}-ROADMAP.md`),
|
||||
`# ${mid}: Test Milestone\n\n## ${sid}: Test Slice\n`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(dir, ".sf", "milestones", mid, `${mid}-CONTEXT.md`),
|
||||
`# Context\n\nMilestone context for testing.\n`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(dir, ".sf", "milestones", mid, "slices", sid, `${sid}-CONTEXT.md`),
|
||||
`# Slice Context\n\nSlice context for testing.\n`,
|
||||
);
|
||||
writeFileSync(
|
||||
join(dir, ".sf", "milestones", mid, "slices", sid, `${sid}-PLAN.md`),
|
||||
`# ${sid}: Test Slice\n\n## Tasks\n\n- T01: Do the thing\n`,
|
||||
);
|
||||
return { dir, mid, sid };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
closeDatabase();
|
||||
for (const dir of tempDirs) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
tempDirs = [];
|
||||
});
|
||||
|
||||
describe("buildPlanMilestonePrompt v2", () => {
|
||||
test("plan_milestone_prompt_inlines_context_and_templates_at_standard_level", async () => {
|
||||
const { dir, mid } = makeProject({ mid: "M910" });
|
||||
openDatabase(join(dir, ".sf", "sf.db"));
|
||||
insertMilestone({
|
||||
id: mid,
|
||||
title: "Test Milestone",
|
||||
status: "active",
|
||||
planning: { vision: "Test.", successCriteria: [] },
|
||||
});
|
||||
|
||||
const prompt = await buildPlanMilestonePrompt(
|
||||
mid,
|
||||
"Test Milestone",
|
||||
dir,
|
||||
"standard",
|
||||
);
|
||||
|
||||
expect(prompt).toContain("Milestone Context");
|
||||
expect(prompt).toContain("## Inlined Context");
|
||||
// Standard level includes roadmap template
|
||||
expect(prompt).toContain("Roadmap");
|
||||
// Standard level includes plan template
|
||||
expect(prompt).toContain("Slice Plan");
|
||||
});
|
||||
|
||||
test("plan_milestone_prompt_omits_db_artifacts_at_minimal_level", async () => {
|
||||
const { dir, mid } = makeProject({ mid: "M911" });
|
||||
openDatabase(join(dir, ".sf", "sf.db"));
|
||||
insertMilestone({
|
||||
id: mid,
|
||||
title: "Minimal Milestone",
|
||||
status: "active",
|
||||
planning: { vision: "Test.", successCriteria: [] },
|
||||
});
|
||||
|
||||
const prompt = await buildPlanMilestonePrompt(
|
||||
mid,
|
||||
"Minimal Milestone",
|
||||
dir,
|
||||
"minimal",
|
||||
);
|
||||
|
||||
expect(prompt).toContain("## Inlined Context");
|
||||
// Minimal level skips project/requirements/decisions DB reads
|
||||
// Still inlines context file
|
||||
expect(prompt).toContain("Milestone Context");
|
||||
});
|
||||
|
||||
test("plan_milestone_prompt_inlines_queue_file_when_present", async () => {
|
||||
const { dir, mid } = makeProject({ mid: "M912" });
|
||||
openDatabase(join(dir, ".sf", "sf.db"));
|
||||
insertMilestone({
|
||||
id: mid,
|
||||
title: "Queue Milestone",
|
||||
status: "active",
|
||||
planning: { vision: "Test.", successCriteria: [] },
|
||||
});
|
||||
mkdirSync(join(dir, ".sf"), { recursive: true });
|
||||
writeFileSync(join(dir, ".sf", "QUEUE.md"), "M912 — next in queue\n");
|
||||
|
||||
const prompt = await buildPlanMilestonePrompt(
|
||||
mid,
|
||||
"Queue Milestone",
|
||||
dir,
|
||||
"minimal",
|
||||
);
|
||||
|
||||
expect(prompt).toContain("Project Queue");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildReplanSlicePrompt v2", () => {
|
||||
test("replan_slice_prompt_includes_roadmap_and_slice_plan", async () => {
|
||||
const { dir, mid, sid } = makeProject({ mid: "M920", sid: "S01" });
|
||||
openDatabase(join(dir, ".sf", "sf.db"));
|
||||
insertMilestone({
|
||||
id: mid,
|
||||
title: "Replan Milestone",
|
||||
status: "active",
|
||||
planning: { vision: "Test.", successCriteria: [] },
|
||||
});
|
||||
insertSlice({
|
||||
milestoneId: mid,
|
||||
id: sid,
|
||||
title: "Test Slice",
|
||||
status: "active",
|
||||
risk: "low",
|
||||
depends: [],
|
||||
demo: "Done.",
|
||||
sequence: 1,
|
||||
});
|
||||
|
||||
const prompt = await buildReplanSlicePrompt(
|
||||
mid,
|
||||
"Replan Milestone",
|
||||
sid,
|
||||
"Test Slice",
|
||||
dir,
|
||||
);
|
||||
|
||||
expect(prompt).toContain("## Inlined Context");
|
||||
expect(prompt).toContain("Milestone Roadmap");
|
||||
expect(prompt).toContain("Current Slice Plan");
|
||||
});
|
||||
|
||||
test("replan_slice_prompt_includes_blocker_summaries_when_present", async () => {
|
||||
const { dir, mid, sid } = makeProject({ mid: "M921", sid: "S01" });
|
||||
openDatabase(join(dir, ".sf", "sf.db"));
|
||||
insertMilestone({
|
||||
id: mid,
|
||||
title: "Blocker Milestone",
|
||||
status: "active",
|
||||
planning: { vision: "Test.", successCriteria: [] },
|
||||
});
|
||||
insertSlice({
|
||||
milestoneId: mid,
|
||||
id: sid,
|
||||
title: "Blocked Slice",
|
||||
status: "active",
|
||||
risk: "high",
|
||||
depends: [],
|
||||
demo: "Unblocked.",
|
||||
sequence: 1,
|
||||
});
|
||||
// Write a task summary with blocker_discovered: true
|
||||
writeFileSync(
|
||||
join(
|
||||
dir,
|
||||
".sf",
|
||||
"milestones",
|
||||
mid,
|
||||
"slices",
|
||||
sid,
|
||||
"tasks",
|
||||
"T01-SUMMARY.md",
|
||||
),
|
||||
"---\nblocker_discovered: true\nid: T01\n---\n# T01 Summary\n\nBlocked by external API.\n",
|
||||
);
|
||||
|
||||
const prompt = await buildReplanSlicePrompt(
|
||||
mid,
|
||||
"Blocker Milestone",
|
||||
sid,
|
||||
"Blocked Slice",
|
||||
dir,
|
||||
);
|
||||
|
||||
expect(prompt).toContain("Blocker Task Summary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildValidateMilestonePrompt v2", () => {
|
||||
test("validate_milestone_prompt_includes_roadmap_and_gate_block", async () => {
|
||||
const { dir, mid, sid } = makeProject({ mid: "M930", sid: "S01" });
|
||||
openDatabase(join(dir, ".sf", "sf.db"));
|
||||
insertMilestone({
|
||||
id: mid,
|
||||
title: "Validate Milestone",
|
||||
status: "active",
|
||||
planning: { vision: "Test.", successCriteria: [] },
|
||||
});
|
||||
insertSlice({
|
||||
milestoneId: mid,
|
||||
id: sid,
|
||||
title: "Completed Slice",
|
||||
status: "complete",
|
||||
risk: "low",
|
||||
depends: [],
|
||||
demo: "Done.",
|
||||
sequence: 1,
|
||||
});
|
||||
writeFileSync(
|
||||
join(dir, ".sf", "milestones", mid, `${mid}-SUMMARY.md`),
|
||||
"# M930 Summary\n\nMilestone done.\n",
|
||||
);
|
||||
|
||||
const prompt = await buildValidateMilestonePrompt(
|
||||
mid,
|
||||
"Validate Milestone",
|
||||
dir,
|
||||
"minimal",
|
||||
);
|
||||
|
||||
expect(prompt).toContain("## Inlined Context");
|
||||
// Gate block must always be present per the milestone validation contract
|
||||
expect(prompt).toContain("MV0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildResearchSlicePrompt v2", () => {
|
||||
test("research_slice_prompt_includes_roadmap_and_slice_context", async () => {
|
||||
const { dir, mid, sid } = makeProject({ mid: "M940", sid: "S01" });
|
||||
openDatabase(join(dir, ".sf", "sf.db"));
|
||||
insertMilestone({
|
||||
id: mid,
|
||||
title: "Research Milestone",
|
||||
status: "active",
|
||||
planning: { vision: "Test.", successCriteria: [] },
|
||||
});
|
||||
insertSlice({
|
||||
milestoneId: mid,
|
||||
id: sid,
|
||||
title: "Research Slice",
|
||||
status: "active",
|
||||
risk: "low",
|
||||
depends: [],
|
||||
demo: "Research done.",
|
||||
sequence: 1,
|
||||
});
|
||||
|
||||
const prompt = await buildResearchSlicePrompt(
|
||||
mid,
|
||||
"Research Milestone",
|
||||
sid,
|
||||
"Research Slice",
|
||||
dir,
|
||||
);
|
||||
|
||||
expect(prompt).toContain("## Inlined Context");
|
||||
expect(prompt).toContain("Milestone Roadmap");
|
||||
});
|
||||
});
|
||||
|
|
@ -46,6 +46,7 @@ export const ARTIFACT_KEYS = [
|
|||
"slice-research",
|
||||
"slice-plan",
|
||||
"slice-summary",
|
||||
"slice-summaries", // aggregated slice excerpts (complete/validate milestone)
|
||||
"slice-uat",
|
||||
"slice-assessment",
|
||||
// Task-scoped
|
||||
|
|
@ -53,11 +54,19 @@ export const ARTIFACT_KEYS = [
|
|||
"task-summary",
|
||||
"prior-task-summaries",
|
||||
"dependency-summaries",
|
||||
"blocker-summaries", // tasks with blocker_discovered:true (replan-slice)
|
||||
// Project-scoped
|
||||
"requirements",
|
||||
"decisions",
|
||||
"project",
|
||||
"templates",
|
||||
"queue", // .sf/QUEUE file (plan-milestone)
|
||||
// Validation-scoped
|
||||
"verification-classes", // milestone.verification_* from DB (validate-milestone)
|
||||
"outstanding-items", // follow-ups aggregated from slice summaries (validate-milestone)
|
||||
"previous-validation", // prior VALIDATION file for re-validation rounds
|
||||
// History-scoped
|
||||
"prior-milestone-summary", // previous milestone's SUMMARY (plan-milestone)
|
||||
];
|
||||
// ─── Manifests ────────────────────────────────────────────────────────────
|
||||
// Phase 1 policy: every manifest encodes today's behavior. Skills = "all"
|
||||
|
|
@ -154,16 +163,26 @@ export const UNIT_MANIFESTS = {
|
|||
codebaseMap: true,
|
||||
preferences: "active-only",
|
||||
tools: TOOLS_PLANNING,
|
||||
// phase-anchor prepends the research-phase handoff block computed at call time.
|
||||
prepend: ["phase-anchor"],
|
||||
artifacts: {
|
||||
// Matches today's buildPlanMilestonePrompt inlining order.
|
||||
// Templates are level-dependent (resolved imperatively in resolveArtifact).
|
||||
// queue is conditional on .sf/QUEUE existence (null return = skipped).
|
||||
// knowledge/graph are true computed artifacts (async, budgeted).
|
||||
inline: [
|
||||
"milestone-context",
|
||||
"milestone-research",
|
||||
"prior-milestone-summary",
|
||||
"project",
|
||||
"requirements",
|
||||
"decisions",
|
||||
"milestone-research",
|
||||
"queue",
|
||||
"templates",
|
||||
],
|
||||
excerpt: [],
|
||||
onDemand: [],
|
||||
computed: ["knowledge", "graph"],
|
||||
},
|
||||
maxSystemPromptChars: COMMON_BUDGET_LARGE,
|
||||
},
|
||||
|
|
@ -215,16 +234,25 @@ export const UNIT_MANIFESTS = {
|
|||
preferences: "active-only",
|
||||
tools: TOOLS_PLANNING,
|
||||
artifacts: {
|
||||
// Matches today's buildValidateMilestonePrompt inlining order.
|
||||
// verification-classes, slice-summaries, outstanding-items, and
|
||||
// previous-validation are pre-computed blocks returned from
|
||||
// resolveArtifact — null when absent/empty.
|
||||
// knowledge/graph are true computed artifacts (async, budgeted).
|
||||
inline: [
|
||||
"roadmap",
|
||||
"slice-summary",
|
||||
"slice-uat",
|
||||
"verification-classes",
|
||||
"slice-summaries",
|
||||
"outstanding-items",
|
||||
"previous-validation",
|
||||
"requirements",
|
||||
"decisions",
|
||||
"templates",
|
||||
"project",
|
||||
"milestone-context",
|
||||
],
|
||||
excerpt: [],
|
||||
onDemand: [],
|
||||
computed: ["knowledge", "graph"],
|
||||
},
|
||||
maxSystemPromptChars: COMMON_BUDGET_LARGE,
|
||||
},
|
||||
|
|
@ -236,19 +264,22 @@ export const UNIT_MANIFESTS = {
|
|||
preferences: "active-only",
|
||||
tools: TOOLS_PLANNING,
|
||||
artifacts: {
|
||||
// #4780 landed slice-summary as excerpt for this unit; phase 2 of
|
||||
// the architecture will read this manifest as the source of truth
|
||||
// and retire the special-case wiring in auto-prompts.ts.
|
||||
// Matches today's buildCompleteMilestonePrompt inlining order.
|
||||
// slice-summaries is a pre-computed block (parallel DB + file reads)
|
||||
// returned from resolveArtifact. knowledge/graph are true computed
|
||||
// artifacts (async, budgeted).
|
||||
inline: [
|
||||
"roadmap",
|
||||
"milestone-context",
|
||||
"slice-summaries",
|
||||
"requirements",
|
||||
"decisions",
|
||||
"project",
|
||||
"milestone-context",
|
||||
"templates",
|
||||
],
|
||||
excerpt: ["slice-summary"],
|
||||
onDemand: ["slice-summary"],
|
||||
excerpt: [],
|
||||
onDemand: [],
|
||||
computed: ["knowledge", "graph"],
|
||||
},
|
||||
maxSystemPromptChars: COMMON_BUDGET_MEDIUM,
|
||||
},
|
||||
|
|
@ -260,15 +291,24 @@ export const UNIT_MANIFESTS = {
|
|||
codebaseMap: true,
|
||||
preferences: "active-only",
|
||||
tools: TOOLS_PLANNING,
|
||||
// overrides prepends the active-overrides block computed at call time.
|
||||
prepend: ["overrides"],
|
||||
artifacts: {
|
||||
// Matches today's buildResearchSlicePrompt inlining order.
|
||||
// dependency-summaries is a SEPARATE template variable (not inlined
|
||||
// into inlinedContext). knowledge/graph are true computed artifacts.
|
||||
inline: [
|
||||
"roadmap",
|
||||
"milestone-context",
|
||||
"slice-context",
|
||||
"milestone-research",
|
||||
"dependency-summaries",
|
||||
"decisions",
|
||||
"requirements",
|
||||
"templates",
|
||||
],
|
||||
excerpt: [],
|
||||
onDemand: [],
|
||||
computed: ["knowledge", "graph"],
|
||||
},
|
||||
maxSystemPromptChars: COMMON_BUDGET_MEDIUM,
|
||||
},
|
||||
|
|
@ -279,17 +319,27 @@ export const UNIT_MANIFESTS = {
|
|||
codebaseMap: true,
|
||||
preferences: "active-only",
|
||||
tools: TOOLS_PLANNING,
|
||||
// overrides prepends the active-overrides block computed at call time.
|
||||
prepend: ["overrides"],
|
||||
artifacts: {
|
||||
// Matches today's renderSlicePrompt inlining order.
|
||||
// dependency-summaries is a SEPARATE template variable (not inlined).
|
||||
// prependBlocks (soft-scope hint / pre-exec failure) and the
|
||||
// research-slice phase anchor are injected between prepend and inline
|
||||
// by the builder (imperatively). knowledge/graph are true computed.
|
||||
// decisions and requirements are skipped when level === "minimal".
|
||||
// templates is level-dependent (plan always; task-plan if full).
|
||||
inline: [
|
||||
"roadmap",
|
||||
"slice-context",
|
||||
"slice-research",
|
||||
"dependency-summaries",
|
||||
"requirements",
|
||||
"decisions",
|
||||
"requirements",
|
||||
"templates",
|
||||
],
|
||||
excerpt: [],
|
||||
onDemand: [],
|
||||
computed: ["knowledge", "graph"],
|
||||
},
|
||||
maxSystemPromptChars: COMMON_BUDGET_LARGE,
|
||||
},
|
||||
|
|
@ -300,15 +350,23 @@ export const UNIT_MANIFESTS = {
|
|||
codebaseMap: true,
|
||||
preferences: "active-only",
|
||||
tools: TOOLS_PLANNING,
|
||||
// overrides prepends the active-overrides block computed at call time.
|
||||
prepend: ["overrides"],
|
||||
artifacts: {
|
||||
// Same inlining order as plan-slice (shares renderSlicePrompt).
|
||||
// The sketch-scope hard constraint is injected between prepend and
|
||||
// inline by the builder (imperatively, via prependBlocks).
|
||||
inline: [
|
||||
"slice-plan",
|
||||
"roadmap",
|
||||
"slice-context",
|
||||
"slice-research",
|
||||
"dependency-summaries",
|
||||
"decisions",
|
||||
"requirements",
|
||||
"templates",
|
||||
],
|
||||
excerpt: [],
|
||||
onDemand: [],
|
||||
computed: ["knowledge", "graph"],
|
||||
},
|
||||
maxSystemPromptChars: COMMON_BUDGET_MEDIUM,
|
||||
},
|
||||
|
|
@ -319,16 +377,22 @@ export const UNIT_MANIFESTS = {
|
|||
codebaseMap: true,
|
||||
preferences: "active-only",
|
||||
tools: TOOLS_PLANNING,
|
||||
// overrides prepends the active-overrides block computed at call time.
|
||||
prepend: ["overrides"],
|
||||
artifacts: {
|
||||
// Matches today's buildReplanSlicePrompt inlining order.
|
||||
// blocker-summaries is a pre-computed block (task file scan) returned
|
||||
// from resolveArtifact — null when no blocker task is found.
|
||||
inline: [
|
||||
"roadmap",
|
||||
"slice-context",
|
||||
"slice-plan",
|
||||
"slice-research",
|
||||
"dependency-summaries",
|
||||
"prior-task-summaries",
|
||||
"templates",
|
||||
"blocker-summaries",
|
||||
"decisions",
|
||||
],
|
||||
excerpt: [],
|
||||
onDemand: [],
|
||||
computed: [],
|
||||
},
|
||||
maxSystemPromptChars: COMMON_BUDGET_MEDIUM,
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue