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:
@@ -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 {
|
||||
|
||||
@@ -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 ───────────────────────────────────────────
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)', () => {
|
||||
|
||||
Reference in New Issue
Block a user