diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 892ce43b68..cccbb07db0 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -453,8 +453,15 @@ The intent is the same as the Claude profile tiers -- use a stronger model for p ## Global Defaults -Save settings as global defaults for future projects: +Save settings as global defaults for all GSD commands: **Location:** `~/.gsd/defaults.json` -When `/gsd-new-project` creates a new `config.json`, it reads global defaults and merges them as the starting configuration. Per-project settings always override globals. +Global defaults are consulted in two scenarios: + +1. **Project initialization:** When `/gsd-new-project` creates a new `config.json`, it reads global defaults and merges them as the starting configuration. +2. **Pre-project commands:** Commands that run before project initialization (e.g., `map-codebase`) use global defaults as a fallback when no `.planning/config.json` exists. + +**Merge order:** `hardcoded defaults ← ~/.gsd/defaults.json ← .planning/config.json` + +Per-project settings always override globals. diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 7477741dbf..75f63481cd 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -365,7 +365,47 @@ function loadConfig(cwd) { response_language: get('response_language') || null, }; } catch { - return defaults; + // No project config — check ~/.gsd/defaults.json as intermediate fallback. + // This ensures pre-project commands (e.g., map-codebase) respect user globals. + const globalDefaultsPath = path.join(os.homedir(), '.gsd', 'defaults.json'); + let userDefaults = {}; + try { + if (fs.existsSync(globalDefaultsPath)) { + userDefaults = JSON.parse(fs.readFileSync(globalDefaultsPath, 'utf-8')); + // Migrate deprecated "depth" key to "granularity" + if ('depth' in userDefaults && !('granularity' in userDefaults)) { + const depthToGranularity = { quick: 'coarse', standard: 'standard', comprehensive: 'fine' }; + userDefaults.granularity = depthToGranularity[userDefaults.depth] || userDefaults.depth; + delete userDefaults.depth; + try { fs.writeFileSync(globalDefaultsPath, JSON.stringify(userDefaults, null, 2), 'utf-8'); } catch { /* intentionally empty */ } + } + } + } catch { + // Ignore malformed global defaults + } + + // Flatten nested defaults.json keys (git.*, workflow.*) to top-level, + // matching how the try block's get() helper resolves both formats. + const flat = { ...defaults }; + for (const [key, val] of Object.entries(userDefaults)) { + if (key === 'git' || key === 'workflow') { + // Nested section — merge each sub-key, mapping workflow.plan_check → plan_checker + if (val && typeof val === 'object') { + for (const [subKey, subVal] of Object.entries(val)) { + const mapped = (key === 'workflow' && subKey === 'plan_check') ? 'plan_checker' : subKey; + if (mapped in flat) flat[mapped] = subVal; + } + } + } else if (key in flat) { + flat[key] = val; + } + } + // Include keys that aren't in the hardcoded defaults but loadConfig() returns + flat.model_overrides = userDefaults.model_overrides || null; + flat.agent_skills = userDefaults.agent_skills || {}; + flat.manager = userDefaults.manager || {}; + flat.response_language = userDefaults.response_language || null; + return flat; } } diff --git a/tests/commands.test.cjs b/tests/commands.test.cjs index a899dcfa57..b86c9dd319 100644 --- a/tests/commands.test.cjs +++ b/tests/commands.test.cjs @@ -1048,12 +1048,16 @@ describe('verify-path-exists command', () => { describe('resolve-model command', () => { let tmpDir; + let originalHome; beforeEach(() => { tmpDir = createTempProject(); + originalHome = process.env.HOME; + process.env.HOME = tmpDir; }); afterEach(() => { + process.env.HOME = originalHome; cleanup(tmpDir); }); diff --git a/tests/config.test.cjs b/tests/config.test.cjs index 0fbcb3bb65..dd4ba9bd85 100644 --- a/tests/config.test.cjs +++ b/tests/config.test.cjs @@ -39,7 +39,8 @@ describe('config-ensure-section command', () => { }); test('creates config.json with expected structure and types', () => { - const result = runGsdTools('config-ensure-section', tmpDir); + // Sandbox HOME to prevent real ~/.gsd/defaults.json from leaking in + const result = runGsdTools('config-ensure-section', tmpDir, { HOME: tmpDir }); assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); diff --git a/tests/core.test.cjs b/tests/core.test.cjs index 88428a7db2..db66b2d14f 100644 --- a/tests/core.test.cjs +++ b/tests/core.test.cjs @@ -38,14 +38,19 @@ const { describe('loadConfig', () => { let tmpDir; let originalCwd; + let originalHome; beforeEach(() => { tmpDir = createTempProject(); originalCwd = process.cwd(); + // Sandbox HOME so real ~/.gsd/defaults.json doesn't leak into tests + originalHome = process.env.HOME; + process.env.HOME = tmpDir; }); afterEach(() => { process.chdir(originalCwd); + process.env.HOME = originalHome; cleanup(tmpDir); }); @@ -121,6 +126,46 @@ describe('loadConfig', () => { assert.strictEqual(config.commit_docs, true); }); + test('falls back to ~/.gsd/defaults.json when no config.json exists', () => { + // Remove .planning/config.json so loadConfig hits the catch path + fs.rmSync(path.join(tmpDir, '.planning', 'config.json'), { force: true }); + // Create ~/.gsd/defaults.json in the sandboxed HOME + const gsdDir = path.join(tmpDir, '.gsd'); + fs.mkdirSync(gsdDir, { recursive: true }); + fs.writeFileSync( + path.join(gsdDir, 'defaults.json'), + JSON.stringify({ model_profile: 'quality', resolve_model_ids: true }) + ); + const config = loadConfig(tmpDir); + assert.strictEqual(config.model_profile, 'quality'); + assert.strictEqual(config.resolve_model_ids, true); + // Keys not in defaults.json should still get hardcoded defaults + assert.strictEqual(config.commit_docs, true); + }); + + test('falls back to hardcoded defaults when no config.json and no defaults.json', () => { + fs.rmSync(path.join(tmpDir, '.planning', 'config.json'), { force: true }); + const config = loadConfig(tmpDir); + assert.strictEqual(config.model_profile, 'balanced'); + assert.strictEqual(config.commit_docs, true); + }); + + test('defaults.json supports nested keys (git, workflow)', () => { + fs.rmSync(path.join(tmpDir, '.planning', 'config.json'), { force: true }); + const gsdDir = path.join(tmpDir, '.gsd'); + fs.mkdirSync(gsdDir, { recursive: true }); + fs.writeFileSync( + path.join(gsdDir, 'defaults.json'), + JSON.stringify({ + git: { branching_strategy: 'per-phase' }, + workflow: { research: false }, + }) + ); + const config = loadConfig(tmpDir); + assert.strictEqual(config.branching_strategy, 'per-phase'); + assert.strictEqual(config.research, false); + }); + test('handles parallelization as boolean', () => { writeConfig({ parallelization: false }); const config = loadConfig(tmpDir);