/**
 * Autonomous mode Prompt Builders — construct dispatch prompts for each unit type.
 *
 * Pure async functions that load templates and inline file content. No module-level
 * state, no globals — every dependency is passed as a parameter or imported as a
 * utility.
 */
import { existsSync } from "node:fs";
import { basename, join } from "node:path";
import { getLoadedSkills } from "@singularity-forge/coding-agent";
import { getAutoSession } from "./auto/session.js";
import { buildExtractionStepsBlock } from "./commands-extract-learnings.js";
import {
	computeBudgets,
	resolveExecutorContextWindow,
	truncateAtSectionBoundary,
} from "./context-budget.js";
import { getErrorMessage } from "./error-utils.js";
import {
	formatOverridesSection,
	loadActiveOverrides,
	loadFile,
	parseSummary,
	parseTaskPlanFile,
} from "./files.js";
import { assertGateCoverage, getGatesForTurn } from "./gate-registry.js";
import { inlineGraphSubgraph } from "./graph-context.js";
import {
	inlineFile,
	inlineFileOptional,
	inlineFileSmart,
} from "./io-helpers.js";
import { injectKnowledgeIntPrompt } from "./knowledge-injector.js";
import {
	formatMemoriesForPrompt,
	getActiveMemoriesRanked,
	getRelevantMemoriesRanked,
} from "./memory-store.js";
import { parseRoadmap } from "./parsers.js";
import {
	relMilestoneFile,
	relMilestonePath,
	relSfRootFile,
	relSliceFile,
	relSlicePath,
	resolveMilestoneFile,
	resolveRuntimeFile,
	resolveSfRootFile,
	resolveSliceFile,
	resolveSlicePath,
	resolveTaskFile,
	resolveTaskFiles,
	resolveTasksDir,
} from "./paths.js";
import { formatAnchorForPrompt, readPhaseAnchor } from "./phase-anchor.js";
import {
	loadEffectiveSFPreferences,
	resolveAllSkillReferences,
	resolveInlineLevel,
	resolveSkillDiscoveryMode,
} from "./preferences.js";
import { inlineTemplate, loadPrompt } from "./prompt-loader.js";
import {
	getOpenIntentChapters,
	getPendingGatesForTurn,
	getSliceTasks,
	isDbAvailable,
} from "./sf-db.js";
import { warnIfManifestHasMissingSkills } from "./skill-manifest.js";
import { loadSkills } from "./skills/index.js";
import {
	formatDecisionsCompact,
	formatRequirementsCompact,
} from "./structured-data-formatter.js";
import {
	buildSliceSummaryExcerpt,
	extractSliceExecutionExcerpt,
	getDependencyTaskSummaryPaths,
	getPriorTaskSummaryPaths,
} from "./summary-helpers.js";
import { composeUnitContext } from "./unit-context-composer.js";
import { getUatType } from "./verdict-parser.js";
import {
	buildCarryForwardSection,
	buildResumeSection,
} from "./workflow-helpers.js";
import { logWarning } from "./workflow-logger.js";

// ─── Preamble Cap ─────────────────────────────────────────────────────────────
/**
 * Historical static ceiling for the preamble cap. Kept as an upper bound even
 * after context-window-aware sizing so large-window users don't suddenly see
 * 10× looser caps than before. Small-window users get a tighter cap derived
 * from their configured executor window.
 */
const MAX_PREAMBLE_CHARS = 30_000;

// ─── Knowledge Injection Helper ────────────────────────────────────────────────
/**
 * Inject relevant knowledge from KNOWLEDGE.md into a prompt context.
 * Gracefully degrades if knowledge base is unavailable.
 */
async function getKnowledgeInjection(basePath, taskContext = {}) {
	try {
		return injectKnowledgeIntPrompt(basePath, taskContext, {
			minConfidence: 0.7,
			minSimilarity: 0.5,
		});
	} catch {
		// Gracefully degrade if knowledge injection fails
		return "(knowledge unavailable)";
	}
}

function formatTaskLedgerFiles(task) {
	const files = [...(task.key_files ?? []), ...(task.files ?? [])]
		.map((entry) => String(entry).trim())
		.filter(Boolean);
	const unique = [...new Set(files)].slice(0, 4);
	return unique.length > 0 ? unique.join(", ") : "(none recorded)";
}

function escapeMarkdownTableCell(value) {
	return String(value ?? "")
		.replace(/\|/g, "\\|")
		.replace(/\n/g, " ");
}

function buildCompleteSliceControlBlock(mid, sid, base) {
	try {
		if (!isDbAvailable()) return "";
		const tasks = getSliceTasks(mid, sid);
		if (tasks.length === 0) return "";
		const sliceRel = relSlicePath(base, mid, sid);
		const rows = tasks.map((task) => {
			const summaryPath = `${sliceRel}/tasks/${task.id}-SUMMARY.md`;
			const status = task.status || "pending";
			const verification =
				task.verification_status ||
				task.verification_result ||
				"(not recorded)";
			const oneLiner = task.one_liner || task.title || "(untitled)";
			return `| ${escapeMarkdownTableCell(task.id)} | ${escapeMarkdownTableCell(status)} | ${escapeMarkdownTableCell(summaryPath)} | ${escapeMarkdownTableCell(oneLiner)} | ${escapeMarkdownTableCell(verification)} | ${escapeMarkdownTableCell(formatTaskLedgerFiles(task))} |`;
		});
		return [
			"## Slice Closeout Control",
			"",
			"Use this DB task ledger as the authoritative closeout index. Do not rediscover task state from the roadmap or by scanning unrelated milestone directories.",
			"",
			"| Task | Status | Summary | Delivered / purpose | Verification | Key files |",
			"|---|---|---|---|---|---|",
			...rows,
			"",
			"If every task row is `done`, `complete`, or `skipped`, verify the slice-level contract once and call `complete_slice`. Do not reopen planning, do not re-run completed task work, and do not assume a missing roadmap checkbox means the tasks are incomplete.",
			"If any task row is still pending or blocked, stop and report the exact task IDs instead of synthesizing new work.",
		].join("\n");
	} catch (err) {
		logWarning(
			"prompt",
			`complete-slice task ledger failed: ${getErrorMessage(err)}`,
		);
		return "";
	}
}

// Module-scope budget cache: `loadEffectiveSFPreferences` does existsSync +
// readFileSync on every call, which is expensive when `resolvePromptBudgets`
// is called multiple times per prompt build (capPreamble + resolveSummaryBudgetChars).
// A 1-second TTL coalesces all calls within a single dispatch tick without
// holding stale values across tick boundaries. Preferences change on human
// timescales, not sub-second timescales.
let _budgetCache = null;
/**
 * Resolve prompt budgets from the configured executor context window.
 *
 * The prompt builders here don't have access to the runtime model registry
 * (they're called from many non-ctx sites), so `resolveExecutorContextWindow`
 * is fed the user-configurable `context_window_override` preference as the
 * `sessionContextWindow` fallback. That preference exists specifically to
 * cover small-window local models (e.g. 32K lemonade/llama.cpp servers) whose
 * n_ctx is not discoverable through the model registry. Issue #4435.
 */
function resolvePromptBudgets() {
	const now = Date.now();
	if (_budgetCache && now < _budgetCache.expiresAt) return _budgetCache.value;
	try {
		const prefs = loadEffectiveSFPreferences();
		const sessionWindow = prefs?.preferences.context_window_override;
		const windowTokens = resolveExecutorContextWindow(
			undefined,
			prefs?.preferences,
			sessionWindow,
		);
		const value = computeBudgets(windowTokens);
		_budgetCache = { value, expiresAt: now + 1_000 };
		return value;
	} catch (e) {
		logWarning("prompt", `resolvePromptBudgets failed: ${e.message}`);
		const value = computeBudgets(200_000);
		_budgetCache = { value, expiresAt: now + 1_000 };
		return value;
	}
}
/**
 * Character budget for dependency/prior slice summaries injected into dispatch
 * prompts. Scales with the executor's configured context window (issue #4435).
 */
function resolveSummaryBudgetChars() {
	return resolvePromptBudgets().summaryBudgetChars;
}
function capPreamble(preamble) {
	// Cap inlined context at min(historical 30K ceiling, scaled inline budget).
	// The ceiling preserves pre-fix behavior for large-window users; the scaled
	// budget tightens the cap for small-window users whose true safe limit is
	// below 30K. `computeBudgets` allocates 40% of total chars to inline context.
	const budget = Math.min(
		MAX_PREAMBLE_CHARS,
		resolvePromptBudgets().inlineContextBudgetChars,
	);
	if (preamble.length <= budget) return preamble;
	return truncateAtSectionBoundary(preamble, budget).content;
}
// ─── Executor Constraints ─────────────────────────────────────────────────────
/**
 * Format executor context constraints for injection into the plan-slice prompt.
 * Uses the budget engine to compute task count ranges and inline context budgets
 * based on the configured executor model's context window.
 */
function formatExecutorConstraints(sessionContextWindow, modelRegistry) {
	let windowTokens;
	try {
		const prefs = loadEffectiveSFPreferences();
		windowTokens = resolveExecutorContextWindow(
			modelRegistry,
			prefs?.preferences,
			sessionContextWindow,
		);
	} catch (e) {
		logWarning("prompt", `resolveExecutorContextWindow failed: ${e.message}`);
		// Delegate to the budget engine without prefs (the path that just threw)
		// so DEFAULT_CONTEXT_WINDOW stays the single source of truth.
		windowTokens = resolveExecutorContextWindow(
			undefined,
			undefined,
			sessionContextWindow,
		);
	}
	const budgets = computeBudgets(windowTokens);
	const { min, max } = budgets.taskCountRange;
	const execWindowK = Math.round(windowTokens / 1000);
	const perTaskBudgetK = Math.round(budgets.inlineContextBudgetChars / 1000);
	return [
		`## Executor Context Constraints`,
		``,
		`The agent that executes each task has a **${execWindowK}K token** context window.`,
		`- Recommended task count for this slice: **${min}–${max} tasks**`,
		`- Each task gets ~${perTaskBudgetK}K chars of inline context (plans, code, decisions)`,
		`- Keep individual tasks completable within a single context window — if a task needs more context than fits, split it`,
	].join("\n");
}
/**
 * Returns a markdown bullet list of known context file paths for the given
 * milestone (and optionally slice). Falls back to a generic tool-agnostic
 * instruction when no SF artifacts are found.
 *
 * @param base - Absolute path to the project root.
 * @param mid  - Milestone ID (e.g. `"M001"`).
 * @param sid  - Optional slice ID (e.g. `"S01"`). When provided, the slice
 *   RESEARCH file is preferred over the milestone-level one.
 * @returns Markdown string of file path bullets, or a fallback instruction.
 */
export function buildSourceFilePaths(base, mid, sid) {
	const paths = [];
	const projectPath = resolveSfRootFile(base, "PROJECT");
	if (existsSync(projectPath)) {
		paths.push(`- **Project**: \`${relSfRootFile("PROJECT")}\``);
	}
	const requirementsPath = resolveSfRootFile(base, "REQUIREMENTS");
	if (existsSync(requirementsPath)) {
		paths.push(`- **Requirements**: \`${relSfRootFile("REQUIREMENTS")}\``);
	}
	const decisionsPath = resolveSfRootFile(base, "DECISIONS");
	if (existsSync(decisionsPath)) {
		paths.push(`- **Decisions**: \`${relSfRootFile("DECISIONS")}\``);
	}
	const queuePath = resolveSfRootFile(base, "QUEUE");
	if (existsSync(queuePath)) {
		paths.push(`- **Queue**: \`${relSfRootFile("QUEUE")}\``);
	}
	const contextPath = resolveMilestoneFile(base, mid, "CONTEXT");
	if (contextPath) {
		paths.push(
			`- **Milestone Context**: \`${relMilestoneFile(base, mid, "CONTEXT")}\``,
		);
	}
	const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
	if (roadmapPath) {
		paths.push(`- **Roadmap**: \`${relMilestoneFile(base, mid, "ROADMAP")}\``);
	}
	if (sid) {
		const researchPath = resolveSliceFile(base, mid, sid, "RESEARCH");
		if (researchPath) {
			paths.push(
				`- **Slice Research**: \`${relSliceFile(base, mid, sid, "RESEARCH")}\``,
			);
		}
	} else {
		const researchPath = resolveMilestoneFile(base, mid, "RESEARCH");
		if (researchPath) {
			paths.push(
				`- **Milestone Research**: \`${relMilestoneFile(base, mid, "RESEARCH")}\``,
			);
		}
	}
	return paths.length > 0
		? paths.join("\n")
		: "- Use the Grep/Glob/Read tools to identify the relevant source files before planning.";
}
// ─── Inline Helpers ───────────────────────────────────────────────────────
// Re-exported from io-helpers.js:
// - inlineFile, inlineFileOptional, inlineFileSmart
/**
 * Compact slice-summary excerpt for milestone-level closers (#4780).
 *
 * Emits the frontmatter fields + short body section heads rather than the
 * full SUMMARY.md body, and keeps the source path in the header so the
 * closer agent can Read the full file on demand when drafting LEARNINGS.
 *
 * Scope: designed for `buildCompleteMilestonePrompt`, which previously
 * inlined the full SUMMARY per slice and routinely paid ~300–500K tokens
 * per close when the narrative was never synthesized. Not used by
 * `buildValidateMilestonePrompt` yet — validate needs fuller verification
 * evidence; follow-up PR can extend or parameterize.
 *
 * If parsing fails (unrecognizable frontmatter, missing id, etc.) the
 * function falls back to `inlineFile` so the closer loses no information.
 */
// Imported from summary-helpers.js:
// - buildSliceSummaryExcerpt, getPriorTaskSummaryPaths
// - getDependencyTaskSummaryPaths, isSummaryCleanForSkip
// - extractSliceExecutionExcerpt
/**
 * Load and inline dependency slice summaries (full content, not just paths).
 */
