@hegemonart/get-design-done 1.25.0 → 1.27.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 +96 -0
- package/README.md +12 -6
- package/SKILL.md +3 -0
- package/agents/README.md +89 -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/peer-cli-capabilities.md +151 -0
- package/reference/peer-protocols.md +266 -0
- 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 +121 -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/bandit-router.cjs +214 -7
- package/scripts/lib/budget-enforcer.cjs +514 -0
- package/scripts/lib/cost-arbitrage.cjs +294 -0
- package/scripts/lib/event-stream/index.ts +14 -1
- package/scripts/lib/event-stream/types.ts +125 -1
- package/scripts/lib/install/installer.cjs +188 -11
- package/scripts/lib/install/parse-runtime-models.cjs +267 -0
- package/scripts/lib/install/runtimes.cjs +101 -0
- package/scripts/lib/peer-cli/acp-client.cjs +375 -0
- package/scripts/lib/peer-cli/adapters/codex.cjs +101 -0
- package/scripts/lib/peer-cli/adapters/copilot.cjs +79 -0
- package/scripts/lib/peer-cli/adapters/cursor.cjs +78 -0
- package/scripts/lib/peer-cli/adapters/gemini.cjs +81 -0
- package/scripts/lib/peer-cli/adapters/qwen.cjs +72 -0
- package/scripts/lib/peer-cli/asp-client.cjs +587 -0
- package/scripts/lib/peer-cli/broker-lifecycle.cjs +406 -0
- package/scripts/lib/peer-cli/registry.cjs +434 -0
- package/scripts/lib/peer-cli/spawn-cmd.cjs +149 -0
- package/scripts/lib/runtime-detect.cjs +96 -0
- package/scripts/lib/session-runner/index.ts +215 -0
- package/scripts/lib/session-runner/types.ts +60 -0
- package/scripts/lib/tier-resolver.cjs +311 -0
- package/scripts/validate-frontmatter.ts +297 -2
- package/skills/peer-cli-add/SKILL.md +170 -0
- package/skills/peer-cli-customize/SKILL.md +110 -0
- package/skills/peers/SKILL.md +101 -0
- package/skills/router/SKILL.md +51 -2
|
@@ -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
|
};
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* parse-runtime-models.cjs — pure parser for reference/runtime-models.md.
|
|
4
|
+
*
|
|
5
|
+
* Reads the per-runtime tier→model adapter source-of-truth (Phase 26 D-01..D-03)
|
|
6
|
+
* and returns a structured object with one entry per runtime. Used by:
|
|
7
|
+
*
|
|
8
|
+
* - scripts/lib/install/installer.cjs (26-03) at install time to emit
|
|
9
|
+
* `models.json` per runtime config-dir.
|
|
10
|
+
* - scripts/lib/tier-resolver.cjs (26-02) at runtime to resolve
|
|
11
|
+
* (runtime, tier) → model.
|
|
12
|
+
*
|
|
13
|
+
* Pure-JS validation against the strict schema at
|
|
14
|
+
* `reference/schemas/runtime-models.schema.json`. No optional dependencies.
|
|
15
|
+
*
|
|
16
|
+
* Public API:
|
|
17
|
+
* parseRuntimeModels({ cwd? }) → {
|
|
18
|
+
* runtimes: [{ id, tier_to_model, reasoning_class_to_model, provenance, single_tier? }, ...],
|
|
19
|
+
* schema_version: 1
|
|
20
|
+
* }
|
|
21
|
+
* parseRuntimeModelsFromString(markdown) → same shape
|
|
22
|
+
*
|
|
23
|
+
* Throws an Error with a specific message on the first validation failure
|
|
24
|
+
* (fail-fast: install-time validation catches typos before runtime).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const fs = require('node:fs');
|
|
28
|
+
const path = require('node:path');
|
|
29
|
+
|
|
30
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
31
|
+
const DEFAULT_PATH = path.join(REPO_ROOT, 'reference', 'runtime-models.md');
|
|
32
|
+
|
|
33
|
+
// Mirrors scripts/lib/install/runtimes.cjs RUNTIMES list (Phase 24 D-02 lock).
|
|
34
|
+
// Re-declared rather than imported to keep this parser dependency-free for
|
|
35
|
+
// downstream consumers that may want to call it from outside the install/ tree.
|
|
36
|
+
// Round-trip tested by tests/parse-runtime-models.test.cjs against the canonical
|
|
37
|
+
// runtimes.cjs list.
|
|
38
|
+
const KNOWN_RUNTIME_IDS = Object.freeze([
|
|
39
|
+
'claude',
|
|
40
|
+
'codex',
|
|
41
|
+
'gemini',
|
|
42
|
+
'qwen',
|
|
43
|
+
'kilo',
|
|
44
|
+
'copilot',
|
|
45
|
+
'cursor',
|
|
46
|
+
'windsurf',
|
|
47
|
+
'antigravity',
|
|
48
|
+
'augment',
|
|
49
|
+
'trae',
|
|
50
|
+
'codebuddy',
|
|
51
|
+
'cline',
|
|
52
|
+
'opencode',
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const TIER_KEYS = Object.freeze(['opus', 'sonnet', 'haiku']);
|
|
56
|
+
const REASONING_CLASS_KEYS = Object.freeze(['high', 'medium', 'low']);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Extract every ```json ... ``` fenced block from a markdown string.
|
|
60
|
+
* Returns an array of { raw, lineNumber } objects.
|
|
61
|
+
*/
|
|
62
|
+
function extractJsonBlocks(markdown) {
|
|
63
|
+
const blocks = [];
|
|
64
|
+
const re = /```json\s*\n([\s\S]*?)\n```/g;
|
|
65
|
+
let m;
|
|
66
|
+
while ((m = re.exec(markdown)) !== null) {
|
|
67
|
+
// Compute 1-indexed line number of the fence opening for error messages.
|
|
68
|
+
const lineNumber = markdown.slice(0, m.index).split('\n').length;
|
|
69
|
+
blocks.push({ raw: m[1], lineNumber });
|
|
70
|
+
}
|
|
71
|
+
return blocks;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function validateModelRow(row, where) {
|
|
75
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) {
|
|
76
|
+
throw new Error(`${where}: expected object, got ${typeof row}`);
|
|
77
|
+
}
|
|
78
|
+
if (typeof row.model !== 'string' || row.model.length === 0) {
|
|
79
|
+
throw new Error(`${where}: 'model' must be a non-empty string`);
|
|
80
|
+
}
|
|
81
|
+
const allowedKeys = new Set(['model', 'provider_model_id']);
|
|
82
|
+
for (const k of Object.keys(row)) {
|
|
83
|
+
if (!allowedKeys.has(k)) {
|
|
84
|
+
throw new Error(`${where}: unknown key '${k}' (allowed: ${[...allowedKeys].join(', ')})`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (row.provider_model_id !== undefined) {
|
|
88
|
+
if (typeof row.provider_model_id !== 'string' || row.provider_model_id.length === 0) {
|
|
89
|
+
throw new Error(`${where}: 'provider_model_id' must be a non-empty string when present`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function validateProvenance(arr, where) {
|
|
95
|
+
if (!Array.isArray(arr) || arr.length < 1) {
|
|
96
|
+
throw new Error(`${where}: 'provenance' must be a non-empty array`);
|
|
97
|
+
}
|
|
98
|
+
arr.forEach((row, i) => {
|
|
99
|
+
const w = `${where}[${i}]`;
|
|
100
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) {
|
|
101
|
+
throw new Error(`${w}: expected object`);
|
|
102
|
+
}
|
|
103
|
+
for (const required of ['source_url', 'retrieved_at', 'last_validated_cycle']) {
|
|
104
|
+
if (typeof row[required] !== 'string' || row[required].length === 0) {
|
|
105
|
+
throw new Error(`${w}: '${required}' must be a non-empty string`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// ISO-8601 sanity check (Date.parse accepts the canonical form we emit).
|
|
109
|
+
if (Number.isNaN(Date.parse(row.retrieved_at))) {
|
|
110
|
+
throw new Error(`${w}: 'retrieved_at' must be a valid ISO 8601 timestamp (got ${JSON.stringify(row.retrieved_at)})`);
|
|
111
|
+
}
|
|
112
|
+
const allowedKeys = new Set(['source_url', 'retrieved_at', 'last_validated_cycle', 'note']);
|
|
113
|
+
for (const k of Object.keys(row)) {
|
|
114
|
+
if (!allowedKeys.has(k)) {
|
|
115
|
+
throw new Error(`${w}: unknown key '${k}' (allowed: ${[...allowedKeys].join(', ')})`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (row.note !== undefined && typeof row.note !== 'string') {
|
|
119
|
+
throw new Error(`${w}: 'note' must be a string when present`);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function validateRuntimeEntry(entry, where) {
|
|
125
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
126
|
+
throw new Error(`${where}: expected object`);
|
|
127
|
+
}
|
|
128
|
+
// Required keys
|
|
129
|
+
for (const required of ['id', 'tier_to_model', 'reasoning_class_to_model', 'provenance']) {
|
|
130
|
+
if (!(required in entry)) {
|
|
131
|
+
throw new Error(`${where}: missing required key '${required}'`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// id enum check
|
|
135
|
+
if (typeof entry.id !== 'string' || !KNOWN_RUNTIME_IDS.includes(entry.id)) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`${where}: 'id' must be one of ${KNOWN_RUNTIME_IDS.join('|')} (got ${JSON.stringify(entry.id)})`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
// No unknown top-level keys
|
|
141
|
+
const allowedKeys = new Set(['id', 'single_tier', 'tier_to_model', 'reasoning_class_to_model', 'provenance']);
|
|
142
|
+
for (const k of Object.keys(entry)) {
|
|
143
|
+
if (!allowedKeys.has(k)) {
|
|
144
|
+
throw new Error(`${where}: unknown key '${k}' (allowed: ${[...allowedKeys].join(', ')})`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (entry.single_tier !== undefined && typeof entry.single_tier !== 'boolean') {
|
|
148
|
+
throw new Error(`${where}: 'single_tier' must be a boolean when present`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// tier_to_model: requires opus/sonnet/haiku
|
|
152
|
+
if (!entry.tier_to_model || typeof entry.tier_to_model !== 'object' || Array.isArray(entry.tier_to_model)) {
|
|
153
|
+
throw new Error(`${where}.tier_to_model: expected object`);
|
|
154
|
+
}
|
|
155
|
+
for (const tier of TIER_KEYS) {
|
|
156
|
+
if (!(tier in entry.tier_to_model)) {
|
|
157
|
+
throw new Error(`${where}.tier_to_model: missing required tier '${tier}'`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
for (const k of Object.keys(entry.tier_to_model)) {
|
|
161
|
+
if (!TIER_KEYS.includes(k)) {
|
|
162
|
+
throw new Error(`${where}.tier_to_model: unknown tier '${k}' (allowed: ${TIER_KEYS.join('|')})`);
|
|
163
|
+
}
|
|
164
|
+
validateModelRow(entry.tier_to_model[k], `${where}.tier_to_model.${k}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// reasoning_class_to_model: requires high/medium/low
|
|
168
|
+
if (!entry.reasoning_class_to_model || typeof entry.reasoning_class_to_model !== 'object' || Array.isArray(entry.reasoning_class_to_model)) {
|
|
169
|
+
throw new Error(`${where}.reasoning_class_to_model: expected object`);
|
|
170
|
+
}
|
|
171
|
+
for (const klass of REASONING_CLASS_KEYS) {
|
|
172
|
+
if (!(klass in entry.reasoning_class_to_model)) {
|
|
173
|
+
throw new Error(`${where}.reasoning_class_to_model: missing required class '${klass}'`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
for (const k of Object.keys(entry.reasoning_class_to_model)) {
|
|
177
|
+
if (!REASONING_CLASS_KEYS.includes(k)) {
|
|
178
|
+
throw new Error(`${where}.reasoning_class_to_model: unknown class '${k}' (allowed: ${REASONING_CLASS_KEYS.join('|')})`);
|
|
179
|
+
}
|
|
180
|
+
validateModelRow(entry.reasoning_class_to_model[k], `${where}.reasoning_class_to_model.${k}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
validateProvenance(entry.provenance, `${where}.provenance`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function parseRuntimeModelsFromString(markdown, sourceLabel) {
|
|
187
|
+
const label = sourceLabel || '<runtime-models markdown>';
|
|
188
|
+
if (typeof markdown !== 'string' || markdown.length === 0) {
|
|
189
|
+
throw new Error(`${label}: empty or non-string input`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const blocks = extractJsonBlocks(markdown);
|
|
193
|
+
if (blocks.length < 2) {
|
|
194
|
+
// We expect at least the schema-version block + 1 runtime block.
|
|
195
|
+
throw new Error(`${label}: expected at least one schema-version block and one runtime block, found ${blocks.length} fenced json blocks`);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// First block MUST be the { "$schema_version": <int> } header.
|
|
199
|
+
let schemaVersion = null;
|
|
200
|
+
let firstParsed;
|
|
201
|
+
try {
|
|
202
|
+
firstParsed = JSON.parse(blocks[0].raw);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
throw new Error(`${label}: first json block (line ${blocks[0].lineNumber}) failed to parse: ${err.message}`);
|
|
205
|
+
}
|
|
206
|
+
if (
|
|
207
|
+
!firstParsed ||
|
|
208
|
+
typeof firstParsed !== 'object' ||
|
|
209
|
+
Array.isArray(firstParsed) ||
|
|
210
|
+
!('$schema_version' in firstParsed) ||
|
|
211
|
+
Object.keys(firstParsed).length !== 1
|
|
212
|
+
) {
|
|
213
|
+
throw new Error(`${label}: first json block (line ${blocks[0].lineNumber}) must be { "$schema_version": <int> } and nothing else`);
|
|
214
|
+
}
|
|
215
|
+
if (firstParsed.$schema_version !== 1) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`${label}: $schema_version must be 1 (got ${JSON.stringify(firstParsed.$schema_version)}). Bump the parser when the schema breaks forward.`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
schemaVersion = firstParsed.$schema_version;
|
|
221
|
+
|
|
222
|
+
// Remaining blocks are runtime entries.
|
|
223
|
+
const runtimes = [];
|
|
224
|
+
const seenIds = new Set();
|
|
225
|
+
for (let i = 1; i < blocks.length; i++) {
|
|
226
|
+
const block = blocks[i];
|
|
227
|
+
let parsed;
|
|
228
|
+
try {
|
|
229
|
+
parsed = JSON.parse(block.raw);
|
|
230
|
+
} catch (err) {
|
|
231
|
+
throw new Error(`${label}: json block at line ${block.lineNumber} failed to parse: ${err.message}`);
|
|
232
|
+
}
|
|
233
|
+
const where = `${label}#runtimes[${i - 1}] (line ${block.lineNumber})`;
|
|
234
|
+
validateRuntimeEntry(parsed, where);
|
|
235
|
+
if (seenIds.has(parsed.id)) {
|
|
236
|
+
throw new Error(`${where}: duplicate runtime id '${parsed.id}' (already seen earlier in the file)`);
|
|
237
|
+
}
|
|
238
|
+
seenIds.add(parsed.id);
|
|
239
|
+
runtimes.push(parsed);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return { schema_version: schemaVersion, runtimes };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function parseRuntimeModels({ cwd } = {}) {
|
|
246
|
+
const filePath = cwd ? path.join(cwd, 'reference', 'runtime-models.md') : DEFAULT_PATH;
|
|
247
|
+
let markdown;
|
|
248
|
+
try {
|
|
249
|
+
markdown = fs.readFileSync(filePath, 'utf8');
|
|
250
|
+
} catch (err) {
|
|
251
|
+
const wrapped = new Error(
|
|
252
|
+
`parse-runtime-models: cannot read ${filePath}\n ${err.message}`,
|
|
253
|
+
);
|
|
254
|
+
wrapped.code = 'EPARSE_RUNTIME_MODELS_READ';
|
|
255
|
+
wrapped.path = filePath;
|
|
256
|
+
throw wrapped;
|
|
257
|
+
}
|
|
258
|
+
return parseRuntimeModelsFromString(markdown, filePath);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
module.exports = {
|
|
262
|
+
parseRuntimeModels,
|
|
263
|
+
parseRuntimeModelsFromString,
|
|
264
|
+
KNOWN_RUNTIME_IDS,
|
|
265
|
+
TIER_KEYS,
|
|
266
|
+
REASONING_CLASS_KEYS,
|
|
267
|
+
};
|
|
@@ -50,6 +50,8 @@ const RUNTIMES = Object.freeze([
|
|
|
50
50
|
configDirFallback: '.gemini',
|
|
51
51
|
kind: 'agents-md',
|
|
52
52
|
files: ['GEMINI.md'],
|
|
53
|
+
// Phase 27 (Plan 27-11): peer-CLI delegation binary, ACP protocol.
|
|
54
|
+
peerBinary: process.platform === 'win32' ? 'gemini.cmd' : 'gemini',
|
|
53
55
|
},
|
|
54
56
|
{
|
|
55
57
|
id: 'kilo',
|
|
@@ -66,6 +68,8 @@ const RUNTIMES = Object.freeze([
|
|
|
66
68
|
configDirFallback: '.codex',
|
|
67
69
|
kind: 'agents-md',
|
|
68
70
|
files: ['AGENTS.md'],
|
|
71
|
+
// Phase 27 (Plan 27-11): peer-CLI delegation binary, ASP protocol.
|
|
72
|
+
peerBinary: process.platform === 'win32' ? 'codex.cmd' : 'codex',
|
|
69
73
|
},
|
|
70
74
|
{
|
|
71
75
|
id: 'copilot',
|
|
@@ -74,6 +78,8 @@ const RUNTIMES = Object.freeze([
|
|
|
74
78
|
configDirFallback: '.copilot',
|
|
75
79
|
kind: 'agents-md',
|
|
76
80
|
files: ['AGENTS.md'],
|
|
81
|
+
// Phase 27 (Plan 27-11): peer-CLI delegation binary, ACP protocol.
|
|
82
|
+
peerBinary: process.platform === 'win32' ? 'copilot.cmd' : 'copilot',
|
|
77
83
|
},
|
|
78
84
|
{
|
|
79
85
|
id: 'cursor',
|
|
@@ -82,6 +88,8 @@ const RUNTIMES = Object.freeze([
|
|
|
82
88
|
configDirFallback: '.cursor',
|
|
83
89
|
kind: 'agents-md',
|
|
84
90
|
files: ['AGENTS.md'],
|
|
91
|
+
// Phase 27 (Plan 27-11): peer-CLI delegation binary, ACP protocol.
|
|
92
|
+
peerBinary: process.platform === 'win32' ? 'cursor-agent.cmd' : 'cursor-agent',
|
|
85
93
|
},
|
|
86
94
|
{
|
|
87
95
|
id: 'windsurf',
|
|
@@ -122,6 +130,8 @@ const RUNTIMES = Object.freeze([
|
|
|
122
130
|
configDirFallback: '.qwen',
|
|
123
131
|
kind: 'agents-md',
|
|
124
132
|
files: ['AGENTS.md'],
|
|
133
|
+
// Phase 27 (Plan 27-11): peer-CLI delegation binary, ACP protocol.
|
|
134
|
+
peerBinary: process.platform === 'win32' ? 'qwen.cmd' : 'qwen',
|
|
125
135
|
},
|
|
126
136
|
{
|
|
127
137
|
id: 'codebuddy',
|
|
@@ -161,6 +171,93 @@ function listRuntimeIds() {
|
|
|
161
171
|
return RUNTIMES.map((r) => r.id);
|
|
162
172
|
}
|
|
163
173
|
|
|
174
|
+
// Phase 26 D-06 — `tier_to_model` lookup helper.
|
|
175
|
+
//
|
|
176
|
+
// `getRuntimeModels(runtimeId, { cwd? })` resolves the per-runtime tier→model
|
|
177
|
+
// adapter from `reference/runtime-models.md` via `parse-runtime-models.cjs`.
|
|
178
|
+
// Returns `null` when the runtime has no entry in runtime-models.md (i.e.,
|
|
179
|
+
// the data source ships rows for fewer than 14 runtimes during the rolling
|
|
180
|
+
// research tail described in CONTEXT D-02). Caller is responsible for
|
|
181
|
+
// degrading gracefully (e.g., installer skips models.json emission when null).
|
|
182
|
+
//
|
|
183
|
+
// The parsed payload is cached per `cwd` to avoid re-reading the markdown
|
|
184
|
+
// on each runtime in a multi-runtime install loop.
|
|
185
|
+
|
|
186
|
+
const _modelsCache = new Map();
|
|
187
|
+
|
|
188
|
+
function getParsedRuntimeModels(opts) {
|
|
189
|
+
const cwd = (opts && opts.cwd) || null;
|
|
190
|
+
const cacheKey = cwd || '<default>';
|
|
191
|
+
if (_modelsCache.has(cacheKey)) return _modelsCache.get(cacheKey);
|
|
192
|
+
// Lazy require avoids a hard dep cycle if runtimes.cjs is imported in
|
|
193
|
+
// contexts that don't ship the reference/ tree (theoretical — not used today).
|
|
194
|
+
const { parseRuntimeModels } = require('./parse-runtime-models.cjs');
|
|
195
|
+
const parsed = parseRuntimeModels(cwd ? { cwd } : {});
|
|
196
|
+
_modelsCache.set(cacheKey, parsed);
|
|
197
|
+
return parsed;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function getRuntimeModels(runtimeId, opts) {
|
|
201
|
+
// Validate the runtime id up-front — this catches typos in the installer
|
|
202
|
+
// entry rather than silently returning null for "claud" vs "claude".
|
|
203
|
+
getRuntime(runtimeId);
|
|
204
|
+
const parsed = getParsedRuntimeModels(opts);
|
|
205
|
+
const entry = parsed.runtimes.find((r) => r.id === runtimeId);
|
|
206
|
+
return entry || null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Test-only hook: drop the cached parse result. Used by tests that mutate
|
|
210
|
+
// the source markdown between assertions.
|
|
211
|
+
function _resetRuntimeModelsCache() {
|
|
212
|
+
_modelsCache.clear();
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Phase 27 (Plan 27-11) — peer-CLI detection helpers.
|
|
216
|
+
//
|
|
217
|
+
// `listPeerCapableRuntimes()` returns the entries that carry a `peerBinary`
|
|
218
|
+
// field — the 5 runtimes that gdd can DELEGATE to (codex, gemini, cursor,
|
|
219
|
+
// copilot, qwen). The other 9 runtimes (claude, opencode, kilo, windsurf,
|
|
220
|
+
// antigravity, augment, trae, codebuddy, cline) are install targets only.
|
|
221
|
+
//
|
|
222
|
+
// `detectInstalledPeers({ which? })` checks each peer-capable runtime's
|
|
223
|
+
// `peerBinary` against the system PATH and returns the IDs of the peers
|
|
224
|
+
// that are installed locally. The `which` parameter is injectable for
|
|
225
|
+
// tests — the production caller passes a real `which`/`where` shim.
|
|
226
|
+
|
|
227
|
+
function listPeerCapableRuntimes() {
|
|
228
|
+
return RUNTIMES.filter((r) => typeof r.peerBinary === 'string');
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function detectInstalledPeers(opts) {
|
|
232
|
+
const opts2 = opts || {};
|
|
233
|
+
const whichFn = opts2.which || _defaultWhich;
|
|
234
|
+
const detected = [];
|
|
235
|
+
for (const r of listPeerCapableRuntimes()) {
|
|
236
|
+
try {
|
|
237
|
+
if (whichFn(r.peerBinary)) {
|
|
238
|
+
detected.push(r.id);
|
|
239
|
+
}
|
|
240
|
+
} catch (_e) {
|
|
241
|
+
// ENOENT / non-zero exit = not installed; never throw.
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return detected;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function _defaultWhich(binary) {
|
|
248
|
+
const { execSync } = require('node:child_process');
|
|
249
|
+
const cmd = process.platform === 'win32' ? 'where' : 'which';
|
|
250
|
+
try {
|
|
251
|
+
const out = execSync(`${cmd} ${binary}`, {
|
|
252
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
253
|
+
encoding: 'utf8',
|
|
254
|
+
}).trim();
|
|
255
|
+
return out.length > 0 ? out.split(/\r?\n/)[0] : null;
|
|
256
|
+
} catch (_e) {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
164
261
|
module.exports = {
|
|
165
262
|
RUNTIMES,
|
|
166
263
|
REPO,
|
|
@@ -169,4 +266,8 @@ module.exports = {
|
|
|
169
266
|
getRuntime,
|
|
170
267
|
listRuntimes,
|
|
171
268
|
listRuntimeIds,
|
|
269
|
+
getRuntimeModels,
|
|
270
|
+
listPeerCapableRuntimes,
|
|
271
|
+
detectInstalledPeers,
|
|
272
|
+
_resetRuntimeModelsCache,
|
|
172
273
|
};
|