From 6f32f9287aedd6ad67bfcc267abe3af14358450f Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Fri, 15 May 2026 23:22:58 +0200 Subject: [PATCH] sf snapshot: pre-dispatch, uncommitted changes after 30m inactivity --- src/resources/extensions/sf/auto-prompts.js | 305 +++++++++++++-- .../extensions/sf/prompts/execute-task.md | 2 + .../sf/prompts/guided-discuss-milestone.md | 2 +- .../sf/prompts/guided-discuss-project.md | 2 +- .../sf/prompts/guided-discuss-requirements.md | 2 +- .../sf/prompts/guided-research-project.md | 2 + .../extensions/sf/prompts/rewrite-docs.md | 2 + .../auto-prompts-m005-migration.test.mjs | 346 ++++++++++++++++++ .../extensions/sf/unit-context-composer.js | 6 +- .../extensions/sf/unit-context-manifest.js | 13 + 10 files changed, 652 insertions(+), 30 deletions(-) create mode 100644 src/resources/extensions/sf/tests/auto-prompts-m005-migration.test.mjs diff --git a/src/resources/extensions/sf/auto-prompts.js b/src/resources/extensions/sf/auto-prompts.js index b6f1b1776..1f6cd4113 100644 --- a/src/resources/extensions/sf/auto-prompts.js +++ b/src/resources/extensions/sf/auto-prompts.js @@ -1115,15 +1115,51 @@ export async function buildWorkflowPreferencesPrompt( * Project-level interview: produces .sf/PROJECT.md. * Fires before any milestone-level work when planning_depth === "deep" * and PROJECT.md is missing. + * + * #4782 phase 3: migrated through composeUnitContext v2. Declared inline + * order: project, templates. Knowledge/graph are computed artifacts injected + * after templates (prepend-less unit; knowledge/graph are not prepended, + * they follow the templates block). */ export async function buildDiscussProjectPrompt( base, structuredQuestionsAvailable = "false", ) { - const inlinedTemplates = inlineTemplate("project", "Project"); + const { inline: composed } = await composeUnitContext("discuss-project", { + base, + resolveArtifact: async (key) => { + switch (key) { + case "project": + return inlineProjectFromDb(base); + case "templates": + return inlineTemplate("project", "Project"); + default: + return null; + } + }, + computed: { + knowledge: { + build: async (_, b) => inlineKnowledgeScoped(b, []), + inputs: {}, + }, + graph: { + build: async (_, b) => inlineGraphSubgraph(b, "project setup", { budget: 3000 }), + inputs: {}, + }, + }, + }); + const parts = []; + if (composed) parts.push(composed); + const knowledgeBlockDP = await inlineKnowledgeScoped(base, []); + if (knowledgeBlockDP) parts.push(knowledgeBlockDP); + const graphBlockDP = await inlineGraphSubgraph(base, "project setup", { budget: 3000 }); + if (graphBlockDP) parts.push(graphBlockDP); + const inlinedContext = capPreamble( + `## Inlined Context (preloaded — do not re-read these files)\n\n${parts.join("\n\n---\n\n")}`, + ); return loadPrompt("guided-discuss-project", { workingDirectory: base, - inlinedTemplates, + inlinedContext, structuredQuestionsAvailable, commitInstruction: "Do not commit planning artifacts — .sf/ is managed externally.", @@ -1135,15 +1171,55 @@ export async function buildDiscussProjectPrompt( * structured R### format. Reads PROJECT.md as authoritative context. * Fires when planning_depth === "deep", PROJECT.md exists, and * REQUIREMENTS.md is missing. + * + * #4782 phase 3: migrated through composeUnitContext v2. Declared inline + * order: project, requirements, templates. Knowledge/graph appended after + * templates (knowledge is full-scope for requirements gathering). */ export async function buildDiscussRequirementsPrompt( base, structuredQuestionsAvailable = "false", ) { - const inlinedTemplates = inlineTemplate("requirements", "Requirements"); + const { inline: composed } = await composeUnitContext("discuss-requirements", { + base, + resolveArtifact: async (key) => { + switch (key) { + case "project": + return inlineProjectFromDb(base); + case "requirements": + return inlineRequirementsFromDb(base); + case "templates": + return inlineTemplate("requirements", "Requirements"); + default: + return null; + } + }, + computed: { + knowledge: { + build: async (_, b) => inlineKnowledgeScoped(b, []), + inputs: {}, + }, + graph: { + build: async (_, b) => + inlineGraphSubgraph(b, "project requirements", { budget: 3000 }), + inputs: {}, + }, + }, + }); + const parts = []; + if (composed) parts.push(composed); + const knowledgeBlockDR = await inlineKnowledgeScoped(base, []); + if (knowledgeBlockDR) parts.push(knowledgeBlockDR); + const graphBlockDR = await inlineGraphSubgraph(base, "project requirements", { + budget: 3000, + }); + if (graphBlockDR) parts.push(graphBlockDR); + const inlinedContext = capPreamble( + `## Inlined Context (preloaded — do not re-read these files)\n\n${parts.join("\n\n---\n\n")}`, + ); return loadPrompt("guided-discuss-requirements", { workingDirectory: base, - inlinedTemplates, + inlinedContext, structuredQuestionsAvailable, commitInstruction: "Do not commit planning artifacts — .sf/ is managed externally.", @@ -1161,13 +1237,57 @@ export async function buildDiscussRequirementsPrompt( * architecture, and pitfalls. Each subagent writes its findings to * .sf/research/. Fires after research-decision marker says "research" and * project research files are missing. Skipped entirely if user picked "skip". + * + * #4782 phase 3: migrated through composeUnitContext v2. Declared inline + * order: project, requirements, decisions, templates. Knowledge/graph + * appended after templates. */ export async function buildResearchProjectPrompt( base, structuredQuestionsAvailable = "false", ) { + const { inline: composed } = await composeUnitContext("research-project", { + base, + resolveArtifact: async (key) => { + switch (key) { + case "project": + return inlineProjectFromDb(base); + case "requirements": + return inlineRequirementsFromDb(base); + case "decisions": + return inlineDecisionsFromDb(base); + case "templates": + return inlineTemplate("research", "Research"); + default: + return null; + } + }, + computed: { + knowledge: { + build: async (_, b) => inlineKnowledgeScoped(b, []), + inputs: {}, + }, + graph: { + build: async (_, b) => + inlineGraphSubgraph(b, "project research", { budget: 3000 }), + inputs: {}, + }, + }, + }); + const parts = []; + if (composed) parts.push(composed); + const knowledgeBlockRP = await inlineKnowledgeScoped(base, []); + if (knowledgeBlockRP) parts.push(knowledgeBlockRP); + const graphBlockRP = await inlineGraphSubgraph(base, "project research", { + budget: 3000, + }); + if (graphBlockRP) parts.push(graphBlockRP); + const inlinedContext = capPreamble( + `## Inlined Context (preloaded — do not re-read these files)\n\n${parts.join("\n\n---\n\n")}`, + ); return loadPrompt("guided-research-project", { workingDirectory: base, + inlinedContext, structuredQuestionsAvailable, }); } @@ -1176,6 +1296,11 @@ export async function buildResearchProjectPrompt( * Loads the guided-discuss-milestone template and inlines the CONTEXT-DRAFT * as a seed when present. The discussion agent interviews the user, writes * a full CONTEXT.md, and the phase transitions to pre-planning automatically. + * + * #4782 phase 3: migrated through composeUnitContext v2. Declared inline + * order: project, requirements, decisions, milestone-context, templates. + * Knowledge/graph are computed artifacts appended after templates. + * The CONTEXT-DRAFT seed is preserved as a post-composer imperative append. */ export async function buildDiscussMilestonePrompt( mid, @@ -1183,11 +1308,57 @@ export async function buildDiscussMilestonePrompt( base, structuredQuestionsAvailable = "false", ) { - const discussTemplates = inlineTemplate("context", "Context"); + const contextPath = resolveMilestoneFile(base, mid, "CONTEXT"); + const contextRel = relMilestoneFile(base, mid, "CONTEXT"); + const { inline: composed } = await composeUnitContext("discuss-milestone", { + base, + resolveArtifact: async (key) => { + switch (key) { + case "project": + return inlineProjectFromDb(base); + case "requirements": + return inlineRequirementsFromDb(base, mid); + case "decisions": + return inlineDecisionsFromDb(base, mid); + case "milestone-context": + return inlineFileOptional( + contextPath, + contextRel, + "Milestone Context", + ); + case "templates": + return inlineTemplate("context", "Context"); + default: + return null; + } + }, + computed: { + knowledge: { + build: async (_, b) => inlineKnowledgeScoped(b, []), + inputs: {}, + }, + graph: { + build: async (_, b) => + inlineGraphSubgraph(b, `${mid} ${midTitle}`, { budget: 3000 }), + inputs: {}, + }, + }, + }); + const parts = []; + if (composed) parts.push(composed); + const knowledgeBlockDM = await inlineKnowledgeScoped(base, []); + if (knowledgeBlockDM) parts.push(knowledgeBlockDM); + const graphBlockDM = await inlineGraphSubgraph(base, `${mid} ${midTitle}`, { + budget: 3000, + }); + if (graphBlockDM) parts.push(graphBlockDM); + const inlinedContext = capPreamble( + `## Inlined Context (preloaded — do not re-read these files)\n\n${parts.join("\n\n---\n\n")}`, + ); const basePrompt = loadPrompt("guided-discuss-milestone", { milestoneId: mid, milestoneTitle: midTitle, - inlinedTemplates: discussTemplates, + inlinedContext, structuredQuestionsAvailable, commitInstruction: "Do not commit planning artifacts — .sf/ is managed externally.", @@ -1856,14 +2027,16 @@ export async function buildExecuteTaskPrompt( const knowledgeAbsPath = resolveSfRootFile(base, "KNOWLEDGE"); const runtimePath = resolveRuntimeFile(base); // Fan out all independent I/O in parallel: task plan, slice plan, continue - // file, runtime, knowledge, graph subgraph, overrides, prior summary paths. + // file, runtime, overrides, prior summary paths. + // #4782 phase 3: knowledge and graph moved to composeUnitContext computed registry + // (manifest declares computed: ["knowledge", "graph"]). The template now includes + // {{inlinedTemplates}} so composed output is visible. Knowledge/graph are no longer + // fetched here — the composer handles them. const [ taskPlanContent, slicePlanContent, continueContent, runtimeContent, - knowledgeInlineET, - graphBlockET, activeOverrides, priorSummaries, ] = await Promise.all([ @@ -1871,15 +2044,6 @@ export async function buildExecuteTaskPrompt( slicePlanPath ? loadFile(slicePlanPath) : Promise.resolve(null), continueFile ? loadFile(continueFile) : Promise.resolve(null), existsSync(runtimePath) ? loadFile(runtimePath) : Promise.resolve(null), - existsSync(knowledgeAbsPath) - ? inlineFileSmart( - knowledgeAbsPath, - relSfRootFile("KNOWLEDGE"), - "Project Knowledge", - `${tTitle} ${sTitle}`, - ) - : Promise.resolve(null), - inlineGraphSubgraph(base, `${tid} ${tTitle}`, { budget: 2000 }), loadActiveOverrides(base), opts.carryForwardPaths ? Promise.resolve(opts.carryForwardPaths) @@ -1943,18 +2107,61 @@ export async function buildExecuteTaskPrompt( base, ); // Only include knowledge if it has content (not a "not found" result) - const knowledgeContent = - knowledgeInlineET && !knowledgeInlineET.includes("not found") - ? knowledgeInlineET - : null; + // #4782 phase 3: knowledge and graph moved to composer computed registry. + // The composer produces inline blocks including the computed knowledge/graph entries. + const { inline: composedInlines } = await composeUnitContext("execute-task", { + base, + resolveArtifact: async (key) => { + switch (key) { + case "task-plan": + return taskPlanContent + ? [ + "## Inlined Task Plan (authoritative local execution contract)", + `Source: \`${taskPlanRelPath}\``, + "", + taskPlanContent.trim(), + ].join("\n") + : [ + "## Inlined Task Plan (authoritative local execution contract)", + `Task plan not found at dispatch time. Read \`${taskPlanRelPath}\` before executing.`, + ].join("\n"); + case "slice-plan": { + return slicePlanContent + ? extractSliceExecutionExcerpt( + slicePlanContent, + relSliceFile(base, mid, sid, "PLAN"), + ) + : null; + } + case "prior-task-summaries": { + if (priorSummaries.length === 0) return null; + const lines = priorSummaries.map((p) => `- \`${p}\``).join("\n"); + return `### Prior Task Summaries\n\n${lines}`; + } + default: + return null; + } + }, + computed: { + knowledge: { + build: async ({ kw }, b) => inlineKnowledgeScoped(b, kw), + inputs: { kw: [tTitle, sTitle] }, + }, + graph: { + build: async ({ query }, b) => + inlineGraphSubgraph(b, query, { budget: 2000 }), + inputs: { query: `${tid} ${tTitle}` }, + }, + }, + }); + // Build inlinedTemplates from composer output + task-summary/decisions templates const inlinedTemplates = inlineLevel === "minimal" ? inlineTemplate("task-summary", "Task Summary") : [ inlineTemplate("task-summary", "Task Summary"), inlineTemplate("decisions", "Decisions"), - ...(knowledgeContent ? [knowledgeContent] : []), - ...(graphBlockET ? [graphBlockET] : []), + ...(composedInlines ? [composedInlines] : []), ].join("\n\n---\n\n"); const taskSummaryPath = join( base, @@ -3216,6 +3423,9 @@ export async function buildRewriteDocsPrompt( ) { const sid = activeSlice?.id; const sTitle = activeSlice?.title ?? ""; + // #4782 phase 3: migrated through composeUnitContext v2. + // The docList accumulation stays imperative — it is a dynamic path enumeration, + // not a static file inline. The composer handles the declared static inlines. const docList = []; if (sid) { const slicePlanPath = resolveSliceFile(base, mid, sid, "PLAN"); @@ -3291,6 +3501,52 @@ export async function buildRewriteDocsPrompt( docList.length > 0 ? docList.join("\n") : "- No active plan documents found."; + + const keywords = extractKeywords(sTitle || midTitle); + const { inline: composed } = await composeUnitContext("rewrite-docs", { + base, + resolveArtifact: async (key) => { + switch (key) { + case "project": + return inlineProjectFromDb(base); + case "requirements": + return inlineRequirementsFromDb(base, mid); + case "decisions": + return inlineDecisionsFromDb(base, mid); + case "templates": + // No rewrite-docs.md in templates/ — the headless rewrite-docs prompt + // template lives in prompts/. Templates are not inlined here. + return null; + default: + return null; + } + }, + computed: { + 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} ${midTitle}` }, + }, + }, + }); + const parts = []; + if (composed) parts.push(composed); + const knowledgeBlockRD = await inlineKnowledgeScoped(base, keywords); + if (knowledgeBlockRD) parts.push(knowledgeBlockRD); + const graphBlockRD = await inlineGraphSubgraph( + base, + `${sid} ${sTitle} ${midTitle}`, + { budget: 3000 }, + ); + if (graphBlockRD) parts.push(graphBlockRD); + const inlinedContext = capPreamble( + `## Inlined Context (preloaded — do not re-read these files)\n\n${parts.join("\n\n---\n\n")}`, + ); + return loadPrompt("rewrite-docs", { milestoneId: mid, milestoneTitle: midTitle, @@ -3299,6 +3555,7 @@ export async function buildRewriteDocsPrompt( overrideContent, documentList, overridesPath: relSfRootFile("OVERRIDES"), + inlinedContext, }); } diff --git a/src/resources/extensions/sf/prompts/execute-task.md b/src/resources/extensions/sf/prompts/execute-task.md index 3439dafcf..6068ba58d 100644 --- a/src/resources/extensions/sf/prompts/execute-task.md +++ b/src/resources/extensions/sf/prompts/execute-task.md @@ -26,6 +26,8 @@ A researcher explored the codebase and a planner decomposed the work — you are {{gatesToClose}} +{{inlinedTemplates}} + ## Backing Source Artifacts - Slice plan: `{{planPath}}` - Task plan source: `{{taskPlanPath}}` diff --git a/src/resources/extensions/sf/prompts/guided-discuss-milestone.md b/src/resources/extensions/sf/prompts/guided-discuss-milestone.md index 64ade5cf2..0735bd7ef 100644 --- a/src/resources/extensions/sf/prompts/guided-discuss-milestone.md +++ b/src/resources/extensions/sf/prompts/guided-discuss-milestone.md @@ -4,7 +4,7 @@ Apply `pm-planning` skill thinking throughout: use Working Backwards to anchor o **Structured questions available: {{structuredQuestionsAvailable}}** -{{inlinedTemplates}} +{{inlinedContext}} --- diff --git a/src/resources/extensions/sf/prompts/guided-discuss-project.md b/src/resources/extensions/sf/prompts/guided-discuss-project.md index 94abc8856..6666a1747 100644 --- a/src/resources/extensions/sf/prompts/guided-discuss-project.md +++ b/src/resources/extensions/sf/prompts/guided-discuss-project.md @@ -6,7 +6,7 @@ This stage runs ONCE per project, before any milestone-level discussion. It prod **Structured questions available: {{structuredQuestionsAvailable}}** -{{inlinedTemplates}} +{{inlinedContext}} --- diff --git a/src/resources/extensions/sf/prompts/guided-discuss-requirements.md b/src/resources/extensions/sf/prompts/guided-discuss-requirements.md index 140b6b77c..3024e775a 100644 --- a/src/resources/extensions/sf/prompts/guided-discuss-requirements.md +++ b/src/resources/extensions/sf/prompts/guided-discuss-requirements.md @@ -6,7 +6,7 @@ This stage runs ONCE per project, after `discuss-project` and before any milesto **Structured questions available: {{structuredQuestionsAvailable}}** -{{inlinedTemplates}} +{{inlinedContext}} --- diff --git a/src/resources/extensions/sf/prompts/guided-research-project.md b/src/resources/extensions/sf/prompts/guided-research-project.md index 9d20f9df9..728d4b2da 100644 --- a/src/resources/extensions/sf/prompts/guided-research-project.md +++ b/src/resources/extensions/sf/prompts/guided-research-project.md @@ -4,6 +4,8 @@ Run **project-level domain research** in 4 parallel dimensions. Read `.sf/PROJEC **Structured questions available: {{structuredQuestionsAvailable}}** +{{inlinedContext}} + --- ## Stage Banner diff --git a/src/resources/extensions/sf/prompts/rewrite-docs.md b/src/resources/extensions/sf/prompts/rewrite-docs.md index 0b969de92..e13ec0ce9 100644 --- a/src/resources/extensions/sf/prompts/rewrite-docs.md +++ b/src/resources/extensions/sf/prompts/rewrite-docs.md @@ -12,6 +12,8 @@ An override was issued by the user that changes a fundamental decision or approa {{documentList}} +{{inlinedContext}} + ## Instructions 1. Read each document listed above diff --git a/src/resources/extensions/sf/tests/auto-prompts-m005-migration.test.mjs b/src/resources/extensions/sf/tests/auto-prompts-m005-migration.test.mjs new file mode 100644 index 000000000..aae889127 --- /dev/null +++ b/src/resources/extensions/sf/tests/auto-prompts-m005-migration.test.mjs @@ -0,0 +1,346 @@ +/** + * auto-prompts-m005-migration.test.mjs — M005 S01/S02: Phase 3 v2 migration contracts. + * + * Purpose: + * 1. Prove that the 5 simple builders migrated in M005 S01 + * (discuss-project, discuss-requirements, research-project, + * discuss-milestone, rewrite-docs) produce prompts containing the + * expected artifact sections via composeUnitContext v2. + * 2. Prove that all 26 declared unit types have manifests in UNIT_MANIFESTS + * with valid inline artifact orderings. + * 3. Prove that prompt-cache-optimizer.js is no longer importable (dead code + * removal verification). + * + * Consumer: CI regression guard for M005 prompt modularization. + */ +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 { + buildDiscussMilestonePrompt, + buildDiscussProjectPrompt, + buildDiscussRequirementsPrompt, + buildResearchProjectPrompt, + buildRewriteDocsPrompt, +} from "../auto-prompts.js"; +import { + closeDatabase, + insertMilestone, + insertSlice, + insertTask, + openDatabase, +} from "../sf-db.js"; +import { + ARTIFACT_KEYS, + KNOWN_UNIT_TYPES, + resolveManifest, + UNIT_MANIFESTS, +} from "../unit-context-manifest.js"; + +let tempDirs = []; + +function makeProject(opts = {}) { + const dir = mkdtempSync(join(tmpdir(), "sf-m005-prompts-")); + tempDirs.push(dir); + const mid = opts.mid ?? "M910"; + const sid = opts.sid ?? "S01"; + const tid = opts.tid ?? "T01"; + 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- ${tid}: Do the thing\n`, + ); + return { dir, mid, sid, tid }; +} + +afterEach(() => { + closeDatabase(); + for (const dir of tempDirs) { + rmSync(dir, { recursive: true, force: true }); + } + tempDirs = []; +}); + +// ─── R007: knowledge and graph are in ARTIFACT_KEYS ─────────────────────── +describe("R007 — knowledge and graph in ARTIFACT_KEYS", () => { + test("knowledge_is_declared_in_artifact_keys", () => { + expect(ARTIFACT_KEYS).toContain("knowledge"); + }); + + test("graph_is_declared_in_artifact_keys", () => { + expect(ARTIFACT_KEYS).toContain("graph"); + }); +}); + +// ─── R009: Builder ordering safety — manifest coverage ───────────────────── +describe("R009 — manifest coverage for all 26 unit types", () => { + test("every_known_unit_type_has_a_manifest", () => { + const missing = KNOWN_UNIT_TYPES.filter( + (type) => resolveManifest(type) === null, + ); + expect(missing).toHaveLength(0); + expect(missing).toEqual([]); + }); + + test("every_unit_type_in_unit_manifests_is_known", () => { + const extra = Object.keys(UNIT_MANIFESTS).filter( + (type) => !KNOWN_UNIT_TYPES.includes(type), + ); + expect(extra).toHaveLength(0); + expect(extra).toEqual([]); + }); + + test("all_manifests_declare_inline_artifacts_array", () => { + const withoutInline = KNOWN_UNIT_TYPES.filter((type) => { + const m = resolveManifest(type); + return !m?.artifacts?.inline || !Array.isArray(m.artifacts.inline); + }); + expect(withoutInline).toHaveLength(0); + expect(withoutInline).toEqual([]); + }); + + test("inline_artifacts_do_not_contain_undeclared_keys", () => { + // Every artifact key referenced in any manifest's inline array must be + // a registered ARTIFACT_KEY. + const undeclared = []; + for (const type of KNOWN_UNIT_TYPES) { + const m = resolveManifest(type); + if (!m?.artifacts?.inline) continue; + for (const key of m.artifacts.inline) { + if (!ARTIFACT_KEYS.includes(key)) { + undeclared.push({ type, key }); + } + } + } + expect(undeclared).toHaveLength(0); + expect(undeclared).toEqual([]); + }); + + test("inline_artifacts_declare_computed_keys_only_when_registered", () => { + // For each manifest that declares computed entries, verify those keys + // are registered in ARTIFACT_KEYS. + const issues = []; + for (const type of KNOWN_UNIT_TYPES) { + const m = resolveManifest(type); + if (!m?.artifacts?.computed) continue; + for (const key of m.artifacts.computed) { + if (!ARTIFACT_KEYS.includes(key)) { + issues.push({ type, key }); + } + } + } + expect(issues).toHaveLength(0); + expect(issues).toEqual([]); + }); +}); + +// ─── S01 Simple Builder Migration Tests ────────────────────────────────── +describe("buildDiscussProjectPrompt v2", () => { + test("discuss_project_prompt_uses_composer_and_contains_inlined_context", async () => { + const { dir } = makeProject({ mid: "M901" }); + openDatabase(join(dir, ".sf", "sf.db")); + insertMilestone({ + id: "M901", + title: "Discuss Project", + status: "active", + planning: { vision: "Test.", successCriteria: [] }, + }); + + const prompt = await buildDiscussProjectPrompt(dir, "false"); + + expect(prompt).toContain("## Inlined Context"); + // The composer wires project through inlineProjectFromDb + expect(prompt).toContain("Project"); + }); + + test("discuss_project_prompt_passes_structured_questions_variable", async () => { + const { dir } = makeProject({ mid: "M902" }); + openDatabase(join(dir, ".sf", "sf.db")); + insertMilestone({ + id: "M902", + title: "Discuss Project", + status: "active", + planning: { vision: "Test.", successCriteria: [] }, + }); + + const prompt = await buildDiscussProjectPrompt(dir, "false"); + + // Template substitution: structuredQuestionsAvailable is substituted with "false" + expect(prompt).toContain("**Structured questions available: false**"); + }); +}); + +describe("buildDiscussRequirementsPrompt v2", () => { + test("discuss_requirements_prompt_uses_composer_and_contains_inlined_context", async () => { + const { dir } = makeProject({ mid: "M903" }); + openDatabase(join(dir, ".sf", "sf.db")); + insertMilestone({ + id: "M903", + title: "Discuss Requirements", + status: "active", + planning: { vision: "Test.", successCriteria: [] }, + }); + + const prompt = await buildDiscussRequirementsPrompt(dir, "false"); + + expect(prompt).toContain("## Inlined Context"); + // Composer wires requirements through inlineRequirementsFromDb + expect(prompt).toContain("Requirements"); + }); +}); + +describe("buildResearchProjectPrompt v2", () => { + test("research_project_prompt_uses_composer_and_contains_inlined_context", async () => { + const { dir } = makeProject({ mid: "M904" }); + openDatabase(join(dir, ".sf", "sf.db")); + insertMilestone({ + id: "M904", + title: "Research Project", + status: "active", + planning: { vision: "Test.", successCriteria: [] }, + }); + + const prompt = await buildResearchProjectPrompt(dir, "false"); + + expect(prompt).toContain("## Inlined Context"); + // Composer wires project, requirements, decisions through DB helpers + expect(prompt).toContain("Project"); + }); +}); + +describe("buildDiscussMilestonePrompt v2", () => { + test("discuss_milestone_prompt_uses_composer_and_contains_inlined_context", async () => { + const { dir, mid } = makeProject({ mid: "M905" }); + openDatabase(join(dir, ".sf", "sf.db")); + insertMilestone({ + id: mid, + title: "Discuss Milestone", + status: "active", + planning: { vision: "Test.", successCriteria: [] }, + }); + + const prompt = await buildDiscussMilestonePrompt( + mid, + "Discuss Milestone", + dir, + "false", + ); + + expect(prompt).toContain("## Inlined Context"); + // Composer wires milestone-context + expect(prompt).toContain("Milestone Context"); + }); + + test("discuss_milestone_prompt_appends_draft_seed_when_present", async () => { + const { dir, mid } = makeProject({ mid: "M906" }); + openDatabase(join(dir, ".sf", "sf.db")); + insertMilestone({ + id: mid, + title: "Discuss Milestone", + status: "active", + planning: { vision: "Test.", successCriteria: [] }, + }); + // Write a CONTEXT-DRAFT + writeFileSync( + join(dir, ".sf", "milestones", mid, `${mid}-CONTEXT-DRAFT.md`), + "# Draft Context\n\nThis is a draft.\n", + ); + + const prompt = await buildDiscussMilestonePrompt( + mid, + "Discuss Milestone", + dir, + "false", + ); + + expect(prompt).toContain("Prior Discussion (Draft Seed)"); + }); +}); + +describe("buildRewriteDocsPrompt v2", () => { + test("rewrite_docs_prompt_uses_composer_and_includes_inlined_context_header", async () => { + const { dir, mid, sid } = makeProject({ mid: "M907", sid: "S01" }); + openDatabase(join(dir, ".sf", "sf.db")); + insertMilestone({ + id: mid, + title: "Rewrite Docs", + status: "active", + planning: { vision: "Test.", successCriteria: [] }, + }); + insertSlice({ + milestoneId: mid, + id: sid, + title: "Docs Slice", + status: "active", + risk: "low", + depends: [], + demo: "Docs done.", + sequence: 1, + }); + + const prompt = await buildRewriteDocsPrompt( + mid, + "Rewrite Docs", + { id: sid, title: "Docs Slice" }, + dir, + [], + ); + + // Composer produces the inlined context section header + expect(prompt).toContain("## Inlined Context"); + // The composeUnitContext produces the section even when resolvers return null + // (empty project/requirements/decisions in test fixture — fine for structural test) + }); + + test("rewrite_docs_prompt_includes_document_list_with_slice_plan_path", async () => { + const { dir, mid, sid } = makeProject({ mid: "M908", sid: "S01" }); + openDatabase(join(dir, ".sf", "sf.db")); + insertMilestone({ + id: mid, + title: "Rewrite Docs", + status: "active", + planning: { vision: "Test.", successCriteria: [] }, + }); + insertSlice({ + milestoneId: mid, + id: sid, + title: "Docs Slice", + status: "active", + risk: "low", + depends: [], + demo: "Docs done.", + sequence: 1, + }); + + const prompt = await buildRewriteDocsPrompt( + mid, + "Rewrite Docs", + { id: sid, title: "Docs Slice" }, + dir, + [], + ); + + // The docList includes the slice plan path (rendered from {{documentList}}) + expect(prompt).toContain("S01-PLAN.md"); + // Document list section header is present + expect(prompt).toContain("Documents to Review and Update"); + }); +}); + +// ─── R010 verification deferred to S02 ───────────────────────────────────── +// prompt-cache-optimizer.js removal is handled in M005/S02. diff --git a/src/resources/extensions/sf/unit-context-composer.js b/src/resources/extensions/sf/unit-context-composer.js index 4ea80975a..47af675be 100644 --- a/src/resources/extensions/sf/unit-context-composer.js +++ b/src/resources/extensions/sf/unit-context-composer.js @@ -103,8 +103,8 @@ export async function composeUnitContext(unitType, opts) { runComputed(manifest.artifacts.computed ?? [], opts), ]); const inlineBlocks = [ - ...inlineResolved.filter((b) => !!b && b.length > 0), - ...excerptResolved.filter((b) => !!b && b.length > 0), + ...inlineResolved.filter((b) => b != null && b.length > 0), + ...excerptResolved.filter((b) => b != null && b.length > 0), ...computedBlocks, ]; return { @@ -128,5 +128,5 @@ async function runComputed(ids, opts) { return entry.build(entry.inputs, opts.base); }), ); - return results.filter((b) => !!b && b.length > 0); + return results.filter((b) => b != null && b.length > 0); } diff --git a/src/resources/extensions/sf/unit-context-manifest.js b/src/resources/extensions/sf/unit-context-manifest.js index 02d6ea46d..ea2ca2cdc 100644 --- a/src/resources/extensions/sf/unit-context-manifest.js +++ b/src/resources/extensions/sf/unit-context-manifest.js @@ -69,6 +69,9 @@ export const ARTIFACT_KEYS = [ "previous-validation", // prior VALIDATION file for re-validation rounds // History-scoped "prior-milestone-summary", // previous milestone's SUMMARY (plan-milestone) + // Computed (async, budgeted) — these are not file paths but derived from DB/memory/graph + "knowledge", // ranked knowledge entries scoped by keywords (knowledge-injector + DB memories) + "graph", // code-graph subgraph scoped by milestone/slice/task identifiers ]; // ─── Manifests ──────────────────────────────────────────────────────────── // Phase 1 policy: every manifest encodes today's behavior. Skills = "all" @@ -217,6 +220,9 @@ export const UNIT_MANIFESTS = { preferences: "active-only", tools: TOOLS_PLANNING, artifacts: { + // #4782 phase 3: Q1 decision — added computed entries to match + // the pattern of similar discuss-type builders. Knowledge/graph + // are appended after templates (not prepended). inline: [ "project", "requirements", @@ -226,6 +232,7 @@ export const UNIT_MANIFESTS = { ], excerpt: [], onDemand: [], + computed: ["knowledge", "graph"], }, maxSystemPromptChars: COMMON_BUDGET_MEDIUM, }, @@ -520,9 +527,15 @@ export const UNIT_MANIFESTS = { preferences: "active-only", tools: TOOLS_DOCS, artifacts: { + // #4782 phase 3: added computed entries for knowledge/graph. + // Static inline: project, requirements, decisions, templates. + // The document-list path accumulation (from the existing imperative + // builder) is preserved as the primary onDemand path list in + // the builder itself; the composer handles the structured inlines. inline: ["project", "requirements", "decisions", "templates"], excerpt: [], onDemand: [], + computed: ["knowledge", "graph"], }, maxSystemPromptChars: COMMON_BUDGET_MEDIUM, },