/*
RFC-1281: buildIntakeBundle — materialize a complete, valid
werkstatt:intake/v1 hand-over bundle through WorkspaceIO for tests and the
committed fixtures/valid-minimal directory. Hashes are computed (not
hard-coded): file shas land in pbp evidence items, asset file/sha256,
evidence-ledger hash, provenance.contractSnapshot and MANIFEST.sha256.
- Do not ship production logic — this is a test fixture builder only.
- RFC-1328: fixture conformant to AMD-005 — uz-Latn statementByLocale, localeNeutral entities, closed palette (surface).
- RFC-1281: created — buildIntakeBundle fixture builder with mutate/manifestTransform hooks.
- RFC-1281: intake contract and onboarding.intake.validate
*/
import { createHash } from "node:crypto";
import { join } from "node:path";
import type { WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
import { resolveIntakeExportSha256 } from "./intake-validate.ts";
/** Sentinel replaced after `mutate` — lets tests inject their own bad values. */
const AUTO = "__AUTO__";
export interface IntakeBundleSeed {
/** intake.json object — contractSnapshot/evidenceLedger carry AUTO sentinels. */
envelope: Record;
/** Bundle-relative files except intake.json + MANIFEST.sha256. */
files: Map;
}
export interface BuildIntakeBundleOptions {
/** sha256: to stamp into provenance.contractSnapshot; defaults to the real export. */
exportSha256?: string;
/** Mutation point before hashing+manifest — break exactly one rule per test. */
mutate?: (seed: IntakeBundleSeed) => void;
/** Post-hash hook on the MANIFEST.sha256 text (INTAKE-01 tampering). */
manifestTransform?: (manifest: string) => string;
}
export interface BuiltIntakeBundle {
dir: string;
/** sha256 of sources/.md + assets/.svg (bare hex). */
sourceSha256: string;
assetSha256: string;
ledgerSha256: string;
}
const sha256Hex = (bytes: Uint8Array): string => createHash("sha256").update(bytes).digest("hex");
const SOURCE_MD = `# Client interview — 2026-10-10
Client: "Европейское качество — доступное каждому" — это наш слоган.
Никаких обещаний без источника — каждое утверждение на сайте должно
ссылаться на подтверждённый факт.
`;
const ASSET_SVG = `\n`;
function defaultEnvelope(sourceSha: string, assetSha: string): Record {
return {
schema: "werkstatt:intake/v1",
intakeId: "int_019a4f2c-7a01-7f30-8c1e-9f2e4a6b8d01",
systemId: "dp7",
producedAt: "2026-10-15T10:00:00Z",
classification: {
locales: [
{ tag: "ru", role: "primary", sourceLanguageBy: "client", reviewer: "studio" },
{
tag: "uz-Latn",
role: "secondary",
sourceLanguageBy: "translation",
reviewer: "unassigned",
},
],
jurisdiction: "DE",
conversionModel: "inquiry",
profile: "standard",
pageSetIntent: [{ intent: "home", evidenceRefs: ["evidence-source/src-01"] }],
},
pbp: [
{
schema: "pbp/business@1",
id: "business/dp7",
type: "business",
status: "published",
name: "DP7",
localeNeutral: true,
legalIdentityRef: { ref: "pbp/legal-identity@1:legal-identity/dp7" },
},
{
schema: "pbp/legal-identity@1",
id: "legal-identity/dp7",
type: "legal-identity",
status: "published",
legalName: "DP7 Digitalprojekt GmbH",
localeNeutral: true,
},
{
schema: "pbp/evidence-source@1",
id: "evidence-source/src-01",
type: "evidence-source",
status: "published",
name: "Client interview 2026-10-10",
kind: "client-statement",
authority: { kind: "client" },
slug: "client-interview-2026-10-10",
display: {
document: "visible",
screenshot: "hidden",
websiteLink: "hidden",
},
items: {
src: {
sha256: sourceSha,
mediaType: "text/markdown",
storage: "private",
},
},
},
{
schema: "pbp/claim@1",
id: "claim/european-quality",
type: "claim",
status: "published",
claimClass: "factual",
claimKind: "fact",
subject: { kind: "business", name: "DP7" },
statement: "Европейское качество — доступное каждому",
statementLang: "ru",
statementByLocale: {
"uz-Latn": "Yevropa sifati — hammaga qulay",
},
evidenceRefs: {
src: { ref: "pbp/evidence-source@1:evidence-source/src-01" },
},
governance: { authorityRef: "pbp/business@1:business/dp7" },
},
{
schema: "pbp/claim@1",
id: "claim/installation-warranty",
type: "claim",
status: "draft",
claimClass: "factual",
claimKind: "fact",
subject: { kind: "business", name: "DP7" },
statement: "One-year installation warranty",
statementLang: "ru",
governance: { authorityRef: "pbp/business@1:business/dp7" },
},
],
annotations: {
"claim/european-quality": {
verbatim: [
{
text: "Европейское качество — доступное каждому",
sourceRef: "evidence-source/src-01",
speaker: "client",
},
],
ledgerRef: "CLM-023",
},
"claim/installation-warranty": { gapRef: "GAP-04" },
},
gaps: [
{
id: "GAP-04",
what: "Warranty policy wording",
why: "A publishable warranty claim requires the client's actual terms",
obtainableBy: "client",
itemType: "file",
clientPrompt: "Upload your warranty terms document",
defaultIfSilent: "One-year installation warranty",
blocks: ["claim/installation-warranty"],
severity: "launch-blocking",
},
],
operatorDecisions: [
{
id: "OPD-01",
kind: "ruling",
gate: "G-V",
question: "Accept concept-1 as the visual direction?",
answer: "yes",
answeredAt: "2026-10-15T10:00:00Z",
reversible: false,
},
],
voice: {
verbatim: [
{
id: "VOI-01",
text: "Европейское качество — доступное каждому",
sourceRef: "evidence-source/src-01",
use: "slogan",
claimRefs: ["claim/european-quality"],
},
],
directives: [
{
id: "DIR-01",
text: "Никаких обещаний без источника",
sourceRef: "evidence-source/src-01",
appliesTo: ["composition", "publication"],
},
],
profile: {
toneOfVoice: {
tone: "calm",
posture: "expert",
readingLevel: "B2",
avoids: ["hype", "superlatives without proof"],
prefers: ["concrete numbers", "short sentences"],
},
linguisticRules: {
ru: ["no anglicisms in headlines"],
"uz-Latn": ["keep loanword spellings consistent"],
},
culturalRules: ["respect local forms of address"],
},
},
visual: {
conceptId: "concept-1",
rejectedConceptIds: ["concept-2"],
axes: {
warmth: "neutral",
contrast: "medium",
density: "comfortable",
typographySharpness: "balanced",
diagramPresence: "minimal",
photoStance: "documentary",
motionStance: "restrained",
textContrast: "aa",
cornerRadius: "4px",
borderWeight: "1px",
},
palette: { brand: "#1a3a5c", ink: "#111111", surface: "#f7f5f0" },
fonts: [
{
family: "Inter",
role: "body",
source: "@fontsource/inter",
scripts: ["Latn", "Cyrl"],
},
],
moodAssetRefs: ["AST-01"],
rationale: "quiet factual authority",
},
assets: [
{
id: "AST-01",
kind: "logo",
file: `assets/${assetSha}.svg`,
sha256: `sha256:${assetSha}`,
mediaType: "image/svg+xml",
origin: "generated",
rights: { state: "generated" },
generator: {
model: "flux-1-pro",
promptSha256: `sha256:${"c".repeat(64)}`,
conceptId: "concept-1",
generatedAt: "2026-10-15T09:00:00Z",
},
},
],
provenance: {
producer: "site-werkstatt",
producerVersion: "0.1.0",
runId: "run-2026-10-15-01",
contractSnapshot: AUTO,
inputs: [
{
path: ".input/client-knowledge-base.json",
sha256: `sha256:${"d".repeat(64)}`,
bytes: 42137,
},
],
evidenceLedger: AUTO,
models: [{ step: "phase0-ledger", model: "claude-opus-4.1", effort: "high" }],
quoteCheck: { result: "pass", checked: 3, failed: 0 },
cost: { currency: "USD", total: 4.2, reportPath: "cost-report.json" },
},
};
}
/**
* Materialize a valid intake bundle at `dir` (created recursively by writes).
* `mutate` runs before hashing so mutations stay hash-consistent; INTAKE-01
* tampering uses `manifestTransform` instead.
*/
export async function buildIntakeBundle(
dir: string,
io: WorkspaceIO,
options: BuildIntakeBundleOptions = {},
): Promise {
const sourceSha = sha256Hex(new TextEncoder().encode(SOURCE_MD));
const assetSha = sha256Hex(new TextEncoder().encode(ASSET_SVG));
const files = new Map();
files.set(`sources/${sourceSha}.md`, SOURCE_MD);
files.set(`assets/${assetSha}.svg`, ASSET_SVG);
files.set(
"evidence-ledger.json",
`${JSON.stringify(
{
runId: "run-2026-10-15-01",
records: [
{
id: "CLM-023",
kind: "client-statement",
sourceRef: "evidence-source/src-01",
recordedAt: "2026-10-10T14:00:00Z",
},
],
},
null,
2,
)}\n`,
);
files.set(
"cost-report.json",
`${JSON.stringify(
{
currency: "USD",
total: 4.2,
steps: [{ step: "phase0-ledger", model: "claude-opus-4.1", cost: 4.2 }],
},
null,
2,
)}\n`,
);
const seed: IntakeBundleSeed = {
envelope: defaultEnvelope(sourceSha, assetSha),
files,
};
options.mutate?.(seed);
// Fill sentinels unless mutate replaced them (tests inject bad values here).
const provenance = seed.envelope.provenance as Record;
const ledgerFile = files.get("evidence-ledger.json");
if (provenance.evidenceLedger === AUTO && ledgerFile !== undefined) {
provenance.evidenceLedger = `sha256:${sha256Hex(new TextEncoder().encode(ledgerFile))}`;
}
if (provenance.contractSnapshot === AUTO) {
const exportSha = options.exportSha256 ?? (await resolveIntakeExportSha256(io))?.sha256;
if (!exportSha) {
throw new Error(
"buildIntakeBundle: intake.schema.generated.yaml not found — run onboarding.intake.schema.export first",
);
}
provenance.contractSnapshot = exportSha;
}
// Write payload files (manifest is hashed afterwards, so intake.json counts too).
for (const [rel, content] of files) {
await io.writeFile(join(dir, rel), content);
}
await io.writeFile(join(dir, "intake.json"), `${JSON.stringify(seed.envelope, null, 2)}\n`);
// MANIFEST.sha256 — sha256sum format, sorted, covering every file but itself.
const allFiles = [...files.keys(), "intake.json"].sort();
const lines: string[] = [];
for (const rel of allFiles) {
const bytes = await io.readFileBytes(join(dir, rel));
lines.push(`${sha256Hex(bytes)} ${rel}`);
}
let manifest = `${lines.join("\n")}\n`;
if (options.manifestTransform) {
manifest = options.manifestTransform(manifest);
}
await io.writeFile(join(dir, "MANIFEST.sha256"), manifest);
return {
dir,
sourceSha256: sourceSha,
assetSha256: assetSha,
ledgerSha256: typeof provenance.evidenceLedger === "string" ? provenance.evidenceLedger : "",
};
}