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
|
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) {
|
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 configPath = path.join(planningDir(cwd), 'config.json');
|
||||||
const defaults = CONFIG_DEFAULTS;
|
const defaults = CONFIG_DEFAULTS;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw = fs.readFileSync(configPath, 'utf-8');
|
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
|
// 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' };
|
const depthToGranularity = { quick: 'coarse', standard: 'standard', comprehensive: 'fine' };
|
||||||
parsed.granularity = depthToGranularity[parsed.depth] || parsed.depth;
|
fileData.granularity = depthToGranularity[fileData.depth] || fileData.depth;
|
||||||
delete parsed.depth;
|
delete fileData.depth;
|
||||||
try { fs.writeFileSync(configPath, JSON.stringify(parsed, null, 2), 'utf-8'); } catch { /* intentionally empty */ }
|
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
|
// 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.
|
// Migrate legacy "multiRepo: true" boolean → planning.sub_repos array.
|
||||||
// Canonical location is planning.sub_repos (#2561); writing to top-level
|
// Canonical location is planning.sub_repos (#2561); writing to top-level
|
||||||
// would be flagged as unknown by the validator below (#2638).
|
// 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);
|
const detected = detectSubRepos(cwd);
|
||||||
if (detected.length > 0) {
|
if (detected.length > 0) {
|
||||||
if (!parsed.planning) parsed.planning = {};
|
if (!fileData.planning) fileData.planning = {};
|
||||||
parsed.planning.sub_repos = detected;
|
fileData.planning.sub_repos = detected;
|
||||||
parsed.planning.commit_docs = false;
|
fileData.planning.commit_docs = false;
|
||||||
delete parsed.multiRepo;
|
delete fileData.multiRepo;
|
||||||
configDirty = true;
|
configDirty = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Self-heal legacy/buggy installs: strip any stale top-level sub_repos,
|
// 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.
|
// preserving its value as the planning.sub_repos seed if that slot is empty.
|
||||||
if (Object.prototype.hasOwnProperty.call(parsed, 'sub_repos')) {
|
if (Object.prototype.hasOwnProperty.call(fileData, 'sub_repos')) {
|
||||||
if (!parsed.planning) parsed.planning = {};
|
if (!fileData.planning) fileData.planning = {};
|
||||||
if (!parsed.planning.sub_repos) {
|
if (!fileData.planning.sub_repos) {
|
||||||
parsed.planning.sub_repos = parsed.sub_repos;
|
fileData.planning.sub_repos = fileData.sub_repos;
|
||||||
}
|
}
|
||||||
delete parsed.sub_repos;
|
delete fileData.sub_repos;
|
||||||
configDirty = true;
|
configDirty = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep planning.sub_repos in sync with actual filesystem
|
// 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) {
|
if (Array.isArray(currentSubRepos) && currentSubRepos.length > 0) {
|
||||||
const detected = detectSubRepos(cwd);
|
const detected = detectSubRepos(cwd);
|
||||||
if (detected.length > 0) {
|
if (detected.length > 0) {
|
||||||
const sorted = [...currentSubRepos].sort();
|
const sorted = [...currentSubRepos].sort();
|
||||||
if (JSON.stringify(sorted) !== JSON.stringify(detected)) {
|
if (JSON.stringify(sorted) !== JSON.stringify(detected)) {
|
||||||
if (!parsed.planning) parsed.planning = {};
|
if (!fileData.planning) fileData.planning = {};
|
||||||
parsed.planning.sub_repos = detected;
|
fileData.planning.sub_repos = detected;
|
||||||
configDirty = true;
|
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) {
|
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.
|
// 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
|
// 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
|
// keys that loadConfig handles but config-set doesn't expose. This avoids maintaining
|
||||||
@@ -436,8 +480,22 @@ function loadConfig(cwd) {
|
|||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// Fall back to ~/.gsd/defaults.json only for truly pre-project contexts (#1683)
|
// 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 (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;
|
return defaults;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -226,6 +226,44 @@ describe('replaceInCurrentMilestone', () => {
|
|||||||
// v1.18 old plans line should remain untouched
|
// v1.18 old plans line should remain untouched
|
||||||
expect(result).toContain('**Plans:** 1/1 plans');
|
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 ───────────────────────────────────────────
|
// ─── readModifyWriteRoadmapMd ───────────────────────────────────────────
|
||||||
|
|||||||
@@ -111,8 +111,9 @@ export function replaceInCurrentMilestone(
|
|||||||
|
|
||||||
// Fast path: the current milestone is not inside a <details> block — the
|
// Fast path: the current milestone is not inside a <details> block — the
|
||||||
// pattern lives in the plain text after the last </details>.
|
// pattern lives in the plain text after the last </details>.
|
||||||
if (after.trim().length > 0) {
|
const replacedAfter = after.replace(pattern, replacement);
|
||||||
return before + after.replace(pattern, replacement);
|
if (replacedAfter !== after) {
|
||||||
|
return before + replacedAfter;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Slow path: the active milestone is inside the last <details> block.
|
// 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) ──────────────────
|
// ─── loadConfig commit_docs gitignore auto-detection (#1250) ──────────────────
|
||||||
|
|
||||||
describe('loadConfig commit_docs gitignore auto-detection (#1250)', () => {
|
describe('loadConfig commit_docs gitignore auto-detection (#1250)', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user