Merge pull request #2743 from gsd-build/fix/2714-workstream-config-inheritance

feat(config): workstream config.json inherits from root .planning/config.json
This commit is contained in:
Tom Boucher
2026-04-26 11:49:30 -04:00
committed by GitHub
4 changed files with 206 additions and 23 deletions

View File

@@ -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 {

View File

@@ -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 <details> even when footer text exists after </details>', async () => {
const { replaceInCurrentMilestone } = await import('./phase-lifecycle.js');
// Scenario: active milestone is the last <details> 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',
'',
'<details>',
'<summary>v1.0 (Archived)</summary>',
'',
'**Plans:** 1/1 plans',
'',
'</details>',
'',
'<details>',
'<summary>v2.0 (Active)</summary>',
'',
'**Plans:** 1/2 plans',
'',
'</details>',
'',
'---',
'*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 <details> 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 ───────────────────────────────────────────

View File

@@ -111,8 +111,9 @@ export function replaceInCurrentMilestone(
// Fast path: the current milestone is not inside a <details> block — the
// pattern lives in the plain text after the last </details>.
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 <details> block.

View File

@@ -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)', () => {