diff --git a/.changeset/1114-write-profile-runtime-home.md b/.changeset/1114-write-profile-runtime-home.md new file mode 100644 index 000000000..0c12716bc --- /dev/null +++ b/.changeset/1114-write-profile-runtime-home.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1119 +--- +**`write-profile` now writes `USER-PROFILE.md` to the active runtime's config home instead of always `~/.claude`** — under Codex, `gsd-tools query write-profile` wrote `~/.claude/gsd-core/USER-PROFILE.md` while Codex `discuss-phase` advisor-mode (installed under `~/.codex`) checked the Codex home and never found it, so advisor-mode silently stayed disabled. The default output path is now resolved via the runtime-aware `getGlobalConfigDir` (`GSD_RUNTIME` / `config.runtime` → e.g. `~/.codex` for Codex), matching how the runtime's own workflows resolve it — mirroring `generate-dev-preferences`. Claude is unchanged (`~/.claude`); an explicit `--output` still wins. (#1114) diff --git a/src/profile-output.cts b/src/profile-output.cts index 425d9988c..73b826034 100644 --- a/src/profile-output.cts +++ b/src/profile-output.cts @@ -20,7 +20,7 @@ import os from 'node:os'; import core = require('./core.cjs'); const { output, error, loadConfig } = core; import { platformReadSync as safeReadFile, platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs'; -import { getGlobalSkillDir } from './runtime-homes.cjs'; +import { getGlobalSkillDir, getGlobalConfigDir } from './runtime-homes.cjs'; import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs'; import { resolveRuntimeNameFromCandidates } from './runtime-name-policy.cjs'; @@ -729,7 +729,27 @@ function cmdWriteProfile(cwd: string, options: CmdWriteProfileOptions, raw: bool let outputPath = options.output; if (!outputPath) { - outputPath = path.join(os.homedir(), '.claude', 'gsd-core', 'USER-PROFILE.md'); + // #1114: resolve the ACTIVE runtime's config home so the profile is written + // where the runtime's own workflows look for it. Previously this hardcoded the + // Claude home, so a Codex run wrote the profile under the Claude config dir + // while Codex advisor-mode (installed under the Codex home) checked the Codex + // dir and never found it. Mirrors cmdGenerateDevPreferences' runtime resolution. + let effectiveRuntime = 'claude'; + try { + const config = loadConfig(cwd); + effectiveRuntime = resolveRuntimeNameFromCandidates( + process.env['GSD_RUNTIME'], + config['runtime'], + 'claude' + ) || 'claude'; + } catch { + effectiveRuntime = resolveRuntimeNameFromCandidates( + process.env['GSD_RUNTIME'], + 'claude' + ) || 'claude'; + } + // path.join (not a string literal) keeps the cline-install leaked-path lint quiet. + outputPath = path.join(getGlobalConfigDir(effectiveRuntime), 'gsd-core', 'USER-PROFILE.md'); } else if (!path.isAbsolute(outputPath)) { outputPath = path.join(cwd, outputPath); } diff --git a/tests/profile-output.test.cjs b/tests/profile-output.test.cjs index 3179146da..f5f837943 100644 --- a/tests/profile-output.test.cjs +++ b/tests/profile-output.test.cjs @@ -117,6 +117,73 @@ describe('write-profile command', () => { assert.ok(out.dimensions_scored > 0, 'should have scored dimensions'); }); + test('#1114: default output resolves the active runtime config home (codex)', () => { + const analysis = { + profile_version: '1.0', + dimensions: { communication_style: { rating: 'terse-direct', confidence: 'HIGH' } }, + }; + const analysisPath = path.join(tmpDir, 'analysis.json'); + const codexHome = path.join(tmpDir, 'codex-home'); + fs.writeFileSync(analysisPath, JSON.stringify(analysis)); + + const result = runGsdTools( + ['write-profile', '--input', analysisPath, '--raw'], + tmpDir, + { CODEX_HOME: codexHome, GSD_RUNTIME: 'codex' } + ); + assert.ok(result.success, `Failed: ${result.error}`); + const out = JSON.parse(result.output); + // Must land in the Codex home (so Codex advisor-mode finds it), NOT .claude. + assert.strictEqual(out.profile_path, path.join(codexHome, 'gsd-core', 'USER-PROFILE.md')); + assert.ok(!out.profile_path.includes(`${path.sep}.claude${path.sep}`), + `codex profile must not be written under .claude; got ${out.profile_path}`); + assert.ok(fs.existsSync(out.profile_path), 'runtime-aware profile should be written to disk'); + }); + + test('#1114: default output is the .claude config home for the claude runtime', () => { + const analysis = { + profile_version: '1.0', + dimensions: { communication_style: { rating: 'terse-direct', confidence: 'HIGH' } }, + }; + const analysisPath = path.join(tmpDir, 'analysis.json'); + fs.writeFileSync(analysisPath, JSON.stringify(analysis)); + + // Clear ambient runtime vars so the test is hermetic regardless of the + // developer's shell (a stray GSD_RUNTIME/CLAUDE_CONFIG_DIR would redirect it). + const result = runGsdTools(['write-profile', '--input', analysisPath, '--raw'], tmpDir, + { HOME: tmpDir, GSD_RUNTIME: '', CLAUDE_CONFIG_DIR: '', CODEX_HOME: '' }); + assert.ok(result.success, `Failed: ${result.error}`); + const out = JSON.parse(result.output); + // os.homedir() returns HOME verbatim, so assert on the suffix to stay + // robust against macOS /var → /private/var symlink normalization. + assert.ok( + out.profile_path.endsWith(path.join('.claude', 'gsd-core', 'USER-PROFILE.md')), + `claude profile must be under .claude/gsd-core; got ${out.profile_path}` + ); + assert.ok(fs.existsSync(out.profile_path), 'profile should be written to disk'); + }); + + test('#1114: config.runtime=codex (no GSD_RUNTIME) also resolves the Codex home', () => { + const analysis = { + profile_version: '1.0', + dimensions: { communication_style: { rating: 'terse-direct', confidence: 'HIGH' } }, + }; + const analysisPath = path.join(tmpDir, 'analysis.json'); + const codexHome = path.join(tmpDir, 'codex-home'); + fs.writeFileSync(analysisPath, JSON.stringify(analysis)); + fs.writeFileSync(path.join(tmpDir, '.planning', 'config.json'), JSON.stringify({ runtime: 'codex' })); + + // No GSD_RUNTIME — the runtime must be read from config.runtime. + const result = runGsdTools( + ['write-profile', '--input', analysisPath, '--raw'], + tmpDir, + { CODEX_HOME: codexHome, GSD_RUNTIME: '' } + ); + assert.ok(result.success, `Failed: ${result.error}`); + const out = JSON.parse(result.output); + assert.strictEqual(out.profile_path, path.join(codexHome, 'gsd-core', 'USER-PROFILE.md')); + }); + test('errors when --input is missing', () => { const result = runGsdTools('write-profile --raw', tmpDir); assert.ok(!result.success, 'should fail without --input');