@hegemonart/get-design-done 1.33.0 → 1.33.6

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 (47) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +49 -0
  4. package/README.md +4 -0
  5. package/SKILL.md +1 -0
  6. package/agents/design-authority-watcher.md +4 -0
  7. package/connections/connections.md +2 -0
  8. package/connections/openrouter.md +86 -0
  9. package/hooks/budget-enforcer.ts +103 -0
  10. package/package.json +5 -2
  11. package/reference/gdd-runtime-audit.md +111 -0
  12. package/reference/gdd-threat-model.md +399 -0
  13. package/reference/openrouter-tier-mapping.md +98 -0
  14. package/reference/prices.openrouter.md +26 -0
  15. package/reference/registry.json +28 -0
  16. package/scripts/lib/authority-watcher/index.cjs +147 -0
  17. package/scripts/lib/budget-enforcer.cjs +16 -0
  18. package/scripts/lib/openrouter/catalog-fetcher.cjs +326 -0
  19. package/scripts/lib/peer-cli/acp-client.cjs +9 -1
  20. package/scripts/lib/peer-cli/asp-client.cjs +10 -1
  21. package/scripts/lib/peer-cli/sanitize-env.cjs +198 -0
  22. package/scripts/lib/redact.cjs +20 -1
  23. package/scripts/lib/tier-resolver-openrouter.cjs +343 -0
  24. package/scripts/lib/transports/ws.cjs +67 -3
  25. package/sdk/event-stream/types.ts +24 -2
  26. package/sdk/mcp/gdd-state/schemas/add_blocker.schema.json +2 -0
  27. package/sdk/mcp/gdd-state/schemas/add_decision.schema.json +1 -0
  28. package/sdk/mcp/gdd-state/schemas/add_must_have.schema.json +1 -0
  29. package/sdk/mcp/gdd-state/schemas/checkpoint.schema.json +1 -0
  30. package/sdk/mcp/gdd-state/schemas/frontmatter_update.schema.json +1 -1
  31. package/sdk/mcp/gdd-state/schemas/get.schema.json +2 -1
  32. package/sdk/mcp/gdd-state/schemas/probe_connections.schema.json +2 -0
  33. package/sdk/mcp/gdd-state/schemas/resolve_blocker.schema.json +1 -0
  34. package/sdk/mcp/gdd-state/server.js +137 -48
  35. package/sdk/mcp/gdd-state/tools/add_blocker.ts +2 -0
  36. package/sdk/mcp/gdd-state/tools/add_decision.ts +2 -0
  37. package/sdk/mcp/gdd-state/tools/add_must_have.ts +2 -0
  38. package/sdk/mcp/gdd-state/tools/checkpoint.ts +2 -0
  39. package/sdk/mcp/gdd-state/tools/frontmatter_update.ts +2 -0
  40. package/sdk/mcp/gdd-state/tools/get.ts +2 -0
  41. package/sdk/mcp/gdd-state/tools/probe_connections.ts +2 -0
  42. package/sdk/mcp/gdd-state/tools/resolve_blocker.ts +2 -0
  43. package/sdk/mcp/gdd-state/tools/set_status.ts +2 -0
  44. package/sdk/mcp/gdd-state/tools/shared.ts +117 -7
  45. package/sdk/mcp/gdd-state/tools/transition_stage.ts +2 -0
  46. package/sdk/mcp/gdd-state/tools/update_progress.ts +2 -0
  47. package/skills/openrouter-status/SKILL.md +86 -0
@@ -170,6 +170,147 @@ function buildKfmCandidate(article, options) {
170
170
  };
171
171
  }
172
172
 