async function inlineDependencySummaries(mid, sid, base, budgetChars) {
	// DB primary path — get slice depends directly
	let depends = null;
	try {
		const { isDbAvailable, getSlice } = await import("./sf-db.js");
		if (isDbAvailable()) {
			const slice = getSlice(mid, sid);
			if (slice) {
				if (slice.depends.length === 0) return "- (no dependencies)";
				depends = slice.depends;
			}
			// If slice not found in DB, fall through to file-based parsing
		}
	} catch (err) {
		logWarning(
			"prompt",
			`inlineDependencySummaries DB lookup failed: ${getErrorMessage(err)}`,
		);
	}
	// If DB didn't provide depends, fall back to roadmap parsing
	if (!depends) {
		const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
		if (roadmapPath) {
			const roadmapContent = await loadFile(roadmapPath);
			if (roadmapContent) {
				const parsed = parseRoadmap(roadmapContent);
				const slice = parsed.slices.find((s) => s.id === sid);
				if (slice && slice.depends.length > 0) {
					depends = slice.depends;
				}
			}
		}
		if (!depends) {
			return "- (no dependencies)";
		}
	}
	// Deduplicate deps while preserving order, then load all summaries in parallel.
	const uniqueDeps = [...new Set(depends)];
	const sections = await Promise.all(
		uniqueDeps.map(async (dep) => {
			const summaryFile = resolveSliceFile(base, mid, dep, "SUMMARY");
			const summaryContent = summaryFile ? await loadFile(summaryFile) : null;
			const relPath = relSliceFile(base, mid, dep, "SUMMARY");
			if (summaryContent) {
				return `#### ${dep} Summary\nSource: \`${relPath}\`\n\n${summaryContent.trim()}`;
			} else {
				return `- \`${relPath}\` _(not found)_`;
			}
		}),
	);
	const result = sections.join("\n\n");
	if (budgetChars !== undefined && result.length > budgetChars) {
		return truncateAtSectionBoundary(result, budgetChars).content;
	}
	return result;
}
/**
 * Load a well-known .sf/ root file for optional inlining.
 * Handles the existsSync check internally.
 */
export async function inlineSfRootFile(base, filename, label) {
	const key = filename.replace(/\.md$/i, "").toUpperCase();
	const absPath = resolveSfRootFile(base, key);
	if (!existsSync(absPath)) return null;
	return inlineFileOptional(absPath, relSfRootFile(key), label);
}
// ─── DB-Aware Inline Helpers ──────────────────────────────────────────────
/**
 * Inline decisions with optional milestone scoping from the DB.
 * Falls back to filesystem via inlineSfRootFile only when DB is unavailable.
 *
 * Cascade logic (R005):
 * 1. Query with { milestoneId, scope } if scope provided
 * 2. If empty AND scope was provided, retry with { milestoneId } only (drop scope)
 * 3. If still empty, return null (intentional per D020)
 */
export async function inlineDecisionsFromDb(base, milestoneId, scope, level) {
	const inlineLevel = level ?? resolveInlineLevel();
	try {
		const { isDbAvailable } = await import("./sf-db.js");
		if (isDbAvailable()) {
			const { queryDecisions, formatDecisionsForPrompt } = await import(
				"./context-store.js"
			);
			// First query: try with both milestoneId and scope (if scope provided)
			let decisions = queryDecisions({ milestoneId, scope });
			// Cascade: if empty AND scope was provided, retry without scope
			if (decisions.length === 0 && scope) {
				decisions = queryDecisions({ milestoneId });
			}
			if (decisions.length > 0) {
				// Use compact format for non-full levels to save ~35% tokens
				const formatted =
					inlineLevel !== "full"
						? formatDecisionsCompact(decisions)
						: formatDecisionsForPrompt(decisions);
				return `### Decisions\nSource: DB\n\n${formatted}`;
			}
			// DB available but cascade returned empty — intentional per D020, don't fall back to file
			return null;
		}
	} catch (err) {
		logWarning(
			"prompt",
			`inlineDecisionsFromDb failed: ${getErrorMessage(err)}`,
		);
	}
	// DB unavailable — fall back to filesystem
	return inlineSfRootFile(base, "decisions.md", "Decisions");
}
/**
 * Inline requirements with optional milestone and slice scoping from the DB.
 * Falls back to filesystem via inlineSfRootFile when DB unavailable or empty.
 */
export async function inlineRequirementsFromDb(
	base,
	milestoneId,
	sliceId,
	level,
) {
	const inlineLevel = level ?? resolveInlineLevel();
	try {
		const { isDbAvailable } = await import("./sf-db.js");
		if (isDbAvailable()) {
			const { queryRequirements, formatRequirementsForPrompt } = await import(
				"./context-store.js"
			);
			const requirements = queryRequirements({ milestoneId, sliceId });
			if (requirements.length > 0) {
				// Use compact format for non-full levels to save ~40% tokens
				const formatted =
					inlineLevel !== "full"
						? formatRequirementsCompact(requirements)
						: formatRequirementsForPrompt(requirements);
				return `### Requirements\nSource: DB\n\n${formatted}`;
			}
		}
	} catch (err) {
		logWarning(
			"prompt",
			`inlineRequirementsFromDb failed: ${getErrorMessage(err)}`,
		);
	}
	return inlineSfRootFile(base, "requirements.md", "Requirements");
}
/**
 * Inline project context from the DB.
 * Falls back to filesystem via inlineSfRootFile when DB unavailable or empty.
 */
export async function inlineProjectFromDb(base) {
	try {
		const { isDbAvailable } = await import("./sf-db.js");
		if (isDbAvailable()) {
			const { queryProject } = await import("./context-store.js");
			const content = queryProject();
			if (content) {
				return `### Project\nSource: \`.sf/PROJECT.md\`\n\n${content}`;
			}
		}
	} catch (err) {
		logWarning("prompt", `inlineProjectFromDb failed: ${getErrorMessage(err)}`);
	}
	return inlineSfRootFile(base, "project.md", "Project");
}
// ─── Stopwords for keyword extraction ─────────────────────────────────────
const STOPWORDS = new Set([
	"of",
	"the",
	"and",
	"a",
	"for",
	"+",
	"-",
	"to",
	"in",
	"on",
	"with",
	"is",
	"as",
	"by",
]);
// Generic words that don't provide meaningful scope differentiation
const GENERIC_WORDS = new Set([
	"setup",
	"integration",
	"implementation",
	"testing",
	"test",
	"tests",
	"config",
	"configuration",
	"init",
	"initial",
	"basic",
	"core",
	"main",
	"primary",
	"final",
	"complete",
	"finish",
	"end",
	"start",
	"begin",
	"first",
	"last",
	"update",
	"updates",
	"fix",
	"fixes",
	"add",
	"adds",
	"remove",
	"removes",
	"create",
	"creates",
	"build",
	"builds",
	"deploy",
	"deployment",
	"refactor",
	"refactoring",
	"cleanup",
	"polish",
	"review",
	// Process/activity words that describe what you're doing, not what domain
	"hardening",
	"validation",
	"verification",
	"optimization",
	"improvement",
	"enhancement",
	"infrastructure",
]);
// Pattern to match slice/milestone/task IDs (e.g., S01, M001, T03)
const UNIT_ID_PATTERN = /^[smt]\d+$/i;
/**
 * Derive a scope keyword from slice title and optional description.
 * Returns the most specific noun (first non-generic keyword) for decision scoping.
 *
 * Examples:
 * - "Auth Middleware & Protected Route" → "auth"
 * - "Database & User Model Setup" → "database"
 * - "Integration Testing" → undefined (too generic)
 * - "API Rate Limiting" → "api"
 *
 * @param sliceTitle - The slice title
 * @param sliceDescription - Optional roadmap description (demo text)
 * @returns A single lowercase keyword or undefined if no meaningful scope
 */
export function deriveSliceScope(sliceTitle, sliceDescription) {
	// Combine title and description for keyword extraction
	const combinedText = sliceDescription
		? `${sliceTitle} ${sliceDescription}`
		: sliceTitle;
	// Extract all words, lowercase, remove punctuation
	const words = combinedText
		.split(/[\s&+,;:|/\\()-]+/)
		.map((w) => w.toLowerCase().replace(/[^a-z0-9]/g, ""))
		.filter((w) => w.length >= 2);
	// Find the first word that is:
	// 1. Not a stopword
	// 2. Not a generic word
	// 3. Not a unit ID (S01, M001, T03)
	// 4. At least 3 characters (meaningful scope)
	for (const word of words) {
		if (STOPWORDS.has(word)) continue;
		if (GENERIC_WORDS.has(word)) continue;
		if (UNIT_ID_PATTERN.test(word)) continue;
		if (word.length < 3) continue;
		return word;
	}
	return undefined;
}
/**
 * Extract keywords from a slice title for scoped knowledge queries.
 * Splits on whitespace, filters stopwords, lowercases.
 * Example: 'KNOWLEDGE scoping + roadmap excerpt' → ['knowledge', 'scoping', 'roadmap', 'excerpt']
 */
function extractKeywords(title) {
	return title
		.split(/\s+/)
		.map((w) => w.toLowerCase().replace(/[^a-z0-9]/g, ""))
		.filter((w) => w.length > 0 && !STOPWORDS.has(w));
}
/**
 * Inline scoped knowledge based on keywords from slice title.
 * Queries DB memories table (primary); falls back to KNOWLEDGE.md file.
 * Returns null if no knowledge exists or no entries match.
 */
async function inlineKnowledgeScoped(base, keywords) {
	try {
		const { isDbAvailable, getActiveMemories } = await import("./sf-db.js");
		if (isDbAvailable()) {
			const entries = getActiveMemories({ category: "knowledge", limit: 200 });
			if (entries.length > 0) {
				const kws = keywords.map((k) => k.toLowerCase());
				const matched = entries.filter((e) =>
					kws.some((kw) => e.content.toLowerCase().includes(kw)),
				);
				if (matched.length === 0) return null;
				const formatted = matched.map((e) => `- ${e.content}`).join("\n");
				return `### Project Knowledge (scoped)\nSource: DB\n\n${formatted}`;
			}
		}
	} catch {
		// fall through to file
	}
	const knowledgePath = resolveSfRootFile(base, "KNOWLEDGE");
	if (!existsSync(knowledgePath)) return null;
	const content = await loadFile(knowledgePath);
	if (!content) return null;
	const { queryKnowledge } = await import("./context-store.js");
	const scoped = await queryKnowledge(content, keywords);
	if (!scoped) return null;
	return `### Project Knowledge (scoped)\nSource: \`${relSfRootFile("KNOWLEDGE")}\`\n\n${scoped.trim()}`;
}
/**
 * Budget-capped knowledge inline for milestone-level prompt assembly.
 * Queries DB memories table (primary); falls back to KNOWLEDGE.md file.
 * Caps the payload at `maxChars` (default 30,000 chars).
 * Returns null when no knowledge exists or no entries match any keyword.
 */
async function inlineKnowledgeBudgeted(base, keywords, options) {
	const DEFAULT_MAX_CHARS = 30_000;
	const HARD_MAX_CHARS = 100_000;
	const raw = Number(options?.maxChars ?? DEFAULT_MAX_CHARS);
	const maxChars = Number.isFinite(raw)
		? Math.max(0, Math.min(Math.floor(raw), HARD_MAX_CHARS))
		: DEFAULT_MAX_CHARS;
	try {
		const { isDbAvailable, getActiveMemories } = await import("./sf-db.js");
		if (isDbAvailable()) {
			const entries = getActiveMemories({ category: "knowledge", limit: 500 });
			if (entries.length > 0) {
				const kws = keywords.map((k) => k.toLowerCase());
				const matched = entries.filter((e) =>
					kws.some((kw) => e.content.toLowerCase().includes(kw)),
				);
				if (matched.length === 0) return null;
				const trimmed = matched.map((e) => `- ${e.content}`).join("\n");
				const truncated =
					trimmed.length > maxChars
						? `${trimmed.slice(0, maxChars)}\n\n[...truncated; rerun with narrower scope if needed]`
						: trimmed;
				return `### Project Knowledge (scoped)\nSource: DB\n\n${truncated}`;
			}
		}
	} catch {
		// fall through to file
	}
	const knowledgePath = resolveSfRootFile(base, "KNOWLEDGE");
	if (!existsSync(knowledgePath)) return null;
	const content = await loadFile(knowledgePath);
	if (!content) return null;
	const { queryKnowledge } = await import("./context-store.js");
	const scoped = await queryKnowledge(content, keywords);
	if (!scoped) return null;
	const trimmed = scoped.trim();
	const truncated =
		trimmed.length > maxChars
			? `${trimmed.slice(0, maxChars)}\n\n[...truncated ${trimmed.length - maxChars} chars; rerun with narrower scope if needed]`
			: trimmed;
	return `### Project Knowledge (scoped)\nSource: \`${relSfRootFile("KNOWLEDGE")}\`\n\n${truncated}`;
}
/**
 * Inline a roadmap excerpt for a specific slice.
 * Reads full roadmap, extracts minimal excerpt with header + predecessor + target row.
 * Returns null if roadmap doesn't exist or slice not found.
 */
