@hegemonart/get-design-done 1.33.5 → 1.34.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 (33) hide show
  1. package/.claude-plugin/marketplace.json +6 -3
  2. package/.claude-plugin/plugin.json +5 -2
  3. package/CHANGELOG.md +48 -0
  4. package/README.md +14 -0
  5. package/SKILL.md +1 -0
  6. package/agents/compose-executor.md +142 -0
  7. package/agents/design-authority-watcher.md +4 -0
  8. package/agents/design-context-builder.md +35 -1
  9. package/agents/design-verifier.md +14 -18
  10. package/agents/flutter-executor.md +147 -0
  11. package/agents/swift-executor.md +226 -0
  12. package/connections/android-emulator.md +107 -0
  13. package/connections/connections.md +6 -0
  14. package/connections/openrouter.md +86 -0
  15. package/connections/xcode-simulator.md +108 -0
  16. package/hooks/budget-enforcer.ts +103 -0
  17. package/package.json +3 -2
  18. package/reference/gdd-threat-model.md +63 -0
  19. package/reference/native-platforms.md +273 -0
  20. package/reference/openrouter-tier-mapping.md +98 -0
  21. package/reference/prices.openrouter.md +26 -0
  22. package/reference/registry.json +21 -0
  23. package/scripts/lib/authority-watcher/index.cjs +147 -0
  24. package/scripts/lib/budget-enforcer.cjs +16 -0
  25. package/scripts/lib/design-tokens/_native-shared.cjs +206 -0
  26. package/scripts/lib/design-tokens/compose.cjs +150 -0
  27. package/scripts/lib/design-tokens/flutter.cjs +128 -0
  28. package/scripts/lib/design-tokens/index.cjs +13 -0
  29. package/scripts/lib/design-tokens/swift.cjs +122 -0
  30. package/scripts/lib/openrouter/catalog-fetcher.cjs +326 -0
  31. package/scripts/lib/tier-resolver-openrouter.cjs +343 -0
  32. package/sdk/event-stream/types.ts +24 -2
  33. package/skills/openrouter-status/SKILL.md +86 -0
@@ -93,6 +93,22 @@ interface BudgetEnforcerBackend {
93
93
  reason: string | null;
94
94
  };
95
95
  modelFromResolved(resolved: unknown, agent: string): string | null;
96
+ // Plan 33.6-03 (SC#6): the canonical cost-row payload builder (the
97
+ // types.ts:237-designated emit site). Threads the optional `provider` tag
98
+ // ("openrouter" when the OpenRouter adapter resolved the model), omitting it
99
+ // when absent (back-compat).
100
+ buildCostEventPayload(args: {
101
+ runtime: string;
102
+ agent: string;
103
+ model_id: string | null;
104
+ tier: string | null;
105
+ tokens_in: number;
106
+ tokens_out: number;
107
+ cost_usd: number | null;
108
+ runtime_role?: 'host' | 'peer';
109
+ peer_id?: string | null;
110
+ provider?: string;
111
+ }): Record<string, unknown>;
96
112
  }
97
113
  const budgetBackend = nodeRequire('../scripts/lib/budget-enforcer.cjs') as BudgetEnforcerBackend;
98
114
  // Plan 26-05: runtime detection for the cost-event runtime tag. Returns
@@ -175,6 +191,51 @@ const tierResolver = nodeRequire(
175
191
  '../scripts/lib/tier-resolver.cjs',
176
192
  ) as TierResolverModule;
177
193
 
194
+ // Plan 33.6-03 (SC#6, D-08, D-12): OpenRouter tier-resolver adapter. When the
195
+ // user opts in (`.design/config.json#openrouter_enabled: true` OR
196
+ // `OPENROUTER_API_KEY` present), the hook consults this adapter FIRST for a
197
+ // resolved model; a non-null result routes to OpenRouter and tags the cost row
198
+ // `provider: "openrouter"`, a null result falls back to the native resolution
199
+ // path (unchanged default behavior). `resolve(tier, opts)` never throws.
200
+ interface TierResolverOpenRouterModule {
201
+ resolve(
202
+ tier: string,
203
+ opts?: { catalog?: unknown; models?: unknown; overrides?: unknown; cachePath?: string; configPath?: string; cwd?: string },
204
+ ): string | null;
205
+ }
206
+ const tierResolverOpenRouter = nodeRequire(
207
+ '../scripts/lib/tier-resolver-openrouter.cjs',
208
+ ) as TierResolverOpenRouterModule;
209
+
210
+ /**
211
+ * Plan 33.6-03 (SC#6 opt-in). OpenRouter is consulted ONLY when the user opts
212
+ * in — either `.design/config.json#openrouter_enabled === true` OR
213
+ * `OPENROUTER_API_KEY` is present in the environment. Best-effort + never
214
+ * throws: a missing/corrupt config degrades to "env var only". This keeps the
215
+ * default (no OpenRouter) behavior byte-identical for every existing user
216
+ * (D-08, D-12).
217
+ *
218
+ * @param cwd base dir for `.design/config.json` (default process.cwd())
219
+ */
220
+ function isOpenRouterEnabled(cwd?: string): boolean {
221
+ if (
222
+ typeof process.env.OPENROUTER_API_KEY === 'string' &&
223
+ process.env.OPENROUTER_API_KEY.length > 0
224
+ ) {
225
+ return true;
226
+ }
227
+ try {
228
+ const configPath = join(cwd ?? process.cwd(), '.design', 'config.json');
229
+ if (!existsSync(configPath)) return false;
230
+ const parsed = JSON.parse(readFileSync(configPath, 'utf8')) as {
231
+ openrouter_enabled?: unknown;
232
+ };
233
+ return Boolean(parsed && parsed.openrouter_enabled === true);
234
+ } catch {
235
+ return false;
236
+ }
237
+ }
238
+
178
239
  // ── Types ───────────────────────────────────────────────────────────────────
