diff --git a/.changeset/fix-3229-model-catalog-source-of-truth.md b/.changeset/fix-3229-model-catalog-source-of-truth.md new file mode 100644 index 000000000..3faebc43f --- /dev/null +++ b/.changeset/fix-3229-model-catalog-source-of-truth.md @@ -0,0 +1,15 @@ +--- +type: Fixed +pr: 3230 +--- +**`resolve-model` no longer drifts between SDK and CLI/CJS** — model-selection data now comes from a shared Model Catalog Module (`sdk/shared/model-catalog.json`) that both the SDK and the main CLI package consume. This fixes the #3229 class of bug where the SDK knew only 18 agents while 33 shipped agents existed on disk, causing `resolve-model` to silently return `{ unknown_agent: true, model: "sonnet" }` for valid agents like `gsd-code-reviewer` and `gsd-security-auditor`. + +The shared catalog now owns: +- the full 33-agent registry +- per-agent golden/quality alias plus balanced/budget aliases +- adaptive routing derivation from `routingTier` +- agent → phase-type map +- agent → dynamic-routing default tier map +- runtime tier defaults for all supported runtimes (`claude`, `codex`, `gemini`, `qwen`, `opencode`, `copilot`, `hermes`, plus Group B runtimes with no built-in defaults) + +`resolve-model` unknown-agent fallback is also now profile-semantic instead of hardcoded `sonnet`: `quality → opus`, `budget → haiku`, `balanced/adaptive → sonnet`, `inherit → inherit`. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 5294ba768..bb29495fa 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -905,8 +905,14 @@ When `runtime` is set, profile tiers (`opus`/`sonnet`/`haiku`) resolve to runtim | Runtime | `opus` | `sonnet` | `haiku` | reasoning_effort | |---------|--------|----------|---------|------------------| -| `claude` | `claude-opus-4-6` | `claude-sonnet-4-6` | `claude-haiku-4-5` | (not used) | +| `claude` | `claude-opus-4-7` | `claude-sonnet-4-6` | `claude-haiku-4-5` | (not used) | | `codex` | `gpt-5.4` | `gpt-5.3-codex` | `gpt-5.4-mini` | `xhigh` / `medium` / `medium` | +| `gemini` | `gemini-3-pro` | `gemini-3-flash` | `gemini-2.5-flash-lite` | (not used) | +| `qwen` | `qwen3-max-2026-01-23` | `qwen3-coder-plus` | `qwen3-coder-next` | (not used) | +| `opencode` | `anthropic/claude-opus-4-7` | `anthropic/claude-sonnet-4-6` | `anthropic/claude-haiku-4-5` | (not used) | +| `copilot` | `claude-opus-4-7` | `claude-sonnet-4-6` | `claude-haiku-4-5` | (not used) | +| `hermes` | `anthropic/claude-opus-4-7` | `anthropic/claude-sonnet-4-6` | `anthropic/claude-haiku-4-5` | (not used) | +| Group B (`kilo`, `cline`, `cursor`, `windsurf`, `augment`, `trae`, `codebuddy`, `antigravity`) | (no built-in default — your runtime handles model selection) | | | | **Codex example** — one config, tiered models, no large `model_overrides` block: diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 9e3e7ea6f..364d84e0a 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -1,5 +1,5 @@ { - "generated": "2026-05-06", + "generated": "2026-05-07", "families": { "agents": [ "gsd-advisor-researcher", @@ -277,6 +277,7 @@ "intel.cjs", "learnings.cjs", "milestone.cjs", + "model-catalog.cjs", "model-profiles.cjs", "phase-command-router.cjs", "phase.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 5652d8c1e..cc58a9a89 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -358,7 +358,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t --- -## CLI Modules (42 shipped) +## CLI Modules (43 shipped) Full listing: `get-shit-done/bin/lib/*.cjs`. @@ -385,7 +385,8 @@ Full listing: `get-shit-done/bin/lib/*.cjs`. | `intel.cjs` | Codebase intel store backing `/gsd-map-codebase --query` and `gsd-intel-updater` | | `learnings.cjs` | Cross-phase learnings extraction for `/gsd-extract-learnings` | | `milestone.cjs` | Milestone archival, requirements marking | -| `model-profiles.cjs` | Model profile resolution table (authoritative profile data) | +| `model-catalog.cjs` | CJS adapter over the shared model catalog JSON; exports canonical runtime tier defaults, agent profile maps, alias maps, and routing metadata for all CLI consumers | +| `model-profiles.cjs` | Backward-compatible profile helpers derived from `model-catalog.cjs`; no longer owns its own model table | | `phase-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phase` | | `phase.cjs` | Phase directory operations, decimal numbering, plan indexing | | `phases-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phases` | diff --git a/docs/adr/0003-model-catalog-module.md b/docs/adr/0003-model-catalog-module.md new file mode 100644 index 000000000..7e9d94016 --- /dev/null +++ b/docs/adr/0003-model-catalog-module.md @@ -0,0 +1,52 @@ +# Model Catalog Module as single source of truth for agent profiles and runtime tier defaults + +- **Status:** Accepted +- **Date:** 2026-05-07 + +We decided to centralize model-selection data in one Model Catalog Module so the SDK, the CLI/CJS layer, and the docs do not maintain separate agent lists, profile maps, or runtime tier defaults. + +## Problem + +Before this ADR there were four drifting sources: + +1. `get-shit-done/bin/lib/model-profiles.cjs` — agent → profile alias map, phase-type map, dynamic-routing default tiers +2. `sdk/src/query/config-query.ts` — stale 18-agent copy of `MODEL_PROFILES` +3. `get-shit-done/workflows/settings-advanced.md` — runtime → built-in model-id table +4. `sdk/src/session-runner.ts` — hardcoded Claude-only profile → model-id map + +This caused issue #3229: the SDK knew only 18 agents while 33 agent files existed on disk, so ~15 agents silently fell back to Sonnet with `unknown_agent: true`. + +## Decision + +Create one machine-readable catalog and derive everything else from it. + +The catalog owns: +- supported runtime names +- runtime tier defaults (`opus` / `sonnet` / `haiku`) and runtime capabilities (e.g. `reasoning_effort` support) +- the full agent registry for model resolution +- the canonical per-agent **golden** alias (quality intent) +- derived profile aliases for `balanced`, `budget`, and `adaptive` +- agent → phase-type mapping +- agent → dynamic-routing default tier mapping + +The canonical file lives in a location both packages ship: +- repo root package (`get-shit-done-cc`) includes it +- standalone SDK package (`@gsd-build/sdk`) includes it + +Both CJS and SDK load this exact file. Neither package keeps its own independent list. + +## Golden profile + +The catalog stores a `golden` alias per agent. `quality` is defined as the golden profile exactly. Other profiles (`balanced`, `budget`, `adaptive`) are explicit views over the same agent registry. This keeps the highest-quality intent in one place while allowing lower-cost profiles to differ per agent where needed. + +## Consequences + +- `resolve-model` in SDK and CJS read the same registry, so missing-agent drift disappears +- `settings-advanced.md` runtime tier table must stay in parity with the catalog (enforced by test) +- `sdk/src/query/helpers.ts` runtime list comes from the catalog, fixing drift like the missing `hermes` runtime +- `sdk/src/session-runner.ts` uses the catalog's Claude runtime tier defaults instead of a private hardcoded profile map +- tests validate: + - every `agents/gsd-*.md` file exists in the catalog + - SDK and CJS resolve the same aliases for all known agents + - unknown-agent fallback follows profile semantics (`quality`→`opus`, `budget`→`haiku`, etc.), not a hardcoded `sonnet` + - docs/runtime tables stay aligned with the catalog diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 1459acd34..183d55440 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -7,6 +7,7 @@ const os = require('os'); const path = require('path'); const { execSync, execFileSync, spawnSync } = require('child_process'); const { MODEL_PROFILES, AGENT_TO_PHASE_TYPE, VALID_PHASE_TYPES, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier } = require('./model-profiles.cjs'); +const { MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, KNOWN_RUNTIMES, RUNTIMES_WITH_REASONING_EFFORT } = require('./model-catalog.cjs'); // Compatibility shim: new imports should use planning-workspace.cjs directly. const { planningDir, @@ -1301,102 +1302,9 @@ function checkAgentsInstalled() { // ─── Model alias resolution ─────────────────────────────────────────────────── -/** - * Map short model aliases to full model IDs. - * Updated each release to match current model versions. - * Users can override with model_overrides in config.json for custom/latest models. - */ -const MODEL_ALIAS_MAP = { - 'opus': 'claude-opus-4-7', - 'sonnet': 'claude-sonnet-4-6', - 'haiku': 'claude-haiku-4-5', -}; - -/** - * #2517 — runtime-aware tier resolution. - * Maps `model_profile` tiers (opus/sonnet/haiku) to runtime-native model IDs and - * (where supported) reasoning_effort settings. - * - * Each entry: { model: , reasoning_effort?: } - * - * `claude` mirrors MODEL_ALIAS_MAP — present for symmetry so `runtime: "claude"` - * resolves through the same code path. `codex` defaults are taken from the spec - * in #2517. Unknown runtimes fall back to the Claude alias to avoid emitting - * provider-specific IDs the runtime cannot accept. - */ -const RUNTIME_PROFILE_MAP = { - claude: Object.fromEntries( - Object.entries(MODEL_ALIAS_MAP).map(([tier, model]) => [tier, { model }]) - ), - codex: { - opus: { model: 'gpt-5.4', reasoning_effort: 'xhigh' }, - sonnet: { model: 'gpt-5.3-codex', reasoning_effort: 'medium' }, - haiku: { model: 'gpt-5.4-mini', reasoning_effort: 'medium' }, - }, - gemini: { - opus: { model: 'gemini-3-pro' }, - sonnet: { model: 'gemini-3-flash' }, - haiku: { model: 'gemini-2.5-flash-lite' }, - }, - qwen: { - opus: { model: 'qwen3-max-2026-01-23' }, - sonnet: { model: 'qwen3-coder-plus' }, - haiku: { model: 'qwen3-coder-next' }, - }, - opencode: { - opus: { model: 'anthropic/claude-opus-4-7' }, - sonnet: { model: 'anthropic/claude-sonnet-4-6' }, - haiku: { model: 'anthropic/claude-haiku-4-5' }, - }, - copilot: { - opus: { model: 'claude-opus-4-7' }, - sonnet: { model: 'claude-sonnet-4-6' }, - haiku: { model: 'claude-haiku-4-5' }, - }, - hermes: { - // Hermes Agent is provider-agnostic; users pick any provider in ~/.hermes/config.yaml. - // Defaults use OpenRouter slugs because (a) OpenRouter is Hermes' default provider and - // (b) the same slugs resolve on OpenRouter, native Anthropic, and Copilot via Hermes' - // aggregator-aware resolver. Users on a different provider override per-tier via - // model_profile_overrides.hermes.{opus,sonnet,haiku} in .planning/config.json. - opus: { model: 'anthropic/claude-opus-4-7' }, - sonnet: { model: 'anthropic/claude-sonnet-4-6' }, - haiku: { model: 'anthropic/claude-haiku-4-5' }, - }, -}; - -const RUNTIMES_WITH_REASONING_EFFORT = new Set(['codex']); - -/** - * Tier enum allowed under `model_profile_overrides[runtime][tier]`. Mirrors the - * regex in `config-schema.cjs` (DYNAMIC_KEY_PATTERNS) so loadConfig surfaces the - * same constraint at read time, not only at config-set time (review finding #10). - */ const RUNTIME_OVERRIDE_TIERS = new Set(['opus', 'sonnet', 'haiku']); - -/** - * Allowlist of runtime names the install pipeline currently knows how to emit - * native model IDs for. Synced with `getDirName` in `bin/install.js` and the - * runtime list in `docs/CONFIGURATION.md`. Free-string runtimes outside this - * set are still accepted (#2517 deliberately leaves the runtime field open) — - * a warning fires once at loadConfig so a typo like `runtime: "codx"` does not - * silently fall back to Claude defaults (review findings #10, #13). - */ -const KNOWN_RUNTIMES = new Set([ - 'claude', 'codex', 'opencode', 'kilo', 'gemini', 'qwen', - 'copilot', 'cursor', 'windsurf', 'augment', 'trae', 'codebuddy', - 'antigravity', 'cline', 'hermes', -]); - const _warnedConfigKeys = new Set(); -/** - * Emit a one-time stderr warning for unknown runtime/tier keys in a parsed - * config blob. Idempotent across calls — the same (file, key) pair only warns - * once per process so loadConfig can be called repeatedly without spamming. - * - * Does NOT reject — preserves back-compat for users on a runtime not yet in the - * allowlist (the new-runtime case must always be possible without code changes). - */ + function _warnUnknownProfileOverrides(parsed, configLabel) { if (!parsed || typeof parsed !== 'object') return; @@ -1574,7 +1482,12 @@ function resolveModelInternal(cwd, agentType) { } // 5. Profile lookup (Claude-native default). - if (!agentModels) return 'sonnet'; + if (!agentModels) { + return profile === 'quality' ? 'opus' + : profile === 'budget' ? 'haiku' + : profile === 'inherit' ? 'inherit' + : 'sonnet'; + } // Gate on tier (not profile) so a valid phase-type override beats // profile=inherit (#3030 CR Major). if (tier === 'inherit') return 'inherit'; diff --git a/get-shit-done/bin/lib/model-catalog.cjs b/get-shit-done/bin/lib/model-catalog.cjs new file mode 100644 index 000000000..509c42725 --- /dev/null +++ b/get-shit-done/bin/lib/model-catalog.cjs @@ -0,0 +1,92 @@ +'use strict'; + +const path = require('node:path'); +const catalog = require(path.join(__dirname, '..', '..', '..', 'sdk', 'shared', 'model-catalog.json')); + +const VALID_PROFILES = [...catalog.profiles]; +const VALID_PHASE_TYPES = new Set(catalog.phaseTypes); +const VALID_AGENT_TIERS = new Set(Object.keys(catalog.adaptiveTierMap)); + +const MODEL_PROFILES = Object.fromEntries( + Object.entries(catalog.agents).map(([agent, meta]) => [agent, { + quality: meta.golden, + balanced: meta.balanced, + budget: meta.budget, + adaptive: catalog.adaptiveTierMap[meta.routingTier], + }]) +); + +const AGENT_TO_PHASE_TYPE = Object.fromEntries( + Object.entries(catalog.agents).map(([agent, meta]) => [agent, meta.phaseType]) +); + +const AGENT_DEFAULT_TIERS = Object.fromEntries( + Object.entries(catalog.agents).map(([agent, meta]) => [agent, meta.routingTier]) +); + +const MODEL_ALIAS_MAP = Object.fromEntries( + Object.entries(catalog.runtimeTierDefaults.claude).map(([tier, entry]) => [tier, entry?.model]) +); + +const RUNTIME_PROFILE_MAP = Object.fromEntries( + Object.entries(catalog.runtimeTierDefaults) + .map(([runtime, tiers]) => [ + runtime, + Object.fromEntries( + Object.entries(tiers).filter(([, entry]) => entry).map(([tier, entry]) => [tier, entry]) + ), + ]) + .filter(([, tiers]) => Object.keys(tiers).length > 0) +); + +const KNOWN_RUNTIMES = new Set(Object.keys(catalog.runtimeTierDefaults)); +const RUNTIMES_WITH_REASONING_EFFORT = new Set( + Object.entries(catalog.runtimeTierDefaults) + .filter(([, tiers]) => Object.values(tiers).some((entry) => entry && entry.reasoning_effort)) + .map(([runtime]) => runtime) +); + +function nextTier(currentTier) { + const order = ['light', 'standard', 'heavy']; + const idx = order.indexOf(String(currentTier)); + if (idx === -1) return null; + return order[Math.min(idx + 1, order.length - 1)]; +} + +function formatAgentToModelMapAsTable(agentToModelMap) { + const agentWidth = Math.max('Agent'.length, ...Object.keys(agentToModelMap).map((a) => a.length)); + const modelWidth = Math.max('Model'.length, ...Object.values(agentToModelMap).map((m) => m.length)); + const sep = '─'.repeat(agentWidth + 2) + '┼' + '─'.repeat(modelWidth + 2); + const header = ` ${'Agent'.padEnd(agentWidth)} │ ${'Model'.padEnd(modelWidth)}`; + let out = `${header}\n${sep}\n`; + for (const [agent, model] of Object.entries(agentToModelMap)) { + out += ` ${agent.padEnd(agentWidth)} │ ${model.padEnd(modelWidth)}\n`; + } + return out; +} + +function getAgentToModelMapForProfile(normalizedProfile) { + const profile = VALID_PROFILES.includes(normalizedProfile) ? normalizedProfile : 'balanced'; + const out = {}; + for (const [agent, profiles] of Object.entries(MODEL_PROFILES)) { + out[agent] = profile === 'inherit' ? 'inherit' : profiles[profile]; + } + return out; +} + +module.exports = { + catalog, + MODEL_PROFILES, + VALID_PROFILES, + AGENT_TO_PHASE_TYPE, + VALID_PHASE_TYPES, + AGENT_DEFAULT_TIERS, + VALID_AGENT_TIERS, + MODEL_ALIAS_MAP, + RUNTIME_PROFILE_MAP, + KNOWN_RUNTIMES, + RUNTIMES_WITH_REASONING_EFFORT, + nextTier, + formatAgentToModelMapAsTable, + getAgentToModelMapForProfile, +}; diff --git a/get-shit-done/bin/lib/model-profiles.cjs b/get-shit-done/bin/lib/model-profiles.cjs index 719e6bb63..af05b75f7 100644 --- a/get-shit-done/bin/lib/model-profiles.cjs +++ b/get-shit-done/bin/lib/model-profiles.cjs @@ -1,184 +1,16 @@ -/** - * Mapping of GSD agent to model for each profile. - * - * Should be in sync with the profiles table in `get-shit-done/references/model-profiles.md`. But - * possibly worth making this the single source of truth at some point, and removing the markdown - * reference table in favor of programmatically determining the model to use for an agent (which - * would be faster, use fewer tokens, and be less error-prone). - */ -const MODEL_PROFILES = { - 'gsd-planner': { quality: 'opus', balanced: 'opus', budget: 'sonnet', adaptive: 'opus' }, - 'gsd-roadmapper': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet', adaptive: 'sonnet' }, - 'gsd-executor': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet', adaptive: 'sonnet' }, - 'gsd-phase-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku', adaptive: 'sonnet' }, - 'gsd-project-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku', adaptive: 'sonnet' }, - 'gsd-research-synthesizer': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' }, - 'gsd-debugger': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet', adaptive: 'opus' }, - 'gsd-codebase-mapper': { quality: 'sonnet', balanced: 'haiku', budget: 'haiku', adaptive: 'haiku' }, - 'gsd-verifier': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'sonnet' }, - 'gsd-plan-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' }, - 'gsd-integration-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' }, - 'gsd-nyquist-auditor': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' }, - 'gsd-pattern-mapper': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' }, - 'gsd-ui-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku', adaptive: 'sonnet' }, - 'gsd-ui-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' }, - 'gsd-ui-auditor': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' }, - 'gsd-doc-writer': { quality: 'opus', balanced: 'sonnet', budget: 'haiku', adaptive: 'sonnet' }, - 'gsd-doc-verifier': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' }, -}; -const VALID_PROFILES = [...Object.keys(MODEL_PROFILES['gsd-planner']), 'inherit']; +'use strict'; -/** - * #3023 — Phase-type → agent mapping table. - * - * Lets users tune model selection at the *phase-type* level (planning, - * research, execution, verification, ...) instead of per-agent. Maps - * each agent in MODEL_PROFILES to exactly one phase-type so resolution - * is deterministic. - * - * Adding a new agent to MODEL_PROFILES requires adding an entry here too; - * tests/feat-3023-phase-type-models.test.cjs asserts coverage. - * - * Phase-type semantics: - * - planning: produces the plan (PLAN.md, ROADMAP.md, patterns) - * - discuss: collaborative scoping (no subagent today; reserved - * for orchestrators that may spawn one) - * - research: gathers external/codebase context (RESEARCH.md) - * - execution: implements the plan or writes user-facing artifacts - * - verification: checks correctness (VERIFICATION.md, audits) - * - completion: post-execution wrap-up (no subagent today; reserved) - */ -const AGENT_TO_PHASE_TYPE = { - // Planning — produces the plan / roadmap / pattern map - 'gsd-planner': 'planning', - 'gsd-roadmapper': 'planning', - 'gsd-pattern-mapper': 'planning', - // Research — external/codebase information gathering - 'gsd-phase-researcher': 'research', - 'gsd-project-researcher': 'research', - 'gsd-research-synthesizer': 'research', - 'gsd-codebase-mapper': 'research', - 'gsd-ui-researcher': 'research', - // Execution — implementation, debugging, doc writing - 'gsd-executor': 'execution', - 'gsd-debugger': 'execution', - 'gsd-doc-writer': 'execution', - // Verification — correctness checks, audits, gap analysis - 'gsd-verifier': 'verification', - 'gsd-plan-checker': 'verification', - 'gsd-integration-checker': 'verification', - 'gsd-nyquist-auditor': 'verification', - 'gsd-ui-checker': 'verification', - 'gsd-ui-auditor': 'verification', - 'gsd-doc-verifier': 'verification', -}; - -/** - * The six phase-type slots accepted in `.planning/config.json` `models` - * block. `discuss` and `completion` are reserved — no current agent maps - * to them today — so users can pre-configure those slots without - * breaking validation when an orchestrator starts honoring them. - */ -const VALID_PHASE_TYPES = new Set([ - 'planning', 'discuss', 'research', 'execution', 'verification', 'completion', -]); - -/** - * #3024 — Per-agent default tier for dynamic routing. - * - * Each agent declares a default routing tier (light/standard/heavy) - * that the dynamic-routing resolver uses to pick from - * `dynamic_routing.tier_models[tier]` on the first attempt. On - * orchestrator-detected soft failure, the resolver escalates to the - * next tier up (capped at `max_escalations`). - * - * Tier semantics: - * - light: cheap/fast — pure mappers/scanners, low-stakes verifiers - * - standard: default workhorse — most researchers/writers/checkers - * - heavy: deep reasoning — planners/debuggers; can't escalate further - * - * Adding a new agent to MODEL_PROFILES requires adding an entry here too; - * tests/feat-3024-dynamic-routing.test.cjs asserts coverage. - */ -const AGENT_DEFAULT_TIERS = { - // Heavy — deep reasoning, planning, hard debugging - 'gsd-planner': 'heavy', - 'gsd-roadmapper': 'heavy', - 'gsd-debugger': 'heavy', - // Standard — default workhorse: research, writing, primary verification - 'gsd-executor': 'standard', - 'gsd-phase-researcher': 'standard', - 'gsd-project-researcher': 'standard', - 'gsd-verifier': 'standard', - 'gsd-doc-writer': 'standard', - 'gsd-ui-researcher': 'standard', - // Light — fast scanners, structural mappers, low-stakes audits - 'gsd-codebase-mapper': 'light', - 'gsd-pattern-mapper': 'light', - 'gsd-research-synthesizer': 'light', - 'gsd-plan-checker': 'light', - 'gsd-integration-checker': 'light', - 'gsd-nyquist-auditor': 'light', - 'gsd-ui-checker': 'light', - 'gsd-ui-auditor': 'light', - 'gsd-doc-verifier': 'light', -}; - -/** - * The three valid agent tier slots for dynamic routing. Used to - * validate `dynamic_routing.tier_models.` keys at config-set - * time and the AGENT_DEFAULT_TIERS values at startup. - */ -const VALID_AGENT_TIERS = new Set(['light', 'standard', 'heavy']); - -/** - * Tier escalation order: light → standard → heavy. - * `nextTier(currentTier)` returns the tier one step up. `heavy` stays - * at heavy (no tier above). Returns null for invalid input so callers - * can detect mis-config rather than silently degrade. - */ -const _TIER_ESCALATION = { light: 'standard', standard: 'heavy', heavy: 'heavy' }; -function nextTier(currentTier) { - if (typeof currentTier !== 'string') return null; - return _TIER_ESCALATION[currentTier] || null; -} - -/** - * Formats the agent-to-model mapping as a human-readable table (in string format). - * - * @param {Object} agentToModelMap - A mapping from agent to model - * @returns {string} A formatted table string - */ -function formatAgentToModelMapAsTable(agentToModelMap) { - const agentWidth = Math.max('Agent'.length, ...Object.keys(agentToModelMap).map((a) => a.length)); - const modelWidth = Math.max( - 'Model'.length, - ...Object.values(agentToModelMap).map((m) => m.length) - ); - const sep = '─'.repeat(agentWidth + 2) + '┼' + '─'.repeat(modelWidth + 2); - const header = ' ' + 'Agent'.padEnd(agentWidth) + ' │ ' + 'Model'.padEnd(modelWidth); - let agentToModelTable = header + '\n' + sep + '\n'; - for (const [agent, model] of Object.entries(agentToModelMap)) { - agentToModelTable += ' ' + agent.padEnd(agentWidth) + ' │ ' + model.padEnd(modelWidth) + '\n'; - } - return agentToModelTable; -} - -/** - * Returns a mapping from agent to model for the given model profile. - * - * @param {string} normalizedProfile - The normalized (lowercase and trimmed) profile name - * @returns {Object} A mapping from agent to model for the given profile - */ -function getAgentToModelMapForProfile(normalizedProfile) { - const agentToModelMap = {}; - for (const [agent, profileToModelMap] of Object.entries(MODEL_PROFILES)) { - agentToModelMap[agent] = normalizedProfile === 'inherit' - ? 'inherit' - : profileToModelMap[normalizedProfile]; - } - return agentToModelMap; -} +const { + MODEL_PROFILES, + VALID_PROFILES, + AGENT_TO_PHASE_TYPE, + VALID_PHASE_TYPES, + AGENT_DEFAULT_TIERS, + VALID_AGENT_TIERS, + nextTier, + formatAgentToModelMapAsTable, + getAgentToModelMapForProfile, +} = require('./model-catalog.cjs'); module.exports = { MODEL_PROFILES, diff --git a/get-shit-done/workflows/settings-advanced.md b/get-shit-done/workflows/settings-advanced.md index c29450abc..3ce557a99 100644 --- a/get-shit-done/workflows/settings-advanced.md +++ b/get-shit-done/workflows/settings-advanced.md @@ -349,6 +349,7 @@ Built-in tier defaults by runtime: | `qwen` | `qwen3-max-2026-01-23` | `qwen3-coder-plus` | `qwen3-coder-next` | | `opencode` | `anthropic/claude-opus-4-7` | `anthropic/claude-sonnet-4-6` | `anthropic/claude-haiku-4-5` | | `copilot` | `claude-opus-4-7` | `claude-sonnet-4-6` | `claude-haiku-4-5` | +| `hermes` | `anthropic/claude-opus-4-7` | `anthropic/claude-sonnet-4-6` | `anthropic/claude-haiku-4-5` | | Group B (`kilo`, `cline`, `cursor`, `windsurf`, `augment`, `trae`, `codebuddy`, `antigravity`) | (no built-in default — your runtime handles model selection) | | | Display a table to the user showing the effective configuration: diff --git a/package.json b/package.json index 922dc7627..59c5d68d8 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "hooks", "scripts", "sdk/src", + "sdk/shared", "sdk/prompts", "sdk/dist", "sdk/package.json", diff --git a/sdk/package.json b/sdk/package.json index 336438328..52883ab48 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -16,6 +16,7 @@ }, "files": [ "dist", + "shared", "prompts" ], "repository": { diff --git a/sdk/shared/model-catalog.json b/sdk/shared/model-catalog.json new file mode 100644 index 000000000..5ceda6009 --- /dev/null +++ b/sdk/shared/model-catalog.json @@ -0,0 +1,122 @@ +{ + "profiles": ["quality", "balanced", "budget", "adaptive", "inherit"], + "phaseTypes": ["planning", "discuss", "research", "execution", "verification", "completion"], + "adaptiveTierMap": { + "heavy": "opus", + "standard": "sonnet", + "light": "haiku" + }, + "runtimeTierDefaults": { + "claude": { + "opus": { "model": "claude-opus-4-7" }, + "sonnet": { "model": "claude-sonnet-4-6" }, + "haiku": { "model": "claude-haiku-4-5" } + }, + "codex": { + "opus": { "model": "gpt-5.4", "reasoning_effort": "xhigh" }, + "sonnet": { "model": "gpt-5.3-codex", "reasoning_effort": "medium" }, + "haiku": { "model": "gpt-5.4-mini", "reasoning_effort": "medium" } + }, + "gemini": { + "opus": { "model": "gemini-3-pro" }, + "sonnet": { "model": "gemini-3-flash" }, + "haiku": { "model": "gemini-2.5-flash-lite" } + }, + "qwen": { + "opus": { "model": "qwen3-max-2026-01-23" }, + "sonnet": { "model": "qwen3-coder-plus" }, + "haiku": { "model": "qwen3-coder-next" } + }, + "opencode": { + "opus": { "model": "anthropic/claude-opus-4-7" }, + "sonnet": { "model": "anthropic/claude-sonnet-4-6" }, + "haiku": { "model": "anthropic/claude-haiku-4-5" } + }, + "copilot": { + "opus": { "model": "claude-opus-4-7" }, + "sonnet": { "model": "claude-sonnet-4-6" }, + "haiku": { "model": "claude-haiku-4-5" } + }, + "hermes": { + "opus": { "model": "anthropic/claude-opus-4-7" }, + "sonnet": { "model": "anthropic/claude-sonnet-4-6" }, + "haiku": { "model": "anthropic/claude-haiku-4-5" } + }, + "kilo": { + "opus": null, + "sonnet": null, + "haiku": null + }, + "cline": { + "opus": null, + "sonnet": null, + "haiku": null + }, + "cursor": { + "opus": null, + "sonnet": null, + "haiku": null + }, + "windsurf": { + "opus": null, + "sonnet": null, + "haiku": null + }, + "augment": { + "opus": null, + "sonnet": null, + "haiku": null + }, + "trae": { + "opus": null, + "sonnet": null, + "haiku": null + }, + "codebuddy": { + "opus": null, + "sonnet": null, + "haiku": null + }, + "antigravity": { + "opus": null, + "sonnet": null, + "haiku": null + } + }, + "agents": { + "gsd-planner": { "golden": "opus", "balanced": "opus", "budget": "sonnet", "phaseType": "planning", "routingTier": "heavy" }, + "gsd-roadmapper": { "golden": "opus", "balanced": "sonnet", "budget": "sonnet", "phaseType": "planning", "routingTier": "heavy" }, + "gsd-executor": { "golden": "opus", "balanced": "sonnet", "budget": "sonnet", "phaseType": "execution", "routingTier": "standard" }, + "gsd-phase-researcher": { "golden": "opus", "balanced": "sonnet", "budget": "haiku", "phaseType": "research", "routingTier": "standard" }, + "gsd-project-researcher": { "golden": "opus", "balanced": "sonnet", "budget": "haiku", "phaseType": "research", "routingTier": "standard" }, + "gsd-research-synthesizer": { "golden": "sonnet", "balanced": "sonnet", "budget": "haiku", "phaseType": "research", "routingTier": "light" }, + "gsd-debugger": { "golden": "opus", "balanced": "sonnet", "budget": "sonnet", "phaseType": "execution", "routingTier": "heavy" }, + "gsd-codebase-mapper": { "golden": "sonnet", "balanced": "haiku", "budget": "haiku", "phaseType": "research", "routingTier": "light" }, + "gsd-verifier": { "golden": "sonnet", "balanced": "sonnet", "budget": "haiku", "phaseType": "verification", "routingTier": "standard" }, + "gsd-plan-checker": { "golden": "sonnet", "balanced": "sonnet", "budget": "haiku", "phaseType": "verification", "routingTier": "light" }, + "gsd-integration-checker": { "golden": "sonnet", "balanced": "sonnet", "budget": "haiku", "phaseType": "verification", "routingTier": "light" }, + "gsd-nyquist-auditor": { "golden": "sonnet", "balanced": "sonnet", "budget": "haiku", "phaseType": "verification", "routingTier": "light" }, + "gsd-pattern-mapper": { "golden": "sonnet", "balanced": "sonnet", "budget": "haiku", "phaseType": "planning", "routingTier": "light" }, + "gsd-ui-researcher": { "golden": "opus", "balanced": "sonnet", "budget": "haiku", "phaseType": "research", "routingTier": "standard" }, + "gsd-ui-checker": { "golden": "sonnet", "balanced": "sonnet", "budget": "haiku", "phaseType": "verification", "routingTier": "light" }, + "gsd-ui-auditor": { "golden": "sonnet", "balanced": "sonnet", "budget": "haiku", "phaseType": "verification", "routingTier": "light" }, + "gsd-doc-writer": { "golden": "opus", "balanced": "sonnet", "budget": "haiku", "phaseType": "execution", "routingTier": "standard" }, + "gsd-doc-verifier": { "golden": "sonnet", "balanced": "sonnet", "budget": "haiku", "phaseType": "verification", "routingTier": "light" }, + + "gsd-advisor-researcher": { "golden": "opus", "balanced": "sonnet", "budget": "haiku", "phaseType": "research", "routingTier": "standard" }, + "gsd-ai-researcher": { "golden": "opus", "balanced": "sonnet", "budget": "haiku", "phaseType": "research", "routingTier": "standard" }, + "gsd-assumptions-analyzer": { "golden": "opus", "balanced": "sonnet", "budget": "sonnet", "phaseType": "discuss", "routingTier": "heavy" }, + "gsd-code-fixer": { "golden": "opus", "balanced": "sonnet", "budget": "sonnet", "phaseType": "execution", "routingTier": "standard" }, + "gsd-code-reviewer": { "golden": "opus", "balanced": "sonnet", "budget": "sonnet", "phaseType": "verification", "routingTier": "standard" }, + "gsd-debug-session-manager": { "golden": "opus", "balanced": "sonnet", "budget": "sonnet", "phaseType": "execution", "routingTier": "heavy" }, + "gsd-doc-classifier": { "golden": "sonnet", "balanced": "haiku", "budget": "haiku", "phaseType": "research", "routingTier": "light" }, + "gsd-doc-synthesizer": { "golden": "opus", "balanced": "sonnet", "budget": "haiku", "phaseType": "research", "routingTier": "standard" }, + "gsd-domain-researcher": { "golden": "opus", "balanced": "sonnet", "budget": "haiku", "phaseType": "research", "routingTier": "standard" }, + "gsd-eval-auditor": { "golden": "opus", "balanced": "sonnet", "budget": "haiku", "phaseType": "verification", "routingTier": "standard" }, + "gsd-eval-planner": { "golden": "opus", "balanced": "opus", "budget": "sonnet", "phaseType": "planning", "routingTier": "heavy" }, + "gsd-framework-selector": { "golden": "opus", "balanced": "sonnet", "budget": "sonnet", "phaseType": "planning", "routingTier": "heavy" }, + "gsd-intel-updater": { "golden": "opus", "balanced": "sonnet", "budget": "haiku", "phaseType": "research", "routingTier": "light" }, + "gsd-security-auditor": { "golden": "opus", "balanced": "sonnet", "budget": "sonnet", "phaseType": "verification", "routingTier": "heavy" }, + "gsd-user-profiler": { "golden": "opus", "balanced": "sonnet", "budget": "sonnet", "phaseType": "research", "routingTier": "heavy" } + } +} diff --git a/sdk/src/model-catalog.ts b/sdk/src/model-catalog.ts new file mode 100644 index 000000000..7653ac1b5 --- /dev/null +++ b/sdk/src/model-catalog.ts @@ -0,0 +1,70 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +interface RuntimeTierEntry { + model: string; + reasoning_effort?: string; +} + +type RuntimeTierTable = Record>; + +interface AgentCatalogEntry { + golden: 'opus' | 'sonnet' | 'haiku'; + balanced: 'opus' | 'sonnet' | 'haiku'; + budget: 'opus' | 'sonnet' | 'haiku'; + phaseType: string; + routingTier: 'light' | 'standard' | 'heavy'; +} + +interface ModelCatalog { + profiles: string[]; + phaseTypes: string[]; + adaptiveTierMap: Record<'light' | 'standard' | 'heavy', 'opus' | 'sonnet' | 'haiku'>; + runtimeTierDefaults: RuntimeTierTable; + agents: Record; +} + +const CATALOG_PATH = new URL('../shared/model-catalog.json', import.meta.url); +export const catalog: ModelCatalog = JSON.parse(readFileSync(fileURLToPath(CATALOG_PATH), 'utf-8')); + +export const VALID_PROFILES: string[] = [...catalog.profiles]; +export const SUPPORTED_RUNTIMES = Object.keys(catalog.runtimeTierDefaults); +export type Runtime = (typeof SUPPORTED_RUNTIMES)[number]; + +export const MODEL_PROFILES: Record> = Object.fromEntries( + Object.entries(catalog.agents).map(([agent, meta]) => [agent, { + quality: meta.golden, + balanced: meta.balanced, + budget: meta.budget, + adaptive: catalog.adaptiveTierMap[meta.routingTier], + }]) +); + +export const AGENT_TO_PHASE_TYPE: Record = Object.fromEntries( + Object.entries(catalog.agents).map(([agent, meta]) => [agent, meta.phaseType]) +); + +export const AGENT_DEFAULT_TIERS: Record = Object.fromEntries( + Object.entries(catalog.agents).map(([agent, meta]) => [agent, meta.routingTier]) +); + +export function getAgentToModelMapForProfile(normalizedProfile: string): Record { + const profile = VALID_PROFILES.includes(normalizedProfile) ? normalizedProfile : 'balanced'; + const out: Record = {}; + for (const [agent, profiles] of Object.entries(MODEL_PROFILES)) { + out[agent] = profile === 'inherit' ? 'inherit' : profiles[profile] ?? profiles.balanced; + } + return out; +} + +export function resolveRuntimeTierDefault(runtime: string, alias: 'opus' | 'sonnet' | 'haiku'): RuntimeTierEntry | null { + return catalog.runtimeTierDefaults[runtime]?.[alias] ?? null; +} + +export function runtimesWithReasoningEffort(): Set { + return new Set( + Object.entries(catalog.runtimeTierDefaults) + .filter(([, tiers]) => Object.values(tiers).some((entry) => entry && entry.reasoning_effort)) + .map(([runtime]) => runtime) + ); +} diff --git a/sdk/src/query/config-query.test.ts b/sdk/src/query/config-query.test.ts index dca26ed61..49dd71791 100644 --- a/sdk/src/query/config-query.test.ts +++ b/sdk/src/query/config-query.test.ts @@ -3,8 +3,8 @@ */ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises'; -import { join } from 'node:path'; +import { mkdtemp, writeFile, mkdir, rm, readdir } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; import { tmpdir } from 'node:os'; import { GSDError, ErrorClassification, exitCodeFor } from '../errors.js'; @@ -121,16 +121,36 @@ describe('resolveModel', () => { expect(data).not.toHaveProperty('unknown_agent'); }); - it('returns unknown_agent flag for unknown agent', async () => { + it('resolves shipped-but-previously-missing agents without unknown_agent (#3229)', async () => { const { resolveModel } = await import('./config-query.js'); await writeFile( join(tmpDir, '.planning', 'config.json'), - JSON.stringify({ model_profile: 'balanced' }), + JSON.stringify({ model_profile: 'quality' }), ); - const result = await resolveModel(['unknown-agent'], tmpDir); + const result = await resolveModel(['gsd-code-reviewer'], tmpDir); const data = result.data as Record; - expect(data).toHaveProperty('model', 'sonnet'); - expect(data).toHaveProperty('unknown_agent', true); + expect(data).toHaveProperty('model', 'opus'); + expect(data).toHaveProperty('profile', 'quality'); + expect(data).not.toHaveProperty('unknown_agent'); + }); + + it('returns profile-semantic fallback for truly unknown agents (#3229)', async () => { + const { resolveModel } = await import('./config-query.js'); + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'quality' }), + ); + const quality = (await resolveModel(['unknown-agent'], tmpDir)).data as Record; + expect(quality).toHaveProperty('model', 'opus'); + expect(quality).toHaveProperty('unknown_agent', true); + + await writeFile( + join(tmpDir, '.planning', 'config.json'), + JSON.stringify({ model_profile: 'budget' }), + ); + const budget = (await resolveModel(['unknown-agent'], tmpDir)).data as Record; + expect(budget).toHaveProperty('model', 'haiku'); + expect(budget).toHaveProperty('unknown_agent', true); }); it('throws GSDError when no agent type provided', async () => { @@ -195,12 +215,17 @@ describe('resolveModel', () => { // ─── MODEL_PROFILES ───────────────────────────────────────────────────────── describe('MODEL_PROFILES', () => { - it('contains all 18 agent entries (sync with model-profiles.cjs)', async () => { + it('contains every shipped gsd agent file on disk (#3229)', async () => { const { MODEL_PROFILES } = await import('./config-query.js'); - expect(Object.keys(MODEL_PROFILES)).toHaveLength(18); + const repoRoot = resolve(process.cwd(), '..'); + const agentFiles = (await readdir(join(repoRoot, 'agents'))) + .filter((f) => /^gsd-.*\.md$/.test(f)) + .map((f) => f.replace(/\.md$/, '')) + .sort(); + expect(Object.keys(MODEL_PROFILES).sort()).toEqual(agentFiles); }); - it('has quality/balanced/budget/adaptive for each agent', async () => { + it('has quality/balanced/budget/adaptive for each shipped agent', async () => { const { MODEL_PROFILES } = await import('./config-query.js'); for (const agent of Object.keys(MODEL_PROFILES)) { expect(MODEL_PROFILES[agent]).toHaveProperty('quality'); @@ -214,9 +239,9 @@ describe('MODEL_PROFILES', () => { // ─── VALID_PROFILES ───────────────────────────────────────────────────────── describe('VALID_PROFILES', () => { - it('contains the four profile names', async () => { + it('contains quality, balanced, budget, adaptive, and inherit', async () => { const { VALID_PROFILES } = await import('./config-query.js'); - expect(VALID_PROFILES).toEqual(['quality', 'balanced', 'budget', 'adaptive']); + expect(VALID_PROFILES.sort()).toEqual(['adaptive', 'balanced', 'budget', 'inherit', 'quality']); }); }); diff --git a/sdk/src/query/config-query.ts b/sdk/src/query/config-query.ts index e9f5821cc..1d5c112d0 100644 --- a/sdk/src/query/config-query.ts +++ b/sdk/src/query/config-query.ts @@ -23,50 +23,8 @@ import { loadConfig } from '../config.js'; import { planningPaths } from './helpers.js'; import { maskIfSecret } from './secrets.js'; import type { QueryHandler } from './utils.js'; - -// ─── MODEL_PROFILES ───────────────────────────────────────────────────────── - -/** - * Mapping of GSD agent type to model alias for each profile tier. - * - * Ported from get-shit-done/bin/lib/model-profiles.cjs. - */ -export const MODEL_PROFILES: Record> = { - 'gsd-planner': { quality: 'opus', balanced: 'opus', budget: 'sonnet', adaptive: 'opus' }, - 'gsd-roadmapper': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet', adaptive: 'sonnet' }, - 'gsd-executor': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet', adaptive: 'sonnet' }, - 'gsd-phase-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku', adaptive: 'sonnet' }, - 'gsd-project-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku', adaptive: 'sonnet' }, - 'gsd-research-synthesizer': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' }, - 'gsd-debugger': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet', adaptive: 'opus' }, - 'gsd-codebase-mapper': { quality: 'sonnet', balanced: 'haiku', budget: 'haiku', adaptive: 'haiku' }, - 'gsd-verifier': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'sonnet' }, - 'gsd-plan-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' }, - 'gsd-integration-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' }, - 'gsd-nyquist-auditor': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' }, - 'gsd-pattern-mapper': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' }, - 'gsd-ui-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku', adaptive: 'sonnet' }, - 'gsd-ui-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' }, - 'gsd-ui-auditor': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' }, - 'gsd-doc-writer': { quality: 'opus', balanced: 'sonnet', budget: 'haiku', adaptive: 'sonnet' }, - 'gsd-doc-verifier': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku', adaptive: 'haiku' }, -}; - -/** Valid model profile names. */ -export const VALID_PROFILES: string[] = Object.keys(MODEL_PROFILES['gsd-planner']); - -/** - * Flat map of agent name → model alias for one profile tier (matches `model-profiles.cjs`). - */ -export function getAgentToModelMapForProfile(normalizedProfile: string): Record { - const profile = VALID_PROFILES.includes(normalizedProfile) ? normalizedProfile : 'balanced'; - const agentToModelMap: Record = {}; - for (const [agent, profileToModelMap] of Object.entries(MODEL_PROFILES)) { - const mapped = profileToModelMap[profile] ?? profileToModelMap.balanced; - agentToModelMap[agent] = mapped ?? 'sonnet'; - } - return agentToModelMap; -} +export { MODEL_PROFILES, VALID_PROFILES, getAgentToModelMapForProfile } from '../model-catalog.js'; +import { MODEL_PROFILES, VALID_PROFILES, getAgentToModelMapForProfile } from '../model-catalog.js'; // ─── configGet ────────────────────────────────────────────────────────────── @@ -204,7 +162,12 @@ export const resolveModel: QueryHandler = async (args, projectDir, workstream) = // Fall back to profile lookup const agentModels = MODEL_PROFILES[agentType]; if (!agentModels) { - return { data: { model: 'sonnet', profile, unknown_agent: true } }; + const semanticFallback = + profile === 'quality' ? 'opus' + : profile === 'budget' ? 'haiku' + : profile === 'inherit' ? 'inherit' + : 'sonnet'; + return { data: { model: semanticFallback, profile, unknown_agent: true } }; } if (profile === 'inherit') { diff --git a/sdk/src/query/helpers.test.ts b/sdk/src/query/helpers.test.ts index 7f40d5e87..2305dfa88 100644 --- a/sdk/src/query/helpers.test.ts +++ b/sdk/src/query/helpers.test.ts @@ -285,7 +285,7 @@ const RUNTIME_ENV_VARS = [ 'OPENCODE_CONFIG', 'KILO_CONFIG_DIR', 'KILO_CONFIG', 'XDG_CONFIG_HOME', 'GEMINI_CONFIG_DIR', 'CODEX_HOME', 'COPILOT_CONFIG_DIR', 'ANTIGRAVITY_CONFIG_DIR', 'CURSOR_CONFIG_DIR', 'WINDSURF_CONFIG_DIR', 'AUGMENT_CONFIG_DIR', 'TRAE_CONFIG_DIR', - 'QWEN_CONFIG_DIR', 'CODEBUDDY_CONFIG_DIR', 'CLINE_CONFIG_DIR', + 'QWEN_CONFIG_DIR', 'CODEBUDDY_CONFIG_DIR', 'CLINE_CONFIG_DIR', 'HERMES_HOME', ] as const; describe('getRuntimeConfigDir', () => { @@ -315,6 +315,7 @@ describe('getRuntimeConfigDir', () => { qwen: join(homedir(), '.qwen'), codebuddy: join(homedir(), '.codebuddy'), cline: join(homedir(), '.cline'), + hermes: join(homedir(), '.hermes'), }; for (const runtime of SUPPORTED_RUNTIMES) { @@ -336,6 +337,7 @@ describe('getRuntimeConfigDir', () => { ['qwen', 'QWEN_CONFIG_DIR', '/x/qwen'], ['codebuddy', 'CODEBUDDY_CONFIG_DIR', '/x/codebuddy'], ['cline', 'CLINE_CONFIG_DIR', '/x/cline'], + ['hermes', 'HERMES_HOME', '/x/hermes'], ['opencode', 'OPENCODE_CONFIG_DIR', '/x/opencode'], ['kilo', 'KILO_CONFIG_DIR', '/x/kilo'], ]; diff --git a/sdk/src/query/helpers.ts b/sdk/src/query/helpers.ts index 2aae268a4..7c875fc49 100644 --- a/sdk/src/query/helpers.ts +++ b/sdk/src/query/helpers.ts @@ -23,19 +23,11 @@ import { existsSync, statSync, readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { GSDError, ErrorClassification } from '../errors.js'; import { relPlanningPath } from '../workstream-utils.js'; +export { SUPPORTED_RUNTIMES, type Runtime } from '../model-catalog.js'; +import { SUPPORTED_RUNTIMES, type Runtime } from '../model-catalog.js'; // ─── Runtime-aware agents directory resolution ───────────────────────────── -/** - * Supported GSD runtimes. Kept in sync with `bin/install.js:getGlobalDir()`. - */ -export const SUPPORTED_RUNTIMES = [ - 'claude', 'opencode', 'kilo', 'gemini', 'codex', 'copilot', 'antigravity', - 'cursor', 'windsurf', 'augment', 'trae', 'qwen', 'codebuddy', 'cline', -] as const; - -export type Runtime = (typeof SUPPORTED_RUNTIMES)[number]; - function expandTilde(p: string): string { return p.startsWith('~/') || p === '~' ? join(homedir(), p.slice(1)) : p; } @@ -82,6 +74,10 @@ export function getRuntimeConfigDir(runtime: Runtime): string { return process.env.CODEBUDDY_CONFIG_DIR ? expandTilde(process.env.CODEBUDDY_CONFIG_DIR) : join(homedir(), '.codebuddy'); case 'cline': return process.env.CLINE_CONFIG_DIR ? expandTilde(process.env.CLINE_CONFIG_DIR) : join(homedir(), '.cline'); + case 'hermes': + return process.env.HERMES_HOME ? expandTilde(process.env.HERMES_HOME) : join(homedir(), '.hermes'); + default: + throw new Error(`Unknown runtime: ${String(runtime)}`); } } diff --git a/sdk/src/session-runner.ts b/sdk/src/session-runner.ts index da57de3ac..3798eac05 100644 --- a/sdk/src/session-runner.ts +++ b/sdk/src/session-runner.ts @@ -14,6 +14,7 @@ import { buildExecutorPrompt, parseAgentTools, DEFAULT_ALLOWED_TOOLS } from './p import type { GSDEventStream, EventStreamContext } from './event-stream.js'; import { getToolsForPhase } from './tool-scoping.js'; import { detectRuntime } from './query/helpers.js'; +import { resolveRuntimeTierDefault } from './model-catalog.js'; // ─── Model resolution ──────────────────────────────────────────────────────── @@ -50,12 +51,13 @@ function resolveModel(options?: SessionOptions, config?: GSDConfig): string | un } if (config?.model_profile) { - const profileMap: Record = { - balanced: 'claude-sonnet-4-6', - quality: 'claude-opus-4-6', - speed: 'claude-haiku-4-5', - }; - return profileMap[config.model_profile] ?? config.model_profile; + const profile = String(config.model_profile).toLowerCase(); + const tier = profile === 'quality' ? 'opus' + : (profile === 'budget' || profile === 'speed') ? 'haiku' + : (profile === 'balanced' || profile === 'adaptive') ? 'sonnet' + : null; + if (!tier) return config.model_profile; + return resolveRuntimeTierDefault('claude', tier)?.model; } return undefined; // Let SDK use its default diff --git a/tests/bug-1829-inherit-model-profile.test.cjs b/tests/bug-1829-inherit-model-profile.test.cjs index be54226a4..e1eea34ed 100644 --- a/tests/bug-1829-inherit-model-profile.test.cjs +++ b/tests/bug-1829-inherit-model-profile.test.cjs @@ -94,9 +94,9 @@ describe('bug #1829: model_profile "inherit" — resolveModelInternal', () => { assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-verifier'), 'inherit'); }); - test('returns "sonnet" (default) for unknown agent even with inherit profile', () => { + test('returns "inherit" for unknown agent with inherit profile', () => { writeConfig(tmpDir, { model_profile: 'inherit' }); - assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-nonexistent'), 'sonnet'); + assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-nonexistent'), 'inherit'); }); test('per-agent override takes precedence over inherit profile', () => { diff --git a/tests/commands.test.cjs b/tests/commands.test.cjs index 31b73fdff..cc84ed57f 100644 --- a/tests/commands.test.cjs +++ b/tests/commands.test.cjs @@ -1071,6 +1071,16 @@ describe('resolve-model command', () => { assert.strictEqual(output.unknown_agent, undefined, 'should not have unknown_agent for known agent'); }); + test('shipped-but-previously-missing agent resolves under quality profile (#3229)', () => { + fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ model_profile: 'quality' })); + const result = runGsdTools('resolve-model gsd-code-reviewer', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.model, 'opus'); + assert.strictEqual(output.profile, 'quality'); + assert.strictEqual(output.unknown_agent, undefined); + }); + test('unknown agent returns unknown_agent=true', () => { const result = runGsdTools('resolve-model fake-nonexistent-agent', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); @@ -1079,6 +1089,24 @@ describe('resolve-model command', () => { assert.strictEqual(output.unknown_agent, true, 'should flag unknown agent'); }); + test('unknown agent uses quality-semantic fallback (opus)', () => { + fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ model_profile: 'quality' })); + const result = runGsdTools('resolve-model fake-nonexistent-agent', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.model, 'opus'); + assert.strictEqual(output.unknown_agent, true); + }); + + test('unknown agent uses budget-semantic fallback (haiku)', () => { + fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ model_profile: 'budget' })); + const result = runGsdTools('resolve-model fake-nonexistent-agent', tmpDir); + assert.ok(result.success, `Command failed: ${result.error}`); + const output = JSON.parse(result.output); + assert.strictEqual(output.model, 'haiku'); + assert.strictEqual(output.unknown_agent, true); + }); + test('default profile fallback when no config exists', () => { // tmpDir has no config.json, so defaults to balanced profile const result = runGsdTools('resolve-model gsd-executor', tmpDir); diff --git a/tests/core.test.cjs b/tests/core.test.cjs index 6386dd47b..1f83e40bd 100644 --- a/tests/core.test.cjs +++ b/tests/core.test.cjs @@ -434,9 +434,9 @@ describe('resolveModelInternal', () => { assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-nonexistent'), 'sonnet'); }); - test('returns sonnet for unknown agent type even with inherit profile', () => { + test('returns inherit for unknown agent type with inherit profile', () => { writeConfig({ model_profile: 'inherit' }); - assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-nonexistent'), 'sonnet'); + assert.strictEqual(resolveModelInternal(tmpDir, 'gsd-nonexistent'), 'inherit'); }); test('defaults to balanced profile when model_profile missing', () => { diff --git a/tests/model-catalog-runtime-defaults.test.cjs b/tests/model-catalog-runtime-defaults.test.cjs new file mode 100644 index 000000000..8c0343981 --- /dev/null +++ b/tests/model-catalog-runtime-defaults.test.cjs @@ -0,0 +1,63 @@ +// allow-test-rule: source-text-is-the-product +// These docs tables are the shipped operator surface for runtime model tiers. + +'use strict'; + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { catalog, KNOWN_RUNTIMES } = require('../get-shit-done/bin/lib/model-catalog.cjs'); + +const ROOT = path.join(__dirname, '..'); +const SETTINGS_ADVANCED = fs.readFileSync(path.join(ROOT, 'get-shit-done', 'workflows', 'settings-advanced.md'), 'utf8'); +const CONFIG_DOC = fs.readFileSync(path.join(ROOT, 'docs', 'CONFIGURATION.md'), 'utf8'); + +describe('model catalog runtime defaults parity (#3229)', () => { + test('known runtimes include hermes and match catalog keys', () => { + assert.ok(KNOWN_RUNTIMES.has('hermes')); + assert.deepStrictEqual([...KNOWN_RUNTIMES].sort(), Object.keys(catalog.runtimeTierDefaults).sort()); + }); + + test('settings-advanced runtime defaults table matches catalog for concrete runtimes', () => { + for (const [runtime, tiers] of Object.entries(catalog.runtimeTierDefaults)) { + if (!tiers.opus) continue; // Group B runtimes intentionally have no built-ins + assert.ok(SETTINGS_ADVANCED.includes(`| \`${runtime}\``), `settings-advanced.md missing ${runtime} row`); + for (const alias of ['opus', 'sonnet', 'haiku']) { + const entry = tiers[alias]; + assert.ok(entry?.model, `${runtime}.${alias} missing model in catalog`); + assert.ok( + SETTINGS_ADVANCED.includes(`\`${entry.model}\``), + `settings-advanced.md missing ${runtime}.${alias} model ${entry.model}`, + ); + } + } + }); + + test('CONFIGURATION runtime defaults table matches catalog for concrete runtimes', () => { + for (const [runtime, tiers] of Object.entries(catalog.runtimeTierDefaults)) { + if (!tiers.opus) continue; + assert.ok(CONFIG_DOC.includes(`| \`${runtime}\``), `CONFIGURATION.md missing ${runtime} row`); + for (const alias of ['opus', 'sonnet', 'haiku']) { + const entry = tiers[alias]; + assert.ok( + CONFIG_DOC.includes(`\`${entry.model}\``), + `CONFIGURATION.md missing ${runtime}.${alias} model ${entry.model}`, + ); + } + } + }); + + test('Group B runtimes remain documented as having no built-in defaults', () => { + const groupB = ['kilo', 'cline', 'cursor', 'windsurf', 'augment', 'trae', 'codebuddy', 'antigravity']; + for (const runtime of groupB) { + const tiers = catalog.runtimeTierDefaults[runtime]; + assert.equal(tiers.opus, null); + assert.equal(tiers.sonnet, null); + assert.equal(tiers.haiku, null); + } + assert.ok(SETTINGS_ADVANCED.includes('Group B')); + assert.ok(CONFIG_DOC.includes('Group B')); + }); +}); diff --git a/tests/model-profiles.test.cjs b/tests/model-profiles.test.cjs index ca06fa9f7..cb5dfe7b8 100644 --- a/tests/model-profiles.test.cjs +++ b/tests/model-profiles.test.cjs @@ -8,6 +8,9 @@ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + const { MODEL_PROFILES, VALID_PROFILES, @@ -15,20 +18,20 @@ const { getAgentToModelMapForProfile, } = require('../get-shit-done/bin/lib/model-profiles.cjs'); +function agentFilesOnDisk() { + return fs.readdirSync(path.join(__dirname, '..', 'agents')) + .filter((f) => /^gsd-.*\.md$/.test(f)) + .map((f) => f.replace(/\.md$/, '')) + .sort(); +} + // ─── MODEL_PROFILES data integrity ──────────────────────────────────────────── describe('MODEL_PROFILES', () => { - test('contains all expected GSD agents', () => { - const expectedAgents = [ - 'gsd-planner', 'gsd-roadmapper', 'gsd-executor', - 'gsd-phase-researcher', 'gsd-project-researcher', 'gsd-research-synthesizer', - 'gsd-debugger', 'gsd-codebase-mapper', 'gsd-verifier', - 'gsd-plan-checker', 'gsd-integration-checker', 'gsd-nyquist-auditor', - 'gsd-ui-researcher', 'gsd-ui-checker', 'gsd-ui-auditor', - ]; - for (const agent of expectedAgents) { - assert.ok(MODEL_PROFILES[agent], `Missing agent: ${agent}`); - } + test('contains every shipped gsd agent file on disk (#3229)', () => { + const expectedAgents = agentFilesOnDisk(); + const actualAgents = Object.keys(MODEL_PROFILES).sort(); + assert.deepStrictEqual(actualAgents, expectedAgents); }); test('every agent has quality, balanced, budget, and adaptive profiles', () => {