async function inlineRoadmapExcerpt(base, mid, sid) {
	const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
	if (!roadmapPath || !existsSync(roadmapPath)) return null;
	const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
	const content = await loadFile(roadmapPath);
	if (!content) return null;
	// Import formatRoadmapExcerpt from context-store
	const { formatRoadmapExcerpt } = await import("./context-store.js");
	const excerpt = formatRoadmapExcerpt(content, sid, roadmapRel);
	// Return null if slice not found in roadmap
	if (!excerpt) return null;
	return `### Milestone Roadmap (excerpt)\nSource: \`${roadmapRel}\`\n\n${excerpt}`;
}
// ─── Skill Activation & Discovery ─────────────────────────────────────────
function normalizeSkillReference(ref) {
	const normalized = ref.replace(/\\/g, "/").trim();
	const base = basename(normalized).replace(/\.md$/i, "");
	const name = /^SKILL$/i.test(base)
		? basename(normalized.replace(/\/SKILL(?:\.md)?$/i, ""))
		: base;
	return name.trim().toLowerCase();
}
function tokenizeSkillContext(...parts) {
	const tokens = new Set();
	const addVariants = (raw) => {
		const value = raw.trim().toLowerCase();
		if (!value || value.length < 2) return;
		tokens.add(value);
		tokens.add(value.replace(/[-_]+/g, " "));
		tokens.add(value.replace(/\s+/g, "-"));
		tokens.add(value.replace(/\s+/g, ""));
	};
	for (const part of parts) {
		if (!part) continue;
		const text = part.toLowerCase();
		const phraseMatches = text.match(/[a-z0-9][a-z0-9+.#/_-]{1,}/g) ?? [];
		for (const match of phraseMatches) {
			addVariants(match);
			for (const piece of match.split(/[^a-z0-9+.#]+/g)) {
				if (piece.length >= 3) addVariants(piece);
			}
		}
	}
	return tokens;
}
function skillMatchesContext(skill, contextTokens) {
	const score = scoreSkillContextMatch(skill, contextTokens);
	return score > 0;
}
function scoreSkillContextMatch(skill, contextTokens) {
	const haystacks = [
		skill.name.toLowerCase(),
		skill.name.toLowerCase().replace(/[-_]+/g, " "),
		skill.description.toLowerCase(),
	];
	let score = 0;
	for (const token of contextTokens) {
		if (token.length < 3) continue;
		if (token === "sf") continue;
		for (const haystack of haystacks) {
			if (haystack === token) score += 5;
			else if (haystack.split(/[^a-z0-9+.#]+/g).includes(token)) score += 3;
			else if (haystack.includes(token)) score += 1;
		}
	}
	return score;
}
function resolvePreferenceSkillNames(refs, base) {
	if (refs.length === 0) return [];
	const prefs = { always_use_skills: refs };
	const report = resolveAllSkillReferences(prefs, base);
	return refs
		.map((ref) => {
			const resolution = report.resolutions.get(ref);
			return normalizeSkillReference(resolution?.resolvedPath ?? ref);
		})
		.filter(Boolean);
}
function ruleMatchesContext(when, contextTokens) {
	const whenTokens = tokenizeSkillContext(when);
	return [...whenTokens].some(
		(token) =>
			contextTokens.has(token) ||
			[...contextTokens].some(
				(ctx) => ctx.includes(token) || token.includes(ctx),
			),
	);
}
function resolveSkillRuleMatches(prefs, contextTokens, base) {
	if (!prefs?.skill_rules?.length) return { include: [], avoid: [] };
	const include = [];
	const avoid = [];
	for (const rule of prefs.skill_rules) {
		if (!ruleMatchesContext(rule.when, contextTokens)) continue;
		include.push(
			...resolvePreferenceSkillNames(
				[...(rule.use ?? []), ...(rule.prefer ?? [])],
				base,
			),
		);
		avoid.push(...resolvePreferenceSkillNames(rule.avoid ?? [], base));
	}
	return { include, avoid };
}
function resolvePreferredSkillNames(prefs, visibleSkills, contextTokens, base) {
	if (!prefs?.prefer_skills?.length) return [];
	const preferred = new Set(
		resolvePreferenceSkillNames(prefs.prefer_skills, base),
	);
	return visibleSkills
		.filter(
			(skill) =>
				preferred.has(normalizeSkillReference(skill.name)) &&
				skillMatchesContext(skill, contextTokens),
		)
		.map((skill) => normalizeSkillReference(skill.name));
}
/**
 * Build the workflow constraints block for agent dispatch prompts.
 *
 * Purpose: inject product-owned hidden workflow pattern skills into every
 * agent dispatch so cross-cutting patterns are always active. These use the
 * SKILL.md shape for evaluation/evolution, but are not user-invocable.
 *
 * Consumer: buildSkillActivationBlock — appended after the skill_activation block.
 */
function buildWorkflowConstraintsBlock(params) {
	let allPatternSkills;
	let constraintPatterns;
	let workflowSkills;
	try {
		allPatternSkills = loadWorkflowPatternSkills(params);
		constraintPatterns = allPatternSkills.filter((skill) =>
			ALWAYS_ON_WORKFLOW_SKILL_NAMES.has(normalizeSkillReference(skill.name)),
		);
		workflowSkills = allPatternSkills.filter(
			(skill) =>
				!ALWAYS_ON_WORKFLOW_SKILL_NAMES.has(
					normalizeSkillReference(skill.name),
				),
		);
	} catch {
		return "";
	}
	if (constraintPatterns.length === 0 && workflowSkills.length === 0) return "";

	const constraintNames = new Set(
		constraintPatterns.map((pattern) => normalizeSkillReference(pattern.name)),
	);
	const explicitNames = params.explicitSkillNames ?? new Set();
	const avoidedNames = params.avoidedSkillNames ?? new Set();
	const selectedByName = new Map();

	for (const pattern of constraintPatterns.slice(0, 8)) {
		selectedByName.set(normalizeSkillReference(pattern.name), {
			...pattern,
			reason: "constraint",
		});
	}

	const contextMatches = [];
	for (const skill of workflowSkills) {
		const name = normalizeSkillReference(skill.name);
		if (selectedByName.has(name)) continue;
		if (avoidedNames.has(name)) continue;
		if (explicitNames.has(name)) {
			selectedByName.set(name, { ...skill, reason: "explicit" });
			continue;
		}
		if (
			skill.modelInvocable !== false &&
			!constraintNames.has(name) &&
			skillMatchesContext(skill, params.contextTokens ?? new Set())
		) {
			contextMatches.push({
				...skill,
				reason: "context",
				matchScore: scoreSkillContextMatch(
					skill,
					params.contextTokens ?? new Set(),
				),
			});
		}
	}
	for (const skill of contextMatches
		.sort((a, b) => b.matchScore - a.matchScore || a.name.localeCompare(b.name))
		.slice(0, 4)) {
		selectedByName.set(normalizeSkillReference(skill.name), skill);
	}

	const sections = [...selectedByName.values()].map((pattern) => {
		const body = pattern.body ?? "";
		const limit = pattern.reason === "constraint" ? 500 : 700;
		const truncated =
			body.length > limit
				? body.slice(0, limit).replace(/\n[^\n]*$/, "") + "\n..."
				: body;
		const label =
			pattern.reason === "constraint"
				? "always-on constraint"
				: `${pattern.reason} workflow pattern`;
		return `### ${pattern.name} (${label})\n\n${truncated}`;
	});

	return `\n\n## Active Workflow Patterns\n\n${sections.join("\n\n")}`;
}

const ALWAYS_ON_WORKFLOW_SKILL_NAMES = new Set([
	"assumption-log",
	"context-lean",
	"error-routing",
	"handoff-readability",
	"irreversible-ops",
	"observe-first",
	"state-discipline",
	"vertical-slice",
]);

function loadWorkflowPatternSkills(params) {
	return loadSkills(params.base, {
		includeBundled: false,
		includeWorkflow: true,
	})
		.filter(
			(skill) =>
				skill.valid &&
				(skill.source === "workflow" ||
					ALWAYS_ON_WORKFLOW_SKILL_NAMES.has(
						normalizeSkillReference(skill.name),
					)),
		)
		.map((skill) => ({
			name: skill.name,
			description: skill.description ?? "",
			body: skill.body ?? "",
			modelInvocable: skill.modelInvocable,
		}));
}

/** Skill names must be lowercase alphanumeric with hyphens — reject anything else
 *  to prevent prompt injection via crafted directory names. */
const SAFE_SKILL_NAME = /^[a-z0-9][a-z0-9-]*$/;
function formatSkillActivationBlock(skillNames) {
	const safe = skillNames.filter((name) => SAFE_SKILL_NAME.test(name));
	if (safe.length === 0) return "";
	// Use explicit parameter syntax so LLMs pass { skill: "..." } instead of { name: "..." }.
	// The function-call-like syntax `Skill('name')` led LLMs to infer a positional
	// parameter name, causing tool validation failures — see #2224.
	const calls = safe
		.map((name) => `Call Skill({ skill: '${name}' })`)
		.join(". ");
	return `<skill_activation>${calls}.</skill_activation>`;
}
export function buildSkillActivationBlock(params) {
	const prefs = params.preferences ?? loadEffectiveSFPreferences()?.preferences;
	const contextTokens = tokenizeSkillContext(
		params.milestoneId,
		params.milestoneTitle,
		params.sliceId,
		params.sliceTitle,
		params.taskId,
		params.taskTitle,
	);
	const loaded = (
		typeof getLoadedSkills === "function" ? getLoadedSkills() : []
	).filter((skill) => !skill.disableModelInvocation);
	// Skill activation here is driven entirely by explicit sources
	// (always_use_skills, prefer_skills, skill_rules, task-plan skills_used).
	// Every match is an explicit user/project intent and must not be dropped
	// by the unit-type manifest — user intent is stronger signal than
	// defaults. The manifest's real home is the skill catalog rendering
	// layer (coding-agent `formatSkillsForPrompt`); that wiring is tracked
	// as the "load-time short-circuit" follow-up to RFC #4779.
	//
	// `unitType` stays plumbed so the strict-mode warning can surface
	// manifest entries that reference uninstalled skills, and so the
	// activation-block site is ready to opt in once PR B lands.
	const visibleSkills = loaded;
	const installedNames = new Set(
		visibleSkills.map((skill) => normalizeSkillReference(skill.name)),
	);
	warnIfManifestHasMissingSkills(params.unitType, installedNames);
	const avoided = new Set(
		resolvePreferenceSkillNames(prefs?.avoid_skills ?? [], params.base),
	);
	const matched = new Set();
	for (const name of resolvePreferenceSkillNames(
		prefs?.always_use_skills ?? [],
		params.base,
	)) {
		matched.add(name);
	}
	const ruleMatches = resolveSkillRuleMatches(
		prefs,
		contextTokens,
		params.base,
	);
	for (const name of ruleMatches.include) matched.add(name);
	for (const name of ruleMatches.avoid) avoided.add(name);
	for (const name of resolvePreferredSkillNames(
		prefs,
		visibleSkills,
		contextTokens,
		params.base,
	)) {
		matched.add(name);
	}
	if (params.taskPlanContent) {
		try {
			const taskPlan = parseTaskPlanFile(params.taskPlanContent);
			for (const skillName of taskPlan.frontmatter.skills_used) {
				matched.add(normalizeSkillReference(skillName));
			}
		} catch (err) {
			logWarning("prompt", `parseTaskPlanFile failed: ${getErrorMessage(err)}`);
		}
	}
	const ordered = [...matched]
		.filter((name) => installedNames.has(name) && !avoided.has(name))
		.sort();
	const userSkillBlock = formatSkillActivationBlock(ordered);

	// Derive workMode + permissionProfile from session state for workflow constraints
	let workMode = "build";
	let permissionProfile = "normal";
	try {
		const session = getAutoSession();
		if (session?.workMode) workMode = session.workMode;
		if (session?.permissionProfile)
			permissionProfile = session.permissionProfile;
	} catch {
		// getAutoSession may be unavailable in test contexts — use defaults
	}
	const workflowBlock = buildWorkflowConstraintsBlock({
		base: params.base,
		contextTokens,
		explicitSkillNames: matched,
		avoidedSkillNames: avoided,
		workMode,
		permissionProfile,
	});

	return userSkillBlock + workflowBlock;
}
/**
 * Build the skill discovery template variables for research prompts.
 * Returns { skillDiscoveryMode, skillDiscoveryInstructions } for template substitution.
 */
export function buildSkillDiscoveryVars() {
	const mode = resolveSkillDiscoveryMode();
	if (mode === "off") {
		return {
			skillDiscoveryMode: "off",
			skillDiscoveryInstructions:
				" Skill discovery is disabled. Skip this step.",
		};
	}
	const autoInstall = mode === "auto";
	const instructions = `
   Identify the key technologies, frameworks, and services this work depends on (e.g. Stripe, Clerk, Supabase, JUCE, SwiftUI).
   For each, check if a professional agent skill already exists:
   - First check \`<available_skills>\` in your system prompt — a skill may already be installed.
   - For technologies without an installed skill, run: \`npx skills find "<technology>"\`
   - Only consider skills that are **directly relevant** to core technologies — not tangentially related.
   - Evaluate results by install count and relevance to the actual work.${
			autoInstall
				? `
   - Install relevant skills: \`npx skills add <owner/repo@skill> -g -y\`
   - Record installed skills in the "Skills Discovered" section of your research output.
   - Installed skills will automatically appear in subsequent units' system prompts — no manual steps needed.`
				: `
   - Note promising skills in your research output with their install commands, but do NOT install them.
   - The user will decide which to install.`
}`;
	return {
		skillDiscoveryMode: mode,
		skillDiscoveryInstructions: instructions,
	};
}
// ─── Text Helpers ──────────────────────────────────────────────────────────
// Re-exported from workflow-helpers.js:
// - buildResumeSection, buildCarryForwardSection
// - checkNeedsReassessment, checkNeedsRunUat
// - escapeRegExp, extractMarkdownSection
// ─── Prompt Builders ──────────────────────────────────────────────────────
/**
 * Build a prompt for the workflow-preferences unit type (deep mode).
 * Captures workflow + planning preferences during deep-mode bootstrap,
 * before discuss-project runs.
 */
export async function buildWorkflowPreferencesPrompt(
	base,
	structuredQuestionsAvailable = "false",
) {
	return loadPrompt("guided-workflow-preferences", {
		workingDirectory: base,
		structuredQuestionsAvailable,
	});
}
/**
 * Build a prompt for the discuss-project unit type (deep mode).
 * 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 { 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);
	// #M005-remediation: knowledge/graph are computed artifacts already
	// included in `composed` via the computed registry above. Manual
	// re-injection here caused duplicate sections in the prompt output.
	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,
		inlinedContext,
		structuredQuestionsAvailable,
		commitInstruction:
			"Do not commit planning artifacts — .sf/ is managed externally.",
	});
}
/**
 * Build a prompt for the discuss-requirements unit type (deep mode).
 * Requirements-level interview: produces .sf/REQUIREMENTS.md using the
 * 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 { 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);
	// #M005-remediation: knowledge/graph included via composed (computed registry).
	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,
		inlinedContext,
		structuredQuestionsAvailable,
		commitInstruction:
			"Do not commit planning artifacts — .sf/ is managed externally.",
	});
}
/**
 * Build a prompt for the research-decision unit type (deep mode).
 * Fixed-question stage: asks "research first or skip?" via
 * ask_user_questions and writes .sf/runtime/research-decision.json.
 * Fires after discuss-requirements and before research-project-parallel.
 */
/**
 * Build a prompt for the research-project-parallel unit type (deep mode).
 * Orchestrator that spawns parallel subagents covering stack, features,
 * 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);
	// #M005-remediation: knowledge/graph included via composed (computed registry).
	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,
	});
}
/**
 * Build a prompt for the discuss-milestone unit type.
 * 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,
	midTitle,
	base,
	structuredQuestionsAvailable = "false",
) {
	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);
	// #M005-remediation: knowledge/graph included via composed (computed registry).
	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,
		inlinedContext,
		structuredQuestionsAvailable,
		commitInstruction:
			"Do not commit planning artifacts — .sf/ is managed externally.",
		fastPathInstruction: "",
	});
	// If a CONTEXT-DRAFT.md exists, append it as seed material
	const draftPath = resolveMilestoneFile(base, mid, "CONTEXT-DRAFT");
	const draftContent = draftPath ? await loadFile(draftPath) : null;
	if (draftContent) {
		return `${basePrompt}\n\n## Prior Discussion (Draft Seed)\n\nThe following draft was captured from a prior multi-milestone discussion. Use it as seed material — the user has already provided this context. Start with a brief reflection on what the draft covers, then probe for any gaps or open questions before writing the full CONTEXT.md.\n\n${draftContent}`;
	}
	return basePrompt;
}
export async function buildResearchMilestonePrompt(mid, midTitle, base) {
	// #M005-remediation: research-milestone now fully delegates ordering
	// to the v2 composer. The manifest declares knowledge as an inline
	// artifact (positioned between decisions and templates) so its
	// keyword-budgeted resolver runs in the correct slot. Graph is a
	// computed artifact appended after templates. Eliminates the manual
	// splice + duplicate-injection pattern previously inlined here.
	const resolveArtifact = async (key) => {
		switch (key) {
			case "milestone-context": {
				const p = resolveMilestoneFile(base, mid, "CONTEXT");
				const r = relMilestoneFile(base, mid, "CONTEXT");
				return await inlineFile(p, r, "Milestone Context");
			}
			case "project":
				return await inlineProjectFromDb(base);
			case "requirements":
				return await inlineRequirementsFromDb(base, mid);
			case "decisions":
				return await inlineDecisionsFromDb(base, mid);
			case "knowledge":
				return await inlineKnowledgeBudgeted(base, extractKeywords(midTitle));
			case "templates":
				return inlineTemplate("research", "Research");
			default:
				return null;
		}
	};
	const { inline: composed } = await composeUnitContext("research-milestone", {
		base,
		resolveArtifact,
		computed: {
			graph: {
				build: async (_, b) =>
					inlineGraphSubgraph(b, `${mid} ${midTitle}`, { budget: 3000 }),
				inputs: {},
			},
		},
	});
	const parts = [];
	if (composed) parts.push(composed);
	const inlinedContext = capPreamble(
		`## Inlined Context (preloaded — do not re-read these files)\n\n${parts.join("\n\n---\n\n")}`,
	);
	const outputRelPath = relMilestoneFile(base, mid, "RESEARCH");
	return loadPrompt("research-milestone", {
		workingDirectory: base,
		milestoneId: mid,
		milestoneTitle: midTitle,
		milestonePath: relMilestonePath(base, mid),
		contextPath: relMilestoneFile(base, mid, "CONTEXT"),
		outputPath: join(base, outputRelPath),
		inlinedContext,
		skillActivation: buildSkillActivationBlock({
			base,
			milestoneId: mid,
			milestoneTitle: midTitle,
			extraContext: [inlinedContext],
			unitType: "research-milestone",
		}),
		...buildSkillDiscoveryVars(),
	});
}
export async function buildPlanMilestonePrompt(mid, midTitle, base, level) {
	const inlineLevel = level ?? resolveInlineLevel();
	const contextPath = resolveMilestoneFile(base, mid, "CONTEXT");
	const contextRel = relMilestoneFile(base, mid, "CONTEXT");
	const researchPath = resolveMilestoneFile(base, mid, "RESEARCH");
	const researchRel = relMilestoneFile(base, mid, "RESEARCH");
	const queuePath = resolveSfRootFile(base, "QUEUE");
	const keywords = extractKeywords(midTitle);
	const { inlinePriorMilestoneSummary } = await import("./files.js");
	const { prepend, inline } = await composeUnitContext("plan-milestone", {
		base,
		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}` },
			},
		},
	});
	const parts = [prepend, inline].filter(Boolean);
	const inlinedContext = capPreamble(
		`## 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.
	let framingBlock = "";
	try {
		const { checkMilestoneFraming, formatFramingFindings } = await import(
			"./milestone-framing-check.js"
		);
		const framingFindings = checkMilestoneFraming(base, mid);
		framingBlock = formatFramingFindings(mid, framingFindings);
	} catch {
		// Non-fatal — framing check must never break milestone planning
	}
	const outputRelPath = relMilestoneFile(base, mid, "ROADMAP");
	const researchOutputPath = join(
		base,
		relMilestoneFile(base, mid, "RESEARCH"),
	);
	const secretsOutputPath = join(base, relMilestoneFile(base, mid, "SECRETS"));
	const inlinedContextWithFraming = framingBlock
		? `${framingBlock}\n\n${inlinedContext}`
		: inlinedContext;
	return loadPrompt("plan-milestone", {
		workingDirectory: base,
		milestoneId: mid,
		milestoneTitle: midTitle,
		milestonePath: relMilestonePath(base, mid),
		contextPath: contextRel,
		researchPath: researchRel,
		researchOutputPath,
		outputPath: join(base, outputRelPath),
		secretsOutputPath,
		inlinedContext: inlinedContextWithFraming,
		sourceFilePaths: buildSourceFilePaths(base, mid),
		skillActivation: buildSkillActivationBlock({
			base,
			milestoneId: mid,
			milestoneTitle: midTitle,
			extraContext: [inlinedContext],
			unitType: "plan-milestone",
		}),
		...buildSkillDiscoveryVars(),
	});
}
export async function buildResearchSlicePrompt(
	mid,
	_midTitle,
	sid,
	sTitle,
	base,
) {
	const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
	const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
	const contextPath = resolveMilestoneFile(base, mid, "CONTEXT");
	const contextRel = relMilestoneFile(base, mid, "CONTEXT");
	const milestoneResearchPath = resolveMilestoneFile(base, mid, "RESEARCH");
	const milestoneResearchRel = relMilestoneFile(base, mid, "RESEARCH");
	const sliceContextPath = resolveSliceFile(base, mid, sid, "CONTEXT");
	const sliceContextRel = relSliceFile(base, mid, sid, "CONTEXT");
	// depContent stays as a separate template variable (not part of inlinedContext).
	const depContent = await inlineDependencySummaries(
		mid,
		sid,
		base,
		resolveSummaryBudgetChars(),
	);
	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 memorySection = await buildProjectMemoriesSection(`${sid} ${sTitle}`);
	const parts = [prepend, memorySection, inline].filter(Boolean);
	const inlinedContext = capPreamble(
		`## 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", {
		workingDirectory: base,
		milestoneId: mid,
		sliceId: sid,
		sliceTitle: sTitle,
		slicePath: relSlicePath(base, mid, sid),
		roadmapPath: roadmapRel,
		contextPath: contextRel,
		milestoneResearchPath: milestoneResearchRel,
		outputPath: join(base, outputRelPath),
		inlinedContext,
		dependencySummaries: depContent,
		skillActivation: buildSkillActivationBlock({
			base,
			milestoneId: mid,
			sliceId: sid,
			sliceTitle: sTitle,
			extraContext: [inlinedContext, depContent],
			unitType: "research-slice",
		}),
		...buildSkillDiscoveryVars(),
	});
}
/**
 * Build a prompt-ready Project Memories section.
 *
 * Purpose: keep memory injection consistent across planning, research, and
 * execution surfaces while preserving query-aware ranking when available.
 *
 * Consumer: autonomous prompt builders for research-slice, plan-slice, and
 * execute-task.
 */
async function buildProjectMemoriesSection(query, limit = 10) {
	const memoryQuery = String(query ?? "").trim();
	try {
		const usingRanker = !!memoryQuery;
		const memories = usingRanker
			? await getRelevantMemoriesRanked(memoryQuery, limit)
			: getActiveMemoriesRanked(limit);
		if (memories.length === 0) return "## Project Memories\n(none yet)";
		return `## Project Memories\n${formatMemoriesForPrompt(memories, 2000, usingRanker)}`;
	} catch {
		return "## Project Memories\n(unavailable)";
	}
}

/**
 * Build a richer query string for getRelevantMemoriesRanked at the
 * execute-task site. Cosine ranking on two short title strings is
 * effectively keyword matching; injecting slice.goal text + current
 * touched-file list materially improves the candidates that
 * surface, especially for slices whose memory-relevant context is
 * named in the goal but not in the title.
 *
 * Bounded so the embedding token cost stays predictable:
 *   - sliceGoal: ~600 chars (front of stored goal text)
 *   - touched files: top 20 paths from git status + diff
 *
 * Fail-open: every individual data source can fail (DB closed, not
 * a git repo, etc.) and we still return the original title-based
 * query so the call site doesn't have to handle errors.
 *
 * Exported for the dedicated unit test.
 */
export async function buildMemoryRetrievalQuery(
	basePath,
	mid,
	sid,
	sliceTitle,
	taskTitle,
) {
	const parts = [
		String(sliceTitle ?? "").trim(),
		String(taskTitle ?? "").trim(),
	];
	let sliceGoal = "";
	try {
		const { isDbAvailable, getSlice } = await import("./sf-db.js");
		if (isDbAvailable()) {
			sliceGoal = String(getSlice(mid, sid)?.goal ?? "").trim();
		}
	} catch {
		sliceGoal = "";
	}
	if (sliceGoal) {
		parts.push(sliceGoal.length > 600 ? sliceGoal.slice(0, 600) : sliceGoal);
	}
	try {
		const { getChangedFilesWithContext } = await import("./diff-context.js");
		const changed = await getChangedFilesWithContext(basePath);
		const paths = changed.slice(0, 20).map((c) => c.path);
		if (paths.length > 0) parts.push(paths.join(" "));
	} catch {
		// Not a git repo, or git unavailable — fine, just skip files.
	}
	return parts.filter((p) => p && p.length > 0).join("\n\n");
}
/**
 * Shared assembly for plan-slice and refine-slice prompts. Both builders need
 * the same inlined context (roadmap excerpt, slice context, research, decisions,
 * requirements, knowledge, graph subgraph, templates, dependency summaries,
 * overrides). Extracted to prevent drift between the two sites.
 *
 * `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 {
		mid,
		sid,
		sTitle,
		base,
		level,
		promptTemplate,
		prependBlocks = [],
		extraVars = {},
		sessionContextWindow,
		modelRegistry,
	} = options;
	const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
	const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
	const researchPath = resolveSliceFile(base, mid, sid, "RESEARCH");
	const researchRel = relSliceFile(base, mid, sid, "RESEARCH");
	const sliceContextPath = resolveSliceFile(base, mid, sid, "CONTEXT");
	const sliceContextRel = relSliceFile(base, mid, sid, "CONTEXT");
	// 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");
	const prefixBlocks = [...prependBlocks];
	const memorySection = await buildProjectMemoriesSection(`${sid} ${sTitle}`);
	prefixBlocks.push(memorySection);
	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 { 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${allParts.join("\n\n---\n\n")}`,
	);
	const executorContextConstraints = formatExecutorConstraints(
		sessionContextWindow,
		modelRegistry,
	);
	const outputRelPath = relSliceFile(base, mid, sid, "PLAN");
	const commitInstruction =
		"Do not commit — .sf/ planning docs are managed externally and not tracked in git.";
	return loadPrompt(promptTemplate, {
		workingDirectory: base,
		milestoneId: mid,
		sliceId: sid,
		sliceTitle: sTitle,
		slicePath: relSlicePath(base, mid, sid),
		roadmapPath: roadmapRel,
		researchPath: researchRel,
		outputPath: join(base, outputRelPath),
		inlinedContext,
		dependencySummaries: depContent,
		sourceFilePaths: buildSourceFilePaths(base, mid, sid),
		executorContextConstraints,
		commitInstruction,
		skillActivation: buildSkillActivationBlock({
			base,
			milestoneId: mid,
			sliceId: sid,
			sliceTitle: sTitle,
			extraContext: [inlinedContext, depContent],
			unitType: promptTemplate,
		}),
		...extraVars,
	});
}
export async function buildPlanSlicePrompt(
	mid,
	_midTitle,
	sid,
	sTitle,
	base,
	level,
	options,
) {
	const prependBlocks = [];
	// SF ADR-011 (progressive planning): when the refining-phase dispatch rule gracefully downgrades to
	// plan-slice (progressive_planning was toggled off mid-milestone), it
	// forwards the stored sketch_scope as a SOFT hint — context, not a hard
	// constraint. The planner is free to expand beyond it.
	if (options?.softScopeHint && options.softScopeHint.trim().length > 0) {
		prependBlocks.push(
			`## Prior Sketch Scope (soft hint — non-binding)\n\n${options.softScopeHint.trim()}\n\n` +
				`This scope was captured during an earlier progressive-planning pass that was later disabled. Treat it as context only — you may plan beyond it if the work genuinely requires more scope. Do NOT treat this as a hard boundary.`,
		);
	}
	// #4551: inject pre-exec failure context so the re-dispatched plan-slice
	// addresses the exact blocked references rather than reproducing the same plan.
	if (options?.priorPreExecFailure) {
		const { blockingFindings, verdictExcerpt } = options.priorPreExecFailure;
		const findingsList =
			blockingFindings.length > 0
				? blockingFindings.map((f) => `- ${f}`).join("\n")
				: "- (no specific findings recorded)";
		prependBlocks.push(
			`## Fix these specific issues from the prior pre-exec check\n\n` +
				`The previous plan-slice attempt was blocked by pre-execution validation.\n` +
				`Gate verdict: ${verdictExcerpt}\n\n` +
				`Blocked references that must be resolved in this plan:\n${findingsList}\n\n` +
				`Revise the plan so that every reference listed above is satisfied before execution begins. ` +
				`Do not reproduce the same file paths, package names, or task ordering that caused these failures.`,
		);
	}
	return renderSlicePrompt({
		mid,
		sid,
		sTitle,
		base,
		level: level ?? resolveInlineLevel(),
		promptTemplate: "plan-slice",
		prependBlocks,
		sessionContextWindow: options?.sessionContextWindow,
		modelRegistry: options?.modelRegistry,
	});
}
/**
 * SF ADR-011 refine-slice: expand a sketch into a full plan using the current
 * codebase state and prior slice summary. Mechanically similar to plan-slice
 * but framed as a *transformation* (sketch → full plan) rather than a
 * blank-sheet planning pass. Reuses inlineDependencySummaries for prior
 * slice SUMMARY and inlines the stored sketch_scope as a hard constraint.
 */
export async function buildRefineSlicePrompt(
	mid,
	_midTitle,
	sid,
	sTitle,
	base,
	level,
	options,
) {
	// Pull the stored sketch scope from the DB — the hard constraint we plan within.
	let sketchScope = "";
	try {
		const { isDbAvailable, getSlice } = await import("./sf-db.js");
		if (isDbAvailable()) {
			sketchScope = getSlice(mid, sid)?.sketch_scope ?? "";
		}
	} catch {
		sketchScope = "";
	}
	const prependBlocks = [];
	if (sketchScope.trim().length > 0) {
		prependBlocks.push(
			`## Sketch Scope (hard constraint)\n\n${sketchScope.trim()}\n\n` +
				`Treat this as the authoritative boundary for the slice. Do not plan work outside this scope; if the scope is too narrow, surface it as a deviation rather than expanding silently.`,
		);
	}
	return renderSlicePrompt({
		mid,
		sid,
		sTitle,
		base,
		level: level ?? resolveInlineLevel(),
		promptTemplate: "refine-slice",
		prependBlocks,
		extraVars: { sketchScope },
		sessionContextWindow: options?.sessionContextWindow,
		modelRegistry: options?.modelRegistry,
	});
}
export async function buildExecuteTaskPrompt(
	mid,
	sid,
	sTitle,
	tid,
	tTitle,
	base,
	level,
) {
	const opts =
		typeof level === "object" && level !== null && !Array.isArray(level)
			? level
			: { level: level };
	const inlineLevel = opts.level ?? resolveInlineLevel();
	// Inject phase handoff anchor from planning phase (if available)
	const planAnchor = readPhaseAnchor(base, mid, "plan-slice");
	// Resolve paths before the parallel fetch so closures capture stable values.
	const taskPlanPath = resolveTaskFile(base, mid, sid, tid, "PLAN");
	const taskPlanRelPath =
		relSlicePath(base, mid, sid) + `/tasks/${tid}-PLAN.md`;
	const slicePlanPath = resolveSliceFile(base, mid, sid, "PLAN");
	const continueFile = resolveSliceFile(base, mid, sid, "CONTINUE");
	const legacyContinueDir = resolveSlicePath(base, mid, sid);
	const legacyContinuePath = legacyContinueDir
		? join(legacyContinueDir, "continue.md")
		: null;
	const continueRelPath = relSliceFile(base, mid, sid, "CONTINUE");
	const runtimePath = resolveRuntimeFile(base);
	// Fan out all independent I/O in parallel: task plan, slice plan, continue
	// 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,
		activeOverrides,
		priorSummaries,
	] = await Promise.all([
		taskPlanPath ? loadFile(taskPlanPath) : Promise.resolve(null),
		slicePlanPath ? loadFile(slicePlanPath) : Promise.resolve(null),
		continueFile ? loadFile(continueFile) : Promise.resolve(null),
		existsSync(runtimePath) ? loadFile(runtimePath) : Promise.resolve(null),
		loadActiveOverrides(base),
		opts.carryForwardPaths
			? Promise.resolve(opts.carryForwardPaths)
			: getPriorTaskSummaryPaths(mid, sid, tid, base),
	]);
	// Legacy continue file only needed when the primary continue file was absent.
	const legacyContinueContent =
		!continueContent && legacyContinuePath
			? await loadFile(legacyContinuePath)
			: null;
	const taskPlanInline = 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");
	const slicePlanExcerpt = extractSliceExecutionExcerpt(
		slicePlanContent,
		relSliceFile(base, mid, sid, "PLAN"),
	);
	const resumeSection = buildResumeSection(
		continueContent,
		legacyContinueContent,
		continueRelPath,
		legacyContinuePath ? `${relSlicePath(base, mid, sid)}/continue.md` : null,
	);
	// ── Crash-resume: surface open intent chapters ──────────────────────────
	// If a prior autonomous run was interrupted mid-unit, one or more chapters
	// will be open in the DB. Inject them as a brief context block so the agent
	// knows what was underway without replaying the full transcript.
	const openChaptersSection = (() => {
		try {
			if (!isDbAvailable()) return "";
			const open = getOpenIntentChapters({ limit: 3 });
			if (!open || open.length === 0) return "";
			const rows = open.map(
				(c) =>
					`- **${c.unitId ?? c.unitType}** (started ${c.openedAt?.slice(0, 19) ?? "unknown"}): ${c.intent}`,
			);
			return `## Interrupted Work (crash-resume context)\n\nThe previous run was interrupted with the following work in progress:\n\n${rows.join("\n")}\n\nIf any of these units overlap with the current task, pick up where you left off.`;
		} catch {
			return "";
		}
	})();
	const priorLines =
		priorSummaries.length > 0
			? priorSummaries.map((p) => `- \`${p}\``).join("\n")
			: "- (no prior tasks)";
	// For minimal inline level, only carry forward the most recent prior summary
	const effectivePriorSummaries =
		inlineLevel === "minimal" && priorSummaries.length > 1
			? priorSummaries.slice(-1)
			: priorSummaries;
	const carryForwardSection = await buildCarryForwardSection(
		effectivePriorSummaries,
		base,
	);
	// Only include knowledge if it has content (not a "not found" result)
	// #4782 phase 3: knowledge and graph moved to composer computed registry.
	// The composer produces inline blocks including the computed knowledge/graph entries.
	// Note: slice-plan and task-plan are returned as null from the resolver — they
	// are already passed as separate template variables ({{taskPlanInline}}, {{slicePlanExcerpt}})
	// and adding them to the composer would render them twice.
	const { inline: composedInlines } = await composeUnitContext("execute-task", {
		base,
		resolveArtifact: async (key) => {
			switch (key) {
				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"),
					...(composedInlines ? [composedInlines] : []),
				].join("\n\n---\n\n");
	const taskSummaryPath = join(
		base,
		`${relSlicePath(base, mid, sid)}/tasks/${tid}-SUMMARY.md`,
	);
	const overridesSection = formatOverridesSection(activeOverrides);
	const runtimeContext = runtimeContent
		? `### Runtime Context\nSource: \`.sf/RUNTIME.md\`\n\n${runtimeContent.trim()}`
		: "";
	// Compute verification budget for the executor's context window (issue #707)
	const prefs = loadEffectiveSFPreferences();
	const contextWindow = resolveExecutorContextWindow(
		opts.modelRegistry,
		prefs?.preferences,
		opts.sessionContextWindow,
	);
	const budgets = computeBudgets(contextWindow);
	const verificationBudget = `~${Math.round(budgets.verificationBudgetChars / 1000)}K chars`;
	// Truncate carry-forward section when it exceeds 40% of inline context budget.
	const carryForwardBudget = Math.floor(budgets.inlineContextBudgetChars * 0.4);
	let finalCarryForward = carryForwardSection;
	if (carryForwardSection.length > carryForwardBudget) {
		finalCarryForward = truncateAtSectionBoundary(
			carryForwardSection,
			carryForwardBudget,
		).content;
	}
	let phaseAnchorSection = planAnchor ? formatAnchorForPrompt(planAnchor) : "";
	// SF ADR-011 Phase 2: inject any resolved-but-unapplied escalation override
	// into this task's prompt. Claim is atomic via DB UPDATE WHERE IS NULL, so
	// if a parallel build already injected it, we skip. Feature-gated by
	// phases.mid_execution_escalation. Prepended to phaseAnchorSection so it
	// appears near the top of the prompt above planning anchors.
	if (prefs?.preferences?.phases?.mid_execution_escalation === true) {
		try {
			const { claimOverrideForInjection } = await import("./escalation.js");
			const claimed = claimOverrideForInjection(base, mid, sid);
			if (claimed) {
				const block = claimed.injectionBlock + "\n\n---\n\n";
				phaseAnchorSection = phaseAnchorSection
					? `${block}${phaseAnchorSection}`
					: block;
			}
		} catch (escalationErr) {
			// Escalation module unavailable or threw — log and proceed.
			logWarning(
				"prompt",
				`escalation override injection failed: ${escalationErr.message}`,
			);
		}
	}
	// Task-scoped gates owned by execute-task (Q5/Q6/Q7). Pull only the
	// gates that plan-slice actually seeded for this task — tasks with no
	// external dependencies legitimately skip Q5, tasks with no runtime
	// load dimension skip Q6, etc.
	const etPending = getPendingGatesForTurn(mid, sid, "execute-task", tid);
	assertGateCoverage(etPending, "execute-task", { requireAll: false });
	const gatesToClose = renderGatesToCloseBlock(
		getGatesForTurn("execute-task"),
		{ pending: new Set(etPending.map((g) => g.gate_id)), allowOmit: true },
	);
	// Query-aware memory ranking: build a query from the active task
	// context so embeddings can promote semantically-relevant memories
	// above the cold static-rank top. Falls back to pure static ranking
	// when no gateway is configured or no embeddings exist yet — see
	// getRelevantMemoriesRanked for the fallback chain.
	//
	// The query is enriched beyond just titles: titles alone are too
	// short for cosine ranking. We add the slice's stored goal text
	// (the WHY) and the current touched-file list from git (the WHAT
	// — what code is in play right now), bounded so we don't blow up
	// the embedding tokens.
	const memoryQuery = await buildMemoryRetrievalQuery(
		base,
		mid,
		sid,
		sTitle,
		tTitle,
	);
	const memoriesSection = await buildProjectMemoriesSection(memoryQuery);
	// SF ADR-011 P2: when the feature is enabled, teach the executor that it can
	// surface non-obvious choices via the `escalation` field on complete_task
	// rather than silently picking. Autonomous mode auto-accepts the recommendation
	// (see phases.escalation_auto_accept), so this is low-cost overhead — but
	// it produces an audit trail and a hard constraint for downstream tasks.
	// When the feature is off, the field is silently dropped, so we omit the
	// guidance entirely to avoid misleading the agent.
	const escalationGuidance =
		prefs?.preferences?.phases?.mid_execution_escalation === true
			? [
					"**Surfacing non-obvious choices (optional).** If you hit a decision with material tradeoffs that downstream tasks should respect (e.g. data-loss vs. block-progress, two valid library choices with different long-term cost), include an `escalation` payload in your `complete_task` call:",
					"",
					"```json",
					'"escalation": {',
					'  "question": "Short, concrete question",',
					'  "options": [',
					'    { "id": "a", "label": "Option A", "tradeoffs": "what it costs" },',
					'    { "id": "b", "label": "Option B", "tradeoffs": "what it costs" }',
					"  ],",
					'  "recommendation": "a",',
					'  "recommendationRationale": "why a wins on this evidence",',
					'  "continueWithDefault": true',
					"}",
					"```",
					"",
					"Provide 2–4 options with concrete tradeoffs. The recommendation must reference one of the option ids. Autonomous mode accepts your recommendation, persists the choice + rationale as a memory, and carries it forward as a hard constraint for downstream tasks. The operator can review the audit trail later via `/escalate list --all`; the executed work itself can't be retroactively undone, so document your reasoning thoroughly. Set `continueWithDefault: false` only when the choice is severe enough that the loop should pause for human review even in autonomous mode (rare).",
				].join("\n")
			: "";
	// #M006 S02: knowledge and graph are now sourced entirely from composeUnitContext
	// computed registry (included in inlinedTemplates). The manual getKnowledgeInjection
	// call was removed — its result would duplicate the knowledge section already
	// produced by the composer.

	const rawPrompt = loadPrompt("execute-task", {
		memoriesSection,
		overridesSection,
		runtimeContext,
		phaseAnchorSection,
		openChaptersSection,
		workingDirectory: base,
		milestoneId: mid,
		sliceId: sid,
		sliceTitle: sTitle,
		taskId: tid,
		taskTitle: tTitle,
		planPath: join(base, relSliceFile(base, mid, sid, "PLAN")),
		slicePath: relSlicePath(base, mid, sid),
		taskPlanPath: taskPlanRelPath,
		taskPlanInline,
		slicePlanExcerpt,
		carryForwardSection: finalCarryForward,
		resumeSection,
		priorTaskLines: priorLines,
		taskSummaryPath,
		inlinedTemplates,
		verificationBudget,
		gatesToClose,
		escalationGuidance,
		skillActivation: buildSkillActivationBlock({
			base,
			milestoneId: mid,
			sliceId: sid,
			sliceTitle: sTitle,
			taskId: tid,
			taskTitle: tTitle,
			taskPlanContent,
			extraContext: [
				taskPlanInline,
				slicePlanExcerpt,
				finalCarryForward,
				resumeSection,
			],
			preferences: prefs?.preferences,
		}),
	});
	return prefs?.preferences?.terse_prompts === true
		? tersifyPrompt(rawPrompt)
		: rawPrompt;
}

/**
 * Strip verbose preamble boilerplate from a dispatch prompt when
 * `terse_prompts: true` is set in preferences.
 *
 * Purpose: reduce token overhead on long-context runs where context counts
 * more than politeness prose. Only removes recognized filler patterns; never
 * truncates factual content.
 *
 * Consumer: buildExecuteTaskPrompt when prefs.terse_prompts is true.
 */
function tersifyPrompt(text) {
	if (!text || typeof text !== "string") return text;
	const FILLER_PATTERNS = [
		/^A researcher explored the codebase and a planner decomposed the work — you are the executor\.[^\n]*/gm,
		/^The task plan below is your authoritative contract[^\n]*/gm,
		/^\s*Do not do broad re-research or spontaneous re-planning\.[^\n]*/gm,
	];
	let result = text;
	for (const pattern of FILLER_PATTERNS) {
		result = result.replace(pattern, "");
	}
	// Collapse 3+ consecutive blank lines into two
	result = result.replace(/\n{3,}/g, "\n\n");
	return result.trim() + "\n";
}
export async function buildCompleteSlicePrompt(
	mid,
	midTitle,
	sid,
	sTitle,
	base,
	level,
) {
	const inlineLevel = level ?? resolveInlineLevel();
	const skippedTaskBlock = (() => {
		try {
			if (!isDbAvailable()) return null;
			const skippedTasks = getSliceTasks(mid, sid).filter(
				(t) => t.status === "skipped",
			);
			if (skippedTasks.length === 0) return null;
			const rows = skippedTasks.map(
				(t) =>
					`- ${t.id}: ${t.title || "(untitled)"} — skipped by SF state; do not execute its task-level verification during slice closeout.`,
			);
			return [
				"### Skipped Tasks",
				"These tasks are closed as skipped. Treat their original verification commands as non-applicable for this closeout and record the gap in the slice summary/UAT instead of running them.",
				"",
				...rows,
			].join("\n");
		} catch {
			return null;
		}
	})();
	// #4782 phase 3: complete-slice migrated through composer. Manifest
	// declares [roadmap, slice-context, slice-plan, requirements,
	// prior-task-summaries, templates]. Overrides prepend and knowledge
	// splice stay imperative — they need the composer v2 contract
	// (computed + prepend blocks; see RFC #4924).
	const resolveArtifact = async (key) => {
		switch (key) {
			case "roadmap": {
				const p = resolveMilestoneFile(base, mid, "ROADMAP");
				const r = relMilestoneFile(base, mid, "ROADMAP");
				return await inlineFile(p, r, "Milestone Roadmap");
			}
			case "slice-context": {
				const p = resolveSliceFile(base, mid, sid, "CONTEXT");
				const r = relSliceFile(base, mid, sid, "CONTEXT");
				return await inlineFileOptional(
					p,
					r,
					"Slice Context (from discussion)",
				);
			}
			case "slice-plan": {
				const p = resolveSliceFile(base, mid, sid, "PLAN");
				const r = relSliceFile(base, mid, sid, "PLAN");
				return await inlineFile(p, r, "Slice Plan");
			}
			case "requirements":
				if (inlineLevel === "minimal") return null;
				return await inlineRequirementsFromDb(base, mid, sid, inlineLevel);
			case "prior-task-summaries": {
				const tDir = resolveTasksDir(base, mid, sid);
				if (!tDir) return null;
				const summaryFiles = resolveTaskFiles(tDir, "SUMMARY").sort();
				if (summaryFiles.length === 0) return null;
				const sRel = relSlicePath(base, mid, sid);
				// Load all task summaries in parallel — independent reads.
				const entries = await Promise.all(
					summaryFiles.map(async (file) => {
						const absPath = join(tDir, file);
						const content = await loadFile(absPath);
						if (!content) return null;
						const relPath = `${sRel}/tasks/${file}`;
						return `### Task Summary: ${file.replace(/-SUMMARY\.md$/i, "")}\nSource: \`${relPath}\`\n\n${content.trim()}`;
					}),
				);
				const blocks = entries.filter((b) => b !== null);
				if (skippedTaskBlock) blocks.push(skippedTaskBlock);
				return blocks.length > 0 ? blocks.join("\n\n---\n\n") : null;
			}
			case "templates": {
				const parts = [inlineTemplate("slice-summary", "Slice Summary")];
				if (inlineLevel !== "minimal") {
					parts.push(inlineTemplate("uat", "UAT"));
				}
				return parts.join("\n\n---\n\n");
			}
			default:
				return null;
		}
	};
	const { inline: composed } = await composeUnitContext("complete-slice", {
		base,
		resolveArtifact,
		computed: {
			knowledge: {
				build: async ({ kw }, b) => inlineKnowledgeBudgeted(b, kw),
				inputs: { kw: [...extractKeywords(midTitle), ...extractKeywords(sTitle)] },
			},
			graph: {
				build: async ({ sidTitle }, b) =>
					inlineGraphSubgraph(b, sidTitle, { budget: 3000 }),
				inputs: { sidTitle: `${sid} ${sTitle}` },
			},
		},
	});
	// Overrides section prepends to the top of the inlined context —
	// standard pattern for slice-level builders (until composer v2 lands
	// the prepend contract).
	const completeActiveOverrides = await loadActiveOverrides(base);
	const completeOverridesInline = formatOverridesSection(
		completeActiveOverrides,
	);
	const finalBody = completeOverridesInline
		? `${completeOverridesInline}\n\n---\n\n${composed}`
		: composed;
	const inlinedContext = capPreamble(
		`## Inlined Context (preloaded — do not re-read these files)\n\n${finalBody}`,
	);
	const closeoutControl = buildCompleteSliceControlBlock(mid, sid, base);
	const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
	const sliceRel = relSlicePath(base, mid, sid);
	const sliceSummaryPath = join(base, `${sliceRel}/${sid}-SUMMARY.md`);
	const sliceUatPath = join(base, `${sliceRel}/${sid}-UAT.md`);
	// Gates owned by complete-slice (e.g. Q8). Pull from the DB so the
	// prompt only prompts for gates the plan actually seeded. The tool
	// handler closes each gate based on the SUMMARY.md section content
	// after the assistant calls complete_slice.
	const csPending = getPendingGatesForTurn(mid, sid, "complete-slice");
	// coverage check: every pending row must be owned by complete-slice.
	// requireAll:false because a slice may have already closed some gates.
	assertGateCoverage(csPending, "complete-slice", { requireAll: false });
	const gatesToClose = renderGatesToCloseBlock(
		getGatesForTurn("complete-slice"),
		{ pending: new Set(csPending.map((g) => g.gate_id)), allowOmit: true },
	);
	return loadPrompt("complete-slice", {
		workingDirectory: base,
		milestoneId: mid,
		sliceId: sid,
		sliceTitle: sTitle,
		slicePath: sliceRel,
		roadmapPath: join(base, roadmapRel),
		closeoutControl,
		inlinedContext,
		sliceSummaryPath,
		sliceUatPath,
		gatesToClose,
	});
}
export async function buildCompleteMilestonePrompt(mid, midTitle, base, level) {
	const inlineLevel = level ?? resolveInlineLevel();
	const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
	const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
	// 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");
		if (isDbAvailable()) {
			sliceIds = getMilestoneSlices(mid)
				.filter((s) => s.status !== "skipped")
				.map((s) => s.id);
		}
	} catch (err) {
		logWarning(
			"prompt",
			`buildCompleteMilestonePrompt DB lookup failed: ${getErrorMessage(err)}`,
		);
	}
	// 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);
		}
	}
	const uniqueSliceIds = [...new Set(sliceIds)];
	// 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");
			const excerpt = await buildSliceSummaryExcerpt(
				summaryPath,
				summaryRel,
				sid,
			);
			return { summaryRel, 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");
	}
	const contextPath = resolveMilestoneFile(base, mid, "CONTEXT");
	const contextRel = relMilestoneFile(base, mid, "CONTEXT");
	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${parts.join("\n\n---\n\n")}`,
	);
	const milestoneSummaryPath = join(
		base,
		`${relMilestonePath(base, mid)}/${mid}-SUMMARY.md`,
	);
	const learningsRelPath = join(
		relMilestonePath(base, mid),
		`${mid}-LEARNINGS.md`,
	);
	const learningsAbsPath = join(base, learningsRelPath);
	const extractLearningsSteps = buildExtractionStepsBlock({
		milestoneId: mid,
		outputPath: learningsAbsPath,
		relativeOutputPath: learningsRelPath,
	});
	return loadPrompt("complete-milestone", {
		workingDirectory: base,
		milestoneId: mid,
		milestoneTitle: midTitle,
		roadmapPath: roadmapRel,
		inlinedContext,
		milestoneSummaryPath,
		extractLearningsSteps,
		skillActivation: buildSkillActivationBlock({
			base,
			milestoneId: mid,
			milestoneTitle: midTitle,
			extraContext: [inlinedContext],
			unitType: "complete-milestone",
		}),
	});
}
export async function buildValidateMilestonePrompt(mid, midTitle, base, level) {
	const inlineLevel = level ?? resolveInlineLevel();
	const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
	const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
	// Pre-compute verification classes from DB.
	let verificationClassesBlock = null;
	try {
		const { isDbAvailable, getMilestone } = await import("./sf-db.js");
		if (isDbAvailable()) {
			const milestone = getMilestone(mid);
			if (milestone) {
				const classes = [];
				if (milestone.verification_contract)
					classes.push(`- **Contract:** ${milestone.verification_contract}`);
				if (milestone.verification_integration)
					classes.push(
						`- **Integration:** ${milestone.verification_integration}`,
					);
				if (milestone.verification_operational)
					classes.push(
						`- **Operational:** ${milestone.verification_operational}`,
					);
				if (milestone.verification_uat)
					classes.push(`- **UAT:** ${milestone.verification_uat}`);
				if (classes.length > 0) {
					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")}`;
				}
			}
		}
	} catch (err) {
		logWarning(
			"prompt",
			`buildValidateMilestonePrompt verification classes lookup failed: ${getErrorMessage(err)}`,
		);
	}
	// Pre-compute slice summaries, assessments, and outstanding items.
	let valSliceIds = [];
	try {
		const { isDbAvailable, getMilestoneSlices } = await import("./sf-db.js");
		if (isDbAvailable()) {
			valSliceIds = getMilestoneSlices(mid)
				.filter((s) => s.status !== "skipped")
				.map((s) => s.id);
		}
	} catch (err) {
		logWarning(
			"prompt",
			`buildValidateMilestonePrompt slice IDs lookup failed: ${getErrorMessage(err)}`,
		);
	}
	// 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);
		}
	}
	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");
			const summaryRel = relSliceFile(base, mid, sid, "SUMMARY");
			const assessmentPath = resolveSliceFile(base, mid, sid, "ASSESSMENT");
			const assessmentRel = relSliceFile(base, mid, sid, "ASSESSMENT");
			const [summaryContent, assessmentInline] = await Promise.all([
				summaryPath ? loadFile(summaryPath) : Promise.resolve(null),
				inlineFileOptional(assessmentPath, assessmentRel, `${sid} Assessment`),
			]);
			const summaryInline = summaryContent
				? `### ${sid} Summary\nSource: \`${summaryRel}\`\n\n${summaryContent.trim()}`
				: `### ${sid} Summary\nSource: \`${summaryRel}\`\n\n_(not found — file does not exist yet)_`;
			const outstandingLines = [];
			if (summaryContent) {
				try {
					const summary = parseSummary(summaryContent);
					if (summary.followUps)
						outstandingLines.push(
							`- **${sid} Follow-ups:** ${summary.followUps.trim()}`,
						);
					if (summary.knownLimitations)
						outstandingLines.push(
							`- **${sid} Known Limitations:** ${summary.knownLimitations.trim()}`,
						);
				} catch {
					// parseSummary failure — skip outstanding items for this slice
				}
			}
			return { summaryInline, assessmentInline, outstandingLines };
		}),
	);
	// Assemble slice-summaries block (summaries + assessments interleaved).
	const sliceSummariesParts = [];
	const outstandingItems = [];
	for (const {
		summaryInline,
		assessmentInline,
		outstandingLines,
	} of valSliceResults) {
		sliceSummariesParts.push(summaryInline);
		if (assessmentInline) sliceSummariesParts.push(assessmentInline);
		outstandingItems.push(...outstandingLines);
	}
	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;
	let remediationRound = 0;
	let previousValidationBlock = null;
	if (validationContent) {
		const roundMatch = validationContent.match(/remediation_round:\s*(\d+)/);
		remediationRound = roundMatch ? parseInt(roundMatch[1], 10) + 1 : 1;
		previousValidationBlock = `### Previous Validation (re-validation round ${remediationRound})\nSource: \`${validationRel}\`\n\n${validationContent.trim()}`;
	}
	const contextPath = resolveMilestoneFile(base, mid, "CONTEXT");
	const contextRel = relMilestoneFile(base, mid, "CONTEXT");
	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${parts.join("\n\n---\n\n")}`,
	);
	const validationOutputPath = join(
		base,
		`${relMilestonePath(base, mid)}/${mid}-VALIDATION.md`,
	);
	const roadmapOutputPath = `${relMilestonePath(base, mid)}/${mid}-ROADMAP.md`;
	const mvGates = getGatesForTurn("validate-milestone");
	const gatesToEvaluate = renderGatesToCloseBlock(mvGates, {
		pending: new Set(mvGates.map((g) => g.id)),
		allowOmit: false,
	});
	return loadPrompt("validate-milestone", {
		workingDirectory: base,
		milestoneId: mid,
		milestoneTitle: midTitle,
		roadmapPath: roadmapOutputPath,
		inlinedContext,
		validationPath: validationOutputPath,
		remediationRound: String(remediationRound),
		gatesToEvaluate,
		skillActivation: buildSkillActivationBlock({
			base,
			milestoneId: mid,
			milestoneTitle: midTitle,
			extraContext: [inlinedContext],
			unitType: "validate-milestone",
		}),
	});
}
export async function buildReplanSlicePrompt(mid, midTitle, sid, sTitle, base) {
	const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
	const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
	const slicePlanPath = resolveSliceFile(base, mid, sid, "PLAN");
	const slicePlanRel = relSliceFile(base, mid, sid, "PLAN");
	const sliceContextPath = resolveSliceFile(base, mid, sid, "CONTEXT");
	const sliceContextRel = relSliceFile(base, mid, sid, "CONTEXT");
	// 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();
		for (const file of summaryFiles) {
			const absPath = join(tDir, file);
			const content = await loadFile(absPath);
			if (!content) continue;
			const summary = parseSummary(content);
			const sRel = relSlicePath(base, mid, sid);
			const relPath = `${sRel}/tasks/${file}`;
			if (summary.frontmatter.blocker_discovered) {
				blockerTaskId =
					summary.frontmatter.id || file.replace(/-SUMMARY\.md$/i, "");
				blockerBlocks.push(
					`### Blocker Task Summary: ${blockerTaskId}\nSource: \`${relPath}\`\n\n${content.trim()}`,
				);
			}
		}
	}
	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${parts.join("\n\n---\n\n")}`,
	);
	const replanPath = join(
		base,
		`${relSlicePath(base, mid, sid)}/${sid}-REPLAN.md`,
	);
	// Build capture context for replan prompt (captures that triggered this replan)
	let captureContext = "(none)";
	try {
		const { loadReplanCaptures } = await import("./triage-resolution.js");
		const replanCaptures = loadReplanCaptures(base);
		if (replanCaptures.length > 0) {
			captureContext = replanCaptures
				.map(
					(c) =>
						`- **${c.id}**: "${c.text}" — ${c.rationale ?? "no rationale"}`,
				)
				.join("\n");
		}
	} catch (err) {
		logWarning("prompt", `loadReplanCaptures failed: ${getErrorMessage(err)}`);
	}
	return loadPrompt("replan-slice", {
		workingDirectory: base,
		milestoneId: mid,
		sliceId: sid,
		sliceTitle: sTitle,
		slicePath: relSlicePath(base, mid, sid),
		planPath: join(base, slicePlanRel),
		blockerTaskId,
		inlinedContext,
		replanPath,
		captureContext,
		skillActivation: buildSkillActivationBlock({
			base,
			milestoneId: mid,
			milestoneTitle: midTitle,
			sliceId: sid,
			sliceTitle: sTitle,
			extraContext: [inlinedContext, captureContext],
			unitType: "replan-slice",
		}),
	});
}
export async function buildRunUatPrompt(
	mid,
	sliceId,
	uatPath,
	uatContent,
	base,
) {
	// #4782 phase 3: run-uat migrated to composeUnitContext v2.
	// Behavior-equivalent — resolver dispatches to the same inline* helpers.
	const resolveArtifact = async (key) => {
		switch (key) {
			case "slice-uat": {
				const p = resolveSliceFile(base, mid, sliceId, "UAT");
				return await inlineFile(p, uatPath, `${sliceId} UAT`);
			}
			case "slice-summary": {
				const p = resolveSliceFile(base, mid, sliceId, "SUMMARY");
				if (!p) return null;
				const r = relSliceFile(base, mid, sliceId, "SUMMARY");
				return await inlineFileOptional(p, r, `${sliceId} Summary`);
			}
			case "project":
				return await inlineProjectFromDb(base);
			default:
				return null;
		}
	};
	const { inline: composed } = await composeUnitContext("run-uat", {
		resolveArtifact,
	});
	const inlinedContext = capPreamble(
		`## Inlined Context (preloaded — do not re-read these files)\n\n${composed}`,
	);
	const uatResultPath = join(
		base,
		relSliceFile(base, mid, sliceId, "ASSESSMENT"),
	);
	const uatType = getUatType(uatContent);
	return loadPrompt("run-uat", {
		workingDirectory: base,
		milestoneId: mid,
		sliceId,
		uatPath,
		uatResultPath,
		uatType,
		inlinedContext,
		skillActivation: buildSkillActivationBlock({
			base,
			milestoneId: mid,
			sliceId,
			extraContext: [inlinedContext],
			unitType: "run-uat",
		}),
	});
}
export async function buildReassessRoadmapPrompt(
	mid,
	midTitle,
	completedSliceId,
	base,
	level,
) {
	const inlineLevel = level ?? resolveInlineLevel();
	// #4782 phase 2 pilot: reassess-roadmap is the first unit type to
	// compose its inlined context through the manifest-driven composer.
	// The resolver below dispatches artifact keys to the existing inline*
	// helpers, preserving identical output so the migration is
	// observable-equivalent. Knowledge stays outside the composer (it's
	// budget-driven, not manifest-driven) until a later phase formalizes
	// knowledge/memory policies as composer inputs.
	const resolveArtifact = async (key) => {
		switch (key) {
			case "roadmap": {
				const p = resolveMilestoneFile(base, mid, "ROADMAP");
				const r = relMilestoneFile(base, mid, "ROADMAP");
				return await inlineFile(p, r, "Current Roadmap");
			}
			case "slice-context": {
				const p = resolveSliceFile(base, mid, completedSliceId, "CONTEXT");
				const r = relSliceFile(base, mid, completedSliceId, "CONTEXT");
				return await inlineFileOptional(
					p,
					r,
					"Slice Context (from discussion)",
				);
			}
			case "slice-summary": {
				const p = resolveSliceFile(base, mid, completedSliceId, "SUMMARY");
				const r = relSliceFile(base, mid, completedSliceId, "SUMMARY");
				return await inlineFile(p, r, `${completedSliceId} Summary`);
			}
			case "project":
				if (inlineLevel === "minimal") return null;
				return await inlineProjectFromDb(base);
			case "requirements":
				if (inlineLevel === "minimal") return null;
				return await inlineRequirementsFromDb(
					base,
					mid,
					undefined,
					inlineLevel,
				);
			case "decisions":
				if (inlineLevel === "minimal") return null;
				return await inlineDecisionsFromDb(base, mid, undefined, inlineLevel);
			default:
				return null;
		}
	};
	const { inline: composed } = await composeUnitContext("reassess-roadmap", {
		base,
		resolveArtifact,
		computed: {
			knowledge: {
				build: async ({ kw }, b) => inlineKnowledgeBudgeted(b, kw),
				inputs: { kw: extractKeywords(midTitle) },
			},
			graph: {
				build: async ({ query }, b) =>
					inlineGraphSubgraph(b, query, { budget: 3000 }),
				inputs: { query: `${mid} ${midTitle}` },
			},
		},
	});
	const inlinedContext = capPreamble(
		`## Inlined Context (preloaded — do not re-read these files)\n\n${composed}`,
	);
	const assessmentPath = join(
		base,
		relSliceFile(base, mid, completedSliceId, "ASSESSMENT"),
	);
	// Build deferred captures context for reassess prompt
	let deferredCaptures = "(none)";
	try {
		const { loadDeferredCaptures } = await import("./triage-resolution.js");
		const deferred = loadDeferredCaptures(base);
		if (deferred.length > 0) {
			deferredCaptures = deferred
				.map(
					(c) =>
						`- **${c.id}**: "${c.text}" — ${c.rationale ?? "deferred during triage"}`,
				)
				.join("\n");
		}
	} catch (err) {
		logWarning(
			"prompt",
			`loadDeferredCaptures failed: ${getErrorMessage(err)}`,
		);
	}
	const reassessCommitInstruction =
		"Do not commit — .sf/ planning docs are managed externally and not tracked in git.";
	return loadPrompt("reassess-roadmap", {
		workingDirectory: base,
		milestoneId: mid,
		milestoneTitle: midTitle,
		completedSliceId,
		roadmapPath: relMilestoneFile(base, mid, "ROADMAP"),
		assessmentPath,
		inlinedContext,
		deferredCaptures,
		commitInstruction: reassessCommitInstruction,
		skillActivation: buildSkillActivationBlock({
			base,
			milestoneId: mid,
			milestoneTitle: midTitle,
			extraContext: [inlinedContext, deferredCaptures],
			unitType: "reassess-roadmap",
		}),
	});
}
// ─── Reactive Execute Prompt ──────────────────────────────────────────────
export async function buildReactiveExecutePrompt(
	mid,
	midTitle,
	sid,
	sTitle,
	readyTaskIds,
	base,
	subagentModel,
	opts,
) {
	const { loadSliceTaskIO, deriveTaskGraph, graphMetrics } = await import(
		"./reactive-graph.js"
	);
	// Build graph for context
	const taskIO = await loadSliceTaskIO(base, mid, sid);
	const graph = deriveTaskGraph(taskIO);
	const metrics = graphMetrics(graph);
	// Build graph context section
	const graphLines = [];
	for (const node of graph) {
		const status = node.done
			? "✅ done"
			: readyTaskIds.includes(node.id)
				? "🟢 ready"
				: "⏳ waiting";
		const deps =
			node.dependsOn.length > 0
				? ` (depends on: ${node.dependsOn.join(", ")})`
				: "";
		graphLines.push(`- **${node.id}: ${node.title}** — ${status}${deps}`);
		if (node.outputFiles.length > 0) {
			graphLines.push(
				`  - Outputs: ${node.outputFiles.map((f) => `\`${f}\``).join(", ")}`,
			);
		}
	}
	const graphContext = [
		`Tasks: ${metrics.taskCount}, Edges: ${metrics.edgeCount}, Ready: ${metrics.readySetSize}`,
		"",
		...graphLines,
	].join("\n");
	// Build individual subagent prompts for each ready task in parallel.
	const modelSuffix = subagentModel ? ` with model: "${subagentModel}"` : "";
	const taskResults = await Promise.all(
		readyTaskIds.map(async (tid) => {
			const node = graph.find((n) => n.id === tid);
			const tTitle = node?.title ?? tid;
			// Build dependency-scoped carry-forward paths for this task.
			const depPaths = await getDependencyTaskSummaryPaths(
				mid,
				sid,
				tid,
				node?.dependsOn ?? [],
				base,
			);
			// Build a full execute-task prompt with dependency-based carry-forward.
			const taskPrompt = await buildExecuteTaskPrompt(
				mid,
				sid,
				sTitle,
				tid,
				tTitle,
				base,
				{
					carryForwardPaths: depPaths,
					sessionContextWindow: opts?.sessionContextWindow,
					modelRegistry: opts?.modelRegistry,
				},
			);
			const section = [
				`### ${tid}: ${tTitle}`,
				"",
				`Use this as the prompt for a \`subagent\` call${modelSuffix}:`,
				"",
				"```",
				taskPrompt,
				"```",
			].join("\n");
			return { tid, tTitle, section };
		}),
	);
	const readyTaskListLines = taskResults.map(
		({ tid, tTitle }) => `- **${tid}: ${tTitle}**`,
	);
	const subagentSections = taskResults.map(({ section }) => section);
	const inlinedTemplates = inlineTemplate("task-summary", "Task Summary");
	return loadPrompt("reactive-execute", {
		workingDirectory: base,
		milestoneId: mid,
		milestoneTitle: midTitle,
		sliceId: sid,
		sliceTitle: sTitle,
		graphContext,
		readyTaskCount: String(readyTaskIds.length),
		readyTaskList: readyTaskListLines.join("\n"),
		subagentPrompts: subagentSections.join("\n\n---\n\n"),
		inlinedTemplates,
	});
}
// ─── Gate Evaluation ──────────────────────────────────────────────────────
//
// Gate definitions (question, guidance, owner turn) now live in
// gate-registry.ts so that prompt builders, dispatch rules, state
// derivation, and tool handlers all consult the same source of truth.
// See gate-registry.ts for the full ownership map.
/**
 * Render a "Gates to Close" block for turns like `complete-slice` and
 * `validate-milestone` that own gates which are closed as a side-effect
 * of writing artifact sections (not via a dedicated gate-evaluate
 * subagent loop).
 *
 * Returns a plain-text block or an empty string if there are no gates to
 * close, so callers can drop it straight into a template variable.
 */
