Files
msd-core/sdk/src/config.ts
Tom Boucher 0f8f7537da 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)
2026-04-24 18:08:07 -04:00

235 lines
7.6 KiB
TypeScript

/**
* Config reader — loads `.planning/config.json` and merges with defaults.
*
* Mirrors the default structure from `get-shit-done/bin/lib/config.cjs`
* `buildNewProjectConfig()`.
*/
import { readFile } from 'node:fs/promises';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { relPlanningPath } from './workstream-utils.js';
// ─── Types ───────────────────────────────────────────────────────────────────
export interface GitConfig {
branching_strategy: string;
phase_branch_template: string;
milestone_branch_template: string;
quick_branch_template: string | null;
}
export interface WorkflowConfig {
research: boolean;
plan_check: boolean;
verifier: boolean;
nyquist_validation: boolean;
/** Mirrors gsd-tools flat `config.tdd_mode` (from `workflow.tdd_mode`). */
tdd_mode: boolean;
auto_advance: boolean;
node_repair: boolean;
node_repair_budget: number;
ui_phase: boolean;
ui_safety_gate: boolean;
text_mode: boolean;
research_before_questions: boolean;
discuss_mode: string;
skip_discuss: boolean;
/** Maximum self-discuss passes in auto/headless mode before forcing proceed. Default: 3. */
max_discuss_passes: number;
/** Subagent timeout in ms (matches `get-shit-done/bin/lib/core.cjs` default 300000). */
subagent_timeout: number;
/**
* Issue #2492. When true (default), enforces that every trackable decision in
* CONTEXT.md `<decisions>` is referenced by at least one plan (translation
* gate, blocking) and reports decisions not honored by shipped artifacts at
* verify-phase (validation gate, non-blocking). Set false to disable both.
*/
context_coverage_gate: boolean;
}
export interface HooksConfig {
context_warnings: boolean;
}
export interface GSDConfig {
model_profile: string;
commit_docs: boolean;
parallelization: boolean;
search_gitignored: boolean;
brave_search: boolean;
firecrawl: boolean;
exa_search: boolean;
git: GitConfig;
workflow: WorkflowConfig;
hooks: HooksConfig;
agent_skills: Record<string, unknown>;
/** Project slug for branch templates; mirrors gsd-tools `config.project_code`. */
project_code?: string | null;
/** Interactive vs headless; mirrors gsd-tools flat `config.mode`. */
mode?: string;
/** Internal auto-chain flag; mirrors gsd-tools `config._auto_chain_active`. */
_auto_chain_active?: boolean;
[key: string]: unknown;
}
// ─── Defaults ────────────────────────────────────────────────────────────────
export const CONFIG_DEFAULTS: GSDConfig = {
model_profile: 'balanced',
commit_docs: true,
parallelization: true,
search_gitignored: false,
brave_search: false,
firecrawl: false,
exa_search: false,
git: {
branching_strategy: 'none',
phase_branch_template: 'gsd/phase-{phase}-{slug}',
milestone_branch_template: 'gsd/{milestone}-{slug}',
quick_branch_template: null,
},
workflow: {
research: true,
plan_check: true,
verifier: true,
nyquist_validation: true,
tdd_mode: false,
auto_advance: false,
node_repair: true,
node_repair_budget: 2,
ui_phase: true,
ui_safety_gate: true,
text_mode: false,
research_before_questions: false,
discuss_mode: 'discuss',
skip_discuss: false,
max_discuss_passes: 3,
subagent_timeout: 300000,
context_coverage_gate: true,
},
hooks: {
context_warnings: true,
},
agent_skills: {},
project_code: null,
mode: 'interactive',
_auto_chain_active: false,
};
// ─── Loader ──────────────────────────────────────────────────────────────────
/**
* Load project config from `.planning/config.json`, merging with defaults.
* 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 {
raw = '';
}
} else {
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 === '') {
// 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>;
try {
parsed = JSON.parse(trimmed);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to parse config at ${configPath}: ${msg}`);
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error(`Config at ${configPath} must be a JSON object`);
}
// 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,
git: {
...CONFIG_DEFAULTS.git,
...(parsed.git as Partial<GitConfig> ?? {}),
},
workflow: {
...CONFIG_DEFAULTS.workflow,
...(parsed.workflow as Partial<WorkflowConfig> ?? {}),
},
hooks: {
...CONFIG_DEFAULTS.hooks,
...(parsed.hooks as Partial<HooksConfig> ?? {}),
},
agent_skills: {
...CONFIG_DEFAULTS.agent_skills,
...(parsed.agent_skills as Record<string, unknown> ?? {}),
},
};
}