@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,459 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { closeSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { relative, resolve, sep } from 'node:path';
4
+ import { parse } from 'yaml';
5
+ import { validateTaskCoverageMatrix } from '../frame/check-ux.ts';
6
+ import { decodePng } from '../motion/energy.ts';
7
+ import { validateProbePlan, type ProbePlan, type ProbeResult } from '../probe/index.ts';
8
+
9
+ export const TASK_EVIDENCE_SCHEMA_VERSION = 1;
10
+ export interface TaskEvidence { schemaVersion: 1; surface: 'product' | 'mixed'; frame: Bound; composition: Bound; tasks: Task[]; }
11
+ interface Bound { path: string; sha256: string }
12
+ type ProbeRole = 'primary' | 'recovery' | 'invalid-submit';
13
+ interface Probe { planPath: string; planSha256: string; resultPath: string; resultSha256: string; role: ProbeRole; viewport: Viewport }
14
+ type Viewport = 'desktop' | 'mobile';
15
+ interface Render { path: string; sha256: string; viewport: Viewport }
16
+ interface Transient { path: string; sha256: string; captureMode: 'settled' | 'reduced-motion'; probeRole: 'primary' | 'recovery'; stateSelector: string; stepIndex: number; viewport: Viewport }
17
+ interface Task { id: string; context: 'production'; production: { route: string; locator: string; workObject: string }; probes: Probe[]; renders: Render[]; invalidSubmit?: Probe; transient?: Transient[] }
18
+ interface FrameRow { recovery: string; viewports: string; requirements: string }
19
+
20
+ const CACHE = '.omd/.cache/';
21
+ const SHA256 = /^[a-f0-9]{64}$/;
22
+ const RESERVED_ROUTE_SEGMENT = /(?:^|\/)(?:demo|storybook|showcase|fixture|demo[-_ ]fixture)(?:\/|$)/i;
23
+ const RESERVED_LABEL_TOKEN = /(?:^|[^a-z0-9])(?:showcase|storybook|demo[-_ ]fixture|fixture)(?=$|[^a-z0-9])/i;
24
+ const VIEWPORTS: Record<Viewport, { width: number; height: number }> = { desktop: { width: 1280, height: 900 }, mobile: { width: 390, height: 844 } };
25
+ const compare = (a: string, b: string) => Buffer.compare(Buffer.from(a), Buffer.from(b));
26
+ const digest = (bytes: Uint8Array | string) => createHash('sha256').update(bytes).digest('hex');
27
+ const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value);
28
+
29
+ function exact(value: Record<string, unknown>, keys: readonly string[], label: string): void {
30
+ const actual = Object.keys(value).sort(compare); const expected = [...keys].sort(compare);
31
+ if (actual.length !== expected.length || actual.some((key, i) => key !== expected[i])) throw new Error(`${label} has unknown or missing keys`);
32
+ }
33
+ function string(value: unknown, label: string): string {
34
+ if (typeof value !== 'string' || !value || value.trim() !== value) throw new Error(`${label} must be a canonical non-empty string`);
35
+ return value;
36
+ }
37
+ function path(value: unknown, label: string): string {
38
+ const result = string(value, label);
39
+ if (result.includes('\\') || result.includes('\0') || result.startsWith('/') || /^[A-Za-z]:\//.test(result) || result.split('/').some(part => !part || part === '.' || part === '..')) throw new Error(`${label} must be a safe project-relative path`);
40
+ return result;
41
+ }
42
+ function hash(value: unknown, label: string): string {
43
+ if (typeof value !== 'string' || !SHA256.test(value)) throw new Error(`${label} must be a lowercase SHA-256 digest`);
44
+ return value;
45
+ }
46
+ function cachePath(value: unknown, label: string): string {
47
+ const result = path(value, label);
48
+ if (!result.startsWith(CACHE)) throw new Error(`${label} must be cache-only`);
49
+ return result;
50
+ }
51
+ function rootOf(input: string): string {
52
+ const root = resolve(input); const stat = lstatSync(root);
53
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('project root must be a non-symlink directory');
54
+ return root;
55
+ }
56
+ function file(root: string, projectPath: string): string {
57
+ const safe = path(projectPath, 'path'); const absolute = resolve(root, ...safe.split('/'));
58
+ const rel = relative(root, absolute);
59
+ if (!rel || rel === '..' || rel.startsWith(`..${sep}`)) throw new Error(`path escapes project: ${projectPath}`);
60
+ let current = root;
61
+ for (const [index, part] of safe.split('/').entries()) {
62
+ current = resolve(current, part); const stat = lstatSync(current);
63
+ if (stat.isSymbolicLink() || (index === safe.split('/').length - 1 ? !stat.isFile() : !stat.isDirectory())) throw new Error(`not a regular project file: ${projectPath}`);
64
+ }
65
+ return absolute;
66
+ }
67
+ function canonical(value: unknown): string {
68
+ if (value === null || typeof value === 'boolean' || typeof value === 'string') return JSON.stringify(value);
69
+ if (typeof value === 'number') { if (!Number.isFinite(value)) throw new Error('canonical JSON rejects non-finite numbers'); return Object.is(value, -0) ? '0' : JSON.stringify(value); }
70
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
71
+ if (!isRecord(value)) throw new Error('canonical JSON rejects unsupported values');
72
+ return `{${Object.keys(value).sort(compare).map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
73
+ }
74
+ function bound(value: unknown, label: string, expected: string): Bound {
75
+ if (!isRecord(value)) throw new Error(`${label} must be an object`);
76
+ exact(value, ['path', 'sha256'], label);
77
+ if (value.path !== expected) throw new Error(`${label}.path must be ${expected}`);
78
+ return { path: expected, sha256: hash(value.sha256, `${label}.sha256`) };
79
+ }
80
+ function parseProbe(value: unknown, label: string, roles: readonly ProbeRole[]): Probe {
81
+ if (!isRecord(value)) throw new Error(`${label} must be an object`);
82
+ exact(value, ['planPath', 'planSha256', 'resultPath', 'resultSha256', 'role', 'viewport'], label);
83
+ if (typeof value.role !== 'string' || !roles.includes(value.role as ProbeRole)) throw new Error(`${label}.role is invalid`);
84
+ if (value.viewport !== 'desktop' && value.viewport !== 'mobile') throw new Error(`${label}.viewport is invalid`);
85
+ return { planPath: cachePath(value.planPath, `${label}.planPath`), planSha256: hash(value.planSha256, `${label}.planSha256`), resultPath: cachePath(value.resultPath, `${label}.resultPath`), resultSha256: hash(value.resultSha256, `${label}.resultSha256`), role: value.role as ProbeRole, viewport: value.viewport };
86
+ }
87
+ function parseRender(value: unknown, label: string): Render {
88
+ if (!isRecord(value)) throw new Error(`${label} must be an object`);
89
+ exact(value, ['path', 'sha256', 'viewport'], label);
90
+ if (value.viewport !== 'desktop' && value.viewport !== 'mobile') throw new Error(`${label}.viewport is invalid`);
91
+ return { path: cachePath(value.path, `${label}.path`), sha256: hash(value.sha256, `${label}.sha256`), viewport: value.viewport };
92
+ }
93
+ function parseTransient(value: unknown, label: string): Transient {
94
+ if (!isRecord(value)) throw new Error(`${label} must be an object`);
95
+ exact(value, ['path', 'sha256', 'captureMode', 'probeRole', 'stateSelector', 'stepIndex', 'viewport'], label);
96
+ if (value.captureMode !== 'settled' && value.captureMode !== 'reduced-motion') throw new Error(`${label}.captureMode is invalid`);
97
+ if (value.probeRole !== 'primary' && value.probeRole !== 'recovery') throw new Error(`${label}.probeRole is invalid`);
98
+ if (value.viewport !== 'desktop' && value.viewport !== 'mobile') throw new Error(`${label}.viewport is invalid`);
99
+ const stepIndex = value.stepIndex;
100
+ if (typeof stepIndex !== 'number' || !Number.isSafeInteger(stepIndex) || stepIndex < 0) throw new Error(`${label}.stepIndex is invalid`);
101
+ const artifactPath = cachePath(value.path, `${label}.path`);
102
+ if (!artifactPath.endsWith('.png')) throw new Error(`${label}.path must be a PNG`);
103
+ return { path: artifactPath, sha256: hash(value.sha256, `${label}.sha256`), captureMode: value.captureMode, probeRole: value.probeRole, stateSelector: string(value.stateSelector, `${label}.stateSelector`), stepIndex, viewport: value.viewport };
104
+ }
105
+ function reservedProductionTarget(production: Task['production']): boolean {
106
+ return RESERVED_ROUTE_SEGMENT.test(production.route) || RESERVED_LABEL_TOKEN.test(production.locator) || RESERVED_LABEL_TOKEN.test(production.workObject);
107
+ }
108
+ function parseTask(value: unknown, index: number): Task {
109
+ const label = `tasks[${index}]`; if (!isRecord(value)) throw new Error(`${label} must be an object`);
110
+ const allowed = ['id', 'context', 'production', 'probes', 'renders', 'invalidSubmit', 'transient'];
111
+ if (Object.keys(value).some(key => !allowed.includes(key))) throw new Error(`${label} has unknown keys`);
112
+ if (!Array.isArray(value.probes) || !Array.isArray(value.renders) || !isRecord(value.production) || value.context !== 'production') throw new Error(`${label} is invalid`);
113
+ exact(value.production, ['route', 'locator', 'workObject'], `${label}.production`);
114
+ const production = { route: string(value.production.route, `${label}.production.route`), locator: string(value.production.locator, `${label}.production.locator`), workObject: string(value.production.workObject, `${label}.production.workObject`) };
115
+ if (!production.route.startsWith('/') || /^\/\//.test(production.route) || reservedProductionTarget(production)) throw new Error(`${label} must identify a local non-demo production target`);
116
+ const probes = value.probes.map((item, n) => parseProbe(item, `${label}.probes[${n}]`, ['primary', 'recovery']));
117
+ const renders = value.renders.map((item, n) => parseRender(item, `${label}.renders[${n}]`));
118
+ if (!probes.some(item => item.role === 'primary') || new Set(probes.map(item => `${item.role}:${item.viewport}`)).size !== probes.length) throw new Error(`${label} requires unique role and viewport probe executions`);
119
+ if (!renders.length || new Set(renders.map(item => item.viewport)).size !== renders.length) throw new Error(`${label}.renders must have unique viewports`);
120
+ const invalidSubmit = value.invalidSubmit === undefined ? undefined : parseProbe(value.invalidSubmit, `${label}.invalidSubmit`, ['invalid-submit']);
121
+ if (value.transient !== undefined && (!Array.isArray(value.transient) || !value.transient.length)) throw new Error(`${label}.transient must be non-empty`);
122
+ const transient = value.transient?.map((item, n) => parseTransient(item, `${label}.transient[${n}]`));
123
+ const id = string(value.id, `${label}.id`);
124
+ if (!/^T[1-9]\d*$/.test(id)) throw new Error(`${label}.id is invalid`);
125
+ return { id, context: 'production', production, probes, renders, ...(invalidSubmit ? { invalidSubmit } : {}), ...(transient ? { transient } : {}) };
126
+ }
127
+ function parseManifest(value: unknown): TaskEvidence {
128
+ if (!isRecord(value)) throw new Error('task evidence manifest must be an object');
129
+ exact(value, ['schemaVersion', 'surface', 'frame', 'composition', 'tasks'], 'task evidence manifest');
130
+ if (value.schemaVersion !== 1 || (value.surface !== 'product' && value.surface !== 'mixed') || !Array.isArray(value.tasks)) throw new Error('task evidence manifest is invalid');
131
+ const tasks = value.tasks.map(parseTask);
132
+ if (!tasks.length || new Set(tasks.map(task => task.id)).size !== tasks.length || new Set(tasks.map(task => task.production.locator)).size !== tasks.length) throw new Error('task ids and production locators must be globally unique');
133
+ return { schemaVersion: 1, surface: value.surface, frame: bound(value.frame, 'frame', '.omd/frame.md'), composition: bound(value.composition, 'composition', '.omd/composition.md'), tasks };
134
+ }
135
+ function frameRows(bytes: Buffer, surface: TaskEvidence['surface']): Map<string, FrameRow> {
136
+ const source = bytes.toString('utf8'); const front = /^---\n([\s\S]*?)\n---/.exec(source); const frontmatter = front?.[1] === undefined ? {} : parse(front[1]);
137
+ if (!isRecord(frontmatter) || frontmatter.uxSurface !== surface) throw new Error(`frame uxSurface must be ${surface}`);
138
+ const sections = source.split(/^## Task coverage matrix[ \t]*$/gm).slice(1).map(part => part.split(/^## /m)[0] ?? ''); const section = sections[0];
139
+ if (sections.length !== 1 || section === undefined) throw new Error('frame must have exactly one task coverage matrix');
140
+ const errors = validateTaskCoverageMatrix(section); if (errors.length) throw new Error(`invalid frame task coverage matrix: ${errors.join('; ')}`);
141
+ const rows = new Map<string, FrameRow>();
142
+ for (const row of section.split('\n').map(row => row.trim()).filter(Boolean)) {
143
+ const cells = row.split('|').map(cell => cell.trim()); const id = cells.shift(); if (!id) throw new Error(`malformed frame task coverage row: ${row}`);
144
+ const fields = new Map<string, string>();
145
+ for (const cell of cells) { const match = /^(goal|start|actions|success|recovery|viewports|requirements):\s*(.+)$/.exec(cell); if (!match?.[1] || !match[2]) throw new Error(`malformed frame task coverage row: ${row}`); fields.set(match[1], match[2]); }
146
+ const recovery = fields.get('recovery'); const viewports = fields.get('viewports'); const requirements = fields.get('requirements');
147
+ if (recovery === undefined || viewports === undefined || requirements === undefined) throw new Error(`malformed frame task coverage row: ${row}`); rows.set(id, { recovery, viewports, requirements });
148
+ }
149
+ return rows;
150
+ }
151
+ function coverage(bytes: Buffer): Map<string, { route: string; locator: string }> {
152
+ const sections = bytes.toString('utf8').split(/^## UX task coverage[ \t]*$/gm).slice(1).map(part => part.split(/^## /m)[0] ?? ''); const section = sections[0];
153
+ if (sections.length !== 1 || section === undefined) throw new Error('composition must have exactly one ## UX task coverage section');
154
+ const rows = new Map<string, { route: string; locator: string }>();
155
+ for (const row of section.split('\n').map(line => line.trim()).filter(Boolean)) {
156
+ const match = /^(T[1-9]\d*)\s*\|\s*production:\s*(\/[^|\s]*)\s*\|\s*locator:\s*(\S+)\s*\|?\s*$/.exec(row);
157
+ if (!match?.[1] || !match[2] || !match[3] || rows.has(match[1])) throw new Error(`malformed composition UX task coverage row: ${row}`);
158
+ rows.set(match[1], { route: match[2], locator: match[3] });
159
+ }
160
+ return rows;
161
+ }
162
+ function expectationMatches(expected: NonNullable<ProbePlan['steps'][number]['expect']>[number], observed: ProbeResult['steps'][number]['expectations'][number]): boolean {
163
+ if (observed.type !== expected.type || !observed.ok) return false;
164
+ if (expected.type === 'url') return observed.value === expected.value;
165
+ if (observed.selector !== expected.selector) return false;
166
+ if (expected.type === 'visible' || expected.type === 'hidden') return true;
167
+ if (expected.type === 'text') return observed.value === expected.value;
168
+ if (expected.type !== 'attribute') return false;
169
+ return observed.name === expected.name && observed.value === expected.value;
170
+ }
171
+ function localProductionTarget(target: string, route: string): void {
172
+ let url: URL; try { url = new URL(target); } catch { throw new Error('probe target must be a localhost HTTP URL'); }
173
+ if (!['http:', 'https:'].includes(url.protocol) || !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) || url.username || url.password || url.pathname !== route || url.search || url.hash) throw new Error('probe result does not bind an exact localhost production route');
174
+ }
175
+ function activationIndex(plan: ProbePlan, locator: string): number {
176
+ return plan.steps.findIndex(step => (step.action === 'click' || step.action === 'press') && step.selector === locator);
177
+ }
178
+ function activationStep(plan: ProbePlan, result: ProbeResult, locator: string): { index: number; expectations: NonNullable<ProbePlan['steps'][number]['expect']> } {
179
+ const index = activationIndex(plan, locator);
180
+ if (index < 0) throw new Error('probe activation must use the production locator');
181
+ const expectations = plan.steps[index]!.expect ?? [];
182
+ const observed = result.steps[index]!;
183
+ if (!expectations.some((expectation, expectationIndex) => expectationMatches(expectation, observed.expectations[expectationIndex]!))) {
184
+ throw new Error('probe activation requires a successful observable');
185
+ }
186
+ return { index, expectations };
187
+ }
188
+ function verifyProbe(root: string, evidence: Probe, task: Task): ProbeResult {
189
+ const planBytes = readFileSync(file(root, evidence.planPath)); const resultBytes = readFileSync(file(root, evidence.resultPath));
190
+ if (digest(planBytes) !== evidence.planSha256 || digest(resultBytes) !== evidence.resultSha256) throw new Error(`probe digest mismatch for ${task.id}`);
191
+ const plan = validateProbePlan(JSON.parse(planBytes.toString('utf8'))); const result = JSON.parse(resultBytes.toString('utf8')) as ProbeResult;
192
+ localProductionTarget(result.target, task.production.route);
193
+ const viewport = VIEWPORTS[evidence.viewport];
194
+ if (result.name !== plan.name || result.viewport?.width !== viewport.width || result.viewport?.height !== viewport.height || !Array.isArray(result.steps) || result.steps.length !== plan.steps.length || !Array.isArray(result.warnings) || result.warnings.some(warning => warning.id === 'PROBE-DEAD-CONTROL')) throw new Error(`probe result does not bind a successful local task route: ${task.id}`);
195
+ for (const [index, step] of plan.steps.entries()) {
196
+ const actual = result.steps[index]!; const expected = step.expect ?? [];
197
+ if (actual.action !== step.action || actual.selector !== step.selector || actual.key !== step.key || !actual.ok || actual.expectations.length !== expected.length) throw new Error(`probe result does not match plan: ${task.id}`);
198
+ for (const [n, expectation] of expected.entries()) if (!expectationMatches(expectation, actual.expectations[n]!)) throw new Error(`probe expectation failed or differs from plan: ${task.id}`);
199
+ }
200
+ activationStep(plan, result, task.production.locator);
201
+ return result;
202
+ }
203
+ function verifyInvalidSubmit(root: string, evidence: Probe, task: Task): ProbeResult {
204
+ const result = verifyProbe(root, evidence, task); const plan = validateProbePlan(JSON.parse(readFileSync(file(root, evidence.planPath), 'utf8')));
205
+ for (const [fillIndex, fill] of plan.steps.entries()) {
206
+ if (fill.action !== 'fill') continue;
207
+ const activation = plan.steps.findIndex((step, index) => index > fillIndex && (step.action === 'click' || step.action === 'press') && step.selector === task.production.locator);
208
+ if (activation < 0) continue;
209
+ const observed = result.steps[activation]!;
210
+ const expectations = plan.steps[activation]!.expect ?? [];
211
+ const error = expectations.some((expectation, index) => (expectation.type === 'visible' || expectation.type === 'text') && expectationMatches(expectation, observed.expectations[index]!) && /error|invalid|required|must/i.test(expectation.type === 'text' ? expectation.value : expectation.selector));
212
+ const preserved = expectations.some((expectation, index) => expectation.type === 'attribute' && expectation.selector === fill.selector && expectation.name === 'value' && expectation.value === fill.value && expectationMatches(expectation, observed.expectations[index]!));
213
+ if (error && preserved) return result;
214
+ }
215
+ throw new Error(`invalid-submit evidence must fill, activate the production locator, then expose an actionable error and preserved value: ${task.id}`);
216
+ }
217
+ function verifyPng(bytes: Buffer, expected: { width: number; height: number }, label: string): ReturnType<typeof decodePng> {
218
+ const png = decodePng(bytes);
219
+ if (png.width !== expected.width || png.height !== expected.height) throw new Error(`${label} must have exact ${expected.width}x${expected.height} viewport pixels`);
220
+ return png;
221
+ }
222
+ function hasMeaningfulVisibleVariation(png: ReturnType<typeof decodePng>): boolean {
223
+ const total = png.width * png.height;
224
+ const colors = new Map<string, number>();
225
+ const visible = new Uint8Array(total);
226
+ const colorAt = (index: number): string => {
227
+ const offset = index * png.channels;
228
+ const alpha = png.channels === 4 ? png.pixels[offset + 3]! : 255;
229
+ if (alpha === 0) return '';
230
+ visible[index] = 1;
231
+ return [0, 1, 2].map(channel => Math.round(png.pixels[offset + channel]! * alpha / 255)).join(',');
232
+ };
233
+ for (let index = 0; index < total; index += 1) { const color = colorAt(index); if (color) colors.set(color, (colors.get(color) ?? 0) + 1); }
234
+ const dominant = [...colors.entries()].sort((a, b) => b[1] - a[1])[0]?.[0];
235
+ const required = Math.ceil(visible.reduce((sum, value) => sum + value, 0) * 0.005);
236
+ const seen = new Uint8Array(total);
237
+ for (let start = 0; start < total; start += 1) {
238
+ if (!visible[start] || seen[start] || colorAt(start) === dominant) continue;
239
+ const queue = [start]; seen[start] = 1; let size = 0; let minX = png.width; let maxX = 0; let minY = png.height; let maxY = 0;
240
+ for (let cursor = 0; cursor < queue.length; cursor += 1) {
241
+ const index = queue[cursor]!; const x = index % png.width; const y = Math.floor(index / png.width); size += 1; minX = Math.min(minX, x); maxX = Math.max(maxX, x); minY = Math.min(minY, y); maxY = Math.max(maxY, y);
242
+ for (const next of [index - 1, index + 1, index - png.width, index + png.width]) if (next >= 0 && next < total && Math.abs((next % png.width) - x) + Math.abs(Math.floor(next / png.width) - y) === 1 && visible[next] && !seen[next] && colorAt(next) !== dominant) { seen[next] = 1; queue.push(next); }
243
+ }
244
+ if (size >= required && size / ((maxX - minX + 1) * (maxY - minY + 1)) >= 0.25) return true;
245
+ }
246
+ return false;
247
+ }
248
+ function verifyTransient(root: string, task: Task, results: Map<string, ProbeResult>): void {
249
+ for (const artifact of task.transient ?? []) {
250
+ const bytes = readFileSync(file(root, artifact.path)); if (digest(bytes) !== artifact.sha256) throw new Error(`transient digest mismatch: ${task.id}`);
251
+ const result = results.get(`${artifact.probeRole}:${artifact.viewport}`); if (!result) throw new Error(`transient must bind an existing ${artifact.probeRole} probe: ${task.id}`);
252
+ const viewport = VIEWPORTS[artifact.viewport];
253
+ const probe = task.probes.find(item => item.role === artifact.probeRole && item.viewport === artifact.viewport)!;
254
+ const plan = validateProbePlan(JSON.parse(readFileSync(file(root, probe.planPath), 'utf8')));
255
+ const step = plan.steps[artifact.stepIndex]; const observed = result.steps[artifact.stepIndex];
256
+ if (!step || !observed || (step.action !== 'click' && step.action !== 'press') || step.selector !== task.production.locator) throw new Error(`transient state must be a successful production activation observable: ${task.id}`);
257
+ const matchesState = (step.expect ?? []).some((expectation, index) => expectation.type !== 'url' && expectation.selector === artifact.stateSelector && expectationMatches(expectation, observed.expectations[index]!));
258
+ if (!matchesState) throw new Error(`transient state must be a successful production activation observable: ${task.id}`);
259
+ if (!hasMeaningfulVisibleVariation(verifyPng(bytes, viewport, `transient evidence for ${task.id}`))) throw new Error(`transient evidence must contain meaningful coherent visible PNG pixels: ${task.id}`);
260
+ }
261
+ }
262
+ function verify(root: string, evidence: TaskEvidence): void {
263
+ const frame = readFileSync(file(root, evidence.frame.path));
264
+ const composition = readFileSync(file(root, evidence.composition.path));
265
+ if (digest(frame) !== evidence.frame.sha256 || digest(composition) !== evidence.composition.sha256) {
266
+ throw new Error('frame or composition digest mismatch');
267
+ }
268
+
269
+ const rows = frameRows(frame, evidence.surface);
270
+ const compositionCoverage = coverage(composition);
271
+ const ids = evidence.tasks.map(task => task.id);
272
+ if (rows.size !== ids.length || compositionCoverage.size !== ids.length || ids.some(id => !rows.has(id) || !compositionCoverage.has(id))) {
273
+ throw new Error('task evidence must match the exact frame and composition task sets');
274
+ }
275
+
276
+ for (const task of evidence.tasks) {
277
+ const row = rows.get(task.id)!;
278
+ const covered = compositionCoverage.get(task.id)!;
279
+ if (covered.route !== task.production.route || covered.locator !== task.production.locator) {
280
+ throw new Error(`composition coverage does not bind production task: ${task.id}`);
281
+ }
282
+ if ((!/^N\/A\b/i.test(row.recovery)) !== task.probes.some(probe => probe.role === 'recovery')) {
283
+ throw new Error(`recovery evidence does not match frame: ${task.id}`);
284
+ }
285
+ if (/\binvalid-submit\b/i.test(row.requirements) !== Boolean(task.invalidSubmit) || /\btransient\b/i.test(row.requirements) !== Boolean(task.transient)) {
286
+ throw new Error(`task evidence requirements do not match frame: ${task.id}`);
287
+ }
288
+
289
+ const requiredViewports: Viewport[] = [];
290
+ if (/\bdesktop\b/i.test(row.viewports)) requiredViewports.push('desktop');
291
+ if (/\bmobile\b/i.test(row.viewports)) requiredViewports.push('mobile');
292
+ const primary = task.probes.filter(probe => probe.role === 'primary');
293
+ const recovery = task.probes.filter(probe => probe.role === 'recovery');
294
+ for (const viewport of requiredViewports) {
295
+ if (primary.filter(probe => probe.viewport === viewport).length !== 1) throw new Error(`primary probe must cover required ${viewport} viewport: ${task.id}`);
296
+ if (recovery.length && recovery.filter(probe => probe.viewport === viewport).length !== 1) throw new Error(`recovery probe must cover required ${viewport} viewport: ${task.id}`);
297
+ }
298
+ if (task.invalidSubmit && !requiredViewports.includes(task.invalidSubmit.viewport)) throw new Error(`invalid-submit probe must use a required viewport: ${task.id}`);
299
+ for (const recoveryProbe of recovery) {
300
+ if (primary.some(probe => probe.viewport === recoveryProbe.viewport && (probe.planPath === recoveryProbe.planPath || probe.planSha256 === recoveryProbe.planSha256 || probe.resultPath === recoveryProbe.resultPath || probe.resultSha256 === recoveryProbe.resultSha256))) throw new Error(`recovery plan and result must be distinct from primary: ${task.id}`);
301
+ }
302
+
303
+ const results = new Map<string, ProbeResult>();
304
+ for (const probe of [...primary, ...recovery]) results.set(`${probe.role}:${probe.viewport}`, verifyProbe(root, probe, task));
305
+ if (task.invalidSubmit) verifyInvalidSubmit(root, task.invalidSubmit, task);
306
+
307
+ for (const render of task.renders) {
308
+ const bytes = readFileSync(file(root, render.path));
309
+ if (!render.path.endsWith('.png') || digest(bytes) !== render.sha256) {
310
+ throw new Error(`render digest or format mismatch: ${task.id}`);
311
+ }
312
+ verifyPng(bytes, VIEWPORTS[render.viewport], `render evidence for ${task.id}`);
313
+ }
314
+
315
+ const hasDesktop = task.renders.some(render => render.viewport === 'desktop');
316
+ const hasMobile = task.renders.some(render => render.viewport === 'mobile');
317
+ if (!hasDesktop || (/\bmobile\b/i.test(row.viewports) && !hasMobile) || (/\bdesktop\b/i.test(row.viewports) && !hasDesktop)) {
318
+ throw new Error(`required viewport evidence is missing: ${task.id}`);
319
+ }
320
+ verifyTransient(root, task, results);
321
+ }
322
+ }
323
+ function readManifest(input: string): unknown {
324
+ regularFile(input, 'manifest');
325
+ return JSON.parse(readFileSync(input, 'utf8'));
326
+ }
327
+ function lstatMissing(pathname: string): boolean {
328
+ try {
329
+ lstatSync(pathname);
330
+ return false;
331
+ } catch (error: unknown) {
332
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true;
333
+ throw error;
334
+ }
335
+ }
336
+ function regularFile(pathname: string, label: string): void {
337
+ const stat = lstatSync(pathname);
338
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`${label} must be a regular non-symlink file`);
339
+ }
340
+ function ensureDirectory(root: string, projectPath: string): string {
341
+ let current = root;
342
+ for (const part of projectPath.split('/')) {
343
+ current = resolve(current, part);
344
+ if (lstatMissing(current)) {
345
+ try {
346
+ mkdirSync(current, { mode: 0o700 });
347
+ } catch (error: unknown) {
348
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
349
+ }
350
+ }
351
+ const stat = lstatSync(current);
352
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
353
+ throw new Error(`publication directory must be a non-symlink directory: ${projectPath}`);
354
+ }
355
+ }
356
+ return current;
357
+ }
358
+ function cleanupTemporary(pathname: string): void {
359
+ if (lstatMissing(pathname)) return;
360
+ regularFile(pathname, 'task evidence temporary record');
361
+ unlinkSync(pathname);
362
+ }
363
+ function acquirePublicationLock(lock: string): number {
364
+ if (!lstatMissing(lock)) throw new Error('task evidence publication lock already exists');
365
+ try {
366
+ const fd = openSync(lock, 'wx', 0o600);
367
+ regularFile(lock, 'task evidence publication lock');
368
+ return fd;
369
+ } catch (error: unknown) {
370
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('task evidence publication lock already exists');
371
+ throw error;
372
+ }
373
+ }
374
+ function publishImmutable(pathname: string, bytes: Buffer): void {
375
+ if (lstatMissing(pathname)) {
376
+ try {
377
+ writeFileSync(pathname, bytes, { flag: 'wx', mode: 0o600 });
378
+ } catch (error: unknown) {
379
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
380
+ }
381
+ }
382
+ regularFile(pathname, 'task evidence immutable record');
383
+ if (!readFileSync(pathname).equals(bytes)) throw new Error('task evidence immutable record bytes differ');
384
+ }
385
+ function publishCurrent(omd: string, output: string, bytes: Buffer): void {
386
+ const temporary = resolve(omd, `.task-evidence.${process.pid}.${randomBytes(12).toString('hex')}.tmp`);
387
+ if (!lstatMissing(temporary)) throw new Error('task evidence temporary record already exists');
388
+ try {
389
+ writeFileSync(temporary, bytes, { flag: 'wx', mode: 0o600 });
390
+ regularFile(temporary, 'task evidence temporary record');
391
+
392
+ if (!lstatMissing(output)) regularFile(output, 'task evidence current record');
393
+ if (lstatMissing(output)) {
394
+ try {
395
+ linkSync(temporary, output);
396
+ } catch (error: unknown) {
397
+ if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
398
+ throw new Error('task evidence current record appeared during publication');
399
+ }
400
+ throw error;
401
+ }
402
+ } else {
403
+ renameSync(temporary, output);
404
+ }
405
+
406
+ regularFile(output, 'task evidence current record');
407
+ if (!readFileSync(output).equals(bytes)) throw new Error('task evidence current record bytes differ');
408
+ } finally {
409
+ cleanupTemporary(temporary);
410
+ }
411
+ }
412
+ function releasePublicationLock(lock: string, fd: number): void {
413
+ closeSync(fd);
414
+ regularFile(lock, 'task evidence publication lock');
415
+ unlinkSync(lock);
416
+ }
417
+ export function publishTaskEvidence(rootInput: string, input: string): string {
418
+ const root = rootOf(rootInput);
419
+ const relativeInput = relative(root, resolve(input)).split(sep).join('/');
420
+ if (!relativeInput.startsWith(CACHE)) throw new Error(`task evidence manifest must be cache-only: ${CACHE}`);
421
+
422
+ const evidence = parseManifest(readManifest(file(root, relativeInput)));
423
+ verify(root, evidence);
424
+ const bytes = Buffer.from(`${canonical(evidence)}\n`);
425
+ const omd = ensureDirectory(root, '.omd');
426
+ const runs = ensureDirectory(root, '.omd/task-evidence-runs');
427
+ const immutable = resolve(runs, `${digest(bytes)}.json`);
428
+ const output = resolve(omd, 'task-evidence.json');
429
+ const lock = resolve(omd, '.task-evidence.lock');
430
+ const lockFd = acquirePublicationLock(lock);
431
+ try {
432
+ publishImmutable(immutable, bytes);
433
+ publishCurrent(omd, output, bytes);
434
+ return output;
435
+ } finally {
436
+ releasePublicationLock(lock, lockFd);
437
+ }
438
+ }
439
+ export function checkTaskEvidence(rootInput: string): TaskEvidence {
440
+ const root = rootOf(rootInput);
441
+ const current = file(root, '.omd/task-evidence.json');
442
+ const bytes = readFileSync(current);
443
+ const evidence = parseManifest(JSON.parse(bytes.toString('utf8')));
444
+
445
+ let immutable: string;
446
+ try {
447
+ immutable = file(root, `.omd/task-evidence-runs/${digest(bytes)}.json`);
448
+ } catch (error: unknown) {
449
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
450
+ throw new Error('task evidence immutable publication is missing');
451
+ }
452
+ throw error;
453
+ }
454
+ if (!bytes.equals(readFileSync(immutable))) {
455
+ throw new Error('task evidence current record does not match immutable publication');
456
+ }
457
+ verify(root, evidence);
458
+ return evidence;
459
+ }