@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,189 @@
1
+ import type { Ir, Node, Violation } from '../types.ts';
2
+
3
+ /**
4
+ * Deterministic interaction-state rules over the IR.
5
+ *
6
+ * ── What the IR can measure ──────────────────────────────────────────────────
7
+ *
8
+ * The DOM IR captures, per node:
9
+ * - node.name tagname + first CSS class, e.g. "input.email-field", "div.error-msg"
10
+ * - node.path full ancestor path, e.g. "form.contact/div.field/input"
11
+ * - node.interactive true for INPUT, SELECT, TEXTAREA, BUTTON, A, SUMMARY, role=button
12
+ * - node.text own text content (trimmed, ≤200 chars)
13
+ *
14
+ * It does NOT capture: aria-* attributes, data-* attributes, full class lists
15
+ * beyond the first class, JS event handlers, or CSS :hover/:focus pseudo-classes.
16
+ *
17
+ * ── What becomes a deterministic rule ────────────────────────────────────────
18
+ *
19
+ * DESIGN-FORM-NO-ERROR
20
+ * When the IR contains at least one form-input node (INPUT/TEXTAREA/SELECT that
21
+ * is interactive) AND no node on the page carries a class name or text pattern
22
+ * associated with an error state, the form has no visible error affordance.
23
+ *
24
+ * Detection signals for error affordance (OR — any one suffices):
25
+ * - A node whose name contains ".error" or ".invalid" (class name evidence)
26
+ * - A text node whose text contains "error" / "오류" / "잘못" (copy evidence)
27
+ * - A node with role="alert" (ARIA error container — the canonical accessible pattern)
28
+ * - A node with aria-invalid="true" (ARIA field-level error signal)
29
+ *
30
+ * False-positive mitigation: the rule fires only when form inputs are present AND
31
+ * no error affordance exists at all — not when an affordance exists but looks wrong.
32
+ * A div.error-state with empty text still counts as present; we are checking
33
+ * structure, not copy quality.
34
+ *
35
+ * The ARIA signals (role=alert, aria-invalid) improve precision over class-name-only
36
+ * detection: a page that correctly implements ARIA error patterns without ".error"
37
+ * class names no longer produces a false positive.
38
+ *
39
+ * ── What stays prompt-only and why ───────────────────────────────────────────
40
+ *
41
+ * LOADING state
42
+ * Skeleton, spinner, and shimmer elements are commonly built with libraries
43
+ * (react-loading-skeleton, Tailwind's animate-pulse) and inlined at runtime.
44
+ * Static IR captures the resting DOM; any loading overlay is usually hidden
45
+ * (display:none or opacity:0) and therefore invisible to the IR extractor
46
+ * (dom.ts skips elements with zero dimensions or display:none). Cannot detect
47
+ * without runtime JavaScript interaction simulation.
48
+ * → Covered by hand.agent.yaml and finish-pass.md rules.
49
+ *
50
+ * EMPTY state
51
+ * Empty states are rendered conditionally by the application — they are absent
52
+ * from the DOM when there is data. The IR captures the rendered page, which
53
+ * typically has data. Detecting "this list has an empty state" from the static
54
+ * DOM would require knowing which lists are data-driven vs. static (nav, footer,
55
+ * feature-list), which requires full application context.
56
+ * Furthermore, the false-positive rate would be unacceptably high: every <ul>,
57
+ * <ol>, and .list container would need to be evaluated, and most would fire on
58
+ * purely static lists (navigation, feature bullets, footer links).
59
+ * → Covered by hand.agent.yaml and finish-pass.md rules.
60
+ *
61
+ * SUCCESS state
62
+ * Success confirmation UIs (toast, inline text, page transition) are triggered
63
+ * by user actions (form submit, button click) and are absent from the resting DOM.
64
+ * No IR measurement can confirm they exist without simulating user actions.
65
+ * → Covered by hand.agent.yaml rules.
66
+ *
67
+ * DISABLED state quality
68
+ * Detecting whether disabled elements communicate why they are disabled requires
69
+ * reading the associated explanatory copy and its proximity to the disabled element
70
+ * — a layout and semantic reasoning task, not a structural one.
71
+ * The IR does capture `interactive` (which covers BUTTON/INPUT), but the disabled
72
+ * attribute is not captured, and reasoning about adjacent copy context requires
73
+ * the full DOM tree with aria relationships, which the IR does not store.
74
+ * → Covered by hand.agent.yaml rules.
75
+ *
76
+ * OFFLINE state
77
+ * Offline detection requires a service worker or navigator.onLine event handler.
78
+ * Neither is visible in the static DOM IR.
79
+ * → Covered by hand.agent.yaml rules.
80
+ */
81
+
82
+ // ── DESIGN-FORM-NO-ERROR ─────────────────────────────────────────────────────
83
+
84
+ /**
85
+ * Returns true when a node is a form-input element that users can type into or
86
+ * select from. Buttons are excluded: they do not receive inline validation.
87
+ */
88
+ function isFormInput(name: string, interactive: boolean): boolean {
89
+ if (!interactive) return false;
90
+ return name.startsWith('input') || name.startsWith('textarea') || name.startsWith('select');
91
+ }
92
+
93
+ /**
94
+ * Returns true when a node carries a structural signal that an error state exists:
95
+ * - Class name contains "error" or "invalid" (structural evidence)
96
+ * - Text content contains common error vocabulary (copy evidence)
97
+ * - node.role === 'alert' (ARIA error container — the canonical accessible pattern)
98
+ * - node.ariaInvalid === true (ARIA field-level error signal)
99
+ *
100
+ * This is an OR check — any one signal suffices to suppress the violation.
101
+ * The ARIA signals were added to reduce false positives: pages that correctly use
102
+ * role=alert or aria-invalid without ".error" class names no longer trigger.
103
+ */
104
+ function hasErrorAffordance(node: Node): boolean {
105
+ const { name, text, role, ariaInvalid } = node;
106
+
107
+ // ARIA evidence: role=alert is the canonical container for live error messages;
108
+ // aria-invalid=true is the field-level signal that a value failed validation.
109
+ if (role === 'alert' || role === 'alertdialog') return true;
110
+ if (ariaInvalid === true) return true;
111
+
112
+ // Class-name evidence: "div.error", "span.invalid", "p.error-message", "input.is-invalid"
113
+ const lower = name.toLowerCase();
114
+ if (
115
+ lower.includes('.error') ||
116
+ lower.includes('.invalid') ||
117
+ lower.includes('error-') ||
118
+ lower.includes('-error') ||
119
+ lower.includes('invalid-') ||
120
+ lower.includes('-invalid')
121
+ ) return true;
122
+
123
+ // Text-content evidence: the rendered page shows an error message
124
+ if (text) {
125
+ const t = text.toLowerCase();
126
+ if (
127
+ t.includes('error') ||
128
+ t.includes('오류') ||
129
+ t.includes('잘못') ||
130
+ t.includes('올바르지') ||
131
+ t.includes('invalid') ||
132
+ t.includes('required') ||
133
+ t.includes('필수')
134
+ ) return true;
135
+ }
136
+
137
+ return false;
138
+ }
139
+
140
+ /**
141
+ * Run deterministic interaction-state checks over the IR.
142
+ *
143
+ * Currently implements one deterministic rule (DESIGN-FORM-NO-ERROR).
144
+ * All other interaction states are prompt-only; see the file-level comment above.
145
+ *
146
+ * Only call this from `omd check` when the page has interactive elements —
147
+ * it is always safe to call regardless, but pages with no inputs will produce
148
+ * no findings (the form-input guard suppresses the rule).
149
+ */
150
+ export function checkInteractionStates(ir: Ir): Violation[] {
151
+ const violations: Violation[] = [];
152
+
153
+ // ── DESIGN-FORM-NO-ERROR ──────────────────────────────────────────────────
154
+ //
155
+ // Fires when: at least one form-input node is present AND no node on the page
156
+ // carries an error-state affordance (class or text pattern).
157
+ //
158
+ // The rule fires once at the page level, not once per input — a form design
159
+ // either has error states or it does not. Firing once per input would produce
160
+ // redundant noise on multi-field forms.
161
+
162
+ const formInputs = ir.nodes.filter((n) => isFormInput(n.name, n.interactive === true));
163
+
164
+ if (formInputs.length > 0) {
165
+ const anyError = ir.nodes.some((n) => hasErrorAffordance(n));
166
+
167
+ if (!anyError) {
168
+ // Report against the first input node's path for location context.
169
+ const representative = formInputs[0]!;
170
+ violations.push({
171
+ id: 'DESIGN-FORM-NO-ERROR',
172
+ severity: 'warn',
173
+ layer: 1,
174
+ category: 'system',
175
+ nodeId: representative.id,
176
+ path: representative.path,
177
+ value: `${formInputs.length} form input${formInputs.length === 1 ? '' : 's'}, 0 error affordances`,
178
+ message:
179
+ `This page has ${formInputs.length} form input${formInputs.length === 1 ? '' : 's'} but no visible error-state affordance. `
180
+ + 'Add error-styled elements (class names containing "error" or "invalid") and error message copy. '
181
+ + 'Every field that can fail validation must show what went wrong and what to fix — '
182
+ + 'role=alert on the error container, aria-invalid on the failed field. '
183
+ + 'A form without an error state is an unfinished interface.',
184
+ });
185
+ }
186
+ }
187
+
188
+ return violations;
189
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Phase 0 eval harness — pure decision core.
3
+ *
4
+ * Pairwise before/after blind-choose is the ONLY gating signal. Quantitative metrics
5
+ * (non-text carrier density, motion presence, image-diff score/threshold/pass — see
6
+ * `DiffResult` in core/figma/diff.ts for that shape) and Awwwards-style category
7
+ * scores (design/usability/creativity/content) are advisory: they may explain or
8
+ * corroborate a verdict, but they can never flip `gate` from what blindChoose decided.
9
+ *
10
+ * NOT implemented here: a live render+blind-choose capture adapter (rendering both
11
+ * variants and recording a human/model's blind pick) is a Phase 2 dependency — it
12
+ * needs a browser/render runtime (see core/render/index.ts's Playwright-backed
13
+ * `withPage`). This module is the pure decision core that consumes pre-captured
14
+ * blindChoose signals; it does not render or capture anything itself. Follow the
15
+ * pure-core/adapter split used by core/composition-contract/index.ts
16
+ * (validateCompositionContractSource = pure, validateCompositionContract = adapter):
17
+ * a future `captureBlindChoose(...)` adapter would live alongside this file once the
18
+ * render runtime is available, calling into `evaluateOutput` below for the decision.
19
+ */
20
+
21
+ export type BlindChooseWinner = 'after' | 'before' | 'tie';
22
+
23
+ export interface BlindChooseSignal {
24
+ winner: BlindChooseWinner;
25
+ /** Confidence in [0, 1], when the capture method can report one. */
26
+ confidence?: number;
27
+ }
28
+
29
+ export type Register = 'quiet' | 'confident' | 'showpiece';
30
+
31
+ export interface QuantitativeSignals {
32
+ /** 0–1 fraction of the layout carried by non-text elements (imagery, shape, color). */
33
+ nonTextCarrierDensity?: number | undefined;
34
+ motionPresent?: boolean | undefined;
35
+ /** Reserved for image-diff-style signals (score/threshold/pass) or other numeric/boolean metrics. */
36
+ [key: string]: number | boolean | undefined;
37
+ }
38
+
39
+ export interface AwwwardsSignals {
40
+ design?: number | undefined;
41
+ usability?: number | undefined;
42
+ creativity?: number | undefined;
43
+ content?: number | undefined;
44
+ }
45
+
46
+ export interface EvalHarnessInputs {
47
+ blindChoose: BlindChooseSignal;
48
+ register?: Register;
49
+ quantitative?: QuantitativeSignals;
50
+ awwwards?: AwwwardsSignals;
51
+ }
52
+
53
+ export interface EvalAdvisory {
54
+ source: 'quantitative' | 'awwwards';
55
+ message: string;
56
+ gating: false;
57
+ }
58
+
59
+ export interface EvalVerdict {
60
+ gate: 'pass' | 'fail';
61
+ gatingSignal: 'blind-choose';
62
+ reason: string;
63
+ advisories: EvalAdvisory[];
64
+ }
65
+
66
+ /** Showpiece register requires a stronger blind-choose confidence to pass the gate. */
67
+ const SHOWPIECE_CONFIDENCE_THRESHOLD = 0.6;
68
+
69
+ /**
70
+ * Determines the gate from blindChoose alone. Quiet/confident/showpiece registers only
71
+ * adjust how blindChoose itself is interpreted (e.g. a showpiece needs a confident win,
72
+ * a quiet output is satisfied by any non-"before" verdict) — they never let quantitative
73
+ * or Awwwards signals override the blind-choose outcome.
74
+ */
75
+ function gateFromBlindChoose(blindChoose: BlindChooseSignal, register: Register): { pass: boolean; reason: string } {
76
+ const { winner, confidence } = blindChoose;
77
+
78
+ if (winner === 'before') {
79
+ return { pass: false, reason: 'blind-choose winner was "before": the after variant did not improve on the baseline' };
80
+ }
81
+
82
+ if (winner === 'tie') {
83
+ if (register === 'quiet' && confidence === undefined) {
84
+ // Quiet register: a tie with no reported confidence is treated as "no regression",
85
+ // which is the bar quiet output is held to. Still requires an explicit tie, never
86
+ // inferred from quantitative/Awwwards data.
87
+ return { pass: true, reason: 'blind-choose was a tie and register is "quiet": no regression is sufficient' };
88
+ }
89
+ return { pass: false, reason: 'blind-choose winner was "tie": no clear preference for the after variant' };
90
+ }
91
+
92
+ // winner === 'after'
93
+ if (register === 'showpiece') {
94
+ if (confidence === undefined || !Number.isFinite(confidence) || confidence < SHOWPIECE_CONFIDENCE_THRESHOLD) {
95
+ return {
96
+ pass: false,
97
+ reason: `register is "showpiece" and requires blind-choose confidence >= ${SHOWPIECE_CONFIDENCE_THRESHOLD}, got ${confidence ?? 'undefined'}`,
98
+ };
99
+ }
100
+ return { pass: true, reason: `blind-choose winner was "after" with confidence ${confidence} meeting the showpiece threshold` };
101
+ }
102
+
103
+ return { pass: true, reason: 'blind-choose winner was "after"' };
104
+ }
105
+
106
+ function quantitativeAdvisories(quantitative: QuantitativeSignals | undefined): EvalAdvisory[] {
107
+ if (!quantitative) return [];
108
+ const advisories: EvalAdvisory[] = [];
109
+ for (const [key, value] of Object.entries(quantitative)) {
110
+ if (value === undefined) continue;
111
+ advisories.push({ source: 'quantitative', message: `${key}: ${value}`, gating: false });
112
+ }
113
+ return advisories;
114
+ }
115
+
116
+ function awwwardsAdvisories(awwwards: AwwwardsSignals | undefined): EvalAdvisory[] {
117
+ if (!awwwards) return [];
118
+ const advisories: EvalAdvisory[] = [];
119
+ for (const [key, value] of Object.entries(awwwards)) {
120
+ if (value === undefined) continue;
121
+ advisories.push({ source: 'awwwards', message: `${key}: ${value}`, gating: false });
122
+ }
123
+ return advisories;
124
+ }
125
+
126
+ /**
127
+ * Pure evaluation core: blind-choose is the sole gating signal (see gateFromBlindChoose).
128
+ * Quantitative metrics and Awwwards category scores are folded into `advisories` only,
129
+ * each explicitly marked `gating: false` — no code path lets them alter `gate`.
130
+ */
131
+ export function evaluateOutput(inputs: EvalHarnessInputs): EvalVerdict {
132
+ const register = inputs.register ?? 'confident';
133
+ const { pass, reason } = gateFromBlindChoose(inputs.blindChoose, register);
134
+
135
+ const advisories: EvalAdvisory[] = [
136
+ ...quantitativeAdvisories(inputs.quantitative),
137
+ ...awwwardsAdvisories(inputs.awwwards),
138
+ ];
139
+
140
+ return {
141
+ gate: pass ? 'pass' : 'fail',
142
+ gatingSignal: 'blind-choose',
143
+ reason,
144
+ advisories,
145
+ };
146
+ }
147
+
148
+ /**
149
+ * One completed refinement round in the RED/GREEN loop. `redCriteria` are the acceptance
150
+ * criteria still failing after this round (empty = GREEN, all met). `evidence` is the durable
151
+ * proof this round actually happened — render paths, verdict artifacts, `omd decision` ids — and
152
+ * is REQUIRED: a round with no evidence does not count. `blindChoose` compares this round's after
153
+ * against its own before, giving the improvement direction.
154
+ */
155
+ export interface RefinementRound {
156
+ round: number;
157
+ blindChoose: BlindChooseWinner;
158
+ redCriteria: string[];
159
+ evidence: string[];
160
+ }
161
+
162
+ export interface RefinementLoopInputs {
163
+ rounds: RefinementRound[];
164
+ /** Hard upper bound on rounds — the loop is never unbounded. Default 3. */
165
+ maxRounds?: number;
166
+ /** Rounds to run before a plateau/GREEN stop is honored. Default 1. */
167
+ minRounds?: number;
168
+ }
169
+
170
+ export type RefinementLoopStatus =
171
+ | 'run-first-round'
172
+ | 'continue'
173
+ | 'green'
174
+ | 'regressed'
175
+ | 'plateaued'
176
+ | 'budget-exhausted'
177
+ | 'missing-evidence';
178
+
179
+ export interface RefinementLoopDecision {
180
+ status: RefinementLoopStatus;
181
+ continueLoop: boolean;
182
+ converged: boolean;
183
+ reason: string;
184
+ roundsRun: number;
185
+ /** The top unmet (RED) criterion to target next when continuing; null otherwise. */
186
+ nextTarget: string | null;
187
+ }
188
+
189
+ /**
190
+ * Bounded RED/GREEN refinement-loop controller. It is NOT an automatic retry loop. Each round
191
+ * MUST leave evidence; the loop continues only while the build is still RED (acceptance criteria
192
+ * unmet) AND blind-choose shows the latest round genuinely improved it AND the round budget
193
+ * remains. It stops on the first of — GREEN (all criteria met = done), a regression (revert), a
194
+ * plateau (tie while still RED), a budget exhaustion, or missing evidence. This is the deliberate,
195
+ * evidence-driven convergence loop `protocol/human-design-loop.md` permits.
196
+ */
197
+ export function evaluateRefinementLoop(inputs: RefinementLoopInputs): RefinementLoopDecision {
198
+ const maxRounds = inputs.maxRounds ?? 3;
199
+ const minRounds = inputs.minRounds ?? 1;
200
+ const rounds = inputs.rounds;
201
+ const n = rounds.length;
202
+
203
+ if (n === 0) {
204
+ return { status: 'run-first-round', continueLoop: true, converged: false, reason: 'no refinement round run yet — run the first round and record its evidence', roundsRun: 0, nextTarget: null };
205
+ }
206
+
207
+ const last = rounds[n - 1]!;
208
+
209
+ if (last.evidence.length === 0) {
210
+ return { status: 'missing-evidence', continueLoop: false, converged: false, reason: `round ${last.round} recorded no evidence — a refinement round without evidence (renders + blind-choose verdict) does not count; record it before deciding`, roundsRun: n, nextTarget: null };
211
+ }
212
+ if (last.blindChoose === 'before') {
213
+ return { status: 'regressed', continueLoop: false, converged: false, reason: `round ${last.round} regressed (blind-choose favored the before) — revert to the previous build and stop`, roundsRun: n, nextTarget: null };
214
+ }
215
+ if (last.redCriteria.length === 0 && n >= minRounds) {
216
+ return { status: 'green', continueLoop: false, converged: true, reason: `round ${last.round} is GREEN — every acceptance criterion is met; stop`, roundsRun: n, nextTarget: null };
217
+ }
218
+ if (n >= maxRounds) {
219
+ return { status: 'budget-exhausted', continueLoop: false, converged: false, reason: `round budget reached (${maxRounds}) — stop with the best build so far; still RED: ${last.redCriteria.join(', ') || '(none reported)'}`, roundsRun: n, nextTarget: null };
220
+ }
221
+ if (last.blindChoose === 'tie' && n >= minRounds) {
222
+ return { status: 'plateaued', continueLoop: false, converged: false, reason: `round ${last.round} plateaued (blind-choose tie) while still RED — no further improvement available; stop and report remaining: ${last.redCriteria.join(', ')}`, roundsRun: n, nextTarget: null };
223
+ }
224
+ return { status: 'continue', continueLoop: true, converged: false, reason: `round ${last.round} improved (blind-choose favored the after) and is still RED — run another round`, roundsRun: n, nextTarget: last.redCriteria[0] ?? null };
225
+ }