173
+ // -------------------------------------------------------------------
174
+ // Plan 33.6-03 (SC#8) — OpenRouter catalog drift
175
+ // -------------------------------------------------------------------
176
+
177
+ /**
178
+ * Index a catalog `models[]` by id, keeping only well-formed rows
179
+ * (objects with a non-empty string `id`). Last write wins on dup ids.
180
+ *
181
+ * @param {unknown} models
182
+ * @returns {Map<string, object>}
183
+ */
184
+ function indexById(models) {
185
+ const map = new Map();
186
+ if (!Array.isArray(models)) return map;
187
+ for (const m of models) {
188
+ if (m && typeof m === 'object' && typeof m.id === 'string' && m.id.length > 0) {
189
+ map.set(m.id, m);
190
+ }
191
+ }
192
+ return map;
193
+ }
194
+
195
+ /**
196
+ * Best-effort "is this model flagged deprecated?" signal. Tolerant of the
197
+ * several shapes an upstream catalog might use: a boolean `deprecated`,
198
+ * or a `status`/`lifecycle`/`availability` string containing "deprecat".
199
+ *
200
+ * @param {object} model
201
+ * @returns {boolean}
202
+ */
203
+ function isDeprecatedFlag(model) {
204
+ if (!model || typeof model !== 'object') return false;
205
+ if (model.deprecated === true) return true;
206
+ for (const key of ['status', 'lifecycle', 'availability', 'state']) {
207
+ const v = model[key];
208
+ if (typeof v === 'string' && /deprecat/i.test(v)) return true;
209
+ }
210
+ return false;
211
+ }
212
+
213
+ /**
214
+ * Stable string-compare of a single pricing field. A pricing field is the
215
+ * catalog's `pricing.prompt` / `pricing.completion` (strings like "0.000003").
216
+ * Compares as strings (the catalog stores them as strings) after a defensive
217
+ * String() coercion; missing → ''.
218
+ */
219
+ function priceStr(model, field) {
220
+ if (!model || typeof model !== 'object' || !model.pricing || typeof model.pricing !== 'object') {
221
+ return '';
222
+ }
223
+ const v = model.pricing[field];
224
+ return v === undefined || v === null ? '' : String(v);
225
+ }
226
+
227
+ function pricingChanged(prevModel, currModel) {
228
+ return (
229
+ priceStr(prevModel, 'prompt') !== priceStr(currModel, 'prompt') ||
230
+ priceStr(prevModel, 'completion') !== priceStr(currModel, 'completion')
231
+ );
232
+ }
233
+
234
+ /**
235
+ * Diff a prior vs current OpenRouter catalog (`models[]` arrays) and classify
236
+ * each delta. Plan 33.6-03 / SC#8.
237
+ *
238
+ * Classification per id:
239
+ * - `new-model` — id in curr, not in prev.
240
+ * - `withdrawn` — id in prev, not in curr.
241
+ * - `deprecated` — id in BOTH, and the curr row carries a deprecated/status
242
+ * flag (best-effort `isDeprecatedFlag`). Takes precedence
243
+ * over a coincident pricing change.
244
+ * - `pricing-change` — id in BOTH, not deprecated, with a changed
245
+ * pricing.prompt or pricing.completion.
246
+ * - (unchanged ids produce NO entry.)
247
+ *
248
+ * Surfacing (the actionable "a model you pinned is going away" signal):
249
+ * `surfaced === true` ONLY when `change ∈ {deprecated, withdrawn}` AND the id
250
+ * is in `options.overrides` (the configured `openrouter_tier_overrides`
251
+ * values). `new-model` / `pricing-change` are classified but `surfaced:false`
252
+ * (noise control per CONTEXT). A deprecated/withdrawn id NOT in overrides is
253
+ * also `surfaced:false`.
254
+ *
255
+ * Pure, zero deps, NEVER throws — garbage inputs degrade to `[]`.
256
+ *
257
+ * @param {Array<object>} prevModels prior catalog `models[]`
258
+ * @param {Array<object>} currModels current catalog `models[]`
259
+ * @param {object} [options]
260
+ * @param {Array<string>} [options.overrides] configured openrouter_tier_overrides id values
261
+ * @returns {Array<{id:string, change:('new-model'|'pricing-change'|'deprecated'|'withdrawn'), surfaced:boolean}>}
262
+ */
263
+ function diffOpenRouterCatalog(prevModels, currModels, options) {
264
+ try {
265
+ const prev = indexById(prevModels);
266
+ const curr = indexById(currModels);
267
+
268
+ const opts = options && typeof options === 'object' ? options : {};
269
+ const overrideList = Array.isArray(opts.overrides) ? opts.overrides : [];
270
+ const overrides = new Set(overrideList.filter(x => typeof x === 'string' && x.length > 0));
271
+
272
+ /** @type {Array<{id:string, change:string, surfaced:boolean}>} */
273
+ const out = [];
274
+
275
+ // Deterministic id order: union of all ids, sorted ascending. Keeps the
276
+ // output stable for a fixed input pair (no Date, no randomness).
277
+ const allIds = new Set([...prev.keys(), ...curr.keys()]);
278
+ const sortedIds = [...allIds].sort();
279
+
280
+ for (const id of sortedIds) {
281
+ const inPrev = prev.has(id);
282
+ const inCurr = curr.has(id);
283
+ let change = null;
284
+
285
+ if (inCurr && !inPrev) {
286
+ change = 'new-model';
287
+ } else if (inPrev && !inCurr) {
288
+ change = 'withdrawn';
289
+ } else {
290
+ // id in both.
291
+ const currModel = curr.get(id);
292
+ if (isDeprecatedFlag(currModel)) {
293
+ change = 'deprecated';
294
+ } else if (pricingChanged(prev.get(id), currModel)) {
295
+ change = 'pricing-change';
296
+ } else {
297
+ continue; // unchanged → no delta
298
+ }
299
+ }
300
+
301
+ const actionable = change === 'deprecated' || change === 'withdrawn';
302
+ const surfaced = actionable && overrides.has(id);
303
+ out.push({ id, change, surfaced });
304
+ }
305
+
306
+ return out;
307
+ } catch {
308
+ // Absolute backstop — diffOpenRouterCatalog NEVER throws (parity with the
309
+ // never-throws discipline of the 33.6 adapter/fetcher).
310
+ return [];
311
+ }
312
+ }
313
+
173
314
  /**
174
315
  * Classify a list of fetched articles into events. Emits one kfm-candidate
175
316
  * per whitelist-matched article. Other articles produce no events here
@@ -192,10 +333,16 @@ module.exports = {
192
333
  classifyArticles,
193
334
  matchesKfmWhitelist,
194
335
  buildKfmCandidate,
336
+ // Plan 33.6-03 (SC#8) — OpenRouter catalog weekly-diff classifier. Added as a
337
+ // sibling pure function; the existing API above is unchanged.
338
+ diffOpenRouterCatalog,
195
339
  // Exposed for tests / advanced consumers.
196
340
  KFM_WHITELIST_PATTERNS,
197
341
  MAX_RAW_EXCERPT,
198
342
  _deriveSymptom: deriveSymptom,
199
343
  _derivePatternHint: derivePatternHint,
200
344
  _truncateExcerpt: truncateExcerpt,
345
+ _indexById: indexById,
346
+ _isDeprecatedFlag: isDeprecatedFlag,
347
+ _pricingChanged: pricingChanged,
201
348
  };
@@ -407,6 +407,15 @@ function computeCost(args, opts) {
407
407
  * Phase 27. The peer-CLI ID when `runtime_role === "peer"` (e.g.
408
408
  * `"gemini"`, `"codex"`). Omitted from output when absent or when
409
409
  * `runtime_role === "host"`.
410
+ * @param {string} [args.provider]
411
+ * Phase 33.6 / Plan 33.6-03 (SC#6). The resolution provider — set to
412
+ * `"openrouter"` by the caller when the model was resolved via the
413
+ * OpenRouter tier-resolver adapter
414
+ * (`scripts/lib/tier-resolver-openrouter.cjs`). Additive/back-compat:
415
+ * when absent (the native-resolution default + every pre-33.6 caller)
416
+ * the key is OMITTED — exactly how `peer_id` is omitted for host rows —
417
+ * so the legacy on-disk cost-row shape stays byte-stable. Only a
418
+ * non-empty string is threaded; anything else is dropped.
410
419
  * @returns {object}
411
420
  */