function renderGatesToCloseBlock(gates, opts) {
	const applicable = gates.filter((g) => opts.pending.has(g.id));
	if (applicable.length === 0) return "";
	const lines = [];
	lines.push("## Gates to Close");
	lines.push("");
	lines.push(
		"These quality gates are still pending for this unit. You MUST address every one before calling the closing tool — the handler closes the DB row based on whether the corresponding artifact section is present.",
	);
	lines.push("");
	for (const def of applicable) {
		lines.push(`### ${def.id} — ${def.promptSection}`);
		lines.push("");
		lines.push(`**Question:** ${def.question}`);
		lines.push("");
		lines.push(def.guidance);
		if (opts.allowOmit) {
			lines.push("");
			lines.push(
				`If this gate genuinely does not apply to this unit, leave the **${def.promptSection}** section empty and the handler will record it as \`omitted\`. Otherwise, fill the section with concrete evidence.`,
			);
		}
		lines.push("");
	}
	return lines.join("\n").trimEnd();
}
export async function buildParallelResearchSlicesPrompt(
	mid,
	midTitle,
	slices,
	basePath,
	subagentModel,
) {
	// Build individual research-slice prompts for each slice in parallel.
	const entries = await Promise.all(
		slices.map(async (slice) => {
			const slicePrompt = await buildResearchSlicePrompt(
				mid,
				midTitle,
				slice.id,
				slice.title,
				basePath,
			);
			const guardedPrompt = [
				"IMPORTANT CHILD-AGENT OVERRIDE:",
				"- You are already one member of the parent parallel research batch.",
				"- Do not call `subagent`, `await_subagent`, or any other delegation tool from inside this child run.",
				"- If the embedded research-slice prompt suggests a research swarm, treat that requirement as already satisfied by the parent dispatch and perform the slice research directly.",
				"",
				slicePrompt,
			].join("\n");
			return { slice, guardedPrompt };
		}),
	);
	const subagentSections = entries.map(({ slice, guardedPrompt }) => {
		return [
			`### ${slice.id}: ${slice.title}`,
			"",
			"Task payload:",
			"",
			"```",
			guardedPrompt,
			"```",
		].join("\n");
	});
	const tasks = entries.map(({ guardedPrompt }) => {
		const task = {
			agent: "worker",
			cwd: basePath,
			task: guardedPrompt,
		};
		if (subagentModel) task.model = subagentModel;
		return task;
	});
	const subagentCall = JSON.stringify({ tasks }, null, 2);
	return loadPrompt("parallel-research-slices", {
		mid,
		midTitle,
		sliceCount: String(slices.length),
		sliceList: slices.map((s) => `- **${s.id}**: ${s.title}`).join("\n"),
		subagentCall,
		subagentPrompts: subagentSections.join("\n\n---\n\n"),
	});
}
export async function buildGateEvaluatePrompt(
	mid,
	midTitle,
	sid,
	sTitle,
	base,
	subagentModel,
) {
	// Pull only the gates this turn actually owns (Q3/Q4). Filter via the
	// registry so that scope:"slice" gates owned by other turns (Q8) can't
	// leak into this prompt and can't block dispatch via silent skip.
	const pending = getPendingGatesForTurn(mid, sid, "gate-evaluate");
	// Fails loudly if the pending list contains a gate id the registry
	// doesn't own for this turn. Missing owned gates is allowed here —
	// `gate-evaluate` is dispatched whenever *any* of its owned gates are
	// pending, not only when all of them are.
	assertGateCoverage(pending, "gate-evaluate", { requireAll: false });
	// Load the slice plan for context
	const planFile = resolveSliceFile(base, mid, sid, "PLAN");
	const planContent = planFile
		? ((await loadFile(planFile)) ?? "(plan file empty)")
		: "(plan file not found)";
	// Build per-gate subagent prompts from the pending rows. Because the
	// registry has already validated every row, `getGateDefinition` cannot
	// return undefined here.
	const pendingIds = new Set(pending.map((g) => g.gate_id));
	const gateDefs = getGatesForTurn("gate-evaluate").filter((def) =>
		pendingIds.has(def.id),
	);
	const subagentSections = [];
	const gateListLines = [];
	for (const def of gateDefs) {
		gateListLines.push(`- **${def.id}**: ${def.question}`);
		const subPrompt = [
			`You are evaluating quality gate **${def.id}** for slice ${sid} (${sTitle}).`,
			"",
			`## Question: ${def.question}`,
			"",
			def.guidance,
			"",
			"## Slice Plan",
			"",
			planContent,
			"",
			"## Instructions",
			"",
			"Analyze the slice plan above and answer the gate question.",
			`Call the \`record_gate\` tool with:`,
			`- \`milestoneId\`: "${mid}"`,
			`- \`sliceId\`: "${sid}"`,
			`- \`gateId\`: "${def.id}"`,
			'- `verdict`: "pass" (no concerns), "flag" (concerns found), or "omitted" (not applicable)',
			"- `rationale`: one-sentence justification",
			"- `findings`: detailed markdown findings (or empty if omitted)",
		].join("\n");
		const modelSuffix = subagentModel ? ` with model: "${subagentModel}"` : "";
		subagentSections.push(
			[
				`### ${def.id}: ${def.question}`,
				"",
				`Use this as the prompt for a \`subagent\` call${modelSuffix}:`,
				"",
				"```",
				subPrompt,
				"```",
			].join("\n"),
		);
	}
	return loadPrompt("gate-evaluate", {
		workingDirectory: base,
		milestoneId: mid,
		milestoneTitle: midTitle,
		sliceId: sid,
		sliceTitle: sTitle,
		slicePlanContent: planContent,
		gateCount: String(pending.length),
		gateList: gateListLines.join("\n"),
		subagentPrompts: subagentSections.join("\n\n---\n\n"),
	});
}
export async function buildRewriteDocsPrompt(
	mid,
	midTitle,
	activeSlice,
	base,
	overrides,
) {
	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");
		const slicePlanRel = relSliceFile(base, mid, sid, "PLAN");
		if (slicePlanPath) {
			docList.push(`- Slice plan: \`${slicePlanRel}\``);
			const tDir = resolveTasksDir(base, mid, sid);
			if (tDir) {
				// DB primary path — get incomplete tasks
				let incompleteTasks = null;
				try {
					const { isDbAvailable, getSliceTasks } = await import("./sf-db.js");
					if (isDbAvailable()) {
						incompleteTasks = getSliceTasks(mid, sid)
							.filter((t) => t.status !== "complete" && t.status !== "done")
							.map((t) => ({ id: t.id }));
					}
				} catch (err) {
					logWarning(
						"prompt",
						`buildRewriteDocsPrompt DB task lookup failed: ${getErrorMessage(err)}`,
					);
				}
				if (!incompleteTasks) {
					// DB unavailable — no task data to inline
					incompleteTasks = [];
				}
				if (incompleteTasks) {
					for (const task of incompleteTasks) {
						const taskPlanPath = resolveTaskFile(
							base,
							mid,
							sid,
							task.id,
							"PLAN",
						);
						if (taskPlanPath) {
							const taskRelPath = `${relSlicePath(base, mid, sid)}/tasks/${task.id}-PLAN.md`;
							docList.push(`- Task plan: \`${taskRelPath}\``);
						}
					}
				}
			}
		}
	}
	const decisionsPath = resolveSfRootFile(base, "DECISIONS");
	if (existsSync(decisionsPath))
		docList.push(`- Decisions: \`${relSfRootFile("DECISIONS")}\``);
	const requirementsPath = resolveSfRootFile(base, "REQUIREMENTS");
	if (existsSync(requirementsPath))
		docList.push(`- Requirements: \`${relSfRootFile("REQUIREMENTS")}\``);
	const projectPath = resolveSfRootFile(base, "PROJECT");
	if (existsSync(projectPath))
		docList.push(`- Project: \`${relSfRootFile("PROJECT")}\``);
	const contextPath = resolveMilestoneFile(base, mid, "CONTEXT");
	const contextRel = relMilestoneFile(base, mid, "CONTEXT");
	if (contextPath)
		docList.push(`- Milestone context (reference only): \`${contextRel}\``);
	const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
	const roadmapRel = relMilestoneFile(base, mid, "ROADMAP");
	if (roadmapPath) docList.push(`- Roadmap: \`${roadmapRel}\``);
	const overrideContent = overrides
		.map((o, i) =>
			[
				`### Override ${i + 1}`,
				`**Change:** ${o.change}`,
				`**Issued:** ${o.timestamp}`,
				`**During:** ${o.appliedAt}`,
			].join("\n"),
		)
		.join("\n\n");
	const documentList =
		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 inlinedContext = capPreamble(
		`## Inlined Context (preloaded — do not re-read these files)\n\n${composed}`,
	);

	return loadPrompt("rewrite-docs", {
		milestoneId: mid,
		milestoneTitle: midTitle,
		sliceId: sid ?? "none",
		sliceTitle: sTitle,
		overrideContent,
		documentList,
		overridesPath: relSfRootFile("OVERRIDES"),
		inlinedContext,
	});
}

