@hegemonart/get-design-done 1.25.0 → 1.26.0

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 (37) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +46 -0
  4. package/README.md +10 -6
  5. package/agents/README.md +60 -0
  6. package/agents/design-reflector.md +43 -0
  7. package/agents/gdd-intel-updater.md +34 -1
  8. package/hooks/budget-enforcer.ts +143 -4
  9. package/package.json +1 -1
  10. package/reference/model-prices.md +40 -19
  11. package/reference/prices/antigravity.md +21 -0
  12. package/reference/prices/augment.md +21 -0
  13. package/reference/prices/claude.md +42 -0
  14. package/reference/prices/cline.md +23 -0
  15. package/reference/prices/codebuddy.md +21 -0
  16. package/reference/prices/codex.md +25 -0
  17. package/reference/prices/copilot.md +21 -0
  18. package/reference/prices/cursor.md +21 -0
  19. package/reference/prices/gemini.md +25 -0
  20. package/reference/prices/kilo.md +21 -0
  21. package/reference/prices/opencode.md +23 -0
  22. package/reference/prices/qwen.md +25 -0
  23. package/reference/prices/trae.md +23 -0
  24. package/reference/prices/windsurf.md +21 -0
  25. package/reference/registry.json +107 -1
  26. package/reference/runtime-models.md +446 -0
  27. package/reference/schemas/runtime-models.schema.json +123 -0
  28. package/scripts/install.cjs +8 -0
  29. package/scripts/lib/budget-enforcer.cjs +446 -0
  30. package/scripts/lib/cost-arbitrage.cjs +294 -0
  31. package/scripts/lib/install/installer.cjs +188 -11
  32. package/scripts/lib/install/parse-runtime-models.cjs +267 -0
  33. package/scripts/lib/install/runtimes.cjs +43 -0
  34. package/scripts/lib/runtime-detect.cjs +96 -0
  35. package/scripts/lib/tier-resolver.cjs +311 -0
  36. package/scripts/validate-frontmatter.ts +138 -1
  37. package/skills/router/SKILL.md +51 -2
