sf snapshot: pre-dispatch, uncommitted changes after 30m inactivity

This commit is contained in:
Mikael Hugo 2026-05-15 23:22:58 +02:00
parent e2c3d6542c
commit 6f32f9287a
10 changed files with 652 additions and 30 deletions

View file

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

View file

@ -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}}`

View file

@ -4,7 +4,7 @@ Apply `pm-planning` skill thinking throughout: use Working Backwards to anchor o
**Structured questions available: {{structuredQuestionsAvailable}}**
{{inlinedTemplates}}
{{inlinedContext}}
---

View file

@ -6,7 +6,7 @@ This stage runs ONCE per project, before any milestone-level discussion. It prod
**Structured questions available: {{structuredQuestionsAvailable}}**
{{inlinedTemplates}}
{{inlinedContext}}
---

View file

@ -6,7 +6,7 @@ This stage runs ONCE per project, after `discuss-project` and before any milesto
**Structured questions available: {{structuredQuestionsAvailable}}**
{{inlinedTemplates}}
{{inlinedContext}}
---

View file

@ -4,6 +4,8 @@ Run **project-level domain research** in 4 parallel dimensions. Read `.sf/PROJEC
**Structured questions available: {{structuredQuestionsAvailable}}**
{{inlinedContext}}
---
## Stage Banner

View file

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

View file

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

View file

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

View file

@ -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,
},