From 0f8f7537dae1417a8ff9f10429885a560cc8512f Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Fri, 24 Apr 2026 18:08:07 -0400 Subject: [PATCH] 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) --- sdk/src/config.test.ts | 84 ++++++++++++++++++++++++++++++++++++++++++ sdk/src/config.ts | 61 +++++++++++++++++++++++++++--- 2 files changed, 139 insertions(+), 6 deletions(-) diff --git a/sdk/src/config.test.ts b/sdk/src/config.test.ts index 843737a33..fc7baaef1 100644 --- a/sdk/src/config.test.ts +++ b/sdk/src/config.test.ts @@ -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).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).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); diff --git a/sdk/src/config.ts b/sdk/src/config.ts index acc27cda2..13390b74d 100644 --- a/sdk/src/config.ts +++ b/sdk/src/config.ts @@ -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> { + 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; + } catch { + return {}; + } +} + export async function loadConfig(projectDir: string, workstream?: string): Promise { 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; @@ -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): GSDConfig { return { ...structuredClone(CONFIG_DEFAULTS), ...parsed,