/**
 * Build the deploy prompt for a completed milestone.
 *
 * Purpose: executes the project's configured deploy command, records the
 * outcome to deploy_runs in sf.db, and surfaces the deployed URL so the
 * smoke unit can verify it.
 *
 * Consumer: auto-dispatch.js `deploy` unit type, triggered after release-done
 * when prefs.deploy.target is set.
 */
export async function buildDeployPrompt(mid, midTitle, base) {
	const prefs = loadEffectiveSFPreferences();
	const deploy = prefs?.preferences?.deploy ?? {};
	const resolveArtifact = async (key) => {
		switch (key) {
			case "project":
				return await inlineProjectFromDb(base);
			default:
				return null;
		}
	};
	const { inline: composed } = await composeUnitContext("deploy", {
		resolveArtifact,
	});
	const inlinedContext = capPreamble(
		`## Inlined Context (preloaded — do not re-read these files)\n\n${composed}`,
	);
	return loadPrompt("deploy", {
		workingDirectory: base,
		milestoneId: mid,
		milestoneTitle: midTitle,
		deployTarget: deploy.target ?? "custom",
		deployCommand: deploy.command ?? "echo 'No deploy command configured'",
		deployedUrl: deploy.url ?? "",
		releaseVersion: deploy.current_version ?? "0.0.0",
		inlinedContext,
		skillActivation: buildSkillActivationBlock({
			base,
			milestoneId: mid,
			extraContext: [inlinedContext],
			unitType: "deploy",
		}),
	});
}

