@3xhaust/oh-my-design 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +282 -0
  3. package/README.md +282 -0
  4. package/adapters/build.ts +184 -0
  5. package/adapters/claude.ts +111 -0
  6. package/adapters/codex.ts +68 -0
  7. package/adapters/tokens.ts +21 -0
  8. package/adapters/tool-map.json +7 -0
  9. package/bin/omd-install.ts +76 -0
  10. package/bin/omd.ts +1774 -0
  11. package/core/asset-sourcing/index.ts +187 -0
  12. package/core/coach/index.ts +98 -0
  13. package/core/composition/app-shell-workbench.md +168 -0
  14. package/core/composition/asymmetric-diagonal-grid.md +178 -0
  15. package/core/composition/bento-grid.md +223 -0
  16. package/core/composition/editorial-index-labels.md +173 -0
  17. package/core/composition/form-wizard-stepper.md +132 -0
  18. package/core/composition/master-detail-flow.md +129 -0
  19. package/core/composition/section-inversion.md +182 -0
  20. package/core/composition/sidebar-margin-annotation.md +184 -0
  21. package/core/composition/split-screen-hero.md +224 -0
  22. package/core/composition/sticky-sidebar-scroll.md +219 -0
  23. package/core/composition/typographic-hero.md +100 -0
  24. package/core/composition-contract/index.ts +233 -0
  25. package/core/composition-contract/visual-richness.ts +101 -0
  26. package/core/config/index.ts +27 -0
  27. package/core/copy/index.ts +296 -0
  28. package/core/craft/finish-pass.md +356 -0
  29. package/core/craft/index.ts +33 -0
  30. package/core/design/index.ts +581 -0
  31. package/core/design/interaction-states.ts +189 -0
  32. package/core/eval-harness/index.ts +225 -0
  33. package/core/evidence/final.ts +444 -0
  34. package/core/evidence/task.ts +459 -0
  35. package/core/figma/client.ts +312 -0
  36. package/core/figma/diff.ts +261 -0
  37. package/core/figma/responsive.ts +310 -0
  38. package/core/figma/system.ts +466 -0
  39. package/core/figma/types.ts +185 -0
  40. package/core/frame/check-ux.ts +180 -0
  41. package/core/frame/index.ts +39 -0
  42. package/core/frame/write.ts +168 -0
  43. package/core/graphics/css-illustration-primitives.md +189 -0
  44. package/core/graphics/duotone-image-presets.md +173 -0
  45. package/core/graphics/gradient-mesh.md +178 -0
  46. package/core/graphics/noise-grain-texture.md +150 -0
  47. package/core/graphics/placeholder-policy.md +185 -0
  48. package/core/graphics/svg-geometric-patterns.md +145 -0
  49. package/core/history/index.ts +39 -0
  50. package/core/install/detect.ts +32 -0
  51. package/core/install/install.ts +321 -0
  52. package/core/install/patch-claude.ts +109 -0
  53. package/core/install/patch-codex.ts +82 -0
  54. package/core/interaction/index.ts +101 -0
  55. package/core/interaction/recipes/signature-lighting.md +203 -0
  56. package/core/ir/dom.ts +352 -0
  57. package/core/ir/normalize.ts +177 -0
  58. package/core/motion/easing.md +151 -0
  59. package/core/motion/energy.ts +188 -0
  60. package/core/motion/recipes/image-hover-distortion.md +198 -0
  61. package/core/motion/recipes/magnetic-hover.md +229 -0
  62. package/core/motion/recipes/marquee.md +202 -0
  63. package/core/motion/recipes/number-counter.md +242 -0
  64. package/core/motion/recipes/page-loader.md +240 -0
  65. package/core/motion/recipes/parallax.md +233 -0
  66. package/core/motion/recipes/scroll-reveal.md +210 -0
  67. package/core/motion/recipes/section-color-inversion.md +217 -0
  68. package/core/motion/recipes/split-text-entrance.md +222 -0
  69. package/core/motion/recipes/stagger-orchestrator.md +227 -0
  70. package/core/motion/recipes/sticky-scene-transition.md +246 -0
  71. package/core/motion/recipes/view-transitions.md +239 -0
  72. package/core/probe/index.ts +186 -0
  73. package/core/probe/schema.json +46 -0
  74. package/core/protocol/composition-contract.md +192 -0
  75. package/core/protocol/copy-deck.md +96 -0
  76. package/core/protocol/human-design-loop.md +447 -0
  77. package/core/protocol/slop-review.md +71 -0
  78. package/core/ref/batch.ts +94 -0
  79. package/core/ref/blueprint.ts +199 -0
  80. package/core/ref/distance.ts +225 -0
  81. package/core/ref/invariants.ts +162 -0
  82. package/core/ref/signal.ts +37 -0
  83. package/core/ref/store.ts +122 -0
  84. package/core/render/index.ts +607 -0
  85. package/core/rules/attribution.ts +159 -0
  86. package/core/rules/builtin/contrast.yaml +8 -0
  87. package/core/rules/builtin/focus.yaml +8 -0
  88. package/core/rules/builtin/hit-area.yaml +8 -0
  89. package/core/rules/builtin/ko.yaml +6 -0
  90. package/core/rules/builtin/motion.yaml +80 -0
  91. package/core/rules/builtin/slop.yaml +365 -0
  92. package/core/rules/builtin/spacing.yaml +9 -0
  93. package/core/rules/builtin/system.yaml +42 -0
  94. package/core/rules/builtin/token.yaml +8 -0
  95. package/core/rules/builtin/ux.yaml +125 -0
  96. package/core/rules/engine.ts +88 -0
  97. package/core/rules/leakage.ts +72 -0
  98. package/core/rules/motion-spec.ts +204 -0
  99. package/core/site/index.ts +100 -0
  100. package/core/slop/index.ts +427 -0
  101. package/core/slop/text-slop.ts +220 -0
  102. package/core/source-seal/index.ts +174 -0
  103. package/core/stack/index.ts +53 -0
  104. package/core/target/index.ts +183 -0
  105. package/core/theory/color.md +285 -0
  106. package/core/theory/components.md +272 -0
  107. package/core/theory/craft.md +270 -0
  108. package/core/theory/expressive.md +213 -0
  109. package/core/theory/imagegen.md +105 -0
  110. package/core/theory/layout.md +159 -0
  111. package/core/theory/motion.md +283 -0
  112. package/core/theory/typography.md +134 -0
  113. package/core/theory/ux.md +580 -0
  114. package/core/theory/voice.md +595 -0
  115. package/core/types.ts +684 -0
  116. package/package.json +40 -0
  117. package/src/agents/composer.agent.yaml +174 -0
  118. package/src/agents/eye.agent.yaml +208 -0
  119. package/src/agents/framer.agent.yaml +52 -0
  120. package/src/agents/glance.agent.yaml +19 -0
  121. package/src/agents/hand.agent.yaml +200 -0
  122. package/src/agents/scout.agent.yaml +71 -0
  123. package/src/agents/sketch.agent.yaml +64 -0
  124. package/src/agents/typesetter.agent.yaml +52 -0
  125. package/src/agents/writer.agent.yaml +63 -0
  126. package/src/skills/omd-coach/SKILL.md +40 -0
  127. package/src/skills/omd-critique/SKILL.md +77 -0
  128. package/src/skills/omd-figma/SKILL.md +256 -0
  129. package/src/skills/omd-humanize/SKILL.md +160 -0
  130. package/src/skills/omd-scout/SKILL.md +77 -0
  131. package/src/skills/omd-ultradesign/SKILL.md +521 -0
