Compare commits

...

2 Commits

Author SHA1 Message Date
Tom Boucher
8c585d1d10 fix(#2714): null merge bug and GSD_PROJECT scope bug in workstream config inheritance
Two bugs in the workstream config inheritance added by this PR:

1. _deepMergeConfig treated `null === undefined`, so a workstream config
   setting a key to null was silently ignored and the root value bled
   through. Fix: only `undefined` short-circuits to base; `null` is
   returned as-is (overlay wins), letting loadConfig's `??` fall back
   to the system default rather than the root-inherited value.

2. rootConfigPath used planningRoot(cwd) which always resolves to
   .planning/ regardless of GSD_PROJECT. With both GSD_PROJECT and
   GSD_WORKSTREAM set, the base config was read from the wrong project's
   root. Fix: use planningDir(cwd, null) which strips the workstream
   segment but preserves project scope.

Tests: 4 regression tests added to core.test.cjs covering both bugs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 16:14:49 -04:00
Junny Lee
a675a20c38 feat: workstream config inherits from root .planning/config.json
When GSD_WORKSTREAM is set, loadConfig() previously read only
.planning/workstreams/<ws>/config.json. If that file was missing or
didn't define a key, the resolver fell back to CONFIG_DEFAULTS — never
to the root .planning/config.json. This forced users with multiple
workstreams (e.g. junny, henry, junny-p1, junny-p2) to duplicate
model_overrides, workflow flags, context_window, features, and other
settings into every workstream config.

This change makes workstream configs deep-merge over root config:

- Root .planning/config.json acts as a base (project-wide defaults)
- Workstream config.json overlays per-workstream overrides
- Workstream wins on key conflict; unset keys inherit from root
- Missing workstream config falls back to root entirely (no defaults)

Matches the standard config-inheritance pattern (npm, eslint, tsconfig).
Backwards compatible: workstreams with full standalone configs keep
working — they just stop falling through to defaults for unset keys,
and instead fall through to root.

Closes #2714
2026-04-25 10:51:59 -07:00
2 changed files with 150 additions and 1 deletions

View File

@@ -269,12 +269,51 @@ 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 config objects. `overlay` wins on key conflict; nested
* objects are merged recursively. Arrays and primitives in `overlay`
* replace the corresponding value in `base` entirely (no array merge).
*
* Used by loadConfig to inherit root .planning/config.json into a
* workstream config when GSD_WORKSTREAM is set (#2714).
*/
function _deepMergeConfig(base, overlay) {
if (overlay === undefined) return base;
if (overlay === null) return null;
if (base === null || base === undefined) return overlay;
if (typeof base !== 'object' || typeof overlay !== 'object') return overlay;
if (Array.isArray(base) || Array.isArray(overlay)) return overlay;
const out = { ...base };
for (const k of Object.keys(overlay)) {
out[k] = _deepMergeConfig(base[k], overlay[k]);
}
return out;
}
function loadConfig(cwd) {
const configPath = path.join(planningDir(cwd), 'config.json');
const rootConfigPath = path.join(planningDir(cwd, null), 'config.json');
const defaults = CONFIG_DEFAULTS;
// Inherit-from-root (#2714): when GSD_WORKSTREAM is active, read the root
// config and the workstream config, deep-merge with workstream-wins. If the
// workstream config is missing, fall back to root entirely. Workstream
// settings override root for shared keys; unset keys inherit from root.
let inheritedRaw = null;
if (configPath !== rootConfigPath && process.env.GSD_WORKSTREAM) {
let rootObj = null;
let wsObj = null;
try { rootObj = JSON.parse(fs.readFileSync(rootConfigPath, 'utf-8')); } catch { /* no root config */ }
try { wsObj = JSON.parse(fs.readFileSync(configPath, 'utf-8')); } catch { /* no ws config */ }
if (rootObj && wsObj) {
inheritedRaw = JSON.stringify(_deepMergeConfig(rootObj, wsObj));
} else if (rootObj && !wsObj) {
inheritedRaw = JSON.stringify(rootObj);
}
}
try {
const raw = fs.readFileSync(configPath, 'utf-8');
const raw = inheritedRaw || fs.readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(raw);
// Migrate deprecated "depth" key to "granularity" with value mapping

View File

@@ -200,6 +200,116 @@ describe('loadConfig', () => {
});
});
// ─── loadConfig workstream config inheritance (#2714) ─────────────────────────
describe('loadConfig workstream config inheritance (#2714)', () => {
let tmpDir;
let savedProject, savedWorkstream;
beforeEach(() => {
tmpDir = createTempProject();
savedProject = process.env.GSD_PROJECT;
savedWorkstream = process.env.GSD_WORKSTREAM;
delete process.env.GSD_PROJECT;
delete process.env.GSD_WORKSTREAM;
});
afterEach(() => {
if (savedProject !== undefined) process.env.GSD_PROJECT = savedProject;
else delete process.env.GSD_PROJECT;
if (savedWorkstream !== undefined) process.env.GSD_WORKSTREAM = savedWorkstream;
else delete process.env.GSD_WORKSTREAM;
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('workstream config inherits keys missing from workstream config', () => {
// Root config sets model_profile; workstream config omits it.
// Workstream should inherit root's model_profile.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ model_profile: 'quality' })
);
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'alice');
fs.mkdirSync(wsDir, { recursive: true });
fs.writeFileSync(
path.join(wsDir, 'config.json'),
JSON.stringify({ brave_search: true })
);
process.env.GSD_WORKSTREAM = 'alice';
const config = loadConfig(tmpDir);
assert.strictEqual(config.model_profile, 'quality', 'should inherit model_profile from root');
assert.strictEqual(config.brave_search, true, 'should keep workstream-specific key');
});
test('workstream config wins on key conflict with root', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ model_profile: 'quality' })
);
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'bob');
fs.mkdirSync(wsDir, { recursive: true });
fs.writeFileSync(
path.join(wsDir, 'config.json'),
JSON.stringify({ model_profile: 'fast' })
);
process.env.GSD_WORKSTREAM = 'bob';
const config = loadConfig(tmpDir);
assert.strictEqual(config.model_profile, 'fast', 'workstream override must win over root');
});
test('workstream null value clears root-inherited value (falls back to system default)', () => {
// Bug 1 (#2714): null in workstream config must win over root, not be ignored.
// workstream sets context_window: null to clear the root-inherited 80000.
// The merge should produce null, which loadConfig's `??` converts to the
// system default (200000) — NOT the root-inherited value (80000).
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ context_window: 80000, model_profile: 'quality' })
);
const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'charlie');
fs.mkdirSync(wsDir, { recursive: true });
fs.writeFileSync(
path.join(wsDir, 'config.json'),
JSON.stringify({ context_window: null })
);
process.env.GSD_WORKSTREAM = 'charlie';
const config = loadConfig(tmpDir);
// Root's 80000 must NOT bleed through — null in workstream clears it
assert.notStrictEqual(config.context_window, 80000, 'root context_window must not bleed through when workstream sets null');
// loadConfig applies `null ?? systemDefault`, so system default (200000) is used
assert.strictEqual(config.context_window, 200000, 'null workstream override should resolve to system default, not root value');
});
test('workstream config inheritance respects GSD_PROJECT scope (not bare .planning root)', () => {
// Bug 2 (#2714): with GSD_PROJECT set, rootConfigPath must be
// .planning/{project}/config.json, not .planning/config.json.
// Write a WRONG root-level config (should NOT be read):
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ model_profile: 'fast' }) // wrong project — must not bleed in
);
// Write the CORRECT project-scoped root config:
const projectDir = path.join(tmpDir, '.planning', 'myapp');
fs.mkdirSync(projectDir, { recursive: true });
fs.writeFileSync(
path.join(projectDir, 'config.json'),
JSON.stringify({ model_profile: 'quality' })
);
// Workstream config under the project scope:
const wsDir = path.join(tmpDir, '.planning', 'myapp', 'workstreams', 'dana');
fs.mkdirSync(wsDir, { recursive: true });
fs.writeFileSync(
path.join(wsDir, 'config.json'),
JSON.stringify({ brave_search: true })
);
process.env.GSD_PROJECT = 'myapp';
process.env.GSD_WORKSTREAM = 'dana';
const config = loadConfig(tmpDir);
assert.strictEqual(config.model_profile, 'quality', 'must inherit from project-scoped root, not bare .planning root');
assert.strictEqual(config.brave_search, true, 'workstream-specific key must still apply');
});
});
// ─── loadConfig commit_docs gitignore auto-detection (#1250) ──────────────────
describe('loadConfig commit_docs gitignore auto-detection (#1250)', () => {