/**
 * Build the smoke-production prompt for a deployed milestone.
 *
 * Purpose: exercises the live production URL after deploy to verify the
 * deployment is correct; writes smoke_results to sf.db; blocks on failure
 * so rollback can run.
 *
 * Consumer: auto-dispatch.js `smoke-production` unit type, triggered after
 * deploy-done when prefs.deploy.url is set.
 */
export async function buildSmokeProductionPrompt(
	mid,
	midTitle,
	deployRunId,
	base,
) {
	const prefs = loadEffectiveSFPreferences();
	const deploy = prefs?.preferences?.deploy ?? {};
	const resolveArtifact = async (key) => {
		switch (key) {
			case "project":
				return await inlineProjectFromDb(base);
			default:
				return null;
		}
	};
	const { inline: composed } = await composeUnitContext("smoke-production", {
		resolveArtifact,
	});
	const inlinedContext = capPreamble(
		`## Inlined Context (preloaded — do not re-read these files)\n\n${composed}`,
	);
	return loadPrompt("smoke-production", {
		workingDirectory: base,
		milestoneId: mid,
		milestoneTitle: midTitle,
		deployRunId,
		deployedUrl: deploy.url ?? "",
		versionPath: deploy.version_path ?? "",
		releaseVersion: "",
		verdict: "PENDING",
		inlinedContext,
		skillActivation: buildSkillActivationBlock({
			base,
			milestoneId: mid,
			extraContext: [inlinedContext],
			unitType: "smoke-production",
		}),
	});
}