179
240
 
180
241
  /**
@@ -661,6 +722,11 @@ function emitCostRecorded(
661
722
  tokens_in: number;
662
723
  tokens_out: number;
663
724
  cost_usd: number | null;
725
+ // Plan 33.6-03 SC#6 — optional resolution provider ("openrouter" when the
726
+ // OpenRouter adapter resolved the model). Additive/back-compat: omitted
727
+ // from the on-disk row when absent, so the legacy cost_recorded shape is
728
+ // preserved for every native-resolution + pre-33.6 spawn.
729
+ provider?: string;
664
730
  },
665
731
  cycle?: string,
666
732
  ): void {
@@ -677,6 +743,10 @@ function emitCostRecorded(
677
743
  tokens_in: payload.tokens_in,
678
744
  tokens_out: payload.tokens_out,
679
745
  cost_usd: payload.cost_usd,
746
+ // Omit-when-absent (mirrors the .cjs buildCostEventPayload discipline).
747
+ ...(typeof payload.provider === 'string' && payload.provider.length > 0
748
+ ? { provider: payload.provider }
749
+ : {}),
680
750
  },
681
751
  };
682
752
  try {
@@ -1149,6 +1219,36 @@ export async function main(): Promise<void> {
1149
1219
  }
1150
1220
  }
1151
1221
 
1222
+ // ── Plan 33.6-03 — OpenRouter resolution consultation (SC#6, D-08, D-12) ────
1223
+ //
1224
+ // When the user opts in (`.design/config.json#openrouter_enabled: true` OR
1225
+ // `OPENROUTER_API_KEY` present), consult the OpenRouter adapter for the
1226
+ // effective tier FIRST. A non-null result routes this spawn to OpenRouter:
1227
+ // we override the model id and tag the cost row `provider: "openrouter"`. A
1228
+ // null result (no key / catalog missing-or-stale / no match) falls through to
1229
+ // the native resolution that's already in `effectiveModelId` — so the default
1230
+ // (OpenRouter disabled) path is byte-identical to pre-33.6 behavior (D-08).
1231
+ // The adapter never throws; this whole branch is also wrapped defensively.
1232
+ let costProvider: string | undefined;
1233
+ if (isOpenRouterEnabled()) {
1234
+ try {
1235
+ const openrouterModel = tierResolverOpenRouter.resolve(effectiveTier);
1236
+ if (typeof openrouterModel === 'string' && openrouterModel.length > 0) {
1237
+ effectiveModelId = openrouterModel;
1238
+ costProvider = 'openrouter';
1239
+ // Reflect the OpenRouter pick into resolved_models so downstream
1240
+ // consumers see the actual model (mirrors the bandit override above).
1241
+ if (routerDecision !== undefined) {
1242
+ const rm = routerDecision.resolved_models ?? {};
1243
+ rm[agent] = openrouterModel;
1244
+ routerDecision.resolved_models = rm;
1245
+ }
1246
+ }
1247
+ } catch {
1248
+ // Fail open — never let OpenRouter resolution block a spawn (D-08).
1249
+ }
1250
+ }
1251
+
1152
1252
  // Compute runtime-aware cost via the shared backend. Failures return
1153
1253
  // null cost; we emit the event regardless so the cost-aggregator sees
1154
1254
  // the lookup attempt (Phase 22 events.jsonl tagging).
@@ -1169,6 +1269,9 @@ export async function main(): Promise<void> {
1169
1269
  tokens_in: Number(toolInput._tokens_in_est ?? 0),
1170
1270
  tokens_out: Number(toolInput._tokens_out_est ?? 0),
1171
1271
  cost_usd: costLookup.cost_usd,
1272
+ // Plan 33.6-03 SC#6 — tag the row when OpenRouter resolved the model.
1273
+ // Omitted (undefined) on the native path → buildCostEventPayload drops it.
1274
+ ...(costProvider !== undefined ? { provider: costProvider } : {}),
1172
1275
  },
1173
1276
  cycle,
1174
1277
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hegemonart/get-design-done",
3
- "version": "1.33.5",
3
+ "version": "1.34.1",
4
4
  "description": "A design-quality pipeline for AI coding agents: brief, plan, implement, and verify UI work against your design system.",
5
5
  "author": "Hegemon",
6
6
  "homepage": "https://github.com/hegemonart/get-design-done",
@@ -113,6 +113,7 @@
113
113
  "ws": "^8.20.0"
114
114
  },
115
115
  "overrides": {
116
- "fast-json-patch": "^3.1.1"
116
+ "fast-json-patch": "^3.1.1",
117
+ "qs": ">=6.15.2"
117
118
  }
118
119
  }
@@ -43,6 +43,7 @@ controls; the table names what crosses the line.
43
43
  | gdd-state MCP `←` environment / config / tool input | Whoever sets `GDD_STATE_PATH` or supplies a tool-call payload, or authors `.design/config.json` | The `GDD_STATE_PATH` env value + the JSON tool-input payloads |
44
44
  | Peer-CLI broker `↔` spawned child | A spawned peer CLI (Codex / Gemini / Cursor / Copilot / Qwen) and its stdout stream | The child's stdout JSON frames + the parent env handed to the child |
45
45
  | Outbound call sites `↔` external host | The remote HTTP host / GitHub / Figma the call reaches | The outbound request payload + whatever the remote returns |
46
+ | OpenRouter catalog fetch `→` openrouter.ai | The OpenRouter `/models` API host (and any MITM on the path) | The `Authorization: Bearer <OPENROUTER_API_KEY>` request header + the untrusted `/models` JSON the host returns |
46
47
 
47
48
  The event payloads that traverse the bus (and therefore the WS transport and
48
49
  any persisted JSONL) are scrubbed at serialize time — see Component 4's
@@ -311,6 +312,68 @@ model).
311
312
 
312
313
  ---
313
314
 
315
+ ## Component 6 — OpenRouter catalog fetcher (scripts/lib/openrouter/catalog-fetcher.cjs)
316
+
317
+ > Added in Phase 33.6 (OR-01, CONTEXT D-06). This is the runtime's **first
318
+ > plugin-side outbound REST client** — the issue-reporter (Component 5) reaches
319
+ > the network only through the user's `gh` CLI, and the WS transport (Component
320
+ > 4) is a *server*, not an outbound client. The catalog fetcher is the first
321
+ > first-party code to open an outbound HTTP request to a third-party host
322
+ > directly, which is why it lands only after the 33.5 audited baseline and the
323
+ > `scan:outbound` gate (33.5-04) are in place.
324
+
325
+ `scripts/lib/openrouter/catalog-fetcher.cjs` performs a read-only GET to the
326
+ OpenRouter model catalog (`https://openrouter.ai/api/v1/models`) through an
327
+ **injectable `fetchImpl`** (default global `fetch`), maps the response into the
328
+ `.design/cache/openrouter-models.json` cache shape, and writes it atomically.
329
+ The live fetch is opt-in — gated on `OPENROUTER_API_KEY` being present at
330
+ runtime; absent it, the fetcher returns cached-if-any-else-null and tier
331
+ resolution falls back to the native provider.
332
+
333
+ - **Assets:** The **`OPENROUTER_API_KEY`** (a billable provider credential) and
334
+ the integrity of the cached catalog the tier-resolver later trusts.
335
+ - **Entry points:** The **`/models` JSON the OpenRouter host returns** (untrusted
336
+ remote input the fetcher must parse), and the `OPENROUTER_BASE_URL` env (an
337
+ operator-supplied endpoint override).
338
+ - **STRIDE threats:**
339
+ - **Spoofing:** A spoofed `/models` endpoint (DNS/MITM, or a hostile
340
+ `OPENROUTER_BASE_URL`) could feed a forged catalog.
341
+ - **Tampering:** A malformed/oversized `/models` body could try to corrupt the
342
+ cache the resolver reads, or smuggle unexpected fields downstream.
343
+ - **Information disclosure:** **The headline risk** — leaking the
344
+ `OPENROUTER_API_KEY` by persisting it to the cache, logging it, or sending it
345
+ to an unintended host.
346
+ - **Denial of service:** A hung or slow host could stall the fetch; a giant
347
+ catalog could pressure memory.
348
+ - **Elevation of privilege:** A forged catalog could steer tier resolution to
349
+ an attacker-chosen model id.
350
+ - **Current mitigations:** The key is read from **`OPENROUTER_API_KEY` env only**,
351
+ sent **solely** as an `Authorization: Bearer` request header, and is **never
352
+ persisted to the cache nor written to any log seam** — the cache shape carries
353
+ only `id`/`name`/`context_length`/`pricing`, and the mapper keeps **only** those
354
+ fields, dropping everything else (the `/models` body is **mapped, never
355
+ eval'd**). The cache write is **atomic** (per-pid temp + rename) into the
356
+ **gitignored** `.design/cache/`, so a partial/corrupt fetch can't leave a
357
+ half-written catalog and the cache never enters git history. The fetcher
358
+ **never throws** (D-08): no key / fetch failure / parse failure all degrade to
359
+ cached-if-any-else-null, bounding the DoS surface, and retries are **bounded**
360
+ (max 3 attempts) on a jittered-backoff curve with `rate-guard` awareness.
361
+ Egress is **allowlisted** via `scripts/lib/openrouter/**` in
362
+ `scripts/security/outbound-allowlist.json` — the only sanctioned outbound site
363
+ in that subtree — so the 33.5 `scan:outbound` gate proves no un-approved egress
364
+ crept in. The **injectable `fetchImpl`** keeps the default `npm test` suite
365
+ hermetic (D-07) — no live network — and there is **no new HTTP dependency**
366
+ (global `fetch` + `sdk/primitives` only — D-10), avoiding both a new supply-chain
367
+ surface and the gate's `axios`/`node-fetch`/`undici` package patterns.
368
+ - **Residual risks:** None this phase leaves open. The catalog is advisory data
369
+ consumed by the tier-resolver heuristic (33.6-02), which already clamps to
370
+ GDD's `opus`/`sonnet`/`haiku` vocabulary and supports user overrides, so a
371
+ forged catalog cannot escalate beyond model-id selection within that bounded
372
+ set; a future hardening could pin the OpenRouter TLS cert or sign the cache,
373
+ but neither is required for the current trust model.
374
+
375
+ ---
376
+
314
377
  ## Residual-risk → closing-plan map
315
378
 
316
379
  Every residual risk identified above is routed to the Phase 33.5 plan (or
@@ -0,0 +1,273 @@
1
+ # Native Platforms — Token-Bridge Spec
2
+
3
+ This reference is the **token→native-code bridge**: it specifies how the canonical
4
+ CSS-token form produced by the Phase-23 token engine
5
+ (`scripts/lib/design-tokens/`) maps onto the three native theme systems —
6
+ SwiftUI, Jetpack Compose, and Flutter — and pins down the **precision contract**
7
+ that defines what "token identity preserved" means for the deterministic
8
+ round-trip (`reference/native-platforms.md` is the authority that
9
+ `test/suite/native-token-bridge.test.cjs` asserts against).
10
+
11
+ It is the sibling of [`reference/platforms.md`](./platforms.md). Those two files
12
+ have distinct jobs and must not be confused:
13
+
14
+ | File | Job |
15
+ | --- | --- |
16
+ | `reference/platforms.md` (Phase 19) | Interaction **conventions** — navigation, safe areas, gestures, native typography, haptics. *Behavioral* knowledge the executors reference when laying out a screen. |
17
+ | `reference/native-platforms.md` (Phase 34.1, this file) | The token→theme **bridge** — how a design token (`#3B82F6`, `16px`, `Inter`) becomes a SwiftUI `Color` / Compose `Color(0x…)` / Flutter `Color(0x…)`, plus the precision contract for the round-trip. *Structural* knowledge the emitters implement. |
18
+
19
+ Per **D-02** the bridge **extends** the Phase-23 engine with three new emitters
20
+ (`scripts/lib/design-tokens/{swift,compose,flutter}.cjs`) rather than forking a
21
+ separate native IR. There is one canonical token form (below) and one set of
22
+ readers; the native emitters are additional *sinks* on the same facade. Per
23
+ **D-10** the round-trip operates at the **token level** — deterministic emit +
24
+ re-extract with documented precision — never full-view parsing and never a live
25
+ simulator, so the default `npm test` stays green on any machine.
26
+
27
+ ---
28
+
29
+ ## 1. Purpose
30
+
31
+ Phase 19 shipped platform *references* but zero generators; Phase 23 shipped a
32
+ multi-source token reader (`css-vars` / `js-const` / `tailwind` / `figma`) that
33
+ all normalise to a single flat `{ tokens: Record<string, string> }` map. Phase
34
+ 34.1 crosses from platform-knowledge into platform-execution: instead of each
35
+ native executor re-deriving "how does `#3B82F6` become a SwiftUI `Color`", the
36
+ mapping lives once here (the spec) and once in the emitters (the
37
+ implementation), and the executors consume it. This amortizes the Phase-23 token
38
+ investment across SwiftUI / Compose / Flutter.
39
+
40
+ The canonical CSS-token form is the **single input** to all three native
41
+ emitters. This spec maps that one input to three native theme systems and states
42
+ the precision each mapping preserves.
43
+
44
+ ---
45
+
46
+ ## 2. Canonical input shape
47
+
48
+ The emitters consume the exact map shape the Phase-23 readers return: a **flat
49
+ `{ tokens: Record<string, string> }` object** whose keys are the design-token
50
+ names with the leading `--` stripped (exactly as `css-vars.cjs` returns) and
51
+ whose values are the raw token values as strings.
52
+
53
+ ```js
54
+ { tokens: { "color-primary": "#3B82F6", "space-4": "16px", "font-family-body": "Inter, system-ui" } }
55
+ ```
56
+
57
+ Each emitter accepts either the full Phase-23 `TokenSet`
58
+ (`{ tokens, source?, format? }`) **or** a bare `{ tokens }` object — it reads
59
+ `tokenSet.tokens` and throws a `TypeError` only when no `.tokens` object is
60
+ present.
61
+
62
+ ### Prefix → category inference
63
+
64
+ Token **category** is inferred from the key prefix. The emitters use this table
65
+ to decide whether a value is a color, a dimension, or a string:
66
+
67
+ | Key prefix | Category | Native treatment |
68
+ | --- | --- | --- |
69
+ | `color-` | color | hex → native channel form (§3–§5, §6 COLOR) |
70
+ | `space-`, `spacing-` | dimension | px → pt / dp / logical px (§6 DIMENSION) |
71
+ | `radius-` | dimension | px → pt / dp / logical px |
72
+ | `size-` | dimension | px → pt / dp / logical px |
73
+ | `font-`, `text-` | typography | string pass-through (family / weight / named size) |
74
+ | `shadow-` | other | string pass-through (composite values are non-mappable) |
75
+ | *(anything else)* | other | value-sniffed: a `#…` value is treated as color, an `Npx` value as dimension, otherwise string pass-through |
76
+
77
+ A value is **always** re-sniffed regardless of prefix, so a `#…` value under a
78
+ non-color prefix is still emitted as a color and a `Npx` value under a non-space
79
+ prefix is still emitted as a dimension. The prefix is the hint; the value is the
80
+ authority. This keeps the bridge robust against arbitrary token-naming schemes.
81
+
82
+ ---
83
+
84
+ ## 3. SwiftUI mapping
85
+
86
+ Target: a Swift source string exposing an `enum` of static theme constants
87
+ (`enum GDDTheme { … }`) — colors as `Color`, dimensions as `CGFloat` points,
88
+ typography families as `String` (and an optional `Font` helper).
89
+
90
+ | Token | SwiftUI form |
91
+ | --- | --- |
92
+ | color `#RRGGBB` | `Color(red: R/255.0, green: G/255.0, blue: B/255.0, opacity: A/255.0)` from the 8-bit channels |
93
+ | dimension `Npx` | `CGFloat` point literal — integer `N` (pt) |
94
+ | font-family | `String` literal (`"Inter, system-ui"`) |
95
+
96
+ Illustrative (2-line) snippet:
97
+
98
+ ```swift
99
+ static let colorPrimary = Color(red: 59.0/255.0, green: 130.0/255.0, blue: 246.0/255.0, opacity: 255.0/255.0)
100
+ static let space4: CGFloat = 16
101
+ ```
102
+
103
+ SwiftUI uses normalized `0.0…1.0` channel fractions; to keep the round-trip
104
+ **exact** the emitter writes each channel as the 8-bit numerator over `255.0`
105
+ (e.g. `59.0/255.0`) rather than a pre-divided decimal — the re-extractor reads
106
+ the numerator back as the integer channel, avoiding float drift. The `Color` /
107
+ `Font` / `ViewModifier` consumption pattern (applying the constants to views) is
108
+ the executor's job; this emitter produces the *constants*.
109
+
110
+ ---
111
+
112
+ ## 4. Jetpack Compose mapping
113
+
114
+ Target: a Kotlin source string with `Color` vals, a `Shapes` block (from
115
+ `radius-` tokens), a `Typography` block (from `font-`/`text-` tokens), and a
116
+ `MaterialTheme` wiring (`object GDDTheme { val Colors… ; val Shapes… ; val Typography… }`).
117
+
118
+ | Token | Compose form |
119
+ | --- | --- |
120
+ | color `#RRGGBB` | `Color(0xAARRGGBB)` long literal (alpha-first, 8 hex digits) |
121
+ | dimension `Npx` | `N.dp` (integer dp) |
122
+ | radius `Npx` | `RoundedCornerShape(N.dp)` inside `Shapes` |
123
+ | typography family | `String` (fed into a `TextStyle.fontFamily` slot / `Typography`) |
124
+
125
+ Illustrative (2-line) snippet:
126
+
127
+ ```kotlin
128
+ val colorPrimary = Color(0xFF3B82F6)
129
+ val space4 = 16.dp
130
+ ```
131
+
132
+ Compose packs ARGB into a single `0xAARRGGBB` long; alpha is the high byte. The
133
+ re-extractor reads the 8 hex digits straight back to the channels.
134
+
135
+ ---
136
+
137
+ ## 5. Flutter mapping
138
+
139
+ Target: a Dart source string building a `ThemeData` whose `colorScheme`
140
+ (`ColorScheme`) carries the color tokens and whose `textTheme` (`TextTheme`)
141
+ carries the typography tokens, plus a constants class
142
+ (`class GDDTheme { … }`).
143
+
144
+ | Token | Flutter form |
145
+ | --- | --- |
146
+ | color `#RRGGBB` | `Color(0xAARRGGBB)` (alpha-first, 8 hex digits) |
147
+ | dimension `Npx` | logical-px **double** — `N.0` |
148
+ | typography family | `String` (`fontFamily: 'Inter'`) |
149
+
150
+ Illustrative (2-line) snippet:
151
+
152
+ ```dart
153
+ static const colorPrimary = Color(0xFF3B82F6);
154
+ static const space4 = 16.0;
155
+ ```
156
+
157
+ Flutter measures in logical pixels and keeps the value as a `double` (`16.0`),
158
+ so — unlike pt/dp — Flutter dimensions are **not** rounded to integers; the
159
+ fractional part survives.
160
+
161
+ ---
162
+
163
+ ## 6. PRECISION CONTRACT
164
+
165
+ This section is the crux. It defines, per value category, exactly what
166
+ information the emit → re-extract round-trip preserves. The test asserts token
167
+ identity **within this precision** — not bit-exact floats, not lossy
168
+ approximation. An emitter is correct **iff** `reextract(emit({tokens}))`
169
+ reproduces every token in the identity set under these rules.
170
+
171
+ ### COLOR — 8-bit-per-channel, EXACT
172
+
173
+ - Accepted input forms: `#RGB`, `#RRGGBB`, `#RGBA`, `#RRGGBBAA` (case-insensitive).
174
+ - `#RGB` / `#RGBA` shorthand **expands** to `#RRGGBB` / `#RRGGBBAA` by
175
+ duplicating each nibble (`#3af` → `#33aaff`). This expansion is part of the
176
+ contract: the re-extractor recovers the **expanded** `#RRGGBB(AA)` form, so
177
+ `#3af` round-trips to `#33aaff` (canonically equal, the documented identity).
178
+ - Each channel is an 8-bit integer (0–255) and is preserved **exactly** — no
179
+ channel may be off by one. SwiftUI stores channels as `N.0/255.0` numerators;
180
+ Compose/Flutter store them in a `0xAARRGGBB` literal. Both forms recover the
181
+ identical 8-bit channels.
182
+ - **Alpha:** when the input has no alpha (`#RGB`/`#RRGGBB`) the emitted color is
183
+ **opaque** — alpha byte `0xFF` (Compose/Flutter) / `opacity: 255.0/255.0`
184
+ (SwiftUI). The re-extractor emits an alpha channel **only when the original
185
+ had one**: a 6-digit input round-trips to a 6-digit `#RRGGBB` (the implied
186
+ opaque alpha is dropped on the way back); an 8-digit input round-trips to the
187
+ 8-digit `#RRGGBBAA`. This keeps `#3B82F6 → #3B82F6` an exact identity.
188
+
189
+ ### DIMENSION — integer pt/dp, logical-px double
190
+
191
+ - Accepted input: `Npx` or a bare unit-less number (`16px`, `16`). The unit is
192
+ normalised to `px` on the canonical side.
193
+ - **iOS (pt) / Android (dp):** rounded to the nearest integer, **round-half-up**
194
+ (`15.5px` → `16`). Because rounding is lossy for non-integers, the round-trip
195
+ **identity set** is restricted to integer-px dimensions (e.g. `16px`), which
196
+ recover exactly: `16px → 16 (pt/dp) → 16px`.
197
+ - **Flutter (logical px):** kept as a `double` (`16px` → `16.0`), so Flutter
198
+ does **not** round and a fractional dimension survives. The re-extractor
199
+ recovers `Npx` by stripping the trailing `.0` for whole numbers.
200
+ - The re-extractor always recovers the canonical `Npx` string form, so the
201
+ emit→re-extract identity for an integer dimension is `"16px" === "16px"`.
202
+
203
+ ### `rem` / `em` — passed through verbatim (non-mappable)
204
+
205
+ `rem`/`em` values depend on a root/element font-size that the token map does not
206
+ carry, so they are **not** converted. They are treated as **non-mappable**
207
+ (below): emitted verbatim into a raw slot and **excluded** from the round-trip
208
+ identity set. (A future plan may add an explicit base-size option; until then,
209
+ verbatim pass-through is the stated, deterministic behavior.)
210
+
211
+ ### TYPOGRAPHY / NAMED VALUES — string pass-through
212
+
213
+ `font-family`, `font-weight`, named sizes, and any other string token are
214
+ emitted **verbatim** as a string literal and recovered **string-equal**
215
+ (`"Inter, system-ui" → "Inter, system-ui"`). No normalisation, no quoting
216
+ changes that alter the recovered string.
217
+
218
+ ### NON-MAPPABLE — verbatim, EXCLUDED from the identity set
219
+
220
+ Values the emitter cannot represent as a native primitive — CSS `var(--x)`
221
+ references, `calc(…)` expressions, gradients (`linear-gradient(…)`), and `rem`/
222
+ `em` dimensions — are **passed through verbatim** into a raw-string slot
223
+ (a trailing comment such as `// non-mappable: <name> = <value>` or the language
224
+ equivalent) so no information is lost, and are **explicitly excluded** from the
225
+ round-trip identity assertion. The contract documents them as
226
+ "verbatim / not round-tripped": the test asserts the verbatim value appears in
227
+ the emitted source, and does **not** assert it survives re-extraction as a typed
228
+ token.
229
+
230
+ ---
231
+
232
+ ## 7. The round-trip (what the test locks)
233
+
234
+ For each emitter the bridge guarantees:
235
+
236
+ 1. **Determinism.** `emit(x) === emit(x)` byte-for-byte. Keys are iterated in a
237
+ stable sorted order; no `Date`, no `process.env`, no filesystem, no network in
238
+ the emit path (D-10).
239
+ 2. **Identity within precision.** For the identity set (color + integer
240
+ dimension + typography), `reextract(emit({tokens}))` deep-equals `{tokens}`
241
+ under the precision rules above (8-bit color channels exact with `#RGB`
242
+ expansion; integer pt/dp; logical-px double; family/weight string-equal).
243
+ 3. **Verbatim exclusion.** Non-mappable values appear verbatim in the source and
244
+ are not part of the identity assertion.
245
+
246
+ Each emitter module exports a symmetric re-extractor
247
+ (`reextractSwift` / `reextractCompose` / `reextractFlutter`) that parses the
248
+ emitted native source back into a `{ tokens }` map, so the round-trip is
249
+ deterministic and bijective on the identity set and reusable by the Phase-34.1
250
+ regression baseline.
251
+
252
+ ---
253
+
254
+ ## 8. Registration
255
+
256
+ This reference is registered in
257
+ [`reference/registry.json`](./registry.json) as the `native-platforms` entry
258
+ (type `heuristic`, phase `34.1`) so the registry round-trip test
259
+ (`test/suite/reference-registry.test.cjs`) stays green — every `reference/*.md`
260
+ must be registered and resolve (D-05, the 33.5-01 lesson).
261
+
262
+ ---
263
+
264
+ ## 9. Cross-references
265
+
266
+ - [`reference/platforms.md`](./platforms.md) — the interaction-conventions
267
+ sibling (navigation, safe areas, gestures, native typography). Executors read
268
+ **both**: this file for the token→theme bridge, that file for layout/behavior.
269
+ - `scripts/lib/design-tokens/` — the Phase-23 token engine this bridge extends
270
+ (`index.cjs` facade + `css-vars` / `js-const` / `tailwind` / `figma` readers +
271
+ the new `swift` / `compose` / `flutter` emitters).
272
+ - `test/fixtures/mapper-outputs/tokens.json` — the canonical token fixture the
273
+ round-trip test derives its map from.
@@ -0,0 +1,98 @@
1
+ # OpenRouter Tier-Mapping Heuristic
2
+
3
+ How `scripts/lib/tier-resolver-openrouter.cjs` maps GDD's tier vocabulary onto a
4
+ dynamic OpenRouter catalog model id. This document is the human-readable companion
5
+ to that adapter; the adapter's `resolve(tier, opts)` is the canonical, executable
6
+ source of the mapping. Phase 33.6, decision D-03 (heuristic + override), D-04
7
+ (tier vocabulary), D-08 (graceful-null → native fallback).
8
+
9
+ ## What it maps
10
+
11
+ The plugin speaks one tier vocabulary everywhere a model tier is named in
12
+ frontmatter or config: `opus`, `sonnet`, `haiku` — the same `VALID_TIERS` the
13
+ Phase-26 `tier-resolver.cjs` enforces. OpenRouter, by contrast, exposes a flat
14
+ catalog of provider-prefixed model ids (`anthropic/claude-opus-4-7`,
15
+ `meta-llama/llama-3.1-8b-instruct`, `qwen/qwen-2.5-72b-instruct`, …). The adapter
16
+ bridges the two by assigning each GDD tier to one internal capability bucket and
17
+ then picking the catalog id that best fits that bucket.
18
+
19
+ The ROADMAP's SC#4 names the buckets `high` / `medium` / `low`; those are the
20
+ heuristic's INTERNAL labels. They map one-to-one to the public tiers (D-04):
21
+
22
+ - `opus` ← HIGH bucket
23
+ - `sonnet` ← MEDIUM bucket
24
+ - `haiku` ← LOW bucket
25
+
26
+ The adapter's public `resolve(tier)` always speaks `opus` / `sonnet` / `haiku`;
27
+ `high` / `medium` / `low` never leak across the API boundary.
28
+
29
+ ## The buckets
30
+
31
+ - **opus (HIGH) = top-tier closed.** The most capable closed-vendor model in the
32
+ catalog — the priciest premium id from a closed namespace. This is the
33
+ "spare-no-expense, hardest reasoning" slot.
34
+ - **sonnet (MEDIUM) = mid / top-open.** A capable model that sits below the opus
35
+ pick — typically the mid-priced closed model, or the strongest open model when
36
+ no second closed tier is present. The everyday workhorse slot.
37
+ - **haiku (LOW) = cheap open.** The cheapest capable OPEN model — the
38
+ fast/inexpensive slot for high-volume, low-stakes calls.
39
+
40
+ ## The signals
41
+
42
+ The heuristic is computed from fields already present on each catalog model, so it
43
+ stays deterministic for a fixed catalog (no clock, no randomness — important so the
44
+ 33.6-04 golden baseline is stable):
45
+
46
+ - **Namespace (closed vs open).** The id prefix before the `/` names the vendor.
47
+ `anthropic`, `openai`, `google` are treated as CLOSED (premium, frontier).
48
+ `meta-llama`, `qwen`, `mistralai`, `deepseek` are treated as OPEN (commodity,
49
+ cheap). The closed/open split is the primary axis: opus and sonnet prefer closed,
50
+ haiku requires open.
51
+ - **Pricing.** Each model carries `pricing.prompt` / `pricing.completion` as string
52
+ decimals (USD per token). Parsed to Number, the completion price is the tie-break:
53
+ highest completion price wins the opus slot; lowest completion price wins the
54
+ haiku slot. Models with unparseable or missing pricing sort last.
55
+ - **Context length.** `context_length` is a secondary capability signal used only to
56
+ break a pricing tie (longer context is treated as more capable).
57
+
58
+ For the canonical fixture catalog (closed `anthropic/claude-opus-4-7` +
59
+ `anthropic/claude-sonnet-4-7`, open `meta-llama/llama-3.1-70b-instruct`,
60
+ `meta-llama/llama-3.1-8b-instruct`, `qwen/qwen-2.5-72b-instruct`) the heuristic
61
+ resolves opus → `anthropic/claude-opus-4-7` (top closed, highest completion price),
62
+ sonnet → `anthropic/claude-sonnet-4-7` (mid closed), and haiku →
63
+ `meta-llama/llama-3.1-8b-instruct` (cheapest open).
64
+
65
+ ## The override escape hatch
66
+
67
+ The heuristic is a sensible default, not a straitjacket. A user can pin any tier to
68
+ an exact catalog id via `.design/config.json`:
69
+
70
+ ```
71
+ {
72
+ "openrouter_tier_overrides": {
73
+ "opus": "anthropic/claude-opus-4-7",
74
+ "haiku": "meta-llama/llama-3.1-8b-instruct"
75
+ }
76
+ }
77
+ ```
78
+
79
+ An override **wins** over the heuristic: when `openrouter_tier_overrides[tier]` is a
80
+ non-empty string, the adapter returns it verbatim — even if that id is not present
81
+ in the live catalog (the user's explicit choice is honored over catalog membership).
82
+ Tests inject the same map via `opts.overrides` instead of reading the live config
83
+ file, so the override path is exercised hermetically. The config read is best-effort:
84
+ a missing file, a missing key, or corrupt JSON degrades to an empty override map
85
+ rather than throwing.
86
+
87
+ ## The graceful-null contract
88
+
89
+ OpenRouter is opt-in ALONGSIDE native provider auth — never OpenRouter-only (D-08).
90
+ When no catalog is available (no cache, an empty `models[]`, or a `readCatalog` that
91
+ returns null) AND no override applies to the requested tier, `resolve` returns
92
+ `null`. A `null` is not an error: it is the signal that the caller (the router /
93
+ budget-enforcer, wired in 33.6-03) should fall back to the native provider via the
94
+ existing `scripts/lib/tier-resolver.cjs` fallback chain. The adapter NEVER throws —
95
+ an unknown tier, a missing config, a corrupt cache, or garbage options all degrade to
96
+ `null` (or to an override when one applies). This keeps OpenRouter a strictly
97
+ additive capability: turning it off, or having it fail to fetch, can never break a
98
+ resolution that would have succeeded natively.
@@ -0,0 +1,26 @@
1
+ # OpenRouter — Catalog-Derived Price Snapshot
2
+
3
+ **Phase 33.6 (v1.33.6).** This file is a **catalog-derived snapshot** of OpenRouter per-model prices — it is **generated from** `.design/cache/openrouter-models.json` (the dynamic catalog fetched by `scripts/lib/openrouter/catalog-fetcher.cjs`), **not** a hand-maintained authority. The **live source of truth is the dynamic catalog**; this table is a derived, illustrative view that can go stale between catalog fetches.
4
+
5
+ Unlike the per-runtime tables under `reference/prices/` (Phase 26 D-08, hand-curated authority with provenance), OpenRouter's prices live in the upstream `/models` response and are refreshed on the 24h TTL. To inspect the current resolved prices, run `/gdd:openrouter-status` or read the cache directly. For the tier→model resolution heuristic see `reference/openrouter-tier-mapping.md`.
6
+
7
+ OpenRouter quotes prices **per token** (USD), for `prompt` (input) and `completion` (output) separately.
8
+
9
+ ## Representative sample (per token, USD)
10
+
11
+ Derived from the fixture catalog at `test/fixtures/baselines/phase-33-6/openrouter-catalog.json` (a snapshot mirror of the cache shape). Actual live prices come from the catalog at fetch time.
12
+
13
+ | model id | prompt $/tok | completion $/tok |
14
+ |----------|--------------|------------------|
15
+ | `anthropic/claude-opus-4-7` | 0.000015 | 0.000075 |
16
+ | `anthropic/claude-sonnet-4-7` | 0.000003 | 0.000015 |
17
+ | `meta-llama/llama-3.1-70b-instruct` | 0.00000052 | 0.00000075 |
18
+ | `meta-llama/llama-3.1-8b-instruct` | 0.00000002 | 0.00000005 |
19
+ | `qwen/qwen-2.5-72b-instruct` | 0.00000038 | 0.0000004 |
20
+
21
+ ## Notes
22
+
23
+ - **Derived view, not authority.** Do not hand-edit prices here to "fix" cost math — fix the catalog fetch instead. This file documents the *shape* and *source* of OpenRouter pricing for the registry round-trip and for human reference.
24
+ - **Per-token vs per-1M.** The native runtime tables (`reference/prices/<runtime>.md`) quote `input_per_1m` / `output_per_1m`; OpenRouter's catalog quotes per-token. Multiply by 1,000,000 to compare (e.g. `anthropic/claude-opus-4-7` ≈ $15 input / $75 output per 1M tokens).
25
+ - **Cost telemetry.** When a model is resolved via the OpenRouter adapter, the cost row tags `provider: openrouter` (Phase 33.6-03, SC#6) — see `scripts/lib/budget-enforcer.cjs#buildCostEventPayload`.
26
+ - **Drift.** The authority-watcher diffs the catalog weekly and surfaces `deprecated`/`withdrawn` models matching a configured `openrouter_tier_overrides` pin (SC#8) — see `scripts/lib/authority-watcher/index.cjs#diffOpenRouterCatalog`.
@@ -874,6 +874,27 @@
874
874
  "type": "heuristic",
875
875
  "phase": 33.5,
876
876
  "description": "Phase 33.5 static security audit of GDD's shipped runtime surface (hooks/scripts/sdk/bin) — outbound-network call sites, secret-handling sites, and external-input surfaces; human-readable companion to scripts/security/outbound-allowlist.json (the canonical active-egress allowlist the 33.5-04 scan-outbound-network.cjs gate consumes) and reference/gdd-threat-model.md."
877
+ },
878
+ {
879
+ "name": "openrouter-tier-mapping",
880
+ "path": "reference/openrouter-tier-mapping.md",
881
+ "type": "heuristic",
882
+ "phase": 33.6,
883
+ "description": "Phase 33.6 OpenRouter tier-mapping heuristic — maps GDD opus/sonnet/haiku onto OpenRouter catalog ids via closed-vs-open + pricing buckets (high/medium/low), with the .design/config.json#openrouter_tier_overrides escape hatch (override wins) and graceful-null → native fallback."
884
+ },
885
+ {
886
+ "name": "prices-openrouter",
887
+ "path": "reference/prices.openrouter.md",
888
+ "type": "data",
889
+ "phase": 33.6,
890
+ "description": "Phase 33.6 catalog-derived OpenRouter price sub-table — per-model prompt/completion $/tok snapshot of .design/cache/openrouter-models.json; derived view, the dynamic catalog is the source of truth (D-11 registry round-trip)."
891
+ },
892
+ {
893
+ "name": "native-platforms",
894
+ "path": "reference/native-platforms.md",
895
+ "type": "heuristic",
896
+ "phase": 34.1,
897
+ "description": "Phase 34.1 token-bridge spec — maps the canonical CSS-token form (Phase 23) to SwiftUI Color/Font/ViewModifier, Jetpack Compose Color/Shapes/Typography/MaterialTheme, and Flutter ThemeData/ColorScheme/TextTheme, with the precision contract (color hex→8-bit channels exact, dimension px→pt/dp/logical px) defining token-identity for the round-trip."
877
898
  }
878
899
  ]
879
900
  }