/* Maintains packages/os/site-kernel-handoff/src/mission/mission-close.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. RFC-1190: restore mission context + evidence archival in release-mode deploys RFC-1190: precedence-ordered resolveMissionContext (--mission → release build-identity → currentMission), MISSION_CONTEXT_UNRESOLVED/MISMATCH blocking, fail-closed EVIDENCE_SYNC_FAILED with --skip-evidence-sync as sole escape hatch, journaled evidence-sync-* ship steps after each certify-*, evidence.sync --site/--system flag. 2026-10-04 handoff fixes: shared 25min mission.check timeout (inner 5min race + 300s orchestrator raced cold ~7-11min captures), sweep excludeOpIds so mission.close no longer self-abandons the live ship journal (broke --resume), name-keyed journal resume state so closed-mission plan shifts can't skip unexecuted steps, wrangler dev documented as the required --base-url preview server. RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt RFC-1263 RFC-1264 RFC-1327: review r1 — lock-safe reconcile gate, residue sweep, honest idempotency classes ADR-0010, ADR-0084, RECOVERY-2026, RFC-0355, RFC-0477, RFC-0480, RFC-0522, RFC-0560, RFC-0580, RFC-0593, RFC-0597, RFC-0652, RFC-0655, RFC-0658, RFC-0703, RFC-0705, RFC-0734, RFC-0762, RFC-0797, RFC-0801, RFC-0820, RFC-0822, RFC-0878, RFC-0913, RFC-0958, RFC-0991, RFC-0992, RFC-1097, RFC-1149, RFC-1159 */ import path from "node:path"; import { parse as yamlParse } from "yaml"; import type { KernelCommandInput, KernelCommandResult, KernelRuntimeContext, } from "@warpgogol/werkstatt-engine/kernel"; import { readSystemConfigSmart, readSystemState, writeSystemState, resolveCacheClonePath, resolveMirrorPath, readPassport, writePassport, } from "../sternsystem/registry-io.ts"; import { readMissionManifest, writeMissionManifest, resolveMissionDir } from "./mission-io.ts"; import { missionHasGreenCoverage } from "./lifecycle-precondition.ts"; import { commitWorkpieceIfDirty, countOperatorCommits, cacheCloneCommit, } from "./mission-git-commit.ts"; import { validateBordbuch, type BordbuchViolation } from "../bordbuch/bordbuch-io.ts"; import { appendAndCommitBordbuch } from "../bordbuch/bordbuch-commit-helper.ts"; import { runMissionValidate, type MissionValidateData, } from "./mission-materialization-commands.ts"; import { acquireLock, releaseLock, commitWerkstattSideEffects } from "../werkstatt/index.ts"; import { gitExec } from "../werkstatt/git-exec.ts"; import { execOrThrow, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; import { atomicWriteFile } from "../werkstatt/atomic.ts"; import { resolveActor } from "./actor-identity.ts"; import { persistEnvFilesToCacheClone } from "./env-persist.ts"; import { persistOperatorConfigFiles } from "./operator-config-files.ts"; import { runOperation, getActiveOpIds } from "../journal/runner.ts"; import { checkDifferentKindOperation } from "../journal/index.ts"; import { abandonIncompleteOperationsForMission, findIncompleteOperationsForMission, referencesMissionId, } from "../journal/sweep.ts"; import type { OperationStep, OperationDefinition } from "../journal/index.ts"; import { getDefaultScopeManager } from "../scope/scope.ts"; import { removeOverlay } from "../runtime/overlay-store.ts"; // RFC-0597: Media cache directories to persist across missions const MEDIA_CACHE_DIRS = [".cache/video", ".cache/video-live"]; async function copyDirRecursive(src: string, dest: string, io: WorkspaceIO): Promise { 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 copyDirRecursive(srcPath, destPath, io); } else { await io.copyFile(srcPath, destPath); } } } export interface CloseReportGit { commitSha: string | null; pushed: boolean; pushError: string | null; dirtyFiles: string[]; } export interface CloseReportMirror { originSha: string | null; mirrorSha: string | null; inSync: boolean; recommendation: string | null; synced: boolean; syncError: string | null; } export interface CloseReportReconcile { reconciledAt: string; verified: boolean; freshnessChecked: boolean; unreconciledCommits: number; workpieceHead: string | null; reconciledSha: string | null; } export interface CloseReportTemplateSync { synced: boolean; syncError: string | null; } export interface CloseReportDigest { path: string | null; skipped: boolean; } export interface CloseReport { releaseId: string | null; git: CloseReportGit; mirror: CloseReportMirror; reconcile: CloseReportReconcile; templateSync: CloseReportTemplateSync; digest: CloseReportDigest; warnings: Array<{ rule: string; message: string }>; } export interface MissionCloseData { missionId: string; systemId: string; state: "closed"; closedAt: string; releaseId: string | null; closeReport: CloseReport; evidenceSynced: boolean; evidenceSyncResult: { r2KeyPrefix: string; uploadedFiles: number } | null; bordbuchValidation: { violations: BordbuchViolation[]; checked: boolean }; } function flagString(input: KernelCommandInput, key: string): string | undefined { const v = input.flags[key]; return typeof v === "string" ? v : undefined; } function flagBoolean(input: KernelCommandInput, key: string): boolean { const v = input.flags[key]; return v === true || v === "true"; } async function runInlineValidate( missionId: string, context: KernelRuntimeContext, ): Promise<{ passed: boolean; failures: string[]; report: MissionValidateData | null }> { const syntheticInput: KernelCommandInput = { argv: [], flags: { mission: missionId, "skip-auto-commit": true }, }; const result = await runMissionValidate(syntheticInput, context); if (result.exitCode !== 1) { return { passed: true, failures: [], report: result.data ?? null }; } const failures: string[] = []; if (result.data?.contractFull?.validators) { for (const v of result.data.contractFull.validators) { if (v.status === "fail") { failures.push(`${v.name}: exit code ${v.exitCode}`); } } } if (failures.length === 0 && result.summary) { failures.push(result.summary); } if (result.data) { failures.push(`See evidence/validation-report.json for details`); } return { passed: false, failures, report: result.data ?? null }; } async function updateReconcileReportHead( evidenceDir: string, commitSha: string | null, io: WorkspaceIO, ): Promise { try { const reconcileReportPath = path.join(evidenceDir, "reconciliation-report.json"); if (await io.exists(reconcileReportPath)) { const report = JSON.parse(await io.readFile(reconcileReportPath)); report.workpieceHeadAtReconcile = commitSha; await atomicWriteFile(reconcileReportPath, JSON.stringify(report, null, 2) + "\n"); } } catch { // Non-fatal } } export async function runMissionClose( input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const { workspaceRoot, logger } = context; const missionId = flagString(input, "mission"); const actor = resolveActor(input); const releaseIdFlag = flagString(input, "release"); const skipEvidenceSync = flagBoolean(input, "skip-evidence-sync"); const skipAutoSync = flagBoolean(input, "skip-auto-sync"); const skipTemplateSync = flagBoolean(input, "skip-template-sync"); const allowNoOp = flagBoolean(input, "allow-no-op"); const skipReconcileCheck = flagBoolean(input, "skip-reconcile-check"); if (!missionId) throw new Error("[mission.close] --mission is required"); const manifest = await readMissionManifest(workspaceRoot, missionId); // RFC-0522: releaseId precedence — flag overrides manifest (written by release.prepare) const releaseId = releaseIdFlag ?? manifest.releaseId ?? null; // RFC-1327: re-running close on an already-closed mission is a no-op exit-0; // aborted or mid-operation states keep throwing. if (manifest.state === "closed") { return { data: { missionId, systemId: manifest.systemId, alreadyClosed: true, } as unknown as MissionCloseData, exitCode: 0, summary: `[mission.close] ${missionId} already closed — no-op`, }; } if (manifest.state !== "open") { throw new Error( `[mission.close] mission '${missionId}' is not open (state: ${manifest.state})`, ); } if (!manifest.reconciledAt) { throw new Error( `[mission.close] mission '${missionId}' has not been reconciled — run mission.reconcile first`, ); } // RFC-0800: Auto-sync template dependencies from workpiece to template BEFORE inline validate. // Placed here so the drift check (in SITES_BUILD_CHECK_PIPELINE via mission.validate) // passes after the template is synced. If sync fails (non-fatal), the drift check // catches the residual drift and blocks close — safety net working as intended. // The template file is committed later via commitWerkstattSideEffects. let templateSyncResult: CloseReportTemplateSync = { synced: false, syncError: null }; if (!skipTemplateSync) { try { const { executeKernelCommand } = await import("@warpgogol/werkstatt-engine/kernel"); logger.info(` Auto-syncing template dependencies from workpiece…`); const syncResult = (await executeKernelCommand({ workspaceRoot, commandName: "config.template.sync", argv: [`--site=${manifest.systemId}`], })) as { exitCode?: number; summary?: string }; const syncExitCode = syncResult.exitCode ?? 0; if (syncExitCode !== 0) { const syncError = syncResult.summary ?? `config.template.sync exited with code ${syncExitCode}`; logger.warn(` Template sync failed (non-fatal): ${syncError}`); templateSyncResult = { synced: false, syncError }; } else { templateSyncResult = { synced: true, syncError: null }; logger.info(` Template dependencies synced`); } } catch (syncErr) { const errMsg = syncErr instanceof Error ? syncErr.message : String(syncErr); logger.warn(` Template sync threw (non-fatal): ${errMsg}`); templateSyncResult = { synced: false, syncError: errMsg }; } } // RFC-0593: inline validation gate — run mission.validate before acquiring locks. // This avoids holding registry/system/mission locks for 2+ minutes during the build. // State is re-checked inside the lock after validation passes. // RFC-1263: a mission carrying a green fast-lane gate record satisfies the // coverage precondition without a full mission.validate run — the scoped // gate set already ran inside mission.content-edit. const validationCheck = missionHasGreenCoverage(manifest, null) ? { passed: true, failures: [] as string[], report: null } : await runInlineValidate(missionId, context); if (!validationCheck.passed) { const failureLines = validationCheck.failures.map((f) => ` ${f}`).join("\n"); throw new Error( `[mission.close] validation failed for mission '${missionId}' — fix issues and re-run mission.validate\n${failureLines}`, ); } await acquireLock( workspaceRoot, `system:${manifest.systemId}`, manifest.operationId, "mission.close", actor, ); await acquireLock( workspaceRoot, `mission:${missionId}`, manifest.operationId, "mission.close", actor, ); try { // RFC-0593: re-read manifest inside lock and re-check state — between out-of-lock // validation and lock acquisition, another process could have aborted the mission. const lockedManifest = await readMissionManifest(workspaceRoot, missionId); if (lockedManifest.state !== "open") { throw new Error( `[mission.close] mission '${missionId}' state changed to '${lockedManifest.state}' during validation — aborting close`, ); } const now = new Date().toISOString(); const missionDir = await resolveMissionDir(workspaceRoot, missionId, context.io); const workpieceDir = path.join(missionDir, "workpiece"); const evidenceDir = path.join(missionDir, "evidence"); await context.io.mkdir(evidenceDir); // RFC-0958: Build step context and run post-lock lifecycle via journal const stepCtx: CloseStepCtx = { workspaceRoot, missionId, missionDir, workpieceDir, evidenceDir, manifest: lockedManifest, actor, releaseId, now, skipEvidenceSync, skipAutoSync, skipReconcileCheck, allowNoOp, skipContentRegression: flagBoolean(input, "skip-content-regression"), skipBehaviorSnapshot: flagBoolean(input, "skip-behavior-snapshot"), skipDigest: flagBoolean(input, "skip-digest"), context, templateSyncResult, digestPath: null, closeReport: null, evidenceSynced: false, evidenceSyncResult: null, freshnessChecked: false, unreconciledCommits: 0, workpieceHead: null, reconciledSha: null, originSha: null, mirrorSha: null, mirrorInSync: false, recommendation: null, }; const steps = await buildCloseSteps(workspaceRoot, missionId, stepCtx); 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.close"); if (blockCheck.blocked) { return { data: { missionId, closed: false, blockedByOperation: blockCheck.incompleteOp, } as unknown as MissionCloseData, exitCode: 1, summary: `[mission.close] ${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.close", steps }; const opResult = await runOperation(journalPath, def, stepCtx, { missionId, platformVersion: "", }); if (!opResult.completed) { const detail = opResult.failedStepError ?? "unknown error"; throw new Error( `[mission.close] step "${opResult.failedStep}" failed: ${detail} — run mission.resume --mission ${missionId} to retry`, ); } const cc = stepCtx; return { data: { missionId, systemId: cc.manifest.systemId, state: "closed", closedAt: now, releaseId, closeReport: cc.closeReport!, evidenceSynced: cc.evidenceSynced, evidenceSyncResult: cc.evidenceSyncResult, bordbuchValidation: { violations: [], checked: true }, }, exitCode: 0, summary: `[mission.close] closed mission ${missionId}`, nextSteps: [ { action: `Prepare a release: pnpm exec werkstatt run release.prepare --mission ${missionId}`, kind: "optional", }, { action: `Archive the mission: pnpm exec forge run docs.archive`, kind: "optional", }, ], }; } finally { await releaseLock(workspaceRoot, `mission:${missionId}`); await releaseLock(workspaceRoot, `system:${manifest.systemId}`); } } // --------------------------------------------------------------------------- // RFC-0958: Journal-integrated close steps // --------------------------------------------------------------------------- export interface CloseStepCtx { workspaceRoot: string; missionId: string; missionDir: string; workpieceDir: string; evidenceDir: string; manifest: import("@warpgogol/werkstatt-engine/schemas").MissionManifest; actor: ReturnType; releaseId: string | null; now: string; skipEvidenceSync: boolean; skipAutoSync: boolean; skipReconcileCheck: boolean; allowNoOp: boolean; skipContentRegression: boolean; skipBehaviorSnapshot: boolean; skipDigest: boolean; context: KernelRuntimeContext; templateSyncResult: CloseReportTemplateSync; digestPath: string | null; closeReport: CloseReport | null; evidenceSynced: boolean; evidenceSyncResult: { r2KeyPrefix: string; uploadedFiles: number } | null; freshnessChecked: boolean; unreconciledCommits: number; workpieceHead: string | null; reconciledSha: string | null; originSha: string | null; mirrorSha: string | null; mirrorInSync: boolean; recommendation: string | null; } export async function buildCloseSteps( workspaceRoot: string, missionId: string, manifest: unknown, ): Promise[]> { const ctx = manifest as CloseStepCtx; const logger = ctx.context.logger; const steps: OperationStep[] = [ { name: "stop-dev-servers", run: async (c: unknown) => { const cc = c as CloseStepCtx; if (await cc.context.io.exists(cc.workpieceDir)) { // Best-effort — a non-zero exit just means no server was running. await execOrThrow(cc.context.io, "pnpm", ["run", "stop"], { cwd: cc.workpieceDir, }).catch(() => {}); } }, verify: async () => true, }, { name: "auto-commit-workpiece", run: async (c: unknown) => { const cc = c as CloseStepCtx; const workpieceCommit = await commitWorkpieceIfDirty( cc.workpieceDir, cc.missionId, undefined, cc.context.io, ); if (workpieceCommit.committed) { logger.info( ` Auto-committed dirty workpiece (${workpieceCommit.commitSha?.slice(0, 8)}) before close`, ); await updateReconcileReportHead(cc.evidenceDir, workpieceCommit.commitSha, cc.context.io); } }, }, { name: "behavior-snapshot-refresh", run: async (c: unknown) => { const cc = c as CloseStepCtx; if (cc.skipBehaviorSnapshot) { logger.info(" [behavior-snapshot-refresh] Skipped by --skip-behavior-snapshot"); return; } try { const { executeKernelCommand } = await import("@warpgogol/werkstatt-engine/kernel"); await executeKernelCommand({ workspaceRoot: cc.workspaceRoot, commandName: "behavior.snapshot.generate", siteName: cc.manifest.systemId, }); const snapshotCommit = await commitWorkpieceIfDirty( cc.workpieceDir, cc.missionId, "behavior-snapshot-refresh", cc.context.io, ); if (snapshotCommit.committed) { await updateReconcileReportHead( cc.evidenceDir, snapshotCommit.commitSha, cc.context.io, ); } logger.info(" [behavior-snapshot-refresh] Snapshot regenerated and committed"); } catch (err) { logger.warn( ` [behavior-snapshot-refresh] Non-fatal: ${err instanceof Error ? err.message : String(err)}`, ); } }, }, { name: "zero-commit-guard", run: async (c: unknown) => { const cc = c as CloseStepCtx; if (!cc.allowNoOp) { const operatorCommits = await countOperatorCommits( cc.workpieceDir, cc.manifest.migratedAt, cc.context.io, ); if (!operatorCommits.hasOperatorCommits) { throw new Error( `[mission.close] ZERO-COMMIT-GUARD: Mission '${cc.missionId}' has zero operator commits since materialization.\n` + ` Brief: "${cc.manifest.brief}"\n` + ` If this is a legitimate no-op mission, re-run with --allow-no-op.`, ); } } }, }, { name: "reconcile-freshness-check", run: async (c: unknown) => { const cc = c as CloseStepCtx; if (!cc.skipReconcileCheck) { const reconcileReportPath = path.join(cc.evidenceDir, "reconciliation-report.json"); let reconciledWorkpieceSha: string | null = null; try { const report = JSON.parse(await cc.context.io.readFile(reconcileReportPath)); reconciledWorkpieceSha = report.workpieceHeadAtReconcile ?? null; } catch { throw new Error( `[mission.close] reconciliation report not found — re-run mission.reconcile or use --skip-reconcile-check`, ); } if (!reconciledWorkpieceSha) { throw new Error( `[mission.close] reconciliation report missing workpieceHeadAtReconcile field`, ); } cc.workpieceHead = await gitExec(cc.workpieceDir, "rev-parse HEAD"); cc.reconciledSha = reconciledWorkpieceSha; if (cc.workpieceHead !== reconciledWorkpieceSha) { try { const countOutput = await gitExec( cc.workpieceDir, `rev-list --count ${reconciledWorkpieceSha}..${cc.workpieceHead}`, ); cc.unreconciledCommits = parseInt(countOutput, 10); } catch { cc.unreconciledCommits = 0; } throw new Error( `[mission.close] mission '${cc.missionId}' has ${cc.unreconciledCommits} unreconciled commit(s)`, ); } cc.freshnessChecked = true; } }, }, { name: "create-git-bundle", run: async (c: unknown) => { const cc = c as CloseStepCtx; if (await cc.context.io.exists(path.join(cc.workpieceDir, ".git"))) { const bundlePath = path.join(cc.evidenceDir, "workpiece.git-bundle"); try { await gitExec( cc.workpieceDir, `bundle create ${JSON.stringify(bundlePath)} --all`, undefined, cc.context.io, ); } catch { // Non-fatal } } }, }, { name: "mirror-sync-check", run: async (c: unknown) => { const cc = c as CloseStepCtx; const config = await readSystemConfigSmart(cc.workspaceRoot, cc.manifest.systemId); if (config && config.mirrors.length > 1) { const preCheckSystemDir = await resolveCacheClonePath( cc.workspaceRoot, cc.manifest.systemId, ); if (await cc.context.io.exists(path.join(preCheckSystemDir, ".git"))) { try { const branch = await gitExec(preCheckSystemDir, "rev-parse --abbrev-ref HEAD"); await gitExec(preCheckSystemDir, `push origin ${JSON.stringify(branch)}`); logger.info(` Pushed cache clone to origin before mirror sync check`); } catch (pushErr) { logger.warn( ` Could not push cache clone to origin: ${pushErr instanceof Error ? pushErr.message : String(pushErr)}`, ); } } if (!cc.skipAutoSync && config.mirrors.length > 2) { try { const { executeKernelCommand } = await import("@warpgogol/werkstatt-engine/kernel"); logger.info(` Syncing mirrors before mirror sync check…`); await executeKernelCommand({ workspaceRoot: cc.workspaceRoot, commandName: "sternsystem.sync", argv: [`--id=${cc.manifest.systemId}`], }); } catch (syncErr) { logger.warn( ` Pre-check mirror sync failed (non-fatal): ${syncErr instanceof Error ? syncErr.message : String(syncErr)}`, ); } } const bareMirror = config.mirrors[1]; const bareRepoPath = resolveMirrorPath(cc.workspaceRoot, bareMirror.path); if (await cc.context.io.exists(bareRepoPath)) { try { let branch: string; try { branch = ( await gitExec(bareRepoPath, "symbolic-ref HEAD", undefined, cc.context.io) ).replace("refs/heads/", ""); } catch { branch = "main"; } try { cc.originSha = await gitExec(bareRepoPath, `rev-parse ${branch}`); } catch { cc.originSha = null; } if (config.mirrors.length > 2) { try { cc.mirrorSha = await gitExec(bareRepoPath, `rev-parse refs/mirror/${branch}`); } catch { cc.mirrorSha = null; } } } catch { // bare repo not accessible } } if (cc.originSha && cc.mirrorSha && cc.originSha !== cc.mirrorSha) { cc.mirrorInSync = false; cc.recommendation = `Run: sternsystem.sync --id ${cc.manifest.systemId}`; } else if (cc.originSha && cc.mirrorSha && cc.originSha === cc.mirrorSha) { cc.mirrorInSync = true; } else if (config.mirrors.length > 2 && !cc.mirrorSha) { cc.mirrorInSync = false; cc.recommendation = `Mirror ref not found. Run: sternsystem.sync --id ${cc.manifest.systemId}`; } else { cc.mirrorInSync = true; } if (config.mirrors.length > 2 && !cc.mirrorInSync) { throw new Error( `[mission.close] external mirrors are out of sync for system '${cc.manifest.systemId}'. ${cc.recommendation ?? ""}`, ); } } else { cc.mirrorInSync = true; } }, }, { // ADR-0084: abandon every incomplete journal operation belonging to this // mission before the state transition — an op that outlives its mission // is definitionally stale and must not stay resumable. name: "abandon-incomplete-operations", run: async (c: unknown) => { const cc = c as CloseStepCtx; const systemDir = await resolveCacheClonePath(cc.workspaceRoot, cc.manifest.systemId); const operationsDir = path.join(systemDir, "operations"); if (!(await cc.context.io.exists(operationsDir))) { return; } // Exclude ops still executing in this process — the sweep itself runs // inside leitstand.ship's journal op, and abandoning that live journal // marks the ship terminal mid-run (observed 2026-10-04: --resume then // reported "nothing to resume" for a genuinely failed certify-dev). const sweep = await abandonIncompleteOperationsForMission( operationsDir, cc.missionId, getActiveOpIds(), ); // Fail closed when a journal file that may reference this mission is // unreadable — we cannot prove no resumable op remains. const blocking = sweep.unreadableFiles.filter((f) => referencesMissionId(f, cc.missionId)); if (blocking.length > 0) { throw new Error( `[mission.close] cannot verify journal operations — unreadable file(s): ${blocking.join(", ")}`, ); } for (const file of sweep.unreadableFiles) { logger.warn(` [abandon-incomplete-operations] unreadable journal file skipped: ${file}`); } if (sweep.abandoned.length === 0) { return; } for (const op of sweep.abandoned) { logger.info(` [abandon-incomplete-operations] abandoned ${op.op} (${op.opId})`); } try { const touched = [...new Set(sweep.abandoned.map((op) => op.journalPath))]; for (const journalPath of touched) { await gitExec( systemDir, `add ${JSON.stringify(path.relative(systemDir, journalPath))}`, ); } await cacheCloneCommit( systemDir, `journal: abandon incomplete operations for ${cc.missionId}`, ); } catch (commitErr) { logger.warn( ` [abandon-incomplete-operations] could not commit journal updates (non-fatal): ${commitErr instanceof Error ? commitErr.message : String(commitErr)}`, ); } }, verify: async (c: unknown) => { const cc = c as CloseStepCtx; const systemDir = await resolveCacheClonePath(cc.workspaceRoot, cc.manifest.systemId); const operationsDir = path.join(systemDir, "operations"); if (!(await cc.context.io.exists(operationsDir))) { return true; } try { const scan = await findIncompleteOperationsForMission( operationsDir, cc.missionId, cc.context.io, getActiveOpIds(), ); return scan.incomplete.length === 0 && scan.unreadableFiles.length === 0; } catch { return false; } }, }, { name: "transition-state", run: async (c: unknown) => { const cc = c as CloseStepCtx; cc.manifest.state = "closed"; cc.manifest.closedAt = cc.now; cc.manifest.closedBy = cc.actor; cc.manifest.releaseId = cc.releaseId; await writeMissionManifest(cc.workspaceRoot, cc.manifest); }, verify: async (c: unknown) => { const cc = c as CloseStepCtx; const reRead = await readMissionManifest(cc.workspaceRoot, cc.missionId); return reRead.state === "closed"; }, }, { // RFC-1264: emit the mission digest pre-bordbuch so the close event can // link it. Non-blocking for the advisory rollout minor — failures warn. name: "mission-digest", run: async (c: unknown) => { const cc = c as CloseStepCtx; if (cc.skipDigest) { logger.info(" [mission-digest] Skipped by --skip-digest (audited in close event)"); return; } try { const { executeKernelCommand } = await import("@warpgogol/werkstatt-engine/kernel"); const result = (await executeKernelCommand({ workspaceRoot: cc.workspaceRoot, commandName: "mission.digest.generate", argv: [`--mission=${cc.missionId}`], })) as { exitCode?: number; data?: { artifacts?: string[] }; summary?: string }; if ((result.exitCode ?? 1) !== 0) { logger.warn( ` [mission-digest] Non-fatal: ${result.summary ?? "mission.digest.generate failed"}`, ); return; } cc.digestPath = result.data?.artifacts?.[0] ?? null; if (cc.digestPath) { logger.info(` [mission-digest] ${cc.digestPath}`); } } catch (err) { logger.warn( ` [mission-digest] Non-fatal: ${err instanceof Error ? err.message : String(err)}`, ); } }, }, { name: "bordbuch-validate-and-append", run: async (c: unknown) => { const cc = c as CloseStepCtx; const bordbuchCheck = await validateBordbuch(cc.workspaceRoot, cc.manifest.systemId); if (bordbuchCheck.violations.length > 0) { const violationLines = bordbuchCheck.violations .map((v) => ` [${v.rule}] ${v.message}`) .join("\n"); throw new Error( `[mission.close] bordbuch has ${bordbuchCheck.violations.length} violation(s) — run bordbuch.repair first\n${violationLines}`, ); } await appendAndCommitBordbuch( cc.workspaceRoot, cc.manifest.systemId, "mission-close", `Mission ${cc.missionId} closed`, cc.actor, { missionId: cc.missionId, releaseId: cc.releaseId, writerRole: "mission", metadata: { // RFC-1263 AC-6: the close event records the lane the mission // took — "fast" when a green composite gate record carried it. lane: cc.manifest.fastLane?.green === true ? "fast" : "full", // RFC-1264 AC-1: link the mission digest; record the audited // escape hatch when the operator passed --skip-digest. ...(cc.digestPath ? { digestPath: cc.digestPath } : {}), ...(cc.skipDigest ? { digestSkipped: true } : {}), ...(cc.releaseId ? { releaseId: cc.releaseId } : {}), }, }, `Bordbuch: mission-close ${cc.missionId}`, ); }, }, { name: "evidence-sync", run: async (c: unknown) => { const cc = c as CloseStepCtx; if (cc.skipEvidenceSync) { logger.warn( ` Evidence sync skipped — local evidence will be lost when mission.cleanup runs`, ); try { await appendAndCommitBordbuch( cc.workspaceRoot, cc.manifest.systemId, "mission-close", `mission-close-evidence-skipped: ${cc.missionId}`, cc.actor, { missionId: cc.missionId, writerRole: "mission", metadata: { evidenceSyncSkipped: true, reason: "operator-used-skip-evidence-sync" }, }, `Bordbuch: mission-close-evidence-skipped ${cc.missionId}`, ); } catch (bordbuchErr) { logger.warn( ` Warning: failed to append evidence-skipped Bordbuch entry: ${bordbuchErr instanceof Error ? bordbuchErr.message : String(bordbuchErr)}`, ); } return; } const axiomEvidenceDir = path.join(cc.missionDir, "evidence", "axiom"); const metadataPath = path.join(axiomEvidenceDir, "evidence-metadata.json"); if ( (await cc.context.io.exists(axiomEvidenceDir)) && (await cc.context.io.exists(metadataPath)) ) { try { const { executeKernelCommand } = await import("@warpgogol/werkstatt-engine/kernel"); const syncResult = (await executeKernelCommand({ workspaceRoot: cc.workspaceRoot, commandName: "evidence.sync", argv: [`--mission=${cc.missionId}`], })) as { data?: { r2KeyPrefix?: string; uploadedFiles?: string[] }; exitCode?: number; }; cc.evidenceSynced = true; const syncData = syncResult.data; if (syncData) { cc.evidenceSyncResult = { r2KeyPrefix: syncData.r2KeyPrefix ?? "", uploadedFiles: syncData.uploadedFiles?.length ?? 0, }; } logger.info(` Evidence synced to R2`); } catch (syncError) { logger.error( ` Evidence sync failed — mission cannot close without archiving evidence`, ); throw new Error( `EVIDENCE_SYNC_FAILED: ${syncError instanceof Error ? syncError.message : String(syncError)}`, ); } } else if (await cc.context.io.exists(axiomEvidenceDir)) { logger.warn( ` Evidence directory exists but evidence-metadata.json is missing — skipping sync`, ); } }, }, { name: "passport-refresh", run: async (c: unknown) => { const cc = c as CloseStepCtx; const logger = cc.context.logger; try { const existingPassport = await readPassport(cc.workspaceRoot, cc.manifest.systemId); if (!existingPassport) { logger.info( ` [passport-refresh] No existing passport — skipping (run sternsystem.passport.generate to create)`, ); return; } const privateKeyEnv = process.env.SIGNING_PRIVATE_KEY; const privateKeyPath = process.env.SIGNING_PRIVATE_KEY_PATH; if (!privateKeyEnv && !privateKeyPath) { logger.info( ` [passport-refresh] No signing key configured — skipping passport refresh`, ); return; } let privateKeyBytes: Uint8Array; // Dynamic import avoids circular dependency at module load time const { loadPrivateKey } = await import("@warpgogol/werkstatt-engine/signing"); const { buildPassportPayload, signPassport, derivePublicKey } = await import("../sternsystem/passport.ts"); try { if (privateKeyEnv) { privateKeyBytes = await loadPrivateKey({ pem: privateKeyEnv }); } else { privateKeyBytes = await loadPrivateKey({ filePath: privateKeyPath!, encoding: "pem", }); } } catch { logger.info( ` [passport-refresh] Failed to load signing key — skipping passport refresh`, ); return; } const creatorPublicKey = await derivePublicKey(privateKeyBytes); const payload = await buildPassportPayload({ systemId: cc.manifest.systemId, werkstattRoot: cc.workspaceRoot, creatorIdentity: existingPassport.payload.creator.identity, creatorPublicKey, }); payload.provenance.createdAt = existingPassport.payload.provenance.createdAt; payload.provenance.generatedAt = existingPassport.payload.provenance.generatedAt; const { passportHash, signature } = await signPassport(payload, privateKeyBytes); if (passportHash === existingPassport.passportHash) { logger.info(` [passport-refresh] Passport unchanged — no write needed`); return; } await writePassport(cc.workspaceRoot, cc.manifest.systemId, { payload, passportHash, signature, }); // Stage passport.json so subsequent commit steps include it const cacheClonePath = resolveCacheClonePath(cc.workspaceRoot, cc.manifest.systemId); await execOrThrow(cc.context.io, "git", ["-C", cacheClonePath, "add", "passport.json"]); logger.success(` [passport-refresh] Passport refreshed (hash: ${passportHash})`); } catch (err) { logger.warn( ` [passport-refresh] Non-fatal error: ${err instanceof Error ? err.message : String(err)}`, ); } }, }, { name: "ownership-register", run: async (c: unknown) => { const cc = c as CloseStepCtx; const logger = cc.context.logger; try { const registryUrl = process.env.FLEET_OWNERSHIP_REGISTRY_URL; if (!registryUrl) { logger.info( ` [ownership-register] FLEET_OWNERSHIP_REGISTRY_URL not set — skipping ownership registration`, ); return; } const { registerOwnership, OwnershipError } = await import("../fleet/ownership-registry.ts"); try { const result = await registerOwnership({ systemId: cc.manifest.systemId, werkstattRoot: cc.workspaceRoot, registryUrl, }); if (!result.registered && result.conflict) { logger.warn( ` [ownership-register] Conflict: site already claimed by ${result.conflict.existingClaim?.instanceId ?? "unknown"} (OWNERSHIP-03)`, ); return; } logger.success( ` [ownership-register] Site registered (hash: ${result.claim.passportHash})`, ); } catch (err) { if (err instanceof OwnershipError) { logger.warn(` [ownership-register] Non-fatal error (${err.code}): ${err.message}`); } else { throw err; } } } catch (err) { logger.warn( ` [ownership-register] Non-fatal error: ${err instanceof Error ? err.message : String(err)}`, ); } }, }, { // RFC-1124: publish the signed fleet claim — the authoritative ownership // record. Non-fatal: workshops without werkstatt.fleet.json or a signing // key skip silently; push failure is retried by sternsystem.sync. name: "claims-publish", run: async (c: unknown) => { const cc = c as CloseStepCtx; const logger = cc.context.logger; try { const { loadFleetClaimsConfig, loadSigningKeyFromEnv, publishClaim } = await import("../fleet/claims-repo.ts"); if (!(await loadFleetClaimsConfig(cc.workspaceRoot))) return; const signingKey = await loadSigningKeyFromEnv(); if (!signingKey) return; const result = await publishClaim({ systemId: cc.manifest.systemId, werkstattRoot: cc.workspaceRoot, signerPrivateKeyHex: signingKey.privateKeyHex, signerPublicKey: signingKey.publicKeyHex, }); logger.success( ` [claims-publish] Fleet claim published (hash: ${result.claim.passportHash.slice(0, 16)}${result.pushed ? ", pushed" : ", local-only"})`, ); } catch (err) { logger.warn( ` [claims-publish] Non-fatal error: ${err instanceof Error ? err.message : String(err)}`, ); } }, }, { name: "write-close-report", run: async (c: unknown) => { const cc = c as CloseStepCtx; const systemDir = await resolveCacheClonePath(cc.workspaceRoot, cc.manifest.systemId); let dirtyFiles: string[] = []; try { const status = await gitExec(systemDir, "status --porcelain"); dirtyFiles = status .split("\n") .filter((l) => l.trim().length > 0) .map((l) => l.slice(3)); } catch { dirtyFiles = []; } const warnings: Array<{ rule: string; message: string }> = []; if (!cc.releaseId) { warnings.push({ rule: "missing-release-id", message: "Mission closed without release — releaseId is null. Run release.prepare after close.", }); } if (cc.skipReconcileCheck) { try { await appendAndCommitBordbuch( cc.workspaceRoot, cc.manifest.systemId, "mission-close", `mission-close-reconcile-check-skipped: ${cc.missionId}`, cc.actor, { missionId: cc.missionId, writerRole: "mission", metadata: { reconcileCheckSkipped: true, reason: "operator-used-skip-reconcile-check", }, }, `Bordbuch: mission-close-reconcile-check-skipped ${cc.missionId}`, ); } catch (bordbuchErr) { logger.warn( ` Warning: failed to append reconcile-check-skipped Bordbuch entry: ${bordbuchErr instanceof Error ? bordbuchErr.message : String(bordbuchErr)}`, ); } } cc.closeReport = { releaseId: cc.releaseId, git: { commitSha: null, pushed: false, pushError: null, dirtyFiles, }, mirror: { originSha: cc.originSha, mirrorSha: cc.mirrorSha, inSync: cc.mirrorInSync, recommendation: cc.recommendation, synced: false, syncError: null, }, reconcile: { reconciledAt: cc.manifest.reconciledAt ?? "", verified: true, freshnessChecked: cc.freshnessChecked, unreconciledCommits: cc.unreconciledCommits, workpieceHead: cc.workpieceHead, reconciledSha: cc.reconciledSha, }, templateSync: cc.templateSyncResult, digest: { path: cc.digestPath, skipped: cc.skipDigest }, warnings, }; const evidencePath = path.join(cc.missionDir, "evidence", "close-report.json"); await atomicWriteFile(evidencePath, JSON.stringify(cc.closeReport, null, 2) + "\n"); }, verify: async (c: unknown) => { const cc = c as CloseStepCtx; return await cc.context.io.exists( path.join(cc.missionDir, "evidence", "close-report.json"), ); }, }, { name: "update-system-state", run: async (c: unknown) => { const cc = c as CloseStepCtx; const closeState = await readSystemState(cc.workspaceRoot, cc.manifest.systemId); if (closeState.currentMission === cc.missionId) { closeState.currentMission = null; await writeSystemState(cc.workspaceRoot, cc.manifest.systemId, closeState); } }, }, { name: "auto-pin-platform-version", run: async (c: unknown) => { const cc = c as CloseStepCtx; try { const { executeKernelCommand } = await import("@warpgogol/werkstatt-engine/kernel"); const pinResult = (await executeKernelCommand({ workspaceRoot: cc.workspaceRoot, commandName: "sternsystem.pin", argv: [`--id=${cc.manifest.systemId}`], })) as { exitCode?: number; summary?: string }; const pinExitCode = pinResult.exitCode ?? 0; if (pinExitCode !== 0) { throw new Error( `sternsystem.pin failed with exitCode ${pinExitCode}: ${pinResult.summary ?? "no summary"}`, ); } logger.info(` Auto-pinned platform version for ${cc.manifest.systemId}`); } catch (pinError) { throw new Error( `[mission.close] sternsystem.pin failed: ${pinError instanceof Error ? pinError.message : String(pinError)}`, ); } try { const systemDir = await resolveCacheClonePath(cc.workspaceRoot, cc.manifest.systemId); await gitExec(systemDir, "add system.pin.json system-config.yaml"); await cacheCloneCommit(systemDir, `chore: auto-pin platform version for ${cc.missionId}`); logger.info(` Committed system.pin.json to cache clone`); } catch (pinCommitError) { logger.warn( ` Could not commit system.pin.json: ${pinCommitError instanceof Error ? pinCommitError.message : String(pinCommitError)}`, ); } }, }, { name: "commit-werkstatt-side-effects", run: async (c: unknown) => { const cc = c as CloseStepCtx; await commitWerkstattSideEffects( cc.workspaceRoot, [ path.join("missions", cc.missionId, "mission.yaml"), "packages/werkstatt-site/src/onboarding/templates/package.template.json", ], `werkstatt: mission.close ${cc.missionId}`, ); }, }, { name: "sync-external-mirrors", run: async (c: unknown) => { const cc = c as CloseStepCtx; const config = await readSystemConfigSmart(cc.workspaceRoot, cc.manifest.systemId); if (config && config.mirrors.length > 2) { try { const { executeKernelCommand } = await import("@warpgogol/werkstatt-engine/kernel"); logger.info(` Syncing external mirrors via sternsystem.sync…`); 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) { const syncError = syncResult.summary ?? `sternsystem.sync exited with code ${syncExitCode}`; logger.warn( `[mission.close] sternsystem.sync failed — run manually: sternsystem.sync --id ${cc.manifest.systemId}`, ); if (cc.closeReport) { cc.closeReport.mirror.synced = false; cc.closeReport.mirror.syncError = syncError; } } else { if (cc.closeReport) { cc.closeReport.mirror.synced = true; cc.closeReport.mirror.syncError = null; } logger.info(` External mirrors synced`); } } catch (syncErr) { logger.warn( `[mission.close] sternsystem.sync threw — run manually: ${syncErr instanceof Error ? syncErr.message : String(syncErr)}`, ); if (cc.closeReport) { cc.closeReport.mirror.synced = false; cc.closeReport.mirror.syncError = syncErr instanceof Error ? syncErr.message : String(syncErr); } } } }, }, { name: "write-materialization-state", run: async (c: unknown) => { const cc = c as CloseStepCtx; try { const systemDir = await resolveCacheClonePath(cc.workspaceRoot, cc.manifest.systemId); let cacheCloneHead: string | null = null; try { cacheCloneHead = await gitExec(systemDir, "rev-parse HEAD", undefined, cc.context.io); } catch { // skip } if (cacheCloneHead) { const stateFile = { systemId: cc.manifest.systemId, cacheCloneHead, lastValidatedAt: cc.now, lastMissionId: cc.missionId, }; await atomicWriteFile( path.join(systemDir, ".materialization-state.json"), JSON.stringify(stateFile, null, 2) + "\n", ); logger.info( ` Wrote .materialization-state.json (HEAD: ${cacheCloneHead.slice(0, 12)})`, ); try { await gitExec(systemDir, "add .materialization-state.json"); await cacheCloneCommit( systemDir, `chore: update materialization state for ${cc.missionId}`, ); logger.info(` Committed .materialization-state.json to cache clone`); } catch { // non-fatal } } try { const envResult = await persistEnvFilesToCacheClone(cc.workpieceDir, systemDir); if (envResult.copied.length > 0) { logger.info(` Persisted ${envResult.copied.length} .env file(s) to cache clone`); } } catch (envErr) { logger.warn( ` Warning: failed to persist .env files: ${envErr instanceof Error ? envErr.message : String(envErr)}`, ); } try { const operatorConfigResult = await persistOperatorConfigFiles( cc.workpieceDir, systemDir, ); if (operatorConfigResult.copied.length > 0) { logger.info( ` Persisted ${operatorConfigResult.copied.length} operator config file(s)`, ); } } catch (operatorConfigErr) { logger.warn( ` Warning: failed to persist operator config files: ${operatorConfigErr instanceof Error ? operatorConfigErr.message : String(operatorConfigErr)}`, ); } for (const cacheDir of MEDIA_CACHE_DIRS) { const srcCache = path.join(cc.workpieceDir, cacheDir); if (await cc.context.io.exists(srcCache)) { const destCache = path.join(systemDir, cacheDir); try { if (await cc.context.io.exists(destCache)) { await cc.context.io.rm(destCache, { recursive: true }); } await cc.context.io.mkdir(path.dirname(destCache)); await copyDirRecursive(srcCache, destCache, cc.context.io); logger.info(` Copied ${cacheDir} from workpiece to cache clone`); } catch (err) { logger.info( ` Warning: failed to copy ${cacheDir}: ${err instanceof Error ? err.message : String(err)}`, ); } } } if (!cc.skipContentRegression) { const contentRegressionSrc = path.join( cc.workpieceDir, ".cache", "content-regression", "current.snapshot.yaml", ); if (await cc.context.io.exists(contentRegressionSrc)) { let currentHash: string | null = null; try { const raw = await cc.context.io.readFile(contentRegressionSrc); const parsed = yamlParse(raw) as { contentHash?: string }; currentHash = parsed?.contentHash ?? null; } catch { // proceed } let goldenHash: string | null = null; const goldenSnapshotPath = path.join( systemDir, ".cache", "content-regression", `${cc.manifest.systemId}.snapshot.yaml`, ); if (await cc.context.io.exists(goldenSnapshotPath)) { try { const raw = await cc.context.io.readFile(goldenSnapshotPath); const parsed = yamlParse(raw) as { contentHash?: string }; goldenHash = parsed?.contentHash ?? null; } catch { // cold start } } if (currentHash && goldenHash && currentHash !== goldenHash) { const applyResultPath = path.join( cc.missionDir, "evidence", "content-regression", "apply-result.json", ); let hasValidApplyResult = false; if (await cc.context.io.exists(applyResultPath)) { try { const raw = await cc.context.io.readFile(applyResultPath); const result = JSON.parse(raw) as { pending?: number; errors?: string[] }; if ((result.pending ?? 0) === 0 && (result.errors?.length ?? 0) === 0) { hasValidApplyResult = true; } } catch { // not valid } } if (!hasValidApplyResult) { logger.warn( ` [mission.close] CREG-05: Content drift detected. Review generation skipped — non-blocking.`, ); } } } } const contentRegressionSrc = path.join( cc.workpieceDir, ".cache", "content-regression", "current.snapshot.yaml", ); if (await cc.context.io.exists(contentRegressionSrc)) { const contentRegressionDestDir = path.join(systemDir, ".cache", "content-regression"); const contentRegressionDest = path.join( contentRegressionDestDir, `${cc.manifest.systemId}.snapshot.yaml`, ); try { await cc.context.io.mkdir(contentRegressionDestDir); await cc.context.io.copyFile(contentRegressionSrc, contentRegressionDest); logger.info( ` Copied content regression snapshot → ${cc.manifest.systemId}.snapshot.yaml`, ); } catch (err) { logger.warn( ` Warning: failed to copy content regression snapshot: ${err instanceof Error ? err.message : String(err)}`, ); } } } catch (err) { logger.info( ` Warning: failed to write materialization state or copy .cache/: ${err instanceof Error ? err.message : String(err)}`, ); } }, }, { name: "write-closed-sentinel", run: async (c: unknown) => { const cc = c as CloseStepCtx; try { await cc.context.io.writeFile(path.join(cc.workpieceDir, ".closed"), `${cc.now}\n`); } catch (sentinelErr) { logger.warn( ` Warning: failed to write .closed sentinel: ${sentinelErr instanceof Error ? sentinelErr.message : String(sentinelErr)}`, ); } }, verify: async (c: unknown) => { const cc = c as CloseStepCtx; return await cc.context.io.exists(path.join(cc.workpieceDir, ".closed")); }, }, { name: "remove-mission-overlay", run: async (c: unknown) => { const cc = c as CloseStepCtx; removeOverlay(`mission-${cc.missionId}`); }, }, { name: "dispose-scope-registry", run: async (c: unknown) => { const cc = c as CloseStepCtx; try { const scopeManager = getDefaultScopeManager(); scopeManager.disposeRegistry({ scope: "per-mission", missionId: cc.missionId }); } catch (scopeErr) { logger.warn( ` [dispose-scope-registry] Non-fatal: ${scopeErr instanceof Error ? scopeErr.message : String(scopeErr)}`, ); } }, }, ]; return steps; }