fix(#2652): layer ~/.gsd/defaults.json over built-ins in SDK loadConfig (#2663)

* fix(#2652): layer ~/.gsd/defaults.json over built-ins in SDK loadConfig

SDK loadConfig only merged built-in CONFIG_DEFAULTS, so pre-project init
queries (e.g. resolveModel in Codex installs) ignored user-level knobs like
resolve_model_ids: "omit" and emitted Claude model aliases from MODEL_PROFILES.

Port the user-defaults layer from get-shit-done/bin/lib/config.cjs:65 to the
TS loader. CJS parity: user defaults only apply when no .planning/config.json
exists (buildNewProjectConfig already bakes them in at /gsd:new-project time).

Fixes #2652

* fix(#2652): isolate GSD_HOME in test, refresh loadConfig JSDoc (CodeRabbit)
This commit is contained in:
Tom Boucher
2026-04-24 18:08:07 -04:00
committed by GitHub
parent 709f0382bf
commit 0f8f7537da
2 changed files with 139 additions and 6 deletions

View File

@@ -6,16 +6,37 @@ import { tmpdir } from 'node:os';
describe('loadConfig', () => {
let tmpDir: string;
let fakeHome: string;
let prevHome: string | undefined;
let prevGsdHome: string | undefined;
beforeEach(async () => {
tmpDir = join(tmpdir(), `gsd-config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(join(tmpDir, '.planning'), { recursive: true });
// Isolate ~/.gsd/defaults.json by pointing HOME at an empty tmp dir.
fakeHome = join(tmpdir(), `gsd-home-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await mkdir(fakeHome, { recursive: true });
prevHome = process.env.HOME;
process.env.HOME = fakeHome;
// Also isolate GSD_HOME (loadUserDefaults prefers it over HOME).
prevGsdHome = process.env.GSD_HOME;
delete process.env.GSD_HOME;
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
await rm(fakeHome, { recursive: true, force: true });
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
if (prevGsdHome === undefined) delete process.env.GSD_HOME;
else process.env.GSD_HOME = prevGsdHome;
});
async function writeUserDefaults(defaults: unknown) {
await mkdir(join(fakeHome, '.gsd'), { recursive: true });
await writeFile(join(fakeHome, '.gsd', 'defaults.json'), JSON.stringify(defaults));
}
it('returns all defaults when config file is missing', async () => {
// No config.json created
await rm(join(tmpDir, '.planning', 'config.json'), { force: true });
@@ -154,6 +175,69 @@ describe('loadConfig', () => {
expect(config.parallelization).toBe(0);
});
// ─── User-level defaults (~/.gsd/defaults.json) ─────────────────────────
// Regression: issue #2652 — SDK loadConfig ignored user-level defaults
// for pre-project Codex installs, so init.quick still emitted Claude
// model aliases from MODEL_PROFILES via resolveModel even when the user
// had `resolve_model_ids: "omit"` in ~/.gsd/defaults.json.
//
// Mirrors CJS behavior in get-shit-done/bin/lib/core.cjs:421 (#1683):
// user-level defaults only apply when no project .planning/config.json
// exists (pre-project context). Once a project is initialized, its
// config.json is authoritative — buildNewProjectConfig baked the user
// defaults in at /gsd:new-project time.
it('pre-project: layers user defaults from ~/.gsd/defaults.json', async () => {
await writeUserDefaults({ resolve_model_ids: 'omit' });
// No project config.json
const config = await loadConfig(tmpDir);
expect((config as Record<string, unknown>).resolve_model_ids).toBe('omit');
// Built-in defaults still present for keys user did not override
expect(config.model_profile).toBe('balanced');
expect(config.workflow.plan_check).toBe(true);
});
it('pre-project: deep-merges nested keys from user defaults', async () => {
await writeUserDefaults({
git: { branching_strategy: 'milestone' },
agent_skills: { planner: 'user-skill' },
});
const config = await loadConfig(tmpDir);
expect(config.git.branching_strategy).toBe('milestone');
expect(config.git.phase_branch_template).toBe('gsd/phase-{phase}-{slug}');
expect(config.agent_skills).toEqual({ planner: 'user-skill' });
});
it('project config is authoritative over user defaults (CJS parity)', async () => {
// User defaults set resolve_model_ids: "omit", but project config omits it.
// Per CJS core.cjs loadConfig (#1683): once .planning/config.json exists,
// ~/.gsd/defaults.json is ignored — buildNewProjectConfig already baked
// the user defaults in at project creation time.
await writeUserDefaults({
resolve_model_ids: 'omit',
model_profile: 'fast',
});
await writeFile(
join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ model_profile: 'quality' }),
);
const config = await loadConfig(tmpDir);
expect(config.model_profile).toBe('quality');
// User-defaults not layered when project config present
expect((config as Record<string, unknown>).resolve_model_ids).toBeUndefined();
});
it('ignores malformed ~/.gsd/defaults.json', async () => {
await mkdir(join(fakeHome, '.gsd'), { recursive: true });
await writeFile(join(fakeHome, '.gsd', 'defaults.json'), '{not json');
const config = await loadConfig(tmpDir);
// Falls back to built-in defaults
expect(config).toEqual(CONFIG_DEFAULTS);
});
it('does not mutate CONFIG_DEFAULTS between calls', async () => {
const before = structuredClone(CONFIG_DEFAULTS);

View File

@@ -6,6 +6,7 @@
*/
import { readFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { relPlanningPath } from './workstream-utils.js';
@@ -120,33 +121,76 @@ export const CONFIG_DEFAULTS: GSDConfig = {
/**
* Load project config from `.planning/config.json`, merging with defaults.
* Returns full defaults when file is missing or empty.
* When project config is missing or empty, layers user defaults
* (`~/.gsd/defaults.json`) over built-in defaults.
* Throws on malformed JSON with a helpful error message.
*/
/**
* Read user-level defaults from `~/.gsd/defaults.json` (or `$GSD_HOME/.gsd/`
* when set). Returns `{}` when the file is missing, empty, or malformed —
* matches CJS behavior in `get-shit-done/bin/lib/core.cjs` (#1683, #2652).
*/
async function loadUserDefaults(): Promise<Record<string, unknown>> {
const home = process.env.GSD_HOME || homedir();
const defaultsPath = join(home, '.gsd', 'defaults.json');
let raw: string;
try {
raw = await readFile(defaultsPath, 'utf-8');
} catch {
return {};
}
const trimmed = raw.trim();
if (trimmed === '') return {};
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
return {};
}
return parsed as Record<string, unknown>;
} catch {
return {};
}
}
export async function loadConfig(projectDir: string, workstream?: string): Promise<GSDConfig> {
const configPath = join(projectDir, relPlanningPath(workstream), 'config.json');
const rootConfigPath = join(projectDir, '.planning', 'config.json');
let raw: string;
let projectConfigFound = false;
try {
raw = await readFile(configPath, 'utf-8');
projectConfigFound = true;
} catch {
// If workstream config missing, fall back to root config
if (workstream) {
try {
raw = await readFile(rootConfigPath, 'utf-8');
projectConfigFound = true;
} catch {
return structuredClone(CONFIG_DEFAULTS);
raw = '';
}
} else {
// File missing — normal for new projects
return structuredClone(CONFIG_DEFAULTS);
raw = '';
}
}
// Pre-project context: no .planning/config.json exists. Layer user-level
// defaults from ~/.gsd/defaults.json over built-in defaults. Mirrors the
// CJS fall-back branch in get-shit-done/bin/lib/core.cjs:421 (#1683) so
// SDK-dispatched init queries (e.g. resolveModel in Codex installs, #2652)
// honor user-level knobs like `resolve_model_ids: "omit"`.
if (!projectConfigFound) {
const userDefaults = await loadUserDefaults();
return mergeDefaults(userDefaults);
}
const trimmed = raw.trim();
if (trimmed === '') {
return structuredClone(CONFIG_DEFAULTS);
// Empty project config — treat as no project config (CJS core.cjs
// catches JSON.parse on empty and falls through to the pre-project path).
const userDefaults = await loadUserDefaults();
return mergeDefaults(userDefaults);
}
let parsed: Record<string, unknown>;
@@ -161,7 +205,12 @@ export async function loadConfig(projectDir: string, workstream?: string): Promi
throw new Error(`Config at ${configPath} must be a JSON object`);
}
// Three-level deep merge: defaults <- parsed
// Project config exists — user-level defaults are ignored (CJS parity).
// `buildNewProjectConfig` already baked them into config.json at /gsd:new-project.
return mergeDefaults(parsed);
}
function mergeDefaults(parsed: Record<string, unknown>): GSDConfig {
return {
...structuredClone(CONFIG_DEFAULTS),
...parsed,