412
421
  function buildCostEventPayload(args) {
@@ -433,6 +442,13 @@ function buildCostEventPayload(args) {
433
442
  : null;
434
443
  out.peer_id = pid;
435
444
  }
445
+ // Phase 33.6 SC#6 — additive provider tag. Threaded ONLY when the caller
446
+ // passes a non-empty string (e.g. "openrouter" when the OpenRouter adapter
447
+ // resolved the model). Omitted otherwise, mirroring peer_id, so the legacy
448
+ // cost-row shape stays stable for native-resolution + pre-33.6 callers.
449
+ if (args && typeof args.provider === 'string' && args.provider.length > 0) {
450
+ out.provider = args.provider;
451
+ }
436
452
  return out;
437
453
  }
438
454
 
@@ -0,0 +1,326 @@
1
+ 'use strict';
2
+ // scripts/lib/openrouter/catalog-fetcher.cjs — Plan 33.6-01 (Wave A.1)
3
+ //
4
+ // The plugin's FIRST plugin-side outbound REST client. Fetches the OpenRouter
5
+ // model catalog (https://openrouter.ai/api/v1/models), maps it into the CONTEXT
6
+ // cache shape, and writes it ATOMICALLY to .design/cache/openrouter-models.json
7
+ // with a 24h TTL skip-if-fresh. The fetch is gated behind an INJECTABLE
8
+ // `fetchImpl` (default global `fetch`) so the entire default test suite is
9
+ // hermetic (D-07) and there is NO new runtime dependency — no axios/node-fetch/
10
+ // undici (D-10). The fetch( egress is allowlisted via scripts/lib/openrouter/**
11
+ // in scripts/security/outbound-allowlist.json (D-06), with a matching egress
12
+ // entry in reference/gdd-threat-model.md.
13
+ //
14
+ // Decisions honored:
15
+ // D-02 Catalog TTL = 24h default (overridable via ttlHours; the caller wires
16
+ // .design/config.json#openrouter_catalog_ttl_hours — the fetcher just
17
+ // takes ttlHours).
18
+ // D-06 fetch( is allowlisted via scripts/lib/openrouter/**; threat-model has
19
+ // the OpenRouter-egress entry.
20
+ // D-07 fetchImpl is injectable (default global fetch); no live network in tests.
21
+ // D-08 Graceful degrade — fetchCatalog NEVER throws. No key / fetch-fail /
22
+ // parse-fail → cached-if-any-else-null. Tier resolution falls back to the
23
+ // native provider.
24
+ // D-10 No new dependency — global fetch + sdk/primitives (jittered-backoff,
25
+ // error-classifier) + scripts/lib/rate-guard.cjs only.
26
+ //
27
+ // The OPENROUTER_API_KEY is read from process.env, sent ONLY as an Authorization:
28
+ // Bearer header, and is NEVER persisted to the cache nor written to any log seam.
29
+
30
+ const fs = require('node:fs');
31
+ const path = require('node:path');
32
+
33
+ const { delayMs, sleep } = require('../../../sdk/primitives/jittered-backoff.cjs');
34
+ const { classify, FailoverReason } = require('../../../sdk/primitives/error-classifier.cjs');
35
+ const rateGuard = require('../rate-guard.cjs');
36
+
37
+ // Repo root is three levels up from scripts/lib/openrouter/.
38
+ const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
39
+ const DEFAULT_CACHE_PATH = path.join(REPO_ROOT, '.design', 'cache', 'openrouter-models.json');
40
+
41
+ const DEFAULT_BASE_URL = 'https://openrouter.ai/api/v1';
42
+ const MODELS_PATH = '/models';
43
+ const MAX_ATTEMPTS = 3; // 1 initial + 2 retries — bounded, never infinite (D-08).
44
+ const PROVIDER = 'openrouter'; // rate-guard provider key.
45
+
46
+ /**
47
+ * Read + parse the catalog cache at `cachePath`. Returns the `models[]` array, or
48
+ * null when the file is missing, corrupt, or shape-invalid. NEVER throws.
49
+ *
50
+ * @param {object} [opts]
51
+ * @param {string} [opts.cachePath] defaults to <repo>/.design/cache/openrouter-models.json
52
+ * @returns {Array<object>|null}
53
+ */
54
+ function readCatalog(opts) {
55
+ const cachePath = (opts && typeof opts.cachePath === 'string' && opts.cachePath) || DEFAULT_CACHE_PATH;
56
+ try {
57
+ if (!fs.existsSync(cachePath)) return null;
58
+ const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
59
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.models)) return null;
60
+ return parsed.models;
61
+ } catch {
62
+ // Corrupt JSON / read error → treat as no cache.
63
+ return null;
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Read the full parsed cache object (not just models) for TTL inspection.
69
+ * Returns the parsed object or null. NEVER throws.
70
+ */
71
+ function readCacheObject(cachePath) {
72
+ try {
73
+ if (!fs.existsSync(cachePath)) return null;
74
+ const parsed = JSON.parse(fs.readFileSync(cachePath, 'utf8'));
75
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.models)) return null;
76
+ return parsed;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Is the cache object fresh relative to `nowMs` under `ttlHours`?
84
+ * A missing/unparseable fetched_at is treated as stale (forces a re-fetch).
85
+ */
86
+ function isFresh(cacheObj, ttlHours, nowMs) {
87
+ if (!cacheObj || typeof cacheObj.fetched_at !== 'string') return false;
88
+ const fetchedMs = Date.parse(cacheObj.fetched_at);
89
+ if (!Number.isFinite(fetchedMs)) return false;
90
+ const ageMs = nowMs - fetchedMs;
91
+ return ageMs >= 0 && ageMs < ttlHours * 3600_000;
92
+ }
93
+
94
+ /**
95
+ * Map an OpenRouter /models response into the CONTEXT cache shape. Defensive:
96
+ * tolerates missing fields, keeps ONLY id/name/context_length/pricing.{prompt,
97
+ * completion}, drops everything else. The /models response is untrusted input —
98
+ * it is mapped, never eval'd.
99
+ *
100
+ * @param {object} body the parsed { data: [...] } response
101
+ * @param {string} fetchedAtIso ISO timestamp to stamp
102
+ * @param {number} ttlHours
103
+ * @param {string} sourceUrl
104
+ * @returns {{fetched_at:string, ttl_hours:number, source:string, models:Array<object>}}
105
+ */
106
+ function mapResponse(body, fetchedAtIso, ttlHours, sourceUrl) {
107
+ const data = body && Array.isArray(body.data) ? body.data : [];
108
+ const models = [];
109
+ for (const entry of data) {
110
+ if (!entry || typeof entry !== 'object') continue;
111
+ if (typeof entry.id !== 'string' || entry.id.length === 0) continue;
112
+ const pricing = entry.pricing && typeof entry.pricing === 'object' ? entry.pricing : {};
113
+ models.push({
114
+ id: entry.id,
115
+ name: typeof entry.name === 'string' ? entry.name : entry.id,
116
+ context_length: Number.isFinite(entry.context_length) ? entry.context_length : null,
117
+ pricing: {
118
+ prompt: pricing.prompt !== undefined && pricing.prompt !== null ? String(pricing.prompt) : null,
119
+ completion:
120
+ pricing.completion !== undefined && pricing.completion !== null
121
+ ? String(pricing.completion)
122
+ : null,
123
+ },
124
+ });
125
+ }
126
+ return {
127
+ fetched_at: fetchedAtIso,
128
+ ttl_hours: ttlHours,
129
+ source: sourceUrl,
130
+ models,
131
+ };
132
+ }
133
+
134
+ /**
135
+ * Atomically write `obj` (JSON) to `cachePath`: write a per-pid temp file in the
136
+ * same directory, then rename over the target. mkdir -p the dir first. The
137
+ * rename is atomic on POSIX and NTFS. NEVER throws — write failure degrades.
138
+ *
139
+ * @returns {boolean} true on success, false on any failure.
140
+ */
141
+ function atomicWrite(cachePath, obj) {
142
+ try {
143
+ const dir = path.dirname(cachePath);
144
+ fs.mkdirSync(dir, { recursive: true });
145
+ const tmp = `${cachePath}.${process.pid}.${Date.now()}.tmp`;
146
+ fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf8');
147
+ try {
148
+ fs.renameSync(tmp, cachePath);
149
+ } catch (renameErr) {
150
+ // Best-effort cleanup of the temp file so we never leave litter behind.
151
+ try {
152
+ fs.unlinkSync(tmp);
153
+ } catch {
154
+ /* ignore */
155
+ }
156
+ throw renameErr;
157
+ }
158
+ return true;
159
+ } catch {
160
+ return false;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Perform the live fetch with bounded jittered-backoff retry on retryable
166
+ * classes (NETWORK_TRANSIENT / RATE_LIMITED), feeding any rate-limit headers to
167
+ * rate-guard. Non-retryable classes (AUTH_ERROR / VALIDATION / ...) stop
168
+ * immediately. Returns the parsed response body on success, or null on any
169
+ * exhausted/non-retryable failure. NEVER throws.
170
+ *
171
+ * @returns {Promise<object|null>}
172
+ */
173
+ async function fetchWithRetry({ fetchImpl, url, apiKey, backoffOpts }) {
174
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
175
+ // Respect any prior rate-limit window before issuing the request.
176
+ try {
177
+ await rateGuard.blockUntilReady(PROVIDER);
178
+ } catch {
179
+ /* rate-guard is best-effort — never let it break the fetch */
180
+ }
181
+
182
+ let res;
183
+ let thrown = null;
184
+ try {
185
+ res = await fetchImpl(url, {
186
+ method: 'GET',
187
+ headers: {
188
+ Authorization: `Bearer ${apiKey}`,
189
+ Accept: 'application/json',
190
+ },
191
+ });
192
+ } catch (networkErr) {
193
+ thrown = networkErr;
194
+ }
195
+
196
+ // A thrown/rejected fetch → classify the raw error.
197
+ if (thrown) {
198
+ const { reason } = classify(thrown);
199
+ if (reason === FailoverReason.NETWORK_TRANSIENT || reason === FailoverReason.RATE_LIMITED) {
200
+ if (attempt < MAX_ATTEMPTS - 1) {
201
+ await sleep(attempt, backoffOpts);
202
+ continue;
203
+ }
204
+ }
205
+ // Non-retryable, or retries exhausted.
206
+ return null;
207
+ }
208
+
209
+ // Feed response headers to rate-guard (best-effort) so a 429/limit window is
210
+ // recorded for the next call.
211
+ try {
212
+ if (res && res.headers) await rateGuard.ingestHeaders(PROVIDER, res.headers);
213
+ } catch {
214
+ /* best-effort */
215
+ }
216
+
217
+ if (res && res.ok) {
218
+ try {
219
+ return await res.json();
220
+ } catch {
221
+ // A 200 with an unparseable body is a transient-ish anomaly; retry if budget remains.
222
+ if (attempt < MAX_ATTEMPTS - 1) {
223
+ await sleep(attempt, backoffOpts);
224
+ continue;
225
+ }
226
+ return null;
227
+ }
228
+ }
229
+
230
+ // Non-OK HTTP — classify by status.
231
+ const status = res && Number.isFinite(res.status) ? res.status : 0;
232
+ const { reason } = classify({ status });
233
+ if (reason === FailoverReason.NETWORK_TRANSIENT || reason === FailoverReason.RATE_LIMITED) {
234
+ if (attempt < MAX_ATTEMPTS - 1) {
235
+ await sleep(attempt, backoffOpts);
236
+ continue;
237
+ }
238
+ return null;
239
+ }
240
+ // AUTH_ERROR / VALIDATION / NETWORK_PERMANENT / etc. — do NOT retry.
241
+ return null;
242
+ }
243
+ return null;
244
+ }
245
+
246
+ /**
247
+ * Fetch (or load-from-cache) the OpenRouter model catalog.
248
+ *
249
+ * Order of operations:
250
+ * 1. readCatalog → if a cache is present AND fresh (within ttlHours of now)
251
+ * → return cache.models WITHOUT calling fetchImpl (TTL skip).
252
+ * 2. else if no apiKey → return cache.models if a cache is present (stale ok),
253
+ * else null. (Graceful — never fetches without a key.)
254
+ * 3. else fetch <baseUrl>/models via fetchImpl (Authorization: Bearer <apiKey>),
255
+ * retrying transient/rate-limited failures on a jittered-backoff curve with
256
+ * rate-guard awareness; non-retryable classes stop.
257
+ * 4. on success → map to the CONTEXT cache shape → atomic write → return models.
258
+ * 5. on exhausted/failed fetch → return cache.models if present else null.
259
+ *
260
+ * NEVER throws (D-08). The whole body is wrapped; any escaped error degrades to
261
+ * cached-if-any-else-null.
262
+ *
263
+ * @param {object} [opts]
264
+ * @param {function} [opts.fetchImpl] injectable fetch (default global fetch — D-07/D-10)
265
+ * @param {function} [opts.now] () => Date, for deterministic TTL (default () => new Date())
266
+ * @param {string} [opts.cachePath] default <repo>/.design/cache/openrouter-models.json
267
+ * @param {number} [opts.ttlHours] default 24 (D-02)
268
+ * @param {string} [opts.apiKey] default process.env.OPENROUTER_API_KEY
269
+ * @param {string} [opts.baseUrl] default process.env.OPENROUTER_BASE_URL || the OpenRouter base
270
+ * @param {object} [opts.backoffOpts] passed to jittered-backoff (tests pass near-zero)
271
+ * @returns {Promise<Array<object>|null>}
272
+ */
273
+ async function fetchCatalog(opts) {
274
+ const o = opts || {};
275
+ const fetchImpl = typeof o.fetchImpl === 'function' ? o.fetchImpl : globalThis.fetch;
276
+ const nowFn = typeof o.now === 'function' ? o.now : () => new Date();
277
+ const cachePath =
278
+ typeof o.cachePath === 'string' && o.cachePath.length > 0 ? o.cachePath : DEFAULT_CACHE_PATH;
279
+ const ttlHours = Number.isFinite(o.ttlHours) ? o.ttlHours : 24;
280
+ const apiKey = 'apiKey' in o ? o.apiKey : process.env.OPENROUTER_API_KEY;
281
+ const baseUrl =
282
+ (typeof o.baseUrl === 'string' && o.baseUrl) || process.env.OPENROUTER_BASE_URL || DEFAULT_BASE_URL;
283
+ const backoffOpts = o.backoffOpts;
284
+
285
+ try {
286
+ const nowMs = nowFn().getTime();
287
+ const cacheObj = readCacheObject(cachePath);
288
+
289
+ // 1. TTL skip — fresh cache short-circuits the fetch entirely.
290
+ if (cacheObj && isFresh(cacheObj, ttlHours, nowMs)) {
291
+ return cacheObj.models;
292
+ }
293
+
294
+ // 2. No key → never fetch; degrade to cached-if-any-else-null.
295
+ if (!apiKey || typeof apiKey !== 'string' || apiKey.length === 0) {
296
+ return cacheObj ? cacheObj.models : null;
297
+ }
298
+
299
+ // 3. Fetch with bounded retry.
300
+ const sourceUrl = `${baseUrl}${MODELS_PATH}`;
301
+ const body = await fetchWithRetry({ fetchImpl, url: sourceUrl, apiKey, backoffOpts });
302
+
303
+ // 5. Exhausted / failed → degrade to cached-if-any-else-null.
304
+ if (body === null) {
305
+ return cacheObj ? cacheObj.models : null;
306
+ }
307
+
308
+ // 4. Success → map + atomic write.
309
+ const fetchedAtIso = nowFn().toISOString();
310
+ // source stays the canonical OpenRouter models URL even when a custom baseUrl
311
+ // is used, so the cache's `source` is the public contract value.
312
+ const mapped = mapResponse(body, fetchedAtIso, ttlHours, `${DEFAULT_BASE_URL}${MODELS_PATH}`);
313
+ atomicWrite(cachePath, mapped); // best-effort; a write failure still returns the models
314
+ return mapped.models;
315
+ } catch {
316
+ // Absolute backstop — fetchCatalog NEVER throws (D-08).
317
+ const fallback = readCatalog({ cachePath });
318
+ return fallback;
319
+ }
320
+ }
321
+
322
+ module.exports = { fetchCatalog, readCatalog, _internal: { mapResponse, isFresh, atomicWrite } };
323
+ // `delayMs` is part of the resilience-primitive contract (jittered-backoff) and
324
+ // is exercised indirectly via `sleep`; reference it so linters/readers see the
325
+ // full retry-curve seam is wired.
326
+ void delayMs;
@@ -50,6 +50,7 @@
50
50
 
51
51
  const { spawn } = require('child_process');
52
52
  const { EventEmitter } = require('events');
53
+ const { sanitizeEnv, readPeerCliAllowlist } = require('./sanitize-env.cjs');
53
54
 
54
55
  /**
55
56
  * Hard cap on the size of a single un-terminated line read from the
@@ -99,7 +100,14 @@ function createAcpClient(opts) {
99
100
  const command = opts.command;
100
101
  const args = Array.isArray(opts.args) ? opts.args : [];
101
102
  const cwd = typeof opts.cwd === 'string' ? opts.cwd : process.cwd();
102
- const env = opts.env && typeof opts.env === 'object' ? opts.env : process.env;
103
+ // Plan 33.5-04 (D-03): when the caller does not supply an explicit env, the
104
+ // child inherits a SANITIZED env (OS-essential baseline + the configured
105
+ // peer_cli.env_allowlist) instead of the raw full process.env — so GDD's
106
+ // ANTHROPIC_API_KEY/GH_TOKEN/GDD_* never leak to spawned peers. An explicit
107
+ // opts.env still wins (callers/tests can pass a full env).
108
+ const env = opts.env && typeof opts.env === 'object'
109
+ ? opts.env
110
+ : sanitizeEnv(process.env, { allowlist: readPeerCliAllowlist() });
103
111
 
104
112
  const events = new EventEmitter();
105
113
 
@@ -80,6 +80,7 @@
80
80
  'use strict';
81
81
 
82
82
  const { spawn } = require('node:child_process');
83
+ const { sanitizeEnv, readPeerCliAllowlist } = require('./sanitize-env.cjs');
83
84
 
84
85
  /** Per-line cap before we treat the stream as malformed. */
85
86
  const MAX_LINE_BYTES = 16 * 1024 * 1024;
@@ -119,7 +120,15 @@ function createAspClient(opts) {
119
120
  stdio: ['pipe', 'pipe', 'pipe'],
120
121
  };
121
122
  if (typeof opts.cwd === 'string' && opts.cwd.length > 0) spawnOptions.cwd = opts.cwd;
122
- if (opts.env && typeof opts.env === 'object') spawnOptions.env = opts.env;
123
+ if (opts.env && typeof opts.env === 'object') {
124
+ spawnOptions.env = opts.env;
125
+ } else {
126
+ // Plan 33.5-04 (D-03): without an explicit env, the child ALWAYS gets a
127
+ // defined, SANITIZED env (OS-essential baseline + peer_cli.env_allowlist)
128
+ // rather than Node defaulting to the raw process.env — closing the
129
+ // ANTHROPIC_API_KEY/GH_TOKEN/GDD_* leak. Explicit opts.env still wins.
130
+ spawnOptions.env = sanitizeEnv(process.env, { allowlist: readPeerCliAllowlist() });
131
+ }
123
132
 
124
133
  // Test-injection seam: callers (or unit tests) can supply a pre-built
125
134
  // ChildProcess so we don't actually fork a binary in tests. The mock