/*
RFC-0221: `handoff.absorb` — version-aware ingest. Builds the catch-up report (version
compare + migrator chain + capability diff + tiered report), refuses downgrades, then materializes:
apply migrators → inject the authored set into apps/<target> → delegate regeneration + validation
to build.prepare/build.check. Red-tier requires --force; --report-only stops before any write.
- Do not git-merge the bundle. Do not round-trip generated files as source of truth.
- RFC-0221: report-only path + downgrade gate.
- RFC-0221: materialization write-path — inject (migrators applied) + delegated regen; --as/--regen/--force.
- 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 { buildCatchupReport } from "./absorb-report.ts";
import {
readGoldenPack,
readLock,
readManifest,
readRegistryView,
resolveCurrentEcosystem,
resolvePackagesHash,
resolvePlatformSemanticHash,
} from "./bundle-io.ts";
import { injectAuthoredSet, loadAuthoredSet, runRegeneration } from "./materialize.ts";
import { buildValidationPack, diffValidationPacks } from "./validation-pack.ts";
import { reportDerivedEdits } from "./derived-edits.ts";
import type { CatchupReport } from "./types.ts";
import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
import { failResult } from "@warpgogol/werkstatt-shared/checks/result-helpers";
export interface HandoffAbsorbData {
bundleDir: string;
report: CatchupReport;
materialized: boolean;
targetApp?: string;
filesInjected?: number;
regenerated?: boolean;
validationClean?: boolean;
}
async function pathExists(p: string, io: WorkspaceIO = createDefaultIO().io): Promise {
try {
await io.stat(p);
return true;
} catch {
return false;
}
}
function resolveBundleDir(input: KernelCommandInput, workspaceRoot: string): string {
const flag = input.flags["bundle"];
const raw = typeof flag === "string" ? flag : undefined;
if (!raw) throw new Error("[handoff.absorb] requires --bundle ");
return path.resolve(workspaceRoot, raw);
}
function printReport(report: CatchupReport, logger: KernelRuntimeContext["logger"]): void {
logger.section(`[handoff.absorb] catch-up report — ${report.app}`);
logger.info(report.comparison.message);
logger.info(`Overall tier: ${report.overallTier.toUpperCase()}`);
if (report.migratorChain.length > 0) {
logger.info(`Migrator chain (${report.migratorChain.length}):`);
for (const m of report.migratorChain) {
logger.info(` ${m.id}: ${m.description}`);
}
}
for (const d of report.capabilityDiff) {
if (d.change === "unchanged") continue;
const mark = d.tier === "red" ? "✗" : d.tier === "yellow" ? "~" : "+";
logger.info(` ${mark} ${d.id}: ${d.note}`);
}
}
export async function runHandoffAbsorb(
input: KernelCommandInput,
context: KernelRuntimeContext,
): Promise> {
const { workspaceRoot, logger } = context;
const bundleDir = resolveBundleDir(input, workspaceRoot);
const reportOnly = input.flags["report-only"] === true;
const regen = input.flags["regen"] === true;
const force = input.flags["force"] === true;
const asFlag = input.flags["as"];
const lock = await readLock(bundleDir);
const [{ version: currentVersion }, currentPackagesHash, currentPlatformSemanticHash, registry] =
await Promise.all([
resolveCurrentEcosystem(workspaceRoot),
resolvePackagesHash(workspaceRoot),
resolvePlatformSemanticHash(workspaceRoot),
readRegistryView(workspaceRoot),
]);
const report = buildCatchupReport({
lock,
currentVersion,
currentPackagesHash,
currentPlatformSemanticHash,
registry,
});
printReport(report, logger);
// Downgrade gate (RFC-0221 §4.1): never absorb a bundle from a newer ecosystem.
if (report.comparison.verdict === "refuse-downgrade") {
return {
exitCode: 1,
summary: `[handoff.absorb] refused — ${report.comparison.message}`,
nextSteps: [
{
action: `[handoff.absorb] refused — ${report.comparison.message}`,
kind: "required" as const,
},
],
data: { bundleDir, report, materialized: false },
};
}
if (reportOnly) {
return {
data: { bundleDir, report, materialized: false },
exitCode: 0,
summary: `[handoff.absorb] report-only — overall tier ${report.overallTier}`,
};
}
// Red tier = manual migration decision (RFC-0221 §6). Do not auto-materialize without --force.
if (report.overallTier === "red" && !force) {
logger.error(
"[handoff.absorb] overall tier is RED — manual migration decisions required (see report above). " +
"Resolve them, or pass --force to materialize anyway.",
);
return {
...failResult("handoff.absorb", [
"[handoff.absorb] refused — red-tier decisions unresolved (use --force to override)",
]),
data: { bundleDir, report, materialized: false },
};
}
// ── Materialize: apply migrators → inject authored set → (delegate) regenerate ──
const targetApp = typeof asFlag === "string" ? asFlag : lock.app;
const targetDir = path.resolve(workspaceRoot, "apps", targetApp);
const isNewApp = !(await pathExists(targetDir));
const manifest = await readManifest(bundleDir);
// Derived-edit detection (RFC-0221 §7): if any derived entry has a hash mismatch, raise it
// as a red-tier decision record so hand edits are not lost.
const derivedEdits = await reportDerivedEdits(bundleDir, manifest);
if (derivedEdits.length > 0) {
logger.warn("[handoff.absorb] derived-edit decision record:");
for (const edit of derivedEdits) {
logger.warn(
` ${edit.path}: hash mismatch (edited after packing). ` +
`Expected ${edit.expectedHash.slice(0, 12)}…, got ${edit.actualHash ? edit.actualHash.slice(0, 12) + "…" : "MISSING"}.`,
);
}
logger.error(
"[handoff.absorb] regeneration will overwrite these edits. " +
"Review them, resolve, or pass --force to regenerate anyway.",
);
return {
...failResult("handoff.absorb", [
`[handoff.absorb] refused — ${derivedEdits.length} derived-file edit(s) detected (use --force to overwrite)`,
]),
data: { bundleDir, report, materialized: false },
};
}
const loaded = await loadAuthoredSet(bundleDir, manifest);
await context.io.mkdir(targetDir);
const filesInjected = await injectAuthoredSet(targetDir, loaded);
logger.section(`[handoff.absorb] materialize → apps/${targetApp}${isNewApp ? " (new app)" : ""}`);
logger.info(
`Injected ${filesInjected} authored file(s) (migrators applied: ${report.migratorChain.length})`,
);
if (!regen) {
const steps = isNewApp
? `pnpm install && site-kernel pipeline build.prepare --site ${targetApp} && site-kernel pipeline build.check --site ${targetApp}`
: `site-kernel pipeline build.prepare --site ${targetApp}`;
logger.info(`[handoff.absorb] regeneration delegated — run: ${steps}`);
return {
data: { bundleDir, report, materialized: true, targetApp, filesInjected, regenerated: false },
exitCode: 0,
summary: `[handoff.absorb] materialized apps/${targetApp} (${filesInjected} files); regenerate with build.prepare${isNewApp ? " (after pnpm install)" : ""}`,
};
}
// A brand-new app is not yet a resolved workspace member — install before building.
const { installCode, prepareCode, checkCode } = await runRegeneration(workspaceRoot, targetApp, {
install: isNewApp,
});
const regenerated = installCode === 0 && prepareCode === 0 && checkCode === 0;
if (!regenerated) {
return {
...failResult("handoff.absorb", [
`[handoff.absorb] materialized apps/${targetApp}, but regeneration/validation failed (install=${installCode}, prepare=${prepareCode}, check=${checkCode})`,
]),
data: { bundleDir, report, materialized: true, targetApp, filesInjected, regenerated: false },
};
}
logger.success(`[handoff.absorb] absorbed apps/${targetApp} — regenerated + validated`);
// Golden-pack diff: confirm the rebuilt site preserved the bundle's significant properties.
const validationClean = await reportGoldenDiff(bundleDir, targetDir, logger);
return {
data: {
bundleDir,
report,
materialized: true,
targetApp,
filesInjected,
regenerated: true,
validationClean: validationClean ?? undefined,
},
exitCode: 0,
summary: `[handoff.absorb] absorbed apps/${targetApp} — overall tier ${report.overallTier}${
validationClean === false ? " (golden-pack drift — see report)" : ""
}`,
nextSteps: [
{
action: `Validate the site: pnpm exec werkstatt run mission.validate --mission `,
kind: "optional",
},
],
};
}
/**
* Diff the rebuilt site's significant properties against the bundle's golden pack.
* Returns true (clean), false (drift), or null (skipped — no golden pack or no build output).
*/
async function reportGoldenDiff(
bundleDir: string,
targetDir: string,
logger: KernelRuntimeContext["logger"],
): Promise {
const golden = await readGoldenPack(bundleDir);
if (!golden || golden.empty) {
logger.info("[handoff.absorb] no golden validation pack in bundle — skipping golden-pack diff");
return null;
}
const fresh = await buildValidationPack(targetDir);
if (fresh.empty) {
logger.info("[handoff.absorb] no build output to validate — skipping golden-pack diff");
return null;
}
const diff = diffValidationPacks(golden, fresh);
if (diff.clean) {
logger.success("[handoff.absorb] golden-pack diff clean — significant properties preserved");
return true;
}
logger.warn("[handoff.absorb] golden-pack drift:");
for (const r of diff.routesRemoved) logger.warn(` - route removed: ${r}`);
for (const r of diff.routesAdded) logger.warn(` + route added: ${r}`);
if (diff.sitemapChanged) logger.warn(" ~ sitemap.xml changed");
for (const l of diff.llmsChanged) logger.warn(` ~ ${l} changed`);
for (const s of diff.scoreDeltas) logger.warn(` ~ ${s.key}: ${s.from} → ${s.to}`);
return false;
}