/*
RFC-0356 §1: mission.materialize — populate Werkstück from pinned Sternsystem bundle.
- Does not define mission lifecycle — that is RFC-0355.
- RFC-1159: step 3 — io.exec exit-code sweep via execOrThrow
- RFC-1199: MATERIALIZATION-IN-PROGRESS sentinel — written in setup-staging into stagingDir/.git (rides atomic-move into workpiece/.git) or inside git-init-commit after git init on the non-git fallback; deleted only after the baseline commit lands; git-init-commit is idempotent on resume (skip commit when HEAD resolves and the staged index is clean); workpiece commit hook installs early in setup-staging
- RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt
- RFC-1265
- RFC-1275: steps 1-5: journal-portal domain, six blocks, binding emission
RFC-0356, RFC-0388, RFC-0389, RFC-0480, RFC-0517, RFC-0568, RFC-0580, RFC-0597, RFC-0620, RFC-0647, RFC-0659, RFC-0796, RFC-0822, RFC-0870, RFC-0952, RFC-0954, RFC-0958, RFC-1093, RFC-1097, RFC-1149
*/
import path from "node:path";
import { parse as yamlParse } from "yaml";
import type {
DiscoveredSiteWorkspace,
GeneratorOwnershipEntry,
KernelCommandInput,
KernelCommandResult,
KernelRuntimeContext,
} from "@warpgogol/werkstatt-engine/kernel";
import {
runKernelWire,
executeKernelCommand,
executeKernelPipeline,
} from "@warpgogol/werkstatt-engine/kernel";
import {
readSystemConfig,
readSystemState,
writeSystemState,
resolveCacheClonePath,
resolveMirrors,
resolveMirrorPath,
} from "../sternsystem/registry-io.ts";
import { installBordbuchPreCommitHook } from "../bordbuch/bordbuch-hook.ts";
import { readMissionManifest, writeMissionManifest, resolveMissionDir } from "./mission-io.ts";
import type { MissionManifest } from "@warpgogol/werkstatt-engine/schemas";
import { restoreEnvFilesFromCacheClone } from "./env-persist.ts";
import { restoreOperatorConfigFiles } from "./operator-config-files.ts";
import { acquireLock, releaseLock, commitWerkstattSideEffects } from "../werkstatt/index.ts";
import { atomicMoveDir, atomicWriteFile, resolveStagingDir } from "../werkstatt/atomic.ts";
import { appendAndCommitBordbuch } from "../bordbuch/bordbuch-commit-helper.ts";
import { installWorkpieceCommitHook } from "./workpiece-commit-hook.ts";
import { rescueWorkpieceEdits } from "./workpiece-rescue.ts";
import { resolveCurrentEcosystem, resolvePlatformSemanticHash } from "../handoff/bundle-io.ts";
import { byteHash } from "@warpgogol/werkstatt-engine/fingerprint";
import { compareSemver } from "@warpgogol/werkstatt-engine/kernel";
import {
createDefaultIO,
execOrThrow,
type WorkspaceIO,
} from "@warpgogol/werkstatt-shared/kernel/workspace-io";
import type { KernelPipelineStep } from "@warpgogol/werkstatt-engine/kernel";
import { runOperation, checkDifferentKindOperation, type OperationStep } from "../journal/index.ts";
/**
* RFC-1199: marker written into the workpiece `.git/` for the window between
* workpiece creation and the `git-init-commit` baseline commit. Lives inside
* `.git` so it can never enter the index; `mission.git.commit` and the
* workpiece pre-commit hook fail closed while it exists. Written in
* `setup-staging` (rides `atomic-move` into the workpiece) or inside
* `git-init-commit` on the non-git fallback; deleted only after the baseline
* commit lands.
*/
export const MATERIALIZATION_SENTINEL = "MATERIALIZATION-IN-PROGRESS";
export interface MissionMaterializeData {
missionId: string;
systemId: string;
versionComparison: {
verdict: "in-sync" | "catch-up" | "refuse-downgrade";
pinVersion: string;
platformVersion: string;
packagesDrift: boolean;
message: string;
};
migratorChain: Array<{ fromVersion: string; toVersion: string; rfc: string; applied: boolean }>;
capabilityDiff: { tier: "green" | "yellow" | "red"; items: Array> };
regeneration: { regeneratedFiles: string[]; success: boolean };
materializedAt: string;
preflightSkipped: boolean;
preflightSkipReason: string | null;
pipelineUsed: string;
mediaCacheWarmed: boolean;
mediaCacheSources: number;
bordbuchHookInstalled: boolean;
artifactCacheHit: boolean;
artifactCacheKey: string | null;
artifactCacheSkipped: boolean;
workspaceGlobCheck: { stalePackages: string[]; ok: boolean };
}
// RFC-0796: Pre-flight guard — check workspace globs for stale package.json references.
// Detects workspace:* dependencies pointing to packages that no longer exist
// (e.g. archived mission workpiece directories). Aborts before pnpm install
// to prevent cryptic resolution failures.
export async function checkWorkspaceGlobsForStalePackages(
workspaceRoot: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise<{ stalePackages: string[]; ok: boolean }> {
const workspaceYamlPath = path.join(workspaceRoot, "pnpm-workspace.yaml");
if (!(await io.exists(workspaceYamlPath))) {
return { stalePackages: [], ok: true };
}
const content = await io.readFile(workspaceYamlPath);
const workspace = yamlParse(content);
const patterns: string[] = workspace.packages ?? [];
// Collect all package.json files matching workspace globs
const packageJsonPaths: string[] = [];
for (const pattern of patterns) {
// Skip negation patterns
if (pattern.startsWith("!")) continue;
const globPattern = path.join(workspaceRoot, pattern, "package.json");
try {
packageJsonPaths.push(...(await io.glob(globPattern)));
} catch {
// Glob pattern may not match — skip
}
}
// Build set of all package names in the workspace
const workspacePackageNames = new Set();
for (const pkgJsonPath of packageJsonPaths) {
try {
const pkgContent = await io.readFile(pkgJsonPath);
const pkg = JSON.parse(pkgContent);
if (pkg.name) {
workspacePackageNames.add(pkg.name);
}
} catch {
// Can't read package.json — skip
}
}
// Check each package.json for workspace:* references to missing packages
const stalePackages: string[] = [];
for (const pkgJsonPath of packageJsonPaths) {
try {
const pkgContent = await io.readFile(pkgJsonPath);
const pkg = JSON.parse(pkgContent);
const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
for (const [depName, depSpec] of Object.entries(deps)) {
if (typeof depSpec === "string" && depSpec.startsWith("workspace:")) {
if (!workspacePackageNames.has(depName)) {
const relPath = path.relative(workspaceRoot, pkgJsonPath);
stalePackages.push(`${relPath} → ${depName} (${depSpec})`);
}
}
}
} catch {
// Can't read package.json — skip
}
}
return { stalePackages, ok: stalePackages.length === 0 };
}
interface MaterializationCacheState {
systemId: string;
cacheKey: string;
cacheCloneHead: string;
platformVersion: string;
platformSemanticHash: string;
writtenAt: string;
}
function flagString(input: KernelCommandInput, key: string): string | undefined {
const v = input.flags[key];
return typeof v === "string" ? v : undefined;
}
function flagBool(input: KernelCommandInput, key: string): boolean {
const v = input.flags[key];
return v === true || v === "true";
}
// RFC-0597: Materialization state file interface
interface MaterializationState {
systemId: string;
cacheCloneHead: string;
lastValidatedAt: string;
lastMissionId: string;
}
// RFC-0597: Media cache directories to persist across missions
const MEDIA_CACHE_DIRS = [".cache/video", ".cache/video-live"];
/**
* RFC-0620: Collect workspace-absolute generated paths from toOwnershipEntries(context.ownershipMap ?? []).
* These are entries whose path starts with `systems/{system}/` — they represent
* generated artifacts written to the cache clone (e.g. bordbuch projections), not
* authored content. They must be excluded from the data-path copy to avoid
* ownership.sync.validate OWN-01 failures in the workpiece context.
*
* The ownership map uses `{system}` as a template placeholder. This function
* returns paths relative to the cache clone root (e.g. `public/.well-known/bordbuch.json`).
*/
async function getWorkspaceAbsoluteGeneratedPaths(
ownershipMap: GeneratorOwnershipEntry[] | undefined,
): Promise> {
const siteChecksModule = "@warpgogol/werkstatt-site/checks";
const { toOwnershipEntries } = await import(siteChecksModule);
const prefix = "systems/{system}/";
const paths = new Set();
for (const entry of toOwnershipEntries(ownershipMap ?? [])) {
if (entry.path.startsWith(prefix)) {
const relativePath = entry.path.slice(prefix.length);
paths.add(relativePath);
}
}
return paths;
}
/**
* RFC-0620: Copy a directory, optionally skipping files whose relative path
* (relative to the copy root) is in the `skipPaths` set.
*/
async function copyDir(
src: string,
dest: string,
io: WorkspaceIO,
skipPaths?: Set,
rootSrc?: string,
): Promise {
if (!(await io.exists(src))) return;
await io.mkdir(dest);
const root = rootSrc ?? src;
const entries = await io.readdir(src);
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory) {
await copyDir(srcPath, destPath, io, skipPaths, root);
} else {
if (skipPaths && skipPaths.size > 0) {
const relPath = path.relative(root, srcPath);
if (skipPaths.has(relPath)) continue;
}
await io.copyFile(srcPath, destPath);
}
}
}
/**
* RFC-0389: Generate full runtime boilerplate into the staging Werkstück using
* onboarding templates and codegen generators, following the onboarding.scaffold pattern.
* Returns the list of all generated file paths (relative to staging dir).
*/
async function generateFullBoilerplate(
stagingDir: string,
systemId: string,
context: KernelRuntimeContext,
logger: { info: (msg: string) => void },
): Promise {
const codegenPath = "@warpgogol/werkstatt-site/codegen";
const onboardingPath = "@warpgogol/werkstatt-site/onboarding";
const checksPath = "@warpgogol/werkstatt-site/checks";
const [codegenMod, onboardingMod, checksMod] = await Promise.all([
import(codegenPath),
import(onboardingPath),
import(checksPath),
]);
const {
runGenerateAgentsDocs,
runGenerateApiRoutes,
runGenerateGlobalStyles,
runGenerateI18nMiddleware,
runGenerateOverlayPages,
runGeneratePublicInfrastructure,
runGenerateRoutes,
runGenerateScriptsOrchestrator,
runFontsImportsGenerate,
runBiomeCssGenerate,
} = codegenMod;
const {
applyTokens,
readTemplate,
readRuntimeTemplate,
generateWorkpiecePackageJson,
readTemplateFields,
resolveBasePath,
resolveAdapterBlock,
resolveJournalBindingsBlock,
} = onboardingMod;
const { runEnvExampleGenerate } = checksMod;
const regeneratedFiles: string[] = [];
// Resolve domain from system.md in the staging directory
const systemMdPath = path.join(stagingDir, "src", "content", "system.md");
let domain = "";
if (await context.io.exists(systemMdPath)) {
try {
const raw = await context.io.readFile(systemMdPath);
const fmMatch = raw.match(/^---\n([\s\S]*?)\n---/);
if (fmMatch) {
const domainMatch = fmMatch[1].match(/^ domain:\s*"?([^"\s]+)"?/m);
if (domainMatch) {
domain = domainMatch[1];
}
}
} catch (err) {
logger.info(
` Warning: failed to read system.md for domain extraction: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
const tokens: Record = {
CLIENT_ID: systemId,
DOMAIN: domain,
};
// Step 1: Write template files into staging directory
// RFC-0959: package.json is generated from WORKPIECE_DEPENDENCY_MANIFEST, not the template.
// RFC-1152: template readers are async — hoist the awaits out of the array
// literal so the .replace chain runs on strings, not Promises.
const templateFields = await readTemplateFields(context.io);
const { packageJson: generatedPkgJson } = await generateWorkpiecePackageJson({
workspaceRoot: context.workspaceRoot,
siteId: systemId,
templateFields,
workpieceDir: stagingDir,
io: context.io,
});
const astroConfigTemplate = await readRuntimeTemplate("astro.config.template.mjs", context.io);
const basePath = await resolveBasePath(stagingDir, context.io);
const templateFiles: Array<{ dest: string; content: string }> = [
{ dest: "package.json", content: generatedPkgJson },
{
dest: "astro.config.mjs",
content: applyTokens(
astroConfigTemplate
.replace(
"// WG_SITE_LINE",
domain ? ` site: "https://${domain}",` : " // site: omitted (no domain configured)",
)
.replace("// WG_BASE_LINE", basePath ? ` base: "${basePath}",` : "")
.replace("// WG_ADAPTER_BLOCK", resolveAdapterBlock(stagingDir)),
tokens,
),
},
{
dest: "wrangler.jsonc",
content: applyTokens(
(await readTemplate("wrangler.template.jsonc", context.io)).replace(
"// WG_JOURNAL_BINDINGS",
await resolveJournalBindingsBlock(stagingDir, context.io),
),
tokens,
),
},
{
dest: "tsconfig.json",
content: await readTemplate("tsconfig.template.json", context.io),
},
{
dest: ".gitignore",
content: applyTokens(await readRuntimeTemplate("gitignore.template", context.io), tokens),
},
{
dest: "postcss.config.cjs",
content: await readRuntimeTemplate("postcss.config.template.cjs", context.io),
},
{
dest: `.github/workflows/deploy-${systemId}.yml`,
content: applyTokens(
await readRuntimeTemplate("github-deploy.template.yaml", context.io),
tokens,
),
},
];
for (const { dest, content } of templateFiles) {
const fullPath = path.join(stagingDir, dest);
await context.io.mkdir(path.dirname(fullPath));
await atomicWriteFile(fullPath, content);
regeneratedFiles.push(dest);
}
logger.info(` Wrote ${templateFiles.length} template files`);
// Validate system.md presence before running generators (RFC-0389 failure modes)
if (!(await context.io.exists(systemMdPath))) {
throw new Error(
`[mission.materialize] system.md not found in staging directory — Sternsystem data set is incomplete`,
);
}
// Step 2: Construct app-scoped context for the staging Werkstück
const stagingSiteWorkspace: DiscoveredSiteWorkspace = {
name: systemId,
directory: stagingDir,
toolsDirectory: path.join(stagingDir, "tools"),
packageName: systemId,
};
const generatorInput: KernelCommandInput = {
argv: [`--app=${systemId}`, `--domain=${domain}`],
flags: { app: systemId, domain },
};
const appContext: KernelRuntimeContext = {
...context,
site: stagingSiteWorkspace,
siteExplicit: true,
};
// Step 3: Run kernel.wire to generate tools/ wiring
const wireResult = await runKernelWire(generatorInput, appContext);
const wireData = wireResult.data as Record;
const wireGenerated = wireData?.generated;
if (Array.isArray(wireGenerated)) {
for (const file of wireGenerated) {
if (typeof file === "string") {
regeneratedFiles.push(file);
}
}
}
logger.info(` kernel.wire completed`);
// Step 4: Run codegen generator sequence (same order as onboarding.scaffold)
const generators: Array<{
name: string;
fn: (input: KernelCommandInput, ctx: KernelRuntimeContext) => Promise;
}> = [
{ name: "agents.generate", fn: runGenerateAgentsDocs },
{ name: "overlay.pages.generate", fn: runGenerateOverlayPages },
{ name: "routes.generate", fn: runGenerateRoutes },
{ name: "api.routes.generate", fn: runGenerateApiRoutes },
{ name: "styles.global.generate", fn: runGenerateGlobalStyles },
// biome.css.generate must run after styles.global.generate (which emits
// global.css with @import "./biome.generated.css") and before dev server
// launch — without this file PostCSS fails with ENOENT.
{ name: "biome.css.generate", fn: runBiomeCssGenerate },
{ name: "fonts.imports.generate", fn: runFontsImportsGenerate },
{ name: "scripts.orchestrator.generate", fn: runGenerateScriptsOrchestrator },
{ name: "public.infrastructure.generate", fn: runGeneratePublicInfrastructure },
{ name: "i18n.middleware.generate", fn: runGenerateI18nMiddleware },
{ name: "env.example.generate", fn: runEnvExampleGenerate },
];
for (const { name, fn } of generators) {
const result = await fn(generatorInput, appContext);
if ((result.exitCode ?? 0) !== 0) {
throw new Error(
`[mission.materialize] codegen generator '${name}' failed: ${result.summary ?? `exitCode=${result.exitCode}, data=${JSON.stringify(result.data)}`}`,
);
}
// Collect generated files from the result
const data = result.data as Record;
const generated = data?.generated;
if (Array.isArray(generated)) {
for (const file of generated) {
if (typeof file === "string") {
regeneratedFiles.push(file);
}
}
}
logger.info(` ${name} completed`);
}
// Step 5: Copy .env.example to .env (RFC-0761 / DNA-40)
// .env.example has empty values with # How to obtain: instructions;
// .env is the single template for the operator to fill in.
const envExamplePath = path.join(stagingDir, ".env.example");
if (await context.io.exists(envExamplePath)) {
const envExampleContent = await context.io.readFile(envExamplePath);
await atomicWriteFile(path.join(stagingDir, ".env"), envExampleContent);
regeneratedFiles.push(".env.example");
logger.info(` .env.example, .env written`);
}
return regeneratedFiles;
}
/**
* RFC-0356 §1.1 step 2: sync the cache clone (mirrors[0]) from the bare repo (mirrors[1]).
* If the cache clone has a .git directory, fetch + reset to origin/main.
* If not but a bare mirror exists, clone it. If no bare mirror, skip (offline mode).
*/
async function syncCacheClone(
workspaceRoot: string,
systemId: string,
logger: { info: (msg: string) => void; warn: (msg: string) => void },
io: WorkspaceIO = createDefaultIO().io,
): Promise {
let config: Awaited> | null = null;
try {
config = await readSystemConfig(workspaceRoot, systemId);
} catch {
config = null;
}
if (!config) {
logger.info(` No system-config.yaml for system '${systemId}' — skipping cache clone sync`);
return;
}
const { cachePath, gitMirrors } = resolveMirrors(workspaceRoot, config);
const gitDir = path.join(cachePath, ".git");
if (gitMirrors.length === 0) {
logger.info(` No bare mirror for system '${systemId}' — skipping cache clone sync`);
return;
}
const bareRepoPath = resolveMirrorPath(workspaceRoot, gitMirrors[0].path);
if (await io.exists(gitDir)) {
// Cache clone exists — fetch and reset to origin/main
logger.info(` Fetching latest from ${bareRepoPath}…`);
try {
// Auto-abort any stuck rebase from a previous interrupted sync.
// .git/rebase-merge or .git/rebase-apply indicates an in-progress rebase
// that will cause git fetch / git reset to fail with "cannot rebase: you have
// unstaged changes" or "It seems that there is already a rebase-merge directory".
const rebaseMerge = path.join(gitDir, "rebase-merge");
const rebaseApply = path.join(gitDir, "rebase-apply");
if ((await io.exists(rebaseMerge)) || (await io.exists(rebaseApply))) {
logger.warn(` ⚠ Cache clone has an in-progress rebase — aborting it before sync.`);
const rebaseAbort = await io.exec("git", ["rebase", "--abort"], {
cwd: cachePath,
timeoutMs: 10_000,
});
if (rebaseAbort.exitCode !== 0) {
// If git rebase --abort fails, manually remove the rebase state directories
await io.rm(rebaseMerge, { recursive: true }).catch(() => {});
await io.rm(rebaseApply, { recursive: true }).catch(() => {});
}
}
// ADR-0031: warn on uncommitted changes before hard reset
const statusResult = await io.exec("git", ["status", "--porcelain"], {
cwd: cachePath,
timeoutMs: 10_000,
});
const status = statusResult.exitCode === 0 ? statusResult.stdout.trim() : "";
if (status) {
logger.warn(
` ⚠ Cache clone has uncommitted changes — they will be lost on reset. Push to bare repo before materializing.`,
);
}
await execOrThrow(io, "git", ["fetch", "origin"], { cwd: cachePath, timeoutMs: 30_000 });
// RFC-0987 Fix 4: resolve branch via symbolic-ref instead of rev-parse --abbrev-ref.
// In detached HEAD, rev-parse returns "HEAD" which produces invalid refspec for reset.
// Try symbolic-ref --short HEAD first (current branch when not detached),
// then refs/remotes/origin/HEAD (remote default for detached HEAD), then main.
let branch = "main";
const headRef = await io.exec("git", ["symbolic-ref", "--short", "HEAD"], {
cwd: cachePath,
timeoutMs: 10_000,
});
if (headRef.exitCode === 0 && headRef.stdout.trim()) {
branch = headRef.stdout.trim();
} else {
// Detached HEAD — try remote default
const originRef = await io.exec(
"git",
["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
{ cwd: cachePath, timeoutMs: 10_000 },
);
if (originRef.exitCode === 0) {
branch = originRef.stdout.trim().replace("origin/", "") || "main";
}
}
await execOrThrow(io, "git", ["reset", "--hard", `origin/${branch}`], {
cwd: cachePath,
timeoutMs: 30_000,
});
logger.info(` Cache clone synced to origin/${branch}`);
} catch (err) {
throw new Error(
`[mission.materialize] failed to sync cache clone for system '${systemId}': ${err instanceof Error ? err.message : String(err)}`,
);
}
} else if (await io.exists(cachePath)) {
// Directory exists but is not a git clone — clone into a temp dir and replace
logger.info(` Cloning ${bareRepoPath} into cache clone…`);
const tmpDir = `${cachePath}.clone-${process.pid}-${Date.now()}`;
try {
const cloneResult = await io.exec("git", ["clone", bareRepoPath, tmpDir], {
timeoutMs: 60_000,
});
if (cloneResult.exitCode !== 0) {
throw new Error(`git clone failed: ${cloneResult.stderr.trim()}`);
}
// Preserve .env and other untracked files from the old directory
const oldEntries = await io.readdir(cachePath);
for (const e of oldEntries) {
if (e.name === ".git") continue;
const src = path.join(cachePath, e.name);
const dest = path.join(tmpDir, e.name);
if (!(await io.exists(dest))) {
if (e.isDirectory) {
await copyDir(src, dest, io);
} else {
await io.copyFile(src, dest);
}
}
}
// Replace old directory with the clone
await io.rm(cachePath, { recursive: true });
await io.rename(tmpDir, cachePath);
logger.info(` Cache clone populated from ${bareRepoPath}`);
} catch (err) {
if (await io.exists(tmpDir)) {
await io.rm(tmpDir, { recursive: true }).catch(() => {});
}
throw new Error(
`[mission.materialize] failed to clone cache clone for system '${systemId}': ${err instanceof Error ? err.message : String(err)}`,
);
}
} else {
// Directory doesn't exist — clone directly
logger.info(` Cloning ${bareRepoPath} into cache clone…`);
await io.mkdir(path.dirname(cachePath));
const cloneResult = await io.exec("git", ["clone", bareRepoPath, cachePath], {
timeoutMs: 60_000,
});
try {
if (cloneResult.exitCode !== 0) {
throw new Error(`git clone failed: ${cloneResult.stderr.trim()}`);
}
logger.info(` Cache clone populated from ${bareRepoPath}`);
} catch (err) {
throw new Error(
`[mission.materialize] failed to clone cache clone for system '${systemId}': ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
interface PreflightValidatorResult {
command: string;
ok: boolean;
exitCode: number;
summary?: string;
}
interface PreflightReport {
schemaVersion: "1.0.0";
missionId: string;
systemId: string;
criticalPassed: boolean;
criticalResults: PreflightValidatorResult[];
warningResults: PreflightValidatorResult[];
skipped: boolean;
ranAt: string;
}
async function runPreflightGate(
workspaceRoot: string,
workpieceDir: string,
systemId: string,
missionId: string,
skipPreflight: boolean,
logger: { info: (msg: string) => void },
io: WorkspaceIO,
): Promise {
const ranAt = new Date().toISOString();
const evidenceDir = path.join(await resolveMissionDir(workspaceRoot, missionId, io), "evidence");
await io.mkdir(evidenceDir);
const reportPath = path.join(evidenceDir, "preflight-report.json");
if (skipPreflight) {
logger.info(` Preflight skipped (--skip-preflight)`);
const report: PreflightReport = {
schemaVersion: "1.0.0",
missionId,
systemId,
criticalPassed: true,
criticalResults: [],
warningResults: [],
skipped: true,
ranAt,
};
await atomicWriteFile(reportPath, JSON.stringify(report, null, 2) + "\n");
return report;
}
async function runSteps(
steps: KernelPipelineStep[],
siteName: string,
): Promise {
const results: PreflightValidatorResult[] = [];
for (const step of steps) {
logger.info(` Preflight: ${step.command}…`);
try {
const execResult = await executeKernelCommand({
workspaceRoot,
commandName: step.command,
siteName,
siteExplicit: true,
});
const single = Array.isArray(execResult) ? execResult[0] : execResult;
const ok = single?.ok ?? false;
const exitCode = single?.exitCode ?? 1;
const summary = single?.summary ?? "";
results.push({ command: step.command, ok, exitCode, summary });
if (!ok) {
logger.info(` ${step.command}: FAIL (exit ${exitCode})`);
} else {
logger.info(` ${step.command}: pass`);
}
} catch (err) {
const errMsg = err instanceof Error ? err.message : String(err);
results.push({
command: step.command,
ok: false,
exitCode: 1,
summary: `error: ${errMsg}`,
});
logger.info(` ${step.command}: ERROR — ${errMsg}`);
}
}
return results;
}
const siteChecksModule = "@warpgogol/werkstatt-site/checks";
const { MISSION_PREFLIGHT_CRITICAL, MISSION_PREFLIGHT_WARNING } = await import(siteChecksModule);
const criticalResults = await runSteps(MISSION_PREFLIGHT_CRITICAL, systemId);
const warningResults = await runSteps(MISSION_PREFLIGHT_WARNING, systemId);
const criticalPassed = criticalResults.every((r) => r.ok);
const report: PreflightReport = {
schemaVersion: "1.0.0",
missionId,
systemId,
criticalPassed,
criticalResults,
warningResults,
skipped: false,
ranAt,
};
await atomicWriteFile(reportPath, JSON.stringify(report, null, 2) + "\n");
if (!criticalPassed) {
const failures = criticalResults
.filter((r) => !r.ok)
.map((r) => ` ${r.command}: ${r.summary ?? "failed"}`)
.join("\n");
throw new Error(
`[mission.materialize] preflight content quality gate FAILED — critical validators did not pass:\n${failures}\n\n` +
`Workpiece preserved at ${workpieceDir} (no git init). ` +
`See evidence/preflight-report.json for details. ` +
`Fix the Sternsystem data set (systems/${systemId}/) and re-run mission.materialize, ` +
`or use --skip-preflight to bypass (Bordbuch-audited).`,
);
}
const warningFailures = warningResults.filter((r) => !r.ok);
if (warningFailures.length > 0) {
logger.info(
` Preflight: ${warningFailures.length} warning${warningFailures.length === 1 ? "" : "s"} — see evidence/preflight-report.json`,
);
}
return report;
}
// RFC-0647: ensurePlaywrightChromium extracted to @warpgogol/site-kernel-checks as ensureChromium.
// RFC-0659: Artifact cache directory and state file paths.
// The state file lives inside .cache/ so it is automatically gitignored.
const ARTIFACT_CACHE_DIR = ".cache/materialization";
const ARTIFACT_CACHE_STATE_FILE = ".cache/materialization-state.json";
/**
* RFC-0659: Compute the artifact cache key from cacheCloneHead, platformVersion,
* and platformSemanticHash. Returns the hash and the individual components for
* writing to the cache state file.
*/
async function computeArtifactCacheKey(
workspaceRoot: string,
cacheCloneHead: string,
): Promise<{ cacheKey: string; platformVersion: string; platformSemanticHash: string }> {
const { version: platformVersion } = await resolveCurrentEcosystem(workspaceRoot);
const platformSemanticHash = await resolvePlatformSemanticHash(workspaceRoot);
const cacheKey = byteHash(`${cacheCloneHead}|${platformVersion}|${platformSemanticHash}`);
return { cacheKey, platformVersion, platformSemanticHash };
}
/**
* RFC-0659: Resolve the current cache clone HEAD. Returns null if HEAD cannot
* be resolved (non-git cache clone, empty repo).
*/
async function resolveCacheCloneHead(systemDir: string, io: WorkspaceIO): Promise {
const result = await io.exec("git", ["rev-parse", "HEAD"], { cwd: systemDir });
return result.exitCode === 0 ? result.stdout.trim() : null;
}
/**
* RFC-0659: Read the artifact cache state file. Returns null if the file does
* not exist or is corrupt.
*/
async function readArtifactCacheState(
systemDir: string,
io: WorkspaceIO,
): Promise {
const statePath = path.join(systemDir, ARTIFACT_CACHE_STATE_FILE);
if (!(await io.exists(statePath))) return null;
try {
const raw = await io.readFile(statePath);
return JSON.parse(raw) as MaterializationCacheState;
} catch {
return null;
}
}
/**
* RFC-0659: Copy a directory tree, excluding specified top-level entries.
* Used to snapshot the workpiece (excluding .git/ and node_modules/) to the cache.
*/
async function copyDirExcluding(
src: string,
dest: string,
excludeTopLevel: Set,
io: WorkspaceIO,
): Promise {
if (!(await io.exists(src))) return;
await io.mkdir(dest);
const entries = await io.readdir(src);
for (const entry of entries) {
if (excludeTopLevel.has(entry.name)) continue;
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory) {
await copyDir(srcPath, destPath, io);
} else {
await io.copyFile(srcPath, destPath);
}
}
}
/**
* RFC-0659: Write the artifact cache state file and ensure .cache/ is gitignored
* in the cache clone. The state file lives inside .cache/ so it is automatically
* gitignored. The .gitignore update is written but NOT committed — committing
* would change the cache clone HEAD and invalidate the cache key on the next run.
*/
async function writeArtifactCacheState(
systemDir: string,
state: MaterializationCacheState,
_logger: { info: (msg: string) => void; warn: (msg: string) => void },
io: WorkspaceIO,
): Promise {
const statePath = path.join(systemDir, ARTIFACT_CACHE_STATE_FILE);
await io.mkdir(path.dirname(statePath));
await atomicWriteFile(statePath, JSON.stringify(state, null, 2) + "\n");
// RFC-0659: Ensure .cache/ is in the cache clone's .gitignore.
// Written but not committed — committing would change the cache clone HEAD
// and invalidate the cache key on the next materialization.
const gitignorePath = path.join(systemDir, ".gitignore");
let gitignoreContent = "";
if (await io.exists(gitignorePath)) {
gitignoreContent = await io.readFile(gitignorePath);
}
if (!gitignoreContent.includes(".cache/")) {
const newContent =
gitignoreContent.endsWith("\n") || gitignoreContent === ""
? gitignoreContent + ".cache/\n"
: gitignoreContent + "\n.cache/\n";
await atomicWriteFile(gitignorePath, newContent);
}
}
export interface MissionMaterializeInternalOptions {
reportOnly: boolean;
skipPreflight: boolean;
force: boolean;
skipOperationBlockCheck?: boolean;
}
export async function runMissionMaterializeInternal(
workspaceRoot: string,
manifest: MissionManifest,
context: KernelRuntimeContext,
options: MissionMaterializeInternalOptions,
): Promise> {
const { logger } = context;
const missionId = manifest.missionId;
const operationId = manifest.operationId;
const { reportOnly, skipPreflight, force, skipOperationBlockCheck } = options;
// RFC-0480: refuse materialization on paused Sternsystem (external edit detection)
const config = await readSystemConfig(workspaceRoot, manifest.systemId);
if (config?.status === "paused") {
throw new Error(
`[mission.materialize] system '${manifest.systemId}' is paused due to external edit detection. Run sternsystem.validate for details.`,
);
}
const systemDir = resolveCacheClonePath(workspaceRoot, manifest.systemId);
// RFC-0356 §1.1 step 2: fetch the latest remote state into the cache clone.
await syncCacheClone(workspaceRoot, manifest.systemId, logger);
// RFC-0953 Guard 2 (moved from runMissionMaterialize): auto-set currentMission
// when null or mismatched. Must run AFTER syncCacheClone because sync resets
// the cache clone to origin/main, wiping uncommitted state changes. If this
// guard ran before sync (as in RFC-0952), it would see the correct value,
// skip the repair, and sync would erase it — leaving currentMission null and
// causing "No target site with a kernel config could be resolved".
const state = await readSystemState(workspaceRoot, manifest.systemId);
if (state.currentMission !== manifest.missionId) {
if (state.currentMission === null) {
logger.info(
` [mission.materialize] currentMission was null in system-state.yaml — set to ${manifest.missionId}.`,
);
} else {
logger.warn(
` [mission.materialize] currentMission was "${state.currentMission}" in system-state.yaml — set to ${manifest.missionId}.`,
);
}
state.currentMission = manifest.missionId;
await writeSystemState(workspaceRoot, manifest.systemId, state);
await commitWerkstattSideEffects(
workspaceRoot,
[path.join("..", "systems-cache", manifest.systemId, "system-state.yaml")],
`werkstatt: mission.materialize state-repair ${manifest.missionId}`,
);
}
// RFC-0954: Fail-closed guard — block re-materialization when workpiece has
// uncommitted changes. Prevents silent content loss.
// Primary materialization (materializedAt === null) skips the guard — rescue
// handles any stale workpiece from a failed previous attempt.
// --force bypasses the guard (rescue still runs as safety net).
const missionDirForRescue = await resolveMissionDir(workspaceRoot, missionId, context.io);
const workpieceDirForRescue = path.join(missionDirForRescue, "workpiece");
if (
manifest.materializedAt &&
!force &&
(await context.io.exists(workpieceDirForRescue)) &&
(await context.io.exists(path.join(workpieceDirForRescue, ".git"))) &&
!(await context.io.exists(path.join(workpieceDirForRescue, ".closed")))
) {
let dirty = false;
try {
const statusResult = await context.io.exec("git", ["status", "--porcelain"], {
cwd: workpieceDirForRescue,
});
dirty = statusResult.exitCode === 0 && statusResult.stdout.trim().length > 0;
if (statusResult.exitCode !== 0) dirty = false;
} catch {
// git status failed — not a valid git repo, skip guard
}
if (dirty) {
throw new Error(
`[mission.materialize] workpiece has uncommitted changes — refusing to overwrite.
` +
`Commit your edits first:
` +
` pnpm exec werkstatt run mission.git.commit --mission ${missionId} --message "your message"
` +
`Or reconcile to merge edits into cache clone:
` +
` pnpm exec werkstatt run mission.reconcile --mission ${missionId}
` +
`To override (rescue will attempt to preserve edits):
` +
` pnpm exec werkstatt run mission.materialize --mission ${missionId} --force`,
);
}
}
// RFC-0954: Rescue uncommitted/unpushed workpiece edits BEFORE staging clone.
// Must run after syncCacheClone (so cache clone is at origin/main) but BEFORE
// staging clone (so rescued edits appear in the new workpiece) and BEFORE
// artifact cache key computation (so cache key includes rescued commits →
// cache miss → full materialization with rescued edits).
const rescueResult = await rescueWorkpieceEdits(
workpieceDirForRescue,
systemDir,
missionId,
logger,
workspaceRoot,
manifest.systemId,
);
if (rescueResult.rescued) {
logger.info(` [rescue] Workpiece edits preserved in cache clone + bare repo`);
} else if (rescueResult.backupDir) {
logger.warn(
` [rescue] Old workpiece backed up to ${path.basename(rescueResult.backupDir)} — recover edits manually`,
);
}
// RFC-0658: Install bordbuch pre-commit hook in cache clone to prevent
// accidental deletion of bordbuch/events.ndjson via git add -A + commit.
// Non-fatal: non-git cache clones skip hook installation silently.
let bordbuchHookInstalled = false;
try {
const hookResult = await installBordbuchPreCommitHook(systemDir, manifest.systemId, context.io);
bordbuchHookInstalled = hookResult.installed;
if (hookResult.installed) {
logger.info(` Installed bordbuch pre-commit hook in cache clone`);
}
} catch (err) {
logger.warn(
` Failed to install bordbuch pre-commit hook: ${err instanceof Error ? err.message : String(err)}`,
);
}
const pinPath = path.join(systemDir, "system.pin.json");
if (!(await context.io.exists(pinPath))) {
throw new Error(`[mission.materialize] system '${manifest.systemId}' has no system.pin.json`);
}
const pinRaw = await context.io.readFile(pinPath);
const pin = JSON.parse(pinRaw);
const pinVersion = pin.platform?.version ?? "unknown";
// Read current platform version from workspace root package.json
const workspacePkgPath = path.join(workspaceRoot, "package.json");
const workspacePkg = JSON.parse(await context.io.readFile(workspacePkgPath));
const platformVersion = workspacePkg.version ?? "unknown";
// Version comparison (simplified — full implementation would use compareEcosystem)
let verdict: "in-sync" | "catch-up" | "refuse-downgrade" = "in-sync";
let message = `in sync at ${pinVersion}; no migration needed.`;
const order = compareSemver(pinVersion, platformVersion);
if (order > 0) {
verdict = "refuse-downgrade";
message = `platform (${platformVersion}) is older than pin (${pinVersion}) — update the platform and retry`;
} else if (order < 0) {
verdict = "catch-up";
message = `catch-up from ${pinVersion} to ${platformVersion} required`;
}
logger.info(` Version comparison: ${verdict}`);
logger.info(` Pin: ${pinVersion}, Platform: ${platformVersion}`);
if (verdict === "refuse-downgrade") {
throw new Error(`[mission.materialize] ${message}`);
}
if (reportOnly) {
return {
data: {
missionId,
systemId: manifest.systemId,
versionComparison: {
verdict,
pinVersion,
platformVersion,
packagesDrift: false,
message,
},
migratorChain: [],
capabilityDiff: { tier: "green", items: [] },
regeneration: { regeneratedFiles: [], success: true },
materializedAt: new Date().toISOString(),
preflightSkipped: false,
preflightSkipReason: null,
pipelineUsed: "build.prepare.dev",
mediaCacheWarmed: false,
mediaCacheSources: 0,
bordbuchHookInstalled: false,
artifactCacheHit: false,
artifactCacheKey: null,
artifactCacheSkipped: false,
workspaceGlobCheck: { stalePackages: [], ok: true },
},
exitCode: 0,
summary: `[mission.materialize] ${missionId} report-only: ${verdict}`,
nextSteps: [
{
action: `Materialize for real: pnpm exec werkstatt run mission.materialize --mission ${missionId} --skip-preflight=false`,
kind: "required",
},
],
};
}
// RFC-0958: Wrap the main materialization sequence in runOperation for crash-safe resumable execution.
// Pre-flight checks (paused guard, sync, state repair, dirty guard, rescue, bordbuch hook, pin check,
// version comparison, report-only early return) run outside the journal. The main sequence
// (preflight skip → artifact cache → staging → data copy → move → restore → build → git → cache →
// report → manifest → commit) runs as journaled steps.
const missionDir = await resolveMissionDir(workspaceRoot, missionId, context.io);
const workpieceDir = path.join(missionDir, "workpiece");
const stagingDir = resolveStagingDir(missionDir, workpieceDir, operationId);
const stepCtx: MaterializeStepCtx = {
workspaceRoot,
missionId,
manifest,
context,
logger,
reportOnly,
skipPreflight,
force,
systemDir,
missionDir,
workpieceDir,
stagingDir,
operationId,
verdict,
pinVersion,
platformVersion,
message,
preflightSkipped: false,
preflightSkipReason: null,
artifactCacheHit: false,
artifactCacheKey: null,
artifactCacheSkipped: false,
cacheCloneHead: null,
artifactCacheKeyComponents: null,
regeneratedFiles: [],
mediaCacheWarmed: false,
mediaCacheSources: 0,
bordbuchHookInstalled,
clonedGitDir: null,
prepareReport: { ok: true, steps: [] },
workspaceGlobCheck: { stalePackages: [], ok: true },
now: new Date().toISOString(),
};
const journalPath = path.join(missionDir, "journal.jsonl");
// RFC-0958 Step 7: block if a different-kind operation is incomplete
// (skipped when called from mission.open's auto-materialize step, since
// mission.open is the calling operation and is still in progress)
if (!skipOperationBlockCheck) {
const blockCheck = await checkDifferentKindOperation(journalPath, "mission.materialize");
if (blockCheck.blocked) {
return {
data: {
missionId,
systemId: manifest.systemId,
blockedByOperation: blockCheck.incompleteOp,
} as unknown as MissionMaterializeData,
exitCode: 1,
summary: `[mission.materialize] ${missionId} blocked: incomplete '${blockCheck.incompleteOp}' operation found in journal`,
nextSteps: [
{
action: `Run: pnpm exec werkstatt run mission.resume --mission ${missionId} to resume or abandon the incomplete operation`,
kind: "required",
},
],
};
}
}
const opResult = await runOperation(
journalPath,
{
op: "mission.materialize",
steps: await buildMaterializeSteps(workspaceRoot, missionId, stepCtx),
},
stepCtx,
);
if (!opResult.completed) {
const detail = opResult.failedStepError ?? "unknown error";
const failedStep = opResult.failedStep;
throw new Error(
`[mission.materialize] step "${failedStep}" failed: ${detail} — run mission.resume --mission ${missionId} to retry`,
);
}
// Build return value from context state after runOperation
const report: MissionMaterializeData = {
missionId,
systemId: manifest.systemId,
versionComparison: { verdict, pinVersion, platformVersion, packagesDrift: false, message },
migratorChain: [],
capabilityDiff: { tier: "green", items: [] },
regeneration: { regeneratedFiles: stepCtx.regeneratedFiles, success: true },
materializedAt: stepCtx.now,
preflightSkipped: skipPreflight || stepCtx.preflightSkipped,
preflightSkipReason: skipPreflight ? "operator-override" : stepCtx.preflightSkipReason,
pipelineUsed: "build.prepare.dev",
mediaCacheWarmed: stepCtx.mediaCacheWarmed,
mediaCacheSources: stepCtx.mediaCacheSources,
bordbuchHookInstalled: stepCtx.bordbuchHookInstalled,
artifactCacheHit: stepCtx.artifactCacheHit,
artifactCacheKey: stepCtx.artifactCacheKey,
artifactCacheSkipped: stepCtx.artifactCacheSkipped,
workspaceGlobCheck: stepCtx.workspaceGlobCheck,
};
return {
data: report,
exitCode: 0,
summary: `[mission.materialize] ${missionId} materialized (${verdict}, green)`,
nextSteps: [
{
action: `Edit content in missions/${missionId}/workpiece/src/content/, then run: pnpm exec werkstatt run mission.validate --mission ${missionId}`,
kind: "required",
},
],
};
}
// ---------------------------------------------------------------------------
// RFC-0958: Journal-integrated materialize steps
// ---------------------------------------------------------------------------
export interface MaterializeStepCtx {
workspaceRoot: string;
missionId: string;
manifest: MissionManifest;
context: KernelRuntimeContext;
logger: {
info: (msg: string) => void;
warn: (msg: string) => void;
error: (msg: string) => void;
};
reportOnly: boolean;
skipPreflight: boolean;
force: boolean;
systemDir: string;
missionDir: string;
workpieceDir: string;
stagingDir: string;
operationId: string;
verdict: "in-sync" | "catch-up" | "refuse-downgrade";
pinVersion: string;
platformVersion: string;
message: string;
preflightSkipped: boolean;
preflightSkipReason: string | null;
artifactCacheHit: boolean;
artifactCacheKey: string | null;
artifactCacheSkipped: boolean;
cacheCloneHead: string | null;
artifactCacheKeyComponents: { platformVersion: string; platformSemanticHash: string } | null;
regeneratedFiles: string[];
mediaCacheWarmed: boolean;
mediaCacheSources: number;
bordbuchHookInstalled: boolean;
clonedGitDir: string | null;
prepareReport: {
ok: boolean;
steps: Array<{ ok: boolean; commandName: string; exitCode: number }>;
};
workspaceGlobCheck: { stalePackages: string[]; ok: boolean };
now: string;
}
export async function buildMaterializeSteps(
_workspaceRoot: string,
_missionId: string,
_ctx: unknown,
): Promise[]> {
const steps: OperationStep[] = [
{
name: "preflight-skip-check",
run: async (c: unknown) => {
const cc = c as MaterializeStepCtx;
if (!cc.skipPreflight) {
const stateFilePath = path.join(cc.systemDir, ".materialization-state.json");
if (await cc.context.io.exists(stateFilePath)) {
try {
const stateRaw = await cc.context.io.readFile(stateFilePath);
const state = JSON.parse(stateRaw) as MaterializationState;
let currentHead: string | null = null;
const headResult = await cc.context.io.exec("git", ["rev-parse", "HEAD"], {
cwd: cc.systemDir,
});
if (headResult.exitCode === 0) currentHead = headResult.stdout.trim();
if (currentHead && state.cacheCloneHead === currentHead) {
cc.preflightSkipped = true;
cc.preflightSkipReason = "cache-clone-head-unchanged";
cc.logger.info(
` Preflight skip: cache clone HEAD unchanged (${currentHead.slice(0, 12)})`,
);
}
} catch {
// Corrupt state file — fail safe, run preflight
}
}
}
},
},
{
name: "artifact-cache-check",
run: async (c: unknown) => {
const cc = c as MaterializeStepCtx;
if (!cc.reportOnly) {
cc.cacheCloneHead = await resolveCacheCloneHead(cc.systemDir, cc.context.io);
if (cc.cacheCloneHead) {
try {
const keyResult = await computeArtifactCacheKey(cc.workspaceRoot, cc.cacheCloneHead);
cc.artifactCacheKey = keyResult.cacheKey;
cc.artifactCacheKeyComponents = {
platformVersion: keyResult.platformVersion,
platformSemanticHash: keyResult.platformSemanticHash,
};
if (!cc.force) {
const cacheState = await readArtifactCacheState(cc.systemDir, cc.context.io);
if (cacheState && cacheState.cacheKey === cc.artifactCacheKey) {
const cacheDir = path.join(cc.systemDir, ARTIFACT_CACHE_DIR, cc.artifactCacheKey);
if (await cc.context.io.exists(cacheDir)) {
cc.artifactCacheHit = true;
cc.logger.info(
` Artifact cache hit (key: ${cc.artifactCacheKey.slice(0, 12)})`,
);
} else {
cc.logger.warn(
` Artifact cache: state file exists but cache directory missing — falling through to full materialization`,
);
try {
await cc.context.io.rm(path.join(cc.systemDir, ARTIFACT_CACHE_STATE_FILE));
} catch {
// ignore
}
}
}
} else {
cc.artifactCacheSkipped = true;
cc.logger.info(` Artifact cache: bypassed (--force)`);
}
} catch (err) {
cc.logger.warn(
` Artifact cache: key computation failed — ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
},
},
{
name: "setup-staging",
run: async (c: unknown) => {
const cc = c as MaterializeStepCtx;
if (await cc.context.io.exists(cc.stagingDir)) {
await cc.context.io.rm(cc.stagingDir, { recursive: true });
}
await cc.context.io.mkdir(cc.stagingDir);
const hasGitCacheClone = await cc.context.io.exists(path.join(cc.systemDir, ".git"));
if (hasGitCacheClone) {
const tempCloneDir = path.join(cc.missionDir, `.clone-${cc.operationId}`);
if (await cc.context.io.exists(tempCloneDir)) {
await cc.context.io.rm(tempCloneDir, { recursive: true });
}
await execOrThrow(cc.context.io, "git", ["clone", cc.systemDir, tempCloneDir]);
cc.clonedGitDir = path.join(cc.stagingDir, ".git");
await cc.context.io.rename(path.join(tempCloneDir, ".git"), cc.clonedGitDir);
await cc.context.io.rm(tempCloneDir, { recursive: true });
cc.logger.info(` Cloned cache clone git history into staging`);
// RFC-1199: write the sentinel the moment .git materializes — it
// rides atomic-move into workpiece/.git and guards the whole window
// (build-prepare runs post-move against workpieceDir). The hook
// installs into staging .git/hooks and survives the rename.
await atomicWriteFile(
path.join(cc.clonedGitDir, MATERIALIZATION_SENTINEL),
JSON.stringify({ missionId: cc.missionId, startedAt: new Date().toISOString() }) + "\n",
);
await installWorkpieceCommitHook(cc.stagingDir);
cc.logger.info(` Materialization sentinel written + commit hook installed in staging`);
}
},
},
{
name: "copy-data-or-cache",
run: async (c: unknown) => {
const cc = c as MaterializeStepCtx;
if (cc.artifactCacheHit && cc.artifactCacheKey) {
const cacheDir = path.join(cc.systemDir, ARTIFACT_CACHE_DIR, cc.artifactCacheKey);
await copyDirExcluding(cacheDir, cc.stagingDir, new Set([".git"]), cc.context.io);
cc.logger.info(` Restored workpiece from artifact cache`);
} else {
const skipPathsGlobal = await getWorkspaceAbsoluteGeneratedPaths(cc.context.ownershipMap);
// Copy the entire cache clone working tree into staging, excluding only
// .git, .cache, node_modules, dist, .env, and materialization state.
// This preserves custom composition files (src/pages/portal/[token].astro,
// src/components/*, src/scripts/*, etc.) that are not in
// STERNSYSTEM_DATA_PATHS and not produced by generators.
// Generated files are removed by the skipPathsGlobal pass below and then
// regenerated by generateFullBoilerplate.
const cacheCloneExclude = new Set([
".git",
".cache",
"node_modules",
"dist",
".env",
".env.local",
".materialization-state.json",
]);
await copyDirExcluding(cc.systemDir, cc.stagingDir, cacheCloneExclude, cc.context.io);
cc.logger.info(` Copied cache clone working tree (excluding .git, .cache, env files)`);
// Remove generated files that were copied from the cache clone —
// copyDirExcluding only filters top-level entries, so nested generated
// files (src/pages/index.astro, src/middleware.ts, etc.) are present
// and must be removed before generateFullBoilerplate regenerates them.
for (const skipPath of skipPathsGlobal) {
const stagingPath = path.join(cc.stagingDir, skipPath);
if (await cc.context.io.exists(stagingPath)) {
await cc.context.io.rm(stagingPath);
}
}
const pinFileSrc = path.join(cc.systemDir, "system.pin.json");
if (await cc.context.io.exists(pinFileSrc)) {
await cc.context.io.copyFile(pinFileSrc, path.join(cc.stagingDir, "system.pin.json"));
cc.logger.info(` Copied system.pin.json`);
}
for (const cacheDir of MEDIA_CACHE_DIRS) {
const srcCache = path.join(cc.systemDir, cacheDir);
if (await cc.context.io.exists(srcCache)) {
const destCache = path.join(cc.stagingDir, cacheDir);
try {
if (await cc.context.io.exists(destCache)) {
await cc.context.io.rm(destCache, { recursive: true });
}
await copyDir(srcCache, destCache, cc.context.io);
cc.mediaCacheWarmed = true;
cc.mediaCacheSources++;
cc.logger.info(` Warmed ${cacheDir} from cache clone`);
} catch (err) {
cc.logger.info(
` Warning: failed to warm ${cacheDir} from cache clone: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
}
cc.regeneratedFiles = await generateFullBoilerplate(
cc.stagingDir,
cc.manifest.systemId,
cc.context,
cc.logger,
);
}
},
},
{
name: "atomic-move",
run: async (c: unknown) => {
const cc = c as MaterializeStepCtx;
await atomicMoveDir(cc.stagingDir, cc.workpieceDir, { replace: true });
},
},
{
name: "restore-env-files",
run: async (c: unknown) => {
const cc = c as MaterializeStepCtx;
const cacheCloneDir = resolveCacheClonePath(cc.workspaceRoot, cc.manifest.systemId);
try {
const envResult = await restoreEnvFilesFromCacheClone(cacheCloneDir, cc.workpieceDir);
if (envResult.copied.length > 0) {
cc.logger.info(
` Restored ${envResult.copied.length} .env file(s) from cache clone: ${envResult.copied.join(", ")}`,
);
} else {
cc.logger.warn(
` Warning: no .env files found in cache clone — using .env.example template. Operator must fill secrets manually.`,
);
}
if (envResult.skipped.length > 0) {
cc.logger.warn(
` Warning: failed to restore .env file(s): ${envResult.skipped.join(", ")}`,
);
}
} catch (envErr) {
cc.logger.warn(
` Warning: failed to restore .env files from cache clone: ${envErr instanceof Error ? envErr.message : String(envErr)}`,
);
}
process.env["PUBLIC_IMAGE_PROVIDER"] = "build-portable";
cc.logger.info(` PUBLIC_IMAGE_PROVIDER set to build-portable in .env files`);
},
},
{
name: "restore-registry-files",
run: async (c: unknown) => {
const cc = c as MaterializeStepCtx;
try {
const genOwnershipPath = "@warpgogol/werkstatt-site/checks/generator-ownership";
const { toOwnershipEntries } = await import(genOwnershipPath);
const registryOnlyNonConditional = toOwnershipEntries(
cc.context.ownershipMap ?? [],
).filter(
(e: { markerPolicy?: string; conditional?: boolean; path: string }) =>
e.markerPolicy === "registry-only" && !e.conditional,
);
let restoredCount = 0;
for (const entry of registryOnlyNonConditional) {
const checkout = await cc.context.io.exec(
"git",
["checkout", "HEAD", "--", entry.path],
{ cwd: cc.workpieceDir },
);
if (checkout.exitCode === 0) restoredCount++;
}
if (restoredCount > 0) {
cc.logger.info(` Restored ${restoredCount} registry-only generated file(s) from git`);
}
} catch (restoreErr) {
cc.logger.warn(
` Warning: failed to restore registry-only generated files: ${restoreErr instanceof Error ? restoreErr.message : String(restoreErr)}`,
);
}
},
},
{
name: "restore-operator-config",
run: async (c: unknown) => {
const cc = c as MaterializeStepCtx;
const cacheCloneDir = resolveCacheClonePath(cc.workspaceRoot, cc.manifest.systemId);
try {
const operatorConfigResult = await restoreOperatorConfigFiles(
cacheCloneDir,
cc.workpieceDir,
);
if (operatorConfigResult.copied.length > 0) {
cc.logger.info(
` Restored ${operatorConfigResult.copied.length} operator config file(s) from cache clone: ${operatorConfigResult.copied.join(", ")}`,
);
}
if (operatorConfigResult.skipped.length > 0) {
cc.logger.warn(
` Warning: failed to restore operator config file(s): ${operatorConfigResult.skipped.join(", ")}`,
);
}
} catch (operatorConfigErr) {
cc.logger.warn(
` Warning: failed to restore operator config files from cache clone: ${operatorConfigErr instanceof Error ? operatorConfigErr.message : String(operatorConfigErr)}`,
);
}
},
},
{
name: "workspace-glob-check",
run: async (c: unknown) => {
const cc = c as MaterializeStepCtx;
cc.workspaceGlobCheck = await checkWorkspaceGlobsForStalePackages(cc.workspaceRoot);
if (!cc.workspaceGlobCheck.ok) {
const staleList = cc.workspaceGlobCheck.stalePackages.join("\n ");
throw new Error(
`[mission.materialize] stale workspace references detected — pnpm install would fail.\n` +
` Stale packages:\n ${staleList}\n` +
` Run 'mission.archive --status closed' to move terminal-state missions to archive,\n` +
` then re-run mission.materialize.`,
);
}
cc.logger.info(` Workspace glob check passed (no stale package references)`);
},
},
{
name: "stop-stale-dev-servers",
run: async (c: unknown) => {
const cc = c as MaterializeStepCtx;
cc.logger.info(` Stopping stale astro dev servers from previous missions…`);
try {
const pgrepResult = await cc.context.io.exec("pgrep", ["-af", "astro.*dev"], {
timeoutMs: 10_000,
});
if (pgrepResult.exitCode === 0) {
const lines = pgrepResult.stdout.trim().split("\n").filter(Boolean);
for (const line of lines) {
const pidStr = line.split("\s+")[0];
if (!pidStr) continue;
const pid = parseInt(pidStr, 10);
if (Number.isNaN(pid) || pid === process.pid) continue;
try {
const cwd = (await cc.context.io.realpath(`/proc/${pid}/cwd`)).trim();
if (cwd.includes("/missions/")) {
process.kill(pid, "SIGTERM");
cc.logger.info(` Stopped stale dev server (pid=${pid}, cwd=${cwd})`);
}
} catch {
// Process may have exited or cwd unreadable — skip silently
}
}
}
} catch {
// pgrep returns non-zero when no matches — no stale servers to stop
}
},
},
{
name: "pnpm-install",
run: async (c: unknown) => {
const cc = c as MaterializeStepCtx;
cc.logger.info(` Linking workpiece workspace dependencies…`);
const installResult = await cc.context.io.exec("pnpm", ["install"], {
cwd: cc.workspaceRoot,
timeoutMs: 300_000,
});
if (installResult.exitCode === 0) {
cc.logger.info(` Workpiece workspace dependencies linked`);
} else {
const msg = installResult.stderr.trim() || `exit ${installResult.exitCode}`;
throw new Error(
`[mission.materialize] pnpm install failed — workpiece cannot be linked into workspace.\n` +
` Error: ${msg}\n` +
` Run 'pnpm install' manually at the workspace root, then re-run mission.materialize.`,
);
}
},
},
{
name: "post-install-version-check",
run: async (c: unknown) => {
const cc = c as MaterializeStepCtx;
const pkgJsonPath = path.join(cc.workpieceDir, "package.json");
if (!(await cc.context.io.exists(pkgJsonPath))) return;
const pkgRaw = await cc.context.io.readFile(pkgJsonPath);
const pkg = JSON.parse(pkgRaw);
const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
for (const [depName, depSpec] of Object.entries(deps)) {
if (typeof depSpec !== "string") continue;
if (!depSpec.startsWith("~") && !depSpec.startsWith("^")) continue;
const installedPath = path.join(cc.workpieceDir, "node_modules", depName, "package.json");
if (!(await cc.context.io.exists(installedPath))) continue;
try {
const installedPkg = JSON.parse(await cc.context.io.readFile(installedPath));
const installedVersion = installedPkg.version;
if (!installedVersion) continue;
const specBody = depSpec.replace(/^[~^]/, "");
const rangeMin = specBody;
const installedParts = installedVersion.split(".").map((n: string) => parseInt(n, 10));
const rangeParts = rangeMin.split(".").map((n: string) => parseInt(n, 10));
const majorMatch = installedParts[0] === rangeParts[0];
const minorMatch = installedParts[1] === rangeParts[1];
const isTilde = depSpec.startsWith("~");
if (!majorMatch || (isTilde && !minorMatch)) {
throw new Error(
`[mission.materialize] post-install version check failed: ${depName} installed ${installedVersion} does not satisfy ${depSpec}.\n` +
` This usually means a stale pnpm-lock.yaml has a different version than the workpiece pin.\n` +
` Run 'pnpm install --force' at the workspace root, then re-run mission.materialize.`,
);
}
} catch (checkErr) {
if (checkErr instanceof Error && checkErr.message.includes("version check failed")) {
throw checkErr;
}
}
}
cc.logger.info(` Post-install version check passed`);
},
},
{
name: "ensure-chromium",
run: async (c: unknown) => {
const cc = c as MaterializeStepCtx;
try {
const siteChecksModule = "@warpgogol/werkstatt-site/checks";
const { ensureChromium } = await import(siteChecksModule);
await ensureChromium(cc.workspaceRoot, cc.logger, cc.context.io);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
cc.logger.warn(
` Playwright Chromium: ensure failed (non-fatal) — ${msg}. ` +
`Run 'pnpm exec playwright install chromium' manually before build:check.`,
);
}
},
},
{
name: "build-prepare",
run: async (c: unknown) => {
const cc = c as MaterializeStepCtx;
if (cc.artifactCacheHit) {
cc.prepareReport = { ok: true, steps: [] };
cc.logger.info(` build.prepare.dev skipped (artifact cache hit)`);
} else {
cc.logger.info(` Running build.prepare.dev pipeline for ${cc.manifest.systemId}…`);
const prepareResult = await executeKernelPipeline({
workspaceRoot: cc.workspaceRoot,
pipelineName: "build.prepare.dev",
siteName: cc.manifest.systemId,
outputFormat: "pretty",
force: true,
});
cc.prepareReport = Array.isArray(prepareResult) ? prepareResult[0] : prepareResult;
if (!cc.prepareReport.ok) {
const failedSteps = cc.prepareReport.steps
.filter((s) => !s.ok)
.map((s) => `${s.commandName} (exit ${s.exitCode})`);
throw new Error(
`[mission.materialize] build.prepare pipeline FAILED — ${failedSteps.length} step(s) failed:\n` +
failedSteps.map((s) => ` - ${s}`).join("\n") +
`\n\nWorkpiece preserved at ${cc.workpieceDir} (no git init).`,
);
}
}
cc.logger.info(
` build.prepare completed (${cc.prepareReport.steps.length} steps, ${cc.prepareReport.steps.filter((s) => s.ok).length} OK)`,
);
},
},
{
name: "preflight-gate",
run: async (c: unknown) => {
const cc = c as MaterializeStepCtx;
const effectiveSkipPreflight = cc.skipPreflight || cc.preflightSkipped;
await runPreflightGate(
cc.workspaceRoot,
cc.workpieceDir,
cc.manifest.systemId,
cc.missionId,
effectiveSkipPreflight,
cc.logger,
cc.context.io,
);
if (cc.skipPreflight) {
await appendAndCommitBordbuch(
cc.workspaceRoot,
cc.manifest.systemId,
"preflight-skipped",
`Preflight content quality gate skipped via --skip-preflight flag for mission ${cc.missionId}`,
"agent",
{
writerRole: "mission",
missionId: cc.missionId,
metadata: { reason: "operator override via --skip-preflight flag" },
},
`Bordbuch: preflight-skipped ${cc.missionId}`,
);
cc.logger.info(` Bordbuch: preflight-skipped entry appended`);
} else if (cc.preflightSkipped) {
await appendAndCommitBordbuch(
cc.workspaceRoot,
cc.manifest.systemId,
"preflight-skipped",
`Preflight content quality gate skipped — cache clone HEAD unchanged for mission ${cc.missionId}`,
"agent",
{
writerRole: "mission",
missionId: cc.missionId,
metadata: { reason: "cache-clone-head-unchanged" },
},
`Bordbuch: preflight-skipped ${cc.missionId}`,
);
cc.logger.info(
` Bordbuch: preflight-skipped entry appended (cache-clone-head-unchanged)`,
);
}
},
},
{
name: "git-init-commit",
run: async (c: unknown) => {
const cc = c as MaterializeStepCtx;
const sentinelPath = path.join(cc.workpieceDir, ".git", MATERIALIZATION_SENTINEL);
const hasGit = await cc.context.io.exists(path.join(cc.workpieceDir, ".git"));
if (!hasGit) {
await execOrThrow(cc.context.io, "git", ["init", "-b", "main"], {
cwd: cc.workpieceDir,
});
// RFC-1199: on the non-git fallback the sentinel attaches right after
// git init — the earliest .git point this path has. The pre-init
// window is guarded by the manifest.materializedAt == null fallback
// in mission.git.commit (a repo-less directory cannot host a raw
// commit anyway).
await atomicWriteFile(
sentinelPath,
JSON.stringify({ missionId: cc.missionId, startedAt: new Date().toISOString() }) + "\n",
);
await installWorkpieceCommitHook(cc.workpieceDir);
}
// Stage all files — data, generated, and custom composition files.
// git add -A ensures custom files (src/pages/portal/[token].astro,
// src/components/*, etc.) are included in the materialize commit.
await execOrThrow(cc.context.io, "git", ["add", "-A"], { cwd: cc.workpieceDir });
// RFC-1199: idempotent on resume — a crash between the baseline commit
// and sentinel deletion re-runs this step; commit is skipped when HEAD
// resolves and the staged index is clean, instead of wedging on
// "nothing to commit".
const head = await cc.context.io.exec("git", ["rev-parse", "--verify", "HEAD"], {
cwd: cc.workpieceDir,
});
let shouldCommit = head.exitCode !== 0;
if (!shouldCommit) {
const diff = await cc.context.io.exec("git", ["diff", "--cached", "--quiet"], {
cwd: cc.workpieceDir,
});
shouldCommit = diff.exitCode !== 0;
}
if (shouldCommit) {
await execOrThrow(
cc.context.io,
"git",
["commit", "-m", `materialize from pin ${cc.pinVersion}`],
{ cwd: cc.workpieceDir, env: { MISSION_GIT_COMMIT: "1" } },
);
} else {
cc.logger.info(` Baseline commit already exists — skipping commit (resumed run)`);
}
// RFC-1199: the sentinel comes down only after the baseline commit
// landed (or is confirmed present on resume). Hook install is
// idempotent — covers re-runs and the setup-staging early install.
if (await cc.context.io.exists(sentinelPath)) {
await cc.context.io.rm(sentinelPath);
}
await installWorkpieceCommitHook(cc.workpieceDir);
cc.logger.info(
hasGit
? ` Git commit created in workpiece (full working tree, on top of cloned history)`
: ` Git initialized in workpiece with initial commit (non-git cache clone fallback)`,
);
},
},
{
name: "write-artifact-cache",
run: async (c: unknown) => {
const cc = c as MaterializeStepCtx;
if (
!cc.artifactCacheHit &&
cc.artifactCacheKey &&
cc.cacheCloneHead &&
cc.artifactCacheKeyComponents
) {
try {
const cacheBaseDir = path.join(cc.systemDir, ARTIFACT_CACHE_DIR);
if (await cc.context.io.exists(cacheBaseDir)) {
const entries = await cc.context.io.readdir(cacheBaseDir);
for (const entry of entries) {
if (entry.name !== cc.artifactCacheKey) {
await cc.context.io.rm(path.join(cacheBaseDir, entry.name), {
recursive: true,
});
}
}
}
const newCacheDir = path.join(cacheBaseDir, cc.artifactCacheKey);
await copyDirExcluding(
cc.workpieceDir,
newCacheDir,
new Set([".git", "node_modules", "dist"]),
cc.context.io,
);
cc.logger.info(` Artifact cache: wrote entry (${cc.artifactCacheKey.slice(0, 12)})`);
await writeArtifactCacheState(
cc.systemDir,
{
systemId: cc.manifest.systemId,
cacheKey: cc.artifactCacheKey,
cacheCloneHead: cc.cacheCloneHead,
platformVersion: cc.artifactCacheKeyComponents.platformVersion,
platformSemanticHash: cc.artifactCacheKeyComponents.platformSemanticHash,
writtenAt: new Date().toISOString(),
},
cc.logger,
cc.context.io,
);
} catch (err) {
cc.logger.warn(
` Artifact cache: write failed (non-fatal) — ${err instanceof Error ? err.message : String(err)}`,
);
}
}
},
},
{
name: "compass-audit",
run: async (c: unknown) => {
const cc = c as MaterializeStepCtx;
const workpieceRelPath = path.relative(cc.workspaceRoot, cc.workpieceDir);
cc.logger.info(` Running compass.audit.baseline for workpiece…`);
try {
await executeKernelCommand({
workspaceRoot: cc.workspaceRoot,
commandName: "compass.audit.baseline",
argv: [`--workpiece=${workpieceRelPath}`],
});
cc.logger.info(` Compass audit baseline seeded for workpiece`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
cc.logger.warn(` Warning: compass.audit.baseline failed: ${msg}`);
}
},
},
{
// RFC-1265: every fresh workpiece carries a current ops card. Advisory —
// a compile failure degrades instruction quality, not workpiece safety.
name: "ops-card",
run: async (c: unknown) => {
const cc = c as MaterializeStepCtx;
try {
await executeKernelCommand({
workspaceRoot: cc.workspaceRoot,
commandName: "ops.card.generate",
argv: [`--site=${cc.manifest.systemId}`],
});
cc.logger.info(` Ops card compiled for ${cc.manifest.systemId}`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
cc.logger.warn(` Warning: ops.card.generate failed: ${msg}`);
}
},
},
{
name: "write-report",
run: async (c: unknown) => {
const cc = c as MaterializeStepCtx;
cc.now = new Date().toISOString();
const evidenceDir = path.join(cc.missionDir, "evidence");
await cc.context.io.mkdir(evidenceDir);
const report = {
schemaVersion: "1.0.0",
missionId: cc.missionId,
systemId: cc.manifest.systemId,
versionComparison: {
verdict: cc.verdict,
pinVersion: cc.pinVersion,
platformVersion: cc.platformVersion,
packagesDrift: false,
message: cc.message,
},
migratorChain: [],
capabilityDiff: { tier: "green" as const, items: [] },
regeneration: { regeneratedFiles: cc.regeneratedFiles, success: true },
buildPrepare: {
steps: cc.prepareReport.steps.length,
passed: cc.prepareReport.steps.filter((s) => s.ok).length,
failed: cc.prepareReport.steps.filter((s) => !s.ok).length,
},
preflightSkipped: cc.skipPreflight || cc.preflightSkipped,
preflightSkipReason: cc.skipPreflight ? "operator-override" : cc.preflightSkipReason,
pipelineUsed: "build.prepare.dev",
mediaCacheWarmed: cc.mediaCacheWarmed,
mediaCacheSources: cc.mediaCacheSources,
bordbuchHookInstalled: cc.bordbuchHookInstalled,
artifactCacheHit: cc.artifactCacheHit,
artifactCacheKey: cc.artifactCacheKey,
artifactCacheSkipped: cc.artifactCacheSkipped,
workspaceGlobCheck: cc.workspaceGlobCheck,
materializedAt: cc.now,
};
await atomicWriteFile(
path.join(evidenceDir, "materialization-report.json"),
JSON.stringify(report, null, 2) + "\n",
);
},
},
{
name: "update-manifest",
run: async (c: unknown) => {
const cc = c as MaterializeStepCtx;
cc.manifest.materializedAt = cc.now;
await writeMissionManifest(cc.workspaceRoot, cc.manifest);
},
},
{
name: "commit-side-effects",
run: async (c: unknown) => {
const cc = c as MaterializeStepCtx;
await commitWerkstattSideEffects(
cc.workspaceRoot,
[path.join("missions", cc.missionId, "mission.yaml"), "pnpm-lock.yaml"],
`werkstatt: mission.materialize ${cc.missionId}`,
);
},
},
];
return steps;
}
export async function runMissionMaterialize(
input: KernelCommandInput,
context: KernelRuntimeContext,
): Promise> {
const { workspaceRoot } = context;
const missionId = flagString(input, "mission");
const reportOnly = flagBool(input, "report-only");
const skipPreflight = flagBool(input, "skip-preflight");
const force = flagBool(input, "force");
if (!missionId) throw new Error("[mission.materialize] --mission is required");
// RFC-0952 Guard 1: actionable error for missing manifest
const manifestPath = path.join(workspaceRoot, "missions", missionId, "mission.yaml");
const manifestExists = await context.io.exists(manifestPath);
if (!manifestExists) {
const mIdx = missionId.lastIndexOf("-m");
const systemId = mIdx > 0 ? missionId.slice(0, mIdx) : null;
const openHint = systemId
? `pnpm exec werkstatt run mission.open --system ${systemId} --brief ""`
: `pnpm exec werkstatt run mission.open --system --brief ""`;
throw new Error(
`[mission.materialize] mission manifest not found at ${path.relative(workspaceRoot, manifestPath)}.
` +
`Run mission.open first to create the mission:
` +
` ${openHint}`,
);
}
const manifest = await readMissionManifest(workspaceRoot, missionId);
if (manifest.state !== "open") {
throw new Error(
`[mission.materialize] mission '${missionId}' is not open (state: ${manifest.state})`,
);
}
const operationId = manifest.operationId;
await acquireLock(
workspaceRoot,
`system:${manifest.systemId}`,
operationId,
"mission.materialize",
"agent",
);
await acquireLock(
workspaceRoot,
`mission:${missionId}`,
operationId,
"mission.materialize",
"agent",
);
try {
return await runMissionMaterializeInternal(workspaceRoot, manifest, context, {
reportOnly,
skipPreflight,
force,
});
} finally {
await releaseLock(workspaceRoot, `mission:${missionId}`);
await releaseLock(workspaceRoot, `system:${manifest.systemId}`);
}
}