@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
@@ -0,0 +1,343 @@
1
+ // scripts/lib/tier-resolver-openrouter.cjs
2
+ //
3
+ // Plan 33.6-02 — OpenRouter tier-resolver adapter.
4
+ //
5
+ // `resolve(tier, opts?) → openrouter-model-id | null`
6
+ //
7
+ // Maps GDD's tier vocabulary (`opus` / `sonnet` / `haiku` — the same
8
+ // VALID_TIERS the Phase-26 tier-resolver.cjs enforces, D-04) onto a concrete
9
+ // model id from OpenRouter's DYNAMIC aggregator catalog. Two inputs decide the
10
+ // answer, in this precedence (FIRST hit wins):
11
+ //
12
+ // 1. An explicit user override (`.design/config.json#openrouter_tier_overrides`,
13
+ // or `opts.overrides` injected for tests). A non-empty override string for
14
+ // the tier is returned VERBATIM and wins over the heuristic — even when that
15
+ // id is not present in the catalog (the user's explicit choice). (D-03)
16
+ // 2. Otherwise the deterministic heuristic over the catalog `models[]`:
17
+ // opus = top-tier CLOSED model (priciest closed-vendor id),
18
+ // sonnet = mid / top-OPEN (the closed model below opus, else the strongest
19
+ // open model — always distinct from the opus pick),
20
+ // haiku = cheap OPEN model (the cheapest open-vendor id).
21
+ // Deterministic for a fixed catalog (stable sort; no Date, no randomness) so
22
+ // the 33.6-04 golden baseline is stable.
23
+ // 3. Otherwise (no catalog / empty models / no candidate, and no override) →
24
+ // null, so the CALLER falls back to the native provider via the existing
25
+ // scripts/lib/tier-resolver.cjs fallback chain. (D-08)
26
+ //
27
+ // The catalog comes from `opts.catalog` (alias `opts.models`) when injected
28
+ // (tests — hermetic, D-07), otherwise from the 33.6-01 cache via
29
+ // `scripts/lib/openrouter/catalog-fetcher.cjs#readCatalog`, required defensively
30
+ // so a missing sibling module degrades to null rather than crashing import.
31
+ //
32
+ // NEVER throws (D-08): an unknown tier, a missing/corrupt config, a corrupt
33
+ // cache, or garbage opts all degrade to null (or to an override when one
34
+ // applies). Zero npm dependencies — node builtins only (D-10). `.cjs` to match
35
+ // the Phase-26 sibling and stay require-able from .ts hooks under
36
+ // --experimental-strip-types.
37
+ //
38
+ // PATTERN: mirrors scripts/lib/tier-resolver.cjs discipline (VALID_TIERS,
39
+ // opts.models injection, never-throws, null-is-valid). This adapter has one
40
+ // upstream (OpenRouter) and no runtime argument, so its signature is
41
+ // `resolve(tier, opts)` rather than `resolve(runtime, tier, opts)`.
42
+ //
43
+ // SCOPE (D-12): OpenRouter is represented ONLY in this tier-resolution layer —
44
+ // NOT in the install registry (scripts/lib/install/runtimes.cjs) and NOT as a
45
+ // reference/runtime-models.md row. This adapter is the catalog's canonical
46
+ // consumer; the router/budget-enforcer consultation + cost-tag wiring lives in
47
+ // plan 33.6-03.
48
+
49
+ 'use strict';
50
+
51
+ const fs = require('node:fs');
52
+ const path = require('node:path');
53
+
54
+ /**
55
+ * GDD's public tier vocabulary — the same set tier-resolver.cjs enforces
56
+ * (D-04). `resolve` returns null for anything outside this set (no throw).
57
+ */
58
+ const VALID_TIERS = Object.freeze(['opus', 'sonnet', 'haiku']);
59
+
60
+ /**
61
+ * Vendor-namespace classification. The id prefix before the first `/` names
62
+ * the vendor; CLOSED = frontier/premium, OPEN = commodity/cheap. The
63
+ * closed-vs-open split is the heuristic's primary axis (D-03).
64
+ */
65
+ const CLOSED_VENDORS = Object.freeze(['anthropic', 'openai', 'google']);
66
+ const OPEN_VENDORS = Object.freeze(['meta-llama', 'qwen', 'mistralai', 'deepseek']);
67
+
68
+ /**
69
+ * The internal capability buckets the heuristic computes, and their one-to-one
70
+ * map onto the public tiers (D-04). Exported for tests + documentation parity
71
+ * with reference/openrouter-tier-mapping.md.
72
+ */
73
+ const TIER_BUCKETS = Object.freeze({
74
+ opus: 'high', // top-tier closed
75
+ sonnet: 'medium', // mid / top-open
76
+ haiku: 'low', // cheap open
77
+ });
78
+
79
+ const DEFAULT_CONFIG_PATH = path.join('.design', 'config.json');
80
+
81
+ /**
82
+ * Best-effort read of `.design/config.json#openrouter_tier_overrides`. Returns
83
+ * a plain object (possibly empty); a missing file, missing key, or corrupt JSON
84
+ * degrades to `{}`. NEVER throws. `opts.configPath` overrides the location for
85
+ * tests; otherwise the path is resolved relative to `cwd` (default
86
+ * `process.cwd()`).
87
+ *
88
+ * @param {object} [opts]
89
+ * @param {string} [opts.configPath]
90
+ * @param {string} [opts.cwd]
91
+ * @returns {{ opus?: string, sonnet?: string, haiku?: string }}
92
+ */
93
+ function readOpenrouterOverrides(opts) {
94
+ try {
95
+ const o = opts || {};
96
+ const configPath =
97
+ typeof o.configPath === 'string' && o.configPath.length > 0
98
+ ? o.configPath
99
+ : path.join(o.cwd || process.cwd(), DEFAULT_CONFIG_PATH);
100
+ if (!fs.existsSync(configPath)) return {};
101
+ const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
102
+ if (!parsed || typeof parsed !== 'object') return {};
103
+ const ov = parsed.openrouter_tier_overrides;
104
+ if (!ov || typeof ov !== 'object') return {};
105
+ return ov;
106
+ } catch {
107
+ // Missing/corrupt config must never break resolution — degrade to no
108
+ // overrides so the heuristic (or null) takes over.
109
+ return {};
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Defensive lazy read of the 33.6-01 catalog cache. Requires the sibling
115
+ * fetcher in a try/catch so a missing module (Wave-A ordering) degrades to
116
+ * null rather than crashing this module's import. Returns `Array<model>|null`.
117
+ *
118
+ * @param {string} [cachePath]
119
+ * @returns {Array<object>|null}
120
+ */
121
+ function readCatalogDefensive(cachePath) {
122
+ try {
123
+ const fetcher = require('./openrouter/catalog-fetcher.cjs');
124
+ if (!fetcher || typeof fetcher.readCatalog !== 'function') return null;
125
+ const models = fetcher.readCatalog(
126
+ typeof cachePath === 'string' && cachePath.length > 0 ? { cachePath } : undefined,
127
+ );
128
+ return Array.isArray(models) ? models : null;
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * The vendor namespace of a model id (the segment before the first `/`),
136
+ * lower-cased. Returns '' for a malformed id.
137
+ */
138
+ function vendorOf(id) {
139
+ if (typeof id !== 'string') return '';
140
+ const slash = id.indexOf('/');
141
+ if (slash <= 0) return '';
142
+ return id.slice(0, slash).toLowerCase();
143
+ }
144
+
145
+ function isClosed(id) {
146
+ return CLOSED_VENDORS.indexOf(vendorOf(id)) >= 0;
147
+ }
148
+
149
+ function isOpen(id) {
150
+ return OPEN_VENDORS.indexOf(vendorOf(id)) >= 0;
151
+ }
152
+
153
+ /**
154
+ * Parse a pricing string ("0.000075") to a finite Number, or null when absent
155
+ * / unparseable. The completion price is the heuristic's ranking key.
156
+ */
157
+ function completionPrice(model) {
158
+ if (!model || typeof model !== 'object' || !model.pricing || typeof model.pricing !== 'object') {
159
+ return null;
160
+ }
161
+ const raw = model.pricing.completion;
162
+ const n = typeof raw === 'number' ? raw : Number.parseFloat(raw);
163
+ return Number.isFinite(n) ? n : null;
164
+ }
165
+
166
+ function contextLengthOf(model) {
167
+ const n = model && typeof model.context_length === 'number' ? model.context_length : 0;
168
+ return Number.isFinite(n) ? n : 0;
169
+ }
170
+
171
+ /**
172
+ * Keep only well-formed catalog rows: objects with a non-empty string `id`.
173
+ * Drops `null`, numbers, and shapeless entries so the ranking never touches a
174
+ * bad row.
175
+ */
176
+ function sanitize(models) {
177
+ if (!Array.isArray(models)) return [];
178
+ return models.filter(
179
+ m => m && typeof m === 'object' && typeof m.id === 'string' && m.id.length > 0,
180
+ );
181
+ }
182
+
183
+ /**
184
+ * Stable comparator factory. `dir` = -1 sorts completion price DESCENDING
185
+ * (priciest first, for opus); `dir` = +1 sorts ASCENDING (cheapest first, for
186
+ * haiku). Models with no parseable price sort LAST regardless of direction.
187
+ * Ties break by context_length (more capable first for desc, less for asc),
188
+ * then by id ascending so the order is fully deterministic for a fixed catalog.
189
+ */
190
+ function byCompletionPrice(dir) {
191
+ return (a, b) => {
192
+ const pa = completionPrice(a);
193
+ const pb = completionPrice(b);
194
+ const aMissing = pa === null;
195
+ const bMissing = pb === null;
196
+ if (aMissing && bMissing) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
197
+ if (aMissing) return 1; // a sorts last
198
+ if (bMissing) return -1; // b sorts last
199
+ if (pa !== pb) return dir < 0 ? pb - pa : pa - pb;
200
+ const ca = contextLengthOf(a);
201
+ const cb = contextLengthOf(b);
202
+ if (ca !== cb) return dir < 0 ? cb - ca : ca - cb;
203
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Compute the heuristic pick for `tier` over a sanitized catalog. Returns a
209
+ * model id or null when no suitable candidate exists.
210
+ *
211
+ * opus → priciest CLOSED model (fallback: priciest model overall).
212
+ * haiku → cheapest OPEN model (fallback: cheapest model overall).
213
+ * sonnet → the next CLOSED model below the opus pick, else the strongest
214
+ * (priciest) OPEN model; always distinct from the opus pick when the
215
+ * catalog has >1 usable model.
216
+ */
217
+ function heuristicPick(tier, clean) {
218
+ if (clean.length === 0) return null;
219
+
220
+ const closed = clean.filter(m => isClosed(m.id));
221
+ const open = clean.filter(m => isOpen(m.id));
222
+
223
+ if (tier === 'opus') {
224
+ const pool = closed.length > 0 ? closed : clean;
225
+ const ranked = pool.slice().sort(byCompletionPrice(-1));
226
+ return ranked.length > 0 ? ranked[0].id : null;
227
+ }
228
+
229
+ if (tier === 'haiku') {
230
+ const pool = open.length > 0 ? open : clean;
231
+ const ranked = pool.slice().sort(byCompletionPrice(1));
232
+ return ranked.length > 0 ? ranked[0].id : null;
233
+ }
234
+
235
+ // sonnet = MEDIUM (mid / top-open). Prefer the closed model directly below
236
+ // the opus pick; otherwise the strongest open model. Never collapse onto the
237
+ // opus pick when an alternative exists.
238
+ const opusPick = heuristicPick('opus', clean);
239
+
240
+ const closedDesc = closed.slice().sort(byCompletionPrice(-1));
241
+ // The first closed model that is NOT the opus pick (i.e. the second-priciest
242
+ // closed, the natural "mid closed" slot).
243
+ const midClosed = closedDesc.find(m => m.id !== opusPick);
244
+ if (midClosed) return midClosed.id;
245
+
246
+ // No second closed model — take the strongest (priciest) OPEN model.
247
+ const openDesc = open.slice().sort(byCompletionPrice(-1));
248
+ const topOpen = openDesc.find(m => m.id !== opusPick);
249
+ if (topOpen) return topOpen.id;
250
+
251
+ // Degenerate single-model catalog: fall back to any non-opus candidate, else
252
+ // the opus pick itself (better a valid id than null for a tier the caller
253
+ // asked for).
254
+ const anyOther = clean.find(m => m.id !== opusPick);
255
+ if (anyOther) return anyOther.id;
256
+ return opusPick;
257
+ }
258
+
259
+ /**
260
+ * Resolve a GDD tier to an OpenRouter catalog model id, or null.
261
+ *
262
+ * @param {string | null | undefined} tier
263
+ * One of `opus` / `sonnet` / `haiku` (D-04). Anything else → null (no throw).
264
+ * @param {object} [opts]
265
+ * @param {Array<object>} [opts.catalog]
266
+ * Injected catalog `models[]` (tests, hermetic — D-07). Takes precedence over
267
+ * the on-disk cache. `opts.models` is accepted as an interop alias.
268
+ * @param {Array<object>} [opts.models]
269
+ * Interop alias for `opts.catalog` (mirrors tier-resolver.cjs naming).
270
+ * @param {{opus?:string,sonnet?:string,haiku?:string}} [opts.overrides]
271
+ * Injected override map (tests). When absent, read from
272
+ * `.design/config.json#openrouter_tier_overrides` (best-effort; missing/
273
+ * corrupt → {}).
274
+ * @param {string} [opts.cachePath]
275
+ * Passed through to readCatalog when no catalog is injected (tests).
276
+ * @param {string} [opts.configPath]
277
+ * Override the .design/config.json location (tests).
278
+ * @param {string} [opts.cwd]
279
+ * Base dir for the default config path (tests).
280
+ * @returns {string | null} an OpenRouter model id, or null (caller falls back
281
+ * to the native provider — D-08).
282
+ */
283
+ function resolve(tier, opts) {
284
+ try {
285
+ // Validate the tier FIRST — an unknown tier is null regardless of overrides
286
+ // or catalog (the override map is keyed by the valid tiers only).
287
+ if (typeof tier !== 'string' || VALID_TIERS.indexOf(tier) < 0) return null;
288
+
289
+ const o = opts && typeof opts === 'object' ? opts : {};
290
+
291
+ // 1. Override wins (D-03). Read injected map, else best-effort config.
292
+ const overrides =
293
+ o.overrides && typeof o.overrides === 'object'
294
+ ? o.overrides
295
+ : readOpenrouterOverrides({ configPath: o.configPath, cwd: o.cwd });
296
+ const override = overrides ? overrides[tier] : undefined;
297
+ if (typeof override === 'string' && override.length > 0) {
298
+ return override; // verbatim — wins over the heuristic, catalog-membership irrelevant
299
+ }
300
+
301
+ // 2. Heuristic over the catalog. Injected catalog/models take precedence;
302
+ // otherwise read the cache defensively. An explicit `catalog: null`
303
+ // (or `models: null`) is honored as "no catalog" and does NOT fall
304
+ // through to the on-disk read — keeps injected tests hermetic.
305
+ let models;
306
+ if ('catalog' in o) {
307
+ models = o.catalog;
308
+ } else if ('models' in o) {
309
+ models = o.models;
310
+ } else {
311
+ models = readCatalogDefensive(o.cachePath);
312
+ }
313
+
314
+ const clean = sanitize(models);
315
+ if (clean.length === 0) return null; // 3. no catalog + no override → null (D-08)
316
+
317
+ const pick = heuristicPick(tier, clean);
318
+ return typeof pick === 'string' && pick.length > 0 ? pick : null;
319
+ } catch {
320
+ // Absolute backstop — resolve NEVER throws (D-08).
321
+ return null;
322
+ }
323
+ }
324
+
325
+ module.exports = {
326
+ resolve,
327
+ readOpenrouterOverrides,
328
+ VALID_TIERS,
329
+ TIER_BUCKETS,
330
+ CLOSED_VENDORS,
331
+ OPEN_VENDORS,
332
+ // internals surfaced for tests only — stable API = `resolve` +
333
+ // `readOpenrouterOverrides`.
334
+ _internal: {
335
+ vendorOf,
336
+ isClosed,
337
+ isOpen,
338
+ completionPrice,
339
+ heuristicPick,
340
+ sanitize,
341
+ readCatalogDefensive,
342
+ },
343
+ };
@@ -146,10 +146,32 @@ export type ParallelismVerdictEvent = BaseEvent & {
146
146
  payload: { task_ids: string[]; verdict: 'parallel' | 'sequential'; reason: string };
147
147
  };
148
148
 
149
- /** Phase 10.1 cost-telemetry event-stream sink. */
149
+ /**
150
+ * Phase 10.1 cost-telemetry event-stream sink.
151
+ *
152
+ * Phase 33.6 / Plan 33.6-03 (SC#6) extension — additive/back-compat: the
153
+ * payload gains an OPTIONAL `provider?: string`, set to `'openrouter'` when the
154
+ * model for this cost row was resolved via the OpenRouter tier-resolver adapter
155
+ * (`scripts/lib/tier-resolver-openrouter.cjs`). Absent on every pre-33.6 event
156
+ * (and on native-resolution rows) — exactly the same additive discipline as the
157
+ * Phase-27 `runtime_role`/`peer_id` extension documented above. The cost-row
158
+ * emit site that threads it is
159
+ * `scripts/lib/budget-enforcer.cjs#buildCostEventPayload`.
160
+ */
150
161
  export type CostUpdateEvent = BaseEvent & {
151
162
  type: 'cost.update';
152
- payload: { agent: string; tier: string; usd: number; tokens_in: number; tokens_out: number };
163
+ payload: {
164
+ agent: string;
165
+ tier: string;
166
+ usd: number;
167
+ tokens_in: number;
168
+ tokens_out: number;
169
+ /**
170
+ * Phase 33.6 SC#6. Set to `'openrouter'` when the model was resolved via the
171
+ * OpenRouter adapter; omitted otherwise (native-resolution + pre-33.6 rows).
172
+ */
173
+ provider?: string;
174
+ };
153
175
  };
154
176
 
155
177
  /** Rate-guard / backoff stream (Plan 20-10, 20-11). */
@@ -0,0 +1,86 @@
1
+ ---
2
+ name: gdd-openrouter-status
3
+ description: "Read-only OpenRouter catalog + tier-mapping diagnostic — surfaces catalog freshness (fetched_at vs the 24h TTL), the last-fetch timestamp, the resolved opus/sonnet/haiku → model mappings (via the Phase-33.6 adapter), and a per-tier preview. Use when investigating which OpenRouter model a tier resolves to, or whether the catalog cache is fresh/stale. Phase 33.6 (v1.33.6) diagnostic — /gdd:openrouter-status."
4
+ argument-hint: "[--refresh]"
5
+ tools: Read, Bash
6
+ disable-model-invocation: true
7
+ ---
8
+
9
+ # gdd-openrouter-status
10
+
11
+ ## Role
12
+
13
+ You are a deterministic, read-only diagnostic skill. You do **not** spawn agents and you do **not** modify the catalog cache. You read `.design/cache/openrouter-models.json` (the Phase-33.6-01 catalog cache) via `scripts/lib/openrouter/catalog-fetcher.cjs#readCatalog`, resolve the `opus`/`sonnet`/`haiku` tiers via `scripts/lib/tier-resolver-openrouter.cjs#resolve`, and emit a single Markdown status block. Read-only — to refresh the catalog you pass `--refresh` (a single opt-in fetch gated on `OPENROUTER_API_KEY`); there is no other mutation. See `connections/openrouter.md` for setup and `reference/openrouter-tier-mapping.md` for the resolution heuristic.
14
+
15
+ This is `disable-model-invocation: true` (mirroring `skills/cache-manager/SKILL.md`): the skill is user-invoked only — the model must not auto-spawn it. It never makes a model call.
16
+
17
+ ## Invocation Contract
18
+
19
+ - **Input**: optional `--refresh`. When absent, the skill is purely read-only (cache + resolve). When `--refresh` is set AND `OPENROUTER_API_KEY` is present, it calls the Phase-33.6-01 fetcher once to refresh the cache before reading; when `--refresh` is set but no key is present, it prints the empty-state/no-key message and does NOT fetch.
20
+ - **Output**: a Markdown OpenRouter-status block to stdout. The block is the entire output.
21
+
22
+ ## Procedure
23
+
24
+ ### 1. (Optional) refresh
25
+
26
+ If `--refresh` is set and `OPENROUTER_API_KEY` is present, run a single fetch to refresh the cache:
27
+
28
+ ```bash
29
+ node -e "require('./scripts/lib/openrouter/catalog-fetcher.cjs').fetchCatalog().then(()=>{}).catch(()=>{})"
30
+ ```
31
+
32
+ This is the ONLY mutation the skill performs, and only on explicit `--refresh`. The fetch never throws (D-08); a failure degrades to the existing cache.
33
+
34
+ ### 2. Read the catalog cache
35
+
36
+ Read `.design/cache/openrouter-models.json` via `readCatalog`. Missing or empty → emit the empty-state message and stop:
37
+
38
+ ```
39
+ ## OpenRouter Status
40
+
41
+ No OpenRouter catalog yet — set OPENROUTER_API_KEY and run a cycle, or `/gdd:openrouter-status --refresh`.
42
+
43
+ Tier resolution is currently falling back to the native provider (graceful degrade — D-08).
44
+ ```
45
+
46
+ ### 3. Compute freshness
47
+
48
+ Read `fetched_at` from the cache object and compare against the 24h TTL (D-02): `age = now - fetched_at`. `age < 24h` → **fresh**; otherwise → **stale** (a stale catalog still resolves — the adapter uses the last good cache).
49
+
50
+ ### 4. Resolve the tiers
51
+
52
+ For each of `opus`, `sonnet`, `haiku`, resolve via the adapter (it reads `.design/config.json#openrouter_tier_overrides` and applies the heuristic over the cached catalog):
53
+
54
+ ```bash
55
+ node -e "const r=require('./scripts/lib/tier-resolver-openrouter.cjs');for(const t of ['opus','sonnet','haiku'])console.log(t, '->', r.resolve(t) || '(null → native fallback)')"
56
+ ```
57
+
58
+ A `null` for a tier means OpenRouter has no pick → the native provider resolves that tier (D-08). Note any tier that resolved from an explicit `openrouter_tier_overrides` pin vs the heuristic.
59
+
60
+ ### 5. Print the status block
61
+
62
+ ```
63
+ ## OpenRouter Status
64
+
65
+ Catalog source: <source URL from cache>
66
+ Last fetched: <fetched_at> (<fresh | stale> — TTL 24h)
67
+ Models in catalog: <count>
68
+
69
+ | Tier | Resolved model id | Source |
70
+ |--------|----------------------------------|--------------------|
71
+ | opus | <id or (null → native fallback)> | <override | heuristic> |
72
+ | sonnet | <id or (null → native fallback)> | <override | heuristic> |
73
+ | haiku | <id or (null → native fallback)> | <override | heuristic> |
74
+
75
+ > Resolution: override (`.design/config.json#openrouter_tier_overrides`) wins, else the closed-vs-open + pricing heuristic over the catalog.
76
+ > A null resolution means tier resolution falls back to the native provider (D-08).
77
+ > Read-only — this skill never modifies the cache; use `--refresh` to re-fetch (needs OPENROUTER_API_KEY).
78
+ ```
79
+
80
+ ## Completion marker
81
+
82
+ End the output with:
83
+
84
+ ```
85
+ ## OPENROUTER-STATUS COMPLETE
86
+ ```