/**
 * Build the release prompt for a completed, validated milestone.
 *
 * Purpose: bumps semver, writes CHANGELOG, commits, tags, and optionally
 * publishes (npm/docker/github-release); records to release_records in sf.db.
 *
 * Consumer: auto-dispatch.js `release` unit type, triggered after
 * completing-milestone when prefs.deploy.auto_release is truthy.
 */
export async function buildReleasePrompt(mid, midTitle, base) {
	const prefs = loadEffectiveSFPreferences();
	const deploy = prefs?.preferences?.deploy ?? {};
	const resolveArtifact = async (key) => {
		switch (key) {
			case "project":
				return await inlineProjectFromDb(base);
			default:
				return null;
		}
	};
	const { inline: composed } = await composeUnitContext("release", {
		resolveArtifact,
	});
	const inlinedContext = capPreamble(
		`## Inlined Context (preloaded — do not re-read these files)\n\n${composed}`,
	);
	return loadPrompt("release", {
		workingDirectory: base,
		milestoneId: mid,
		milestoneTitle: midTitle,
		currentVersion: deploy.current_version ?? "0.0.0",
		releaseType: deploy.release_type ?? "patch",
		publishChannel: deploy.publish_channel ?? "none",
		today: new Date().toISOString().slice(0, 10),
		newVersion: deploy.current_version ?? "0.0.0",
		inlinedContext,
		skillActivation: buildSkillActivationBlock({
			base,
			milestoneId: mid,
			extraContext: [inlinedContext],
			unitType: "release",
		}),
	});
}

