* feat: make model profiles runtime-aware for Codex/non-Claude runtimes (closes #2517) Adds an optional top-level `runtime` config key plus a `model_profile_overrides[runtime][tier]` map. When `runtime` is set, profile tiers (opus/sonnet/haiku) resolve to runtime-native model IDs (and reasoning_effort where supported) instead of bare Claude aliases. Codex defaults from the spec: opus -> gpt-5.4 reasoning_effort: xhigh sonnet -> gpt-5.3-codex reasoning_effort: medium haiku -> gpt-5.4-mini reasoning_effort: medium Claude defaults mirror MODEL_ALIAS_MAP. Unknown runtimes fall back to the Claude-alias safe default rather than emit IDs the runtime cannot accept. reasoning_effort is only emitted into Codex install paths; never returned from resolveModelInternal and never written to Claude agent frontmatter. Backwards compatible: any user without `runtime` set sees identical behavior — the new branch is gated on `config.runtime != null`. Precedence (highest to lowest): 1. per-agent model_overrides 2. runtime-aware tier resolution (when `runtime` is set) 3. resolve_model_ids: "omit" 4. Claude-native default 5. inherit (literal passthrough) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(#2517): address adversarial review of #2609 (findings 1-16) Addresses all 16 findings from the adversarial review of PR #2609. Each finding is enumerated below with its resolution. CRITICAL - F1: readGsdRuntimeProfileResolver(targetDir) now probes per-project .planning/config.json AND ~/.gsd/defaults.json with per-project winning, so the PR's headline claim ("set runtime in project config and Codex TOML emit picks it up") actually holds end-to-end. - F2: resolveTierEntry field-merges user overrides with built-in defaults. The CONFIGURATION.md string-shorthand example `{ codex: { opus: "gpt-5-pro" } }` now keeps reasoning_effort from the built-in entry. Partial-object overrides like `{ opus: { reasoning_effort: 'low' } }` keep the built-in model. Both paths regression-tested. MAJOR - F3: resolveReasoningEffortInternal gates strictly on the RUNTIMES_WITH_REASONING_EFFORT allowlist regardless of override presence. Override + unknown-runtime no longer leaks reasoning_effort. - F4: runtime:"claude" is now a no-op for resolution (it is the implicit default). It no longer hijacks resolve_model_ids:"omit". Existing tests for `runtime:"claude"` returning Claude IDs were rewritten to reflect the no-op semantics; new test asserts the omit case returns "". - F5: _readGsdConfigFile in install.js writes a stderr warning on JSON parse failure instead of silently returning null. Read failure and parse failure are warned separately. Library require is hoisted to top of install.js so it is not co-mingled with config-read failure modes. - F6: install.js requires for core.cjs / model-profiles.cjs are hoisted to the top of the file with __dirname-based absolute paths so global npm install works regardless of cwd. Test asserts both lib paths exist relative to install.js __dirname. - F7: docs/CONFIGURATION.md `runtime` row no longer lists `opencode` as a valid runtime — install-path emission for non-Codex runtimes is explicitly out of scope per #2517 / #2612, and the doc now points at #2612 for the follow-on work. resolveModelInternal still accepts any runtime string (back-compat) and falls back safely for unknown values. - F8: Tests now isolate HOME (and GSD_HOME) to a per-test tmpdir so the developer's real ~/.gsd/defaults.json cannot bleed into assertions. Same pattern CodeRabbit caught on PRs #2603 / #2604. - F9: `runtime` and `model_profile_overrides` documented as flat-only in core.cjs comments — not routed through `get()` because they are top-level keys per docs/CONFIGURATION.md and introducing nested resolution for two new keys was not worth the edge-case surface. - F10/F13: loadConfig now invokes _warnUnknownProfileOverrides on the raw parsed config so direct .planning/config.json edits surface unknown runtime values (e.g. typo `runtime: "codx"`) and unknown tier values (e.g. `model_profile_overrides.codex.banana`) at read time. Warnings only — preserves back-compat for runtimes added later. Per-process warning cache prevents log spam across repeated loadConfig calls. MINOR / NIT - F11: Removed dead `tier || 'sonnet'` defensive shortcut. The local is now `const alias = tier;` with a comment explaining why `tier` is guaranteed truthy at that point (every MODEL_PROFILES entry defines `balanced`, the fallback profile). - F12: Extracted resolveTierEntry() in core.cjs as the single source of truth for runtime-aware tier resolution. core.cjs and bin/install.js both consume it — no duplicated lookup logic between the two files. - F14: Added regression tests for findings #1, #2, #3, #4, #6, #10, #13 in tests/issue-2517-runtime-aware-profiles.test.cjs. Each must-fix path has a corresponding test that fails against the pre-fix code and passes against the post-fix code. - F15: docs/CONFIGURATION.md `model_profile` row cross-references #1713 / #1806 next to the `adaptive` enum value. - F16: RUNTIME_PROFILE_MAP remains in core.cjs as the single source of truth; install.js imports it through the exported resolveTierEntry helper rather than carrying its own copy. Doc files (CONFIGURATION.md, USER-GUIDE.md, settings.md) intentionally still embed the IDs as text — code comment in core.cjs flags that those doc files must be updated whenever the constant changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
147
bin/install.js
147
bin/install.js
@@ -57,6 +57,20 @@ const claudeToCopilotTools = {
|
||||
// Get version from package.json
|
||||
const pkg = require('../package.json');
|
||||
|
||||
// #2517 — runtime-aware tier resolution shared with core.cjs.
|
||||
// Hoisted to top with absolute __dirname-based paths so `gsd install codex` works
|
||||
// when invoked via npm global install (cwd is the user's project, not the gsd repo
|
||||
// root). Inline `require('../get-shit-done/...')` from inside install functions
|
||||
// works only because Node resolves it relative to the install.js file regardless
|
||||
// of cwd, but keeping the require at the top makes the dependency explicit and
|
||||
// surfaces resolution failures at process start instead of at first install call.
|
||||
const _gsdLibDir = path.join(__dirname, '..', 'get-shit-done', 'bin', 'lib');
|
||||
const { MODEL_PROFILES: GSD_MODEL_PROFILES } = require(path.join(_gsdLibDir, 'model-profiles.cjs'));
|
||||
const {
|
||||
RUNTIME_PROFILE_MAP: GSD_RUNTIME_PROFILE_MAP,
|
||||
resolveTierEntry: gsdResolveTierEntry,
|
||||
} = require(path.join(_gsdLibDir, 'core.cjs'));
|
||||
|
||||
// Parse args
|
||||
const args = process.argv.slice(2);
|
||||
const hasGlobal = args.includes('--global') || args.includes('-g');
|
||||
@@ -620,6 +634,115 @@ function readGsdGlobalModelOverrides() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #2517 — Read a single GSD config file (defaults.json or per-project
|
||||
* config.json) into a plain object, returning null on missing/empty files
|
||||
* and warning to stderr on JSON parse failures so silent corruption can't
|
||||
* mask broken configs (review finding #5).
|
||||
*/
|
||||
function _readGsdConfigFile(absPath, label) {
|
||||
if (!fs.existsSync(absPath)) return null;
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(absPath, 'utf-8');
|
||||
} catch (err) {
|
||||
process.stderr.write(`gsd: warning — could not read ${label} (${absPath}): ${err.message}\n`);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (err) {
|
||||
process.stderr.write(`gsd: warning — invalid JSON in ${label} (${absPath}): ${err.message}\n`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #2517 — Build a runtime-aware tier resolver for the install path.
|
||||
*
|
||||
* Probes BOTH per-project `<targetDir>/.planning/config.json` AND
|
||||
* `~/.gsd/defaults.json`, with per-project keys winning over global. This
|
||||
* matches `loadConfig`'s precedence and is the only way the PR's headline claim
|
||||
* — "set runtime in .planning/config.json and the Codex TOML emit picks it up"
|
||||
* — actually holds end-to-end (review finding #1).
|
||||
*
|
||||
* `targetDir` should be the consuming runtime's install root — install code
|
||||
* passes `path.dirname(<runtime root>)` so `.planning/config.json` resolves
|
||||
* relative to the user's project. When `targetDir` is null/undefined, only the
|
||||
* global defaults are consulted.
|
||||
*
|
||||
* Returns null if no `runtime` is configured (preserves prior behavior — only
|
||||
* model_overrides is embedded, no tier/reasoning-effort inference). Returns
|
||||
* null when `model_profile` is `inherit` so the literal alias passes through
|
||||
* unchanged.
|
||||
*
|
||||
* Returns { runtime, resolve(agentName) -> { model, reasoning_effort? } | null }
|
||||
*/
|
||||
function readGsdRuntimeProfileResolver(targetDir = null) {
|
||||
const homeDefaults = _readGsdConfigFile(
|
||||
path.join(os.homedir(), '.gsd', 'defaults.json'),
|
||||
'~/.gsd/defaults.json'
|
||||
);
|
||||
|
||||
// Per-project config probe. Resolve the project root by walking up from
|
||||
// targetDir until we hit a `.planning/` directory; this covers both the
|
||||
// common case (caller passes the project root) and the case where caller
|
||||
// passes a nested install dir like `<root>/.codex/`.
|
||||
let projectConfig = null;
|
||||
if (targetDir) {
|
||||
let probeDir = path.resolve(targetDir);
|
||||
for (let depth = 0; depth < 8; depth += 1) {
|
||||
const candidate = path.join(probeDir, '.planning', 'config.json');
|
||||
if (fs.existsSync(candidate)) {
|
||||
projectConfig = _readGsdConfigFile(candidate, '.planning/config.json');
|
||||
break;
|
||||
}
|
||||
const parent = path.dirname(probeDir);
|
||||
if (parent === probeDir) break;
|
||||
probeDir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
// Per-project wins. Only fall back to ~/.gsd/defaults.json when the project
|
||||
// didn't set the field. Field-level merge (not whole-object replace) so a
|
||||
// user can keep `runtime` global while overriding only `model_profile` per
|
||||
// project, and vice versa.
|
||||
const merged = {
|
||||
runtime:
|
||||
(projectConfig && projectConfig.runtime) ||
|
||||
(homeDefaults && homeDefaults.runtime) ||
|
||||
null,
|
||||
model_profile:
|
||||
(projectConfig && projectConfig.model_profile) ||
|
||||
(homeDefaults && homeDefaults.model_profile) ||
|
||||
'balanced',
|
||||
model_profile_overrides:
|
||||
(projectConfig && projectConfig.model_profile_overrides) ||
|
||||
(homeDefaults && homeDefaults.model_profile_overrides) ||
|
||||
null,
|
||||
};
|
||||
|
||||
if (!merged.runtime) return null;
|
||||
|
||||
const profile = String(merged.model_profile).toLowerCase();
|
||||
if (profile === 'inherit') return null;
|
||||
|
||||
return {
|
||||
runtime: merged.runtime,
|
||||
resolve(agentName) {
|
||||
const agentModels = GSD_MODEL_PROFILES[agentName];
|
||||
if (!agentModels) return null;
|
||||
const tier = agentModels[profile] || agentModels.balanced;
|
||||
if (!tier) return null;
|
||||
return gsdResolveTierEntry({
|
||||
runtime: merged.runtime,
|
||||
tier,
|
||||
overrides: merged.model_profile_overrides,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Cache for attribution settings (populated once per runtime during install)
|
||||
const attributionCache = new Map();
|
||||
|
||||
@@ -1789,7 +1912,7 @@ purpose: ${toSingleLine(description)}
|
||||
* Sets required agent metadata, sandbox_mode, and developer_instructions
|
||||
* from the agent markdown content.
|
||||
*/
|
||||
function generateCodexAgentToml(agentName, agentContent, modelOverrides = null) {
|
||||
function generateCodexAgentToml(agentName, agentContent, modelOverrides = null, runtimeResolver = null) {
|
||||
const sandboxMode = CODEX_AGENT_SANDBOX[agentName] || 'read-only';
|
||||
const { frontmatter, body } = extractFrontmatterAndBody(agentContent);
|
||||
const frontmatterText = frontmatter || '';
|
||||
@@ -1808,9 +1931,20 @@ function generateCodexAgentToml(agentName, agentContent, modelOverrides = null)
|
||||
// Embed model override when configured in ~/.gsd/defaults.json so that
|
||||
// model_overrides is respected on Codex (which uses static TOML, not inline
|
||||
// Task() model parameters). See #2256.
|
||||
// Precedence: per-agent model_overrides > runtime-aware tier resolution (#2517).
|
||||
const modelOverride = modelOverrides?.[resolvedName] || modelOverrides?.[agentName];
|
||||
if (modelOverride) {
|
||||
lines.push(`model = ${JSON.stringify(modelOverride)}`);
|
||||
} else if (runtimeResolver) {
|
||||
// #2517 — runtime-aware tier resolution. Embeds Codex-native model + reasoning_effort
|
||||
// from RUNTIME_PROFILE_MAP / model_profile_overrides for the configured tier.
|
||||
const entry = runtimeResolver.resolve(resolvedName) || runtimeResolver.resolve(agentName);
|
||||
if (entry?.model) {
|
||||
lines.push(`model = ${JSON.stringify(entry.model)}`);
|
||||
if (entry.reasoning_effort) {
|
||||
lines.push(`model_reasoning_effort = ${JSON.stringify(entry.reasoning_effort)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Agent prompts contain raw backslashes in regexes and shell snippets.
|
||||
@@ -3050,8 +3184,16 @@ function installCodexConfig(targetDir, agentsSrc) {
|
||||
|
||||
// Pass model overrides from ~/.gsd/defaults.json so Codex TOML files
|
||||
// embed the configured model — Codex cannot receive model inline (#2256).
|
||||
// #2517 — also pass the runtime-aware tier resolver so profile tiers can
|
||||
// resolve to Codex-native model IDs + reasoning_effort when `runtime: "codex"`
|
||||
// is set in defaults.json.
|
||||
const modelOverrides = readGsdGlobalModelOverrides();
|
||||
const tomlContent = generateCodexAgentToml(name, content, modelOverrides);
|
||||
// Pass `targetDir` so per-project .planning/config.json wins over global
|
||||
// ~/.gsd/defaults.json — without this, the PR's headline claim that
|
||||
// setting runtime in the project config reaches the Codex emit path is
|
||||
// false (review finding #1).
|
||||
const runtimeResolver = readGsdRuntimeProfileResolver(targetDir);
|
||||
const tomlContent = generateCodexAgentToml(name, content, modelOverrides, runtimeResolver);
|
||||
fs.writeFileSync(path.join(agentsTomlDir, `${name}.toml`), tomlContent);
|
||||
}
|
||||
|
||||
@@ -6943,6 +7085,7 @@ if (process.env.GSD_TEST_MODE) {
|
||||
stripGsdFromCodexConfig,
|
||||
mergeCodexConfig,
|
||||
installCodexConfig,
|
||||
readGsdRuntimeProfileResolver,
|
||||
install,
|
||||
uninstall,
|
||||
convertClaudeCommandToCodexSkill,
|
||||
|
||||
Reference in New Issue
Block a user