@@ -0,0 +1,446 @@
1
+ // scripts/lib/budget-enforcer.cjs
2
+ //
3
+ // Plan 26-05 — shared cost-computation backend for budget-enforcer.
4
+ //
5
+ // Pure module that takes a `(model_id, runtime_id, token_counts)` triple and
6
+ // returns a USD cost figure by reading the per-runtime price table at
7
+ // `reference/prices/<runtime>.md`. Used by both:
8
+ //
9
+ // 1. `hooks/budget-enforcer.ts` — the Claude Code PreToolUse hook, when
10
+ // the router decision payload carries a `resolved_models[agent]`
11
+ // entry. The hook calls `computeCost({ model_id, runtime, tokens_in,
12
+ // tokens_out, cache_hit })` and writes the resulting figure into the
13
+ // OPT-09 telemetry row.
14
+ // 2. Non-CC code-level mirrors of the budget-enforcer surface — runtime
15
+ // adapters that wrap the same Phase 22 events.jsonl primitives. Same
16
+ // backend means cost numbers are computed identically across runtime
17
+ // hosts; downstream cost-aggregator (Phase 22) can roll up by
18
+ // `runtime` tag with apples-to-apples figures.
19
+ //
20
+ // `.cjs` extension matches Phase 22 primitives. The .ts hook reaches it
21
+ // via `createRequire` — same scheme as `rate-guard.cjs` and
22
+ // `iteration-budget.cjs`.
23
+ //
24
+ // Per-runtime price tables (D-08):
25
+ // - `reference/prices/claude.md` — Anthropic models
26
+ // - `reference/prices/codex.md` — OpenAI Codex (gpt-5 family)
27
+ // - `reference/prices/gemini.md` — Google Gemini 2.5 family
28
+ // - `reference/prices/qwen.md` — Alibaba Qwen 3 family
29
+ // - `reference/prices/<other>.md` — stub-only at v1.26.0; researcher
30
+ // fills with provenance citation in a later cycle.
31
+ //
32
+ // Each price table has the same canonical row shape:
33
+ // | Model | Tier | input_per_1m | output_per_1m | cached_input_per_1m |
34
+ //
35
+ // The parser extracts the markdown table by header signature, so future
36
+ // per-runtime authors can add columns at the right edge without breaking
37
+ // the consumer (forward-compatible).
38
+ //
39
+ // Fallback chain (mirrors tier-resolver D-04 spirit):
40
+ // 1. Runtime price table has the model row → use it.
41
+ // 2. Runtime price table missing OR model row missing → fall back to
42
+ // claude.md and emit `cost_lookup_fallback` event (caller emits;
43
+ // this module surfaces the fact via the return shape).
44
+ // 3. claude.md also missing the model → return null cost + reason.
45
+ //
46
+ // Pure module — no top-level side effects beyond the lazy cache. Never
47
+ // throws on missing/malformed price tables (returns null cost +
48
+ // diagnostic reason); throws ONLY on programmer errors (bad arg shapes).
49
+
50
+ 'use strict';
51
+
52
+ const fs = require('node:fs');
53
+ const path = require('node:path');
54
+
55
+ const REPO_ROOT_GUESS = path.resolve(__dirname, '..', '..');
56
+ const DEFAULT_RUNTIME_ID = 'claude';
57
+ const VALID_TIERS = Object.freeze(['opus', 'sonnet', 'haiku']);
58
+
59
+ /**
60
+ * Parsed price-row shape returned by `parsePriceTable`.
61
+ * Numeric fields are USD per 1M tokens.
62
+ *
63
+ * @typedef {{
64
+ * model: string,
65
+ * tier: string,
66
+ * input_per_1m: number,
67
+ * output_per_1m: number,
68
+ * cached_input_per_1m: number,
69
+ * }} PriceRow
70
+ */
71
+
72
+ /**
73
+ * In-memory cache of parsed price tables, keyed by runtime ID. `null`
74
+ * means we tried and the file was missing/unparseable (so we don't
75
+ * re-read the same broken file every spawn).
76
+ */
77
+ const _cache = new Map();
78
+
79
+ /**
80
+ * Reset the parsed-prices cache. Tests use this after writing fixture
81
+ * price tables to a temp directory; production callers rarely need it.
82
+ */
83
+ function reset() {
84
+ _cache.clear();
85
+ }
86
+
87
+ /**
88
+ * Compute the absolute path to `reference/prices/<runtime>.md`. Honors
89
+ * the `cwd` option for test isolation — defaults to the repo root
90
+ * derived from this module's filesystem location.
91
+ *
92
+ * @param {string} runtime
93
+ * @param {{cwd?: string}} [opts]
94
+ * @returns {string}
95
+ */
96
+ function priceTablePath(runtime, opts) {
97
+ const root = (opts && typeof opts.cwd === 'string' && opts.cwd.length > 0)
98
+ ? opts.cwd
99
+ : REPO_ROOT_GUESS;
100
+ return path.join(root, 'reference', 'prices', `${runtime}.md`);
101
+ }
102
+
103
+ /**
104
+ * Parse a markdown price table by locating the canonical header row and
105
+ * scanning subsequent `|`-delimited rows until the table ends. The
106
+ * header signature is the four required columns in order:
107
+ *
108
+ * `| Model | Tier | input_per_1m | output_per_1m | cached_input_per_1m |`
109
+ *
110
+ * Extra columns at the right are ignored (forward-compatible). Rows
111
+ * missing required columns or carrying placeholder TODO markers
112
+ * (e.g. `<TODO>` in a numeric cell) are skipped silently — the caller
113
+ * sees a null lookup for that model and falls back per the chain.
114
+ *
115
+ * @param {string} markdown
116
+ * @returns {PriceRow[]}
117
+ */
118
+ function parsePriceTable(markdown) {
119
+ if (typeof markdown !== 'string' || markdown.length === 0) return [];
120
+
121
+ const lines = markdown.split(/\r?\n/);
122
+ const rows = [];
123
+
124
+ // Locate header row by scanning for the canonical column signature.
125
+ // We tolerate whitespace + casing variations on the header cells.
126
+ const headerNeedles = ['model', 'tier', 'input_per_1m', 'output_per_1m', 'cached_input_per_1m'];
127
+ let headerIdx = -1;
128
+ for (let i = 0; i < lines.length; i++) {
129
+ const line = lines[i];
130
+ if (!/^\s*\|/.test(line)) continue;
131
+ const cells = line.split('|').map((s) => s.trim().toLowerCase());
132
+ let matched = 0;
133
+ for (const needle of headerNeedles) {
134
+ if (cells.includes(needle)) matched++;
135
+ }
136
+ if (matched === headerNeedles.length) {
137
+ headerIdx = i;
138
+ break;
139
+ }
140
+ }
141
+ if (headerIdx < 0) return [];
142
+
143
+ // Build the cell-index map from the header so column reorderings are
144
+ // tolerated (a column-shuffle reorg is a docs PR not a logic break).
145
+ const headerCells = lines[headerIdx].split('|').map((s) => s.trim().toLowerCase());
146
+ const colIdx = {};
147
+ for (const needle of headerNeedles) {
148
+ colIdx[needle] = headerCells.indexOf(needle);
149
+ }
150
+
151
+ // Skip the separator row (e.g. `|---|---|...|`).
152
+ let i = headerIdx + 1;
153
+ if (i < lines.length && /^\s*\|[\s|:-]+\|\s*$/.test(lines[i])) i++;
154
+
155
+ for (; i < lines.length; i++) {
156
+ const line = lines[i];
157
+ if (!/^\s*\|/.test(line)) break; // table ended
158
+ const cells = line.split('|').map((s) => s.trim());
159
+ // Pull required cells by header index.
160
+ const model = cells[colIdx.model];
161
+ const tier = cells[colIdx.tier];
162
+ const input = Number(cells[colIdx.input_per_1m]);
163
+ const output = Number(cells[colIdx.output_per_1m]);
164
+ const cached = Number(cells[colIdx.cached_input_per_1m]);
165
+ if (typeof model !== 'string' || model.length === 0) continue;
166
+ // Skip TODO/placeholder rows where prices haven't been confirmed.
167
+ if (
168
+ !Number.isFinite(input) ||
169
+ !Number.isFinite(output) ||
170
+ !Number.isFinite(cached)
171
+ ) {
172
+ continue;
173
+ }
174
+ rows.push({
175
+ model,
176
+ tier: typeof tier === 'string' ? tier : 'unknown',
177
+ input_per_1m: input,
178
+ output_per_1m: output,
179
+ cached_input_per_1m: cached,
180
+ });
181
+ }
182
+ return rows;
183
+ }
184
+
185
+ /**
186
+ * Load + cache the parsed price table for a runtime. Returns the empty
187
+ * array (cached) if the file is missing or unparseable, so subsequent
188
+ * calls don't re-read.
189
+ *
190
+ * @param {string} runtime
191
+ * @param {{cwd?: string}} [opts]
192
+ * @returns {PriceRow[]}
193
+ */
194
+ function loadPriceTable(runtime, opts) {
195
+ const key = `${runtime}:${(opts && opts.cwd) || ''}`;
196
+ if (_cache.has(key)) return _cache.get(key);
197
+ const fp = priceTablePath(runtime, opts);
198
+ let rows = [];
199
+ try {
200
+ if (fs.existsSync(fp)) {
201
+ const md = fs.readFileSync(fp, 'utf8');
202
+ rows = parsePriceTable(md);
203
+ }
204
+ } catch {
205
+ rows = [];
206
+ }
207
+ _cache.set(key, rows);
208
+ return rows;
209
+ }
210
+
211
+ /**
212
+ * Find a price row by model ID OR by tier name. Model-ID match takes
213
+ * precedence (exact concrete-model lookup is the resolved_models path);
214
+ * tier match is the back-compat fallback for callers that only know
215
+ * the tier (legacy model_tier_overrides path).
216
+ *
217
+ * @param {PriceRow[]} rows
218
+ * @param {{model_id?: string|null, tier?: string|null}} q
219
+ * @returns {PriceRow | null}
220
+ */
221
+ function findPriceRow(rows, q) {
222
+ if (!Array.isArray(rows) || rows.length === 0) return null;
223
+ const wantModel = q && typeof q.model_id === 'string' && q.model_id.length > 0
224
+ ? q.model_id
225
+ : null;
226
+ const wantTier = q && typeof q.tier === 'string' && q.tier.length > 0
227
+ ? q.tier
228
+ : null;
229
+ if (wantModel !== null) {
230
+ for (const r of rows) {
231
+ if (r.model === wantModel) return r;
232
+ }
233
+ }
234
+ if (wantTier !== null) {
235
+ for (const r of rows) {
236
+ if (r.tier === wantTier) return r;
237
+ }
238
+ }
239
+ return null;
240
+ }
241
+
242
+ /**
243
+ * Apply the OPT-09 estimator formula to a price row + token counts.
244
+ * Cache-hit input rows charge `cached_input_per_1m` instead of
245
+ * `input_per_1m` (consistent with skills/router/SKILL.md D-08).
246
+ *
247
+ * @param {PriceRow} row
248
+ * @param {{tokens_in: number, tokens_out: number, cache_hit?: boolean}} t
249
+ * @returns {number} cost in USD
250
+ */
251
+ function applyFormula(row, t) {
252
+ const tokensIn = Math.max(0, Number(t.tokens_in || 0));
253
+ const tokensOut = Math.max(0, Number(t.tokens_out || 0));
254
+ const inputRate = t.cache_hit === true ? row.cached_input_per_1m : row.input_per_1m;
255
+ return (
256
+ (tokensIn / 1_000_000) * inputRate +
257
+ (tokensOut / 1_000_000) * row.output_per_1m
258
+ );
259
+ }
260
+
261
+ /**
262
+ * Compute the USD cost for a spawn, given a concrete model identifier
263
+ * (the resolved_models path) OR a tier name (the legacy fallback path)
264
+ * plus token counts.
265
+ *
266
+ * Lookup order:
267
+ * 1. The runtime's price table by model_id → use.
268
+ * 2. The runtime's price table by tier → use.
269
+ * 3. The claude price table by model_id → use (fallback).
270
+ * 4. The claude price table by tier → use (fallback).
271
+ * 5. Nothing matched → return { cost_usd: null, reason }.
272
+ *
273
+ * Never throws for missing files / missing rows. Returns `cost_usd: null`
274
+ * + a diagnostic `reason` string the caller can emit on the event stream
275
+ * (`cost_lookup_failed` event) or write into telemetry.
276
+ *
277
+ * @param {object} args
278
+ * @param {string|null} [args.model_id]
279
+ * Concrete model name (e.g. 'gpt-5', 'claude-sonnet-4-7'). Preferred.
280
+ * @param {string|null} [args.tier]
281
+ * Tier name ('opus'|'sonnet'|'haiku'). Used when model_id is absent.
282
+ * @param {string} args.runtime
283
+ * Runtime ID ('claude', 'codex', 'gemini', …). Required.
284
+ * @param {number} args.tokens_in
285
+ * @param {number} args.tokens_out
286
+ * @param {boolean} [args.cache_hit]
287
+ * @param {object} [opts]
288
+ * @param {string} [opts.cwd]
289
+ * Override repo root (tests).
290
+ * @returns {{
291
+ * cost_usd: number|null,
292
+ * model: string|null,
293
+ * tier: string|null,
294
+ * runtime_used: string|null,
295
+ * fallback: boolean,
296
+ * reason: string|null,
297
+ * }}
298
+ */
299
+ function computeCost(args, opts) {
300
+ if (!args || typeof args !== 'object') {
301
+ return {
302
+ cost_usd: null,
303
+ model: null,
304
+ tier: null,
305
+ runtime_used: null,
306
+ fallback: false,
307
+ reason: 'invalid_args',
308
+ };
309
+ }
310
+ const runtime = typeof args.runtime === 'string' && args.runtime.length > 0
311
+ ? args.runtime
312
+ : null;
313
+ if (runtime === null) {
314
+ return {
315
+ cost_usd: null,
316
+ model: null,
317
+ tier: typeof args.tier === 'string' ? args.tier : null,
318
+ runtime_used: null,
319
+ fallback: false,
320
+ reason: 'missing_runtime',
321
+ };
322
+ }
323
+
324
+ const tokens = {
325
+ tokens_in: Number(args.tokens_in || 0),
326
+ tokens_out: Number(args.tokens_out || 0),
327
+ cache_hit: args.cache_hit === true,
328
+ };
329
+ const q = {
330
+ model_id: typeof args.model_id === 'string' && args.model_id.length > 0
331
+ ? args.model_id
332
+ : null,
333
+ tier: typeof args.tier === 'string' && args.tier.length > 0
334
+ ? args.tier
335
+ : null,
336
+ };
337
+
338
+ // Branch 1+2: runtime price table.
339
+ const rows = loadPriceTable(runtime, opts);
340
+ const direct = findPriceRow(rows, q);
341
+ if (direct !== null) {
342
+ return {
343
+ cost_usd: applyFormula(direct, tokens),
344
+ model: direct.model,
345
+ tier: direct.tier,
346
+ runtime_used: runtime,
347
+ fallback: false,
348
+ reason: null,
349
+ };
350
+ }
351
+
352
+ // Branch 3+4: claude fallback (only if not already querying claude).
353
+ if (runtime !== DEFAULT_RUNTIME_ID) {
354
+ const fallbackRows = loadPriceTable(DEFAULT_RUNTIME_ID, opts);
355
+ const fb = findPriceRow(fallbackRows, q);
356
+ if (fb !== null) {
357
+ return {
358
+ cost_usd: applyFormula(fb, tokens),
359
+ model: fb.model,
360
+ tier: fb.tier,
361
+ runtime_used: DEFAULT_RUNTIME_ID,
362
+ fallback: true,
363
+ reason: rows.length === 0 ? 'runtime_table_missing' : 'model_not_in_runtime_table',
364
+ };
365
+ }
366
+ }
367
+
368
+ // Branch 5: nothing matched.
369
+ return {
370
+ cost_usd: null,
371
+ model: null,
372
+ tier: q.tier,
373
+ runtime_used: null,
374
+ fallback: false,
375
+ reason: rows.length === 0 ? 'runtime_table_missing' : 'model_not_found',
376
+ };
377
+ }
378
+
379
+ /**
380
+ * Convenience: build a `cost_recorded` event payload (D-08 shape) from
381
+ * a computeCost result + the spawn metadata. Returned object is the
382
+ * `payload` field for `appendEvent({ type: 'cost_recorded', payload })`.
383
+ * The .ts hook owns the actual `appendEvent()` call (it has the typed
384
+ * event-stream import); .cjs callers (non-CC mirrors) compose this with
385
+ * the JSONL line shape used in tier-resolver.cjs's `emitEvent()`.
386
+ *
387
+ * @param {object} args
388
+ * @param {string} args.runtime
389
+ * @param {string} args.agent
390
+ * @param {string|null} args.model_id
391
+ * @param {string|null} args.tier
392
+ * @param {number} args.tokens_in
393
+ * @param {number} args.tokens_out
394
+ * @param {number|null} args.cost_usd
395
+ * @returns {object}
396
+ */
397
+ function buildCostEventPayload(args) {
398
+ return {
399
+ runtime: args.runtime,
400
+ agent: args.agent,
401
+ model_id: args.model_id,
402
+ tier: args.tier,
403
+ tokens_in: Number(args.tokens_in || 0),
404
+ tokens_out: Number(args.tokens_out || 0),
405
+ cost_usd: typeof args.cost_usd === 'number' && Number.isFinite(args.cost_usd)
406
+ ? args.cost_usd
407
+ : null,
408
+ };
409
+ }
410
+
411
+ /**
412
+ * Resolve a concrete model_id from the router's `resolved_models` map
413
+ * for a given agent. Returns null when:
414
+ * - resolved_models is absent / not an object;
415
+ * - the agent key is missing;
416
+ * - the value is not a non-empty string.
417
+ *
418
+ * Hosts on the resolved_models consumer path (D-07): if this returns
419
+ * non-null, the cost lookup goes through the per-runtime price table by
420
+ * model_id; otherwise the caller falls back to the legacy
421
+ * model_tier_overrides path.
422
+ *
423
+ * @param {unknown} resolvedModels
424
+ * @param {string} agent
425
+ * @returns {string|null}
426
+ */
427
+ function modelFromResolved(resolvedModels, agent) {
428
+ if (!resolvedModels || typeof resolvedModels !== 'object') return null;
429
+ const v = resolvedModels[agent];
430
+ if (typeof v === 'string' && v.length > 0) return v;
431
+ return null;
432
+ }
433
+
434
+ module.exports = {
435
+ computeCost,
436
+ buildCostEventPayload,
437
+ modelFromResolved,
438
+ parsePriceTable,
439
+ loadPriceTable,
440
+ priceTablePath,
441
+ reset,
442
+ VALID_TIERS,
443
+ DEFAULT_RUNTIME_ID,
444
+ // surfaced for tests only — stable API = computeCost + modelFromResolved
445
+ _internal: { findPriceRow, applyFormula },
446
+ };