* 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:
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user