diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 6b2167e50..8d4fd9738 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -269,20 +269,59 @@ const CONFIG_DEFAULTS = { post_planning_gaps: true, // workflow.post_planning_gaps — unified post-planning gap report (#2493): scan REQUIREMENTS.md + CONTEXT.md decisions vs all PLAN.md files }; +/** + * Deep-merge two plain config objects. `overlay` wins on key conflict. + * Explicit `null` in overlay overrides base (null means "unset this key"). + * Arrays are replaced, not merged. Non-object primitives use overlay value. + * + * Note: `undefined` in overlay is treated as "no value provided" and falls + * back to base (preserves inheritance). Explicit `null` overrides base. + */ +function _deepMergeConfig(base, overlay) { + if (overlay === null || overlay === undefined) return overlay; + if (typeof base !== 'object' || typeof overlay !== 'object') return overlay; + const result = { ...base }; + for (const key of Object.keys(overlay)) { + if (overlay[key] !== null && typeof overlay[key] === 'object' && !Array.isArray(overlay[key])) { + result[key] = _deepMergeConfig(base[key] ?? {}, overlay[key]); + } else { + result[key] = overlay[key]; + } + } + return result; +} + function loadConfig(cwd) { + // When GSD_WORKSTREAM is set, load root config first so workstream config + // can inherit from it. This prevents users from duplicating model_overrides, + // workflow.*, etc. across every workstream config (#2714). + const ws = process.env.GSD_WORKSTREAM || null; + let rootParsed = null; + if (ws) { + const rootConfigPath = path.join(planningRoot(cwd), 'config.json'); + try { + const raw = fs.readFileSync(rootConfigPath, 'utf-8'); + rootParsed = JSON.parse(raw); + } catch { + // Root config missing or unparseable — workstream config stands alone + } + } + const configPath = path.join(planningDir(cwd), 'config.json'); const defaults = CONFIG_DEFAULTS; try { const raw = fs.readFileSync(configPath, 'utf-8'); - const parsed = JSON.parse(raw); + // `fileData` is the parsed content of the config.json file on disk — used + // for migrations and writes so we never persist merged values back to disk. + const fileData = JSON.parse(raw); // Migrate deprecated "depth" key to "granularity" with value mapping - if ('depth' in parsed && !('granularity' in parsed)) { + if ('depth' in fileData && !('granularity' in fileData)) { const depthToGranularity = { quick: 'coarse', standard: 'standard', comprehensive: 'fine' }; - parsed.granularity = depthToGranularity[parsed.depth] || parsed.depth; - delete parsed.depth; - try { fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2), 'utf-8'); } catch { /* intentionally empty */ } + fileData.granularity = depthToGranularity[fileData.depth] || fileData.depth; + delete fileData.depth; + try { fs.writeFileSync(configPath, JSON.stringify(fileData, null, 2), 'utf-8'); } catch { /* intentionally empty */ } } // Auto-detect and sync sub_repos: scan for child directories with .git @@ -291,47 +330,52 @@ function loadConfig(cwd) { // Migrate legacy "multiRepo: true" boolean → planning.sub_repos array. // Canonical location is planning.sub_repos (#2561); writing to top-level // would be flagged as unknown by the validator below (#2638). - if (parsed.multiRepo === true && !parsed.sub_repos && !parsed.planning?.sub_repos) { + if (fileData.multiRepo === true && !fileData.sub_repos && !fileData.planning?.sub_repos) { const detected = detectSubRepos(cwd); if (detected.length > 0) { - if (!parsed.planning) parsed.planning = {}; - parsed.planning.sub_repos = detected; - parsed.planning.commit_docs = false; - delete parsed.multiRepo; + if (!fileData.planning) fileData.planning = {}; + fileData.planning.sub_repos = detected; + fileData.planning.commit_docs = false; + delete fileData.multiRepo; configDirty = true; } } // Self-heal legacy/buggy installs: strip any stale top-level sub_repos, // preserving its value as the planning.sub_repos seed if that slot is empty. - if (Object.prototype.hasOwnProperty.call(parsed, 'sub_repos')) { - if (!parsed.planning) parsed.planning = {}; - if (!parsed.planning.sub_repos) { - parsed.planning.sub_repos = parsed.sub_repos; + if (Object.prototype.hasOwnProperty.call(fileData, 'sub_repos')) { + if (!fileData.planning) fileData.planning = {}; + if (!fileData.planning.sub_repos) { + fileData.planning.sub_repos = fileData.sub_repos; } - delete parsed.sub_repos; + delete fileData.sub_repos; configDirty = true; } // Keep planning.sub_repos in sync with actual filesystem - const currentSubRepos = parsed.planning?.sub_repos || []; + const currentSubRepos = fileData.planning?.sub_repos || []; if (Array.isArray(currentSubRepos) && currentSubRepos.length > 0) { const detected = detectSubRepos(cwd); if (detected.length > 0) { const sorted = [...currentSubRepos].sort(); if (JSON.stringify(sorted) !== JSON.stringify(detected)) { - if (!parsed.planning) parsed.planning = {}; - parsed.planning.sub_repos = detected; + if (!fileData.planning) fileData.planning = {}; + fileData.planning.sub_repos = detected; configDirty = true; } } } - // Persist sub_repos changes (migration or sync) + // Persist sub_repos changes (migration or sync) — write only the on-disk + // file contents, never the merged result, to avoid polluting workstream configs. if (configDirty) { - try { fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2), 'utf-8'); } catch {} + try { fs.writeFileSync(configPath, JSON.stringify(fileData, null, 2), 'utf-8'); } catch {} } + // Now apply root→workstream inheritance. `parsed` is the effective config + // used for value extraction below; fileData is kept for disk writes only. + const parsed = rootParsed ? _deepMergeConfig(rootParsed, fileData) : fileData; + // Warn about unrecognized top-level keys so users don't silently lose config. // Derived from config-set's VALID_CONFIG_KEYS (canonical source) plus internal-only // keys that loadConfig handles but config-set doesn't expose. This avoids maintaining @@ -436,8 +480,22 @@ function loadConfig(cwd) { }; } catch { // Fall back to ~/.gsd/defaults.json only for truly pre-project contexts (#1683) - // If .planning/ exists, the project is initialized — just missing config.json + // If .planning/ exists, the project is initialized — just missing config.json. + // When GSD_WORKSTREAM is set and root config was loaded, the workstream config + // doesn't exist — treat root config as the effective config for this workstream. if (fs.existsSync(planningDir(cwd))) { + if (rootParsed) { + // Workstream has no config.json: re-parse using root config as the sole source. + // Temporarily clear GSD_WORKSTREAM so planningDir() returns root .planning/, + // then reload. This is safe: rootParsed is already the root config object. + const savedWs = process.env.GSD_WORKSTREAM; + delete process.env.GSD_WORKSTREAM; + try { + return loadConfig(cwd); + } finally { + process.env.GSD_WORKSTREAM = savedWs; + } + } return defaults; } try { diff --git a/sdk/src/query/phase-lifecycle.test.ts b/sdk/src/query/phase-lifecycle.test.ts index ab1b77c39..3f2863ac5 100644 --- a/sdk/src/query/phase-lifecycle.test.ts +++ b/sdk/src/query/phase-lifecycle.test.ts @@ -226,6 +226,44 @@ describe('replaceInCurrentMilestone', () => { // v1.18 old plans line should remain untouched expect(result).toContain('**Plans:** 1/1 plans'); }); + + it('replaces inside active
even when footer text exists after
', async () => { + const { replaceInCurrentMilestone } = await import('./phase-lifecycle.js'); + // Scenario: active milestone is the last
block, but a footer + // (e.g. "---\n*Last updated*") follows it. The fast-path sees after.trim() + // non-empty and replaces in the footer instead of inside the active block. + const content = [ + '# Roadmap', + '', + '
', + 'v1.0 (Archived)', + '', + '**Plans:** 1/1 plans', + '', + '
', + '', + '
', + 'v2.0 (Active)', + '', + '**Plans:** 1/2 plans', + '', + '
', + '', + '---', + '*Last updated: 2026-01-01*', + ].join('\n'); + + const pattern = /\*\*Plans:\*\* [^\n]+/g; + const result = replaceInCurrentMilestone(content, pattern, '**Plans:** 2/2 plans complete'); + + // Active milestone inside last
should be updated + expect(result).toContain('**Plans:** 2/2 plans complete'); + // Archived milestone should remain untouched + expect(result).toContain('**Plans:** 1/1 plans'); + // Footer should be preserved verbatim + expect(result).toContain('---'); + expect(result).toContain('*Last updated: 2026-01-01*'); + }); }); // ─── readModifyWriteRoadmapMd ─────────────────────────────────────────── diff --git a/sdk/src/query/phase-lifecycle.ts b/sdk/src/query/phase-lifecycle.ts index c42ebe864..306e787c2 100644 --- a/sdk/src/query/phase-lifecycle.ts +++ b/sdk/src/query/phase-lifecycle.ts @@ -111,8 +111,9 @@ export function replaceInCurrentMilestone( // Fast path: the current milestone is not inside a
block — the // pattern lives in the plain text after the last
. - if (after.trim().length > 0) { - return before + after.replace(pattern, replacement); + const replacedAfter = after.replace(pattern, replacement); + if (replacedAfter !== after) { + return before + replacedAfter; } // Slow path: the active milestone is inside the last
block. diff --git a/tests/core.test.cjs b/tests/core.test.cjs index 1ad7d6879..155e212ff 100644 --- a/tests/core.test.cjs +++ b/tests/core.test.cjs @@ -200,6 +200,92 @@ describe('loadConfig', () => { }); }); +// ─── loadConfig workstream config inheritance (#2714) ──────────────────────── + +describe('loadConfig workstream config inheritance (#2714)', () => { + let tmpDir; + let originalEnv; + + beforeEach(() => { + tmpDir = createTempProject(); + originalEnv = process.env.GSD_WORKSTREAM; + delete process.env.GSD_WORKSTREAM; + }); + + afterEach(() => { + if (originalEnv !== undefined) { + process.env.GSD_WORKSTREAM = originalEnv; + } else { + delete process.env.GSD_WORKSTREAM; + } + cleanup(tmpDir); + }); + + function writeRootConfig(obj) { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify(obj, null, 2) + ); + } + + function writeWorkstreamConfig(wsName, obj) { + const wsDir = path.join(tmpDir, '.planning', 'workstreams', wsName); + fs.mkdirSync(wsDir, { recursive: true }); + fs.writeFileSync( + path.join(wsDir, 'config.json'), + JSON.stringify(obj, null, 2) + ); + } + + test('workstream config inherits model_overrides from root when not defined in workstream', () => { + writeRootConfig({ model_overrides: { 'gsd-executor': 'opus' } }); + writeWorkstreamConfig('feature-a', { model_profile: 'quality' }); + process.env.GSD_WORKSTREAM = 'feature-a'; + const config = loadConfig(tmpDir); + assert.deepStrictEqual(config.model_overrides, { 'gsd-executor': 'opus' }); + assert.strictEqual(config.model_profile, 'quality'); + }); + + test('workstream-specific keys override root config values', () => { + writeRootConfig({ model_profile: 'balanced', model_overrides: { 'gsd-executor': 'opus' } }); + writeWorkstreamConfig('feature-b', { model_profile: 'speed', model_overrides: { 'gsd-executor': 'haiku' } }); + process.env.GSD_WORKSTREAM = 'feature-b'; + const config = loadConfig(tmpDir); + assert.strictEqual(config.model_profile, 'speed'); + assert.deepStrictEqual(config.model_overrides, { 'gsd-executor': 'haiku' }); + }); + + test('deep merge works for nested workflow.* keys', () => { + writeRootConfig({ workflow: { research: false, auto_advance: true } }); + writeWorkstreamConfig('feature-c', { workflow: { auto_advance: false } }); + process.env.GSD_WORKSTREAM = 'feature-c'; + const config = loadConfig(tmpDir); + // research inherited from root + assert.strictEqual(config.research, false); + // auto_advance overridden by workstream + assert.strictEqual(config.auto_advance, false); + }); + + test('explicit null in workstream config overrides root value (PR #2717 null-override bug)', () => { + writeRootConfig({ model_overrides: { 'gsd-executor': 'opus', 'gsd-planner': 'sonnet' } }); + writeWorkstreamConfig('feature-d', { model_overrides: null }); + process.env.GSD_WORKSTREAM = 'feature-d'; + const config = loadConfig(tmpDir); + // null in workstream should override root, not fall back to root value + assert.strictEqual(config.model_overrides, null); + }); + + test('workstream without config.json inherits root config', () => { + writeRootConfig({ model_profile: 'quality', model_overrides: { 'gsd-executor': 'opus' } }); + // Create workstream dir without config.json + fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'feature-e'), { recursive: true }); + process.env.GSD_WORKSTREAM = 'feature-e'; + const config = loadConfig(tmpDir); + assert.strictEqual(config.model_profile, 'quality'); + assert.deepStrictEqual(config.model_overrides, { 'gsd-executor': 'opus' }); + }); +}); + // ─── loadConfig commit_docs gitignore auto-detection (#1250) ────────────────── describe('loadConfig commit_docs gitignore auto-detection (#1250)', () => {