/* RFC-1263: mission.content-edit — the content fast-lane composite. For a mission whose touched set resolves entirely inside the editable content surface it runs the scoped gate set (content.patch pipeline + author validators, content.regression.check, advisory deploy-preflight), commits via mission.git.commit, records the green gate set on the mission manifest, reconciles, and — once the site's contentEditLane is "active" — closes. leitstand.ship's full-validate gate is untouched: ship remains the only deploy path. Do not write evidence/validation-report.json — the fast-lane record lives in the mission manifest's fastLane field; scoped coverage must never read as full coverage downstream. Do not implement gate logic — the composite delegates every step to the underlying commands via executeKernelCommand. RFC-1263: initial composite — eligibility -> content.patch -> CREG -> advisory deploy-preflight -> git.commit -> fastLane record -> reconcile -> close when contentEditLane=active (advisory rollout stops before close). RFC-1263 */ import path from "node:path"; import { parse as parseYaml } from "yaml"; import type { KernelCommandInput, KernelCommandResult, KernelRuntimeContext, } from "@warpgogol/werkstatt-engine/kernel"; import { executeKernelCommand } from "@warpgogol/werkstatt-engine/kernel"; import { readMissionManifest, writeMissionManifest, resolveMissionDir } from "./mission-io.ts"; import { evaluateContentEditEligibility, type ContentEditOpRef, type EligibilityResult, } from "./content-edit-eligibility.ts"; import { readSystemState, resolveCacheClonePath } from "../sternsystem/registry-io.ts"; import { resolveActor } from "./actor-identity.ts"; export interface ContentEditGate { gate: string; status: "pass" | "fail" | "advisory"; violations?: number; } export interface ContentEditResult { command: "mission.content-edit"; status: "closed" | "fast-lane-ready" | "gate-failed" | "rejected" | "dry-run"; mission: string | null; /** "fast" on admitted runs; "full" on rejection (AC-2). */ lane: "fast" | "full"; eligibility: EligibilityResult; gates: ContentEditGate[]; commit?: string; reconciled?: boolean; closed?: boolean; durationMs: number; } interface StepResult { exitCode: number; data?: Record; summary?: string; } async function step( workspaceRoot: string, commandName: string, argv: string[], ): Promise { const result = (await executeKernelCommand({ workspaceRoot, commandName, argv, outputFormat: "json", })) as StepResult; return { exitCode: result.exitCode ?? 0, data: result.data, summary: result.summary }; } 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"; } // ─── touched-set resolution ────────────────────────────────────────────────── /** Split `/[#]` — mirrors content-ops parseSelector. */ function splitSelector( raw: string, ): { collection: string; file: string; fieldPath: string } | null { const hash = raw.indexOf("#"); const address = hash === -1 ? raw : raw.slice(0, hash); const fieldPath = hash === -1 ? "" : raw.slice(hash + 1); const slash = address.indexOf("/"); if (slash === -1) { return address === "system" ? { collection: "system", file: "system", fieldPath } : null; } const collection = address.slice(0, slash); const file = address.slice(slash + 1); if (!collection || !file) return null; return { collection, file, fieldPath }; } /** Parses the ops file (list or {ops:[…]}) into selector refs for eligibility. */ async function opsRefsFromFile( opsFile: string, io: KernelRuntimeContext["io"], ): Promise<{ ops: ContentEditOpRef[]; error?: string }> { if (!(await io.exists(opsFile))) return { ops: [], error: `--ops-file not found: ${opsFile}` }; const parsed = parseYaml(await io.readFile(opsFile)) as Array> | { ops?: Array> } | null; const entries = Array.isArray(parsed) ? parsed : parsed?.ops; if (!Array.isArray(entries) || entries.length === 0) { return { ops: [], error: `${opsFile}: expected a non-empty ops list ([{op, selector, ...}])` }; } const ops: ContentEditOpRef[] = []; for (const [i, raw] of entries.entries()) { const kind = (raw.kind ?? raw.op) as string | undefined; const selector = typeof raw.selector === "string" ? raw.selector : undefined; if (!kind || !selector) { return { ops: [], error: `ops[${i}]: needs selector + kind` }; } const parts = splitSelector(selector); if (!parts) { return { ops: [], error: `ops[${i}]: malformed selector "${selector}"` }; } ops.push({ kind, ...parts, ...(typeof raw.fieldClass === "string" ? { fieldClass: raw.fieldClass } : {}), }); } return { ops }; } /** Workpiece `git status --porcelain` → changed workpiece-relative paths. */ async function workpieceTouchedFiles( workpieceDir: string, io: KernelRuntimeContext["io"], ): Promise { const res = await io.exec("git", ["status", "--porcelain=v1", "-z"], { cwd: workpieceDir }); if (res.exitCode !== 0) return []; return res.stdout .split("\0") .map((line) => line.trim()) .filter(Boolean) .map((line) => { const p = line.slice(3); const arrow = p.indexOf(" -> "); return (arrow === -1 ? p : p.slice(arrow + 4)).replace(/^"|"$/g, ""); }); } // ─── composite ─────────────────────────────────────────────────────────────── export async function runMissionContentEdit( input: KernelCommandInput, context: KernelRuntimeContext, ): Promise> { const { workspaceRoot, logger, io } = context; const startedAt = Date.now(); const siteFlag = flagString(input, "site"); const missionFlag = flagString(input, "mission"); const brief = flagString(input, "brief"); const opsFile = flagString(input, "ops-file"); const message = flagString(input, "message"); const actor = resolveActor(input); const dryRun = flagBoolean(input, "dry-run") || context.dryRun === true; const result = ( partial: Omit, exitCode: number, summary: string, ): KernelCommandResult => ({ data: { command: "mission.content-edit", durationMs: Date.now() - startedAt, ...partial }, exitCode, summary, }); if (Boolean(missionFlag) === Boolean(siteFlag)) { return result( { status: "rejected", mission: missionFlag ?? null, lane: "full", eligibility: { eligible: false, reasons: [] }, gates: [], }, 1, "[mission.content-edit] exactly one of --mission or --site is required", ); } // ── resolve mission / site surface ───────────────────────────────────────── let missionId = missionFlag ?? null; let systemId = siteFlag ?? ""; let siteDir: string; if (missionId) { const manifest = await readMissionManifest(workspaceRoot, missionId); if (manifest.state !== "open") { return result( { status: "rejected", mission: missionId, lane: "full", eligibility: { eligible: false, reasons: [`mission '${missionId}' is not open (state: ${manifest.state})`], }, gates: [], }, 1, `[mission.content-edit] mission '${missionId}' is not open`, ); } systemId = manifest.systemId; // content.patch / content.regression.check resolve --site to the *current* // mission workpiece — refuse early when the passed mission is not it, // otherwise ops could land in a different mission's workpiece. const sysState = await readSystemState(workspaceRoot, systemId, io); if (sysState.currentMission !== missionId) { return result( { status: "rejected", mission: missionId, lane: "full", eligibility: { eligible: false, reasons: [ `mission '${missionId}' is not the site's current mission ` + `(system-state.yaml currentMission: ${sysState.currentMission ?? "none"})`, ], }, gates: [], }, 1, `[mission.content-edit] '${missionId}' is not currentMission for ${systemId}`, ); } siteDir = path.join(await resolveMissionDir(workspaceRoot, missionId, io), "workpiece"); if (!(await io.exists(siteDir))) { return result( { status: "rejected", mission: missionId, lane: "full", eligibility: { eligible: false, reasons: [`workpiece missing — run mission.materialize first`], }, gates: [], }, 1, `[mission.content-edit] no workpiece for mission '${missionId}'`, ); } } else { if (!brief) { return result( { status: "rejected", mission: null, lane: "full", eligibility: { eligible: false, reasons: ["--brief is required with --site"] }, gates: [], }, 1, "[mission.content-edit] --brief is required with --site", ); } siteDir = resolveCacheClonePath(workspaceRoot, systemId); } // ── touched set ──────────────────────────────────────────────────────────── let opsRefs: ContentEditOpRef[] = []; if (opsFile) { const parsed = await opsRefsFromFile(opsFile, io); if (parsed.error) { return result( { status: "rejected", mission: missionId, lane: "full", eligibility: { eligible: false, reasons: [parsed.error] }, gates: [], }, 1, `[mission.content-edit] ${parsed.error}`, ); } opsRefs = parsed.ops; } const stagedFiles = missionId ? await workpieceTouchedFiles(siteDir, io) : []; if (opsRefs.length === 0 && stagedFiles.length === 0) { return result( { status: "rejected", mission: missionId, lane: "full", eligibility: { eligible: false, reasons: ["empty touched set — nothing to edit (pass --ops-file or stage changes)"], }, gates: [], }, 1, "[mission.content-edit] empty touched set", ); } // ── eligibility (live manifest, evaluated per invocation) ────────────────── const eligibility = await evaluateContentEditEligibility({ siteDir, files: stagedFiles, ops: opsRefs, io, }); if (!eligibility.eligible) { return result( { status: "rejected", mission: missionId, lane: "full", eligibility, gates: [], }, 1, `[mission.content-edit] lane: full — ${eligibility.reasons.join("; ")}`, ); } // ── dry-run: eligibility + read-only gates, zero writes ──────────────────── if (dryRun) { const gates: ContentEditGate[] = [{ gate: "eligibility", status: "pass", violations: 0 }]; if (missionId) { const creg = await step(workspaceRoot, "content.regression.check", [ `--site=${systemId}`, "--dry-run", ]); gates.push({ gate: "content-regression", status: creg.exitCode === 0 ? "pass" : "fail", violations: creg.exitCode === 0 ? 0 : 1, }); } return result( { status: "dry-run", mission: missionId, lane: "fast", eligibility, gates }, gates.some((g) => g.status === "fail") ? 1 : 0, `[mission.content-edit] dry-run — eligible; gates reported, nothing written`, ); } // ── open (site path) ─────────────────────────────────────────────────────── const gates: ContentEditGate[] = []; if (!missionId) { const open = await step(workspaceRoot, "mission.open", [ `--system=${systemId}`, `--brief=${brief}`, `--actor=${actor}`, "--lane=content", ]); if (open.exitCode !== 0 || typeof open.data?.missionId !== "string") { return result( { status: "gate-failed", mission: null, lane: "fast", eligibility, gates: [{ gate: "mission.open", status: "fail" }], }, 1, `[mission.content-edit] mission.open failed: ${open.summary ?? "no mission id"}`, ); } missionId = open.data.missionId; siteDir = path.join(await resolveMissionDir(workspaceRoot, missionId, io), "workpiece"); logger.info(` [content-edit] opened ${missionId} (lane: content)`); } const fail = (gate: string, summary: string) => result( { status: "gate-failed", mission: missionId, lane: "fast", eligibility, gates }, 1, `[mission.content-edit] ${gate} failed — ${summary} (mission ${missionId} stays open)`, ); // ── gates 1–2: ops pipeline (per-op validation + author validators) ──────── if (opsFile) { const patch = await step(workspaceRoot, "content.patch", [ `--site=${systemId}`, `--ops-file=${opsFile}`, ]); const patchData = patch.data as | { status?: string; diagnostics?: Array<{ severity?: string }>; validators?: Array<{ command: string; status: string; violations: number }>; } | undefined; const patchViolations = (patchData?.diagnostics ?? []).filter( (d) => d.severity === "error", ).length; gates.push({ gate: "content-ops", status: patch.exitCode === 0 ? "pass" : "fail", violations: patchViolations, }); if (patch.exitCode !== 0) { return fail("content-ops", patch.summary ?? "content.patch reported blocking diagnostics"); } const validatorViolations = (patchData?.validators ?? []).reduce( (sum, v) => sum + (v.violations ?? 0), 0, ); gates.push({ gate: "author-validators", status: "pass", violations: validatorViolations }); } // ── gate 3: content regression vs golden snapshot (blocking, AC-3) ───────── const creg = await step(workspaceRoot, "content.regression.check", [`--site=${systemId}`]); const cregData = creg.data as { diagnostics?: Array<{ severity?: string; ruleId?: string }> } | undefined; const cregViolations = (cregData?.diagnostics ?? []).filter((d) => d.severity === "error").length; gates.push({ gate: "content-regression", status: creg.exitCode === 0 ? "pass" : "fail", violations: cregViolations || (creg.exitCode === 0 ? 0 : 1), }); if (creg.exitCode !== 0) { return fail( "content-regression", `${creg.summary ?? "drift detected"} — resolved-content drift requires the RFC-0734 review disposition (review.yaml); the fast lane does not auto-accept`, ); } // ── gate 4: deploy-preflight (advisory by contract) ──────────────────────── const preflight = await step(workspaceRoot, "mission.deploy-preflight", [ `--mission=${missionId}`, ]); const preflightViolations = typeof (preflight.data as { totalViolations?: number } | undefined)?.totalViolations === "number" ? (preflight.data as { totalViolations: number }).totalViolations : preflight.exitCode === 0 ? 0 : 1; gates.push({ gate: "deploy-preflight", status: preflightViolations === 0 ? "pass" : "advisory", violations: preflightViolations, }); // ── gate 5: commit (its own pre-commit gates included) ───────────────────── const commit = await step(workspaceRoot, "mission.git.commit", [ `--mission=${missionId}`, `--message=${message ?? brief ?? `content-edit ${missionId}`}`, `--actor=${actor}`, ]); gates.push({ gate: "mission.git.commit", status: commit.exitCode === 0 ? "pass" : "fail", }); if (commit.exitCode !== 0) { return fail("mission.git.commit", commit.summary ?? "commit failed"); } const commitSha = typeof commit.data?.commitSha === "string" ? commit.data.commitSha : undefined; // ── persist the fast-lane gate record (gates 1–5) before reconcile ───────── const manifest = await readMissionManifest(workspaceRoot, missionId); manifest.fastLane = { green: true, gates: gates.map((g) => ({ gate: g.gate, status: g.status, violations: g.violations })), recordedAt: new Date().toISOString(), }; await writeMissionManifest(workspaceRoot, manifest, io); // ── gate 6: reconcile (accepts the fast-lane record via the shared predicate) const reconcile = await step(workspaceRoot, "mission.reconcile", [ `--mission=${missionId}`, `--actor=${actor}`, ]); gates.push({ gate: "mission.reconcile", status: reconcile.exitCode === 0 ? "pass" : "fail", }); if (reconcile.exitCode !== 0) { return fail("mission.reconcile", reconcile.summary ?? "reconcile failed"); } // ── gate 7: close — only once the site activated the lane (rollout step 2–3) const state = await readSystemState(workspaceRoot, systemId, io); const laneState = state.contentEditLane ?? "advisory"; if (laneState !== "active") { // Refresh the record so audit shows the full gate set including reconcile. manifest.fastLane = { green: true, gates: gates.map((g) => ({ gate: g.gate, status: g.status, violations: g.violations })), recordedAt: new Date().toISOString(), }; await writeMissionManifest(workspaceRoot, manifest, io); return result( { status: "fast-lane-ready", mission: missionId, lane: "fast", eligibility, gates, commit: commitSha, reconciled: true, closed: false, }, 0, `[mission.content-edit] fast-lane gates green for ${missionId} — close via standard flow ` + `(system-state.yaml contentEditLane: ${laneState}; set to "active" to close fast-lane missions)`, ); } const close = await step(workspaceRoot, "mission.close", [ `--mission=${missionId}`, `--actor=${actor}`, ]); gates.push({ gate: "mission.close", status: close.exitCode === 0 ? "pass" : "fail" }); if (close.exitCode !== 0) { return fail("mission.close", close.summary ?? "close failed"); } return result( { status: "closed", mission: missionId, lane: "fast", eligibility, gates, commit: commitSha, reconciled: true, closed: true, }, 0, `[mission.content-edit] ${missionId} closed on the content fast lane ` + `(${gates.filter((g) => g.status === "pass").length}/${gates.length} gates green)`, ); }