@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
package/bin/omd.ts ADDED
@@ -0,0 +1,1774 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync, readdirSync } from 'node:fs';
3
+ import { join, dirname, resolve, relative } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { stringify } from 'yaml';
6
+ import { readFrame } from '../core/frame/index.ts';
7
+ import { writeFrameRecord, reframe, setGenerator, logDecision, logChoice, logTaste, tasteProfile } from '../core/frame/write.ts';
8
+ import { logRun, readHistory } from '../core/history/index.ts';
9
+ import { analyse } from '../core/coach/index.ts';
10
+ import { findLeakedRationale } from '../core/rules/leakage.ts';
11
+ import { checkAttribution } from '../core/rules/attribution.ts';
12
+ import { checkMotionSpec } from '../core/rules/motion-spec.ts';
13
+ import { discoverEvidence, generateDesignMd, validateDesignMd } from '../core/design/index.ts';
14
+ import { validateCopyDeck, validateCopyReviewReport } from '../core/copy/index.ts';
15
+ import { checkInteractionStates } from '../core/design/interaction-states.ts';
16
+ import { checkFrameUx } from '../core/frame/check-ux.ts';
17
+ import { scanSlopSource } from '../core/slop/index.ts';
18
+ import { validateCompositionContract } from '../core/composition-contract/index.ts';
19
+ import { validateSourceSeal, writeSourceSeal } from '../core/source-seal/index.ts';
20
+ import { checkFinalEvidence, finalizeFinalEvidence } from '../core/evidence/final.ts';
21
+ import { checkTaskEvidence, publishTaskEvidence } from '../core/evidence/task.ts';
22
+ import { computeStack } from '../core/stack/index.ts';
23
+ import { scanTextSlop } from '../core/slop/text-slop.ts';
24
+ import { evaluateVisualRichness } from '../core/composition-contract/visual-richness.ts';
25
+ import type { VisualRichnessRegister } from '../core/composition-contract/visual-richness.ts';
26
+ import type { Category, EnergyCurve, Layer, RawIr, Violation } from '../core/types.ts';
27
+
28
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..');
29
+
30
+ interface Opts {
31
+ _: string[];
32
+ json?: boolean;
33
+ ir?: string;
34
+ layer?: string;
35
+ out?: string;
36
+ viewport?: string;
37
+ problem?: string;
38
+ reframe?: string;
39
+ why?: string;
40
+ category?: string;
41
+ set?: string;
42
+ chose?: string;
43
+ to?: string;
44
+ because?: string;
45
+ as?: string;
46
+ add?: string;
47
+ noLog?: boolean;
48
+ /** Skip the energy (motion) capture on `omd ref add` — one fewer browser launch for non-motion refs. */
49
+ noEnergy?: boolean;
50
+ selector?: string;
51
+ image?: boolean;
52
+ filmstrip?: boolean;
53
+ squint?: boolean;
54
+ fullPage?: boolean;
55
+ fromUser?: boolean;
56
+ all?: boolean;
57
+ plan?: string;
58
+ render?: string;
59
+ observed?: string;
60
+ changed?: string;
61
+ kind?: string;
62
+ evidence?: string;
63
+ input?: string;
64
+ /** Capture a full-resolution structural blueprint of the selected component. */
65
+ blueprint?: boolean;
66
+ /** Persist a scoped component screenshot alongside its blueprint (`omd ref add --selector … --shot`). */
67
+ shot?: boolean;
68
+ /** Directory of pages for cross-page site consistency check (`omd check --site <dir>`). */
69
+ site?: string;
70
+ /** Similarity threshold for `omd figma diff` / `omd target diff` (0–1, default 0.97). */
71
+ threshold?: string;
72
+ /** Force re-export even when a cached Figma export exists (`omd figma diff --fresh`). */
73
+ fresh?: boolean;
74
+ /** Named visual target to diff against (`omd target diff --target <name>`). */
75
+ target?: string;
76
+ /** Validate design.md sections rather than discover/generate (`omd design --check`). */
77
+ check?: boolean;
78
+ /** Validate the preserved copy-eye report structure (`omd copy --review-check`). */
79
+ reviewCheck?: boolean;
80
+ /** UX anchor: what task does the user arrive with? (`omd frame set --task "..."`) */
81
+ task?: string;
82
+ /** UX anchor: most frequent action on the primary screen. (`omd frame set --frequent-action "..."`) */
83
+ frequentAction?: string;
84
+ /** UX anchor: costliest error and its recovery path. (`omd frame set --costliest-error "..."`) */
85
+ costliestError?: string;
86
+ /** UX anchor: surface classification — marketing | product | editorial | mixed. (`omd frame set --surface "..."`) */
87
+ surface?: string;
88
+ /** Task coverage matrix rows for product or mixed surfaces. (`omd frame set --task-matrix "T1 …"`) */
89
+ taskMatrix?: string;
90
+ /** Render desktop+mobile fixed and full-page proofs in one browser (`omd render <page> --proofs -o <prefix>`). */
91
+ proofs?: boolean;
92
+ /** Register override for `omd visual-richness --register quiet|confident|showpiece`. */
93
+ register?: string;
94
+ }
95
+
96
+ const FLAGS = new Set(['json', 'no-log', 'no-energy', 'image', 'filmstrip', 'squint', 'full-page', 'from-user', 'all', 'blueprint', 'shot', 'proofs', 'fresh', 'check', 'review-check']);
97
+ const ALIASES: Record<string, keyof Opts> = {
98
+ o: 'out',
99
+ 'no-log': 'noLog',
100
+ 'no-energy': 'noEnergy',
101
+ 'from-user': 'fromUser',
102
+ 'full-page': 'fullPage',
103
+ 'frequent-action': 'frequentAction',
104
+ 'costliest-error': 'costliestError',
105
+ 'review-check': 'reviewCheck',
106
+ 'task-matrix': 'taskMatrix',
107
+ };
108
+
109
+ function parseArgs(args: string[]): Opts {
110
+ const opts: Opts = { _: [] };
111
+ for (let i = 0; i < args.length; i++) {
112
+ const arg = args[i]!;
113
+ if (!arg.startsWith('-')) {
114
+ opts._.push(arg);
115
+ continue;
116
+ }
117
+ const name = arg.replace(/^--?/, '');
118
+ const key = ALIASES[name] ?? (name as keyof Opts);
119
+ const bag = opts as unknown as Record<string, unknown>;
120
+ if (FLAGS.has(name)) bag[key] = true;
121
+ else bag[key] = args[++i];
122
+ }
123
+ return opts;
124
+ }
125
+
126
+ async function rawIrFor(opts: Opts, target: string | undefined, selector?: string | null): Promise<RawIr> {
127
+ if (target) {
128
+ const { extractIr, parseViewport } = await import('../core/render/index.ts');
129
+ return extractIr(target, { viewport: parseViewport(opts.viewport), selector: selector ?? null });
130
+ }
131
+ const irPath = opts.ir ?? join(process.cwd(), '.omd', '.cache', 'ir.json');
132
+ return JSON.parse(readFileSync(irPath, 'utf8')) as RawIr;
133
+ }
134
+
135
+ async function cmdIr(opts: Opts): Promise<never> {
136
+ const raw = await rawIrFor(opts, opts._[0]);
137
+ const out = opts.out ?? join(process.cwd(), '.omd', '.cache', 'ir.json');
138
+ mkdirSync(dirname(resolve(out)), { recursive: true });
139
+ writeFileSync(out, JSON.stringify(raw, null, 2));
140
+ console.log(`${out} (${raw.nodes.length} nodes, ${Object.keys(raw.tokens ?? {}).length} tokens)`);
141
+ process.exit(0);
142
+ }
143
+
144
+ async function cmdRender(opts: Opts): Promise<never> {
145
+ const { renderPage, renderProofs, renderFilmstrip, parseViewport } = await import('../core/render/index.ts');
146
+ const target = opts._[0];
147
+ if (!target) usage();
148
+
149
+ if (opts.filmstrip) {
150
+ // Filmstrip: 4–6 viewport screenshots at ~300ms intervals, plus an HTML index.
151
+ // The HTML index is the deliverable: eye reads it to see what appeared when.
152
+ const out = opts.out ?? 'filmstrip.html';
153
+ mkdirSync(dirname(resolve(out)), { recursive: true });
154
+ const frames = await renderFilmstrip(target, { viewport: parseViewport(opts.viewport), out });
155
+ // Report the index path (sans extension already included) alongside the frame count.
156
+ const indexPath = out.endsWith('.html') || out.endsWith('.htm') ? out : `${out}.html`;
157
+ console.log(`${indexPath} (${frames.length} frames)`);
158
+ process.exit(0);
159
+ }
160
+
161
+ if (opts.proofs) {
162
+ // One browser launch produces all four sketch/craft proofs (fixed + full-page, desktop + mobile).
163
+ const prefix = opts.out ?? 'proof';
164
+ mkdirSync(dirname(resolve(prefix)), { recursive: true });
165
+ for (const p of await renderProofs(target, prefix)) console.log(p);
166
+ process.exit(0);
167
+ }
168
+
169
+ const out = opts.out ?? 'shot.png';
170
+ mkdirSync(dirname(resolve(out)), { recursive: true });
171
+ await renderPage(target, {
172
+ viewport: parseViewport(opts.viewport), out,
173
+ ...(opts.squint ? { squint: true } : {}),
174
+ ...(opts.fullPage ? { fullPage: true } : {}),
175
+ });
176
+ console.log(out);
177
+ process.exit(0);
178
+ }
179
+
180
+ async function cmdProbe(opts: Opts): Promise<never> {
181
+ const target = opts._[0];
182
+ if (!target) throw new Error('usage: omd probe <page> [--plan path] [--json] [--out path]');
183
+ const { readProbePlan, runProbe, writeProbeResult } = await import('../core/probe/index.ts');
184
+ const planPath = resolve(opts.plan ?? join(process.cwd(), '.omd', 'probes', 'primary.json'));
185
+ if (!existsSync(planPath)) throw new Error(`probe plan not found: ${planPath}`);
186
+ const result = await runProbe(target, readProbePlan(planPath));
187
+ const safe = result.name.replace(/[^a-z0-9_-]+/gi, '-').replace(/^-|-$/g, '') || 'probe';
188
+ const out = resolve(opts.out ?? join(process.cwd(), '.omd', '.cache', 'probes', `${safe}.json`));
189
+ writeProbeResult(out, result);
190
+ if (opts.json) process.stdout.write(JSON.stringify(result));
191
+ else {
192
+ for (const warning of result.warnings) console.log(`[${warning.severity}] ${warning.id}: ${warning.message}`);
193
+ console.log(out);
194
+ }
195
+ process.exit(result.warnings.length ? 1 : 0);
196
+ }
197
+
198
+ async function cmdConfig(sub: string | undefined, opts: Opts): Promise<never> {
199
+ const { readConfig, setCheckpoint } = await import('../core/config/index.ts');
200
+ if (sub === 'show') {
201
+ console.log(JSON.stringify(readConfig(process.cwd()), null, 2));
202
+ process.exit(0);
203
+ }
204
+ if (sub === 'set' && opts._[0] === 'checkpoint' && opts._[1]) {
205
+ console.log(setCheckpoint(process.cwd(), opts._[1]));
206
+ process.exit(0);
207
+ }
208
+ throw new Error('usage: omd config set checkpoint none|concept|structure|both | omd config show');
209
+ }
210
+
211
+ async function cmdCraft(sub: string | undefined, opts: Opts): Promise<never> {
212
+ const { readCraft, recordCraft } = await import('../core/craft/index.ts');
213
+ if (sub === 'checkpoint') {
214
+ const phase = opts._[0];
215
+ if (phase !== 'semantic' && phase !== 'visual') {
216
+ throw new Error('usage: omd craft checkpoint semantic|visual --render <path> --observed "..." --changed "..."');
217
+ }
218
+ console.log(recordCraft(process.cwd(), {
219
+ phase, render: opts.render ?? '', observed: opts.observed ?? '', changed: opts.changed ?? '',
220
+ }));
221
+ process.exit(0);
222
+ }
223
+ if (sub === 'status') {
224
+ const records = readCraft(process.cwd());
225
+ if (opts.json) process.stdout.write(JSON.stringify(records));
226
+ else if (!records.length) console.log('No craft checkpoints recorded.');
227
+ else for (const record of records) console.log(`${record.phase}: ${record.observed} -> ${record.changed} (${record.render})`);
228
+ process.exit(0);
229
+ }
230
+ throw new Error('usage: omd craft checkpoint ... | omd craft status [--json]');
231
+ }
232
+
233
+ /**
234
+ * Cross-page site consistency check: `omd check --site <dir>`.
235
+ *
236
+ * Loads every .html file (and .json IR cache) in the given directory, extracts
237
+ * IR for each page, compares their design ladders and token coverage, and warns
238
+ * when pages disagree. The comparison logic (checkSite) is pure; only the
239
+ * extraction here touches the browser.
240
+ *
241
+ * CLI shape: `omd check --site ./dist` — all .html files in the directory.
242
+ * Multiple positional args also work: `omd check a.html b.html`
243
+ * (when --site is absent and opts._ has ≥2 entries).
244
+ */
245
+ async function cmdCheckSite(opts: Opts): Promise<never> {
246
+ const { normalize } = await import('../core/ir/normalize.ts');
247
+ const { extractInvariants } = await import('../core/ref/invariants.ts');
248
+ const { checkSite } = await import('../core/site/index.ts');
249
+ const { extractIr, parseViewport } = await import('../core/render/index.ts');
250
+
251
+ // Resolve the list of page paths: either --site <dir> (glob all HTML files)
252
+ // or multiple positional args.
253
+ let pagePaths: string[] = [];
254
+ if (opts.site) {
255
+ const dir = resolve(opts.site);
256
+ const { readdirSync: rds } = await import('node:fs');
257
+ try {
258
+ pagePaths = rds(dir)
259
+ .filter((f) => f.endsWith('.html') || f.endsWith('.htm'))
260
+ .map((f) => join(dir, f))
261
+ .sort();
262
+ } catch (e) {
263
+ console.error(`cannot read directory: ${opts.site} — ${e instanceof Error ? e.message : String(e)}`);
264
+ process.exit(1);
265
+ }
266
+ if (pagePaths.length === 0) {
267
+ console.error(`no .html files found in: ${opts.site}`);
268
+ process.exit(1);
269
+ }
270
+ } else {
271
+ // Multiple positional args
272
+ pagePaths = opts._.map((p) => resolve(p));
273
+ }
274
+
275
+ if (pagePaths.length < 2) {
276
+ console.error('--site requires at least 2 pages to compare; use `omd check <page>` for a single-page check');
277
+ process.exit(1);
278
+ }
279
+
280
+ const viewport = parseViewport(opts.viewport);
281
+
282
+ const pages: Array<{ path: string; invariants: import('../core/types.ts').Invariants; tokens?: Record<string, string> }> = [];
283
+ for (const pagePath of pagePaths) {
284
+ try {
285
+ const raw = await extractIr(pagePath, { viewport, selector: null });
286
+ const ir = normalize(raw);
287
+ const invariants = extractInvariants(ir);
288
+ pages.push({ path: pagePath, invariants, ...(raw.tokens ? { tokens: raw.tokens } : {}) });
289
+ console.error(` extracted: ${pagePath} (${ir.nodes.length} nodes)`);
290
+ } catch (e) {
291
+ const msg = e instanceof Error ? e.message : String(e);
292
+ console.error(` skipped: ${pagePath} — ${msg}`);
293
+ }
294
+ }
295
+
296
+ if (pages.length < 2) {
297
+ console.error('fewer than 2 pages extracted successfully — cannot compare');
298
+ process.exit(1);
299
+ }
300
+
301
+ const violations = checkSite(pages);
302
+
303
+ if (opts.json) {
304
+ process.stdout.write(JSON.stringify(violations));
305
+ } else {
306
+ for (const v of violations) {
307
+ console.log(`[${v.severity}] ${v.id}: ${v.message}`);
308
+ }
309
+ if (violations.length === 0) console.log('ok — no cross-page drift detected');
310
+ }
311
+
312
+ process.exit(violations.length > 0 ? 1 : 0);
313
+ }
314
+
315
+ async function cmdCheck(opts: Opts): Promise<never> {
316
+ // Route to site-wide consistency check when --site is present or ≥2 positional args given.
317
+ if (opts.site || opts._.length >= 2) return cmdCheckSite(opts);
318
+
319
+ const { normalize } = await import('../core/ir/normalize.ts');
320
+ const { loadRules, check } = await import('../core/rules/engine.ts');
321
+
322
+ const ir = normalize(await rawIrFor(opts, opts._[0]));
323
+ const rules = loadRules(join(root, 'core', 'rules', 'builtin'));
324
+ const layers = opts.layer?.split(',').map((l) => Number(l.trim()) as Layer);
325
+ const categories = opts.category?.split(',').map((c) => c.trim()) as Category[] | undefined;
326
+ const violations = check(ir, rules, { ...(layers ? { layers } : {}), ...(categories ? { categories } : {}) });
327
+
328
+ // Design rationale belongs in .omd/, never in the shipped copy. Checked separately from
329
+ // the YAML rules because it compares page text against these two records, not the IR alone.
330
+ const frameRecords: string[] = [];
331
+ for (const name of ['frame.md', 'decisions.md']) {
332
+ const path = join(process.cwd(), '.omd', name);
333
+ if (existsSync(path)) frameRecords.push(readFileSync(path, 'utf8'));
334
+ }
335
+ const leaks = findLeakedRationale(ir, frameRecords)
336
+ // F3: leaks are layer 1 (like everything in check()); honor --layer the same way
337
+ // check() does, not just --category, or `--layer 2` still emits leak findings.
338
+ .filter((v) => (!categories || categories.includes(v.category)) && (!layers || layers.includes(v.layer)));
339
+
340
+ // Attribution audit: only active when .omd/attribution.md exists. Verifies that every
341
+ // token group used on the page is accounted for, and every row's source points to a real
342
+ // capture or theory reference. Keeps attribution honest without requiring it of every page.
343
+ const attrViolations: Violation[] = [];
344
+ const attrPath = join(process.cwd(), '.omd', 'attribution.md');
345
+ if (existsSync(attrPath)) {
346
+ const attrMd = readFileSync(attrPath, 'utf8');
347
+ const refsDir = join(process.cwd(), '.omd', 'refs');
348
+ const captureNames = existsSync(refsDir)
349
+ ? readdirSync(refsDir).filter((f) => f.endsWith('.json')).map((f) => f.slice(0, -5))
350
+ : [];
351
+ const theoryDir = join(root, 'core', 'theory');
352
+ const theoryNames = existsSync(theoryDir)
353
+ ? readdirSync(theoryDir).filter((f) => f.endsWith('.md')).map((f) => f.slice(0, -3))
354
+ : [];
355
+ attrViolations.push(
356
+ ...checkAttribution(ir, attrMd, captureNames, theoryNames)
357
+ .filter((v) => (!categories || categories.includes(v.category)) && (!layers || layers.includes(v.layer))),
358
+ );
359
+ }
360
+
361
+ // Motion-spec audit: only active when .omd/motion-spec.md exists. Compares the scene
362
+ // inventory written by omd-hand (what was planned) against the live probe data (what ran)
363
+ // and the filmstrip energy curve (pixel-level confirmation that catches GSAP/rAF too).
364
+ //
365
+ // Contract: check reads the most recent energy curve from .omd/.cache/ if present.
366
+ // The ultradesign skill runs `omd render --filmstrip` before `omd check`, which writes
367
+ // `<base>-energy.json` alongside the filmstrip HTML. If that file is present, it is
368
+ // used as a supplementary signal; if absent, probe data alone is used.
369
+ const motionSpecViolations: Violation[] = [];
370
+ const motionSpecPath = join(process.cwd(), '.omd', 'motion-spec.md');
371
+ if (existsSync(motionSpecPath)) {
372
+ const motionSpecMd = readFileSync(motionSpecPath, 'utf8');
373
+ const framePath = join(process.cwd(), '.omd', 'frame.md');
374
+ const frameMd = existsSync(framePath) ? readFileSync(framePath, 'utf8') : null;
375
+
376
+ let energyCurve: EnergyCurve | null = null;
377
+ const cacheDir = join(process.cwd(), '.omd', '.cache');
378
+ if (existsSync(cacheDir)) {
379
+ const energyFiles = readdirSync(cacheDir).filter((f) => f.endsWith('-energy.json'));
380
+ if (energyFiles.length > 0) {
381
+ try {
382
+ energyCurve = JSON.parse(readFileSync(join(cacheDir, energyFiles[0]!), 'utf8')) as EnergyCurve;
383
+ } catch { /* corrupt or unreadable: skip */ }
384
+ }
385
+ }
386
+
387
+ motionSpecViolations.push(
388
+ ...checkMotionSpec(ir, motionSpecMd, frameMd, energyCurve)
389
+ .filter((v) => (!categories || categories.includes(v.category)) && (!layers || layers.includes(v.layer))),
390
+ );
391
+ }
392
+
393
+ // Design contract audit: only active when .omd/design.md exists. Validates that all
394
+ // required sections are present and that the Interaction states section enumerates
395
+ // the six required states. A bare project without design.md is not nagged.
396
+ const designViolations: Violation[] = [];
397
+ const designPath = join(process.cwd(), '.omd', 'design.md');
398
+ if (existsSync(designPath)) {
399
+ const designMd = readFileSync(designPath, 'utf8');
400
+ designViolations.push(
401
+ ...validateDesignMd(designMd)
402
+ .filter((v) => (!categories || categories.includes(v.category)) && (!layers || layers.includes(v.layer))),
403
+ );
404
+ }
405
+
406
+ // Interaction-state rules: deterministic IR checks for measurable state gaps.
407
+ // Runs unconditionally (no guard file required) — a form without an error state
408
+ // is a defect on any page, whether or not design.md has been established.
409
+ const interactionViolations: Violation[] = checkInteractionStates(ir)
410
+ .filter((v) => (!categories || categories.includes(v.category)) && (!layers || layers.includes(v.layer)));
411
+
412
+ // Frame UX audit: only active when .omd/frame.md exists. Checks that the three UX
413
+ // anchor questions (task, frequent-action, costliest-error) have been answered.
414
+ // A bare project without frame.md is not nagged — but a frame that skipped the
415
+ // interrogation is flagged with FRAME-UX-INCOMPLETE.
416
+ const frameUxViolations: Violation[] = [];
417
+ const frameMdPath = join(process.cwd(), '.omd', 'frame.md');
418
+ if (existsSync(frameMdPath)) {
419
+ frameUxViolations.push(
420
+ ...checkFrameUx(process.cwd())
421
+ .filter((v) => (!categories || categories.includes(v.category)) && (!layers || layers.includes(v.layer))),
422
+ );
423
+ }
424
+
425
+ // Code-unit order, not localeCompare — same determinism guarantee as check() itself.
426
+ const cmp = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
427
+ const combined: Violation[] = [
428
+ ...violations, ...leaks, ...attrViolations, ...motionSpecViolations,
429
+ ...designViolations, ...interactionViolations, ...frameUxViolations,
430
+ ].sort((a, b) => cmp(a.path, b.path) || cmp(a.id, b.id));
431
+
432
+ if (opts.json) process.stdout.write(JSON.stringify(combined));
433
+ else for (const v of combined) console.log(`[${v.severity}] ${v.id} ${v.path}: ${v.message}`);
434
+
435
+ if (!opts.noLog) {
436
+ const page = opts._[0] ?? opts.ir ?? '(unknown)';
437
+ logRun(process.cwd(), page, combined);
438
+ }
439
+
440
+ process.exit(combined.length > 0 ? 1 : 0);
441
+ }
442
+
443
+ /** One decision is one block, so a rule named in both its title and its reason counts once. */
444
+ function readDecisionBlocks(cwd: string): string[] {
445
+ const path = join(cwd, '.omd', 'decisions.md');
446
+ if (!existsSync(path)) return [];
447
+ return readFileSync(path, 'utf8').split(/^## /m).slice(1);
448
+ }
449
+
450
+ function cmdCoach(): never {
451
+ const report = analyse(readHistory(process.cwd()), readDecisionBlocks(process.cwd()));
452
+
453
+ if (report.runs === 0) {
454
+ console.log('No check history yet. Run `omd check` on something.');
455
+ process.exit(0);
456
+ }
457
+
458
+ if (!report.confident) {
459
+ console.log(`Seen ${report.runs} run${report.runs === 1 ? '' : 's'} so far.`);
460
+ for (const r of report.recurring) console.log(` ${r.rule} ${r.total} findings across ${r.runs} runs`);
461
+ console.log(`\nToo little history to claim a trend. Four runs is the minimum; there are ${report.runs}.`);
462
+ process.exit(0);
463
+ }
464
+
465
+ for (const r of report.recurring) {
466
+ // A rule with no baseline has no percentage. Say "appeared", never a fabricated number.
467
+ const delta = r.changePct === null ? 'appeared' : `${r.changePct > 0 ? '+' : ''}${r.changePct}%`;
468
+ console.log(`${r.rule} ${r.total} findings across ${r.runs} runs ${delta} ${r.trend}`);
469
+ }
470
+
471
+ if (report.overrules.length > 0) {
472
+ console.log('\nOverruled:');
473
+ for (const o of report.overrules) console.log(` ${o.rule} x${o.count}`);
474
+ }
475
+
476
+ process.exit(0);
477
+ }
478
+
479
+ function cmdFrameShow(): never {
480
+ const frame = readFrame(process.cwd());
481
+ if (!frame) {
482
+ console.error('No frame yet. Run `omd frame set --problem P --reframe R --why EVIDENCE`.');
483
+ process.exit(1);
484
+ }
485
+ console.log(JSON.stringify(frame, null, 2));
486
+ process.exit(0);
487
+ }
488
+
489
+ /**
490
+ * The agent picks, states why, and records it. It does not stop and wait: a loop that
491
+ * halts for a decision the user was never asked to make is a loop that never finishes.
492
+ * The recorded choice is the training signal, and the user can overrule it afterwards.
493
+ */
494
+ function cmdChoose(opts: Opts): never {
495
+ const among = opts._;
496
+ if (among.length < 2) {
497
+ console.error('usage: omd choose c1 c2 c3 --chose c3 --why "..."');
498
+ process.exit(1);
499
+ }
500
+ if (!opts.chose || !opts.why) {
501
+ console.error('--chose and --why are both required. A choice without a reason teaches nothing.');
502
+ process.exit(1);
503
+ }
504
+ const path = logChoice(process.cwd(), { among, chose: opts.chose, why: opts.why });
505
+ console.log(`${path} (${tasteProfile(process.cwd(), true).n} choices recorded)`);
506
+ process.exit(0);
507
+ }
508
+
509
+ /**
510
+ * `omd ref add-batch <manifest.json>` — capture many references concurrently over ONE browser.
511
+ * The manifest is a JSON array of `{ source, as, selector?, blueprint?, shot?, fromUser?, viewport? }`.
512
+ * Same per-reference result as `omd ref add`, minus the energy pass, at a fraction of the wall time.
513
+ */
514
+ async function cmdRefAddBatch(opts: Opts): Promise<never> {
515
+ const manifestPath = opts._[0];
516
+ if (!manifestPath) {
517
+ console.error('usage: omd ref add-batch <manifest.json> (JSON array of { source, as, selector?, blueprint?, shot?, fromUser?, viewport? })');
518
+ process.exit(1);
519
+ }
520
+ const { addRefsBatch } = await import('../core/ref/batch.ts');
521
+ const specs = JSON.parse(readFileSync(resolve(manifestPath), 'utf8')) as import('../core/ref/batch.ts').RefSpec[];
522
+ if (!Array.isArray(specs) || specs.length === 0) throw new Error('manifest must be a non-empty JSON array of reference specs');
523
+ for (const s of specs) {
524
+ if (!s || typeof s.source !== 'string' || typeof s.as !== 'string') {
525
+ throw new Error('each manifest entry needs a string `source` and `as`');
526
+ }
527
+ }
528
+ const result = await addRefsBatch(process.cwd(), specs, { rulesRoot: join(root, 'core', 'rules', 'builtin') });
529
+ if (opts.json) process.stdout.write(JSON.stringify(result));
530
+ else {
531
+ const ok = result.outcomes.filter((o) => o.ok).length;
532
+ console.log(`ref add-batch: ${ok}/${result.outcomes.length} captured (concurrency ${result.concurrency})`);
533
+ for (const o of result.outcomes) {
534
+ if (o.ok) console.log(` ok ${o.as} <- ${o.source}${o.slopCount ? ` (${o.slopCount} slop)` : ''}`);
535
+ else console.error(` FAIL ${o.as} <- ${o.source}: ${o.error}`);
536
+ }
537
+ }
538
+ process.exit(result.outcomes.some((o) => !o.ok) ? 1 : 0);
539
+ }
540
+
541
+ async function cmdRefAdd(opts: Opts): Promise<never> {
542
+ const target = opts._[0];
543
+ if (!target || !opts.as) {
544
+ console.error('usage: omd ref add <url|file> --as <component> [--selector "css"] [--image]');
545
+ process.exit(1);
546
+ }
547
+ if (opts.image && opts.selector) {
548
+ console.error('--image and --selector cannot be used together: an image has no subtree to scope.');
549
+ process.exit(1);
550
+ }
551
+ if (opts.blueprint && !opts.selector) {
552
+ console.error('--blueprint requires --selector: a blueprint measures one component, not a whole page.');
553
+ process.exit(1);
554
+ }
555
+ if (opts.shot && !opts.selector) {
556
+ console.error('--shot requires --selector: a scoped screenshot captures one component, not a whole page.');
557
+ process.exit(1);
558
+ }
559
+ const { saveRef } = await import('../core/ref/store.ts');
560
+
561
+ if (opts.image) {
562
+ const path = saveRef(process.cwd(), {
563
+ source: target,
564
+ component: opts.as,
565
+ kind: 'image',
566
+ capturedAt: new Date().toISOString(),
567
+ invariants: null,
568
+ principles: [],
569
+ ...(opts.fromUser ? { origin: 'user' as const } : {}),
570
+ });
571
+ console.log(path);
572
+ process.exit(0);
573
+ }
574
+
575
+ const { normalize } = await import('../core/ir/normalize.ts');
576
+ const { extractInvariants } = await import('../core/ref/invariants.ts');
577
+ const { designSignal, LOW_SIGNAL } = await import('../core/ref/signal.ts');
578
+
579
+ const { loadRules, check } = await import('../core/rules/engine.ts');
580
+
581
+ const raw = await rawIrFor(opts, target, opts.selector);
582
+ const ir = normalize(raw);
583
+ const invariants = extractInvariants(ir);
584
+
585
+ const slopViolations = check(ir, loadRules(join(root, 'core', 'rules', 'builtin')), { categories: ['slop'] });
586
+ const slopCount = slopViolations.length;
587
+ const slopIds = [...new Set(slopViolations.map((v) => v.id))];
588
+
589
+ // Energy curve: capture pixel-diff motion energy for the reference. Uses a second
590
+ // Playwright session so the cost is one extra browser launch per `omd ref add`.
591
+ // Sees ALL motion including GSAP/rAF — closing the getAnimations() blind spot.
592
+ // Failure is silently ignored: a blocked page or unsupported format must not prevent
593
+ // the reference from being saved.
594
+ const { captureEnergy, parseViewport } = await import('../core/render/index.ts');
595
+ const energyCurve = opts.noEnergy ? null : await captureEnergy(target, { viewport: parseViewport(opts.viewport) });
596
+
597
+ // Blueprint: full-resolution structural snapshot with skin abstracted to color roles.
598
+ // Only captured when --blueprint is passed together with --selector.
599
+ let blueprint: import('../core/types.ts').Blueprint | undefined;
600
+ if (opts.blueprint && opts.selector) {
601
+ const { captureBlueprint } = await import('../core/ref/blueprint.ts');
602
+ blueprint = captureBlueprint(raw.nodes, opts.selector);
603
+ console.error(`blueprint: ${blueprint.nodes.length} nodes captured`);
604
+ }
605
+
606
+ // Scoped screenshot: pair the component's pixels with its blueprint/invariants on one
607
+ // record so image-first art direction can seed from both (see core/theory/imagegen.md).
608
+ // A render failure must not lose the reference — warn and continue.
609
+ let imagePath: string | undefined;
610
+ if (opts.shot && opts.selector) {
611
+ const { renderElement } = await import('../core/render/index.ts');
612
+ const { refImagePath } = await import('../core/ref/store.ts');
613
+ const absShot = refImagePath(process.cwd(), { source: target, component: opts.as });
614
+ try {
615
+ await renderElement(target, { viewport: parseViewport(opts.viewport), selector: opts.selector, out: absShot });
616
+ imagePath = relative(process.cwd(), absShot);
617
+ console.error(`shot: ${imagePath}`);
618
+ } catch (err) {
619
+ console.error(`shot skipped: ${err instanceof Error ? err.message : String(err)}`);
620
+ }
621
+ }
622
+
623
+ const path = saveRef(process.cwd(), {
624
+ source: target,
625
+ component: opts.as,
626
+ kind: opts.selector ? 'component' : 'page',
627
+ capturedAt: new Date().toISOString(),
628
+ ...(opts.selector ? { selector: opts.selector } : {}),
629
+ invariants,
630
+ principles: [],
631
+ slopCount,
632
+ ...(opts.fromUser ? { origin: 'user' as const } : {}),
633
+ ...(energyCurve !== null ? { energyCurve } : {}),
634
+ ...(blueprint !== undefined ? { blueprint } : {}),
635
+ ...(imagePath !== undefined ? { imagePath } : {}),
636
+ });
637
+ console.log(path);
638
+ console.log(JSON.stringify(invariants, null, 2));
639
+ console.error(`slop findings: ${slopCount}${slopCount > 0 ? ` [${slopIds.join(', ')}]` : ''}`);
640
+
641
+ const signal = designSignal(invariants);
642
+ if (signal.score < LOW_SIGNAL) {
643
+ console.error(
644
+ `warning: low design signal (${signal.score} — missing: ${signal.missing.join(', ')}).\n`
645
+ + 'This page makes almost no visual decisions; as a visual reference it teaches nothing.\n'
646
+ + 'Keep it only as a content or anti-reference, and say so in its principles.',
647
+ );
648
+ }
649
+ if (slopCount >= 2) {
650
+ if (opts.fromUser) {
651
+ console.error(
652
+ `note: the reference you provided shows ${slopCount} slop signals (${slopIds.join(', ')}) — using it as a stated anti-reference.\n`
653
+ + 'It is saved; mark its principles to document what to avoid.',
654
+ );
655
+ } else {
656
+ console.error(
657
+ `warning: ${slopCount} slop findings (${slopIds.join(', ')}) — this page reproduces patterns the tool exists to avoid.\n`
658
+ + 'Board it only as an explicitly-stated anti-reference.',
659
+ );
660
+ }
661
+ }
662
+ process.exit(0);
663
+ }
664
+
665
+ /**
666
+ * A principle answers *why* a reference is the way it is, and no function can form that
667
+ * judgement. It is written by a model that looked at the render, and merely recorded here —
668
+ * the same split as everywhere else: the tool measures, the model interprets.
669
+ */
670
+ async function cmdRefPrinciples(opts: Opts): Promise<never> {
671
+ const source = opts._[0];
672
+ if (!source || !opts.as || !opts.add) {
673
+ console.error('usage: omd ref principles <source> --as <component> --add "why it is built that way"');
674
+ process.exit(1);
675
+ }
676
+ const { addPrinciples } = await import('../core/ref/store.ts');
677
+ addPrinciples(process.cwd(), source, opts.as, [opts.add]);
678
+ console.log(`${source} (${opts.as}): principle recorded`);
679
+ process.exit(0);
680
+ }
681
+
682
+ async function cmdRefShow(opts: Opts): Promise<never> {
683
+ const source = opts._[0];
684
+ if (!source || !opts.as) {
685
+ console.error('usage: omd ref show <source> --as <component>');
686
+ process.exit(1);
687
+ }
688
+ const { loadRefs } = await import('../core/ref/store.ts');
689
+ const ref = loadRefs(process.cwd()).find((r) => r.source === source && r.component === opts.as);
690
+ if (!ref) {
691
+ console.error(`no reference for ${source} (${opts.as})`);
692
+ process.exit(1);
693
+ }
694
+
695
+ console.log(`${ref.source} ${ref.component} captured ${ref.capturedAt}`);
696
+ console.log(JSON.stringify(ref.invariants, null, 2));
697
+ if (ref.principles.length === 0) {
698
+ console.log('\nNo principles yet — nothing has looked at it.');
699
+ } else {
700
+ console.log('\nPrinciples:');
701
+ for (const p of ref.principles) console.log(` - ${p}`);
702
+ }
703
+ if (ref.blueprint) {
704
+ printBlueprint(ref.blueprint);
705
+ }
706
+ process.exit(0);
707
+ }
708
+
709
+ /**
710
+ * Print a blueprint as an indented, buildable spec. Each line is one node with its
711
+ * role, dimensions, typography, surface, color roles, and motion timings. The tree
712
+ * structure is reconstructed from the children arrays.
713
+ */
714
+ function printBlueprint(bp: import('../core/types.ts').Blueprint): void {
715
+ const childIds = new Set(bp.nodes.flatMap((n) => n.children));
716
+ const roots = bp.nodes.filter((n) => !childIds.has(n.id));
717
+ const byId = new Map(bp.nodes.map((n) => [n.id, n]));
718
+
719
+ const printNode = (id: string, depth: number): void => {
720
+ const node = byId.get(id);
721
+ if (!node) return;
722
+ const indent = ' '.repeat(depth);
723
+ const parts: string[] = [node.role, `${node.box.w}×${node.box.h}`];
724
+ if (node.fontSize != null) parts.push(`${node.fontSize}px`);
725
+ if (node.fontWeight != null) parts.push(`fw:${node.fontWeight}`);
726
+ if (node.lineHeight != null) parts.push(`lh:${node.lineHeight}`);
727
+ if (node.radius != null) parts.push(`r=${node.radius}`);
728
+ if (node.hasShadow) parts.push('shadow');
729
+ if (node.fillRole) parts.push(`fill:${node.fillRole}`);
730
+ if (node.textRole) parts.push(`text:${node.textRole}`);
731
+ if (node.textLength) parts.push(`[${node.textLength}]`);
732
+ if (node.motionDurations?.length) parts.push(`motion:${node.motionDurations.join(',')}ms`);
733
+ if (node.direction) parts.push(node.direction === 'VERTICAL' ? 'col' : 'row');
734
+ if (node.gap != null) parts.push(`gap:${node.gap}`);
735
+ if (node.padding) {
736
+ const [t, r, b, l] = node.padding;
737
+ if ((t ?? 0) + (r ?? 0) + (b ?? 0) + (l ?? 0) > 0) parts.push(`p:${t}/${r}/${b}/${l}`);
738
+ }
739
+ console.log(`${indent}${parts.join(' ')}`);
740
+ for (const childId of node.children) printNode(childId, depth + 1);
741
+ };
742
+
743
+ console.log(`\nBlueprint selector: ${bp.selector} (${bp.nodes.length} nodes)`);
744
+ for (const root of roots) printNode(root.id, 0);
745
+ }
746
+
747
+ async function cmdRefList(): Promise<never> {
748
+ const { loadRefs } = await import('../core/ref/store.ts');
749
+ const { designSignal, LOW_SIGNAL } = await import('../core/ref/signal.ts');
750
+ const { topKinshipPairs } = await import('../core/ref/distance.ts');
751
+ const refs = loadRefs(process.cwd());
752
+ if (refs.length === 0) {
753
+ console.log('No references yet.');
754
+ process.exit(0);
755
+ }
756
+ for (const ref of refs) {
757
+ const granularity = ref.selector ? `[${ref.kind} ${ref.selector}]` : `[${ref.kind}]`;
758
+ const userNote = ref.origin === 'user' ? ' [user]' : '';
759
+ if (ref.kind === 'image' || ref.invariants === null) {
760
+ console.log(`${ref.source} ${ref.component} ${granularity}${userNote}`);
761
+ continue;
762
+ }
763
+ const inv = ref.invariants;
764
+ const signal = designSignal(inv);
765
+ const lowSignalNote = signal.score < LOW_SIGNAL ? ` [low-signal ${signal.score}]` : '';
766
+ const slopNote = (ref.slopCount ?? 0) >= 2 ? ` [slop:${ref.slopCount}]` : '';
767
+ console.log(
768
+ `${ref.source} ${ref.component} ${granularity} radius=[${inv.radiusLadder.join(',')}] `
769
+ + `spacing=[${inv.spacingLadder.join(',')}] elevation=${inv.elevationLevels}${lowSignalNote}${slopNote}${userNote}`,
770
+ );
771
+ }
772
+
773
+ const pairs = topKinshipPairs(refs);
774
+ if (pairs.length > 0) {
775
+ console.log('');
776
+ for (const p of pairs) {
777
+ console.log(`kinship: ${p.a} ${p.b} ${p.similarity.toFixed(2)}`);
778
+ }
779
+ console.error(
780
+ 'warning: kinship cluster — references scoring ≥0.85 against each other carry the same average.\n'
781
+ + 'A cluster is a contamination signal; drop the duplicates or mark the weaker capture as an anti-reference.',
782
+ );
783
+ }
784
+
785
+ process.exit(0);
786
+ }
787
+
788
+ async function cmdRefDistance(opts: Opts): Promise<never> {
789
+ const target = opts._[0];
790
+ if (!target) usage();
791
+
792
+ const { loadRefs } = await import('../core/ref/store.ts');
793
+ const refs = loadRefs(process.cwd());
794
+ if (refs.length === 0) {
795
+ console.log('No references to compare against.');
796
+ process.exit(0);
797
+ }
798
+
799
+ const measured = refs.filter((r) => r.invariants !== null);
800
+ const skippedImages = refs.filter((r) => r.kind === 'image').length;
801
+
802
+ if (measured.length === 0) {
803
+ console.log('Nothing to compare: no measured references, only image references.');
804
+ process.exit(0);
805
+ }
806
+
807
+ const { normalize } = await import('../core/ir/normalize.ts');
808
+ const { extractInvariants } = await import('../core/ref/invariants.ts');
809
+ const { distances } = await import('../core/ref/distance.ts');
810
+
811
+ const raw = await rawIrFor(opts, target);
812
+ const ir = normalize(raw);
813
+ const invariants = extractInvariants(ir);
814
+ const results = distances(invariants, measured);
815
+
816
+ for (const r of results) {
817
+ console.log(` ${r.similarity.toFixed(2)} ${r.reference} (${r.drivers.join(', ')})`);
818
+ }
819
+
820
+ if (skippedImages > 0) {
821
+ console.log(`(skipped ${skippedImages} image reference${skippedImages === 1 ? '' : 's'} — pixels cannot be measured)`);
822
+ }
823
+
824
+ const tooClose = results.filter((r) => r.similarity >= 0.6);
825
+ if (tooClose.length > 0) {
826
+ console.error(
827
+ 'warning: this page resembles a reference too closely '
828
+ + `(${tooClose.map((r) => r.reference).join(', ')}) — assembled work should resemble none of them.`,
829
+ );
830
+ process.exit(1);
831
+ }
832
+ process.exit(0);
833
+ }
834
+
835
+ // ── Figma commands ────────────────────────────────────────────────────────────
836
+
837
+ async function cmdFigmaPull(url: string | undefined): Promise<never> {
838
+ if (!url) {
839
+ console.error('usage: omd figma pull <file-url>');
840
+ process.exit(1);
841
+ }
842
+
843
+ const token = process.env['FIGMA_TOKEN'];
844
+ if (!token) {
845
+ console.error('FIGMA_TOKEN environment variable is not set.\nSet it to a Figma personal access token to use figma pull.');
846
+ process.exit(1);
847
+ }
848
+
849
+ const { parseFileKey, fetchAndNormalize } = await import('../core/figma/client.ts');
850
+
851
+ let fileKey: string;
852
+ try {
853
+ fileKey = parseFileKey(url);
854
+ } catch (e) {
855
+ console.error(e instanceof Error ? e.message : String(e));
856
+ process.exit(1);
857
+ }
858
+
859
+ console.log(`Fetching Figma file ${fileKey} …`);
860
+ const snapshot = await fetchAndNormalize(fileKey, token);
861
+
862
+ const outDir = join(process.cwd(), '.omd', 'figma');
863
+ mkdirSync(outDir, { recursive: true });
864
+ const snapPath = join(outDir, 'snapshot.json');
865
+ writeFileSync(snapPath, JSON.stringify(snapshot, null, 2));
866
+
867
+ // Human-readable inventory
868
+ const totalFrames = snapshot.pages.reduce((n, p) => n + p.frames.length, 0);
869
+ const setCount = Object.keys(snapshot.componentSets).length;
870
+ console.log(`\nFile: ${snapshot.fileName}`);
871
+ for (const page of snapshot.pages) {
872
+ console.log(` Page "${page.name}" — ${page.frames.length} frame${page.frames.length === 1 ? '' : 's'}`);
873
+ for (const frame of page.frames) {
874
+ console.log(` • ${frame.name}`);
875
+ }
876
+ }
877
+ console.log(`\nTotal frames: ${totalFrames} Component sets: ${setCount}`);
878
+ if (setCount > 0) {
879
+ for (const cs of Object.values(snapshot.componentSets)) {
880
+ const keys = new Set<string>();
881
+ for (const v of cs.variants) {
882
+ const { parseVariantName } = await import('../core/figma/system.ts');
883
+ for (const k of Object.keys(parseVariantName(v.name))) keys.add(k);
884
+ }
885
+ const desc = keys.size > 0 ? ` (${[...keys].join('×')})` : '';
886
+ console.log(` • ${cs.name} — ${cs.variants.length} variant${cs.variants.length === 1 ? '' : 's'}${desc}`);
887
+ }
888
+ }
889
+ console.log(`\nSnapshot saved: ${snapPath}`);
890
+ process.exit(0);
891
+ }
892
+
893
+ async function cmdFigmaSystem(): Promise<never> {
894
+ const snapPath = join(process.cwd(), '.omd', 'figma', 'snapshot.json');
895
+ if (!existsSync(snapPath)) {
896
+ console.error(`No snapshot found at ${snapPath}\nRun \`omd figma pull <file-url>\` first.`);
897
+ process.exit(1);
898
+ }
899
+
900
+ const { buildComponentMatrix, extractTokens, generateCss, generateMarkdown } = await import('../core/figma/system.ts');
901
+ const snapshot = JSON.parse(readFileSync(snapPath, 'utf8')) as import('../core/figma/types.ts').FigmaSnapshot;
902
+
903
+ const tokens = extractTokens(snapshot);
904
+ const matrix = buildComponentMatrix(snapshot);
905
+ const css = generateCss(tokens);
906
+ const md = generateMarkdown(tokens, matrix, snapshot.fileName);
907
+
908
+ const outDir = join(process.cwd(), '.omd', 'figma');
909
+ const cssPath = join(outDir, 'tokens.css');
910
+ const mdPath = join(outDir, 'design-system.md');
911
+ writeFileSync(cssPath, css);
912
+ writeFileSync(mdPath, md);
913
+
914
+ console.log(`Design system synthesized from: ${snapshot.fileName}`);
915
+ console.log(` Colors: ${tokens.colors.length}`);
916
+ console.log(` Type scale: ${tokens.typeScale.length} steps`);
917
+ console.log(` Spacing: ${tokens.spacing.length} values`);
918
+ console.log(` Radii: ${tokens.radii.length} values`);
919
+ console.log(` Shadows: ${tokens.shadows.length} values`);
920
+ console.log(` Components: ${matrix.length} set${matrix.length === 1 ? '' : 's'}`);
921
+ console.log(`\n ${cssPath}`);
922
+ console.log(` ${mdPath}`);
923
+ process.exit(0);
924
+ }
925
+
926
+ async function cmdFigmaDiff(opts: Opts): Promise<never> {
927
+ const frameId = opts._[0];
928
+ const pageOrUrl = opts._[1];
929
+
930
+ if (!frameId || !pageOrUrl) {
931
+ console.error(
932
+ 'usage: omd figma diff <frame-id> <page-or-url> [--threshold 0.97] [--fresh] [--json]',
933
+ );
934
+ process.exit(1);
935
+ }
936
+
937
+ const threshold =
938
+ opts.threshold !== undefined ? parseFloat(opts.threshold) : 0.97;
939
+ if (isNaN(threshold) || threshold < 0 || threshold > 1) {
940
+ console.error('--threshold must be a number between 0 and 1');
941
+ process.exit(1);
942
+ }
943
+
944
+ const fresh = opts.fresh === true;
945
+ const jsonOut = opts.json === true;
946
+
947
+ // Load snapshot — frame dimensions and file key live here.
948
+ const snapPath = join(process.cwd(), '.omd', 'figma', 'snapshot.json');
949
+ if (!existsSync(snapPath)) {
950
+ console.error(
951
+ `No snapshot found at ${snapPath}\nRun \`omd figma pull <file-url>\` first.`,
952
+ );
953
+ process.exit(1);
954
+ }
955
+
956
+ const snapshot = JSON.parse(
957
+ readFileSync(snapPath, 'utf8'),
958
+ ) as import('../core/figma/types.ts').FigmaSnapshot;
959
+
960
+ // Locate frame in snapshot.
961
+ let foundFrame: import('../core/figma/types.ts').SnapshotFrame | undefined;
962
+ for (const page of snapshot.pages) {
963
+ const f = page.frames.find((fr) => fr.id === frameId);
964
+ if (f !== undefined) { foundFrame = f; break; }
965
+ }
966
+
967
+ if (foundFrame === undefined) {
968
+ console.error(`Frame ID "${frameId}" not found in snapshot.\nKnown frames:`);
969
+ for (const page of snapshot.pages) {
970
+ for (const fr of page.frames) {
971
+ console.error(` ${fr.id} ${fr.name}`);
972
+ }
973
+ }
974
+ process.exit(1);
975
+ }
976
+
977
+ // Frame dimensions come from the root node's absoluteBoundingBox.
978
+ // collectNodes() always puts the frame node first (index 0).
979
+ const rootNode = foundFrame.nodes[0];
980
+ if (rootNode?.absoluteBoundingBox === undefined) {
981
+ console.error(
982
+ `Frame "${foundFrame.name}" (${frameId}) has no absoluteBoundingBox in snapshot.\n`
983
+ + 'Re-run `omd figma pull` to refresh the snapshot.',
984
+ );
985
+ process.exit(1);
986
+ }
987
+
988
+ const { width: frameW, height: frameH } = rootNode.absoluteBoundingBox;
989
+
990
+ // ── Step 1: reference PNG (cache or live export) ──────────────────────────
991
+
992
+ const safeId = frameId.replace(/[:/]/g, '_');
993
+ const exportsDir = join(process.cwd(), '.omd', 'figma', 'exports');
994
+ const cachePath = join(exportsDir, `${safeId}.png`);
995
+
996
+ if (!fresh && existsSync(cachePath)) {
997
+ if (!jsonOut) console.log(`Using cached export: ${cachePath}`);
998
+ } else {
999
+ const token = process.env['FIGMA_TOKEN'];
1000
+ if (token === undefined || token.length === 0) {
1001
+ console.error(
1002
+ 'FIGMA_TOKEN environment variable is not set.\n'
1003
+ + 'Set it to a Figma personal access token to export frames.',
1004
+ );
1005
+ process.exit(1);
1006
+ }
1007
+
1008
+ if (!jsonOut) console.log(`Exporting frame ${frameId} from Figma …`);
1009
+
1010
+ const imgRes = await fetch(
1011
+ `https://api.figma.com/v1/images/${snapshot.fileKey}`
1012
+ + `?ids=${encodeURIComponent(frameId)}&format=png&scale=1`,
1013
+ { headers: { 'X-Figma-Token': token } },
1014
+ );
1015
+ if (!imgRes.ok) {
1016
+ const body = await imgRes.text().catch(() => '');
1017
+ console.error(`Figma image export failed (${imgRes.status}): ${body.slice(0, 300)}`);
1018
+ process.exit(1);
1019
+ }
1020
+
1021
+ const imgData = (await imgRes.json()) as {
1022
+ err?: string;
1023
+ images?: Record<string, string | null>;
1024
+ };
1025
+ if (imgData.err !== undefined && imgData.err !== null) {
1026
+ console.error(`Figma image export error: ${imgData.err}`);
1027
+ process.exit(1);
1028
+ }
1029
+
1030
+ const tempUrl = imgData.images?.[frameId];
1031
+ if (tempUrl === undefined || tempUrl === null) {
1032
+ console.error(`No image URL returned for frame ${frameId}`);
1033
+ process.exit(1);
1034
+ }
1035
+
1036
+ const pngRes = await fetch(tempUrl);
1037
+ if (!pngRes.ok) {
1038
+ console.error(`Failed to download PNG (${pngRes.status}): ${tempUrl}`);
1039
+ process.exit(1);
1040
+ }
1041
+
1042
+ mkdirSync(exportsDir, { recursive: true });
1043
+ writeFileSync(cachePath, Buffer.from(await pngRes.arrayBuffer()));
1044
+ if (!jsonOut) console.log(`Exported: ${cachePath}`);
1045
+ }
1046
+
1047
+ // ── Step 2: render build at exact frame dimensions ────────────────────────
1048
+
1049
+ if (!jsonOut) {
1050
+ console.log(`Rendering ${pageOrUrl} at ${Math.round(frameW)}×${Math.round(frameH)} …`);
1051
+ }
1052
+
1053
+ const { renderPage } = await import('../core/render/index.ts');
1054
+ const rendersDir = join(process.cwd(), '.omd', 'figma', 'renders');
1055
+ mkdirSync(rendersDir, { recursive: true });
1056
+ const renderPath = join(rendersDir, `${safeId}.png`);
1057
+
1058
+ await renderPage(pageOrUrl, {
1059
+ viewport: { width: Math.round(frameW), height: Math.round(frameH) },
1060
+ out: renderPath,
1061
+ });
1062
+
1063
+ // ── Step 3: pixel diff ────────────────────────────────────────────────────
1064
+
1065
+ const { compareImages, formatDiffReport } = await import('../core/figma/diff.ts');
1066
+ const refBuf = readFileSync(cachePath);
1067
+ const buildBuf = readFileSync(renderPath);
1068
+
1069
+ const result = compareImages(refBuf, buildBuf, threshold);
1070
+
1071
+ if (jsonOut) {
1072
+ console.log(JSON.stringify(result, null, 2));
1073
+ } else {
1074
+ console.log('\n' + formatDiffReport(result));
1075
+ }
1076
+
1077
+ process.exit(result.pass ? 0 : 1);
1078
+ }
1079
+
1080
+ /**
1081
+ * `omd design [--check]`
1082
+ *
1083
+ * Without --check: discover repo evidence, then create or refresh `.omd/design.md`.
1084
+ * If the file already exists its preamble is preserved and only missing sections are
1085
+ * appended; a fresh project gets the full generated template.
1086
+ *
1087
+ * With --check: validate the existing design.md against the required section schema
1088
+ * and the Interaction states enumeration contract. Exits 1 when violations are found.
1089
+ * Only active when .omd/design.md exists; a bare project is not nagged.
1090
+ */
1091
+ async function cmdDesign(opts: Opts): Promise<never> {
1092
+ const omdDir = join(process.cwd(), '.omd');
1093
+ const designPath = join(omdDir, 'design.md');
1094
+
1095
+ if (opts.check) {
1096
+ if (!existsSync(designPath)) {
1097
+ console.log('No .omd/design.md found. Run `omd design` to create the design contract.');
1098
+ process.exit(0);
1099
+ }
1100
+ const md = readFileSync(designPath, 'utf8');
1101
+ const violations = validateDesignMd(md);
1102
+ if (opts.json) {
1103
+ process.stdout.write(JSON.stringify(violations));
1104
+ } else {
1105
+ for (const v of violations) {
1106
+ console.log(`[${v.severity}] ${v.id} ${v.path}: ${v.message}`);
1107
+ }
1108
+ if (violations.length === 0) console.log('ok — design.md passes all section checks');
1109
+ }
1110
+ process.exit(violations.length > 0 ? 1 : 0);
1111
+ }
1112
+
1113
+ // Discover → generate/refresh
1114
+ const evidence = discoverEvidence(process.cwd());
1115
+
1116
+ if (existsSync(designPath)) {
1117
+ // Refresh: report evidence summary and note that the file already exists.
1118
+ // We do not overwrite the user's work; instead we report what was found so
1119
+ // they can update the open questions manually.
1120
+ const existing = readFileSync(designPath, 'utf8');
1121
+ const violations = validateDesignMd(existing);
1122
+ console.log(`design.md already exists: ${designPath}`);
1123
+ console.log(`\nEvidence scan:`);
1124
+ console.log(` framework: ${evidence.framework ?? 'unknown'}`);
1125
+ console.log(` surfaces: ${evidence.surfaceCount}`);
1126
+ console.log(` app/tooling: ${evidence.appEvidencePaths.length > 0 ? evidence.appEvidencePaths.join(', ') : 'none found'}`);
1127
+ console.log(` tokens: ${evidence.hasThemeTokens ? evidence.tokenFilePaths.join(', ') : 'none found'}`);
1128
+ console.log(` references: ${evidence.captureCount}`);
1129
+ console.log(` frame.md: ${evidence.frameMd ? 'present' : 'absent'}`);
1130
+ console.log(` motion-spec: ${evidence.hasMotionSpec ? 'present' : 'absent'}`);
1131
+ console.log(` voice-study: ${evidence.hasVoiceStudy ? 'present' : 'absent'}`);
1132
+ if (violations.length > 0) {
1133
+ console.log(`\n${violations.length} section check${violations.length === 1 ? '' : 's'} failed:`);
1134
+ for (const v of violations) {
1135
+ console.log(` [${v.severity}] ${v.id}: ${v.message}`);
1136
+ }
1137
+ console.log('\nRun `omd design --check` to re-validate after updates.');
1138
+ } else {
1139
+ console.log('\nAll required sections present. Run `omd design --check` to re-validate.');
1140
+ }
1141
+ process.exit(0);
1142
+ }
1143
+
1144
+ // Create fresh design.md
1145
+ mkdirSync(omdDir, { recursive: true });
1146
+ const content = generateDesignMd(evidence);
1147
+ writeFileSync(designPath, content);
1148
+
1149
+ console.log(`Created: ${designPath}`);
1150
+ console.log(`\nEvidence used:`);
1151
+ console.log(` framework: ${evidence.framework ?? 'unknown'}`);
1152
+ console.log(` surfaces: ${evidence.surfaceCount}`);
1153
+ console.log(` app/tooling: ${evidence.appEvidencePaths.length > 0 ? evidence.appEvidencePaths.join(', ') : 'none found'}`);
1154
+ console.log(` tokens: ${evidence.hasThemeTokens ? evidence.tokenFilePaths.join(', ') : 'none found'}`);
1155
+ console.log(` references: ${evidence.captureCount}`);
1156
+ console.log(` frame.md: ${evidence.frameMd ? 'present' : 'absent'}`);
1157
+ console.log(`\nNext steps:`);
1158
+ console.log(` 1. Fill in the open questions in .omd/design.md`);
1159
+ console.log(` 2. Run \`omd design --check\` to validate section coverage`);
1160
+ console.log(` 3. Cite design.md sections in every hand decision`);
1161
+ process.exit(0);
1162
+ }
1163
+
1164
+ /** Copy-deck or copy-eye report structure gates; neither judges prose quality or blindness. */
1165
+ function cmdCopy(opts: Opts): never {
1166
+ if (opts.check === opts.reviewCheck) {
1167
+ throw new Error('usage: omd copy --check [--json] | omd copy --review-check [--json]');
1168
+ }
1169
+
1170
+ if (opts.reviewCheck) {
1171
+ const path = join(process.cwd(), '.omd', '.cache', 'copy-eye.md');
1172
+ const violations = validateCopyReviewReport(existsSync(path) ? readFileSync(path, 'utf8') : '');
1173
+ if (opts.json) process.stdout.write(JSON.stringify(violations));
1174
+ else {
1175
+ for (const violation of violations) {
1176
+ console.log(`[error] ${violation.id} ${violation.path}: ${violation.message}`);
1177
+ }
1178
+ if (violations.length === 0) {
1179
+ console.log('ok — copy-eye.md passes report-structure checks only; blindness and semantic quality are not proven');
1180
+ }
1181
+ }
1182
+ process.exit(violations.length > 0 ? 1 : 0);
1183
+ }
1184
+
1185
+ const path = join(process.cwd(), '.omd', 'copy-deck.md');
1186
+ const violations = validateCopyDeck(existsSync(path) ? readFileSync(path, 'utf8') : '');
1187
+ if (opts.json) process.stdout.write(JSON.stringify(violations));
1188
+ else {
1189
+ for (const violation of violations) {
1190
+ console.log(`[error] ${violation.id} ${violation.path}: ${violation.message}`);
1191
+ }
1192
+ if (violations.length === 0) console.log('ok — copy-deck.md passes all structural checks');
1193
+ }
1194
+ process.exit(violations.length > 0 ? 1 : 0);
1195
+ }
1196
+
1197
+ /** `omd composition --check [--json]` — structural/freshness gate for composition.md. */
1198
+ function cmdComposition(opts: Opts): never {
1199
+ if (!opts.check) throw new Error('usage: omd composition --check [--json]');
1200
+ const findings = validateCompositionContract(process.cwd());
1201
+ if (opts.json) process.stdout.write(JSON.stringify(findings));
1202
+ else {
1203
+ for (const finding of findings) console.log(`[error] ${finding.id} ${finding.path}: ${finding.message}`);
1204
+ if (findings.length === 0) console.log('ok — composition.md passes structure and freshness checks');
1205
+ }
1206
+ process.exit(findings.length > 0 ? 1 : 0);
1207
+ }
1208
+
1209
+ /** Final byte-freshness evidence only; this does not judge semantic copy/source fidelity. */
1210
+ function cmdSource(mode: string | undefined, opts: Opts): never {
1211
+ const sourceRoot = resolve(opts._[0] ?? process.cwd());
1212
+ if (mode === '--seal') {
1213
+ const path = writeSourceSeal(sourceRoot);
1214
+ if (opts.json) process.stdout.write(JSON.stringify({ path }));
1215
+ else console.log(path);
1216
+ process.exit(0);
1217
+ }
1218
+ if (mode === '--check') {
1219
+ const findings = validateSourceSeal(sourceRoot);
1220
+ if (opts.json) process.stdout.write(JSON.stringify(findings));
1221
+ else {
1222
+ for (const finding of findings) console.log(`[error] ${finding.id} ${finding.path}: ${finding.message}`);
1223
+ if (findings.length === 0) console.log('ok — source seal matches approved inputs and production source bytes');
1224
+ }
1225
+ process.exit(findings.length > 0 ? 1 : 0);
1226
+ }
1227
+ throw new Error('usage: omd source --seal [root] | --check [root] [--json]');
1228
+ }
1229
+ /**
1230
+ * `omd evidence tasks --input .omd/.cache/task-evidence-manifest.json` validates and atomically publishes task evidence.
1231
+ * `omd evidence tasks-check --json` re-verifies its exact files and immutable publication.
1232
+ */
1233
+ function cmdEvidence(mode: string | undefined, opts: Opts): never {
1234
+ if (mode === 'finalize') {
1235
+ if (!opts.input || opts._.length > 0) throw new Error('usage: omd evidence finalize --input <manifest.json>');
1236
+ const path = finalizeFinalEvidence(process.cwd(), opts.input);
1237
+ if (opts.json) process.stdout.write(JSON.stringify({ path }));
1238
+ else console.log(path);
1239
+ process.exit(0);
1240
+ }
1241
+ if (mode === 'check') {
1242
+ if (opts._.length > 0) throw new Error('usage: omd evidence check [--json]');
1243
+ const evidence = checkFinalEvidence(process.cwd());
1244
+ if (opts.json) process.stdout.write(JSON.stringify(evidence));
1245
+ else console.log('ok — final evidence matches current source seal, build target, and artifacts');
1246
+ process.exit(0);
1247
+ }
1248
+ if (mode === 'tasks') {
1249
+ if (!opts.input || opts._.length > 0) throw new Error('usage: omd evidence tasks --input .omd/.cache/task-evidence-manifest.json');
1250
+ const path = publishTaskEvidence(process.cwd(), opts.input);
1251
+ if (opts.json) process.stdout.write(JSON.stringify({ path }));
1252
+ else console.log(path);
1253
+ process.exit(0);
1254
+ }
1255
+ if (mode === 'tasks-check') {
1256
+ if (opts._.length > 0) throw new Error('usage: omd evidence tasks-check [--json]');
1257
+ const evidence = checkTaskEvidence(process.cwd());
1258
+ if (opts.json) process.stdout.write(JSON.stringify(evidence));
1259
+ else console.log('ok — task evidence matches bound frame, composition, probes, and renders');
1260
+ process.exit(0);
1261
+ }
1262
+ throw new Error('usage: omd evidence finalize --input <manifest.json> | check [--json] | tasks --input .omd/.cache/task-evidence-manifest.json | tasks-check [--json]');
1263
+ }
1264
+
1265
+ async function cmdDoctor(): Promise<never> {
1266
+ let allPass = true;
1267
+
1268
+ function report(label: string, pass: boolean, detail?: string): void {
1269
+ console.log(`${pass ? 'pass' : 'fail'} ${label}${detail ? ` (${detail})` : ''}`);
1270
+ if (!pass) allPass = false;
1271
+ }
1272
+
1273
+ // Node version: package.json engines requires >=22.18
1274
+ const parts = process.versions.node.split('.').map(Number);
1275
+ const [major = 0, minor = 0] = parts;
1276
+ const nodeOk = major > 22 || (major === 22 && minor >= 18);
1277
+ report('node >=22.18', nodeOk, process.versions.node);
1278
+
1279
+ // Playwright importability + chromium executable
1280
+ try {
1281
+ const { chromium } = await import('playwright');
1282
+ const exePath = chromium.executablePath();
1283
+ const exeExists = existsSync(exePath);
1284
+ report('playwright chromium', exeExists, exeExists ? 'found' : `not found: ${exePath}`);
1285
+ } catch {
1286
+ report('playwright chromium', false, 'playwright not importable — run: npx playwright install chromium');
1287
+ }
1288
+
1289
+ // .omd/ writability at cwd
1290
+ const omdDir = join(process.cwd(), '.omd');
1291
+ try {
1292
+ mkdirSync(omdDir, { recursive: true });
1293
+ const probe = join(omdDir, '.doctor-probe');
1294
+ writeFileSync(probe, '');
1295
+ try { unlinkSync(probe); } catch { /* ignore */ }
1296
+ report('.omd/ writable', true);
1297
+ } catch (e) {
1298
+ report('.omd/ writable', false, e instanceof Error ? e.message : String(e));
1299
+ }
1300
+
1301
+ // Theory-pack files — resolved relative to the CLI's own root
1302
+ const theoryFiles = ['color.md', 'typography.md', 'layout.md', 'motion.md', 'expressive.md', 'components.md', 'craft.md', 'voice.md', 'ux.md'];
1303
+ for (const f of theoryFiles) {
1304
+ const path = join(root, 'core', 'theory', f);
1305
+ report(`theory/${f}`, existsSync(path));
1306
+ }
1307
+
1308
+ // FIGMA_TOKEN — optional. Figma integration is unavailable without it, but
1309
+ // absence is not a failure. Always report pass; note when not set.
1310
+ const figmaToken = process.env['FIGMA_TOKEN'];
1311
+ const figmaTokenSet = figmaToken !== undefined && figmaToken.length > 0;
1312
+ report(
1313
+ 'FIGMA_TOKEN',
1314
+ true,
1315
+ figmaTokenSet ? 'set' : 'not set — Figma optional; export FIGMA_TOKEN to enable omd figma pull',
1316
+ );
1317
+
1318
+ process.exit(allPass ? 0 : 1);
1319
+ }
1320
+
1321
+ // ── Target commands ──────────────────────────────────────────────────────────
1322
+
1323
+ /**
1324
+ * `omd target set <image-path-or-url> --as <name>`
1325
+ *
1326
+ * Download or copy the reference image into `.omd/target/<name>.png` and record
1327
+ * its dimensions (the intended render viewport). Multiple named targets allowed.
1328
+ * Accepts local file paths and HTTP/HTTPS URLs; no new dependencies — uses the
1329
+ * built-in `fetch` for URL downloads and the in-repo decodePng for dimensions.
1330
+ */
1331
+ async function cmdTargetSet(opts: Opts): Promise<never> {
1332
+ const source = opts._[0];
1333
+ if (!source || !opts.as) {
1334
+ console.error('usage: omd target set <image-path-or-url> --as <name>');
1335
+ process.exit(1);
1336
+ }
1337
+
1338
+ let buf: Buffer;
1339
+ if (/^https?:\/\//.test(source)) {
1340
+ const res = await fetch(source);
1341
+ if (!res.ok) {
1342
+ console.error(`failed to download target image (${res.status}): ${source}`);
1343
+ process.exit(1);
1344
+ }
1345
+ buf = Buffer.from(await res.arrayBuffer());
1346
+ } else {
1347
+ const absPath = resolve(source);
1348
+ if (!existsSync(absPath)) {
1349
+ console.error(`file not found: ${source}`);
1350
+ process.exit(1);
1351
+ }
1352
+ buf = readFileSync(absPath);
1353
+ }
1354
+
1355
+ const { registerTarget } = await import('../core/target/index.ts');
1356
+ const entry = registerTarget(process.cwd(), opts.as, source, buf);
1357
+
1358
+ console.log(
1359
+ `target "${entry.name}" registered ${entry.viewport.width}×${entry.viewport.height} ${entry.path}`,
1360
+ );
1361
+ process.exit(0);
1362
+ }
1363
+
1364
+ /**
1365
+ * `omd target diff <page> [--target <name>] [--viewport WxH] [--threshold N] [--json]`
1366
+ *
1367
+ * Renders the build page at the target's stored viewport dimensions, decodes both
1368
+ * PNGs with the in-repo decoder, and compares them using the same algorithm and
1369
+ * contract as `omd figma diff`. Exits 1 when the similarity score falls below the
1370
+ * threshold (default 0.97). `--json` emits the stable DiffResult for a fix loop.
1371
+ */
1372
+ async function cmdTargetDiff(opts: Opts): Promise<never> {
1373
+ const page = opts._[0];
1374
+ if (!page) {
1375
+ console.error('usage: omd target diff <page> [--target <name>] [--viewport WxH] [--threshold N] [--json]');
1376
+ process.exit(1);
1377
+ }
1378
+
1379
+ const threshold =
1380
+ opts.threshold !== undefined ? parseFloat(opts.threshold) : 0.97;
1381
+ if (isNaN(threshold) || threshold < 0 || threshold > 1) {
1382
+ console.error('--threshold must be a number between 0 and 1');
1383
+ process.exit(1);
1384
+ }
1385
+
1386
+ const jsonOut = opts.json === true;
1387
+
1388
+ const { listTargets, findTarget, compareAgainstTarget, formatDiffReport } = await import('../core/target/index.ts');
1389
+ const { renderPage, parseViewport } = await import('../core/render/index.ts');
1390
+
1391
+ // Resolve target entry
1392
+ let entry;
1393
+ if (opts.target) {
1394
+ entry = findTarget(process.cwd(), opts.target);
1395
+ if (!entry) {
1396
+ console.error(`target "${opts.target}" not found. Run \`omd target list\` to see registered targets.`);
1397
+ process.exit(1);
1398
+ }
1399
+ } else {
1400
+ const all = listTargets(process.cwd());
1401
+ if (all.length === 0) {
1402
+ console.error('no targets registered. Run `omd target set <image> --as <name>` first.');
1403
+ process.exit(1);
1404
+ }
1405
+ entry = all[0]!;
1406
+ if (!jsonOut) console.log(`using target "${entry.name}" (first registered)`);
1407
+ }
1408
+
1409
+ // Render viewport: --viewport flag overrides the target's stored dimensions
1410
+ const viewport = opts.viewport
1411
+ ? parseViewport(opts.viewport)
1412
+ : { width: entry.viewport.width, height: entry.viewport.height };
1413
+
1414
+ // Render the build
1415
+ const rendersDir = join(process.cwd(), '.omd', 'target', '.renders');
1416
+ mkdirSync(rendersDir, { recursive: true });
1417
+ const safeTarget = entry.name.replace(/[^a-zA-Z0-9._-]/g, '_');
1418
+ const renderPath = join(rendersDir, `${safeTarget}.png`);
1419
+
1420
+ if (!jsonOut) {
1421
+ console.log(`rendering ${page} at ${viewport.width}×${viewport.height} …`);
1422
+ }
1423
+ await renderPage(page, { viewport, out: renderPath });
1424
+
1425
+ // Compare
1426
+ const targetBuf = readFileSync(entry.path);
1427
+ const buildBuf = readFileSync(renderPath);
1428
+ const result = compareAgainstTarget(targetBuf, buildBuf, threshold);
1429
+
1430
+ if (jsonOut) {
1431
+ console.log(JSON.stringify(result, null, 2));
1432
+ } else {
1433
+ console.log('\n' + formatDiffReport(result));
1434
+ }
1435
+
1436
+ process.exit(result.pass ? 0 : 1);
1437
+ }
1438
+
1439
+ /** `omd target list` — show all registered visual targets. */
1440
+ async function cmdTargetList(): Promise<never> {
1441
+ const { listTargets } = await import('../core/target/index.ts');
1442
+ const targets = listTargets(process.cwd());
1443
+ if (targets.length === 0) {
1444
+ console.log('No targets registered. Run `omd target set <image> --as <name>`.');
1445
+ process.exit(0);
1446
+ }
1447
+ for (const t of targets) {
1448
+ console.log(
1449
+ `${t.name} ${t.viewport.width}×${t.viewport.height} source: ${t.source} registered: ${t.registeredAt.slice(0, 10)}`,
1450
+ );
1451
+ }
1452
+ process.exit(0);
1453
+ }
1454
+
1455
+ /**
1456
+ * `omd pack dir` — prints the absolute path of the knowledge-pack root (<root>/core).
1457
+ * `omd pack list` — enumerates every .md file in the pack, one relative path per line.
1458
+ * `omd pack <relpath>` — prints one pack file to stdout (e.g. `omd pack theory/color.md`).
1459
+ *
1460
+ * These three forms let any host (Claude Code, Codex, or a plain shell) resolve pack paths
1461
+ * without knowing where the plugin was installed. A prompt that needs theory/color.md can
1462
+ * run `omd pack dir` once and Read the file from the printed path — no env-var assumptions.
1463
+ */
1464
+ function cmdPack(sub: string | undefined, ...rest: string[]): never {
1465
+ const packsRoot = join(root, 'core');
1466
+
1467
+ if (sub === 'dir') {
1468
+ console.log(packsRoot);
1469
+ process.exit(0);
1470
+ }
1471
+
1472
+ if (sub === 'list') {
1473
+ function* walkMd(dir: string, rel: string): Generator<string> {
1474
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
1475
+ const entryRel = rel ? `${rel}/${entry.name}` : entry.name;
1476
+ if (entry.isDirectory()) yield* walkMd(join(dir, entry.name), entryRel);
1477
+ else if (entry.name.endsWith('.md')) yield entryRel;
1478
+ }
1479
+ }
1480
+ if (!existsSync(packsRoot)) {
1481
+ console.error(`pack root not found: ${packsRoot}`);
1482
+ process.exit(1);
1483
+ }
1484
+ for (const rel of walkMd(packsRoot, '')) console.log(rel);
1485
+ process.exit(0);
1486
+ }
1487
+
1488
+ if (sub) {
1489
+ // `omd pack <relpath>` — treat `sub` as a relative path under the pack root.
1490
+ const target = join(packsRoot, sub, ...rest);
1491
+ if (!existsSync(target)) {
1492
+ console.error(`pack file not found: ${target}`);
1493
+ process.exit(1);
1494
+ }
1495
+ process.stdout.write(readFileSync(target, 'utf8'));
1496
+ process.exit(0);
1497
+ }
1498
+
1499
+ console.error('usage: omd pack dir | list | <relpath>');
1500
+ process.exit(1);
1501
+ }
1502
+
1503
+ function cmdSlop(sub: string | undefined, opts: Opts): never {
1504
+ if (sub !== 'scan') throw new Error('usage: omd slop scan [root] [--json]');
1505
+ const result = scanSlopSource(opts._[0] ?? process.cwd());
1506
+ if (opts.json) process.stdout.write(JSON.stringify(result));
1507
+ else {
1508
+ console.log(`source candidates: ${result.candidates.length} (${result.filesScanned} files scanned)`);
1509
+ for (const item of result.candidates) {
1510
+ console.log(`${item.path}:${item.line} ${item.candidateId} ${item.reviewQuestion}`);
1511
+ }
1512
+ }
1513
+ process.exit(0);
1514
+ }
1515
+
1516
+ /** Advisory AI-cliche scan of copy-deck / rendered copy. Non-gating; always exit 0. */
1517
+ function cmdTextSlop(opts: Opts): never {
1518
+ const file = opts._[0] ?? join(process.cwd(), '.omd', 'copy-deck.md');
1519
+ const text = existsSync(file) ? readFileSync(file, 'utf8') : '';
1520
+ const candidates = scanTextSlop(text);
1521
+ if (opts.json) process.stdout.write(JSON.stringify({ file, candidates }));
1522
+ else {
1523
+ console.log(`text-slop candidates: ${candidates.length} (${file})`);
1524
+ for (const c of candidates) console.log(`${file}:${c.line} ${c.candidateId} ${c.reviewQuestion}`);
1525
+ if (candidates.length === 0) console.log('ok — no AI-cliche phrase candidates (advisory only; not proof of good prose)');
1526
+ }
1527
+ process.exit(0);
1528
+ }
1529
+
1530
+ /** Advisory carrier / visual-richness read of composition.md. Non-gating; always exit 0. */
1531
+ function cmdVisualRichness(opts: Opts): never {
1532
+ const file = opts._[0] ?? join(process.cwd(), '.omd', 'composition.md');
1533
+ const contract = existsSync(file) ? readFileSync(file, 'utf8') : '';
1534
+ const valid = ['quiet', 'confident', 'showpiece'];
1535
+ const register = valid.includes(opts.register ?? '') ? (opts.register as VisualRichnessRegister) : undefined;
1536
+ const findings = evaluateVisualRichness(register ? { contract, register } : { contract });
1537
+ if (opts.json) process.stdout.write(JSON.stringify({ file, register: register ?? null, findings }));
1538
+ else {
1539
+ console.log(`visual-richness advisories: ${findings.length} (${file}${register ? `, register=${register}` : ''})`);
1540
+ for (const f of findings) console.log(`[advisory] ${f.id} ${f.section}: ${f.message}`);
1541
+ if (findings.length === 0) console.log('ok — every content section names a purposeful visual carrier (advisory only)');
1542
+ }
1543
+ process.exit(0);
1544
+ }
1545
+
1546
+ /** `omd stack` — deterministic stack routing from folder evidence; the hand builds exactly what it names. */
1547
+ function cmdStack(opts: Opts): never {
1548
+ const d = computeStack(process.cwd());
1549
+ if (opts.json) process.stdout.write(JSON.stringify(d));
1550
+ else {
1551
+ console.log(`stack: ${d.stack}${d.framework ? ` (${d.framework})` : ''}`);
1552
+ console.log(` reason: ${d.reason}`);
1553
+ console.log(` greenfield: ${d.greenfield}; plain-HTML allowed only with a verbatim user request: ${d.htmlOverrideAllowed}`);
1554
+ }
1555
+ process.exit(0);
1556
+ }
1557
+
1558
+ function usage(): never {
1559
+ console.error(
1560
+ 'usage: omd <command>\n\n'
1561
+ + ' ir <page> [-o f] rendered DOM -> Design IR\n'
1562
+ + ' render <page> -o shot.png [--viewport WxH] headless screenshot\n'
1563
+ + ' render <page> --full-page -o shot.png supplementary long-page capture\n'
1564
+ + ' render <page> --squint -o shot.png grayscale + blur hierarchy isolation\n'
1565
+ + ' render <page> --proofs -o <prefix> all four proofs (fixed+full, desktop+mobile) in one browser\n'
1566
+ + ' render <page> --filmstrip -o f.html [--viewport WxH] load-time filmstrip\n'
1567
+ + ' probe <page> [--plan path] [--json] [--out path] declared local interaction path\n'
1568
+ + ' check [<page>|--ir f] [--json] [--category slop] [--no-log]\n'
1569
+ + ' check --site <dir> cross-page consistency (SITE-*)\n'
1570
+ + ' check <page1> <page2> ... same, multi-page positional\n'
1571
+ + ' slop scan [root] [--json] read-only source candidate scan\n'
1572
+ + ' stack [--json] deterministic stack routing (blank greenfield -> React+Vite+TS)\n'
1573
+ + ' coach trends across `omd check` history\n'
1574
+ + '\n'
1575
+ + ' frame show\n'
1576
+ + ' frame set --problem P --reframe R --why EVIDENCE\n'
1577
+ + ' [--task T --frequent-action A --costliest-error E --surface S --task-matrix "T1 …"]\n'
1578
+ + ' frame reframe --to "..." --because "what the render revealed"\n'
1579
+ + ' frame generator --set "metaphor"\n'
1580
+ + '\n'
1581
+ + ' choose c1 c2 c3 --chose c3 --why "..."\n'
1582
+ + ' decision "what" --why "why"\n'
1583
+ + ' taste record "subject" --kind selection|praise|rejection|overrule --evidence "verbatim" --from-user\n'
1584
+ + ' taste profile [--all]\n'
1585
+ + ' config set checkpoint none|concept|structure|both | config show\n'
1586
+ + ' craft checkpoint semantic|visual --render P --observed "..." --changed "..."\n'
1587
+ + ' craft status [--json]\n'
1588
+ + '\n'
1589
+ + ' ref add <url|file> --as <component> [--selector "css"] [--image] [--blueprint]\n'
1590
+ + ' render, extract invariants, save\n'
1591
+ + ' ref add ... --selector ".nav" --blueprint also capture a component blueprint\n'
1592
+ + ' ref add ... --selector ".nav" --blueprint --shot also save the component screenshot beside its blueprint\n'
1593
+ + ' ref add-batch <manifest.json> capture many references in parallel over one browser\n'
1594
+ + ' ref list one line per saved reference\n'
1595
+ + ' ref distance <page> compare a page to every saved reference\n'
1596
+ + ' ref principles <source> --as C --add "..." record why a reference works\n'
1597
+ + ' ref show <source> --as C invariants + principles\n'
1598
+ + '\n'
1599
+ + ' design discover evidence and create/refresh .omd/design.md\n'
1600
+ + ' design --check validate design.md section coverage\n'
1601
+ + ' copy --check [--json] validate required copy deck structure and fact refs\n'
1602
+ + ' copy --review-check [--json] validate copy-eye report structure only (not blindness)\n'
1603
+ + ' composition --check [--json] validate composition sections and input freshness\n'
1604
+ + ' source --seal [root] write final approved-input/source byte seal\n'
1605
+ + ' source --check [root] [--json] fail when the source seal is missing or stale\n'
1606
+ + ' evidence finalize --input manifest.json immutably record this run and atomically supersede current evidence\n'
1607
+ + ' evidence check [--json] re-hash final evidence source seal, build target, and artifacts\n'
1608
+ + ' evidence tasks --input .omd/.cache/task-evidence-manifest.json publish strict production task evidence\n'
1609
+ + ' evidence tasks-check [--json] revalidate strict production task evidence\n'
1610
+ + '\n'
1611
+ + ' text-slop [file] [--json] advisory AI-cliche scan of copy (default .omd/copy-deck.md)\n'
1612
+ + ' visual-richness [file] [--register R] [--json] advisory carrier read of composition (default .omd/composition.md)\n'
1613
+ + '\n'
1614
+ + ' pack dir print the knowledge-pack root path\n'
1615
+ + ' pack list list all pack .md files\n'
1616
+ + ' pack <relpath> print one pack file (e.g. theory/color.md)\n'
1617
+ + '\n'
1618
+ + ' doctor check environment prerequisites\n'
1619
+ + '\n'
1620
+ + ' figma pull <file-url> fetch Figma file -> .omd/figma/snapshot.json\n'
1621
+ + ' figma system synthesize design system from snapshot\n'
1622
+ + ' figma diff <frame-id> <page-or-url> pixel diff: Figma export vs build render\n'
1623
+ + '\n'
1624
+ + ' target set <image-path-or-url> --as <name> register a visual target (mockup / screenshot)\n'
1625
+ + ' target list show registered targets\n'
1626
+ + ' target diff <page> [--target <name>] [--viewport WxH] [--threshold N] [--json]\n'
1627
+ + ' pixel diff: target vs build render (exit 1 below threshold)',
1628
+ );
1629
+ process.exit(1);
1630
+ }
1631
+
1632
+ async function main(): Promise<never> {
1633
+ const args = process.argv.slice(2);
1634
+ const [cmd, sub] = args;
1635
+
1636
+ if (cmd === '--version') {
1637
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { version: string };
1638
+ console.log(pkg.version);
1639
+ process.exit(0);
1640
+ }
1641
+
1642
+ if (cmd === 'ir') return cmdIr(parseArgs(args.slice(1)));
1643
+ if (cmd === 'render') return cmdRender(parseArgs(args.slice(1)));
1644
+ if (cmd === 'probe') return cmdProbe(parseArgs(args.slice(1)));
1645
+ if (cmd === 'check') return cmdCheck(parseArgs(args.slice(1)));
1646
+ if (cmd === 'slop') return cmdSlop(sub, parseArgs(args.slice(2)));
1647
+ if (cmd === 'coach') return cmdCoach();
1648
+ if (cmd === 'config') return cmdConfig(sub, parseArgs(args.slice(2)));
1649
+ if (cmd === 'craft') return cmdCraft(sub, parseArgs(args.slice(2)));
1650
+
1651
+ if (cmd === 'frame') {
1652
+ const opts = parseArgs(args.slice(2));
1653
+ if (sub === 'show') return cmdFrameShow();
1654
+
1655
+ if (sub === 'set') {
1656
+ const path = writeFrameRecord(process.cwd(), {
1657
+ problem: opts.problem ?? '',
1658
+ reframe: opts.reframe ?? '',
1659
+ ...(opts.why ? { why: opts.why } : {}),
1660
+ ...(opts.task ? { uxTask: opts.task } : {}),
1661
+ ...(opts.frequentAction ? { uxFrequentAction: opts.frequentAction } : {}),
1662
+ ...(opts.costliestError ? { uxCostliestError: opts.costliestError } : {}),
1663
+ ...(opts.surface ? { uxSurface: opts.surface } : {}),
1664
+ ...(opts.taskMatrix ? { taskCoverageMatrix: opts.taskMatrix } : {}),
1665
+ });
1666
+ console.log(path);
1667
+ process.exit(0);
1668
+ }
1669
+
1670
+ if (sub === 'reframe') {
1671
+ if (!opts.to || !opts.because) {
1672
+ console.error('usage: omd frame reframe --to "..." --because "what the render revealed"');
1673
+ process.exit(1);
1674
+ }
1675
+ console.log(reframe(process.cwd(), { to: opts.to, because: opts.because }));
1676
+ process.exit(0);
1677
+ }
1678
+
1679
+ if (sub === 'generator') {
1680
+ if (!opts.set) usage();
1681
+ setGenerator(process.cwd(), opts.set);
1682
+ console.log(`generator: ${opts.set}`);
1683
+ process.exit(0);
1684
+ }
1685
+ return usage();
1686
+ }
1687
+
1688
+ if (cmd === 'ref') {
1689
+ const opts = parseArgs(args.slice(2));
1690
+ if (sub === 'add') return cmdRefAdd(opts);
1691
+ if (sub === 'add-batch') return cmdRefAddBatch(opts);
1692
+ if (sub === 'list') return cmdRefList();
1693
+ if (sub === 'distance') return cmdRefDistance(opts);
1694
+ if (sub === 'principles') return cmdRefPrinciples(opts);
1695
+ if (sub === 'show') return cmdRefShow(opts);
1696
+ return usage();
1697
+ }
1698
+
1699
+ if (cmd === 'design') return cmdDesign(parseArgs(args.slice(1)));
1700
+ if (cmd === 'copy') return cmdCopy(parseArgs(args.slice(1)));
1701
+ if (cmd === 'composition') return cmdComposition(parseArgs(args.slice(1)));
1702
+ if (cmd === 'source') return cmdSource(sub, parseArgs(args.slice(2)));
1703
+ if (cmd === 'evidence') return cmdEvidence(sub, parseArgs(args.slice(2)));
1704
+ if (cmd === 'stack') return cmdStack(parseArgs(args.slice(1)));
1705
+ if (cmd === 'text-slop') return cmdTextSlop(parseArgs(args.slice(1)));
1706
+ if (cmd === 'visual-richness') return cmdVisualRichness(parseArgs(args.slice(1)));
1707
+ if (cmd === 'pack') return cmdPack(sub, ...args.slice(2));
1708
+ if (cmd === 'doctor') return cmdDoctor();
1709
+
1710
+ if (cmd === 'figma') {
1711
+ if (sub === 'pull') return cmdFigmaPull(args[2]);
1712
+ if (sub === 'system') return cmdFigmaSystem();
1713
+ if (sub === 'diff') return cmdFigmaDiff(parseArgs(args.slice(2)));
1714
+ return usage();
1715
+ }
1716
+
1717
+ if (cmd === 'target') {
1718
+ const opts = parseArgs(args.slice(2));
1719
+ if (sub === 'set') return cmdTargetSet(opts);
1720
+ if (sub === 'list') return cmdTargetList();
1721
+ if (sub === 'diff') return cmdTargetDiff(opts);
1722
+ return usage();
1723
+ }
1724
+
1725
+ if (cmd === 'choose') return cmdChoose(parseArgs(args.slice(1)));
1726
+
1727
+ if (cmd === 'decision') {
1728
+ const opts = parseArgs(args.slice(1));
1729
+ const what = opts._[0];
1730
+ if (!what || !opts.why) {
1731
+ console.error('usage: omd decision "what" --why "why"\nA decision without a reason is not a decision.');
1732
+ process.exit(1);
1733
+ }
1734
+ console.log(logDecision(process.cwd(), what, opts.why));
1735
+ process.exit(0);
1736
+ }
1737
+
1738
+ if (cmd === 'taste' && sub === 'record') {
1739
+ const opts = parseArgs(args.slice(2));
1740
+ const subject = opts._[0];
1741
+ if (!subject || !opts.kind || !['selection', 'praise', 'rejection', 'overrule'].includes(opts.kind) || !opts.evidence) {
1742
+ throw new Error('usage: omd taste record "subject" --kind selection|praise|rejection|overrule --evidence "verbatim" --from-user');
1743
+ }
1744
+ console.log(logTaste(process.cwd(), {
1745
+ subject, kind: opts.kind as 'selection' | 'praise' | 'rejection' | 'overrule',
1746
+ evidence: opts.evidence, fromUser: opts.fromUser === true,
1747
+ }));
1748
+ process.exit(0);
1749
+ }
1750
+
1751
+ if (cmd === 'taste' && sub === 'profile') {
1752
+ const opts = parseArgs(args.slice(2));
1753
+ const { n, records } = tasteProfile(process.cwd(), opts.all);
1754
+ if (n === 0) console.log(opts.all ? 'No taste records yet.' : 'No explicit user taste recorded yet.');
1755
+ else {
1756
+ const lines = records.map((r) => {
1757
+ if (r.among && r.chose) {
1758
+ const over = r.among.filter((a) => a !== r.chose).join(',');
1759
+ return ` [${r.actor}] ${r.chose} over ${over}${r.why ? ` \u2014 ${r.why}` : ''}`;
1760
+ }
1761
+ return ` [${r.actor}] ${r.kind}: ${r.subject} \u2014 ${r.evidence}`;
1762
+ });
1763
+ console.log(`${n} taste records\n${lines.join('\n')}`);
1764
+ }
1765
+ process.exit(0);
1766
+ }
1767
+
1768
+ return usage();
1769
+ }
1770
+
1771
+ main().catch((err: unknown) => {
1772
+ console.error(err instanceof Error ? err.message : String(err));
1773
+ process.exit(1);
1774
+ });