diff --git a/.changeset/sturdy-seals-fly.md b/.changeset/sturdy-seals-fly.md new file mode 100644 index 000000000..798b1daa6 --- /dev/null +++ b/.changeset/sturdy-seals-fly.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2442 +--- +**`~/.gsd/defaults.json` no longer silently drops `model_policy`, `model_profile_overrides`, and `runtime`** — the global-defaults path of config load now forwards these three keys identically to a project's `.planning/config.json`, so a machine-wide model policy / runtime / overrides specified globally is honored even outside a project. (#2069) diff --git a/src/config-loader.cts b/src/config-loader.cts index 658297723..ebf3181d0 100644 --- a/src/config-loader.cts +++ b/src/config-loader.cts @@ -731,6 +731,14 @@ function loadConfigResolved(cwd: string, options: Record = {}): fast_mode: (globalDefaults['fast_mode']) || null, agent_skills: (globalDefaults['agent_skills']) || {}, response_language: (globalDefaults['response_language']) || null, + // #2069: forward model_policy / model_profile_overrides / runtime so the global-defaults + // path is at parity with the project-config path (which forwards these three from + // parsed['…'] at the top of this function). Without these entries, ~/.gsd/defaults.json + // silently drops them — model_policy/provider/budget etc. are honored when set in a + // project but ignored when set globally. + runtime: (globalDefaults['runtime']) || null, + model_profile_overrides: (globalDefaults['model_profile_overrides']) || null, + model_policy: (globalDefaults['model_policy']) || null, }; // Branch D: global-defaults try { diff --git a/tests/defaults-json-fallback.test.cjs b/tests/defaults-json-fallback.test.cjs index 4cfec0a2f..a3eb17c3e 100644 --- a/tests/defaults-json-fallback.test.cjs +++ b/tests/defaults-json-fallback.test.cjs @@ -140,4 +140,107 @@ describe('loadConfig ~/.gsd/defaults.json fallback (#1683)', () => { assert.strictEqual(config.model_profile, 'balanced'); assert.strictEqual(config.context_window, 200000); }); + + // ─── #2069: global-defaults must forward model_policy / model_profile_overrides / runtime ─ + // The _globalBaseCfg whitelist in Branch D of loadConfigResolved previously omitted these + // three keys, so they were silently dropped from ~/.gsd/defaults.json while the identical + // keys in a project's .planning/config.json were honored. The tests below are fail-first: + // each asserts the global path forwards its key, mirroring the project-config parity test. + + test('#2069 defaults.json model_policy → forwarded from global defaults', (t) => { + const tmpDir = createBareTmpDir(); + const policy = { provider: 'anthropic', budget: 'high' }; + + const gsdDir = path.join(tmpDir, '.gsd'); + fs.mkdirSync(gsdDir, { recursive: true }); + fs.writeFileSync( + path.join(gsdDir, 'defaults.json'), + JSON.stringify({ model_policy: policy }) + ); + + process.env.GSD_HOME = tmpDir; + t.after(() => { delete process.env.GSD_HOME; cleanup(tmpDir); }); + + const config = loadConfig(tmpDir); + assert.deepStrictEqual(config.model_policy, policy); + }); + + test('#2069 defaults.json model_profile_overrides → forwarded from global defaults', (t) => { + const tmpDir = createBareTmpDir(); + const overrides = { claude: { planner: { model: 'claude-opus-4-5' } } }; + + const gsdDir = path.join(tmpDir, '.gsd'); + fs.mkdirSync(gsdDir, { recursive: true }); + fs.writeFileSync( + path.join(gsdDir, 'defaults.json'), + JSON.stringify({ model_profile_overrides: overrides }) + ); + + process.env.GSD_HOME = tmpDir; + t.after(() => { delete process.env.GSD_HOME; cleanup(tmpDir); }); + + const config = loadConfig(tmpDir); + assert.deepStrictEqual(config.model_profile_overrides, overrides); + }); + + test('#2069 defaults.json runtime → forwarded from global defaults', (t) => { + const tmpDir = createBareTmpDir(); + const runtime = 'codex'; + + const gsdDir = path.join(tmpDir, '.gsd'); + fs.mkdirSync(gsdDir, { recursive: true }); + fs.writeFileSync( + path.join(gsdDir, 'defaults.json'), + JSON.stringify({ runtime }) + ); + + process.env.GSD_HOME = tmpDir; + t.after(() => { delete process.env.GSD_HOME; cleanup(tmpDir); }); + + const config = loadConfig(tmpDir); + assert.strictEqual(config.runtime, runtime); + }); + + test('#2069 defaults.json parity: model_policy / model_profile_overrides / runtime survive identically to project-config path', (t) => { + // Identical values in ~/.gsd/defaults.json (no .planning/) vs .planning/config.json (no defaults.json) + // must produce identical config. for each of the three previously-dropped keys. A future + // regression breaking shape-parity for just one key — e.g. changing the project path to + // `parsed['runtime'] ?? null` — would slip a single-key test, so all three are asserted here. + const policy = { provider: 'anthropic', budget: 'low' }; + const overrides = { claude: { planner: { model: 'claude-opus-4-5' } } }; + const runtime = 'codex'; + + // Global path: ~/.gsd/defaults.json only, no .planning/ + const globalDir = createBareTmpDir(); + const globalGsdDir = path.join(globalDir, '.gsd'); + fs.mkdirSync(globalGsdDir, { recursive: true }); + fs.writeFileSync( + path.join(globalGsdDir, 'defaults.json'), + JSON.stringify({ model_policy: policy, model_profile_overrides: overrides, runtime }) + ); + + process.env.GSD_HOME = globalDir; + const globalConfig = loadConfig(globalDir); + delete process.env.GSD_HOME; + t.after(() => { cleanup(globalDir); }); + + // Project path: .planning/config.json only, no defaults.json + const projectDir = createBareTmpDir(); + fs.mkdirSync(path.join(projectDir, '.planning'), { recursive: true }); + fs.writeFileSync( + path.join(projectDir, '.planning', 'config.json'), + JSON.stringify({ model_policy: policy, model_profile_overrides: overrides, runtime }) + ); + + // GSD_HOME pointing somewhere with no defaults.json so the project path is the sole source. + const emptyHome = createBareTmpDir(); + process.env.GSD_HOME = emptyHome; + const projectConfig = loadConfig(projectDir); + delete process.env.GSD_HOME; + t.after(() => { cleanup(projectDir); cleanup(emptyHome); }); + + assert.deepStrictEqual(globalConfig.model_policy, projectConfig.model_policy); + assert.deepStrictEqual(globalConfig.model_profile_overrides, projectConfig.model_profile_overrides); + assert.strictEqual(globalConfig.runtime, projectConfig.runtime); + }); });