/* RFC-0480: mission.git.commit — canonical operator edit commit command for mission workpiece. RFC-0594: runs targeted content validators based on changed file paths before committing. Does not reconcile — use mission.reconcile for that. Does not run full mission.validate — use mission.validate for that. Pre-commit validators are a targeted subset, not a full validation. RFC-1199: fail-closed materialization preflight — MATERIALIZATION_IN_PROGRESS result when workpiece/.git/MATERIALIZATION-IN-PROGRESS exists or manifest.materializedAt is null; runs before the .git check, staging, and commit-gate validators RFC-1204: trigger effects.contract.validate unscoped on pages/prose staging RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt RFC-1254: drop unused io-migration leftovers RFC-1323: write-time comment-terminator safety — shared comment-safety seam, authored CS-08 reject, injected neutralization, header-region scan ADR-0096, ADR-0104, RFC-0480, RFC-0522, RFC-0560, RFC-0568, RFC-0594, RFC-0644, RFC-0797, RFC-0820, RFC-0878, RFC-1095, RFC-1149, RFC-1159, RFC-1160, RFC-1185, RFC-1189, warpgogol-m000196 */ import { createDefaultIO, execOrThrow, type WorkspaceIO, } from "@warpgogol/werkstatt-shared/kernel/workspace-io"; import path from "node:path"; import type { KernelCommandInput, KernelCommandResult, KernelRuntimeContext, } from "@warpgogol/werkstatt-engine/kernel"; import { executeKernelCommand } from "@warpgogol/werkstatt-engine/kernel"; import { runCompassSummaryRecord, stripConventionalPrefix } from "@warpgogol/forge/os/compass"; import { readMissionManifest, resolveMissionDir } from "./mission-io.ts"; import { MATERIALIZATION_SENTINEL } from "./mission-materialize.ts"; import { createSignedCommit } from "./signed-commit.ts"; import { readBordbuch } from "../bordbuch/bordbuch-io.ts"; import { PIPELINE_SUBJECT_PREFIXES } from "../sternsystem/external-edit-guard.ts"; export interface MissionGitCommitData { missionId: string; commitSha: string; message: string; committedAt: string; signed: boolean; actorId: string | null; signature: string | null; preCommitValidation?: PreCommitValidationResult; noChanges?: boolean; } export interface ValidatorMapping { prefix: string; validator: string; /** When true, runPreCommitValidation passes --scope-files with the changed files matching prefix. */ scoped?: boolean; } export interface PreCommitValidationResult { passed: boolean; validatorsRun: string[]; failures: Array<{ validator: string; message: string; files: string[]; }>; } export const VALIDATOR_MAPPINGS: ValidatorMapping[] = [ { prefix: "src/content/business-profile/", validator: "pbp.content.validate" }, // RFC-1160: block pinning — pages scoped, system.md unscoped (planets[] is whole-site) { prefix: "src/content/pages/", validator: "page.block.validate", scoped: true }, { prefix: "src/content/system.md", validator: "page.block.validate" }, { prefix: "src/content/pages/", validator: "semantic.drift.validate" }, // RFC-1185: explicit inherit edges — collision groups are cross-page per // language, so the validator always runs unscoped (no --scope-files). { prefix: "src/content/pages/", validator: "shared.context.validate" }, { prefix: "src/content/system.md", validator: "shared.context.validate" }, { prefix: "src/content/faq/", validator: "faq.validate" }, // RFC-1160: source-image budget — scoped to staged content assets { prefix: "src/content/", validator: "image.variants.validate", scoped: true }, { prefix: "src/content/", validator: "typography.validate", scoped: true }, // RFC-1189: public-text hygiene of generated credits.md — prose edits (the // generated page lives under prose/) and labels.md edits (partyKindLabels // is the kind-label source) trigger the read-only scan at commit time. { prefix: "src/content/prose/", validator: "material.credits.public-text.validate" }, { prefix: "src/content/site/", validator: "material.credits.public-text.validate" }, // RFC-1204: effect-signature parity (EFF-PARITY-01/02) is cross-page and // cross-locale, so staging any content file forces the unscoped run. { prefix: "src/content/pages/", validator: "effects.contract.validate" }, { prefix: "src/content/prose/", validator: "effects.contract.validate" }, // warpgogol-m000196: post-hero density rhythm — the check resolves // component-level density defaults from section sources, so it always runs // unscoped over all pages when any page file is staged. { prefix: "src/content/pages/", validator: "layout.rhythm.validate" }, ]; function matchesPrefix(filePath: string, prefix: string): boolean { return filePath.startsWith(prefix); } export function selectValidators(changedFiles: string[]): string[] { const validators = new Set(); for (const file of changedFiles) { for (const mapping of VALIDATOR_MAPPINGS) { if (matchesPrefix(file, mapping.prefix)) { validators.add(mapping.validator); } } } return [...validators]; } export async function runPreCommitValidation( changedFiles: string[], systemId: string, workspaceRoot: string, ): Promise { const validatorsToRun = selectValidators(changedFiles); if (validatorsToRun.length === 0) { return { passed: true, validatorsRun: [], failures: [] }; } const failures: PreCommitValidationResult["failures"] = []; const validatorsRun: string[] = []; for (const validatorName of validatorsToRun) { // RFC-1160: per-validator scope resolution — any staged file matching a // non-scoped mapping forces an unscoped run (e.g. system.md planets[] must // re-check every page even when page files are also staged). Otherwise the // run is scoped to the union of files matching the validator's scoped prefixes. const mappings = VALIDATOR_MAPPINGS.filter((m) => m.validator === validatorName); const unscopedMatch = mappings.some( (m) => m.scoped !== true && changedFiles.some((f) => matchesPrefix(f, m.prefix)), ); const scopedFiles = unscopedMatch ? [] : changedFiles.filter((f) => mappings.some((m) => m.scoped === true && matchesPrefix(f, m.prefix)), ); const argv = !unscopedMatch && scopedFiles.length > 0 ? ["--scope-files", scopedFiles.join(",")] : undefined; try { const execResult = await executeKernelCommand({ workspaceRoot, commandName: validatorName, siteName: systemId, siteExplicit: true, argv, }); const single = Array.isArray(execResult) ? execResult[0] : execResult; const ok = single?.ok ?? false; const exitCode = single?.exitCode ?? 1; const summary = single?.summary ?? ""; validatorsRun.push(validatorName); if (!ok || exitCode !== 0) { failures.push({ validator: validatorName, message: summary || `validator exited with code ${exitCode}`, files: changedFiles.filter((f) => VALIDATOR_MAPPINGS.some( (m) => m.validator === validatorName && matchesPrefix(f, m.prefix), ), ), }); } } catch (err) { const errMsg = err instanceof Error ? err.message : String(err); if (errMsg.includes("not registered") || errMsg.includes("not found")) { process.stderr.write( `[warn] [mission.git.commit] validator '${validatorName}' is not registered — skipping. `, ); continue; } failures.push({ validator: validatorName, message: `validator crashed: ${errMsg}`, files: changedFiles.filter((f) => VALIDATOR_MAPPINGS.some( (m) => m.validator === validatorName && matchesPrefix(f, m.prefix), ), ), }); validatorsRun.push(validatorName); } } return { passed: failures.length === 0, validatorsRun, failures, }; } export interface WorkpieceDirtyResult { dirty: boolean; fileCount: number; files: string[]; } export interface UntrackedFileReport { path: string; createdAt: string; sizeBytes: number; likelyOrigin: "previous-mission" | "direct-commit" | "unknown"; originHint?: string; } const BOILERPLATE_PATTERNS = [ /^\.github\/workflows\/deploy-.*\.yml$/, /^package\.json$/, /^astro\.config\.mjs$/, /^wrangler\.jsonc$/, /^tsconfig\.json$/, /^\.gitignore$/, /^postcss\.config\.cjs$/, /^\.env\.example$/, /^src\/routes\/.*\.astro$/, /^src\/pages\/.*\.astro$/, ]; function matchesBoilerplatePattern(filePath: string): boolean { return BOILERPLATE_PATTERNS.some((p) => p.test(filePath)); } export async function investigateUntrackedFiles( workspaceRoot: string, systemId: string, systemDir: string, files: string[], ): Promise { const bordbuchEntries = await readBordbuch(workspaceRoot, systemId); const missionOpenEntries = bordbuchEntries.filter((e) => e.kind === "mission-open"); const missionTimeRanges = missionOpenEntries.map((e, i) => { const next = missionOpenEntries[i + 1]; return { start: e.occurredAt, end: next ? next.occurredAt : new Date().toISOString() }; }); const reports: UntrackedFileReport[] = []; for (const file of files) { const fullPath = path.join(systemDir, file); let createdAt = "unknown"; let sizeBytes = 0; try { const stat = await createDefaultIO().io.stat(fullPath); createdAt = new Date(stat.mtimeMs).toISOString(); sizeBytes = stat.size; } catch { // File may have been removed between detection and investigation } const isBoilerplate = matchesBoilerplatePattern(file); const inMissionRange = missionTimeRanges.some( (r) => createdAt !== "unknown" && createdAt >= r.start && createdAt <= r.end, ); let likelyOrigin: UntrackedFileReport["likelyOrigin"]; let originHint: string | undefined; if (isBoilerplate && inMissionRange) { const matchingRange = missionTimeRanges.find( (r) => createdAt !== "unknown" && createdAt >= r.start && createdAt <= r.end, ); likelyOrigin = "previous-mission"; originHint = matchingRange ? `file matches boilerplate pattern and was created during a mission starting at ${matchingRange.start}` : undefined; } else if (!isBoilerplate && !inMissionRange) { likelyOrigin = "direct-commit"; originHint = "file does not match boilerplate patterns and was not created during a known mission"; } else { likelyOrigin = "unknown"; } reports.push({ path: file, createdAt, sizeBytes, likelyOrigin, originHint }); } return reports; } export interface OperatorCommitsResult { hasOperatorCommits: boolean; commitCount: number; commits: string[]; } export async function countOperatorCommits( workpieceDir: string, migratedAt: string | null, io: WorkspaceIO = createDefaultIO().io, ): Promise { if (!(await io.exists(path.join(workpieceDir, ".git")))) { return { hasOperatorCommits: false, commitCount: 0, commits: [] }; } // Workpieces materialized from a cache clone carry the whole system history; // the newest "materialize from pin" commit marks where this mission starts. // Commits after it count as operator work unless their subject is // pipeline-authored (Bordbuch:, system-state:, cache-clone:, …). const baselineResult = await io.exec( "git", ["log", "-n", "1", "--format=%H", "--grep=^materialize from pin", "HEAD"], { cwd: workpieceDir }, ); const baselineSha = baselineResult.exitCode === 0 ? baselineResult.stdout.trim() : ""; if (baselineSha) { const sinceResult = await io.exec("git", ["log", "--format=%h %s", `${baselineSha}..HEAD`], { cwd: workpieceDir, }); if (sinceResult.exitCode === 0) { const commits = sinceResult.stdout .split("\n") .map((l) => l.trim()) .filter(Boolean) .filter((line) => { const subject = line.slice(line.indexOf(" ") + 1); return !PIPELINE_SUBJECT_PREFIXES.some((prefix) => subject.startsWith(prefix)); }); return { hasOperatorCommits: commits.length > 0, commitCount: commits.length, commits }; } } const rootResult = await io.exec("git", ["rev-list", "--max-parents=0", "HEAD"], { cwd: workpieceDir, }); if (rootResult.exitCode !== 0) { return { hasOperatorCommits: false, commitCount: 0, commits: [] }; } const rootSha = rootResult.stdout.trim(); const listResult = await io.exec("git", ["rev-list", `${rootSha}..HEAD`, "--oneline"], { cwd: workpieceDir, }); if (listResult.exitCode !== 0) { return { hasOperatorCommits: false, commitCount: 0, commits: [] }; } const output = listResult.stdout.trim(); const allCommits = output ? output.split("\n") : []; const baselineCount = migratedAt ? 1 : 0; const operatorCommits = allCommits.slice(baselineCount); return { hasOperatorCommits: operatorCommits.length > 0, commitCount: operatorCommits.length, commits: operatorCommits, }; } export interface WorkpieceCommitResult { committed: boolean; commitSha: string | null; } async function commitDirIfDirty( dir: string, commitMessage: string, io: WorkspaceIO, ): Promise { const dirtyCheck = await isWorkpieceDirty(dir, io); if (!dirtyCheck.dirty) { return { committed: false, commitSha: null }; } await execOrThrow(io, "git", ["add", "-A"], { cwd: dir }); await execOrThrow(io, "git", ["commit", "--no-verify", "-m", commitMessage], { cwd: dir }); const shaResult = await execOrThrow(io, "git", ["rev-parse", "HEAD"], { cwd: dir }); const commitSha = shaResult.stdout.trim(); return { committed: true, commitSha }; } export async function commitWorkpieceIfDirty( workpieceDir: string, missionId: string, message?: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { // RFC-0878: Defence-in-depth — refuse to commit if .closed sentinel exists. // commitDirIfDirty uses --no-verify which bypasses the pre-commit hook. if (await io.exists(path.join(workpieceDir, ".closed"))) { throw new Error( `[commitWorkpieceIfDirty] workpiece is closed (RFC-0878) — mission '${missionId}' has a .closed sentinel file. No commits are allowed in a closed workpiece.`, ); } const commitMessage = message ?? `workpiece: auto-commit before reconcile ${missionId}`; return commitDirIfDirty(workpieceDir, commitMessage, io); } export async function commitCacheCloneIfDirty( systemDir: string, systemId: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { return commitDirIfDirty( systemDir, `cache-clone: auto-commit generated files before reconcile ${systemId}`, io, ); } /** * Centralized cache-clone git commit helper (RFC-0821). * * Always passes MISSION_GIT_COMMIT=1 to bypass the cache-clone pre-commit hook. * Use this instead of raw execSync("git commit ...") or await gitExec(..., "commit ...") * when committing to a Sternsystem cache clone. * * @param systemDir - cache clone directory path * @param message - commit message * @param options.noEdit - use --no-edit (for merge commits) */ export async function cacheCloneCommit( systemDir: string, message: string, options?: { noEdit?: boolean }, io: WorkspaceIO = createDefaultIO().io, ): Promise { const commitArgs = options?.noEdit ? ["--no-edit"] : ["-m", message]; const result = await io.exec("git", ["commit", ...commitArgs], { cwd: systemDir, env: { MISSION_GIT_COMMIT: "1" }, }); if (result.exitCode !== 0) { throw new Error(`git commit failed (exit ${result.exitCode}): ${result.stderr.trim()}`); } return result.stdout.trim(); } export async function isWorkpieceDirty( workpieceDir: string, io: WorkspaceIO = createDefaultIO().io, ): Promise { if (!(await io.exists(path.join(workpieceDir, ".git")))) { return { dirty: false, fileCount: 0, files: [] }; } const statusResult = await io.exec("git", ["status", "--porcelain"], { cwd: workpieceDir, }); if (statusResult.exitCode !== 0) { return { dirty: false, fileCount: 0, files: [] }; } // Use raw stdout (not trimmed) — leading spaces in " M file.txt" matter for slice(3). const lines = statusResult.stdout.split("\n").filter((l) => l.trim().length > 0); // Filter out files that match a .gitignore pattern even if still tracked. // This prevents mission.close from blocking on generated files like // derived-prices.generated.json that are gitignored but still tracked. const files: string[] = []; for (const line of lines) { const file = line.slice(3).trim(); const ignoreResult = await io.exec("git", ["check-ignore", "--no-index", "--", file], { cwd: workpieceDir, }); // exit 0 → file matches a .gitignore pattern → skip if (ignoreResult.exitCode !== 0) files.push(file); } return { dirty: files.length > 0, fileCount: files.length, files }; } function flagString(input: KernelCommandInput, key: string): string | undefined { const v = input.flags[key]; return typeof v === "string" ? v : undefined; } async function git(cwd: string, args: string[], io: WorkspaceIO): Promise { const result = await io.exec("git", args, { cwd, env: { MISSION_GIT_COMMIT: "1" }, }); if (result.exitCode !== 0) { throw new Error( `git ${args[0] ?? ""} failed (exit ${result.exitCode}): ${result.stderr.trim()}`, ); } return result.stdout.trim(); } export async function runMissionGitCommit( input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const { workspaceRoot } = context; const missionId = flagString(input, "mission"); const message = flagString(input, "message"); const rfcId = flagString(input, "rfc"); const adrId = flagString(input, "adr"); // ADR-0096: --scope-files restricts staging and validation to the listed // workpiece-relative paths — bookkeeping commits (e.g. snapshot bootstrap) // must not sweep or be blocked by unrelated dirty content. const scopeFilesCsv = flagString(input, "scope-files"); const scopeFiles = scopeFilesCsv ? scopeFilesCsv .split(",") .map((f) => f.trim()) .filter(Boolean) : undefined; if (!missionId) throw new Error("[mission.git.commit] --mission is required"); if (!message) throw new Error("[mission.git.commit] --message is required"); const manifest = await readMissionManifest(workspaceRoot, missionId); if (manifest.state !== "open") { throw new Error( `[mission.git.commit] mission '${missionId}' is not open (state: ${manifest.state})`, ); } const missionDir = await resolveMissionDir(workspaceRoot, missionId, context.io); const workpieceDir = path.join(missionDir, "workpiece"); if (!(await context.io.exists(workpieceDir))) { throw new Error( `[mission.git.commit] workpiece not found for mission '${missionId}' — run mission.materialize first`, ); } // RFC-1199: fail closed while materialization is in flight. The sentinel // marks the window precisely (workpiece/.git exists, baseline commit not yet // landed); manifest.materializedAt == null is the fallback for a crashed // run that lost the sentinel or never reached git-init-commit — mission.open // auto-materializes (RFC-0951), so an open mission with materializedAt unset // is always mid-materialization. Runs before the .git check so the named // error is uniform, including the repo-less fallback window. const sentinelPath = path.join(workpieceDir, ".git", MATERIALIZATION_SENTINEL); if ((await context.io.exists(sentinelPath)) || manifest.materializedAt == null) { return { exitCode: 1, summary: `[mission.git.commit] MATERIALIZATION_IN_PROGRESS: workpiece for '${missionId}' ` + `is still being materialized — the pipeline has not created the baseline commit yet`, nextSteps: [ { action: `Wait for mission.materialize to finish; if the operation failed, ` + `run mission.resume --mission ${missionId}`, kind: "required", }, ], }; } if (!(await context.io.exists(path.join(workpieceDir, ".git")))) { throw new Error( `[mission.git.commit] workpiece is not a git repository — run mission.materialize first`, ); } // Stage changes: all when unscoped, only listed paths when --scope-files. if (scopeFiles && scopeFiles.length > 0) { await git(workpieceDir, ["add", "--", ...scopeFiles], context.io); } else { await git(workpieceDir, ["add", "-A"], context.io); } // Check if there are changes to commit. ADR-0104: scoped commits use // `git commit --only`, which takes the named paths' worktree state — so // "changes" means worktree-vs-HEAD inside the scope, not the whole index. let hasChanges: boolean; try { await git( workpieceDir, scopeFiles?.length ? ["diff", "--quiet", "HEAD", "--", ...scopeFiles] : ["diff", "--cached", "--quiet"], context.io, ); hasChanges = false; } catch { hasChanges = true; } if (!hasChanges) { process.stderr.write( `[mission.git.commit] WARNING: No changes to commit for mission '${missionId}'.\n` + ` Brief: "${manifest.brief}"\n` + ` If you expected changes, verify your edits were actually written to disk.\n` + ` This is the most common cause of silent mission loss — work reported as done but never persisted.\n`, ); const data: MissionGitCommitData = { missionId, commitSha: await git(workpieceDir, ["rev-parse", "HEAD"], context.io), message: "(no changes — workpiece is clean)", committedAt: new Date().toISOString(), signed: false, actorId: null, signature: null, noChanges: true, }; return { data, exitCode: 0, summary: `[mission.git.commit] ${missionId} no changes to commit`, }; } // RFC-1095: record CHANGE_SUMMARY items when the commit carries a governance // ID. Runs after staging, before validation and commit; non-fatal — a header // miss never blocks a commit, the next compass.validate surfaces the gap. // ADR-0104: a scoped commit validates exactly the files it will commit — // index residue outside the scope can neither be swept in nor block it. const stagedFiles = ( await git( workpieceDir, scopeFiles?.length ? ["diff", "--cached", "--name-only", "--", ...scopeFiles] : ["diff", "--cached", "--name-only"], context.io, ) ) .split("\n") .filter(Boolean); const governanceId = rfcId ?? adrId; if (governanceId) { try { const recordResult = await runCompassSummaryRecord( { argv: [], flags: { id: governanceId, files: stagedFiles, text: stripConventionalPrefix(message), workpiece: workpieceDir, // RFC-1323: the commit message is injected text — neutralize // comment terminators instead of rejecting (non-fatal path). injected: true, }, }, context, ); const recorded = recordResult.data?.recorded ?? []; if (recorded.length > 0) { // Array form — no shell: filenames with $(...) or quotes cannot inject. await execOrThrow(context.io, "git", ["add", ...recorded], { cwd: workpieceDir, env: { MISSION_GIT_COMMIT: "1" }, }); } for (const reminder of recordResult.data?.keyDecisionsReminders ?? []) { process.stderr.write(`[mission.git.commit] review KEY_DECISIONS in ${reminder}\n`); } // RFC-1323: disclose terminator neutralization on injected message text. for (const file of recordResult.data?.neutralized ?? []) { process.stderr.write( `[mission.git.commit] compass.summary.record neutralized a comment terminator in the injected item for ${file}\n`, ); } } catch (err) { process.stderr.write( `[mission.git.commit] compass.summary.record failed (non-fatal): ${err instanceof Error ? err.message : String(err)}\n`, ); } } // RFC-1095: governance trailer matching ecosystem.commit's X-RFC convention. const commitMessage = governanceId ? `${message}\n\n${rfcId ? `X-RFC: ${rfcId}` : `X-ADR: ${adrId}`}` : message; // RFC-0594 + ADR-0096: run targeted content validators on the staged file // set — scoped commits validate only what they actually commit, so unrelated // dirty files can neither be swept in nor block a bookkeeping commit. const preCommitValidation = await runPreCommitValidation( stagedFiles, manifest.systemId, workspaceRoot, ); if (!preCommitValidation.passed) { const failureLines = preCommitValidation.failures .map((f) => ` ${f.validator}: ${f.message}`) .join("\n"); process.stderr.write(`[mission.git.commit] pre-commit validation failed:\n${failureLines}\n`); return { exitCode: 1, data: { missionId, commitSha: "", message, committedAt: new Date().toISOString(), signed: false, actorId: null, signature: null, preCommitValidation, }, summary: `[mission.git.commit] ${missionId} pre-commit validation failed — fix issues before committing`, nextSteps: [ { action: `Fix the validation failures above, then re-run: pnpm exec werkstatt run mission.git.commit --mission ${missionId} --message ""`, kind: "required", }, ], }; } const signingKey = process.env["WERKSTATT_OPERATOR_SIGNING_KEY"]; const actorId = manifest.openedBy ?? "unknown"; if (signingKey) { const result = await createSignedCommit( workpieceDir, commitMessage, actorId, signingKey, scopeFiles, ); const data: MissionGitCommitData = { missionId, commitSha: result.commitSha, message, committedAt: new Date().toISOString(), signed: result.signed, actorId: result.actorId, signature: result.signature, preCommitValidation, }; return { data, exitCode: 0, summary: result.signed ? `[mission.git.commit] ${missionId} signed commit: ${result.commitSha.slice(0, 12)}` : `[mission.git.commit] ${missionId} committed: ${result.commitSha.slice(0, 12)}`, }; } // No signing key — produce unsigned commit process.stderr.write( `[warn] WERKSTATT_OPERATOR_SIGNING_KEY not set — producing unsigned commit.\n`, ); const commitSha = await git( workpieceDir, scopeFiles?.length ? ["commit", "--only", "-m", commitMessage, "--", ...scopeFiles] : ["commit", "-m", commitMessage], context.io, ); const data: MissionGitCommitData = { missionId, commitSha, message, committedAt: new Date().toISOString(), signed: false, actorId: null, signature: null, preCommitValidation, }; return { data, exitCode: 0, summary: `[mission.git.commit] ${missionId} committed: ${commitSha.slice(0, 12)}`, nextSteps: [ { action: `Validate the mission: pnpm exec werkstatt run mission.validate --mission ${missionId}`, kind: "optional", }, ], }; }