From 4684dd93741ab6e41ea888f93784c8266f93758a Mon Sep 17 00:00:00 2001 From: Rod <128448506+rodzved@users.noreply.github.com> Date: Sun, 5 Apr 2026 03:50:32 +0800 Subject: [PATCH 1/2] feat: loadConfig() falls back to ~/.gsd/defaults.json when no project config exists Closes #1683 --- docs/CONFIGURATION.md | 11 ++++++-- get-shit-done/bin/lib/core.cjs | 49 +++++++++++++++++++++++++++++++++- tests/commands.test.cjs | 4 +++ tests/config.test.cjs | 3 ++- tests/core.test.cjs | 45 +++++++++++++++++++++++++++++++ 5 files changed, 108 insertions(+), 4 deletions(-) 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..40734886a7 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -365,7 +365,54 @@ 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 + } + + return { + ...defaults, + model_profile: userDefaults.model_profile ?? defaults.model_profile, + commit_docs: userDefaults.commit_docs ?? defaults.commit_docs, + search_gitignored: userDefaults.search_gitignored ?? defaults.search_gitignored, + branching_strategy: (userDefaults.git?.branching_strategy ?? userDefaults.branching_strategy) ?? defaults.branching_strategy, + phase_branch_template: (userDefaults.git?.phase_branch_template ?? userDefaults.phase_branch_template) ?? defaults.phase_branch_template, + milestone_branch_template: (userDefaults.git?.milestone_branch_template ?? userDefaults.milestone_branch_template) ?? defaults.milestone_branch_template, + quick_branch_template: (userDefaults.git?.quick_branch_template ?? userDefaults.quick_branch_template) ?? defaults.quick_branch_template, + research: (userDefaults.workflow?.research ?? userDefaults.research) ?? defaults.research, + plan_checker: (userDefaults.workflow?.plan_check ?? userDefaults.plan_checker) ?? defaults.plan_checker, + verifier: (userDefaults.workflow?.verifier ?? userDefaults.verifier) ?? defaults.verifier, + nyquist_validation: (userDefaults.workflow?.nyquist_validation ?? userDefaults.nyquist_validation) ?? defaults.nyquist_validation, + parallelization: userDefaults.parallelization ?? defaults.parallelization, + brave_search: userDefaults.brave_search ?? defaults.brave_search, + firecrawl: userDefaults.firecrawl ?? defaults.firecrawl, + exa_search: userDefaults.exa_search ?? defaults.exa_search, + text_mode: (userDefaults.workflow?.text_mode ?? userDefaults.text_mode) ?? defaults.text_mode, + sub_repos: userDefaults.sub_repos ?? defaults.sub_repos, + resolve_model_ids: userDefaults.resolve_model_ids ?? defaults.resolve_model_ids, + context_window: userDefaults.context_window ?? defaults.context_window, + phase_naming: userDefaults.phase_naming ?? defaults.phase_naming, + project_code: userDefaults.project_code ?? defaults.project_code, + subagent_timeout: (userDefaults.workflow?.subagent_timeout ?? userDefaults.subagent_timeout) ?? defaults.subagent_timeout, + model_overrides: userDefaults.model_overrides || null, + agent_skills: userDefaults.agent_skills || {}, + manager: userDefaults.manager || {}, + response_language: userDefaults.response_language || null, + }; } } 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); From f7878f4a8ad9613764216c15da08e97f2e07e27c Mon Sep 17 00:00:00 2001 From: Rod <128448506+rodzved@users.noreply.github.com> Date: Sun, 5 Apr 2026 04:04:35 +0800 Subject: [PATCH 2/2] refactor: replace duplicated key-by-key return with flat merge loop Avoids maintenance trap where new config keys added to the try block would need to be duplicated in the catch fallback path. --- get-shit-done/bin/lib/core.cjs | 51 +++++++++++++++------------------- 1 file changed, 22 insertions(+), 29 deletions(-) diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 40734886a7..75f63481cd 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -384,35 +384,28 @@ function loadConfig(cwd) { // Ignore malformed global defaults } - return { - ...defaults, - model_profile: userDefaults.model_profile ?? defaults.model_profile, - commit_docs: userDefaults.commit_docs ?? defaults.commit_docs, - search_gitignored: userDefaults.search_gitignored ?? defaults.search_gitignored, - branching_strategy: (userDefaults.git?.branching_strategy ?? userDefaults.branching_strategy) ?? defaults.branching_strategy, - phase_branch_template: (userDefaults.git?.phase_branch_template ?? userDefaults.phase_branch_template) ?? defaults.phase_branch_template, - milestone_branch_template: (userDefaults.git?.milestone_branch_template ?? userDefaults.milestone_branch_template) ?? defaults.milestone_branch_template, - quick_branch_template: (userDefaults.git?.quick_branch_template ?? userDefaults.quick_branch_template) ?? defaults.quick_branch_template, - research: (userDefaults.workflow?.research ?? userDefaults.research) ?? defaults.research, - plan_checker: (userDefaults.workflow?.plan_check ?? userDefaults.plan_checker) ?? defaults.plan_checker, - verifier: (userDefaults.workflow?.verifier ?? userDefaults.verifier) ?? defaults.verifier, - nyquist_validation: (userDefaults.workflow?.nyquist_validation ?? userDefaults.nyquist_validation) ?? defaults.nyquist_validation, - parallelization: userDefaults.parallelization ?? defaults.parallelization, - brave_search: userDefaults.brave_search ?? defaults.brave_search, - firecrawl: userDefaults.firecrawl ?? defaults.firecrawl, - exa_search: userDefaults.exa_search ?? defaults.exa_search, - text_mode: (userDefaults.workflow?.text_mode ?? userDefaults.text_mode) ?? defaults.text_mode, - sub_repos: userDefaults.sub_repos ?? defaults.sub_repos, - resolve_model_ids: userDefaults.resolve_model_ids ?? defaults.resolve_model_ids, - context_window: userDefaults.context_window ?? defaults.context_window, - phase_naming: userDefaults.phase_naming ?? defaults.phase_naming, - project_code: userDefaults.project_code ?? defaults.project_code, - subagent_timeout: (userDefaults.workflow?.subagent_timeout ?? userDefaults.subagent_timeout) ?? defaults.subagent_timeout, - model_overrides: userDefaults.model_overrides || null, - agent_skills: userDefaults.agent_skills || {}, - manager: userDefaults.manager || {}, - response_language: userDefaults.response_language || null, - }; + // 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; } }