/*
RFC-0555: workpiece.write — write a file to a mission workpiece with DNA-22
path validation. Content is read from stdin (not a CLI flag) to avoid shell
argument length limits. Does NOT auto-commit — LLMs must call
mission.git.commit separately to group multiple writes into one commit.
- Does not auto-commit — mission.git.commit is the atomicity boundary.
- Does not handle system.md partial field edits — future enhancement.
- RFC-0555: initial workpiece.write command handler.
- RFC-1149: migrate ambient filesystem and process IO to the WorkspaceIO port across engine and shared
- RFC-1249: wire werkstatt.commands.validate into packages.check + retire CMD-OUTPUT debt
- RFC-1254: drop unused io-migration leftovers
- RFC-1262
*/
import path from "node:path";
import type {
KernelCommandInput,
KernelCommandResult,
KernelRuntimeContext,
} from "@warpgogol/werkstatt-engine/kernel";
import {
generatedWriteGuard,
loadGeneratedPathRegistry,
} from "@warpgogol/werkstatt-shared/kernel/generated-paths";
import { readMissionManifest, resolveMissionDir } from "../mission/mission-io.ts";
import { isClientEditable } from "./dna-22-checker.ts";
export interface WorkpieceWriteResult {
path: string;
bytesWritten: number;
}
function flagString(input: KernelCommandInput, key: string): string | undefined {
const v = input.flags[key];
return typeof v === "string" ? v : undefined;
}
async function readStdin(): Promise {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString("utf8");
}
export async function runWorkpieceWrite(
input: KernelCommandInput,
context: KernelRuntimeContext,
): Promise> {
const missionId = flagString(input, "mission");
const relativePath = flagString(input, "path");
if (!missionId) {
throw new Error("[workpiece.write] --mission flag is required");
}
if (!relativePath) {
throw new Error("[workpiece.write] --path flag is required");
}
const { workspaceRoot, logger } = context;
const manifestPath = path.join(
await resolveMissionDir(workspaceRoot, missionId, context.io),
"mission.yaml",
);
if (!(await context.io.exists(manifestPath))) {
throw new Error(`[workpiece.write] Mission '${missionId}' is not open or does not exist`);
}
const manifest = await readMissionManifest(workspaceRoot, missionId);
if (manifest.state !== "open") {
throw new Error(
`[workpiece.write] Mission '${missionId}' is not open (state: ${manifest.state})`,
);
}
const workpieceDir = path.join(
await resolveMissionDir(workspaceRoot, missionId, context.io),
"workpiece",
);
if (!(await context.io.exists(workpieceDir))) {
throw new Error(
`[workpiece.write] Workpiece for mission '${missionId}' not found. Run mission.materialize first.`,
);
}
const normalizedPath = relativePath.replace(/\\/g, "/").replace(/^\.\//, "");
const resolvedPath = path.resolve(workpieceDir, normalizedPath);
if (!resolvedPath.startsWith(workpieceDir)) {
throw new Error("[workpiece.write] Path traversal detected");
}
const allowed = await isClientEditable(workpieceDir, normalizedPath);
if (!allowed) {
throw new Error(`Path '${normalizedPath}' is outside client-editable surface (DNA-22)`);
}
// RFC-1262 GENW-01: generated-path write guard. Fail-open when the registry
// is absent (it is materialized by generated.paths.generate in build.prepare;
// a workpiece that never ran prepare emits GENW-02 advisory, not a block).
const generatedPaths = await loadGeneratedPathRegistry(workpieceDir, context.io);
if (!generatedPaths) {
logger.warn(
`[workpiece.write] GENW-02: generated-path registry absent in workpiece — guard inactive; run generated.paths.generate`,
);
} else {
const verdict = generatedWriteGuard(generatedPaths, normalizedPath);
if (!verdict.allowed) {
throw new Error(
`[workpiece.write] GENW-01: path '${normalizedPath}' is generated by '${verdict.owner}' — ${verdict.hint}`,
);
}
}
const content = await readStdin();
const parentDir = path.dirname(resolvedPath);
if (!(await context.io.exists(parentDir))) {
await context.io.mkdir(parentDir);
}
await context.io.writeFile(resolvedPath, content);
const bytesWritten = Buffer.byteLength(content, "utf8");
logger.info(`[workpiece.write] ${missionId}: wrote ${normalizedPath} (${bytesWritten} bytes)`);
return {
data: { path: normalizedPath, bytesWritten },
exitCode: 0,
summary: "[workpiece.write] Done",
};
}