From cf70d8251a9e3e17440603676de80ee687becc8b Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 12 Jun 2026 19:18:09 -0400 Subject: [PATCH] chore: merge release v1.4.5 to main (#1142) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: bump version to 1.4.5 for hotfix * fix(#1133): honor model_policy on the claude runtime via alias mapping model_policy (including the anthropic-fable Claude Fable 5 preset) was silently ignored on the default `claude` runtime: resolveModelInternal gated the policy branch on `runtime !== 'claude'`, so agents kept resolving from model_profile and no warning was emitted. Wire model_policy into the claude path: map the policy-resolved model ID back to a Claude Code agent alias (claude-fable-5 -> fable, claude-opus-4-8 -> opus, etc.). A value that is already a bare alias is returned as-is; an ID with no Claude alias warns once to stderr and falls back to the configured tier alias rather than emitting an unspawnable ID. Non-claude runtimes return full IDs verbatim (unchanged) and resolveModelForTier precedence is unchanged. Authored against the v1.4.4 base (src/core.cts) for the 1.4.5 hotfix line — NOT next, where this logic lives in src/model-resolver.cts; the forward-port to next is a separate follow-up. Closes #1133 Co-Authored-By: Claude Opus 4.8 * chore: promote CHANGELOG for v1.4.5 --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 --- .claude-plugin/plugin.json | 2 +- CHANGELOG.md | 6 ++ docs/CONFIGURATION.md | 2 +- gemini-extension.json | 2 +- gsd-core/workflows/settings-advanced.md | 2 + package-lock.json | 4 +- package.json | 2 +- src/core.cts | 49 +++++++++++-- tests/feat-49-model-policy-presets.test.cjs | 77 +++++++++++++++------ 9 files changed, 116 insertions(+), 30 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 1e4e7e869..c74270839 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "gsd-core", "displayName": "GSD Core", - "version": "1.4.4", + "version": "1.4.5", "description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.", "author": { "name": "open-gsd", diff --git a/CHANGELOG.md b/CHANGELOG.md index 0151004ad..72ed80a19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [1.4.5] - 2026-06-12 + +### Fixed + +- **`model_policy` is now honored on the default `claude` runtime** — including the `anthropic-fable` Claude Fable 5 preset. Policy-resolved model IDs map to Claude Code agent aliases (e.g. `claude-fable-5` → `fable`), and IDs without a Claude alias warn and fall back to the configured tier. Previously the entire `model_policy` block was silently ignored on `claude`. (#1133) (#1133) + ## [1.4.4] - 2026-06-11 ### Changed diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 578e4ff52..9c81729dc 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1284,7 +1284,7 @@ Choose a provider and budget level via the settings workflow; GSD writes the can } ``` -Known providers: `openai`, `anthropic`, `anthropic-fable`, `google`, `qwen`. Budget levels: `high`, `medium`, `low`. Use `anthropic` to keep the Opus 4.8-backed Claude preset, or `anthropic-fable` to opt into Claude Fable 5 for high-budget top-tier routing. +Known providers: `openai`, `anthropic`, `anthropic-fable`, `google`, `qwen`. Budget levels: `high`, `medium`, `low`. Use `anthropic` to keep the Opus 4.8-backed Claude preset, or `anthropic-fable` to opt into Claude Fable 5 for high-budget top-tier routing. On the default `claude` runtime, policy-resolved model IDs are mapped to Claude Code agent aliases (for example `claude-fable-5` → `fable`); an ID with no corresponding Claude alias emits a warning and falls back to the configured tier. For advanced per-runtime control, `runtime_tiers` accepts explicit entries using the internal profile tier names (`opus`, `sonnet`, `haiku`): diff --git a/gemini-extension.json b/gemini-extension.json index 7bf0294af..0bf515ffc 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,6 +1,6 @@ { "name": "gsd-core", - "version": "1.4.4", + "version": "1.4.5", "description": "GSD Core — a meta-prompting, context engineering, and spec-driven development system for AI coding agents. Loads gsd's operating context into every Gemini CLI session.", "contextFileName": "GEMINI.md" } diff --git a/gsd-core/workflows/settings-advanced.md b/gsd-core/workflows/settings-advanced.md index 503daf243..9aedc380e 100644 --- a/gsd-core/workflows/settings-advanced.md +++ b/gsd-core/workflows/settings-advanced.md @@ -672,6 +672,8 @@ Canonical tier mappings by provider and budget: Look up the selected (provider, budget) row and proceed to Step E to write those values. +> **claude runtime note:** On the default `claude` runtime, policy-resolved model IDs (e.g. `claude-fable-5`) are mapped to Claude Code agent aliases (`fable`, `opus`, `sonnet`, `haiku`); an ID with no corresponding alias emits a stderr warning and falls back to the configured tier alias. + **Step D — Generic-provider path:** Prompt the user to enter each model ID as a free-text input. An empty input means "keep diff --git a/package-lock.json b/package-lock.json index 0ee8d6eb6..01b4a72ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@opengsd/gsd-core", - "version": "1.4.4", + "version": "1.4.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@opengsd/gsd-core", - "version": "1.4.4", + "version": "1.4.5", "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.84", diff --git a/package.json b/package.json index 8c2a096ed..75782453d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@opengsd/gsd-core", - "version": "1.4.4", + "version": "1.4.5", "description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.", "bin": { "gsd-core": "bin/install.js", diff --git a/src/core.cts b/src/core.cts index de5c2f1c9..3ff234b9f 100644 --- a/src/core.cts +++ b/src/core.cts @@ -1426,6 +1426,7 @@ function _warnUnknownProfileOverrides(parsed: Record, configLab // between cases that intentionally exercise the warning path repeatedly. function _resetRuntimeWarningCacheForTests(): void { _warnedConfigKeys.clear(); + _modelPolicyUnmappableWarned.clear(); } interface TierEntryResolved { @@ -1471,6 +1472,31 @@ function _resolveRuntimeTier(config: Record, tier: string): Tie }); } +// Reverse of the Claude tier-default IDs, plus the Fable alias which Claude +// Code's Agent tool accepts but which is not a GSD model-profile tier (#1133). +const CLAUDE_POLICY_ID_TO_ALIAS: Record = { + ...Object.fromEntries( + Object.entries(MODEL_ALIAS_MAP) + .filter((e): e is [string, string] => typeof e[1] === 'string') + .map(([aliasName, id]) => [id, aliasName]), + ), + 'claude-fable-5': 'fable', +}; +const CLAUDE_AGENT_ALIASES = new Set(['opus', 'sonnet', 'haiku', 'fable']); + +// Dedupe stderr warnings so repeated agent resolutions don't spam (#1133). +const _modelPolicyUnmappableWarned = new Set(); +function warnModelPolicyUnmappable(agentType: string, policyModel: string, tier: string): void { + const key = `${agentType}::${policyModel}::${tier}`; + if (_modelPolicyUnmappableWarned.has(key)) return; + _modelPolicyUnmappableWarned.add(key); + // MUST go to stderr — resolve-model's JSON result is parsed from stdout. + process.stderr.write( + `gsd: warning — model_policy resolved "${policyModel}" for ${agentType}, ` + + `but it has no Claude agent alias; using "${tier}" instead.\n`, + ); +} + /** * #49 — Provider-neutral model policy preset resolution. */ @@ -1548,14 +1574,29 @@ function resolveModelInternal(cwd: string, agentType: string): string { ? 'inherit' : (agentModels ? (agentModels[profile] || agentModels['balanced']) : null)); - // 2.5. model_policy preset (#49) + // 2.5. model_policy preset (#49, #1133) const configRuntime = config['runtime'] as string | null | undefined; - if (configRuntime && configRuntime !== 'claude' && tier && tier !== 'inherit') { + if (tier && tier !== 'inherit') { + const onClaude = !configRuntime || configRuntime === 'claude'; + const effectiveRuntime = configRuntime || 'claude'; const mergedPolicy = config['model_policy'] - ? { ...(config['model_policy'] as Record), runtime: configRuntime } + ? { ...(config['model_policy'] as Record), runtime: effectiveRuntime } : null; const policyModel = resolveModelPolicy(mergedPolicy, tier); - if (policyModel) return policyModel; + if (policyModel) { + // Non-Claude runtimes take full model IDs verbatim (unchanged behavior). + if (!onClaude) return policyModel; + // Claude Code's Agent tool takes tier aliases (opus/sonnet/haiku/fable), + // not full model IDs — map the policy-resolved ID back to an alias (#1133). + const aliasForId = CLAUDE_POLICY_ID_TO_ALIAS[policyModel]; + if (aliasForId) return aliasForId; + // The policy value may already be a bare Claude agent alias (e.g. "fable"). + if (CLAUDE_AGENT_ALIASES.has(policyModel)) return policyModel; + // No Claude alias for this ID (e.g. a pinned minor version like + // claude-opus-4-5). Warn once and fall through to the tier alias rather + // than returning an ID Claude Code cannot spawn. + warnModelPolicyUnmappable(agentType, policyModel, tier); + } } // 3. Runtime-aware resolution (#2517) diff --git a/tests/feat-49-model-policy-presets.test.cjs b/tests/feat-49-model-policy-presets.test.cjs index c0c3e9b28..4e3e23371 100644 --- a/tests/feat-49-model-policy-presets.test.cjs +++ b/tests/feat-49-model-policy-presets.test.cjs @@ -376,34 +376,57 @@ describe('#49 resolveModelInternal: model_policy in the resolution chain', () => 'runtime_tiers must not fire when config.runtime is absent'); }); - test('model_policy is skipped when runtime:"claude" (no-op gate)', () => { - // runtime:"claude" is the implicit default and is treated as a no-op - // for model_policy resolution (same as the no-op gate in the existing - // runtime-aware resolution step). model_policy provider preset - // for anthropic may still fire — but runtime_tiers for claude is a no-op - // because claude-native resolution already handles that path. + test('model_policy provider preset resolves to a Claude alias on runtime:"claude" (#1133)', () => { writeConfig(projectDir, { runtime: 'claude', - model_profile: 'quality', + model_profile: 'balanced', + model_policy: { provider: 'anthropic-fable', budget: 'high' }, + }); + // gsd-planner -> opus tier; anthropic-fable opus/high = claude-fable-5 -> alias "fable" + assert.strictEqual(resolveModelInternal(projectDir, 'gsd-planner'), 'fable'); + }); + + test('model_policy works with implicit claude runtime (no runtime key) (#1133)', () => { + writeConfig(projectDir, { + model_profile: 'balanced', + model_policy: { provider: 'anthropic-fable', budget: 'high' }, + }); + // gsd-executor -> sonnet tier; anthropic-fable sonnet/high = claude-fable-5 -> "fable" + assert.strictEqual(resolveModelInternal(projectDir, 'gsd-executor'), 'fable'); + }); + + test('unmappable model_policy ID warns and falls back to the tier alias on claude (#1133)', () => { + _resetRuntimeWarningCacheForTests(); + writeConfig(projectDir, { + runtime: 'claude', + model_profile: 'balanced', + model_policy: { provider: 'anthropic-fable', budget: 'low' }, + }); + // gsd-planner -> opus tier; anthropic-fable opus/low = claude-opus-4-5 (no alias) -> fall back to "opus" + assert.strictEqual(resolveModelInternal(projectDir, 'gsd-planner'), 'opus'); + }); + + test('model_policy.runtime_tiers applies on runtime:"claude", mapped to alias (#1133)', () => { + writeConfig(projectDir, { + runtime: 'claude', + model_profile: 'balanced', model_policy: { provider: 'anthropic', budget: 'high', - runtime_tiers: { - claude: { - opus: { model: 'claude-runtime-tiers-should-not-appear' }, - }, - }, + runtime_tiers: { claude: { opus: { model: 'claude-fable-5' } } }, }, }); - let result; - assert.doesNotThrow(() => { - result = resolveModelInternal(projectDir, 'gsd-planner'); + // gsd-planner -> opus tier; runtime_tiers.claude.opus = claude-fable-5 -> "fable" (was a no-op pre-#1133) + assert.strictEqual(resolveModelInternal(projectDir, 'gsd-planner'), 'fable'); + }); + + test('model_policy still returns full IDs on non-claude runtimes (#1133 regression)', () => { + writeConfig(projectDir, { + runtime: 'opencode', + model_profile: 'balanced', + model_policy: { provider: 'anthropic-fable', budget: 'high' }, }); - assert.ok(typeof result === 'string', 'must return a string'); - // The claude runtime_tiers entry must not appear — model_policy runtime_tiers - // is a no-op for runtime:"claude" - assert.notStrictEqual(result, 'claude-runtime-tiers-should-not-appear', - 'model_policy.runtime_tiers must be a no-op when runtime is "claude"'); + assert.strictEqual(resolveModelInternal(projectDir, 'gsd-planner'), 'claude-fable-5'); }); test('model_policy is skipped when tier:"inherit"', () => { @@ -817,4 +840,18 @@ describe('#49 resolveModelForTier: model_policy beats dynamic_routing', () => { }); assert.strictEqual(resolveModelForTier(tmpDir, 'gsd-executor', 0), 'my-sonnet'); }); + + test('model_policy value that is already a bare Claude alias is returned as-is on claude (#1133)', () => { + writeConfig(tmpDir, { + runtime: 'claude', + model_profile: 'balanced', + model_policy: { + provider: 'anthropic', + budget: 'high', + runtime_tiers: { claude: { opus: { model: 'fable' } } }, + }, + }); + // gsd-planner → opus tier; runtime_tiers.claude.opus = "fable" is already a valid alias → "fable" + assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-planner'), 'fable'); + }); });