/*
Maintains packages/os/site-kernel-handoff/src/mission/mission-materialization-commands.ts as an authored site-kernel-handoff authored module so agents can evolve it without rediscovering local boundaries.
- Do not introduce app-specific runtime composition or deployment behavior into this reusable package source file.
- ADR-0096 follow-up: runMissionBuild now calls bootstrapCommittedSnapshot before build.post — the ADR-0096 baseline commit existed only on the mission.validate path, so mission.build deadlocked on SNAP-02 for first-time missions with no committed behavior snapshot.
- RFC-1263
- RFC-1296
- RFC-1327: run-twice idempotency — declarations, command.idempotency.validate, rerun tests, reconcile/ship/abort convergence fixes
- RFC-1327: review r1 — lock-safe reconcile gate, residue sweep, honest idempotency classes
ADR-0008, ADR-0060, ADR-0093, ADR-0096, ADR-0110, RFC-0356, RFC-0480, RFC-0522, RFC-0560, RFC-0568, RFC-0578, RFC-0579, RFC-0580, RFC-0635, RFC-0644, RFC-0689, RFC-0697, RFC-0702, RFC-0705, RFC-0749, RFC-0763, RFC-0796, RFC-0797, RFC-0820, RFC-0913, RFC-0918, RFC-0958, RFC-0973, RFC-1020, RFC-1028, RFC-1074, RFC-1086, RFC-1097, RFC-1149, RFC-1159, RFC-1218, RFC-1219, RFC-1221, RFC-1249, warpgogol-m000196
*/
import {
createDefaultIO,
execOrThrow,
type WorkspaceIO,
} from "@warpgogol/werkstatt-shared/kernel/workspace-io";
import path from "node:path";
import picomatch from "picomatch";
import { parse as yamlParse } from "yaml";
import type {
Diagnostic,
DiscoveredSiteWorkspace,
KernelCommandInput,
KernelCommandResult,
KernelNextStep,
KernelPipelineReport,
KernelRuntimeContext,
} from "@warpgogol/werkstatt-engine/kernel";
import { executeKernelCommand, executeKernelPipeline } from "@warpgogol/werkstatt-engine/kernel";
import { createCacheLayer } from "../kernel/cache/cache-layer.ts";
import { clearPipelineCacheHits } from "../kernel/runtime/execute-pipeline.ts";
import { collectFiles } from "@warpgogol/werkstatt-shared/node/fs";
import {
runPipelinePhase,
computeBuildInputHash,
writePreliminaryBuildIdentity,
cleanupPreliminaryBuildIdentity,
} from "../handoff/build-pipeline-helpers.ts";
import { readMissionManifest, writeMissionManifest, resolveMissionDir } from "./mission-io.ts";
import { missionHasGreenCoverage } from "./lifecycle-precondition.ts";
import {
isWorkpieceDirty,
investigateUntrackedFiles,
commitWorkpieceIfDirty,
commitCacheCloneIfDirty,
cacheCloneCommit,
} from "./mission-git-commit.ts";
import { acquireLock, releaseLock, commitWerkstattSideEffects } from "../werkstatt/index.ts";
import { atomicWriteFile } from "../werkstatt/atomic.ts";
import { resolveActor } from "./actor-identity.ts";
import { resolveCacheClonePath, readSystemConfig } from "../sternsystem/registry-io.ts";
import {
orchestrateSnap01Recovery,
bootstrapCommittedSnapshot,
SNAPSHOT_FILENAME,
} from "./snapshot-auto-regen.ts";
import { commitBordbuchProjections } from "../bordbuch/bordbuch-commit.ts";
import { appendAndCommitBordbuch } from "../bordbuch/bordbuch-commit-helper.ts";
import type { WorkpieceConfigPresenceResult } from "./workpiece-config-presence-check.ts";
import {
restoreCacheCloneGitignore,
sweepUntrackedGeneratedResidue,
untrackForbiddenGeneratedFiles,
CACHE_CLONE_GENERATED_PATTERNS,
} from "./cache-clone-gitignore.ts";
import { PIPELINE_SUBJECT_PREFIXES } from "../sternsystem/external-edit-guard.ts";
import { runOperation } from "../journal/runner.ts";
import { checkDifferentKindOperation } from "../journal/index.ts";
import type { OperationStep, OperationDefinition } from "../journal/index.ts";
import type { MissionManifest } from "@warpgogol/werkstatt-engine/schemas";
import { writeValidationState, buildValidatorStatesFromSteps } from "./validation-state.ts";
import { computeDistFacts } from "./dist-facts.ts";
import { validatorsFromPipelineReports, contractFullFromPrevious } from "./validation-report.ts";
const STERNSYSTEM_DATA_PATHS = [
"src/content",
"public",
"provenance",
"system-config.yaml",
"system-state.yaml",
];
/**
* Files that exist only in the cache clone (not in workpiece git).
* During mission.reconcile merge, these cause modify/delete conflicts.
* They are auto-resolved by keeping the cache clone version (ours).
* Generated-registry paths are the second auto-resolved class — they resolve
* theirs (workpiece) per RFC-1218 via the ownershipMap-derived allowlist.
* Add new cache-clone-only files here instead of modifying isAutoResolvablePath.
*/
const CACHE_CLONE_ONLY_PATHS: readonly string[] = [
"bordbuch/",
"public/.well-known/bordbuch",
"dns-records.yaml",
".materialization-state.json",
SNAPSHOT_FILENAME,
".gitignore",
// RFC-1219: operation journals are append-only cache-side state — merge
// conflicts on them resolve ours (the workpiece copy is a stale replica),
// and the rewind preservation below keeps them across reset --hard.
"operations/",
];
// RFC-0763: shared helper for bordbuch cleanup on failure paths.
// Avoids duplicating the try/catch/log block at each failure early-return.
async function cleanupBordbuchOnFailure(
workspaceRoot: string,
systemId: string,
label: string,
logger: { info: (msg: string) => void; warn: (msg: string) => void },
): Promise {
try {
const result = await commitBordbuchProjections(workspaceRoot, systemId);
if (result.committed) {
logger.info(
` Bordbuch cleanup on ${label}: committed ${result.filesCommitted.length} file(s)`,
);
}
} catch (err) {
logger.warn(
` Bordbuch cleanup on ${label} failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`,
);
}
}
// RFC-1149: shell-string exec preserved verbatim via sh -c — commands with
// interpolated shell quoting (JSON.stringify args, -- ${paths}) keep identical
// semantics without re-tokenizing into argv.
async function shExec(cmd: string, cwd: string, io: WorkspaceIO): Promise {
const result = await io.exec("sh", ["-c", cmd], { cwd });
if (result.exitCode !== 0) {
const err = new Error(
`command failed (exit ${result.exitCode}): ${cmd}\n${result.stderr.trim()}`,
);
throw err;
}
return result.stdout;
}
/**
* warpgogol-m000196: resolve "ours" for reconcile merge conflicts on
* cache-clone-only paths. "Ours" can mean two things: the clone HEAD version
* (path tracked in HEAD — restore it) or ABSENT (gitignored RFC-0913 files
* arrive tracked from the workpiece side and conflict as UD/DU, where clone
* HEAD has no such path and `git checkout HEAD --` fails with "pathspec did
* not match"). Presence in HEAD decides restore-vs-remove. Exported for the
* regression test — the inline version previously failed real reconciles.
*/
export async function resolveOursConflicts(
systemDir: string,
oursPaths: string[],
io: WorkspaceIO,
): Promise {
for (const oursPath of oursPaths) {
const quoted = JSON.stringify(oursPath);
const inHead = (await shExec(`git ls-tree HEAD -- ${quoted}`, systemDir, io)).trim();
if (inHead.length > 0) {
await shExec(`git checkout HEAD -- ${quoted}`, systemDir, io);
await shExec(`git add -- ${quoted}`, systemDir, io);
} else {
await shExec(`git rm -f --ignore-unmatch -- ${quoted}`, systemDir, io);
}
}
}
async function copyDir(src: string, dest: string, io: WorkspaceIO): Promise {
if (!(await io.exists(src))) return;
await io.mkdir(dest);
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);
} else {
await io.copyFile(srcPath, destPath);
}
}
}
function flagString(input: KernelCommandInput, key: string): string | undefined {
const v = input.flags[key];
return typeof v === "string" ? v : undefined;
}
// RFC-1074: Shared distribution persistence — used by both runMissionValidate and runMissionBuild.
// Copies workpiece/dist to distribution/dist, writes build-input-hash.json (on success),
// and writes build-manifest.json. Best-effort: caller wraps in try/catch if needed.
async function persistDistribution(params: {
workspaceRoot: string;
workpieceDir: string;
distributionDir: string;
missionId: string;
systemId: string;
succeeded: boolean;
routeCount: number;
sitemapHash: string;
builtAt: string;
io: WorkspaceIO;
error?: string;
}): Promise {
const distSrc = path.join(params.workpieceDir, "dist");
const distDest = path.join(params.distributionDir, "dist");
if (await params.io.exists(distSrc)) {
if (await params.io.exists(distDest)) {
await params.io.rm(distDest, { recursive: true });
}
await copyDir(distSrc, distDest, params.io);
}
if (params.succeeded) {
const { buildInputHash } = await computeBuildInputHash(
params.workspaceRoot,
params.workpieceDir,
);
await atomicWriteFile(
path.join(params.distributionDir, "build-input-hash.json"),
JSON.stringify({ buildInputHash, computedAt: new Date().toISOString() }, null, 2) + "\n",
);
}
const buildManifest = {
builtAt: params.builtAt,
missionId: params.missionId,
systemId: params.systemId,
succeeded: params.succeeded,
routeCount: params.routeCount,
sitemapHash: params.sitemapHash,
...(params.error ? { error: params.error } : {}),
};
await atomicWriteFile(
path.join(params.distributionDir, "build-manifest.json"),
JSON.stringify(buildManifest, null, 2) + "\n",
);
}
// RFC-0976: Shared auto-commit logic for both distribution-reuse and full-build success paths.
// Commits generated artifacts via commitWorkpieceIfDirty, then re-checks dirty state.
// Returns the post-commit dirty check so the caller can warn if files remain uncommitted.
async function autoCommitAfterValidation(
workpieceDir: string,
missionId: string,
skipAutoCommit: boolean,
logger: { info: (msg: string) => void; warn: (msg: string) => void },
io: WorkspaceIO,
): Promise<{ dirty: boolean; fileCount: number; files?: string[] }> {
let dirtyCheck = await isWorkpieceDirty(workpieceDir, io);
// ADR-0085: never attempt commits on a closed workpiece — RFC-0878 blocks
// them anyway; report the dirty state without the misleading failure warn.
const workpieceClosed = await io.exists(path.join(workpieceDir, ".closed"));
if (dirtyCheck.dirty && !skipAutoCommit && !workpieceClosed) {
try {
const autoCommit = await commitWorkpieceIfDirty(
workpieceDir,
missionId,
"chore: post-validation artifacts (mission.validate)",
io,
);
if (autoCommit.committed) {
logger.info(
`[mission.validate] auto-committed ${dirtyCheck.fileCount} generated artifact(s) (${autoCommit.commitSha?.slice(0, 8)})`,
);
}
dirtyCheck = await isWorkpieceDirty(workpieceDir, io);
} catch (err) {
logger.warn(
`[mission.validate] auto-commit failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`,
);
}
}
if (dirtyCheck.dirty) {
logger.warn(
`[mission.validate] workpiece has ${dirtyCheck.fileCount} uncommitted file(s) — reconcile will auto-commit these before merge. Run \`git status\` to review.`,
);
}
return dirtyCheck;
}
// RFC-0796: Workspace-level advisory check for stale entries in missions/ root.
// Warns about stale symlinks or terminal-state directories. Non-blocking.
export async function validateNoStaleMissionEntries(
workspaceRoot: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
const missionsPath = path.join(workspaceRoot, "missions");
if (!(await io.exists(missionsPath))) return [];
const warnings: StaleEntryViolation[] = [];
const entries = await io.readdir(missionsPath);
for (const entry of entries) {
if (entry.name === "archive") continue;
const entryPath = path.join(missionsPath, entry.name);
const sourceRel = `missions/${entry.name}`;
const linkCheck = await io.exec("test", ["-L", entryPath]);
if (linkCheck.exitCode === 0) {
warnings.push({
path: sourceRel,
kind: "symlink",
message: "stale symlink in missions/ root — run mission.archive to clean up",
});
} else if (entry.isDirectory) {
// Check if this is a terminal-state mission directory
const manifestPath = path.join(entryPath, "mission.yaml");
if (await io.exists(manifestPath)) {
try {
const content = await io.readFile(manifestPath);
const m = yamlParse(content);
if (m.state === "closed" || m.state === "aborted") {
warnings.push({
path: sourceRel,
kind: "terminal-state-in-root",
state: m.state,
message: `terminal-state mission (${m.state}) in missions/ root — run mission.archive to move to archive`,
});
}
} catch {
// Can't read manifest — skip
}
}
}
}
return warnings;
}
// §2: mission.validate
export interface StaleEntryViolation {
path: string;
kind: "symlink" | "terminal-state-in-root";
state?: string;
message: string;
}
export interface MissionValidateData {
missionId: string;
contractFull: { passed: boolean; validators: Array> };
build: { succeeded: boolean; routeCount: number; sitemapHash: string; error?: string };
diagnostics?: Diagnostic[];
distributionReused: boolean;
buildInputHash: string | null;
fullBuildRan: boolean;
validatedAt: string;
staleEntryWarnings?: StaleEntryViolation[];
}
interface BuildFailurePattern {
id: string;
test: (errorOutput: string) => boolean;
fixHint: string;
excerpt: (errorOutput: string) => string;
}
function extractErrorLine(output: string, pattern: RegExp): string {
const lines = output.split("\n");
const matchLine = lines.find((l) => pattern.test(l));
return (matchLine ?? lines[0] ?? "").trim().slice(0, 200);
}
const BUILD_FAILURE_PATTERNS: BuildFailurePattern[] = [
{
id: "enoent-system-manifest",
test: (out) => /ENOENT.*system\.md|loadSystemManifestSync.*not found/i.test(out),
fixHint:
"Guard loadSystemManifestSync with import.meta.env.DEV — it resolves __dirname differently during prerender. See middleware.template.ts.",
excerpt: (out) => extractErrorLine(out, /ENOENT|loadSystemManifestSync/i),
},
{
id: "module-not-found",
test: (out) => /Cannot find module|Module not found|ERR_MODULE_NOT_FOUND/i.test(out),
fixHint:
"Check the import path in the file shown above. If it's a workspace package, run pnpm install. If it's a relative path, verify the file exists.",
excerpt: (out) => extractErrorLine(out, /Cannot find module|Module not found/i),
},
{
id: "content-schema-error",
test: (out) => /schema|frontmatter|collection.*error|ZodError/i.test(out),
fixHint:
"Check the frontmatter of the file shown above against its content collection schema. Look for missing required fields or type mismatches.",
excerpt: (out) => extractErrorLine(out, /schema|frontmatter|ZodError/i),
},
{
id: "typescript-error",
test: (out) => /error TS\d+:|Type .* is not assignable/i.test(out),
fixHint:
"Fix the TypeScript type mismatch in the file shown above. Check the component props schema or the type declaration.",
excerpt: (out) => extractErrorLine(out, /error TS\d+/i),
},
];
function matchBuildFailure(errorOutput: string): BuildFailurePattern | undefined {
return BUILD_FAILURE_PATTERNS.find((p) => p.test(errorOutput));
}
export function buildFailureDiagnostics(buildError: string): Diagnostic[] {
const pattern = matchBuildFailure(buildError);
return [
{
ruleId: "BUILD-01",
severity: "error",
message: pattern
? `Astro build failed (${pattern.id}): ${pattern.excerpt(buildError)}`
: `Astro build failed: ${buildError.slice(0, 200)}`,
fixHint: pattern?.fixHint ?? "Read the full build output above for the error details.",
data: {
patternId: pattern?.id ?? "unknown",
buildErrorLength: buildError.length,
},
},
];
}
function buildValidateNextSteps(
missionId: string,
dirtyCheck: { dirty: boolean; fileCount: number },
): KernelNextStep[] {
return dirtyCheck.dirty
? [
{
action: `Commit uncommitted changes: pnpm exec werkstatt run mission.git.commit --mission ${missionId} --message ""`,
kind: "required",
},
{
action: `Then run: pnpm exec werkstatt run mission.reconcile --mission ${missionId}`,
kind: "optional",
},
]
: [
{
action: `Run: pnpm exec werkstatt run mission.reconcile --mission ${missionId}`,
kind: "optional",
},
{
action: `Then run: pnpm exec werkstatt run mission.close --mission ${missionId}`,
kind: "optional",
},
];
}
// RFC-1296: per-validator evidence summary (additive over RFC-1086 fields)
export interface ValidationReportValidator {
name: string;
status: "pass" | "fail";
exitCode: number;
/** RFC-1296: which pipeline produced the step — "build.check" | "build.post". */
pipeline?: string;
errors?: number;
warnings?: number;
rules?: Record;
}
// RFC-1086: PreviousValidationReport — subset of validation-report.json read by --fast
export interface PreviousValidationReport {
schemaVersion: string;
missionId: string;
contractFull: {
passed: boolean;
validators: ValidationReportValidator[];
/** RFC-1296: set when validators were copied from an earlier report. */
reusedFrom?: string;
/** RFC-1296: why a reuse-path report is not a pass. */
reason?: string;
};
build: {
succeeded: boolean;
routeCount: number;
sitemapHash: string;
failedSteps: Array<{ name: string; exitCode: number }>;
};
distributionReused: boolean;
buildInputHash: string | null;
fullBuildRan: boolean;
validatedAt: string;
}
// RFC-1086: read and parse the previous validation-report.json
export async function readPreviousValidationReport(
evidenceDir: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
const reportPath = path.join(evidenceDir, "validation-report.json");
try {
const raw = await io.readFile(reportPath);
return JSON.parse(raw) as PreviousValidationReport;
} catch {
return null;
}
}
// RFC-1086: check if all 4 phases passed in the previous report
export function isPreviousReportAllPassed(report: PreviousValidationReport): boolean {
return (
report.contractFull.passed && report.build.succeeded && report.build.failedSteps.length === 0
);
}
// RFC-0958: ValidateStepCtx — mutable context for journaled validate steps
export interface ValidateStepCtx {
workspaceRoot: string;
missionId: string;
manifest: MissionManifest;
logger: { info: (msg: string) => void; warn: (msg: string) => void };
evidenceDir: string;
missionDir: string;
workpieceDir: string;
// ADR-0085: synthetic site pointing at the workpiece — set for closed
// missions, where registry.currentMission is null and site discovery would
// resolve to the cache clone instead of the workpiece under audit.
siteWorkspace?: DiscoveredSiteWorkspace;
collectErrors: boolean;
skipContentRegression: boolean;
autoAcceptRegression: boolean;
force: boolean;
fast: boolean;
previousReport: PreviousValidationReport | null;
cascadeRerun: boolean;
prepareReport: KernelPipelineReport | null;
pipelineReport: KernelPipelineReport | null;
staticPassed: boolean;
stepCount: number;
failedSteps: Array<{ name: string; exitCode: number }>;
buildSucceeded: boolean;
buildError: string | undefined;
routeCount: number;
sitemapHash: string;
postPipelineReport: KernelPipelineReport | undefined;
dirtyBeforeBuildPost: { dirty: boolean; fileCount: number };
preliminaryBuildIdentityPath: string | null;
now: string;
io: WorkspaceIO;
}
// RFC-1086: create a minimally valid KernelPipelineReport for skipped phases
function makeSkippedPipelineReport(pipelineName: string): KernelPipelineReport {
const report: KernelPipelineReport = {
pipelineName,
exitCode: 0,
ok: true,
steps: [],
timing: {
pipeline: pipelineName,
totalDurationMs: 0,
stepCount: 0,
slowestSteps: [],
timeoutCount: 0,
warningCount: 0,
},
};
return report;
}
// RFC-0958: buildValidateSteps — journaled steps for mission.validate build cycle
export function buildValidateSteps(_ctx: ValidateStepCtx): OperationStep[] {
return [
{
name: "build-prepare",
run: async (c: ValidateStepCtx) => {
// RFC-1086: skip build.prepare if --fast and previous report shows it passed
if (c.fast && c.previousReport && c.previousReport.contractFull.passed) {
c.logger.info(" Skipping build.prepare (passed in previous run)");
c.prepareReport = makeSkippedPipelineReport("build.prepare");
return;
}
c.cascadeRerun = true;
c.logger.info(` Running build.prepare pipeline for ${c.manifest.systemId}…`);
const prepareResult = await executeKernelPipeline({
workspaceRoot: c.workspaceRoot,
pipelineName: "build.prepare",
siteName: c.manifest.systemId,
outputFormat: "pretty",
...(c.siteWorkspace ? { siteWorkspace: c.siteWorkspace } : {}),
...(c.force ? { force: true } : {}),
...(c.collectErrors ? { collectErrors: true } : {}),
});
c.prepareReport = Array.isArray(prepareResult) ? prepareResult[0] : prepareResult;
if (!c.prepareReport.ok) {
const failedPrepareSteps = c.prepareReport.steps
.filter((s) => !s.ok)
.map((s) => ({ name: s.commandName, exitCode: s.exitCode }));
const report = {
schemaVersion: "1.0.0",
missionId: c.missionId,
contractFull: {
passed: false,
validators: c.prepareReport.steps.map((s) => ({
name: s.commandName,
status: s.ok ? "pass" : "fail",
exitCode: s.exitCode,
})),
},
build: {
succeeded: false,
routeCount: 0,
sitemapHash: "sha256:failed",
failedSteps: failedPrepareSteps,
},
distributionReused: false,
buildInputHash: null,
fullBuildRan: true,
validatedAt: new Date().toISOString(),
};
await atomicWriteFile(
path.join(c.evidenceDir, "validation-report.json"),
JSON.stringify(report, null, 2) + "\n",
);
// RFC-0763: clean bordbuch projections on build.prepare failure path
await cleanupBordbuchOnFailure(
c.workspaceRoot,
c.manifest.systemId,
"build.prepare failure",
c.logger,
);
throw new Error(
`[mission.validate] ${c.missionId} build.prepare FAILED (${failedPrepareSteps.length} steps failed)`,
);
}
},
verify: async (c: ValidateStepCtx) => {
return c.prepareReport !== null && c.prepareReport.ok;
},
},
{
name: "build-check",
run: async (c: ValidateStepCtx) => {
// RFC-1086: skip build.check if --fast, no cascade, and previous report shows it passed
if (
c.fast &&
!c.cascadeRerun &&
c.previousReport &&
c.previousReport.build.failedSteps.length === 0
) {
c.logger.info(" Skipping build.check (passed in previous run)");
c.pipelineReport = makeSkippedPipelineReport("build.check");
c.staticPassed = true;
return;
}
c.cascadeRerun = true;
c.logger.info(` Running build.check pipeline for ${c.manifest.systemId}…`);
const pipelineFlags: Record = {};
if (c.skipContentRegression) pipelineFlags["skip-content-regression"] = true;
if (c.autoAcceptRegression) pipelineFlags["auto-accept"] = true;
const pipelineResult = await executeKernelPipeline({
workspaceRoot: c.workspaceRoot,
pipelineName: "build.check",
siteName: c.manifest.systemId,
outputFormat: "pretty",
...(c.siteWorkspace ? { siteWorkspace: c.siteWorkspace } : {}),
...(c.force ? { force: true } : {}),
...(Object.keys(pipelineFlags).length > 0 ? { flags: pipelineFlags } : {}),
...(c.collectErrors ? { collectErrors: true } : {}),
});
c.pipelineReport = Array.isArray(pipelineResult) ? pipelineResult[0] : pipelineResult;
c.staticPassed = c.pipelineReport.ok;
c.stepCount = c.pipelineReport.steps.length;
c.failedSteps = c.pipelineReport.steps
.filter((s) => !s.ok)
.map((s) => ({ name: s.commandName, exitCode: s.exitCode }));
},
verify: async (c: ValidateStepCtx) => {
return c.pipelineReport !== null;
},
},
{
name: "astro-build",
run: async (c: ValidateStepCtx) => {
if (!c.staticPassed) return;
// RFC-1086: skip astro build if --fast, no cascade, and previous report shows it passed
if (c.fast && !c.cascadeRerun && c.previousReport && c.previousReport.build.succeeded) {
c.logger.info(" Skipping astro build (passed in previous run)");
c.buildSucceeded = true;
c.routeCount = c.previousReport.build.routeCount;
c.sitemapHash = c.previousReport.build.sitemapHash;
return;
}
c.cascadeRerun = true;
// RFC-0615: clean stale dist/ before build to prevent false positives
const distDir = path.join(c.workpieceDir, "dist");
if (await c.io.exists(distDir)) {
c.logger.info(` Cleaning stale dist/ before build…`);
await c.io.rm(distDir, { recursive: true });
}
c.preliminaryBuildIdentityPath = await writePreliminaryBuildIdentity(
c.workpieceDir,
{
releaseId: `workpiece-${c.missionId}`,
systemId: c.manifest.systemId,
missionId: c.missionId,
semver: "0.0.0-workpiece",
},
c.logger,
);
c.logger.info(` Running astro build in ${c.workpieceDir}…`);
try {
const buildResult = await c.io.exec("pnpm", ["exec", "astro", "build"], {
cwd: c.workpieceDir,
timeoutMs: 300_000,
});
if (buildResult.exitCode !== 0) {
throw new Error(
buildResult.stderr.trim() || `astro build exited ${buildResult.exitCode}`,
);
}
c.buildSucceeded = true;
// RFC-1296: honest dist facts — count rendered non-redirect pages and
// hash the sitemap at its real adapter-specific location.
const facts = await computeDistFacts(path.join(c.workpieceDir, "dist"), c.io);
c.routeCount = facts.routeCount;
c.sitemapHash = facts.sitemapHash;
} catch (err) {
c.buildError = err instanceof Error ? err.message : String(err);
c.logger.info(` Build failed: ${c.buildError}`);
}
// RFC-0615: check dirty state before build.post
c.dirtyBeforeBuildPost = await isWorkpieceDirty(c.workpieceDir, c.io);
if (c.dirtyBeforeBuildPost.dirty) {
c.logger.info(
` [warn] workpiece has ${c.dirtyBeforeBuildPost.fileCount} uncommitted file(s) — snapshot auto-regeneration will be skipped`,
);
}
},
verify: async (c: ValidateStepCtx) => {
// Step is done if static checks failed (skip path) or build was attempted
return c.buildSucceeded || c.buildError !== undefined || !c.staticPassed;
},
},
{
name: "build-post",
run: async (c: ValidateStepCtx) => {
// RFC-1086: skip build.post if --fast, no cascade, and previous report shows build succeeded
if (c.fast && !c.cascadeRerun && c.previousReport && c.previousReport.build.succeeded) {
c.logger.info(" Skipping build.post (passed in previous run)");
// Still need to write the validation report.
// RFC-1296: reuse without validator evidence is not a pass.
c.now = new Date().toISOString();
const report = {
schemaVersion: "1.0.0",
missionId: c.missionId,
contractFull: contractFullFromPrevious(c.previousReport),
build: {
succeeded: true,
routeCount: c.routeCount,
sitemapHash: c.sitemapHash,
failedSteps: [],
},
distributionReused: c.previousReport.distributionReused,
buildInputHash: c.previousReport.buildInputHash,
fullBuildRan: false,
validatedAt: c.now,
};
await atomicWriteFile(
path.join(c.evidenceDir, "validation-report.json"),
JSON.stringify(report, null, 2) + "\n",
);
return;
}
// RFC-0356: run build.post after astro build — only when static checks passed
if (c.staticPassed) {
if (c.buildSucceeded) {
// ADR-0096: ensure a committed behavior.snapshot.generated.yaml
// exists before build.post — behavior.snapshot.validate diffs
// against the committed baseline (SNAP-02) and would deadlock
// first-time sites otherwise. Non-fatal: failure is logged and
// build.post proceeds (SNAP-02's fixHint remains the fallback).
await bootstrapCommittedSnapshot({
workspaceRoot: c.workspaceRoot,
systemId: c.manifest.systemId,
missionId: c.missionId,
logger: c.logger,
});
c.logger.info(` Running build.post pipeline for ${c.manifest.systemId}…`);
try {
const postResult = await executeKernelPipeline({
workspaceRoot: c.workspaceRoot,
pipelineName: "build.post",
siteName: c.manifest.systemId,
outputFormat: "pretty",
...(c.siteWorkspace ? { siteWorkspace: c.siteWorkspace } : {}),
...(c.force ? { force: true } : {}),
...(c.collectErrors ? { collectErrors: true } : {}),
});
c.postPipelineReport = Array.isArray(postResult) ? postResult[0] : postResult;
if (!c.postPipelineReport.ok) {
c.buildError = `build.post failed at step: ${c.postPipelineReport.timing.failedStep ?? "unknown"}`;
c.buildSucceeded = false;
c.logger.info(` ${c.buildError}`);
}
} catch (err) {
c.buildError = err instanceof Error ? err.message : String(err);
c.buildSucceeded = false;
c.logger.info(` build.post failed: ${c.buildError}`);
}
}
// RFC-0615/RFC-0697: auto-regenerate behavior snapshot on SNAP-01
if (
c.postPipelineReport &&
!c.postPipelineReport.ok &&
!c.dirtyBeforeBuildPost.dirty &&
!c.buildSucceeded
) {
const snapshotStep = c.postPipelineReport.steps.find(
(s) => s.commandName === "behavior.snapshot.validate",
);
const snapResult = await orchestrateSnap01Recovery({
workspaceRoot: c.workspaceRoot,
systemId: c.manifest.systemId,
missionId: c.missionId,
logger: c.logger,
validateFn: async () => snapshotStep?.data,
rebuildFn: async () => {
c.logger.info(` Re-running build.post after snapshot regeneration…`);
const revalidateResult = await executeKernelPipeline({
workspaceRoot: c.workspaceRoot,
pipelineName: "build.post",
siteName: c.manifest.systemId,
outputFormat: "pretty",
...(c.siteWorkspace ? { siteWorkspace: c.siteWorkspace } : {}),
...(c.force ? { force: true } : {}),
...(c.collectErrors ? { collectErrors: true } : {}),
});
const revalidateReport = Array.isArray(revalidateResult)
? revalidateResult[0]
: revalidateResult;
if (!revalidateReport.ok) {
throw new Error(
`build.post still failing after snapshot regeneration: ${revalidateReport.timing.failedStep ?? "unknown"}`,
);
}
},
});
if (snapResult.regenerated && snapResult.rebuildSucceeded) {
c.buildSucceeded = true;
c.buildError = undefined;
c.logger.info(` build.post passed after snapshot regeneration`);
} else if (snapResult.regenerated && !snapResult.rebuildSucceeded) {
c.buildError =
snapResult.error ?? "build.post still failing after snapshot regeneration";
c.logger.info(` ${c.buildError}`);
} else if (snapResult.error) {
c.buildError = snapResult.error;
}
}
}
// Cleanup preliminary build identity
if (c.preliminaryBuildIdentityPath) {
await cleanupPreliminaryBuildIdentity(c.preliminaryBuildIdentityPath);
}
// Write validation report
c.now = new Date().toISOString();
const passed = c.staticPassed && c.buildSucceeded;
const buildDiagnostics = c.buildError ? buildFailureDiagnostics(c.buildError) : [];
// RFC-1296: per-validator diagnostics summary — errors/warnings per
// rule, and build.post steps are recorded too (tagged by pipeline).
const report = {
schemaVersion: "1.0.0",
missionId: c.missionId,
contractFull: {
passed,
validators: validatorsFromPipelineReports(c.pipelineReport, c.postPipelineReport),
},
build: {
succeeded: c.buildSucceeded,
routeCount: c.routeCount,
sitemapHash: c.sitemapHash,
...(c.buildError ? { error: c.buildError } : {}),
failedSteps: c.failedSteps,
},
...(buildDiagnostics.length > 0 ? { diagnostics: buildDiagnostics } : {}),
distributionReused: false,
buildInputHash: null,
fullBuildRan: true,
validatedAt: c.now,
};
await atomicWriteFile(
path.join(c.evidenceDir, "validation-report.json"),
JSON.stringify(report, null, 2) + "\n",
);
},
verify: async (c: ValidateStepCtx) => {
// Report is written — check file exists
return c.io.exists(path.join(c.evidenceDir, "validation-report.json"));
},
},
];
}
export async function runMissionValidate(
input: KernelCommandInput,
context: KernelRuntimeContext,
): Promise> {
const { workspaceRoot, logger } = context;
const missionId = flagString(input, "mission");
if (!missionId) throw new Error("[mission.validate] --mission is required");
const manifest = await readMissionManifest(workspaceRoot, missionId);
// ADR-0085: closed missions remain auditable — mutating pipeline steps are
// skipped by the executor (`.closed` sentinel), validators run read-only.
if (manifest.state !== "open" && manifest.state !== "closed") {
throw new Error(
`[mission.validate] mission '${missionId}' is not open or closed (state: ${manifest.state})`,
);
}
if (!manifest.materializedAt) {
throw new Error(
`[mission.validate] mission '${missionId}' has not been materialized — run mission.materialize first`,
);
}
const missionDir = await resolveMissionDir(workspaceRoot, missionId, context.io);
const workpieceDir = path.join(missionDir, "workpiece");
const evidenceDir = path.join(missionDir, "evidence");
const distributionDir = path.join(missionDir, "distribution");
await context.io.mkdir(evidenceDir);
// ADR-0085: for closed missions, registry.currentMission is null and site
// discovery would resolve to the cache clone — point pipelines at the
// workpiece under audit via a synthetic site (same pattern as
// release.prepare).
const closedSiteWorkspace: DiscoveredSiteWorkspace | undefined =
manifest.state === "closed"
? {
name: manifest.systemId,
directory: workpieceDir,
toolsDirectory: path.join(workpieceDir, "tools"),
configPath: path.join(workpieceDir, "tools", "kernel.config.ts"),
}
: undefined;
// RFC-1086: parse --fast flag — mutually exclusive with --force
const fast = input.flags.fast === true;
const force = input.flags.force === true;
if (fast && force) {
return {
exitCode: 1,
summary: "[mission.validate] --fast and --force are mutually exclusive",
nextSteps: [
{
action: "[mission.validate] --fast and --force are mutually exclusive",
kind: "required" as const,
},
],
data: {
missionId,
contractFull: { passed: false, validators: [] },
build: { succeeded: false, routeCount: 0, sitemapHash: "sha256:invalid" },
distributionReused: false,
buildInputHash: null,
fullBuildRan: false,
validatedAt: new Date().toISOString(),
} as unknown as MissionValidateData,
};
}
// RFC-1086: read previous validation-report.json BEFORE deletion (when --fast is set)
let previousReport: PreviousValidationReport | null = null;
let effectiveFast = fast;
if (fast) {
previousReport = await readPreviousValidationReport(evidenceDir);
if (previousReport === null) {
logger.warn(" No previous validation report found — running full validation.");
effectiveFast = false;
} else if (previousReport.missionId !== missionId) {
logger.warn(
" Previous validation report is for a different mission — running full validation.",
);
effectiveFast = false;
previousReport = null;
} else if (isPreviousReportAllPassed(previousReport)) {
// All phases passed — short-circuit, do NOT overwrite the existing report
logger.info(" All phases passed in previous report — skipping validation.");
const staleEntryWarnings = await validateNoStaleMissionEntries(workspaceRoot, context.io);
if (staleEntryWarnings.length > 0) {
for (const w of staleEntryWarnings) {
logger.warn(` [stale-entry] ${w.path}: ${w.message}`);
}
}
await writeValidationState(workspaceRoot, {
missionId,
lastValidatedAt: previousReport.validatedAt,
lastValidationStatus: "pass",
validatorStates: [],
});
return {
data: {
...previousReport,
staleEntryWarnings,
} as unknown as MissionValidateData,
exitCode: 0,
summary: `[mission.validate] ${missionId} validation passed (all phases passed in previous report — skipped)`,
};
}
}
// RFC-1020: Delete stale validation report from any previous run.
// This prevents mission.reconcile from reading a failed report after a successful re-validate.
// The report is always rewritten by the validate step (success or failure), so deleting it
// upfront is safe — a fresh report will be written by the end of this run.
// RFC-1086: guard deletion when --fast is active so the previous report can be read.
const validationReportPath = path.join(evidenceDir, "validation-report.json");
if (!effectiveFast) {
await context.io.rm(validationReportPath).catch(() => {});
}
// RFC-0724: Auto-commit dirty bordbuch files on all paths (not just reuse).
// This prevents dirty bordbuch projection files from blocking the pipeline.
try {
const bordbuchResult = await commitBordbuchProjections(workspaceRoot, manifest.systemId);
if (bordbuchResult.committed) {
logger.info(
` Bordbuch auto-commit: committed ${bordbuchResult.filesCommitted.length} file(s) before validate`,
);
}
} catch (err) {
logger.warn(
` Bordbuch auto-commit failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`,
);
}
// RFC-0635: check if distribution can be reused by comparing build-input-hash.
// If the hash matches and distribution/dist/ exists, skip the entire build cycle.
// RFC-1086: --fast skips distribution reuse — it needs to run the pipeline phases.
if (!force && !effectiveFast) {
const distributionMetaPath = path.join(distributionDir, "build-input-hash.json");
const distributionDistDir = path.join(distributionDir, "dist");
if (
(await context.io.exists(distributionMetaPath)) &&
(await context.io.exists(distributionDistDir))
) {
let canReuse = false;
let storedHash: string | null = null;
try {
const meta = JSON.parse(await context.io.readFile(distributionMetaPath));
storedHash = meta.buildInputHash ?? null;
} catch {
// Corrupt or unreadable — fall through to full build
}
if (storedHash) {
try {
const { buildInputHash: computedHash } = await computeBuildInputHash(
workspaceRoot,
workpieceDir,
);
if (storedHash === computedHash) {
canReuse = true;
}
} catch {
// Hash computation failed — fall through to full build
}
}
if (canReuse && storedHash) {
logger.info(` Distribution reuse: build-input-hash matched — skipping build cycle`);
// Copy distribution/dist/ to workpiece/dist/ if missing
const workpieceDistDir = path.join(workpieceDir, "dist");
if (!(await context.io.exists(workpieceDistDir))) {
logger.info(` Copying distribution/dist/ to workpiece/dist/…`);
await copyDir(distributionDistDir, workpieceDistDir, context.io);
}
// RFC-1296: derive dist facts from the reused distribution tree, not
// the build-manifest — the tree is the evidence.
const distFacts = await computeDistFacts(distributionDistDir, context.io);
// RFC-1296: reuse the previous report's validator evidence; reuse
// without evidence is not a pass — the operator reruns a full validate.
const previousReport = await readPreviousValidationReport(evidenceDir, context.io);
const now = new Date().toISOString();
const reusedReport = {
schemaVersion: "1.0.0",
missionId,
contractFull: contractFullFromPrevious(previousReport),
build: {
succeeded: true,
routeCount: distFacts.routeCount,
sitemapHash: distFacts.sitemapHash,
},
distributionReused: true,
buildInputHash: storedHash,
fullBuildRan: false,
validatedAt: now,
};
await atomicWriteFile(
path.join(evidenceDir, "validation-report.json"),
JSON.stringify(reusedReport, null, 2) + "\n",
);
// RFC-0976: auto-commit generated artifacts after successful validation (reuse path).
const dirtyCheck = await autoCommitAfterValidation(
workpieceDir,
missionId,
input.flags["skip-auto-commit"] === true,
logger,
context.io,
);
// RFC-0724: bordbuch auto-commit is now done at the top of mission.validate (covers all paths).
// The reuse path no longer needs its own cleanup call.
const reuseNextSteps = buildValidateNextSteps(missionId, dirtyCheck);
// RFC-0796: stale entry warnings (non-blocking)
const staleEntryWarnings = await validateNoStaleMissionEntries(workspaceRoot, context.io);
if (staleEntryWarnings.length > 0) {
for (const w of staleEntryWarnings) {
logger.warn(` [stale-entry] ${w.path}: ${w.message}`);
}
}
// RFC-1028: write .validation-state.json
await writeValidationState(workspaceRoot, {
missionId,
lastValidatedAt: now,
lastValidationStatus: "pass",
validatorStates: [],
});
return {
data: { ...reusedReport, staleEntryWarnings } as unknown as MissionValidateData,
exitCode: 0,
summary: `[mission.validate] ${missionId} validation passed (distribution reused, build-input-hash matched)`,
nextSteps: reuseNextSteps,
};
}
}
}
// RFC-0844: Operator config presence check — fail fast before expensive build.
// Runs BEFORE Playwright pre-flight (existsSync <10ms vs browser launch ~100-500ms).
// Skipped on distribution-reuse path (returns early above).
try {
const presenceResult = (await executeKernelCommand({
workspaceRoot,
commandName: "workpiece.config.presence.check",
argv: [`--mission=${missionId}`],
outputFormat: "pretty",
})) as { exitCode?: number; data?: WorkpieceConfigPresenceResult };
if ((presenceResult.exitCode ?? 0) !== 0) {
const missing = presenceResult.data?.missing ?? [];
const missingFiles = missing.map((m) => m.file).join(", ");
logger.info(` [preflight] Missing operator config files: ${missingFiles}`);
for (const m of missing) {
logger.info(` Restore: ${m.restoreCommand}`);
}
const preflightReport = {
schemaVersion: "1.0.0",
missionId,
contractFull: { passed: false, validators: [] },
build: {
succeeded: false,
routeCount: 0,
sitemapHash: "sha256:config-presence-failed",
failedSteps: [{ name: "workpiece.config.presence.check", exitCode: 1 }],
},
distributionReused: false,
buildInputHash: null,
fullBuildRan: false,
validatedAt: new Date().toISOString(),
};
await atomicWriteFile(
path.join(evidenceDir, "validation-report.json"),
JSON.stringify(preflightReport, null, 2) + "\n",
);
return {
data: preflightReport as unknown as MissionValidateData,
exitCode: 1,
summary: `[mission.validate] ${missionId} pre-flight FAILED: missing operator config files (${missingFiles})`,
nextSteps: [
{
action: `Restore missing files:\n${missing.map((m) => m.restoreCommand).join("\n")}\nThen re-run: pnpm exec werkstatt run mission.validate --mission ${missionId}`,
kind: "required",
},
],
};
}
logger.info(` Operator config files: all present`);
} catch (err) {
// Non-fatal: if the check itself throws unexpectedly, log and continue
logger.warn(
` Operator config presence check error (non-fatal): ${err instanceof Error ? err.message : String(err)}`,
);
}
// RFC-0813: Playwright Chromium pre-flight check — fail fast before expensive
// build.prepare + build.check + astro build + build.post cycle.
// Skipped on distribution-reuse path (returns early above).
try {
const preflightResult = (await executeKernelCommand({
workspaceRoot,
commandName: "playwright.preflight.check",
outputFormat: "pretty",
})) as { exitCode?: number; summary?: string };
if ((preflightResult.exitCode ?? 0) !== 0) {
const msg = preflightResult.summary ?? "Playwright Chromium is not installed";
logger.info(` [preflight] ${msg}`);
const preflightReport = {
schemaVersion: "1.0.0",
missionId,
contractFull: { passed: false, validators: [] },
build: {
succeeded: false,
routeCount: 0,
sitemapHash: "sha256:preflight-failed",
failedSteps: [{ name: "playwright.preflight.check", exitCode: 1 }],
},
distributionReused: false,
buildInputHash: null,
fullBuildRan: false,
validatedAt: new Date().toISOString(),
};
await atomicWriteFile(
path.join(evidenceDir, "validation-report.json"),
JSON.stringify(preflightReport, null, 2) + "\n",
);
return {
data: preflightReport as unknown as MissionValidateData,
exitCode: 1,
summary: `[mission.validate] ${missionId} pre-flight FAILED: Playwright Chromium is not installed`,
nextSteps: [
{
action: `Run: pnpm exec playwright install chromium, then re-run: pnpm exec werkstatt run mission.validate --mission ${missionId}`,
kind: "required",
},
],
};
}
logger.info(` Playwright Chromium: pre-flight check passed`);
} catch (err) {
// Non-fatal: if the check itself throws unexpectedly, log and continue
logger.warn(
` Playwright Chromium pre-flight check error (non-fatal): ${err instanceof Error ? err.message : String(err)}`,
);
}
// RFC-0958: Wrap the build cycle in runOperation for crash-safe resumable execution.
// Pre-flight checks (distribution reuse, config presence, playwright) run outside the journal.
// The build cycle (build.prepare → build.check → astro build → build.post → report write) runs as journaled steps.
// Post-validation logic (dirty check, cache clone cleanup, stale entries, nextSteps) runs after runOperation.
const collectErrors = input.flags["collect-errors"] === true;
const skipContentRegression = input.flags["skip-content-regression"] === true;
const autoAcceptRegression = input.flags["auto-accept-regression"] === true;
const validateCtx: ValidateStepCtx = {
workspaceRoot,
missionId,
manifest,
logger,
evidenceDir,
missionDir,
workpieceDir,
...(closedSiteWorkspace ? { siteWorkspace: closedSiteWorkspace } : {}),
collectErrors,
skipContentRegression,
autoAcceptRegression,
force,
fast: effectiveFast,
previousReport,
cascadeRerun: false,
prepareReport: null,
pipelineReport: null,
staticPassed: false,
stepCount: 0,
failedSteps: [],
buildSucceeded: false,
buildError: undefined,
routeCount: 0,
sitemapHash: "sha256:not-built",
io: context.io,
postPipelineReport: undefined,
dirtyBeforeBuildPost: { dirty: false, fileCount: 0 },
preliminaryBuildIdentityPath: null,
now: new Date().toISOString(),
};
const journalPath = path.join(missionDir, "journal.jsonl");
// RFC-0958 Step 7: block if a different-kind operation is incomplete
const blockCheck = await checkDifferentKindOperation(journalPath, "mission.validate");
if (blockCheck.blocked) {
return {
data: {
missionId,
contractFull: { passed: false, validators: [] },
build: { succeeded: false, routeCount: 0, sitemapHash: "sha256:blocked" },
distributionReused: false,
buildInputHash: null,
fullBuildRan: false,
validatedAt: new Date().toISOString(),
blockedByOperation: blockCheck.incompleteOp,
} as unknown as MissionValidateData,
exitCode: 1,
summary: `[mission.validate] ${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",
},
],
};
}
// RFC-0973: --force auto-clears all caches and stale artifacts before pipeline execution.
// Must happen AFTER checkDifferentKindOperation passes to preserve incomplete-operation detection.
if (force) {
logger.info(" Clearing all caches and stale artifacts (--force)…");
// 1. Clear kernel cache DB (all namespaces)
try {
const cache = await createCacheLayer(workspaceRoot);
try {
await cache.clear();
} finally {
await cache.close();
}
} catch (err) {
logger.warn(
` Kernel cache clear failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`,
);
}
// 2. Clear pipeline cache hits
try {
await clearPipelineCacheHits(workspaceRoot);
} catch (err) {
logger.warn(
` Pipeline cache hits clear failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`,
);
}
// 3. Delete stale journal (safe — checkDifferentKindOperation already passed)
await context.io.rm(path.join(missionDir, "journal.jsonl")).catch(() => {});
// 4. Delete stale validation report
await context.io.rm(path.join(evidenceDir, "validation-report.json")).catch(() => {});
}
const opResult = await runOperation(
journalPath,
{ op: "mission.validate", steps: buildValidateSteps(validateCtx) },
validateCtx,
);
if (!opResult.completed) {
const detail = opResult.failedStepError ?? "unknown error";
const failedStep = opResult.failedStep;
// build.prepare failure writes its own report before throwing
if (failedStep === "build-prepare") {
return {
exitCode: 1,
summary: `[mission.validate] ${missionId} build.prepare FAILED: ${detail}`,
nextSteps: [
{
action: `[mission.validate] ${missionId} build.prepare FAILED: ${detail}`,
kind: "required" as const,
},
],
data: {
schemaVersion: "1.0.0",
missionId,
contractFull: { passed: false, validators: [] },
build: { succeeded: false, routeCount: 0, sitemapHash: "sha256:failed" },
distributionReused: false,
buildInputHash: null,
fullBuildRan: true,
validatedAt: new Date().toISOString(),
} as unknown as MissionValidateData,
};
}
throw new Error(
`[mission.validate] step "${failedStep}" failed: ${detail} — run mission.resume --mission ${missionId} to retry`,
);
}
// Read report from context state after runOperation
const passed = validateCtx.staticPassed && validateCtx.buildSucceeded;
const buildDiagnostics = validateCtx.buildError
? buildFailureDiagnostics(validateCtx.buildError)
: [];
const report = {
schemaVersion: "1.0.0",
missionId,
contractFull: {
passed,
validators:
validateCtx.pipelineReport?.steps.map((s) => ({
name: s.commandName,
status: s.ok ? "pass" : "fail",
exitCode: s.exitCode,
})) ?? [],
},
build: {
succeeded: validateCtx.buildSucceeded,
routeCount: validateCtx.routeCount,
sitemapHash: validateCtx.sitemapHash,
...(validateCtx.buildError ? { error: validateCtx.buildError } : {}),
failedSteps: validateCtx.failedSteps,
},
...(buildDiagnostics.length > 0 ? { diagnostics: buildDiagnostics } : {}),
distributionReused: false,
buildInputHash: null,
fullBuildRan: true,
validatedAt: validateCtx.now,
};
if (!passed) {
const reason = !validateCtx.staticPassed
? `${validateCtx.failedSteps.length}/${validateCtx.stepCount} steps failed`
: "astro build failed";
const failNextSteps: KernelNextStep[] = [
{
action: `Fix the failing validators above, then re-run: pnpm exec werkstatt run mission.validate --mission ${missionId}`,
kind: "required",
},
];
// RFC-0763: clean bordbuch projections on validation failure path
await cleanupBordbuchOnFailure(workspaceRoot, manifest.systemId, "validation failure", logger);
// RFC-1028: write .validation-state.json
await writeValidationState(workspaceRoot, {
missionId,
lastValidatedAt: validateCtx.now,
lastValidationStatus: "fail",
validatorStates: validateCtx.pipelineReport
? buildValidatorStatesFromSteps(validateCtx.pipelineReport.steps)
: [],
});
return {
data: report as unknown as MissionValidateData,
exitCode: 1,
summary: `[mission.validate] ${missionId} validation FAILED (${reason})`,
nextSteps: failNextSteps,
};
}
// RFC-1074: Persist distribution after successful full build so release.prepare can reuse it.
// Shared with runMissionBuild via persistDistribution helper.
try {
await persistDistribution({
workspaceRoot,
workpieceDir,
distributionDir,
missionId,
systemId: manifest.systemId,
succeeded: true,
routeCount: validateCtx.routeCount,
sitemapHash: validateCtx.sitemapHash,
builtAt: validateCtx.now,
io: context.io,
});
logger.info(
` Distribution persisted to missions/${missionId}/distribution/ (reusable by release.prepare)`,
);
} catch (err) {
logger.warn(
` Distribution persist failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`,
);
}
// RFC-0976: auto-commit generated artifacts after successful validation.
const dirtyCheck = await autoCommitAfterValidation(
workpieceDir,
missionId,
input.flags["skip-auto-commit"] === true,
logger,
context.io,
);
// RFC-0797: post-validation cache clone cleanup — commit ALL generated files
// (superset of bordbuch projections). This prevents the RFC-0522 dirty cache clone
// warning from firing for any generated file and unblocks mission.reconcile.
try {
const postValidateCachePath = await resolveCacheClonePath(workspaceRoot, manifest.systemId);
const postValidateCache = await commitCacheCloneIfDirty(
postValidateCachePath,
manifest.systemId,
context.io,
);
if (postValidateCache.committed) {
logger.info(
` Cache clone post-validate cleanup: committed generated file(s) (${postValidateCache.commitSha?.slice(0, 8)})`,
);
}
} catch (err) {
logger.warn(
` Cache clone post-validate cleanup failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`,
);
}
// RFC-0522: warn on dirty cache clone — reconcile will fail until resolved
const systemDir = await resolveCacheClonePath(workspaceRoot, manifest.systemId);
if (await context.io.exists(path.join(systemDir, ".git"))) {
const cacheDirtyCheck = await isWorkpieceDirty(systemDir, context.io);
if (cacheDirtyCheck.dirty) {
logger.warn(
`[mission.validate] cache clone for system '${manifest.systemId}' has ${cacheDirtyCheck.fileCount} uncommitted file(s) — reconcile will fail until resolved`,
);
}
}
// RFC-0796: stale entry warnings (non-blocking)
const staleEntryWarnings = await validateNoStaleMissionEntries(workspaceRoot, context.io);
if (staleEntryWarnings.length > 0) {
for (const w of staleEntryWarnings) {
logger.warn(` [stale-entry] ${w.path}: ${w.message}`);
}
}
// RFC-0579: populate nextSteps based on workpiece dirty state
const passNextSteps = buildValidateNextSteps(missionId, dirtyCheck);
// RFC-1028: write .validation-state.json
await writeValidationState(workspaceRoot, {
missionId,
lastValidatedAt: validateCtx.now,
lastValidationStatus: "pass",
validatorStates: validateCtx.pipelineReport
? buildValidatorStatesFromSteps(validateCtx.pipelineReport.steps)
: [],
});
return {
data: { ...report, staleEntryWarnings } as unknown as MissionValidateData,
exitCode: 0,
summary: `[mission.validate] ${missionId} validation passed (${validateCtx.stepCount} steps, ${validateCtx.routeCount} routes built)`,
nextSteps: passNextSteps,
};
}
// §3: mission.preview — extracted to mission-preview.ts (RFC-0480)
// §4: mission.build
export interface MissionBuildData {
missionId: string;
distributionPath: string;
buildSucceeded: boolean;
builtAt: string;
}
export async function runMissionBuild(
input: KernelCommandInput,
context: KernelRuntimeContext,
): Promise> {
const { workspaceRoot, logger } = context;
const missionId = flagString(input, "mission");
if (!missionId) throw new Error("[mission.build] --mission is required");
const manifest = await readMissionManifest(workspaceRoot, missionId);
if (manifest.state !== "open") {
throw new Error(
`[mission.build] mission '${missionId}' is not open (state: ${manifest.state})`,
);
}
const missionDir = await resolveMissionDir(workspaceRoot, missionId, context.io);
const workpieceDir = path.join(missionDir, "workpiece");
const distributionDir = path.join(missionDir, "distribution");
// RFC-0356 §4: run build.prepare → astro build → build.post unconditionally.
// All three phases must succeed — without build.post the distribution is
// unsigned (text.normalize.apply, passport.emit, etc. never run).
let buildSucceeded = false;
let buildError: string | undefined;
// Phase 1: build.prepare (codegen, derived artifacts)
logger.info(` Running build.prepare pipeline for ${manifest.systemId}…`);
try {
await runPipelinePhase(workspaceRoot, "build.prepare", manifest.systemId);
} catch (err) {
buildError = err instanceof Error ? err.message : String(err);
logger.info(` build.prepare failed: ${buildError}`);
}
// Phase 1b: build.check (content validators) — RFC-0635
// Ensures distributions have passed all content validators before being
// reused by mission.validate. build-input-hash.json is only written when
// all phases including build.check succeed.
if (!buildError) {
logger.info(` Running build.check pipeline for ${manifest.systemId}…`);
try {
await runPipelinePhase(workspaceRoot, "build.check", manifest.systemId);
} catch (err) {
buildError = err instanceof Error ? err.message : String(err);
logger.info(` build.check failed: ${buildError}`);
}
}
// Phase 2: astro build
let buildRouteCount = 0;
let buildSitemapHash = "sha256:no-sitemap";
let preliminaryBuildIdentityPath: string | null = null;
if (!buildError) {
preliminaryBuildIdentityPath = await writePreliminaryBuildIdentity(
workpieceDir,
{
releaseId: `workpiece-${missionId}`,
systemId: manifest.systemId,
missionId,
semver: "0.0.0-workpiece",
},
logger,
);
logger.info(` Running astro build in ${workpieceDir}…`);
try {
const buildResult = await context.io.exec("pnpm", ["exec", "astro", "build"], {
cwd: workpieceDir,
timeoutMs: 300_000,
});
if (buildResult.exitCode !== 0) {
throw new Error(buildResult.stderr.trim() || `astro build exited ${buildResult.exitCode}`);
}
// RFC-1296: honest dist facts — count rendered non-redirect pages and
// hash the sitemap at its real adapter-specific location.
const facts = await computeDistFacts(path.join(workpieceDir, "dist"), context.io);
buildRouteCount = facts.routeCount;
buildSitemapHash = facts.sitemapHash;
} catch (err) {
buildError = err instanceof Error ? err.message : String(err);
logger.info(` Astro build failed: ${buildError}`);
}
}
// Phase 3: build.post (text.normalize.apply, passport.emit, etc.)
if (!buildError) {
// ADR-0096: ensure a committed behavior.snapshot.generated.yaml exists
// before build.post — behavior.snapshot.validate diffs against the
// committed baseline (SNAP-02) and would deadlock first-time missions
// otherwise. Non-fatal: failure is logged and build.post proceeds
// (SNAP-02's fixHint remains the fallback).
await bootstrapCommittedSnapshot({
workspaceRoot,
systemId: manifest.systemId,
missionId,
logger,
context,
});
logger.info(` Running build.post pipeline for ${manifest.systemId}…`);
try {
await runPipelinePhase(workspaceRoot, "build.post", manifest.systemId);
} catch (err) {
buildError = err instanceof Error ? err.message : String(err);
logger.info(` build.post failed: ${buildError}`);
}
}
buildSucceeded = !buildError;
if (preliminaryBuildIdentityPath) {
await cleanupPreliminaryBuildIdentity(preliminaryBuildIdentityPath);
}
// RFC-1074: Shared with runMissionValidate via persistDistribution helper.
const now = new Date().toISOString();
const distDest = path.join(distributionDir, "dist");
await persistDistribution({
workspaceRoot,
workpieceDir,
distributionDir,
missionId,
systemId: manifest.systemId,
succeeded: buildSucceeded,
routeCount: buildRouteCount,
sitemapHash: buildSitemapHash,
builtAt: now,
io: context.io,
error: buildError,
});
const evidenceDir = path.join(missionDir, "evidence");
await context.io.mkdir(evidenceDir);
const buildManifest = {
builtAt: now,
missionId,
systemId: manifest.systemId,
succeeded: buildSucceeded,
routeCount: buildRouteCount,
sitemapHash: buildSitemapHash,
...(buildError ? { error: buildError } : {}),
};
await atomicWriteFile(
path.join(evidenceDir, "build-report.json"),
JSON.stringify(buildManifest, null, 2) + "\n",
);
if (!buildSucceeded) {
return {
data: {
missionId,
distributionPath: distDest,
buildSucceeded: false,
builtAt: now,
},
exitCode: 1,
summary: `[mission.build] ${missionId} build FAILED`,
nextSteps: [
{
action: `Fix the build errors above, then re-run: pnpm exec werkstatt run mission.build --mission ${missionId}`,
kind: "required",
},
],
};
}
return {
data: { missionId, distributionPath: distDest, buildSucceeded: true, builtAt: now },
exitCode: 0,
summary: `[mission.build] ${missionId} distribution built`,
nextSteps: [
{
action: `Validate the mission: pnpm exec werkstatt run mission.validate --mission ${missionId}`,
kind: "optional",
},
],
};
}
// §5: mission.diff
export interface MissionDiffData {
missionId: string;
added: string[];
modified: string[];
removed: string[];
}
async function collectRelativeFiles(
dir: string,
base: string = dir,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
if (!(await io.exists(dir))) return [];
const files = await collectFiles(dir, { ignore: (name) => name === ".git" });
return files.map((filePath) => path.relative(base, filePath).replace(/\\/g, "/"));
}
export async function runMissionDiff(
input: KernelCommandInput,
context: KernelRuntimeContext,
): Promise> {
const { workspaceRoot, logger } = context;
const missionId = flagString(input, "mission");
if (!missionId) throw new Error("[mission.diff] --mission is required");
const manifest = await readMissionManifest(workspaceRoot, missionId);
const systemDir = await resolveCacheClonePath(workspaceRoot, manifest.systemId);
const workpieceDir = path.join(
await resolveMissionDir(workspaceRoot, missionId, context.io),
"workpiece",
);
const systemFiles = new Set(await collectRelativeFiles(systemDir, undefined, context.io));
const workpieceFiles = new Set(await collectRelativeFiles(workpieceDir, undefined, context.io));
const added = [...workpieceFiles].filter((f) => !systemFiles.has(f));
const removed = [...systemFiles].filter((f) => !workpieceFiles.has(f));
const modified: string[] = [];
for (const f of workpieceFiles) {
if (systemFiles.has(f)) {
const sysContent = await context.io.readFile(path.join(systemDir, f)).catch(() => "");
const wpContent = await context.io.readFile(path.join(workpieceDir, f)).catch(() => "");
if (sysContent !== wpContent) modified.push(f);
}
}
const missionDir = await resolveMissionDir(workspaceRoot, missionId, context.io);
const evidenceDir = path.join(missionDir, "evidence");
await context.io.mkdir(evidenceDir);
const diffReport = {
schemaVersion: "1.0.0",
missionId,
added,
modified,
removed,
};
await atomicWriteFile(
path.join(evidenceDir, "authored-diff.json"),
JSON.stringify(diffReport, null, 2) + "\n",
);
logger.info(` Added: ${added.length}, Modified: ${modified.length}, Removed: ${removed.length}`);
return {
data: { missionId, added, modified, removed },
exitCode: 0,
summary: `[mission.diff] ${missionId}: ${added.length} added, ${modified.length} modified, ${removed.length} removed`,
nextSteps: [
{
action: `Commit changes: pnpm exec werkstatt run mission.git.commit --mission ${missionId} --message ""`,
kind: "optional",
},
],
};
}
// §6: mission.reconcile
export interface MissionReconcileData {
missionId: string;
systemId: string;
commitSha: string | null;
preReconcileSha: string | null;
reconciledAt: string;
mergedShas: string[];
autoResolvedPaths?: string[];
workpieceAutoCommitted: boolean;
workpieceCommitSha: string | null;
mirrorSync?: {
attempted: boolean;
succeeded: boolean;
error: string | null;
};
// RFC-0918: post-push divergence diagnostic
divergenceWarning?: {
cacheCloneHead: string;
originHead: string;
diverged: boolean;
/** ADR-0110: true when every origin-side commit missing from HEAD is
pipeline-authored (PIPELINE_SUBJECT_PREFIXES) or none exist — the
expected post-reconcile transient that sternsystem.sync converges;
false when origin carries foreign commits (real divergence). */
expectedTransient?: boolean;
} | null;
}
// RFC-0584: shared merge-abort helper — attempts git merge --abort, silently catches failure
async function abortMerge(systemDir: string, io: WorkspaceIO): Promise {
await execOrThrow(io, "git", ["merge", "--abort"], { cwd: systemDir }).catch(() => {});
}
export async function runMissionReconcile(
input: KernelCommandInput,
context: KernelRuntimeContext,
): Promise> {
const { workspaceRoot } = context;
const missionId = flagString(input, "mission");
const message = flagString(input, "message") ?? `Reconcile ${missionId}`;
const actor = resolveActor(input);
if (!missionId) throw new Error("[mission.reconcile] --mission is required");
const manifest = await readMissionManifest(workspaceRoot, missionId);
if (manifest.state !== "open") {
throw new Error(
`[mission.reconcile] mission '${missionId}' is not open (state: ${manifest.state})`,
);
}
// RFC-1263: coverage precondition — a green mission.validate report OR a
// green fast-lane gate record on the mission manifest. Centralized via
// missionHasGreenCoverage so reconcile/close share one rule.
const evidenceDir = path.join(
await resolveMissionDir(workspaceRoot, missionId, context.io),
"evidence",
);
const validationPath = path.join(evidenceDir, "validation-report.json");
if (!(await context.io.exists(validationPath)) && manifest.fastLane?.green !== true) {
throw new Error(
`[mission.reconcile] mission '${missionId}' has not passed validation — run mission.validate first`,
);
}
await acquireLock(
workspaceRoot,
`system:${manifest.systemId}`,
manifest.operationId,
"mission.reconcile",
actor,
);
await acquireLock(
workspaceRoot,
`mission:${missionId}`,
manifest.operationId,
"mission.reconcile",
actor,
);
// RFC-0356 §6 + RFC-1263: verify coverage before reconciling — full
// validation report when present, else the fast-lane gate record.
const validationRaw = await context.io.readFile(validationPath).catch(() => null);
const validationReport = validationRaw
? (JSON.parse(validationRaw) as { contractFull?: { passed?: boolean } })
: null;
if (!missionHasGreenCoverage(manifest, validationReport)) {
throw new Error(
`[mission.reconcile] mission '${missionId}' validation did not pass — fix issues and re-run mission.validate`,
);
}
const workpieceDir = path.join(
await resolveMissionDir(workspaceRoot, missionId, context.io),
"workpiece",
);
const systemDir = await resolveCacheClonePath(workspaceRoot, manifest.systemId);
try {
// RFC-1327: a re-run on an already-reconciled workpiece head is a no-op.
// The merge machinery rewinds the clone to preReconcileSha, which strips
// the .gitignore restoration commit and leaves untracked generated files
// looking dirty — skip the pipeline when the report already folded this
// head. Gates: manifest.reconciledAt set (a crash between report write and
// manifest update must resume the journaled tail, not no-op) and a clean
// worktree (uncommitted edits on the same head still need the auto-commit
// sweep). Inside try so the acquired locks release via finally.
const prevReportPath = path.join(evidenceDir, "reconciliation-report.json");
if (manifest.reconciledAt && (await context.io.exists(prevReportPath))) {
try {
const prevReport = JSON.parse(await context.io.readFile(prevReportPath)) as {
workpieceHeadAtReconcile?: string | null;
};
const currentHead = (await shExec("git rev-parse HEAD", workpieceDir, context.io)).trim();
const dirty = await isWorkpieceDirty(workpieceDir, context.io);
if (
prevReport.workpieceHeadAtReconcile &&
prevReport.workpieceHeadAtReconcile === currentHead &&
!dirty.dirty
) {
return {
data: {
missionId,
systemId: manifest.systemId,
commitSha: null,
workpieceHeadAtReconcile: currentHead,
alreadyReconciled: true,
} as unknown as MissionReconcileData,
exitCode: 0,
summary: `[mission.reconcile] ${missionId} already reconciled at ${currentHead.slice(0, 8)} — no-op`,
nextSteps: [
{
action: `Close the mission: pnpm exec werkstatt run mission.close --mission ${missionId}`,
kind: "optional",
},
],
};
}
} catch {
// Missing/unreadable report or non-git workpiece — fall through to the merge path.
}
}
const now = new Date().toISOString();
const missionDir = await resolveMissionDir(workspaceRoot, missionId, context.io);
const reconcileCtx: ReconcileStepCtx = {
workspaceRoot,
missionId,
missionDir,
workpieceDir,
evidenceDir,
systemDir,
manifest,
actor,
message,
now,
context,
commitSha: null,
preReconcileSha: null,
mergeCommitSha: null,
transferredCommits: 0,
mergedShas: [],
copiedPaths: [],
autoResolvedPaths: [],
workpieceHeadAtReconcile: null,
gitignoreRestored: false,
forbiddenFilesUntracked: [],
mirrorSync: { attempted: false, succeeded: false, error: null },
divergenceWarning: null,
workpieceCommit: { committed: false, commitSha: null },
};
const steps = await buildReconcileSteps(workspaceRoot, missionId, reconcileCtx);
const journalPath = path.join(missionDir, "journal.jsonl");
// RFC-0958 Step 7: block if a different-kind operation is incomplete
const blockCheck = await checkDifferentKindOperation(journalPath, "mission.reconcile");
if (blockCheck.blocked) {
return {
data: {
missionId,
systemId: manifest.systemId,
commitSha: null,
blockedByOperation: blockCheck.incompleteOp,
} as unknown as MissionReconcileData,
exitCode: 1,
summary: `[mission.reconcile] ${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 def: OperationDefinition = { op: "mission.reconcile", steps };
const opResult = await runOperation(journalPath, def, reconcileCtx, {
missionId,
platformVersion: "",
});
if (!opResult.completed) {
const detail = opResult.failedStepError ?? "unknown error";
throw new Error(
`[mission.reconcile] step "${opResult.failedStep}" failed: ${detail} — run mission.resume --mission ${missionId} to retry`,
);
}
const cc = reconcileCtx;
const autoResolveSuffix =
cc.autoResolvedPaths.length > 0
? `, ${cc.autoResolvedPaths.length} conflict${cc.autoResolvedPaths.length > 1 ? "s" : ""} auto-resolved`
: "";
const mirrorSyncSuffix = cc.mirrorSync.attempted
? cc.mirrorSync.succeeded
? ", mirrors synced"
: ", mirror sync failed — non-fatal"
: "";
return {
data: {
missionId,
systemId: manifest.systemId,
commitSha: cc.commitSha,
preReconcileSha: cc.preReconcileSha,
mergedShas: cc.mergedShas,
reconciledAt: now,
...(cc.autoResolvedPaths.length > 0 ? { autoResolvedPaths: cc.autoResolvedPaths } : {}),
workpieceAutoCommitted: cc.workpieceCommit.committed,
workpieceCommitSha: cc.workpieceCommit.commitSha,
...(cc.mirrorSync.attempted ? { mirrorSync: cc.mirrorSync } : {}),
divergenceWarning: cc.divergenceWarning,
},
exitCode: 0,
summary: `[mission.reconcile] ${missionId} reconciled (${cc.commitSha ? `${cc.commitSha.slice(0, 8)}, ${cc.transferredCommits} commits merged` : "no git"}${autoResolveSuffix}${cc.workpieceCommit.committed ? `, workpiece auto-committed ${cc.workpieceCommit.commitSha?.slice(0, 8)}` : ""}${mirrorSyncSuffix})`,
nextSteps: [
{
action: `Close the mission: pnpm exec werkstatt run mission.close --mission ${missionId}`,
kind: "optional",
},
],
};
} finally {
await releaseLock(workspaceRoot, `mission:${missionId}`);
await releaseLock(workspaceRoot, `system:${manifest.systemId}`);
}
}
// ---------------------------------------------------------------------------
// RFC-0958: Journal-integrated reconcile steps
// ---------------------------------------------------------------------------
export interface ReconcileStepCtx {
workspaceRoot: string;
missionId: string;
missionDir: string;
workpieceDir: string;
evidenceDir: string;
systemDir: string;
manifest: MissionManifest;
actor: ReturnType;
message: string;
now: string;
context: KernelRuntimeContext;
commitSha: string | null;
preReconcileSha: string | null;
mergeCommitSha: string | null;
transferredCommits: number;
mergedShas: string[];
copiedPaths: string[];
autoResolvedPaths: string[];
workpieceHeadAtReconcile: string | null;
gitignoreRestored: boolean;
forbiddenFilesUntracked: string[];
mirrorSync: { attempted: boolean; succeeded: boolean; error: string | null };
divergenceWarning: {
cacheCloneHead: string;
originHead: string;
diverged: boolean;
expectedTransient?: boolean;
} | null;
workpieceCommit: { committed: boolean; commitSha: string | null };
}
export async function buildReconcileSteps(
_workspaceRoot: string,
_missionId: string,
_ctx: unknown,
): Promise[]> {
const steps: OperationStep[] = [
{
name: "workpiece-git-check",
run: async (c: unknown) => {
const cc = c as ReconcileStepCtx;
if (!(await cc.context.io.exists(path.join(cc.workpieceDir, ".git")))) {
throw new Error(
`[mission.reconcile] workpiece is not a git repository — run mission.materialize first`,
);
}
},
},
{
name: "auto-commit-workpiece",
run: async (c: unknown) => {
const cc = c as ReconcileStepCtx;
const result = await commitWorkpieceIfDirty(
cc.workpieceDir,
cc.missionId,
undefined,
cc.context.io,
);
cc.workpieceCommit = result;
if (result.committed) {
cc.context.logger.info(
` Auto-committed dirty workpiece (${result.commitSha?.slice(0, 8)}) before reconcile`,
);
}
},
},
{
name: "auto-commit-cache-clone",
run: async (c: unknown) => {
const cc = c as ReconcileStepCtx;
const gitDir = path.join(cc.systemDir, ".git");
if (!(await cc.context.io.exists(gitDir))) return;
const cacheCommit = await commitCacheCloneIfDirty(
cc.systemDir,
cc.manifest.systemId,
cc.context.io,
);
if (cacheCommit.committed) {
cc.context.logger.info(
` Auto-committed cache clone (${cacheCommit.commitSha?.slice(0, 8)}) before reconcile`,
);
}
},
},
{
name: "dirty-cache-clone-guard",
run: async (c: unknown) => {
const cc = c as ReconcileStepCtx;
const gitDir = path.join(cc.systemDir, ".git");
if (!(await cc.context.io.exists(gitDir))) return;
const cacheDirtyCheck = await isWorkpieceDirty(cc.systemDir, cc.context.io);
if (cacheDirtyCheck.dirty) {
const untrackedReport = await investigateUntrackedFiles(
cc.workspaceRoot,
cc.manifest.systemId,
cc.systemDir,
cacheDirtyCheck.files,
);
await atomicWriteFile(
path.join(cc.evidenceDir, "untracked-files-report.json"),
JSON.stringify(untrackedReport, null, 2) + "\n",
);
const reportSummary = untrackedReport
.map(
(r) => ` ${r.path} — ${r.likelyOrigin}${r.originHint ? ` (${r.originHint})` : ""}`,
)
.join("\n");
throw new Error(
`[mission.reconcile] cache clone for system '${cc.manifest.systemId}' has ${cacheDirtyCheck.fileCount} uncommitted/untracked file(s):\n` +
reportSummary +
`\n\nEvidence written to evidence/untracked-files-report.json.\nResolve uncommitted changes in the cache clone before re-running reconcile.`,
);
}
},
},
{
name: "record-pre-reconcile-sha",
run: async (c: unknown) => {
const cc = c as ReconcileStepCtx;
const gitDir = path.join(cc.systemDir, ".git");
if (!(await cc.context.io.exists(gitDir))) return;
try {
cc.preReconcileSha = (
await shExec("git rev-parse HEAD", cc.systemDir, cc.context.io)
).trim();
} catch {
cc.preReconcileSha = null;
}
const prevReportPath = path.join(cc.evidenceDir, "reconciliation-report.json");
if (await cc.context.io.exists(prevReportPath)) {
let prevReport: { preReconcileSha?: string };
try {
prevReport = JSON.parse(await cc.context.io.readFile(prevReportPath)) as {
preReconcileSha?: string;
};
} catch {
// Unparseable report — continue
prevReport = {};
}
if (prevReport.preReconcileSha) {
let rewound = false;
try {
await shExec(
`git reset --hard ${prevReport.preReconcileSha}`,
cc.systemDir,
cc.context.io,
);
cc.context.logger.info(
` Reset cache clone to pre-reconcile SHA ${prevReport.preReconcileSha.slice(0, 12)}`,
);
rewound = true;
} catch {
// Previous SHA may not exist — continue with current HEAD
}
// RFC-1219: the rewind deletes every file tracked at the
// pre-reset HEAD but absent at the reset target — including
// operation journals committed mid-run by
// auto-commit-cache-clone (observed: ship journal wiped →
// orphan step-failed → "nothing to resume"). Restore
// operations/ from the pre-reset HEAD and re-commit so the
// merge sees tracked-clean state. Journals are append-only;
// restoration is always safe. A failure here is blocking —
// continuing silently would lose journal state.
if (rewound && cc.preReconcileSha) {
// Probe failure is blocking too — "can't inspect the tree" must
// not silently degrade to "nothing to restore" (that path is
// exactly how the journal was lost in m000192).
const opsInTree = await shExec(
`git ls-tree --name-only ${cc.preReconcileSha} -- operations/`,
cc.systemDir,
cc.context.io,
);
if (opsInTree.trim().length > 0) {
await shExec(
`git checkout ${cc.preReconcileSha} -- operations/`,
cc.systemDir,
cc.context.io,
);
const opsStaged = await shExec(
"git status --porcelain -- operations/",
cc.systemDir,
cc.context.io,
);
if (opsStaged.trim().length > 0) {
await cacheCloneCommit(
cc.systemDir,
"cache-clone: preserve operations journals across reconcile rewind",
{},
cc.context.io,
);
cc.context.logger.info(
" Preserved operations/ journals across pre-reconcile rewind",
);
}
}
}
}
}
},
},
{
name: "fetch-and-merge",
run: async (c: unknown) => {
const cc = c as ReconcileStepCtx;
const gitDir = path.join(cc.systemDir, ".git");
if (!(await cc.context.io.exists(gitDir))) {
for (const dataPath of STERNSYSTEM_DATA_PATHS) {
const src = path.join(cc.workpieceDir, dataPath);
const dest = path.join(cc.systemDir, dataPath);
if (await cc.context.io.exists(src)) {
if (await cc.context.io.exists(dest)) {
await cc.context.io.rm(dest, { recursive: true });
}
await copyDir(src, dest, cc.context.io);
cc.copiedPaths.push(dataPath);
cc.context.logger.info(` Reconciled ${dataPath}`);
}
}
return;
}
const workpieceBranch = (
await shExec("git rev-parse --abbrev-ref HEAD", cc.workpieceDir, cc.context.io)
).trim();
// RFC-1327: pre-merge residue sweep — untracked on-disk generated files
// (pre-fix residue or post-rewind un-ignore) would crash the merge with
// "untracked working tree files would be overwritten".
const swept = await sweepUntrackedGeneratedResidue(cc.systemDir, cc.context.io);
if (swept.length > 0) {
cc.context.logger.info(
` Swept ${swept.length} untracked generated file(s) before merge`,
);
}
await shExec(
`git fetch ${JSON.stringify(cc.workpieceDir)} ${JSON.stringify(workpieceBranch)}`,
cc.systemDir,
cc.context.io,
);
const mergeMessage = `reconcile mission ${cc.missionId}`;
try {
await shExec(
`git merge --no-ff FETCH_HEAD -m ${JSON.stringify(mergeMessage)}`,
cc.systemDir,
cc.context.io,
);
} catch (err) {
const isAutoResolvablePath = (p: string) =>
CACHE_CLONE_ONLY_PATHS.some((pattern) => p.startsWith(pattern));
// RFC-1218: generated-path allowlist — the RFC-0960 ownership
// registry (generates[] declarations) unioned with the RFC-0913
// cache-clone patterns. ownershipMap absent (site plugin not
// loaded) degenerates to the RFC-0913 set alone.
const generatedPatterns = [
...(cc.context.ownershipMap ?? []).map((e) => e.artifact.path),
...CACHE_CLONE_GENERATED_PATTERNS,
];
const isGeneratedPath = (p: string) =>
generatedPatterns.some((pattern) => p === pattern || picomatch.isMatch(p, pattern));
const conflictedPaths: string[] = [];
const bordbuchDeletedPaths: string[] = [];
try {
const statusOutput = await shExec(
"git status --porcelain",
cc.systemDir,
cc.context.io,
);
for (const line of statusOutput.split("\n")) {
if (!line) continue;
const status = line.slice(0, 2);
const filePath = line.slice(3).trim();
if (!filePath) continue;
if (
status.startsWith("DU") ||
status.startsWith("UD") ||
status.startsWith("AA") ||
status.startsWith("UU")
) {
conflictedPaths.push(filePath);
} else if (status === "D ") {
if (isAutoResolvablePath(filePath)) bordbuchDeletedPaths.push(filePath);
}
}
} catch {
// git status failed
}
// RFC-1218: three classes — clone-only paths resolve ours, generated
// allowlist paths resolve theirs (workpiece), anything else aborts.
const oursPaths = conflictedPaths.filter(isAutoResolvablePath);
const theirsPaths = conflictedPaths.filter(
(p) => !isAutoResolvablePath(p) && isGeneratedPath(p),
);
const unclassifiedPaths = conflictedPaths.filter(
(p) => !isAutoResolvablePath(p) && !isGeneratedPath(p),
);
const allAutoResolvable = conflictedPaths.length > 0 && unclassifiedPaths.length === 0;
if (allAutoResolvable) {
try {
if (bordbuchDeletedPaths.length > 0) {
const delPathArgs = bordbuchDeletedPaths.map((p) => JSON.stringify(p)).join(" ");
await shExec(`git checkout HEAD -- ${delPathArgs}`, cc.systemDir, cc.context.io);
await shExec(`git add -- ${delPathArgs}`, cc.systemDir, cc.context.io);
}
if (oursPaths.length > 0) {
// warpgogol-m000196: "ours" for a clone-only path can also mean
// ABSENT — see resolveOursConflicts.
await resolveOursConflicts(cc.systemDir, oursPaths, cc.context.io);
}
for (const theirsPath of theirsPaths) {
const quoted = JSON.stringify(theirsPath);
// Workpiece-side presence decides checkout-vs-remove: UD means
// the workpiece deleted the file, so theirs-wins = git rm.
const inTheirs = (
await shExec(`git ls-tree FETCH_HEAD -- ${quoted}`, cc.systemDir, cc.context.io)
).trim();
if (inTheirs.length > 0) {
await shExec(`git checkout FETCH_HEAD -- ${quoted}`, cc.systemDir, cc.context.io);
} else {
await shExec(
`git rm -f --ignore-unmatch -- ${quoted}`,
cc.systemDir,
cc.context.io,
);
}
}
const sortedResolved = [...conflictedPaths].sort();
const resolveMessage =
`${mergeMessage}\n\nAuto-resolved conflicts:\n` +
sortedResolved.map((p) => `- ${p}`).join("\n");
await cacheCloneCommit(cc.systemDir, resolveMessage, {}, cc.context.io);
cc.autoResolvedPaths = sortedResolved;
cc.context.logger.info(
` Auto-resolved ${cc.autoResolvedPaths.length} conflict(s) ` +
`(${oursPaths.length} clone-only, ${theirsPaths.length} generated)`,
);
} catch (resolveErr) {
await abortMerge(cc.systemDir, cc.context.io);
throw new Error(
`[mission.reconcile] conflict auto-resolution failed: ${(resolveErr as Error).message}.\n` +
`Merge has been aborted. Inspect the cache clone state manually.\n` +
`Reconcile is idempotent — it will reset the cache clone to preReconcileSha and re-merge.`,
);
}
} else {
await abortMerge(cc.systemDir, cc.context.io);
const unclassifiedNote =
unclassifiedPaths.length > 0
? `\nUnclassified conflicted paths:\n` +
unclassifiedPaths.map((p) => ` - ${p}`).join("\n")
: "";
throw new Error(
`[mission.reconcile] git merge --no-ff failed: ${(err as Error).message}.${unclassifiedNote}\n` +
`Resolve conflicts in the workpiece (not the cache clone), commit via mission.git.commit, then re-run reconcile.\n` +
`Reconcile is idempotent — it will reset the cache clone to preReconcileSha and re-merge.`,
);
}
}
},
},
{
name: "capture-workpiece-head",
run: async (c: unknown) => {
const cc = c as ReconcileStepCtx;
const gitDir = path.join(cc.systemDir, ".git");
if (!(await cc.context.io.exists(gitDir))) return;
try {
cc.workpieceHeadAtReconcile = (
await shExec("git rev-parse HEAD", cc.workpieceDir, cc.context.io)
).trim();
} catch {
cc.workpieceHeadAtReconcile = null;
}
cc.commitSha = (await shExec("git rev-parse HEAD", cc.systemDir, cc.context.io)).trim();
},
},
{
name: "post-merge-guard",
run: async (c: unknown) => {
const cc = c as ReconcileStepCtx;
const gitDir = path.join(cc.systemDir, ".git");
if (!(await cc.context.io.exists(gitDir))) return;
const criticalFiles = ["system-config.yaml", "system-state.yaml"];
const restoredFiles: string[] = [];
for (const cf of criticalFiles) {
if (!(await cc.context.io.exists(path.join(cc.systemDir, cf))) && cc.preReconcileSha) {
try {
await shExec(
`git checkout ${cc.preReconcileSha} -- ${JSON.stringify(cf)}`,
cc.systemDir,
cc.context.io,
);
restoredFiles.push(cf);
cc.context.logger.info(` Restored ${cf} after merge (was missing)`);
} catch {
// File may not exist in preReconcileSha either — skip
}
}
}
if (restoredFiles.length > 0) {
const addArgs = restoredFiles.map((f) => JSON.stringify(f)).join(" ");
await shExec(`git add -- ${addArgs}`, cc.systemDir, cc.context.io);
await cacheCloneCommit(
cc.systemDir,
"restore critical config files after merge",
undefined,
cc.context.io,
);
}
},
},
{
name: "gitignore-restoration",
run: async (c: unknown) => {
const cc = c as ReconcileStepCtx;
const gitDir = path.join(cc.systemDir, ".git");
if (!(await cc.context.io.exists(gitDir))) return;
try {
cc.gitignoreRestored = await restoreCacheCloneGitignore(cc.systemDir, cc.context.io);
if (cc.gitignoreRestored) {
cc.context.logger.info(` Restored cache-clone-only .gitignore patterns after merge`);
await shExec("git add .gitignore", cc.systemDir, cc.context.io);
}
cc.forbiddenFilesUntracked = await untrackForbiddenGeneratedFiles(
cc.systemDir,
cc.context.io,
);
if (cc.forbiddenFilesUntracked.length > 0) {
cc.context.logger.info(
` Untracked ${cc.forbiddenFilesUntracked.length} forbidden/generated file(s)`,
);
}
if (cc.gitignoreRestored || cc.forbiddenFilesUntracked.length > 0) {
await cacheCloneCommit(
cc.systemDir,
"restore cache-clone .gitignore and untrack forbidden files (RFC-0913)",
undefined,
cc.context.io,
);
}
} catch (restoreErr) {
cc.context.logger.warn(
` .gitignore restoration failed (non-fatal): ${restoreErr instanceof Error ? restoreErr.message : String(restoreErr)}`,
);
}
},
},
{
name: "count-transferred-commits",
run: async (c: unknown) => {
const cc = c as ReconcileStepCtx;
const gitDir = path.join(cc.systemDir, ".git");
if (!(await cc.context.io.exists(gitDir))) return;
// RFC-1221: derive the folded set from the merge commit's own parents
// — rev-list ^2 --not ^1 needs no recorded ctx
// state, so the computation survives journal resume (a completed
// record-pre-reconcile-sha is skipped, leaving cc.preReconcileSha
// null) and the fold path where no new merge was needed alike.
if (cc.commitSha) {
try {
const secondParent = (
await shExec(
`git rev-parse --verify -q ${cc.commitSha}^2 || true`,
cc.systemDir,
cc.context.io,
)
).trim();
if (secondParent.length > 0) {
// commitSha IS the merge commit — record it under its own name.
// The previous `HEAD^1` capture ran after post-merge steps could
// already have committed, storing a post-merge parent instead.
cc.mergeCommitSha = cc.commitSha;
const firstParent = (
await shExec(`git rev-parse ${cc.commitSha}^1`, cc.systemDir, cc.context.io)
).trim();
// The merge's first parent IS the head the merge started from —
// the truthful preReconcileSha whenever the recorded value was
// lost (resume) or degenerate (self-range on the fold path).
if (!cc.preReconcileSha || cc.preReconcileSha === cc.commitSha) {
cc.preReconcileSha = firstParent;
}
const shaListOutput = (
await shExec(
`git rev-list ${cc.commitSha}^2 --not ${cc.commitSha}^1`,
cc.systemDir,
cc.context.io,
)
).trim();
const candidates = shaListOutput
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0);
// Restrict to SHAs present in the workpiece repo — the folded
// ancestry can legitimately contain cache-clone bookkeeping
// (workpiece origin is the cache clone); those pass the
// subject whitelist anyway, so over-tracing is safe while
// under-tracing is not. On any workpiece-repo failure record
// the unfiltered set.
let workpieceUsable = true;
try {
await shExec("git rev-parse --git-dir", cc.workpieceDir, cc.context.io);
} catch {
workpieceUsable = false;
}
if (!workpieceUsable) {
cc.mergedShas = candidates;
} else {
const kept: string[] = [];
for (const sha of candidates) {
try {
await shExec(`git cat-file -e ${sha}`, cc.workpieceDir, cc.context.io);
kept.push(sha);
} catch {
// Absent from the workpiece — cache-clone-side commit, skip.
}
}
cc.mergedShas = kept;
}
cc.transferredCommits = cc.mergedShas.length;
} else {
// Non-merge commitSha — no-op fold: nothing was folded.
cc.mergedShas = [];
cc.transferredCommits = 0;
}
} catch (foldErr) {
cc.context.logger.warn(
`[mission.reconcile] folded-SHA computation failed (non-fatal): ${foldErr instanceof Error ? foldErr.message : String(foldErr)}`,
);
cc.transferredCommits = 0;
cc.mergedShas = [];
}
}
cc.context.logger.info(
` Merged ${cc.transferredCommits} commit(s) from workpiece to cache clone (${cc.commitSha?.slice(0, 8)})`,
);
if (cc.transferredCommits === 0) {
cc.context.logger.warn(
`[mission.reconcile] WARNING: Zero commits transferred from workpiece to cache clone.\n` +
` Brief: "${cc.manifest.brief}"\n` +
` If you expected changes, the workpiece may have no operator commits — check mission.git.commit output.\n`,
);
}
},
},
{
// ADR-0093: record the reconcile trace — commitSha is the merge/head
// commit, mergedShas[] lists every workpiece commit folded into the
// cache clone. The external-edit guard treats all of them as traced.
name: "bordbuch-append",
run: async (c: unknown) => {
const cc = c as ReconcileStepCtx;
await appendAndCommitBordbuch(
cc.workspaceRoot,
cc.manifest.systemId,
"mission-reconcile",
`Mission ${cc.missionId} reconciled`,
cc.actor,
{
missionId: cc.missionId,
writerRole: "mission",
metadata: {
commitSha: cc.commitSha,
preReconcileSha: cc.preReconcileSha,
// RFC-1221: omit the field when nothing was folded — an absent
// mergedShas is the precise "no folded commits" signal.
...(cc.mergedShas.length > 0 ? { mergedShas: cc.mergedShas } : {}),
transferredCommits: cc.transferredCommits,
// RFC-1218: audit trail for auto-resolved merge conflicts.
...(cc.autoResolvedPaths.length > 0
? { autoResolvedPaths: cc.autoResolvedPaths }
: {}),
},
},
`Bordbuch: mission-reconcile ${cc.missionId}`,
);
},
},
{
name: "push-to-origin",
run: async (c: unknown) => {
const cc = c as ReconcileStepCtx;
const gitDir = path.join(cc.systemDir, ".git");
if (!(await cc.context.io.exists(gitDir))) return;
const branch = (
await shExec("git rev-parse --abbrev-ref HEAD", cc.systemDir, cc.context.io)
).trim();
const pushBackoffMs = [1000, 2000, 4000];
let pushSucceeded = false;
for (let attempt = 0; attempt < pushBackoffMs.length; attempt++) {
try {
await shExec(`git push origin ${JSON.stringify(branch)}`, cc.systemDir, cc.context.io);
pushSucceeded = true;
break;
} catch {
if (attempt < pushBackoffMs.length - 1) {
cc.context.logger.info(` Push attempt ${attempt + 1} failed — retrying…`);
await new Promise((resolve) => setTimeout(resolve, pushBackoffMs[attempt]));
}
}
}
if (!pushSucceeded) {
cc.context.logger.info(
` Push failed after ${pushBackoffMs.length} attempts (non-fatal — ` +
`sternsystem.sync converges unpushed commits)`,
);
}
},
},
{
name: "divergence-check",
run: async (c: unknown) => {
const cc = c as ReconcileStepCtx;
const gitDir = path.join(cc.systemDir, ".git");
if (!(await cc.context.io.exists(gitDir))) return;
try {
const cacheCloneHead = (
await shExec("git rev-parse HEAD", cc.systemDir, cc.context.io)
).trim();
const originHead = (
await shExec("git rev-parse origin/main", cc.systemDir, cc.context.io)
).trim();
if (cacheCloneHead !== originHead) {
// ADR-0110: classify by the *nature* of origin-side commits, not
// ancestry — a re-run reconcile produces a different merge SHA for
// the same content, so the bare can hold "extra" commits that are
// still pipeline bookkeeping. Transient = every commit on
// origin/main missing from HEAD is pipeline-authored (or there are
// none); a foreign subject means a real divergence.
let foreignCount = 0;
let classifyFailed = false;
try {
const log = (
await shExec(
"git log --format=%s origin/main --not HEAD",
cc.systemDir,
cc.context.io,
)
).trim();
foreignCount = (log ? log.split("\n") : []).filter(
(s) => !PIPELINE_SUBJECT_PREFIXES.some((p) => s.startsWith(p)),
).length;
} catch {
classifyFailed = true;
}
const expectedTransient = !classifyFailed && foreignCount === 0;
cc.divergenceWarning = {
cacheCloneHead,
originHead,
diverged: true,
expectedTransient,
};
cc.context.logger.warn(
expectedTransient
? `[mission.reconcile] Cache clone HEAD (${cacheCloneHead.slice(0, 8)}) differs ` +
`from origin/main (${originHead.slice(0, 8)}) — expected transient after the ` +
`pre-reconcile rewind (ADR-0110); the reconcile mirror-sync or a manual ` +
`\`sternsystem.sync --id ${cc.manifest.systemId}\` converges it. ` +
`Never run \`git reset --hard origin/main\` — it discards the reconcile merge.`
: `[mission.reconcile] WARNING: Cache clone HEAD (${cacheCloneHead.slice(0, 8)}) ` +
`diverged from origin/main (${originHead.slice(0, 8)}) — ` +
(classifyFailed
? `origin-side commits could not be classified`
: `origin/main carries ${foreignCount} foreign (non-pipeline) commit(s)`) +
`; this is not the reconcile transient. Converge via ` +
`\`sternsystem.sync --id ${cc.manifest.systemId}\` and escalate if it ` +
`persists — never \`git reset --hard origin/main\` (discards the merge).`,
);
}
} catch {
// Non-fatal — origin/main may not exist yet
}
},
},
{
name: "mirror-sync",
run: async (c: unknown) => {
const cc = c as ReconcileStepCtx;
const gitDir = path.join(cc.systemDir, ".git");
if (!(await cc.context.io.exists(gitDir))) return;
try {
const config = await readSystemConfig(cc.workspaceRoot, cc.manifest.systemId);
if (config && config.mirrors.length > 2) {
cc.mirrorSync.attempted = true;
cc.context.logger.info(` Syncing external mirrors via sternsystem.sync…`);
try {
const syncResult = (await executeKernelCommand({
workspaceRoot: cc.workspaceRoot,
commandName: "sternsystem.sync",
argv: [`--id=${cc.manifest.systemId}`],
})) as { exitCode?: number; summary?: string };
const syncExitCode = syncResult.exitCode ?? 0;
if (syncExitCode !== 0) {
cc.mirrorSync.succeeded = false;
cc.mirrorSync.error =
syncResult.summary ?? `sternsystem.sync exited with code ${syncExitCode}`;
cc.context.logger.warn(
` External mirror sync failed (non-fatal): ${cc.mirrorSync.error}`,
);
} else {
cc.mirrorSync.succeeded = true;
cc.context.logger.info(` External mirrors synced`);
}
} catch (syncError) {
cc.mirrorSync.succeeded = false;
cc.mirrorSync.error =
syncError instanceof Error ? syncError.message : String(syncError);
cc.context.logger.warn(
` External mirror sync failed (non-fatal): ${cc.mirrorSync.error}`,
);
}
}
} catch (registryError) {
cc.context.logger.warn(
` Could not read registry for mirror sync: ${registryError instanceof Error ? registryError.message : String(registryError)}`,
);
}
},
},
{
name: "write-reconciliation-report",
run: async (c: unknown) => {
const cc = c as ReconcileStepCtx;
const report = {
schemaVersion: "1.0.0",
missionId: cc.missionId,
systemId: cc.manifest.systemId,
commitSha: cc.commitSha,
preReconcileSha: cc.preReconcileSha,
reconciledAt: cc.now,
mergeCommitSha: cc.mergeCommitSha,
transferredCommits: cc.transferredCommits,
// Unlike the bordbuch entry (which omits the field when empty —
// absence is the "no folded commits" signal, RFC-1221), the report
// is a flat diagnostic shape and always records the array.
mergedShas: cc.mergedShas,
zeroTransferWarning: cc.transferredCommits === 0,
message: cc.message,
copiedPaths: cc.copiedPaths,
autoResolvedPaths: cc.autoResolvedPaths,
workpieceHeadAtReconcile: cc.workpieceHeadAtReconcile,
gitignoreRestored: cc.gitignoreRestored,
forbiddenFilesUntracked: cc.forbiddenFilesUntracked,
mirrorSync: cc.mirrorSync.attempted ? cc.mirrorSync : undefined,
divergenceWarning: cc.divergenceWarning,
};
await atomicWriteFile(
path.join(cc.evidenceDir, "reconciliation-report.json"),
JSON.stringify(report, null, 2) + "\n",
);
},
},
{
name: "update-manifest",
run: async (c: unknown) => {
const cc = c as ReconcileStepCtx;
cc.manifest.reconciledAt = cc.now;
await writeMissionManifest(cc.workspaceRoot, cc.manifest);
},
},
{
name: "commit-werkstatt-side-effects",
run: async (c: unknown) => {
const cc = c as ReconcileStepCtx;
await commitWerkstattSideEffects(
cc.workspaceRoot,
[path.join("missions", cc.missionId, "mission.yaml")],
`werkstatt: mission.reconcile ${cc.missionId}`,
);
},
},
];
return steps;
}