/* RFC-0583: bordbuch.repair — detect orphan-mission-close and unmatched-mission-open violations, insert missing events, recompute hash chain and event-id sequence, write atomically. Do not repair duplicate-mission-id, sensitive-payload, hash-mismatch, or hash-chain-gap violations — these are unrepairable by this command. Do not add bordbuch.repair to any pipeline — it is an on-demand operator command. RFC-0583: initial bordbuch.repair command handler. RFC-1097: step 6 — compass.migrate codemod run Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order. RFC-1097: sweep — werkstatt-engine clean Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics. RFC-1237: resolve dry-run via context.dryRun fallback — consumeCommonFlags strips --dry-run into context.dryRun before dispatch, so a flags-only read was permanently false under werkstatt run (enforced by KERNEL-FLAG-10). RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt */ import path from "node:path"; import type { KernelCommandInput, KernelCommandResult, KernelRuntimeContext, } from "@warpgogol/werkstatt-engine/kernel"; import type { BordbuchEntry } from "@warpgogol/werkstatt-engine/schemas"; import { readBordbuch, validateBordbuch, computeEntryHash, resolveBordbuchPath, commitAndPushBordbuch, } from "./bordbuch-io.ts"; import { resolveCacheClonePath } from "../sternsystem/registry-io.ts"; import { acquireLock, releaseLock, generateOperationId } from "../werkstatt/index.ts"; import { atomicWriteFile } from "../werkstatt/atomic.ts"; export interface BordbuchRepairOrphan { missionId: string; closeEventId: string; closeEventKind: "mission-close" | "mission-abort"; proposedOpen: Omit; metadataSource: "auto-derived" | "operator-supplied"; } export interface BordbuchRepairUnmatched { missionId: string; openEventId: string; proposedClose: Omit; metadataSource: "auto-derived" | "operator-supplied"; } export interface BordbuchRepairResult { systemId: string; insertedEvents: number; recomputedHashes: number; repairedFilePath: string; dryRun: boolean; orphans?: BordbuchRepairOrphan[]; } 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"; } const UNREPAIRABLE_RULES = new Set([ "duplicate-mission-id", "sensitive-payload", "hash-mismatch", "hash-chain-gap", "event-id-gap", ]); function entriesToNdjson(entries: BordbuchEntry[]): string { return entries.map((e) => JSON.stringify(e)).join("\n") + "\n"; } export async function runBordbuchRepair( input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const { workspaceRoot, logger } = context; const systemId = flagString(input, "system") ?? context.site?.name; const dryRun = context.dryRun === true || flagBoolean(input, "dry-run") === true; const missionFilter = flagString(input, "mission"); const metadataStr = flagString(input, "metadata"); if (!systemId) throw new Error("[bordbuch.repair] --system is required"); let operatorMetadata: { occurredAt?: string; summary?: string; actor?: string } | undefined; if (metadataStr) { try { operatorMetadata = JSON.parse(metadataStr); } catch { throw new Error("[bordbuch.repair] --metadata must be valid JSON"); } } const operationId = generateOperationId(); await acquireLock(workspaceRoot, `system:${systemId}`, operationId, "bordbuch.repair", "agent"); await acquireLock(workspaceRoot, `bordbuch:${systemId}`, operationId, "bordbuch.repair", "agent"); try { const entries = await readBordbuch(workspaceRoot, systemId); const { violations } = await validateBordbuch(workspaceRoot, systemId); const filePath = await resolveBordbuchPath(workspaceRoot, systemId); if (violations.length === 0) { logger.info(`[bordbuch.repair] ${systemId}: no repairs needed`); return { data: { systemId, insertedEvents: 0, recomputedHashes: 0, repairedFilePath: filePath, dryRun, }, exitCode: 0, summary: `[bordbuch.repair] ${systemId}: no repairs needed`, }; } const orphanViolations = violations.filter((v) => v.rule === "orphan-mission-close"); const unmatchedViolations = violations.filter((v) => v.rule === "unmatched-mission-open"); const unrepairable = violations.filter((v) => UNREPAIRABLE_RULES.has(v.rule)); if (unrepairable.length > 0) { const messages = unrepairable.map((v) => `[${v.rule}] ${v.message}`).join("; "); throw new Error( `[bordbuch.repair] unrepairable violations found: ${messages}. These require manual intervention.`, ); } if (orphanViolations.length === 0 && unmatchedViolations.length === 0) { throw new Error( `[bordbuch.repair] no repairable violations found. Other violations require manual intervention.`, ); } const orphans: BordbuchRepairOrphan[] = []; const unmatched: BordbuchRepairUnmatched[] = []; const insertions: Array<{ index: number; entry: BordbuchEntry }> = []; const appends: BordbuchEntry[] = []; for (const violation of orphanViolations) { const closeEntry = entries.find((e) => e.id === violation.eventId); if (!closeEntry || !closeEntry.missionId) continue; if (missionFilter && closeEntry.missionId !== missionFilter) continue; const occurredAt = operatorMetadata?.occurredAt ?? closeEntry.occurredAt; const summary = operatorMetadata?.summary ?? "Mission opened (auto-repaired)"; const actor = operatorMetadata?.actor ?? "agent"; const proposedOpen: Omit = { schemaVersion: "1.0.0", systemId, occurredAt, kind: "mission-open", status: "done", missionId: closeEntry.missionId, releaseId: null, actor, summary, metadata: { autoRepaired: true }, }; orphans.push({ missionId: closeEntry.missionId, closeEventId: closeEntry.id, closeEventKind: closeEntry.kind as "mission-close" | "mission-abort", proposedOpen, metadataSource: operatorMetadata ? "operator-supplied" : "auto-derived", }); const closeIndex = entries.indexOf(closeEntry); const openEntry: BordbuchEntry = { ...proposedOpen, id: "event-000000", previousHash: null, hash: "sha256:placeholder", }; insertions.push({ index: closeIndex, entry: openEntry }); } if (missionFilter && orphans.length === 0 && unmatched.length === 0) { throw new Error( `[bordbuch.repair] no repairable violations found for mission '${missionFilter}'.`, ); } // Process unmatched-mission-open: append mission-close events for orphaned opens. for (const violation of unmatchedViolations) { const missionId = violation.message.match(/'([^']+)'/)?.[1] ?? ""; if (!missionId) continue; if (missionFilter && missionId !== missionFilter) continue; const openEntry = entries.find((e) => e.kind === "mission-open" && e.missionId === missionId); const occurredAt = operatorMetadata?.occurredAt ?? new Date().toISOString(); const summary = operatorMetadata?.summary ?? "Mission closed (auto-repaired — unmatched open)"; const actor = operatorMetadata?.actor ?? "agent"; const proposedClose: Omit = { schemaVersion: "1.0.0", systemId, occurredAt, kind: "mission-close", status: "done", missionId, releaseId: null, actor, summary, metadata: { autoRepaired: true }, }; unmatched.push({ missionId, openEventId: openEntry?.id ?? "unknown", proposedClose, metadataSource: operatorMetadata ? "operator-supplied" : "auto-derived", }); appends.push({ ...proposedClose, id: "event-000000", previousHash: null, hash: "sha256:placeholder", }); } const repairedEntries: BordbuchEntry[] = []; let insertIdx = 0; for (let i = 0; i < entries.length; i++) { while (insertIdx < insertions.length && insertions[insertIdx].index === i) { repairedEntries.push(insertions[insertIdx].entry); insertIdx++; } repairedEntries.push(entries[i]); } while (insertIdx < insertions.length) { repairedEntries.push(insertions[insertIdx].entry); insertIdx++; } // Append mission-close events for unmatched-mission-open violations for (const entry of appends) { repairedEntries.push(entry); } let prevHash: string | null = null; for (let i = 0; i < repairedEntries.length; i++) { const id = `event-${String(i + 1).padStart(6, "0")}`; const { hash: _oldHash, id: _oldId, previousHash: _oldPrev, ...rest } = repairedEntries[i]; const entryWithoutHash: Omit = { ...rest, id, previousHash: prevHash, }; const hash = computeEntryHash(entryWithoutHash); repairedEntries[i] = { ...entryWithoutHash, hash }; prevHash = hash; } const recomputedHashes = repairedEntries.length; const originalContent = (await context.io.exists(filePath)) ? await context.io.readFile(filePath) : ""; const repairedNdjson = entriesToNdjson(repairedEntries); await atomicWriteFile(filePath, repairedNdjson); const { violations: postViolations } = await validateBordbuch(workspaceRoot, systemId); if (postViolations.length > 0) { await atomicWriteFile(filePath, originalContent); const messages = postViolations.map((v) => `[${v.rule}] ${v.message}`).join("; "); throw new Error( `[bordbuch.repair] repaired bordbuch still invalid: ${messages}. File not written.`, ); } if (dryRun) { await atomicWriteFile(filePath, originalContent); const totalRepairs = orphans.length + unmatched.length; logger.info(`[bordbuch.repair] ${systemId}: dry-run — ${totalRepairs} repair(s) planned`); return { data: { systemId, insertedEvents: 0, recomputedHashes: 0, repairedFilePath: filePath, dryRun: true, orphans, }, exitCode: 0, summary: `[bordbuch.repair] ${systemId}: dry-run — ${totalRepairs} repair(s) planned`, nextSteps: [ { action: `Apply repairs: pnpm exec werkstatt run bordbuch.repair --site ${systemId}`, kind: "optional", }, ], }; } const cacheCloneDir = await resolveCacheClonePath(workspaceRoot, systemId); // Clear stale currentMission in system-state.yaml for repaired unmatched missions. // This prevents "ghost mission" errors where system-state.yaml references a mission // that was closed via bordbuch.repair but the state file was not updated. if (unmatched.length > 0) { try { const { readSystemState, writeSystemState } = await import("../sternsystem/registry-io.ts"); const state = await readSystemState(workspaceRoot, systemId); const repairedMissionIds = new Set(unmatched.map((u) => u.missionId)); if (state.currentMission && repairedMissionIds.has(state.currentMission)) { state.currentMission = ""; await writeSystemState(workspaceRoot, systemId, state); logger.info( `[bordbuch.repair] ${systemId}: cleared stale currentMission in system-state.yaml`, ); } } catch { // System state not available — non-fatal, bordbuch repair still succeeds } } if (await context.io.exists(path.join(cacheCloneDir, ".git"))) { const totalRepairs = orphans.length + unmatched.length; const commitResult = await commitAndPushBordbuch( cacheCloneDir, `bordbuch.repair: ${systemId} — auto-repair ${totalRepairs} violation(s) (${orphans.length} orphan-close, ${unmatched.length} unmatched-open)`, ); if (!commitResult.commitSha || !commitResult.pushed) { throw new Error( `[bordbuch.repair] ${systemId}: bordbuch file written but ${!commitResult.commitSha ? "commit" : "push"} failed — ` + `cache clone may have rebase conflicts. Error: ${commitResult.error ?? "unknown"}. ` + `Check git state in the cache clone and re-run bordbuch.repair.`, ); } } const totalInserted = orphans.length + unmatched.length; logger.success( `[bordbuch.repair] ${systemId}: inserted ${orphans.length} mission-open + ${unmatched.length} mission-close event(s), recomputed ${recomputedHashes} hashes`, ); return { data: { systemId, insertedEvents: totalInserted, recomputedHashes, repairedFilePath: filePath, dryRun: false, }, exitCode: 0, summary: `[bordbuch.repair] ${systemId}: inserted ${orphans.length} mission-open + ${unmatched.length} mission-close event(s), recomputed ${recomputedHashes} hashes`, nextSteps: [], }; } finally { await releaseLock(workspaceRoot, `bordbuch:${systemId}`); await releaseLock(workspaceRoot, `system:${systemId}`); } }