Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
42 changes: 41 additions & 1 deletion get-shit-done/bin/lib/core.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand Down
4 changes: 4 additions & 0 deletions tests/commands.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down
3 changes: 2 additions & 1 deletion tests/config.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
45 changes: 45 additions & 0 deletions tests/core.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});

Expand Down Expand Up @@ -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);
Expand Down