@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
|
@@ -86,6 +86,221 @@ const RATE_GUARD_PROVIDER = 'anthropic';
|
|
|
86
86
|
/** Default retries (first attempt + 1 retry). */
|
|
87
87
|
const DEFAULT_MAX_RETRIES = 2;
|
|
88
88
|
|
|
89
|
+
// ── Plan 27-06 — Peer-CLI delegation primitives ─────────────────────────────
|
|
90
|
+
//
|
|
91
|
+
// Lazy registry loader: the registry is a .cjs module under scripts/lib/peer-cli
|
|
92
|
+
// landed by Plan 27-05. Tests inject a stub via SessionRunnerOptions.registryOverride;
|
|
93
|
+
// real callers fall through to the live module. Resolution is anchored to the
|
|
94
|
+
// repo root via the same `_nodeRequire` we use for jittered-backoff/rate-guard
|
|
95
|
+
// so the runner survives test sandboxes that chdir.
|
|
96
|
+
//
|
|
97
|
+
// We swallow load errors and return null → caller treats as "no peer available"
|
|
98
|
+
// → falls back to local SDK. This keeps the session-runner functional even on
|
|
99
|
+
// fresh checkouts where Plan 27-05 hasn't landed yet.
|
|
100
|
+
|
|
101
|
+
interface PeerRegistry {
|
|
102
|
+
dispatch: (
|
|
103
|
+
role: string,
|
|
104
|
+
tier: string | null,
|
|
105
|
+
text: string,
|
|
106
|
+
opts: { cwd?: string; [k: string]: unknown },
|
|
107
|
+
) => Promise<{ result: unknown; peer: string; protocol: 'acp' | 'asp' } | null>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let _peerRegistryCache: PeerRegistry | null | undefined;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve the peer-CLI registry. Memoized; returns null if the module
|
|
114
|
+
* isn't installable (missing file, require throws, shape mismatch).
|
|
115
|
+
* Tests bypass this entirely by passing `registryOverride` on the
|
|
116
|
+
* SessionRunnerOptions.
|
|
117
|
+
*/
|
|
118
|
+
function loadPeerRegistry(): PeerRegistry | null {
|
|
119
|
+
if (_peerRegistryCache !== undefined) return _peerRegistryCache;
|
|
120
|
+
try {
|
|
121
|
+
const mod = _nodeRequire(
|
|
122
|
+
_resolve(_REPO_ROOT, 'scripts/lib/peer-cli/registry.cjs'),
|
|
123
|
+
);
|
|
124
|
+
if (mod && typeof mod === 'object' && typeof (mod as { dispatch?: unknown }).dispatch === 'function') {
|
|
125
|
+
_peerRegistryCache = mod as PeerRegistry;
|
|
126
|
+
return _peerRegistryCache;
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
// registry.cjs missing or threw on require — treat as "no peers available"
|
|
130
|
+
}
|
|
131
|
+
_peerRegistryCache = null;
|
|
132
|
+
return _peerRegistryCache;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Visible-for-testing reset of the peer-registry cache. The session-runner
|
|
137
|
+
* caches the registry module after first resolve so production runs don't
|
|
138
|
+
* re-require it per call; tests that swap process state (chdir into a
|
|
139
|
+
* sandbox, write a different registry.cjs, etc.) can call this between
|
|
140
|
+
* tests to force reload. Production code never calls this.
|
|
141
|
+
*/
|
|
142
|
+
export function _resetPeerRegistryCache(): void {
|
|
143
|
+
_peerRegistryCache = undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Parse a `delegate_to` value into (peer, role). Returns null when the
|
|
148
|
+
* value is missing, the literal "none" opt-out, or doesn't match the
|
|
149
|
+
* `<peer>-<role>` shape. The session-runner uses this to figure out
|
|
150
|
+
* which role to ask the registry for.
|
|
151
|
+
*
|
|
152
|
+
* Note: validate-frontmatter.ts already enforces the value shape at lint
|
|
153
|
+
* time — by the time a `delegate_to` reaches session-runner it's been
|
|
154
|
+
* validated against the capability matrix. We re-parse here defensively
|
|
155
|
+
* because the runner is consumed by tests that may pass arbitrary
|
|
156
|
+
* strings, and the cost of an extra split is trivial.
|
|
157
|
+
*/
|
|
158
|
+
function parseDelegateTo(value: string | undefined): { peer: string; role: string } | null {
|
|
159
|
+
if (typeof value !== 'string' || value.length === 0) return null;
|
|
160
|
+
if (value === 'none') return null;
|
|
161
|
+
const dashIdx = value.indexOf('-');
|
|
162
|
+
if (dashIdx <= 0 || dashIdx >= value.length - 1) return null;
|
|
163
|
+
return {
|
|
164
|
+
peer: value.slice(0, dashIdx),
|
|
165
|
+
role: value.slice(dashIdx + 1),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Try to dispatch a session via peer-CLI before falling back to the
|
|
171
|
+
* Anthropic SDK. Returns either a fully-built SessionResult on peer
|
|
172
|
+
* success, or null when the caller should continue to the local path.
|
|
173
|
+
*
|
|
174
|
+
* Per CONTEXT D-07 (transparent fallback): every failure path inside
|
|
175
|
+
* this helper returns null — peer-absent, registry-load-failure,
|
|
176
|
+
* adapter-error, dispatch-throw, anything. The local SDK path then
|
|
177
|
+
* runs as if the delegation never happened. Failure is observable
|
|
178
|
+
* only as a placeholder log call (and, once Plan 27-08 wires real
|
|
179
|
+
* events, as a `peer_call_failed` chain entry).
|
|
180
|
+
*/
|
|
181
|
+
async function tryDelegate(args: {
|
|
182
|
+
opts: SessionRunnerOptions;
|
|
183
|
+
sanitizedPrompt: string;
|
|
184
|
+
transcriptPath: string;
|
|
185
|
+
sessionId: string;
|
|
186
|
+
sanitizer: { sanitized: string; applied: readonly string[]; removedSections: readonly string[] };
|
|
187
|
+
}): Promise<SessionResult | null> {
|
|
188
|
+
const { opts, sanitizedPrompt, transcriptPath, sanitizer } = args;
|
|
189
|
+
const parsed = parseDelegateTo(opts.delegateTo);
|
|
190
|
+
if (parsed === null) return null; // not configured / explicit opt-out
|
|
191
|
+
|
|
192
|
+
const role = typeof opts.delegateRole === 'string' && opts.delegateRole.length > 0
|
|
193
|
+
? opts.delegateRole
|
|
194
|
+
: parsed.role;
|
|
195
|
+
const tier = opts.delegateTier === undefined ? null : opts.delegateTier;
|
|
196
|
+
|
|
197
|
+
const dispatcher: PeerRegistry['dispatch'] | null = (() => {
|
|
198
|
+
if (typeof opts.registryOverride === 'function') return opts.registryOverride;
|
|
199
|
+
const reg = loadPeerRegistry();
|
|
200
|
+
return reg !== null ? reg.dispatch : null;
|
|
201
|
+
})();
|
|
202
|
+
if (dispatcher === null) {
|
|
203
|
+
// No registry available at all — fall through to local. Phase 22
|
|
204
|
+
// event emission (Plan 27-08) hooks here as `peer_call_failed`
|
|
205
|
+
// with reason="registry_missing". For now, a placeholder stderr
|
|
206
|
+
// breadcrumb so operators can grep for delegation drops without
|
|
207
|
+
// CI-failing on stdout pollution.
|
|
208
|
+
_logPeerCallFailed({ peer: parsed.peer, role, errorClass: 'registry_missing' });
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let dispatchResult: { result: unknown; peer: string; protocol: 'acp' | 'asp' } | null = null;
|
|
213
|
+
try {
|
|
214
|
+
dispatchResult = await dispatcher(role, tier, sanitizedPrompt, { cwd: process.cwd() });
|
|
215
|
+
} catch (err) {
|
|
216
|
+
_logPeerCallFailed({
|
|
217
|
+
peer: parsed.peer,
|
|
218
|
+
role,
|
|
219
|
+
errorClass: 'dispatch_threw',
|
|
220
|
+
message: err instanceof Error ? err.message : String(err),
|
|
221
|
+
});
|
|
222
|
+
return null; // transparent fallback
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (dispatchResult === null) {
|
|
226
|
+
// Registry returned null — peer absent, capability mismatch, or
|
|
227
|
+
// adapter-side error. Per D-07 we fall back silently.
|
|
228
|
+
_logPeerCallFailed({ peer: parsed.peer, role, errorClass: 'registry_returned_null' });
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Peer succeeded. Build a SessionResult that mirrors the local path's
|
|
233
|
+
// shape so downstream consumers (stage-handlers, transcript readers,
|
|
234
|
+
// tests) treat both paths uniformly. We do NOT write a transcript file
|
|
235
|
+
// for delegated calls in v1.27.0 — the peer broker (Plan 27-03) keeps
|
|
236
|
+
// its own logs and Plan 27-08 wires the events that observers need.
|
|
237
|
+
// The transcript_path field still points at the would-be path so any
|
|
238
|
+
// consumer that probes it sees a stable string (existsSync will be
|
|
239
|
+
// false, which is correct: the file isn't ours to write).
|
|
240
|
+
const finalText = _coerceFinalText(dispatchResult.result);
|
|
241
|
+
return {
|
|
242
|
+
status: 'completed',
|
|
243
|
+
transcript_path: transcriptPath,
|
|
244
|
+
turns: 1,
|
|
245
|
+
usage: { input_tokens: 0, output_tokens: 0, usd_cost: 0 },
|
|
246
|
+
...(finalText !== undefined ? { final_text: finalText } : {}),
|
|
247
|
+
tool_calls: [],
|
|
248
|
+
sanitizer: {
|
|
249
|
+
applied: [...sanitizer.applied],
|
|
250
|
+
removedSections: [...sanitizer.removedSections],
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Best-effort extract a final text string from the adapter's free-form result. */
|
|
256
|
+
function _coerceFinalText(result: unknown): string | undefined {
|
|
257
|
+
if (typeof result === 'string' && result.length > 0) return result;
|
|
258
|
+
if (result !== null && typeof result === 'object') {
|
|
259
|
+
const obj = result as Record<string, unknown>;
|
|
260
|
+
if (typeof obj['final_text'] === 'string' && obj['final_text'].length > 0) {
|
|
261
|
+
return obj['final_text'] as string;
|
|
262
|
+
}
|
|
263
|
+
if (typeof obj['text'] === 'string' && obj['text'].length > 0) {
|
|
264
|
+
return obj['text'] as string;
|
|
265
|
+
}
|
|
266
|
+
if (typeof obj['output'] === 'string' && obj['output'].length > 0) {
|
|
267
|
+
return obj['output'] as string;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Placeholder for Plan 27-08's `peer_call_failed` event. Until 27-08
|
|
275
|
+
* wires real `appendEvent('peer_call_failed', ...)`, we write a single
|
|
276
|
+
* stderr line so operators can grep for silent delegation drops. We
|
|
277
|
+
* deliberately don't go through `appendEvent` here because the Phase 22
|
|
278
|
+
* event-stream hasn't gained a `peer_call_failed` type yet (that's
|
|
279
|
+
* 27-08's job) and pushing an unknown event type today would create a
|
|
280
|
+
* migration mess for the reflector.
|
|
281
|
+
*/
|
|
282
|
+
function _logPeerCallFailed(args: {
|
|
283
|
+
peer: string;
|
|
284
|
+
role: string;
|
|
285
|
+
errorClass: string;
|
|
286
|
+
message?: string;
|
|
287
|
+
}): void {
|
|
288
|
+
// One-line, machine-greppable. Quiet by default in test runs (NODE_ENV)
|
|
289
|
+
// so the test output stays clean. Operators set GDD_PEER_DEBUG=1 to see
|
|
290
|
+
// the breadcrumb in production logs.
|
|
291
|
+
if (process.env['GDD_PEER_DEBUG'] !== '1') return;
|
|
292
|
+
const payload = JSON.stringify({
|
|
293
|
+
type: 'peer_call_failed',
|
|
294
|
+
peer_id: args.peer,
|
|
295
|
+
role: args.role,
|
|
296
|
+
error_class: args.errorClass,
|
|
297
|
+
...(args.message !== undefined ? { message: args.message } : {}),
|
|
298
|
+
ts: new Date().toISOString(),
|
|
299
|
+
});
|
|
300
|
+
// eslint-disable-next-line no-console
|
|
301
|
+
console.error(`[peer-cli] ${payload}`);
|
|
302
|
+
}
|
|
303
|
+
|
|
89
304
|
/** Baseline retry backoff parameters (matches jittered-backoff defaults for
|
|
90
305
|
* the SDK-retry case; 1s base → 30s cap). */
|
|
91
306
|
const RETRY_BACKOFF = { baseMs: 1000, maxMs: 30_000 } as const;
|
|
@@ -118,6 +118,66 @@ export interface SessionRunnerOptions {
|
|
|
118
118
|
applied: readonly string[];
|
|
119
119
|
removedSections: readonly string[];
|
|
120
120
|
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Phase 27 (Plan 27-06) — peer-CLI delegation.
|
|
124
|
+
*
|
|
125
|
+
* Optional. When set to `<peer>-<role>` (e.g. `gemini-research`), the
|
|
126
|
+
* session-runner attempts to dispatch the call to the named peer-CLI
|
|
127
|
+
* via `scripts/lib/peer-cli/registry.cjs#dispatch` BEFORE invoking the
|
|
128
|
+
* local Anthropic SDK. The peer's response, when successful, becomes
|
|
129
|
+
* the SessionResult — no SDK call is made.
|
|
130
|
+
*
|
|
131
|
+
* Fallback (CONTEXT D-07): if the registry returns `null` (peer
|
|
132
|
+
* absent / opt-out / adapter error / dispatch error) OR throws, the
|
|
133
|
+
* session-runner silently retries with the local Anthropic SDK. The
|
|
134
|
+
* caller never sees the peer failure — failure is a measurement
|
|
135
|
+
* signal, not a cycle-breaker.
|
|
136
|
+
*
|
|
137
|
+
* Special values:
|
|
138
|
+
* - `none` → explicit opt-out; never delegate. Same as omitting the field.
|
|
139
|
+
* - undefined → default behavior; never delegate.
|
|
140
|
+
*
|
|
141
|
+
* The session-runner never reads agent frontmatter on its own. Callers
|
|
142
|
+
* (pipeline-runner, explore, discuss, etc.) are responsible for
|
|
143
|
+
* resolving the agent's `delegate_to:` frontmatter and passing it
|
|
144
|
+
* through this option.
|
|
145
|
+
*/
|
|
146
|
+
delegateTo?: string;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Phase 27 (Plan 27-06) — role hint for peer-CLI dispatch.
|
|
150
|
+
*
|
|
151
|
+
* Used only when `delegateTo` is set. Defaults to the role parsed out
|
|
152
|
+
* of `delegateTo` (e.g. `delegateTo: "gemini-research"` → role
|
|
153
|
+
* `"research"`). Provide explicitly when the caller wants to override
|
|
154
|
+
* the parsed value (rare).
|
|
155
|
+
*/
|
|
156
|
+
delegateRole?: string;
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Phase 27 (Plan 27-06) — tier hint for peer-CLI dispatch.
|
|
160
|
+
*
|
|
161
|
+
* Currently advisory; the registry's capability matrix doesn't gate
|
|
162
|
+
* on tier. Used by adapters for telemetry and by Plan 27-08 events.
|
|
163
|
+
* Defaults to null (let the adapter pick).
|
|
164
|
+
*/
|
|
165
|
+
delegateTier?: string | null;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Phase 27 (Plan 27-06) — registry override for tests.
|
|
169
|
+
*
|
|
170
|
+
* Default loads `scripts/lib/peer-cli/registry.cjs` lazily on first
|
|
171
|
+
* delegation attempt. Tests inject a stub `dispatch()` to avoid
|
|
172
|
+
* spawning real peers. The override mirrors the registry's `dispatch`
|
|
173
|
+
* signature: `(role, tier, text, opts) => Promise<{result,peer,protocol} | null>`.
|
|
174
|
+
*/
|
|
175
|
+
registryOverride?: (
|
|
176
|
+
role: string,
|
|
177
|
+
tier: string | null,
|
|
178
|
+
text: string,
|
|
179
|
+
opts: { cwd?: string; [k: string]: unknown },
|
|
180
|
+
) => Promise<{ result: unknown; peer: string; protocol: 'acp' | 'asp' } | null>;
|
|
121
181
|
}
|
|
122
182
|
|
|
123
183
|
/**
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// scripts/lib/tier-resolver.cjs
|
|
2
|
+
//
|
|
3
|
+
// Plan 26-02 — tier→model resolver with fallback chain.
|
|
4
|
+
//
|
|
5
|
+
// `resolve(runtime, tier, opts?) → model-string | null`
|
|
6
|
+
//
|
|
7
|
+
// Translates the tier vocabulary frontmatter speaks (`opus`, `sonnet`,
|
|
8
|
+
// `haiku`) into the concrete model name a specific runtime understands
|
|
9
|
+
// (e.g. `gpt-5`, `gemini-2.5-pro`, `qwen3-max`). Source-of-truth for the
|
|
10
|
+
// mapping is `reference/runtime-models.md` (plan 26-01); this module
|
|
11
|
+
// reads the parsed form via 26-01's parser helper at
|
|
12
|
+
// `scripts/lib/install/parse-runtime-models.cjs`.
|
|
13
|
+
//
|
|
14
|
+
// Parsed-models shape (from 26-01):
|
|
15
|
+
// {
|
|
16
|
+
// schema_version: 1,
|
|
17
|
+
// runtimes: [
|
|
18
|
+
// { id: 'claude',
|
|
19
|
+
// tier_to_model: { opus: { model: 'claude-opus-4-7' }, … },
|
|
20
|
+
// reasoning_class_to_model: { high: { model: '…' }, … },
|
|
21
|
+
// provenance: [...]
|
|
22
|
+
// },
|
|
23
|
+
// …
|
|
24
|
+
// ]
|
|
25
|
+
// }
|
|
26
|
+
//
|
|
27
|
+
// Fallback chain (D-04):
|
|
28
|
+
// 1. runtime-specific entry has the tier → use directly (no event).
|
|
29
|
+
// 2. runtime row missing OR tier missing on the row → fall back to the
|
|
30
|
+
// `claude` row (Anthropic-default convention 26-01 baked into every
|
|
31
|
+
// placeholder runtime), emit `tier_resolution_fallback`.
|
|
32
|
+
// 3. neither available (e.g. a parsed map with no claude row, or a
|
|
33
|
+
// claude row missing the requested tier) → return null, emit
|
|
34
|
+
// `tier_resolution_failed`.
|
|
35
|
+
//
|
|
36
|
+
// Never throws. null is a valid output the caller (router, budget-
|
|
37
|
+
// enforcer) must handle gracefully. Garbage input (undefined runtime,
|
|
38
|
+
// bogus tier, malformed models) returns null + failure event.
|
|
39
|
+
//
|
|
40
|
+
// `.cjs` to match Phase 22 primitives and let .ts hooks require it
|
|
41
|
+
// under --experimental-strip-types without ESM-interop friction.
|
|
42
|
+
//
|
|
43
|
+
// Pure module — no top-level side effects beyond reading the parsed
|
|
44
|
+
// runtime-models document on first call. The parsed form is cached per-
|
|
45
|
+
// process; callers that need a fresh read between cycles call `reset()`.
|
|
46
|
+
//
|
|
47
|
+
// Test-injection contract: callers may pass `opts.models` to bypass the
|
|
48
|
+
// on-disk lookup entirely. Used by `tests/tier-resolver.test.cjs` to
|
|
49
|
+
// exercise the fallback branches deterministically.
|
|
50
|
+
|
|
51
|
+
'use strict';
|
|
52
|
+
|
|
53
|
+
const fs = require('node:fs');
|
|
54
|
+
const path = require('node:path');
|
|
55
|
+
|
|
56
|
+
const VALID_TIERS = Object.freeze(['opus', 'sonnet', 'haiku']);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Runtime-id whose row supplies the fallback for missing entries.
|
|
60
|
+
* 26-01's runtime-models.md uses Anthropic models as the closest-
|
|
61
|
+
* published-equivalent placeholder for every runtime that lacks a
|
|
62
|
+
* confirmed tier-map; that convention makes `claude` the natural
|
|
63
|
+
* D-04-branch-2 default. If 26-01 ever changes that convention,
|
|
64
|
+
* update this constant in lockstep.
|
|
65
|
+
*/
|
|
66
|
+
const DEFAULT_RUNTIME_ID = 'claude';
|
|
67
|
+
|
|
68
|
+
const DEFAULT_EVENTS_PATH = path.join('.design', 'telemetry', 'events.jsonl');
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Cached parsed-models data. `null` until first lazy load (or after
|
|
72
|
+
* `reset()`).
|
|
73
|
+
*/
|
|
74
|
+
let _cachedModels = null;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Lazy soft-import of the 26-01 parser. Returns null if the parser
|
|
78
|
+
* file is unreachable — the resolver then degrades to "always emit
|
|
79
|
+
* failed" for on-disk callers, while test callers using `opts.models`
|
|
80
|
+
* are unaffected.
|
|
81
|
+
*/
|
|
82
|
+
function loadParser() {
|
|
83
|
+
try {
|
|
84
|
+
const modPath = path.join(__dirname, 'install', 'parse-runtime-models.cjs');
|
|
85
|
+
if (!fs.existsSync(modPath)) return null;
|
|
86
|
+
return require(modPath);
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Lazy load + cache the parsed runtime-models map. Returns null when
|
|
94
|
+
* the parser is unavailable or throws on the source markdown.
|
|
95
|
+
*/
|
|
96
|
+
function loadModels() {
|
|
97
|
+
if (_cachedModels !== null) return _cachedModels;
|
|
98
|
+
const parser = loadParser();
|
|
99
|
+
if (parser === null) return null;
|
|
100
|
+
try {
|
|
101
|
+
const fn = typeof parser.parseRuntimeModels === 'function'
|
|
102
|
+
? parser.parseRuntimeModels
|
|
103
|
+
: (typeof parser === 'function' ? parser : null);
|
|
104
|
+
if (fn === null) return null;
|
|
105
|
+
const out = fn();
|
|
106
|
+
if (out && typeof out === 'object') {
|
|
107
|
+
_cachedModels = out;
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
} catch {
|
|
112
|
+
// Parser throws on schema validation failure — treat as
|
|
113
|
+
// "no usable models" so the resolver fails open with events
|
|
114
|
+
// rather than crashing the consumer.
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Reset the parsed-models cache. Tests use this after writing fixture
|
|
121
|
+
* runtime-models.md to a temp cwd; production callers rarely need it.
|
|
122
|
+
*/
|
|
123
|
+
function reset() {
|
|
124
|
+
_cachedModels = null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Append a single event line to the on-disk events.jsonl. Honors
|
|
129
|
+
* `GDD_EVENTS_PATH` for test isolation (matches the TS EventWriter's
|
|
130
|
+
* env-var contract). Never throws — diagnostic on stderr only.
|
|
131
|
+
*
|
|
132
|
+
* We don't `require` the .ts EventWriter from .cjs (would force every
|
|
133
|
+
* consumer to run under --experimental-strip-types); instead we write
|
|
134
|
+
* the same JSONL line shape directly. The envelope matches BaseEvent
|
|
135
|
+
* so downstream consumers don't care which producer wrote the line.
|
|
136
|
+
*/
|
|
137
|
+
function emitEvent(type, payload) {
|
|
138
|
+
const line = JSON.stringify({
|
|
139
|
+
type,
|
|
140
|
+
timestamp: new Date().toISOString(),
|
|
141
|
+
sessionId: process.env.GDD_SESSION_ID || 'tier-resolver',
|
|
142
|
+
payload,
|
|
143
|
+
_meta: {
|
|
144
|
+
pid: process.pid,
|
|
145
|
+
host: 'tier-resolver',
|
|
146
|
+
source: 'tier-resolver',
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
const envPath = process.env.GDD_EVENTS_PATH;
|
|
150
|
+
const target = envPath && envPath.length > 0
|
|
151
|
+
? envPath
|
|
152
|
+
: path.join(process.cwd(), DEFAULT_EVENTS_PATH);
|
|
153
|
+
try {
|
|
154
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
155
|
+
fs.appendFileSync(target, line + '\n', { encoding: 'utf8' });
|
|
156
|
+
} catch (err) {
|
|
157
|
+
// Don't let event-emission failure cascade into resolver failure;
|
|
158
|
+
// the resolver's job is to return a model (or null), not to
|
|
159
|
+
// guarantee telemetry. The event-stream has its own resilience
|
|
160
|
+
// story (Phase 20-14 / Phase 22).
|
|
161
|
+
try {
|
|
162
|
+
process.stderr.write(
|
|
163
|
+
`[tier-resolver] event emit failed: ${err && err.message ? err.message : String(err)}\n`,
|
|
164
|
+
);
|
|
165
|
+
} catch {
|
|
166
|
+
/* swallow */
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Find a runtime row by id. Accepts both the 26-01 array shape
|
|
173
|
+
* (`runtimes: [{id, …}, …]`) and a plain-object map shape
|
|
174
|
+
* (`runtimes: {id: {…}}`) used by some test fixtures. Returns the row
|
|
175
|
+
* or null when not found / malformed.
|
|
176
|
+
*/
|
|
177
|
+
function findRuntimeRow(models, id) {
|
|
178
|
+
if (!models || typeof models !== 'object') return null;
|
|
179
|
+
const r = models.runtimes;
|
|
180
|
+
if (Array.isArray(r)) {
|
|
181
|
+
for (const row of r) {
|
|
182
|
+
if (row && typeof row === 'object' && row.id === id) return row;
|
|
183
|
+
}
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
if (r && typeof r === 'object') {
|
|
187
|
+
const row = r[id];
|
|
188
|
+
return row && typeof row === 'object' ? row : null;
|
|
189
|
+
}
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Read the model string for `tier` from a runtime row. The 26-01
|
|
195
|
+
* shape nests one level: `tier_to_model.opus = { model: '…' }`. A
|
|
196
|
+
* flat shape (`tier_to_model.opus = '…'`) is also accepted to keep
|
|
197
|
+
* test fixtures terse. Returns the model string or null when absent
|
|
198
|
+
* or malformed.
|
|
199
|
+
*/
|
|
200
|
+
function lookupTier(row, tier) {
|
|
201
|
+
if (!row || typeof row !== 'object') return null;
|
|
202
|
+
const map = row.tier_to_model;
|
|
203
|
+
if (!map || typeof map !== 'object') return null;
|
|
204
|
+
const v = map[tier];
|
|
205
|
+
if (typeof v === 'string' && v.length > 0) return v;
|
|
206
|
+
if (v && typeof v === 'object' && typeof v.model === 'string' && v.model.length > 0) {
|
|
207
|
+
return v.model;
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Resolve a `(runtime, tier)` pair to a concrete model string. Returns
|
|
214
|
+
* null when neither the runtime-specific entry nor the runtime-default
|
|
215
|
+
* fallback supplies a value for the tier; emits a structured event in
|
|
216
|
+
* both the fallback and failure branches.
|
|
217
|
+
*
|
|
218
|
+
* @param {string | null | undefined} runtime
|
|
219
|
+
* Runtime ID (e.g. 'claude', 'codex'). Garbage input returns null +
|
|
220
|
+
* failure event.
|
|
221
|
+
* @param {string | null | undefined} tier
|
|
222
|
+
* Tier name. Must be one of `opus`/`sonnet`/`haiku`. Anything else
|
|
223
|
+
* returns null + failure event.
|
|
224
|
+
* @param {object} [opts]
|
|
225
|
+
* @param {object} [opts.models]
|
|
226
|
+
* Pre-parsed models map. When supplied, bypasses the on-disk lookup
|
|
227
|
+
* entirely (tests use this).
|
|
228
|
+
* @param {boolean} [opts.silent]
|
|
229
|
+
* When true, suppresses event emission on the fallback / failure
|
|
230
|
+
* paths. Used by callers that batch-resolve and prefer to roll up
|
|
231
|
+
* their own diagnostics. Default false.
|
|
232
|
+
* @returns {string | null}
|
|
233
|
+
*/
|
|
234
|
+
function resolve(runtime, tier, opts) {
|
|
235
|
+
const models = (opts && opts.models) || loadModels();
|
|
236
|
+
const silent = !!(opts && opts.silent);
|
|
237
|
+
|
|
238
|
+
// Validate inputs FIRST so the failure event payload carries the
|
|
239
|
+
// garbage values verbatim — useful for telemetry diagnosis.
|
|
240
|
+
const runtimeOk = typeof runtime === 'string' && runtime.length > 0;
|
|
241
|
+
const tierOk = typeof tier === 'string' && VALID_TIERS.indexOf(tier) >= 0;
|
|
242
|
+
|
|
243
|
+
if (!runtimeOk || !tierOk || !models || typeof models !== 'object') {
|
|
244
|
+
if (!silent) {
|
|
245
|
+
emitEvent('tier_resolution_failed', {
|
|
246
|
+
runtime: runtimeOk ? runtime : (runtime === undefined ? null : runtime),
|
|
247
|
+
tier: tierOk ? tier : (tier === undefined ? null : tier),
|
|
248
|
+
reason: !runtimeOk
|
|
249
|
+
? 'invalid_runtime'
|
|
250
|
+
: !tierOk
|
|
251
|
+
? 'invalid_tier'
|
|
252
|
+
: 'models_unavailable',
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const row = findRuntimeRow(models, runtime);
|
|
259
|
+
|
|
260
|
+
// Branch 1: runtime-specific hit.
|
|
261
|
+
const direct = lookupTier(row, tier);
|
|
262
|
+
if (direct !== null) return direct;
|
|
263
|
+
|
|
264
|
+
// Branch 2: fall back to the default-runtime row. 26-01 inlines
|
|
265
|
+
// Anthropic-default models on every placeholder runtime, so this
|
|
266
|
+
// branch primarily catches "runtime id not in the 14-runtime map"
|
|
267
|
+
// and "claude row itself missing the tier" — the latter being
|
|
268
|
+
// structurally near-impossible if 26-01's schema validation is on,
|
|
269
|
+
// but we still handle it.
|
|
270
|
+
const defaultRow = findRuntimeRow(models, DEFAULT_RUNTIME_ID);
|
|
271
|
+
// Don't double-fall-back if the runtime IS the default and we
|
|
272
|
+
// already missed the tier — that's a true failure.
|
|
273
|
+
const fallbackModel = runtime === DEFAULT_RUNTIME_ID
|
|
274
|
+
? null
|
|
275
|
+
: lookupTier(defaultRow, tier);
|
|
276
|
+
if (fallbackModel !== null) {
|
|
277
|
+
if (!silent) {
|
|
278
|
+
emitEvent('tier_resolution_fallback', {
|
|
279
|
+
runtime,
|
|
280
|
+
tier,
|
|
281
|
+
model: fallbackModel,
|
|
282
|
+
reason: row === null ? 'runtime_not_in_map' : 'tier_missing_for_runtime',
|
|
283
|
+
fallback_runtime: DEFAULT_RUNTIME_ID,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
return fallbackModel;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Branch 3: nothing usable.
|
|
290
|
+
if (!silent) {
|
|
291
|
+
emitEvent('tier_resolution_failed', {
|
|
292
|
+
runtime,
|
|
293
|
+
tier,
|
|
294
|
+
reason: row === null
|
|
295
|
+
? 'runtime_not_in_map'
|
|
296
|
+
: (runtime === DEFAULT_RUNTIME_ID
|
|
297
|
+
? 'tier_missing_on_default_runtime'
|
|
298
|
+
: 'tier_missing_no_default'),
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
module.exports = {
|
|
305
|
+
resolve,
|
|
306
|
+
reset,
|
|
307
|
+
VALID_TIERS,
|
|
308
|
+
DEFAULT_RUNTIME_ID,
|
|
309
|
+
// internals surfaced for tests only — stable API = `resolve` + `reset`.
|
|
310
|
+
_internal: { lookupTier, findRuntimeRow, emitEvent, loadParser, loadModels },
|
|
311
|
+
};
|