diff --git a/.changeset/calm-bears-travel.md b/.changeset/calm-bears-travel.md new file mode 100644 index 000000000..8418186dd --- /dev/null +++ b/.changeset/calm-bears-travel.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 2833 +--- +**Workstream-scoped config reads now inherit from the project root config** — `config-get` under an active workstream (`GSD_WORKSTREAM`) now resolves a key absent from the workstream's own config to the project-root value before falling back to schema defaults, instead of reporting 'Key not found'. A workstream config still overrides root for any key it sets; root only fills gaps. Previously a key set only at root was silently lost under a workstream, causing shipped workflow boolean guards (e.g. use_worktrees, plan_review_convergence) to apply their hardcoded fallback and silently invert the user's setting. diff --git a/src/config.cts b/src/config.cts index c83d6101d..826873ac7 100644 --- a/src/config.cts +++ b/src/config.cts @@ -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; - } 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; + try { + if (!fs.existsSync(rootConfigPath)) return { found: false, value: undefined }; + rootConfig = JSON.parse(fs.readFileSync(rootConfigPath, 'utf-8')) as Record; + } 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)[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. * diff --git a/tests/config-get-default.test.cjs b/tests/config-get-default.test.cjs index d89f427fc..948807f52 100644 --- a/tests/config-get-default.test.cjs +++ b/tests/config-get-default.test.cjs @@ -1376,3 +1376,182 @@ test('runCli treats jsonErrors=false as an explicit human-formatter path', (t) = }); }); } + +// ─── #2702: workstream-scoped config-get inherits from root config ────────── +// +// When GSD_WORKSTREAM is set, planningDir(cwd) points at .planning/workstreams//. +// cmdConfigGet used to read ONLY that file, so a key configured at the project root +// (.planning/config.json) but absent from the workstream's own config was reported +// "Key not found" — silently inverting boolean workflow guards. The fix adds a +// root-config inheritance rung: workstream overrides root, but root fills gaps. + +describe('#2702: workstream config-get inherits from root config', () => { + let tmpDir; + let rootPlanningDir; + let wsPlanningDir; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-config-ws-2702-')); + rootPlanningDir = path.join(tmpDir, '.planning'); + wsPlanningDir = path.join(rootPlanningDir, 'workstreams', 'alpha'); + fs.mkdirSync(wsPlanningDir, { recursive: true }); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + // Run cmdConfigGet with GSD_WORKSTREAM scoped to 'alpha' (so planningDir → ws dir). + function runScoped(...args) { + const { keyPath, raw, defaultValue } = parseConfigGetArgs(args); + const saved = process.env.GSD_WORKSTREAM; + process.env.GSD_WORKSTREAM = 'alpha'; + let out; + try { + out = captureFdWrite(1, () => { + config.cmdConfigGet(tmpDir, keyPath, raw, defaultValue); + }); + } finally { + if (saved === undefined) delete process.env.GSD_WORKSTREAM; + else process.env.GSD_WORKSTREAM = saved; + } + return out.trim(); + } + + function runScopedExpectError(...args) { + const { keyPath, raw, defaultValue } = parseConfigGetArgs(args); + const saved = process.env.GSD_WORKSTREAM; + process.env.GSD_WORKSTREAM = 'alpha'; + const origExit = process.exit; + const origWriteSync = fs.writeSync; + io.setJsonErrorMode(true); + let exitCode; + let stderr = ''; + fs.writeSync = (fd, ...rest) => { + if (fd !== 2) return origWriteSync.call(fs, fd, ...rest); + stderr += String(rest[0]); + return Buffer.byteLength(String(rest[0])); + }; + process.exit = (code) => { exitCode = code; throw new _ExitSignal(code); }; + try { + config.cmdConfigGet(tmpDir, keyPath, raw, defaultValue); + } catch (e) { + if (!(e instanceof _ExitSignal)) throw e; + } finally { + process.exit = origExit; + fs.writeSync = origWriteSync; + io.setJsonErrorMode(false); + if (saved === undefined) delete process.env.GSD_WORKSTREAM; + else process.env.GSD_WORKSTREAM = saved; + } + const parts = stderr.split('\n').filter(Boolean); + let payload = {}; + try { payload = JSON.parse(parts[parts.length - 1]); } catch { /* human mode */ } + return { status: exitCode, reason: payload.reason }; + } + + function writeRoot(obj) { + fs.writeFileSync(path.join(rootPlanningDir, 'config.json'), JSON.stringify(obj)); + } + function writeWs(obj) { + fs.writeFileSync(path.join(wsPlanningDir, 'config.json'), JSON.stringify(obj)); + } + + test('inherits a fail-closed key (workflow.use_worktrees) from root when workstream omits it', () => { + writeRoot({ workflow: { use_worktrees: true } }); + // workstream config exists but does NOT set the key + writeWs({ workflow: {} }); + assert.equal(runScoped('config-get', 'workflow.use_worktrees', '--raw'), 'true'); + }); + + test('inherits a fail-open key (workflow.plan_review_convergence) from root', () => { + writeRoot({ workflow: { plan_review_convergence: true } }); + writeWs({ workflow: {} }); + assert.equal(runScoped('config-get', 'workflow.plan_review_convergence', '--raw'), 'true'); + }); + + test('inherits a core key (workflow.verifier) from root', () => { + writeRoot({ workflow: { verifier: true } }); + writeWs({ workflow: {} }); + assert.equal(runScoped('config-get', 'workflow.verifier', '--raw'), 'true'); + }); + + test('workstream config overrides root value for a key it sets', () => { + writeRoot({ workflow: { use_worktrees: true } }); + writeWs({ workflow: { use_worktrees: false } }); + assert.equal(runScoped('config-get', 'workflow.use_worktrees', '--raw'), 'false'); + }); + + test('still errors on a key absent from workstream, root, and schema', () => { + writeWs({ workflow: {} }); + writeRoot({ workflow: {} }); + const { status, reason } = runScopedExpectError('config-get', 'workflow.totally_absent', '--raw'); + assert.notEqual(status, 0, 'a key absent everywhere must error'); + assert.equal(reason, io.ERROR_REASON.CONFIG_KEY_NOT_FOUND); + }); + + test('--default is ignored when the key is inherited from root', () => { + writeRoot({ workflow: { use_worktrees: true } }); + writeWs({ workflow: {} }); + assert.equal( + runScoped('config-get', 'workflow.use_worktrees', '--default', 'false', '--raw'), + 'true', + ); + }); + + test('inherits a nested fail-closed key (workflow.context_coverage_gate) from root', () => { + writeRoot({ workflow: { context_coverage_gate: false } }); + writeWs({ workflow: {} }); + assert.equal(runScoped('config-get', 'workflow.context_coverage_gate', '--raw'), 'false'); + }); + + test('scoped and unscoped reads return the same string form when workstream does not override', () => { + writeRoot({ workflow: { use_worktrees: true } }); + writeWs({ workflow: {} }); + const scoped = runScoped('config-get', 'workflow.use_worktrees', '--raw'); + // Unscoped read: no GSD_WORKSTREAM — reads root directly. + const unscoped = captureFdWrite(1, () => { + config.cmdConfigGet(tmpDir, 'workflow.use_worktrees', true, undefined); + }).trim(); + assert.equal(scoped, unscoped, 'scoped (inherited) and unscoped reads must agree'); + assert.equal(scoped, 'true'); + }); + + test('unscoped config-get still reads root value (no regression when not scoped)', () => { + writeRoot({ workflow: { use_worktrees: true } }); + const out = captureFdWrite(1, () => { + config.cmdConfigGet(tmpDir, 'workflow.use_worktrees', true, undefined); + }).trim(); + assert.equal(out, 'true'); + }); + + test('inherits from root even when the workstream has no config.json at all', () => { + writeRoot({ workflow: { use_worktrees: true } }); + // No writeWs — workstreams/alpha/config.json does not exist. + assert.equal(runScoped('config-get', 'workflow.use_worktrees', '--raw'), 'true'); + }); + + test('GSD_PROJECT alone (no workstream) does not trigger root inheritance — matches loadConfigResolved', () => { + // GSD_PROJECT scopes planningDir to .planning// but loadConfigResolved + // gates root-reading on `if (ws)`, so a project-only read must NOT inherit root. + // The fix gates on GSD_WORKSTREAM presence (not path inequality) to match. + // A --default is supplied so a non-inheriting read returns a clean sentinel + // ('not-inherited') rather than hitting the error/exit path. + const projectPlanningDir = path.join(rootPlanningDir, 'myproj'); + fs.mkdirSync(projectPlanningDir, { recursive: true }); + fs.writeFileSync(path.join(projectPlanningDir, 'config.json'), JSON.stringify({ workflow: {} })); + writeRoot({ workflow: { use_worktrees: true } }); + const saved = process.env.GSD_PROJECT; + process.env.GSD_PROJECT = 'myproj'; + try { + const out = captureFdWrite(1, () => { + config.cmdConfigGet(tmpDir, 'workflow.use_worktrees', true, 'not-inherited'); + }).trim(); + // No workstream → no root inheritance → returns the --default sentinel, NOT root 'true'. + assert.equal(out, 'not-inherited', 'GSD_PROJECT-only must not inherit root (matches loadConfigResolved)'); + } finally { + if (saved === undefined) delete process.env.GSD_PROJECT; + else process.env.GSD_PROJECT = saved; + } + }); +});