@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +46 -0
- package/README.md +10 -6
- package/agents/README.md +60 -0
- package/agents/design-reflector.md +43 -0
- package/agents/gdd-intel-updater.md +34 -1
- package/hooks/budget-enforcer.ts +143 -4
- package/package.json +1 -1
- package/reference/model-prices.md +40 -19
- package/reference/prices/antigravity.md +21 -0
- package/reference/prices/augment.md +21 -0
- package/reference/prices/claude.md +42 -0
- package/reference/prices/cline.md +23 -0
- package/reference/prices/codebuddy.md +21 -0
- package/reference/prices/codex.md +25 -0
- package/reference/prices/copilot.md +21 -0
- package/reference/prices/cursor.md +21 -0
- package/reference/prices/gemini.md +25 -0
- package/reference/prices/kilo.md +21 -0
- package/reference/prices/opencode.md +23 -0
- package/reference/prices/qwen.md +25 -0
- package/reference/prices/trae.md +23 -0
- package/reference/prices/windsurf.md +21 -0
- package/reference/registry.json +107 -1
- package/reference/runtime-models.md +446 -0
- package/reference/schemas/runtime-models.schema.json +123 -0
- package/scripts/install.cjs +8 -0
- package/scripts/lib/budget-enforcer.cjs +446 -0
- package/scripts/lib/cost-arbitrage.cjs +294 -0
- package/scripts/lib/install/installer.cjs +188 -11
- package/scripts/lib/install/parse-runtime-models.cjs +267 -0
- package/scripts/lib/install/runtimes.cjs +43 -0
- package/scripts/lib/runtime-detect.cjs +96 -0
- package/scripts/lib/tier-resolver.cjs +311 -0
- package/scripts/validate-frontmatter.ts +138 -1
- package/skills/router/SKILL.md +51 -2
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
// scripts/lib/cost-arbitrage.cjs
|
|
2
|
+
//
|
|
3
|
+
// Plan 26-06 — cross-runtime cost-arbitrage analysis (D-09).
|
|
4
|
+
//
|
|
5
|
+
// Pure function: given a sequence of cost events (each tagged with
|
|
6
|
+
// runtime, agent, tier, cycle, and cost), surfaces structured arbitrage
|
|
7
|
+
// proposals when one runtime's spend on a given `(agent, tier)` pair
|
|
8
|
+
// significantly exceeds another's over the most recent N cycles.
|
|
9
|
+
//
|
|
10
|
+
// Contract:
|
|
11
|
+
// analyze(events, options?) → proposals[]
|
|
12
|
+
//
|
|
13
|
+
// Inputs:
|
|
14
|
+
// * `events` — array of event envelopes shaped like Phase 22's
|
|
15
|
+
// `cost.update` events:
|
|
16
|
+
// {
|
|
17
|
+
// type: 'cost.update',
|
|
18
|
+
// cycle?: 'cycle-3',
|
|
19
|
+
// payload: {
|
|
20
|
+
// agent: 'design-reflector',
|
|
21
|
+
// tier: 'opus',
|
|
22
|
+
// runtime: 'claude' | 'codex' | …,
|
|
23
|
+
// usd: 0.42,
|
|
24
|
+
// ...
|
|
25
|
+
// }
|
|
26
|
+
// }
|
|
27
|
+
// Non-cost events and malformed entries are skipped silently.
|
|
28
|
+
// * `options.windowCycles` — how many of the most recent cycles to
|
|
29
|
+
// consider. Default 5 (D-09). Cycles are ordered by first-appearance
|
|
30
|
+
// in the events array (events.jsonl is append-only, so insertion
|
|
31
|
+
// order ≡ chronological order).
|
|
32
|
+
// * `options.thresholdPct` — relative-delta threshold above which an
|
|
33
|
+
// arbitrage signal is emitted. Default 0.5 (50%, D-09). Computed as
|
|
34
|
+
// `|maxAvg - minAvg| / minAvg`. The 50% number is a starting
|
|
35
|
+
// heuristic; bandit-style learning over arbitrage outcomes is
|
|
36
|
+
// Phase 23.5+ territory.
|
|
37
|
+
//
|
|
38
|
+
// Output:
|
|
39
|
+
// Array of structured proposals, each shaped like:
|
|
40
|
+
// {
|
|
41
|
+
// type: 'cost_arbitrage',
|
|
42
|
+
// agent: 'design-reflector',
|
|
43
|
+
// tier: 'opus',
|
|
44
|
+
// runtimes: {
|
|
45
|
+
// claude: { avg_cost_per_cycle: 0.42, n_cycles: 5 },
|
|
46
|
+
// codex: { avg_cost_per_cycle: 1.10, n_cycles: 5 }
|
|
47
|
+
// },
|
|
48
|
+
// delta_pct: 0.617,
|
|
49
|
+
// proposal: 'Switch design-reflector tier=opus invocations from codex to claude for ~62% cost saving',
|
|
50
|
+
// evidence_window: 'last_5_cycles'
|
|
51
|
+
// }
|
|
52
|
+
//
|
|
53
|
+
// Design notes:
|
|
54
|
+
// - Per-cycle averaging: events are first summed per
|
|
55
|
+
// (agent, tier, runtime, cycle), then averaged across the cycles
|
|
56
|
+
// where that triple was observed. This prevents per-runtime
|
|
57
|
+
// double-counting when a single cycle had multiple agent spawns
|
|
58
|
+
// in the same runtime (sum first, average next).
|
|
59
|
+
// - Mixed-runtime cycle history: a cycle that ran some spawns in CC
|
|
60
|
+
// and others in Codex is correctly attributed — each spawn's
|
|
61
|
+
// `payload.runtime` tag drives the bucket, never the cycle.
|
|
62
|
+
// - Single-runtime-only history: when only one runtime has events
|
|
63
|
+
// for a given (agent, tier), no arbitrage signal can be computed
|
|
64
|
+
// (need at least two runtimes to compare). The rule is silent — no
|
|
65
|
+
// false-positive proposals.
|
|
66
|
+
// - Pure: no I/O, no global state. Tests inject synthetic event
|
|
67
|
+
// arrays; production callers (the reflector agent) read
|
|
68
|
+
// `.design/telemetry/events.jsonl`, parse line-by-line, and pass
|
|
69
|
+
// the parsed array in.
|
|
70
|
+
|
|
71
|
+
'use strict';
|
|
72
|
+
|
|
73
|
+
const DEFAULT_WINDOW_CYCLES = 5;
|
|
74
|
+
const DEFAULT_THRESHOLD_PCT = 0.5;
|
|
75
|
+
|
|
76
|
+
const COST_EVENT_TYPE = 'cost.update';
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Phase 26-05 will tag cost events with a `runtime` field on
|
|
80
|
+
* `payload.runtime`. We accept that as the canonical site. As a fallback
|
|
81
|
+
* (for legacy events written before 26-05 lands, or for harnesses that
|
|
82
|
+
* stamp the runtime on the envelope's `_meta.runtime` instead), we also
|
|
83
|
+
* peek at top-level `runtime` and `_meta.runtime`. Whichever is present
|
|
84
|
+
* wins; payload-first to keep 26-05's contract authoritative.
|
|
85
|
+
*/
|
|
86
|
+
function extractRuntime(event) {
|
|
87
|
+
if (!event || typeof event !== 'object') return null;
|
|
88
|
+
const p = event.payload;
|
|
89
|
+
if (p && typeof p === 'object' && typeof p.runtime === 'string' && p.runtime.length > 0) {
|
|
90
|
+
return p.runtime;
|
|
91
|
+
}
|
|
92
|
+
if (typeof event.runtime === 'string' && event.runtime.length > 0) {
|
|
93
|
+
return event.runtime;
|
|
94
|
+
}
|
|
95
|
+
const meta = event._meta;
|
|
96
|
+
if (meta && typeof meta === 'object' && typeof meta.runtime === 'string' && meta.runtime.length > 0) {
|
|
97
|
+
return meta.runtime;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Extract the (agent, tier, runtime, cycle, usd) tuple from a single
|
|
104
|
+
* event envelope. Returns null when the event is not a cost.update or
|
|
105
|
+
* is missing any required field. Garbage input never throws.
|
|
106
|
+
*/
|
|
107
|
+
function extractCostRow(event) {
|
|
108
|
+
if (!event || typeof event !== 'object') return null;
|
|
109
|
+
if (event.type !== COST_EVENT_TYPE) return null;
|
|
110
|
+
const p = event.payload;
|
|
111
|
+
if (!p || typeof p !== 'object') return null;
|
|
112
|
+
if (typeof p.agent !== 'string' || p.agent.length === 0) return null;
|
|
113
|
+
if (typeof p.tier !== 'string' || p.tier.length === 0) return null;
|
|
114
|
+
const runtime = extractRuntime(event);
|
|
115
|
+
if (runtime === null) return null;
|
|
116
|
+
const usd = typeof p.usd === 'number' && Number.isFinite(p.usd) ? p.usd : null;
|
|
117
|
+
if (usd === null) return null;
|
|
118
|
+
// Cycle is optional in the BaseEvent envelope but required for
|
|
119
|
+
// per-cycle averaging. Events without a cycle are silently skipped —
|
|
120
|
+
// they would otherwise collapse all of history into a single bucket
|
|
121
|
+
// and produce misleading averages.
|
|
122
|
+
const cycle = typeof event.cycle === 'string' && event.cycle.length > 0
|
|
123
|
+
? event.cycle
|
|
124
|
+
: null;
|
|
125
|
+
if (cycle === null) return null;
|
|
126
|
+
return { agent: p.agent, tier: p.tier, runtime, cycle, usd };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Build the per-(agent, tier, runtime, cycle) sum map. This is the
|
|
131
|
+
* primary defense against double-counting: if a cycle has 4 spawns of
|
|
132
|
+
* design-verifier in claude, those 4 usd values become a single
|
|
133
|
+
* cycle-bucket sum; downstream averaging then divides by the number of
|
|
134
|
+
* cycles, not the number of spawns.
|
|
135
|
+
*/
|
|
136
|
+
function aggregateByCycle(events) {
|
|
137
|
+
// Map<agent, Map<tier, Map<runtime, Map<cycle, sum-usd>>>>
|
|
138
|
+
const buckets = new Map();
|
|
139
|
+
// Cycle ordering: the order each cycle id first appears in the
|
|
140
|
+
// events stream. Events.jsonl is append-only, so first-appearance
|
|
141
|
+
// ≡ chronological order. We don't try to parse cycle ids as
|
|
142
|
+
// sequential — slugs like "cycle-3" or "2026-04-29" are both valid.
|
|
143
|
+
const cycleOrder = [];
|
|
144
|
+
const seenCycles = new Set();
|
|
145
|
+
|
|
146
|
+
for (const ev of events) {
|
|
147
|
+
const row = extractCostRow(ev);
|
|
148
|
+
if (row === null) continue;
|
|
149
|
+
if (!seenCycles.has(row.cycle)) {
|
|
150
|
+
seenCycles.add(row.cycle);
|
|
151
|
+
cycleOrder.push(row.cycle);
|
|
152
|
+
}
|
|
153
|
+
let agentBucket = buckets.get(row.agent);
|
|
154
|
+
if (agentBucket === undefined) {
|
|
155
|
+
agentBucket = new Map();
|
|
156
|
+
buckets.set(row.agent, agentBucket);
|
|
157
|
+
}
|
|
158
|
+
let tierBucket = agentBucket.get(row.tier);
|
|
159
|
+
if (tierBucket === undefined) {
|
|
160
|
+
tierBucket = new Map();
|
|
161
|
+
agentBucket.set(row.tier, tierBucket);
|
|
162
|
+
}
|
|
163
|
+
let runtimeBucket = tierBucket.get(row.runtime);
|
|
164
|
+
if (runtimeBucket === undefined) {
|
|
165
|
+
runtimeBucket = new Map();
|
|
166
|
+
tierBucket.set(row.runtime, runtimeBucket);
|
|
167
|
+
}
|
|
168
|
+
const existing = runtimeBucket.get(row.cycle);
|
|
169
|
+
runtimeBucket.set(row.cycle, (existing === undefined ? 0 : existing) + row.usd);
|
|
170
|
+
}
|
|
171
|
+
return { buckets, cycleOrder };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Compute per-runtime averages for a single (agent, tier) pair,
|
|
176
|
+
* restricted to the window of recent cycles. Returns:
|
|
177
|
+
* { runtime: { avg_cost_per_cycle, n_cycles } }
|
|
178
|
+
* Only runtimes with at least one cycle in the window appear.
|
|
179
|
+
*/
|
|
180
|
+
function averageWithinWindow(tierBucket, cycleWindowSet) {
|
|
181
|
+
const out = {};
|
|
182
|
+
for (const [runtime, runtimeBucket] of tierBucket.entries()) {
|
|
183
|
+
let sum = 0;
|
|
184
|
+
let n = 0;
|
|
185
|
+
for (const [cycle, cycleSum] of runtimeBucket.entries()) {
|
|
186
|
+
if (!cycleWindowSet.has(cycle)) continue;
|
|
187
|
+
sum += cycleSum;
|
|
188
|
+
n += 1;
|
|
189
|
+
}
|
|
190
|
+
if (n === 0) continue;
|
|
191
|
+
out[runtime] = { avg_cost_per_cycle: sum / n, n_cycles: n };
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Build the proposal sentence. Fixed phrasing keeps test assertions
|
|
198
|
+
* stable across cycle slugs. Direction (cheap-runtime, expensive-runtime)
|
|
199
|
+
* is inferred from the averages.
|
|
200
|
+
*/
|
|
201
|
+
function buildProposalText(agent, tier, cheapRuntime, expensiveRuntime, deltaPct) {
|
|
202
|
+
const pct = Math.round(deltaPct * 100);
|
|
203
|
+
return `Switch ${agent} tier=${tier} invocations from ${expensiveRuntime} to ${cheapRuntime} for ~${pct}% cost saving`;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Main entry point. See module-level header for contract.
|
|
208
|
+
*/
|
|
209
|
+
function analyze(events, options) {
|
|
210
|
+
const opts = options && typeof options === 'object' ? options : {};
|
|
211
|
+
const windowCycles = typeof opts.windowCycles === 'number' && opts.windowCycles > 0
|
|
212
|
+
? Math.floor(opts.windowCycles)
|
|
213
|
+
: DEFAULT_WINDOW_CYCLES;
|
|
214
|
+
const thresholdPct = typeof opts.thresholdPct === 'number' && opts.thresholdPct > 0
|
|
215
|
+
? opts.thresholdPct
|
|
216
|
+
: DEFAULT_THRESHOLD_PCT;
|
|
217
|
+
|
|
218
|
+
if (!Array.isArray(events) || events.length === 0) return [];
|
|
219
|
+
|
|
220
|
+
const { buckets, cycleOrder } = aggregateByCycle(events);
|
|
221
|
+
if (cycleOrder.length === 0) return [];
|
|
222
|
+
|
|
223
|
+
// Window = last N cycles by first-appearance order.
|
|
224
|
+
const recentCycles = cycleOrder.slice(-windowCycles);
|
|
225
|
+
const cycleWindowSet = new Set(recentCycles);
|
|
226
|
+
const evidenceWindowLabel = `last_${recentCycles.length}_cycles`;
|
|
227
|
+
|
|
228
|
+
const proposals = [];
|
|
229
|
+
|
|
230
|
+
// Iterate (agent, tier) pairs deterministically (sorted) so output
|
|
231
|
+
// ordering is stable across runs and platforms — useful for snapshot
|
|
232
|
+
// tests and reproducible reflection files.
|
|
233
|
+
const agentNames = Array.from(buckets.keys()).sort();
|
|
234
|
+
for (const agent of agentNames) {
|
|
235
|
+
const agentBucket = buckets.get(agent);
|
|
236
|
+
if (agentBucket === undefined) continue;
|
|
237
|
+
const tierNames = Array.from(agentBucket.keys()).sort();
|
|
238
|
+
for (const tier of tierNames) {
|
|
239
|
+
const tierBucket = agentBucket.get(tier);
|
|
240
|
+
if (tierBucket === undefined) continue;
|
|
241
|
+
const runtimeAverages = averageWithinWindow(tierBucket, cycleWindowSet);
|
|
242
|
+
const runtimeIds = Object.keys(runtimeAverages);
|
|
243
|
+
// Single-runtime-only history → silent (D-09: no false-positive
|
|
244
|
+
// arbitrage signal when there's nothing to compare against).
|
|
245
|
+
if (runtimeIds.length < 2) continue;
|
|
246
|
+
|
|
247
|
+
// Find the runtime pair with the largest spread. We could emit
|
|
248
|
+
// one proposal per runtime pair but that gets noisy fast — the
|
|
249
|
+
// reflector wants the most-actionable signal first. Pair = (min, max).
|
|
250
|
+
let minRuntime = null;
|
|
251
|
+
let maxRuntime = null;
|
|
252
|
+
let minAvg = Infinity;
|
|
253
|
+
let maxAvg = -Infinity;
|
|
254
|
+
for (const r of runtimeIds) {
|
|
255
|
+
const v = runtimeAverages[r];
|
|
256
|
+
if (v === undefined) continue;
|
|
257
|
+
const avg = v.avg_cost_per_cycle;
|
|
258
|
+
if (avg < minAvg) { minAvg = avg; minRuntime = r; }
|
|
259
|
+
if (avg > maxAvg) { maxAvg = avg; maxRuntime = r; }
|
|
260
|
+
}
|
|
261
|
+
if (minRuntime === null || maxRuntime === null) continue;
|
|
262
|
+
if (minRuntime === maxRuntime) continue;
|
|
263
|
+
// Guard against zero-cost denominators — if both runtimes
|
|
264
|
+
// averaged $0 we have nothing to arbitrage; if only one did
|
|
265
|
+
// we report a finite spread but zero-divide on the threshold
|
|
266
|
+
// check, which would emit a misleading "Infinity%" proposal.
|
|
267
|
+
if (minAvg <= 0) continue;
|
|
268
|
+
|
|
269
|
+
const deltaPct = (maxAvg - minAvg) / minAvg;
|
|
270
|
+
if (deltaPct <= thresholdPct) continue;
|
|
271
|
+
|
|
272
|
+
proposals.push({
|
|
273
|
+
type: 'cost_arbitrage',
|
|
274
|
+
agent,
|
|
275
|
+
tier,
|
|
276
|
+
runtimes: runtimeAverages,
|
|
277
|
+
delta_pct: Number(deltaPct.toFixed(3)),
|
|
278
|
+
proposal: buildProposalText(agent, tier, minRuntime, maxRuntime, deltaPct),
|
|
279
|
+
evidence_window: evidenceWindowLabel,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return proposals;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
module.exports = {
|
|
288
|
+
analyze,
|
|
289
|
+
// Exposed for test injection / unit-testing the lower layers.
|
|
290
|
+
extractCostRow,
|
|
291
|
+
aggregateByCycle,
|
|
292
|
+
DEFAULT_WINDOW_CYCLES,
|
|
293
|
+
DEFAULT_THRESHOLD_PCT,
|
|
294
|
+
};
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
const fs = require('node:fs');
|
|
7
7
|
const path = require('node:path');
|
|
8
8
|
|
|
9
|
-
const { getRuntime } = require('./runtimes.cjs');
|
|
9
|
+
const { getRuntime, getRuntimeModels } = require('./runtimes.cjs');
|
|
10
10
|
const { resolveConfigDir } = require('./config-dir.cjs');
|
|
11
11
|
const {
|
|
12
12
|
mergeClaudeSettings,
|
|
@@ -15,6 +15,15 @@ const {
|
|
|
15
15
|
isPluginOwned,
|
|
16
16
|
} = require('./merge.cjs');
|
|
17
17
|
|
|
18
|
+
// Phase 26 D-06 — schema for the per-runtime models.json file emitted into
|
|
19
|
+
// each runtime's config directory at install time. Forward-compatible: new
|
|
20
|
+
// fields land additive; breaking changes bump `schema_version`.
|
|
21
|
+
const MODELS_JSON_SCHEMA_VERSION = 1;
|
|
22
|
+
const MODELS_JSON_FILE = 'models.json';
|
|
23
|
+
const MODELS_JSON_SOURCE = 'reference/runtime-models.md';
|
|
24
|
+
const MODELS_JSON_FINGERPRINT_KEY = 'generated_by';
|
|
25
|
+
const MODELS_JSON_FINGERPRINT_VALUE = 'get-design-done';
|
|
26
|
+
|
|
18
27
|
function loadJsonOr(empty, filePath) {
|
|
19
28
|
if (!fs.existsSync(filePath)) return empty;
|
|
20
29
|
const raw = fs.readFileSync(filePath, 'utf8');
|
|
@@ -48,13 +57,20 @@ function installRuntime(runtimeId, opts) {
|
|
|
48
57
|
const dryRun = Boolean(opts && opts.dryRun);
|
|
49
58
|
const configDir = resolveConfigDir(runtimeId, opts);
|
|
50
59
|
|
|
60
|
+
let result;
|
|
51
61
|
if (runtime.kind === 'claude-marketplace') {
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
|
|
62
|
+
result = installClaudeMarketplace(runtime, configDir, dryRun);
|
|
63
|
+
} else if (runtime.kind === 'agents-md') {
|
|
64
|
+
result = installAgentsMd(runtime, configDir, dryRun);
|
|
65
|
+
} else {
|
|
66
|
+
throw new Error(`Unsupported runtime kind: ${runtime.kind}`);
|
|
56
67
|
}
|
|
57
|
-
|
|
68
|
+
|
|
69
|
+
// Phase 26 D-06 — emit per-runtime models.json into the same config-dir.
|
|
70
|
+
// Side-effect attached to the primary result so existing callers see the
|
|
71
|
+
// unchanged shape AND get visibility into the second file.
|
|
72
|
+
result.modelsJson = installModelsJson(runtime, configDir, dryRun, opts);
|
|
73
|
+
return result;
|
|
58
74
|
}
|
|
59
75
|
|
|
60
76
|
function uninstallRuntime(runtimeId, opts) {
|
|
@@ -62,13 +78,20 @@ function uninstallRuntime(runtimeId, opts) {
|
|
|
62
78
|
const dryRun = Boolean(opts && opts.dryRun);
|
|
63
79
|
const configDir = resolveConfigDir(runtimeId, opts);
|
|
64
80
|
|
|
81
|
+
let result;
|
|
65
82
|
if (runtime.kind === 'claude-marketplace') {
|
|
66
|
-
|
|
83
|
+
result = uninstallClaudeMarketplace(runtime, configDir, dryRun);
|
|
84
|
+
} else if (runtime.kind === 'agents-md') {
|
|
85
|
+
result = uninstallAgentsMd(runtime, configDir, dryRun);
|
|
86
|
+
} else {
|
|
87
|
+
throw new Error(`Unsupported runtime kind: ${runtime.kind}`);
|
|
67
88
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
89
|
+
|
|
90
|
+
// Phase 26 D-06 — clean up the models.json we wrote on install.
|
|
91
|
+
// Idempotent: missing file → unchanged; foreign file (no fingerprint) is
|
|
92
|
+
// left alone, mirroring the AGENTS.md skipped-foreign discipline.
|
|
93
|
+
result.modelsJson = uninstallModelsJson(runtime, configDir, dryRun);
|
|
94
|
+
return result;
|
|
72
95
|
}
|
|
73
96
|
|
|
74
97
|
function installClaudeMarketplace(runtime, configDir, dryRun) {
|
|
@@ -203,6 +226,154 @@ function uninstallAgentsMd(runtime, configDir, dryRun) {
|
|
|
203
226
|
};
|
|
204
227
|
}
|
|
205
228
|
|
|
229
|
+
// Phase 26 D-06 — `models.json` emission per runtime config-dir.
|
|
230
|
+
//
|
|
231
|
+
// Format (locked by CONTEXT D-06):
|
|
232
|
+
// {
|
|
233
|
+
// "tier_to_model": { "opus": "<model>", "sonnet": "<model>", "haiku": "<model>" },
|
|
234
|
+
// "reasoning_class_to_model": { "high": "<model>", "medium": "<model>", "low": "<model>" },
|
|
235
|
+
// "runtime": "<runtime-id>",
|
|
236
|
+
// "schema_version": 1,
|
|
237
|
+
// "generated_at": "<ISO-timestamp>",
|
|
238
|
+
// "source": "reference/runtime-models.md",
|
|
239
|
+
// "generated_by": "get-design-done"
|
|
240
|
+
// }
|
|
241
|
+
//
|
|
242
|
+
// `generated_by` is the fingerprint uninstall uses to decide whether the
|
|
243
|
+
// file is plugin-owned (mirroring the AGENTS.md fingerprint discipline in
|
|
244
|
+
// merge.cjs).
|
|
245
|
+
|
|
246
|
+
function buildModelsJsonPayload(runtime, opts) {
|
|
247
|
+
const entry = getRuntimeModels(runtime.id, opts);
|
|
248
|
+
if (!entry) return null;
|
|
249
|
+
// Flatten { model: "..." } rows into bare strings per CONTEXT D-06's
|
|
250
|
+
// schema example. provider_model_id (if present in the source) is dropped
|
|
251
|
+
// here — runtime harnesses that need it can re-read runtime-models.md.
|
|
252
|
+
const flatten = (rowMap) => {
|
|
253
|
+
const out = {};
|
|
254
|
+
for (const k of Object.keys(rowMap)) {
|
|
255
|
+
out[k] = rowMap[k].model;
|
|
256
|
+
}
|
|
257
|
+
return out;
|
|
258
|
+
};
|
|
259
|
+
return {
|
|
260
|
+
tier_to_model: flatten(entry.tier_to_model),
|
|
261
|
+
reasoning_class_to_model: flatten(entry.reasoning_class_to_model),
|
|
262
|
+
runtime: runtime.id,
|
|
263
|
+
schema_version: MODELS_JSON_SCHEMA_VERSION,
|
|
264
|
+
generated_at: (opts && opts.now) || new Date().toISOString(),
|
|
265
|
+
source: MODELS_JSON_SOURCE,
|
|
266
|
+
[MODELS_JSON_FINGERPRINT_KEY]: MODELS_JSON_FINGERPRINT_VALUE,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function isModelsJsonPluginOwned(parsed) {
|
|
271
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
|
|
272
|
+
return parsed[MODELS_JSON_FINGERPRINT_KEY] === MODELS_JSON_FINGERPRINT_VALUE;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function installModelsJson(runtime, configDir, dryRun, opts) {
|
|
276
|
+
const target = path.join(configDir, MODELS_JSON_FILE);
|
|
277
|
+
const payload = buildModelsJsonPayload(runtime, opts);
|
|
278
|
+
if (!payload) {
|
|
279
|
+
// Runtime has no entry in runtime-models.md (e.g., research tail). Skip
|
|
280
|
+
// emission rather than writing an incomplete file. Surfaces as
|
|
281
|
+
// "skipped-no-data" in install summary so the operator can see why.
|
|
282
|
+
return {
|
|
283
|
+
path: target,
|
|
284
|
+
action: 'skipped-no-data',
|
|
285
|
+
dryRun,
|
|
286
|
+
reason: `No tier→model entry for runtime "${runtime.id}" in ${MODELS_JSON_SOURCE}`,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
ensureDir(configDir, dryRun);
|
|
290
|
+
|
|
291
|
+
const desired = `${JSON.stringify(payload, null, 2)}\n`;
|
|
292
|
+
|
|
293
|
+
if (fs.existsSync(target)) {
|
|
294
|
+
let current;
|
|
295
|
+
try {
|
|
296
|
+
current = fs.readFileSync(target, 'utf8');
|
|
297
|
+
} catch (err) {
|
|
298
|
+
// Read failure is unusual but non-fatal — surface and continue.
|
|
299
|
+
return {
|
|
300
|
+
path: target,
|
|
301
|
+
action: 'skipped-foreign',
|
|
302
|
+
dryRun,
|
|
303
|
+
reason: `Could not read existing ${MODELS_JSON_FILE}: ${err.message}`,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
let parsed = null;
|
|
307
|
+
try {
|
|
308
|
+
parsed = JSON.parse(current);
|
|
309
|
+
} catch {
|
|
310
|
+
// Corrupted/foreign JSON we did not write — leave it alone.
|
|
311
|
+
return {
|
|
312
|
+
path: target,
|
|
313
|
+
action: 'skipped-foreign',
|
|
314
|
+
dryRun,
|
|
315
|
+
reason: `Existing ${MODELS_JSON_FILE} is not valid JSON; refusing to overwrite.`,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
if (!isModelsJsonPluginOwned(parsed)) {
|
|
319
|
+
return {
|
|
320
|
+
path: target,
|
|
321
|
+
action: 'skipped-foreign',
|
|
322
|
+
dryRun,
|
|
323
|
+
reason: `Existing ${MODELS_JSON_FILE} was not authored by this plugin; refusing to overwrite.`,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
// Compare ignoring `generated_at` so re-runs aren't perpetually "updated"
|
|
327
|
+
// just because the timestamp moved.
|
|
328
|
+
if (modelsJsonContentEqual(parsed, payload)) {
|
|
329
|
+
return { path: target, action: 'unchanged', dryRun };
|
|
330
|
+
}
|
|
331
|
+
if (!dryRun) atomicWrite(target, desired);
|
|
332
|
+
return { path: target, action: 'updated', dryRun };
|
|
333
|
+
}
|
|
334
|
+
if (!dryRun) atomicWrite(target, desired);
|
|
335
|
+
return { path: target, action: 'created', dryRun };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function modelsJsonContentEqual(a, b) {
|
|
339
|
+
// Strip `generated_at` from both sides — every other field must match
|
|
340
|
+
// byte-for-byte for the install to be a true no-op.
|
|
341
|
+
const stripTs = (o) => {
|
|
342
|
+
const copy = { ...o };
|
|
343
|
+
delete copy.generated_at;
|
|
344
|
+
return copy;
|
|
345
|
+
};
|
|
346
|
+
return JSON.stringify(stripTs(a)) === JSON.stringify(stripTs(b));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function uninstallModelsJson(runtime, configDir, dryRun) {
|
|
350
|
+
const target = path.join(configDir, MODELS_JSON_FILE);
|
|
351
|
+
if (!fs.existsSync(target)) {
|
|
352
|
+
return { path: target, action: 'unchanged', dryRun };
|
|
353
|
+
}
|
|
354
|
+
let parsed = null;
|
|
355
|
+
try {
|
|
356
|
+
parsed = JSON.parse(fs.readFileSync(target, 'utf8'));
|
|
357
|
+
} catch {
|
|
358
|
+
return {
|
|
359
|
+
path: target,
|
|
360
|
+
action: 'skipped-foreign',
|
|
361
|
+
dryRun,
|
|
362
|
+
reason: `Existing ${MODELS_JSON_FILE} is not valid JSON; not removing.`,
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
if (!isModelsJsonPluginOwned(parsed)) {
|
|
366
|
+
return {
|
|
367
|
+
path: target,
|
|
368
|
+
action: 'skipped-foreign',
|
|
369
|
+
dryRun,
|
|
370
|
+
reason: `Existing ${MODELS_JSON_FILE} was not authored by this plugin; not removing.`,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
if (!dryRun) fs.unlinkSync(target);
|
|
374
|
+
return { path: target, action: 'removed', dryRun };
|
|
375
|
+
}
|
|
376
|
+
|
|
206
377
|
function detectInstalled(opts) {
|
|
207
378
|
const installed = [];
|
|
208
379
|
const { listRuntimes } = require('./runtimes.cjs');
|
|
@@ -241,4 +412,10 @@ module.exports = {
|
|
|
241
412
|
installRuntime,
|
|
242
413
|
uninstallRuntime,
|
|
243
414
|
detectInstalled,
|
|
415
|
+
// Phase 26 D-06 — exported for tests / external tooling that wants to
|
|
416
|
+
// preview the payload without performing a write.
|
|
417
|
+
buildModelsJsonPayload,
|
|
418
|
+
MODELS_JSON_FILE,
|
|
419
|
+
MODELS_JSON_SCHEMA_VERSION,
|
|
420
|
+
MODELS_JSON_SOURCE,
|
|
244
421
|
};
|