@@ -0,0 +1,444 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { closeSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { relative, resolve, sep } from 'node:path';
4
+ import { parse } from 'yaml';
5
+ import { crc32 } from 'node:zlib';
6
+ import { decodePng } from '../motion/energy.ts';
7
+ import { validateSourceSeal } from '../source-seal/index.ts';
8
+ import { checkTaskEvidence } from './task.ts';
9
+
10
+ export const FINAL_EVIDENCE_SCHEMA_VERSION = 1;
11
+
12
+ export type FinalEvidenceArtifactKind = 'check' | 'test' | 'probe' | 'screenshot' | 'render' | 'filmstrip' | 'task-evidence';
13
+
14
+ interface FinalEvidenceArtifactBase {
15
+ path: string;
16
+ sha256: string;
17
+ }
18
+
19
+ export type FinalEvidenceArtifact =
20
+ | ({ kind: 'check' | 'test' | 'filmstrip' | 'task-evidence' } & FinalEvidenceArtifactBase)
21
+ | ({ kind: 'probe'; role: 'primary' | 'recovery' } & FinalEvidenceArtifactBase)
22
+ | ({ kind: 'screenshot' | 'render'; viewport: 'desktop' | 'mobile' } & FinalEvidenceArtifactBase);
23
+
24
+ export interface FinalEvidenceInteraction {
25
+ scope: 'stateful' | 'navigation-only' | 'static';
26
+ motion: boolean;
27
+ surface: 'marketing' | 'product' | 'editorial' | 'mixed';
28
+ }
29
+
30
+ export interface FinalEvidenceBuild {
31
+ target: string;
32
+ fingerprint: string;
33
+ servedTarget: string;
34
+ }
35
+
36
+ export interface FinalEvidenceTools {
37
+ versions: Record<string, string>;
38
+ commands: string[];
39
+ }
40
+
41
+ export interface FinalEvidenceManifest {
42
+ schemaVersion: 1;
43
+ runId: string;
44
+ sourceSeal: { path: '.omd/source-seal.json'; sha256: string };
45
+ build: FinalEvidenceBuild;
46
+ tools: FinalEvidenceTools;
47
+ interaction: FinalEvidenceInteraction;
48
+ artifacts: FinalEvidenceArtifact[];
49
+ }
50
+
51
+ export type FinalEvidence = FinalEvidenceManifest;
52
+
53
+ const SHA256 = /^[a-f0-9]{64}$/;
54
+ const ARTIFACT_KINDS = new Set<FinalEvidenceArtifactKind>(['check', 'test', 'probe', 'screenshot', 'render', 'filmstrip', 'task-evidence']);
55
+ const INTERACTION_SCOPES = new Set<FinalEvidenceInteraction['scope']>(['stateful', 'navigation-only', 'static']);
56
+ const INTERACTION_SURFACES = new Set<FinalEvidenceInteraction['surface']>(['marketing', 'product', 'editorial', 'mixed']);
57
+ const ARTIFACT_CACHE_PREFIX = '.omd/.cache/';
58
+ const compare = (a: string, b: string): number => Buffer.compare(Buffer.from(a), Buffer.from(b));
59
+ const digest = (bytes: Uint8Array | string): string => createHash('sha256').update(bytes).digest('hex');
60
+ function canonicalJson(value: unknown): string {
61
+ if (value === null || typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value);
62
+ if (typeof value === 'number') {
63
+ if (!Number.isFinite(value)) throw new Error('canonical JSON rejects non-finite numbers');
64
+ return Object.is(value, -0) ? '0' : JSON.stringify(value);
65
+ }
66
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
67
+ if (!isRecord(value)) throw new Error('canonical JSON rejects unsupported values');
68
+ return `{${Object.keys(value).sort(compare).map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
69
+ }
70
+
71
+ function isRecord(value: unknown): value is Record<string, unknown> {
72
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
73
+ }
74
+
75
+ function exactKeys(value: Record<string, unknown>, keys: readonly string[], label: string): void {
76
+ const actual = Object.keys(value).sort(compare);
77
+ const expected = [...keys].sort(compare);
78
+ if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
79
+ throw new Error(`${label} has unknown or missing keys`);
80
+ }
81
+ }
82
+
83
+ function safePath(value: unknown, label: string): string {
84
+ if (typeof value !== 'string' || value.length === 0 || value.includes('\\') || value.includes('\0') || value.startsWith('/') || /^[A-Za-z]:\//.test(value)) {
85
+ throw new Error(`${label} must be a safe project-relative path`);
86
+ }
87
+ if (value.split('/').some(segment => segment.length === 0 || segment === '.' || segment === '..')) {
88
+ throw new Error(`${label} must be a safe project-relative path`);
89
+ }
90
+ return value;
91
+ }
92
+
93
+ function safeString(value: unknown, label: string): string {
94
+ if (typeof value !== 'string' || value.length === 0 || value.trim() !== value) throw new Error(`${label} must be a non-empty string without leading or trailing whitespace`);
95
+ return value;
96
+ }
97
+
98
+ function safeRunId(value: unknown): string {
99
+ const runId = safeString(value, 'runId');
100
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]*(?:\.[A-Za-z0-9][A-Za-z0-9_-]*)*$/.test(runId) || runId.length > 128) throw new Error('runId must be a safe stable filename identifier');
101
+ return runId;
102
+ }
103
+
104
+ function safeSha256(value: unknown, label: string): string {
105
+ if (typeof value !== 'string' || !SHA256.test(value)) throw new Error(`${label} must be a lowercase SHA-256 digest`);
106
+ return value;
107
+ }
108
+
109
+ function projectRoot(rootInput: string): string {
110
+ const root = resolve(rootInput);
111
+ const stat = lstatSync(root);
112
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`project root must be a non-symlink directory: ${root}`);
113
+ return root;
114
+ }
115
+
116
+ /** Resolves a path only after rejecting symlinks and special files in every component. */
117
+ function regularProjectFile(root: string, path: string): string {
118
+ const safe = safePath(path, 'path');
119
+ const absolute = resolve(root, ...safe.split('/'));
120
+ const rel = relative(root, absolute);
121
+ if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`)) throw new Error(`path escapes project: ${path}`);
122
+ let current = root;
123
+ const segments = safe.split('/');
124
+ for (const [index, segment] of segments.entries()) {
125
+ current = resolve(current, segment);
126
+ const stat = lstatSync(current);
127
+ if (stat.isSymbolicLink()) throw new Error(`symlink is forbidden: ${path}`);
128
+ if (index === segments.length - 1) {
129
+ if (!stat.isFile()) throw new Error(`not a regular file: ${path}`);
130
+ } else if (!stat.isDirectory()) {
131
+ throw new Error(`not a directory: ${path}`);
132
+ }
133
+ }
134
+ return absolute;
135
+ }
136
+
137
+ function buildFingerprintAt(root: string, path: string): string {
138
+ const safe = safePath(path, 'build target');
139
+ const absolute = resolve(root, ...safe.split('/'));
140
+ const stat = lstatSync(absolute);
141
+ if (stat.isSymbolicLink()) throw new Error(`symlink is forbidden: ${safe}`);
142
+ if (stat.isFile()) return digest(readFileSync(regularProjectFile(root, safe)));
143
+ if (!stat.isDirectory()) throw new Error(`build target is not a regular file or directory: ${safe}`);
144
+
145
+ const entries: string[] = [];
146
+ const walk = (relativePath: string): void => {
147
+ const directory = resolve(root, ...relativePath.split('/'));
148
+ for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => compare(a.name, b.name))) {
149
+ const child = `${relativePath}/${entry.name}`;
150
+ const childStat = lstatSync(resolve(directory, entry.name));
151
+ if (childStat.isSymbolicLink()) throw new Error(`symlink is forbidden: ${child}`);
152
+ if (childStat.isDirectory()) walk(child);
153
+ else if (childStat.isFile()) entries.push(child);
154
+ else throw new Error(`special file is forbidden: ${child}`);
155
+ }
156
+ };
157
+ walk(safe);
158
+ const canonical = entries.sort(compare).map(path => `${path}\0${digest(readFileSync(regularProjectFile(root, path)))}`).join('\n');
159
+ return digest(canonical);
160
+ }
161
+
162
+ export function computeBuildFingerprint(rootInput: string, target: string): string {
163
+ return buildFingerprintAt(projectRoot(rootInput), target);
164
+ }
165
+
166
+ function parseManifest(value: unknown): FinalEvidenceManifest {
167
+ if (!isRecord(value)) throw new Error('final evidence manifest must be an object');
168
+ exactKeys(value, ['schemaVersion', 'runId', 'sourceSeal', 'build', 'tools', 'interaction', 'artifacts'], 'final evidence manifest');
169
+ if (value.schemaVersion !== FINAL_EVIDENCE_SCHEMA_VERSION) throw new Error('final evidence manifest schemaVersion must be 1');
170
+ const sourceSeal = value.sourceSeal;
171
+ const build = value.build;
172
+ const tools = value.tools;
173
+ const interaction = value.interaction;
174
+ if (!isRecord(sourceSeal) || !isRecord(build) || !isRecord(tools) || !isRecord(interaction) || !Array.isArray(value.artifacts)) throw new Error('final evidence manifest has invalid nested values');
175
+ exactKeys(sourceSeal, ['path', 'sha256'], 'sourceSeal');
176
+ if (sourceSeal.path !== '.omd/source-seal.json') throw new Error('sourceSeal.path must be .omd/source-seal.json');
177
+ exactKeys(build, ['target', 'fingerprint', 'servedTarget'], 'build');
178
+ exactKeys(tools, ['versions', 'commands'], 'tools');
179
+ exactKeys(interaction, ['scope', 'motion', 'surface'], 'interaction');
180
+ if (
181
+ typeof interaction.scope !== 'string'
182
+ || !INTERACTION_SCOPES.has(interaction.scope as FinalEvidenceInteraction['scope'])
183
+ || typeof interaction.motion !== 'boolean'
184
+ || typeof interaction.surface !== 'string'
185
+ || !INTERACTION_SURFACES.has(interaction.surface as FinalEvidenceInteraction['surface'])
186
+ ) {
187
+ throw new Error('interaction must contain a valid scope, boolean motion, and valid surface');
188
+ }
189
+ if ((interaction.surface === 'product' || interaction.surface === 'mixed') && interaction.scope === 'static') {
190
+ throw new Error('product and mixed interactions must not be static');
191
+ }
192
+ if (!isRecord(tools.versions) || !Array.isArray(tools.commands)) throw new Error('tools must contain versions and commands');
193
+ const versions: Record<string, string> = {};
194
+ for (const [name, version] of Object.entries(tools.versions)) versions[safeString(name, 'tool version name')] = safeString(version, `tool version ${name}`);
195
+ if (Object.keys(versions).length === 0 || tools.commands.length === 0) throw new Error('tools versions and commands must be non-empty');
196
+ const commands = tools.commands.map((command, index) => safeString(command, `tools.commands[${index}]`));
197
+ if (new Set(commands).size !== commands.length) throw new Error('tools.commands contains a duplicate command');
198
+ if (value.artifacts.length === 0) throw new Error('artifacts must be non-empty');
199
+ const artifacts = value.artifacts.map((item, index): FinalEvidenceArtifact => {
200
+ if (!isRecord(item)) throw new Error(`artifacts[${index}] must be an object`);
201
+ if (typeof item.kind !== 'string' || !ARTIFACT_KINDS.has(item.kind as FinalEvidenceArtifactKind)) throw new Error(`artifacts[${index}].kind is invalid`);
202
+ const path = safePath(item.path, `artifacts[${index}].path`);
203
+ if (item.kind === 'task-evidence') {
204
+ if (path !== '.omd/task-evidence.json') throw new Error('task-evidence artifact path must be .omd/task-evidence.json');
205
+ } else if (!path.startsWith(ARTIFACT_CACHE_PREFIX)) throw new Error(`artifacts[${index}].path must be under ${ARTIFACT_CACHE_PREFIX}`);
206
+ const sha256 = safeSha256(item.sha256, `artifacts[${index}].sha256`);
207
+ switch (item.kind) {
208
+ case 'check':
209
+ case 'test':
210
+ case 'filmstrip':
211
+ case 'task-evidence':
212
+ exactKeys(item, ['kind', 'path', 'sha256'], `artifacts[${index}]`);
213
+ return { kind: item.kind, path, sha256 };
214
+ case 'probe':
215
+ exactKeys(item, ['kind', 'path', 'sha256', 'role'], `artifacts[${index}]`);
216
+ if (item.role !== 'primary' && item.role !== 'recovery') throw new Error(`artifacts[${index}].role is invalid`);
217
+ return { kind: 'probe', path, sha256, role: item.role };
218
+ case 'screenshot':
219
+ case 'render':
220
+ exactKeys(item, ['kind', 'path', 'sha256', 'viewport'], `artifacts[${index}]`);
221
+ if (item.viewport !== 'desktop' && item.viewport !== 'mobile') throw new Error(`artifacts[${index}].viewport is invalid`);
222
+ return { kind: item.kind, path, sha256, viewport: item.viewport };
223
+ default:
224
+ throw new Error(`artifacts[${index}].kind is invalid`);
225
+ }
226
+ });
227
+ const paths = new Set<string>();
228
+ for (const artifact of artifacts) {
229
+ if (paths.has(artifact.path)) throw new Error(`duplicate artifact path: ${artifact.path}`);
230
+ paths.add(artifact.path);
231
+ }
232
+ for (const kind of ['check', 'test'] as const) {
233
+ if (!artifacts.some(artifact => artifact.kind === kind)) throw new Error(`artifacts must include a ${kind} record`);
234
+ }
235
+ const probes = artifacts.filter((artifact): artifact is Extract<FinalEvidenceArtifact, { kind: 'probe' }> => artifact.kind === 'probe');
236
+ const primaryProbes = probes.filter(artifact => artifact.role === 'primary').length;
237
+ const recoveryProbes = probes.filter(artifact => artifact.role === 'recovery').length;
238
+ if (interaction.scope === 'stateful' && (primaryProbes !== 1 || recoveryProbes !== 1)) throw new Error('stateful interaction requires exactly one primary and one recovery probe');
239
+ if (interaction.scope === 'navigation-only' && (primaryProbes !== 1 || recoveryProbes !== 0)) throw new Error('navigation-only interaction requires exactly one primary probe and no recovery probe');
240
+ if (interaction.scope === 'static' && probes.length !== 0) throw new Error('static interaction must not include probes');
241
+ const viewports = new Set(artifacts.filter((artifact): artifact is Extract<FinalEvidenceArtifact, { viewport: 'desktop' | 'mobile' }> => artifact.kind === 'screenshot' || artifact.kind === 'render').map(artifact => artifact.viewport));
242
+ if (!viewports.has('desktop') || !viewports.has('mobile')) throw new Error('artifacts must include desktop and mobile screenshot or render records');
243
+ const filmstrips = artifacts.filter(artifact => artifact.kind === 'filmstrip').length;
244
+ if (interaction.motion ? filmstrips === 0 : filmstrips !== 0) throw new Error(interaction.motion ? 'motion interaction requires a filmstrip' : 'non-motion interaction must not include filmstrips');
245
+ return {
246
+ schemaVersion: 1,
247
+ runId: safeRunId(value.runId),
248
+ sourceSeal: { path: '.omd/source-seal.json', sha256: safeSha256(sourceSeal.sha256, 'sourceSeal.sha256') },
249
+ build: { target: safePath(build.target, 'build.target'), fingerprint: safeSha256(build.fingerprint, 'build.fingerprint'), servedTarget: safeString(build.servedTarget, 'build.servedTarget') },
250
+ tools: { versions, commands },
251
+ interaction: { scope: interaction.scope as FinalEvidenceInteraction['scope'], motion: interaction.motion, surface: interaction.surface as FinalEvidenceInteraction['surface'] },
252
+ artifacts,
253
+ };
254
+ }
255
+
256
+ function frameSurface(root: string): FinalEvidenceInteraction['surface'] {
257
+ const path = regularProjectFile(root, '.omd/frame.md');
258
+ const source = readFileSync(path, 'utf8');
259
+ const frontmatter = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(source)?.[1];
260
+ if (frontmatter === undefined) throw new Error('frame must have YAML frontmatter');
261
+ let value: unknown;
262
+ try {
263
+ value = parse(frontmatter);
264
+ } catch {
265
+ throw new Error('frame frontmatter is malformed');
266
+ }
267
+ if (!isRecord(value) || typeof value.uxSurface !== 'string') {
268
+ throw new Error('frame uxSurface must be a primitive surface value');
269
+ }
270
+ const normalized = value.uxSurface.trim().toLowerCase();
271
+ if (!INTERACTION_SURFACES.has(normalized as FinalEvidenceInteraction['surface'])) {
272
+ throw new Error('frame uxSurface is unknown');
273
+ }
274
+ return normalized as FinalEvidenceInteraction['surface'];
275
+ }
276
+
277
+ function decodedPng(path: string, label: string): ReturnType<typeof decodePng> {
278
+ try {
279
+ const bytes = readFileSync(path);
280
+ if (bytes.length < 8 || !bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) throw new Error('bad signature');
281
+ let offset = 8;
282
+ let sawIhdr = false;
283
+ let sawIdat = false;
284
+ let sawIend = false;
285
+ while (offset < bytes.length) {
286
+ if (offset + 12 > bytes.length) throw new Error('truncated chunk');
287
+ const length = bytes.readUInt32BE(offset);
288
+ const end = offset + 12 + length;
289
+ if (end > bytes.length) throw new Error('truncated chunk data');
290
+ const type = bytes.subarray(offset + 4, offset + 8).toString('ascii');
291
+ if ((crc32(bytes.subarray(offset + 4, offset + 8 + length)) >>> 0) !== bytes.readUInt32BE(offset + 8 + length)) throw new Error('invalid chunk CRC');
292
+ if (!sawIhdr && (type !== 'IHDR' || length !== 13)) throw new Error('IHDR must be first');
293
+ if (type === 'IHDR') {
294
+ if (sawIhdr) throw new Error('duplicate IHDR');
295
+ sawIhdr = true;
296
+ } else if (type === 'IDAT') {
297
+ if (!sawIhdr || sawIend) throw new Error('invalid IDAT placement');
298
+ sawIdat = true;
299
+ } else if (type === 'IEND') {
300
+ if (!sawIhdr || !sawIdat || length !== 0 || sawIend || end !== bytes.length) throw new Error('invalid IEND');
301
+ sawIend = true;
302
+ }
303
+ offset = end;
304
+ }
305
+ if (!sawIhdr || !sawIdat || !sawIend) throw new Error('missing required PNG chunks');
306
+ return decodePng(bytes);
307
+ } catch {
308
+ throw new Error(`${label} must be a structurally valid PNG`);
309
+ }
310
+ }
311
+
312
+ function verifyMedia(root: string, manifest: FinalEvidenceManifest): void {
313
+ for (const artifact of manifest.artifacts) {
314
+ const path = regularProjectFile(root, artifact.path);
315
+ if (artifact.kind === 'screenshot' || artifact.kind === 'render') {
316
+ const expected = artifact.viewport === 'desktop' ? { width: 1280, height: 900 } : { width: 390, height: 844 };
317
+ const image = decodedPng(path, `${artifact.kind} ${artifact.path}`);
318
+ if (image.width !== expected.width || image.height !== expected.height) {
319
+ throw new Error(`${artifact.kind} ${artifact.path} must be ${expected.width}x${expected.height} for ${artifact.viewport}`);
320
+ }
321
+ } else if (artifact.kind === 'filmstrip') {
322
+ const image = decodedPng(path, `filmstrip ${artifact.path}`);
323
+ const first = image.pixels[0];
324
+ if (image.pixels.every(pixel => pixel === first)) throw new Error(`filmstrip ${artifact.path} must be non-uniform`);
325
+ }
326
+ }
327
+ }
328
+
329
+ function verifyTaskEvidenceApplicability(manifest: FinalEvidenceManifest, frame: FinalEvidenceInteraction['surface']): void {
330
+ const taskEvidenceArtifacts = manifest.artifacts.filter((artifact): artifact is Extract<FinalEvidenceArtifact, { kind: 'task-evidence' }> => artifact.kind === 'task-evidence');
331
+ if ((frame === 'product' || frame === 'mixed') && taskEvidenceArtifacts.length !== 1) {
332
+ throw new Error('product and mixed frame surfaces require exactly one task-evidence artifact');
333
+ }
334
+ if ((frame === 'marketing' || frame === 'editorial') && taskEvidenceArtifacts.length !== 0) {
335
+ throw new Error('marketing and editorial frame surfaces must not include task-evidence artifacts');
336
+ }
337
+ }
338
+
339
+ function verifyBindings(root: string, manifest: FinalEvidenceManifest): void {
340
+ const sourceSeal = regularProjectFile(root, manifest.sourceSeal.path);
341
+ if (digest(readFileSync(sourceSeal)) !== manifest.sourceSeal.sha256) throw new Error(`source seal digest mismatch: ${manifest.sourceSeal.path}`);
342
+ const findings = validateSourceSeal(root);
343
+ if (findings.length > 0) throw new Error(`source seal is stale: ${findings.map(finding => finding.path).join(', ')}`);
344
+ const frame = frameSurface(root);
345
+ if (frame !== manifest.interaction.surface) throw new Error('frame uxSurface does not match interaction surface');
346
+ verifyTaskEvidenceApplicability(manifest, frame);
347
+ const fingerprint = buildFingerprintAt(root, manifest.build.target);
348
+ if (fingerprint !== manifest.build.fingerprint) throw new Error(`build fingerprint mismatch: ${manifest.build.target}`);
349
+ const taskEvidence = manifest.artifacts.find((artifact): artifact is Extract<FinalEvidenceArtifact, { kind: 'task-evidence' }> => artifact.kind === 'task-evidence');
350
+ if (taskEvidence && checkTaskEvidence(root).surface !== frame) throw new Error('task evidence surface does not match frame uxSurface');
351
+ for (const artifact of manifest.artifacts) {
352
+ const path = regularProjectFile(root, artifact.path);
353
+ if (digest(readFileSync(path)) !== artifact.sha256) throw new Error(`artifact digest mismatch: ${artifact.kind} ${artifact.path}`);
354
+ }
355
+ verifyMedia(root, manifest);
356
+ }
357
+
358
+ function readManifestFile(path: string): unknown {
359
+ const stat = lstatSync(path);
360
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`manifest must be a regular non-symlink file: ${path}`);
361
+ try { return JSON.parse(readFileSync(path, 'utf8')) as unknown; } catch { throw new Error(`manifest is not valid JSON: ${path}`); }
362
+ }
363
+
364
+ /** Validates a caller manifest and atomically publishes an immutable bound final evidence record. */
365
+ export function finalizeFinalEvidence(rootInput: string, manifestPath: string): string {
366
+ const root = projectRoot(rootInput);
367
+ const manifest = parseManifest(readManifestFile(resolve(manifestPath)));
368
+ verifyBindings(root, manifest);
369
+ const omd = resolve(root, '.omd');
370
+ const runs = resolve(omd, 'final-evidence-runs');
371
+ const output = resolve(omd, 'final-evidence.json');
372
+ const lock = resolve(omd, '.final-evidence.lock');
373
+ const record = resolve(runs, `${manifest.runId}.json`);
374
+ const bytes = `${canonicalJson(manifest)}\n`;
375
+
376
+ mkdirSync(omd, { recursive: true });
377
+ const omdStat = lstatSync(omd);
378
+ if (!omdStat.isDirectory() || omdStat.isSymbolicLink()) throw new Error(`final evidence directory must be a non-symlink directory: ${omd}`);
379
+ mkdirSync(runs, { recursive: true });
380
+ const runsStat = lstatSync(runs);
381
+ if (!runsStat.isDirectory() || runsStat.isSymbolicLink()) throw new Error(`final evidence directory must be a non-symlink directory: ${runs}`);
382
+
383
+ let lockFd: number | undefined;
384
+ try {
385
+ try {
386
+ lockFd = openSync(lock, 'wx', 0o600);
387
+ } catch (error: unknown) {
388
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('final evidence publication is already in progress');
389
+ throw error;
390
+ }
391
+
392
+ try {
393
+ writeFileSync(record, bytes, { flag: 'wx', mode: 0o600 });
394
+ } catch (error: unknown) {
395
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error(`final evidence run already exists: ${manifest.runId}`);
396
+ throw error;
397
+ }
398
+
399
+ const current = (() => {
400
+ try { return lstatSync(output); } catch (error: unknown) {
401
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
402
+ throw error;
403
+ }
404
+ })();
405
+ if (current && (!current.isFile() || current.isSymbolicLink())) throw new Error(`final evidence current record must be a regular non-symlink file: ${output}`);
406
+
407
+ const temporary = resolve(omd, `.final-evidence.${process.pid}.${randomBytes(12).toString('hex')}.tmp`);
408
+ try {
409
+ writeFileSync(temporary, bytes, { flag: 'wx', mode: 0o600 });
410
+ if (current) renameSync(temporary, output);
411
+ else linkSync(temporary, output);
412
+ } catch (error: unknown) {
413
+ if (!current && (error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('final evidence current record appeared during publication');
414
+ throw error;
415
+ } finally {
416
+ try { unlinkSync(temporary); } catch (error: unknown) {
417
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
418
+ }
419
+ }
420
+ return output;
421
+ } finally {
422
+ if (lockFd !== undefined) {
423
+ try { closeSync(lockFd); } finally {
424
+ try { unlinkSync(lock); } catch (error: unknown) {
425
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
426
+ }
427
+ }
428
+ }
429
+ }
430
+ }
431
+
432
+ /** Re-reads the published record and verifies all bound source, build, and artifact bytes. */
433
+ export function checkFinalEvidence(rootInput: string): FinalEvidence {
434
+ const root = projectRoot(rootInput);
435
+ const path = regularProjectFile(root, '.omd/final-evidence.json');
436
+ const bytes = readFileSync(path);
437
+ let value: unknown;
438
+ try { value = JSON.parse(bytes.toString('utf8')) as unknown; } catch { throw new Error(`manifest is not valid JSON: ${path}`); }
439
+ const manifest = parseManifest(value);
440
+ const record = regularProjectFile(root, `.omd/final-evidence-runs/${manifest.runId}.json`);
441
+ if (!bytes.equals(readFileSync(record))) throw new Error(`final evidence current record does not match immutable run record: ${manifest.runId}`);
442
+ verifyBindings(root, manifest);
443
+ return manifest;
444
+ }