fix(#2702): workstream config-get inherits absent keys from root config (#2833)

* test(#2702): failing-first regression for workstream config-get root inheritance

* fix(#2702): workstream config-get inherits absent keys from root config

* fix(#2702): root inheritance wins over --default; gate on GSD_WORKSTREAM not path (review)

* test(#2702): make GSD_PROJECT test exit-safe (--default sentinel, no error path)

* docs(changeset): #2702 workstream config-get root inheritance

* docs(changeset): backfill #2702 PR number to 2833
This commit is contained in:
Tom Boucher
2026-07-29 14:19:57 -04:00
committed by GitHub
parent 6229f0e55c
commit 8fc244b754
3 changed files with 244 additions and 4 deletions

View File

@@ -18,7 +18,7 @@ const { CONFIG_DEFAULTS } = configLoader;
import { platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs');
const { planningDir, withPlanningLock } = planningWorkspace;
const { planningDir, planningRoot, withPlanningLock } = planningWorkspace;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import modelProfiles = require('./model-profiles.cjs');
const { VALID_PROFILES, getAgentToModelMapForProfile, formatAgentToModelMapAsTable } = modelProfiles;
@@ -965,10 +965,15 @@ function cmdConfigGet(cwd: string, keyPath: string | undefined, raw: boolean, de
try {
if (fs.existsSync(configPath)) {
config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
} else if (hasDefault) {
emitResolvedDefault(kp, defaultValue, raw);
return;
} else {
// #2702: when a workstream is active and has no config.json of its own, fall
// back to the project ROOT config first — a key the user configured at root is
// a real, present value and must inherit (per #1893: a present key wins over
// --default). Only when root also misses do --default / schema default apply.
// (When no workstream is active, resolveFromRootConfig is a no-op: same file.)
const rootVal = resolveFromRootConfig(cwd, kp);
if (rootVal.found) { emitResolvedDefault(kp, rootVal.value, raw); return; }
if (hasDefault) { emitResolvedDefault(kp, defaultValue, raw); return; }
const sd = resolveSchemaDefault(cwd, kp);
if (sd.found) { emitResolvedDefault(kp, sd.value, raw); return; }
error('No config.json found at ' + configPath, ERROR_REASON.CONFIG_NO_FILE);
@@ -983,6 +988,9 @@ function cmdConfigGet(cwd: string, keyPath: string | undefined, raw: boolean, de
let current: unknown = config;
for (const key of keys) {
if (current === undefined || current === null || typeof current !== 'object') {
// #2702: root-config inheritance before --default / schema default (see above).
const rootVal = resolveFromRootConfig(cwd, kp);
if (rootVal.found) { emitResolvedDefault(kp, rootVal.value, raw); return; }
if (hasDefault) { emitResolvedDefault(kp, defaultValue, raw); return; }
const sd = resolveSchemaDefault(cwd, kp);
if (sd.found) { emitResolvedDefault(kp, sd.value, raw); return; }
@@ -1003,6 +1011,9 @@ function cmdConfigGet(cwd: string, keyPath: string | undefined, raw: boolean, de
}
if (current === undefined) {
// #2702: root-config inheritance before --default / schema default (see above).
const rootVal = resolveFromRootConfig(cwd, kp);
if (rootVal.found) { emitResolvedDefault(kp, rootVal.value, raw); return; }
if (hasDefault) { emitResolvedDefault(kp, defaultValue, raw); return; }
const sd = resolveSchemaDefault(cwd, kp);
if (sd.found) { emitResolvedDefault(kp, sd.value, raw); return; }
@@ -1020,6 +1031,51 @@ function cmdConfigGet(cwd: string, keyPath: string | undefined, raw: boolean, de
output(current, raw, String(current));
}
/**
* #2702: resolve a dot-notation key against the project ROOT config
* (`.planning/config.json`), ignoring any active workstream scope. Returns
* `{found:false}` when the root config is absent, unparseable, or does not
* contain the key. This is the inheritance rung `cmdConfigGet` was missing —
* when a workstream's own config doesn't set a key, the project root value
* must show through (workstream overrides root; it never fully replaces it),
* exactly as `loadConfigResolved`'s root+workstream merge already does for
* every other config consumer. No-op (found:false) when no workstream is
* active, because `planningDir === planningRoot` and the caller already read
* that file directly.
*/
function resolveFromRootConfig(cwd: string, kp: string): { found: boolean; value: unknown } {
// Only meaningful when a workstream is active (GSD_WORKSTREAM set) — that is what
// redirects planningDir away from root AND what loadConfigResolved gates root-reading
// on. Gating on `process.env.GSD_WORKSTREAM` (not on a planningDir !== planningRoot
// path inequality) avoids a false trigger under GSD_PROJECT alone, where planningDir
// diverges from planningRoot without a workstream and loadConfigResolved does NOT
// inherit root — matching the runtime's own `if (ws)` gate keeps the two surfaces
// from diverging on the project-scoped (non-workstream) case.
if (!process.env['GSD_WORKSTREAM']) return { found: false, value: undefined };
const root = planningRoot(cwd);
const rootConfigPath = path.join(root, 'config.json');
let rootConfig: Record<string, unknown>;
try {
if (!fs.existsSync(rootConfigPath)) return { found: false, value: undefined };
rootConfig = JSON.parse(fs.readFileSync(rootConfigPath, 'utf-8')) as Record<string, unknown>;
} catch {
// Unparseable root config → don't inherit (do not let a corrupt root file
// change config-get's verdict). Fall through to schema default / error.
return { found: false, value: undefined };
}
let current: unknown = rootConfig;
for (const key of kp.split('.')) {
if (current === undefined || current === null || typeof current !== 'object') {
return { found: false, value: undefined };
}
current = Object.prototype.hasOwnProperty.call(current, key)
? (current as Record<string, unknown>)[key]
: undefined;
}
if (current === undefined) return { found: false, value: undefined };
return { found: true, value: current };
}
/**
* Command to set the model profile in the config file.
*