/**
 * Build the rollback prompt for a failed smoke-production check.
 *
 * Purpose: reverts the failed deployment to the last known-good state;
 * records to rollback_runs and updates the deploy_runs status in sf.db.
 *
 * Consumer: auto-dispatch.js `rollback` unit type, triggered after
 * smoke-failed when prefs.deploy.target is set.
 */
export async function buildRollbackPrompt(
	mid,
	midTitle,
	deployRunId,
	rollbackReason,
	base,
) {
	const prefs = loadEffectiveSFPreferences();
	const deploy = prefs?.preferences?.deploy ?? {};
	const resolveArtifact = async (key) => {
		switch (key) {
			case "project":
				return await inlineProjectFromDb(base);
			default:
				return null;
		}
	};
	const { inline: composed } = await composeUnitContext("rollback", {
		resolveArtifact,
	});
	const inlinedContext = capPreamble(
		`## Inlined Context (preloaded — do not re-read these files)\n\n${composed}`,
	);
	return loadPrompt("rollback", {
		workingDirectory: base,
		milestoneId: mid,
		milestoneTitle: midTitle,
		deployRunId,
		deployTarget: deploy.target ?? "custom",
		deployedUrl: deploy.url ?? "",
		appName: deploy.app_name ?? mid,
		rollbackReason,
		inlinedContext,
		skillActivation: buildSkillActivationBlock({
			base,
			milestoneId: mid,
			extraContext: [inlinedContext],
			unitType: "rollback",
		}),
	});
}

/**
 * Build the challenge (adversarial review) prompt for a milestone or slice.
 *
 * Purpose: activates the adversary agent to red-team, assumption-audit, or
 * failure-inject against the named target; blocks the pipeline if critical
 * findings require remediation.
 *
 * Consumer: auto-dispatch.js `challenge` unit type, dispatched by the
 * adversary role in the swarm after validation passes.
 */
export async function buildChallengePrompt(
	mid,
	midTitle,
	challengeTarget,
	challengeMode,
	base,
) {
	const resolveArtifact = async (key) => {
		switch (key) {
			case "project":
				return await inlineProjectFromDb(base);
			default:
				return null;
		}
	};
	const { inline: composed } = await composeUnitContext("challenge", {
		resolveArtifact,
	});
	const inlinedContext = capPreamble(
		`## Inlined Context (preloaded — do not re-read these files)\n\n${composed}`,
	);
	return loadPrompt("challenge", {
		workingDirectory: base,
		milestoneId: mid,
		milestoneTitle: midTitle,
		challengeTarget: challengeTarget ?? "milestone",
		challengeMode: challengeMode ?? "red-team",
		inlinedContext,
		skillActivation: buildSkillActivationBlock({
			base,
			milestoneId: mid,
			extraContext: [inlinedContext],
			unitType: "challenge",
		}),
	});
}
