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