* test(#2565): add failing regression for generate-claude-profile runtime target cmdGenerateClaudeProfile hardcodes .claude/CLAUDE.md (project + global), ignoring the runtime policy that #3163 wired into the sibling cmdGenerateClaudeMd handler. These tests pin the parity contract for project scope (codex -> AGENTS.md, env precedence, --output override, claude preserved), global scope (codex -> <CODEX_HOME>/AGENTS.md, claude preserved), and a divergence guard asserting both handlers agree on the project instruction path for the same runtime. Failing-first: all seven tests reproduce the bug on unmodified next. * fix(#2565): route generate-claude-profile target through runtime policy cmdGenerateClaudeProfile hardcoded .claude/CLAUDE.md for both project and global scope, ignoring the runtime-aware resolution that #3163 wired into the sibling cmdGenerateClaudeMd handler. The #3163 fix diverged when it did not propagate here, so /gsd-profile-user kept writing Claude instruction files on Codex installs (and other AGENTS-native runtimes: opencode, kilo, kimi, antigravity, copilot). Fix mirrors the proven #3163 pattern using existing policy primitives: - Project scope resolves through getProjectInstructionFile(runtime) and a non-claude runtime wins over a stale claude_md_path (AGENTS-native projects must never write to CLAUDE.md). - Global scope derives ~/.<config-home>/<instruction-basename> via getGlobalConfigDir + basename(getProjectInstructionFile), so codex lands at ~/.codex/AGENTS.md. Claude global is preserved byte-for-byte (no env-var drift beyond the prior hardcoded path). - GSD_RUNTIME env var takes precedence over config.runtime. A parity test asserts both handlers agree on the project instruction path for the same runtime, guarding against future re-divergence (CLAUDE.md 'Generative Fix Divergence' rule). * docs(#2565): add changeset fragment for generate-claude-profile runtime fix * docs(#2565): remove parenthetical product description from changeset The product-name purity guard (#1777) flags 'ProductName (description)' patterns in changeset fragments because the prose renders verbatim into CHANGELOG.md at release time. The original fragment had 'Codex (and other AGENTS-native runtimes)' in the bold header, which matched the banned pattern. Reworded to drop the parenthetical; also trimmed the per-runtime mapping (belongs in code comments, not changelog prose). * docs(#2565): backfill PR number in changeset fragment * test(#2565): isolate os.homedir() cross-platform in claude global test The 'global scope: claude runtime writes to ~/.claude/CLAUDE.md' test set only HOME to redirect os.homedir() at a tmpDir. On Windows, Node's os.homedir() reads USERPROFILE (not HOME), so the child process still resolved the real user profile and the path assertion failed (#2659 CI). Set USERPROFILE alongside HOME so the isolation holds on both POSIX and Windows. Production code is unchanged — it uses os.homedir() exactly as the prior hardcoded path did.
This commit is contained in:
5
.changeset/amber-foxes-leap.md
Normal file
5
.changeset/amber-foxes-leap.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 2659
|
||||
---
|
||||
**`/gsd-profile-user` now writes the runtime-native instruction file on Codex and other AGENTS-native runtimes** — `generate-claude-profile` hardcoded `.claude/CLAUDE.md` for both project and global scope, ignoring the runtime-aware resolution that #3163 wired into the sibling `generate-claude-md` handler. The #3163 fix diverged when it didn't propagate here, so running `$gsd-profile-user --refresh` on a Codex install created/modified Claude configuration instead of producing a Codex `AGENTS.md` profile. The command now resolves its target through the shared runtime policy: project scope uses `getProjectInstructionFile(runtime)` and global scope derives `~/.<config-home>/<instruction-basename>`, so codex lands at `~/.codex/AGENTS.md`. Claude behaviour is preserved. A parity test guards against future re-divergence between the two handlers. (#2659)
|
||||
@@ -1028,20 +1028,52 @@ function cmdGenerateClaudeProfile(cwd: string, options: CmdGenerateClaudeProfile
|
||||
|
||||
const sectionContent = sectionLines.join('\n');
|
||||
|
||||
// #2565: resolve target through effective runtime policy instead of
|
||||
// hardcoded .claude/CLAUDE.md. Mirrors the #3163 fix applied to
|
||||
// cmdGenerateClaudeMd above; that fix diverged when it didn't propagate
|
||||
// here, leaving /gsd-profile-user writing Claude files on Codex installs.
|
||||
// - Project scope: getProjectInstructionFile(runtime) is the single source
|
||||
// of truth (AGENTS.md for codex/opencode/kilo/kimi/unknown; GEMINI.md for
|
||||
// antigravity; .github/copilot-instructions.md for copilot).
|
||||
// - Global scope: ~/.<config-home>/<instruction-basename>, derived from
|
||||
// getGlobalConfigDir + basename(getProjectInstructionFile), so codex lands
|
||||
// at ~/.codex/AGENTS.md. Claude global is preserved byte-for-byte (no
|
||||
// env-var drift beyond the prior hardcoded path).
|
||||
// - GSD_RUNTIME env var takes precedence over config.runtime (mirrors the
|
||||
// #3163 env-precedence contract). Non-claude always wins over a stale
|
||||
// claude_md_path (#3163 rationale: an AGENTS-native project must never
|
||||
// write to CLAUDE.md even if a prior Claude setup left claude_md_path).
|
||||
let config: Record<string, unknown> = {};
|
||||
try {
|
||||
config = loadConfig(cwd);
|
||||
} catch { /* use defaults */ }
|
||||
const effectiveRuntime = resolveRuntimeNameFromCandidates(
|
||||
process.env['GSD_RUNTIME'],
|
||||
config['runtime'],
|
||||
);
|
||||
const isClaudeRuntime = !effectiveRuntime || effectiveRuntime === 'claude';
|
||||
|
||||
let targetPath: string;
|
||||
if (options.global) {
|
||||
targetPath = path.join(os.homedir(), '.claude', 'CLAUDE.md');
|
||||
if (isClaudeRuntime) {
|
||||
targetPath = path.join(os.homedir(), '.claude', 'CLAUDE.md');
|
||||
} else {
|
||||
targetPath = path.join(
|
||||
getGlobalConfigDir(effectiveRuntime),
|
||||
path.basename(getProjectInstructionFile(effectiveRuntime)),
|
||||
);
|
||||
}
|
||||
} else if (options.output) {
|
||||
targetPath = path.isAbsolute(options.output) ? options.output : path.join(cwd, options.output);
|
||||
} else {
|
||||
// Read claude_md_path from config; #1098 default is ./.claude/CLAUDE.md
|
||||
// Read claude_md_path from config; #1098 default is .claude/CLAUDE.md
|
||||
// (kept consistent with cmdGenerateClaudeMd so the profile section and the
|
||||
// managed sections land in the same file on a config-less project).
|
||||
let configClaudeMdPath = './.claude/CLAUDE.md';
|
||||
try {
|
||||
const config = loadConfig(cwd);
|
||||
if (config['claude_md_path']) configClaudeMdPath = config['claude_md_path'] as string;
|
||||
} catch { /* use default */ }
|
||||
let configClaudeMdPath = '.claude/CLAUDE.md';
|
||||
if (config['claude_md_path']) configClaudeMdPath = config['claude_md_path'] as string;
|
||||
if (!isClaudeRuntime) {
|
||||
configClaudeMdPath = getProjectInstructionFile(effectiveRuntime);
|
||||
}
|
||||
targetPath = path.isAbsolute(configClaudeMdPath) ? configClaudeMdPath : path.join(cwd, configClaudeMdPath);
|
||||
}
|
||||
|
||||
|
||||
@@ -488,3 +488,202 @@ describe('bug #3163: generate-claude-md uses AGENTS.md for Codex runtime', () =>
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Bug #2565: generate-claude-profile runtime-aware target ─────────────
|
||||
// #2565 is the same divergence class as #3163 above, but in the sibling
|
||||
// handler cmdGenerateClaudeProfile (the /gsd-profile-user surface) instead
|
||||
// of cmdGenerateClaudeMd. The #3163 fix did not propagate here, so
|
||||
// generate-claude-profile kept writing .claude/CLAUDE.md on Codex installs.
|
||||
// These tests pin the parity contract: both handlers MUST resolve the
|
||||
// project instruction file through getProjectInstructionFile for non-claude
|
||||
// runtimes, and the final test asserts the two never diverge again
|
||||
// (the "generative fix divergence" guard from CLAUDE.md).
|
||||
describe('bug #2565: generate-claude-profile uses AGENTS.md for Codex runtime', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'PROJECT.md'),
|
||||
'# Test Project\n\nA Codex-hosted project.\n',
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
function writeAnalysis() {
|
||||
const analysisPath = path.join(tmpDir, '.planning', 'analysis.json');
|
||||
fs.writeFileSync(
|
||||
analysisPath,
|
||||
JSON.stringify({
|
||||
dimensions: { communication_style: { rating: 'terse-direct', confidence: 'HIGH' } },
|
||||
data_source: 'test',
|
||||
}),
|
||||
'utf-8'
|
||||
);
|
||||
return analysisPath;
|
||||
}
|
||||
|
||||
function writeConfig(obj) {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify(obj),
|
||||
'utf-8'
|
||||
);
|
||||
}
|
||||
|
||||
test('project scope writes AGENTS.md when config.runtime is codex (stale claude_md_path overridden)', () => {
|
||||
writeConfig({ runtime: 'codex', claude_md_path: './CLAUDE.md' });
|
||||
const analysisPath = writeAnalysis();
|
||||
|
||||
const result = runGsdTools(
|
||||
['generate-claude-profile', '--analysis', analysisPath],
|
||||
tmpDir,
|
||||
{ HOME: tmpDir }
|
||||
);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const parsed = JSON.parse(result.output);
|
||||
const realTmpDir = fs.realpathSync(tmpDir);
|
||||
const expectedAgentsPath = path.join(realTmpDir, 'AGENTS.md');
|
||||
|
||||
assert.strictEqual(parsed.claude_md_path, expectedAgentsPath,
|
||||
`Expected output path to be AGENTS.md but got: ${parsed.claude_md_path}`);
|
||||
assert.ok(fs.existsSync(expectedAgentsPath), 'AGENTS.md must exist after generation');
|
||||
assert.ok(!fs.existsSync(path.join(realTmpDir, 'CLAUDE.md')),
|
||||
'CLAUDE.md must not be created for Codex runtime');
|
||||
});
|
||||
|
||||
test('project scope: GSD_RUNTIME=codex env var overrides config.runtime=claude', () => {
|
||||
writeConfig({ runtime: 'claude', claude_md_path: './CLAUDE.md' });
|
||||
const analysisPath = writeAnalysis();
|
||||
|
||||
const result = runGsdTools(
|
||||
['generate-claude-profile', '--analysis', analysisPath],
|
||||
tmpDir,
|
||||
{ HOME: tmpDir, GSD_RUNTIME: 'codex' }
|
||||
);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const parsed = JSON.parse(result.output);
|
||||
const realTmpDir = fs.realpathSync(tmpDir);
|
||||
assert.strictEqual(parsed.claude_md_path, path.join(realTmpDir, 'AGENTS.md'),
|
||||
`Expected AGENTS.md under GSD_RUNTIME=codex but got: ${parsed.claude_md_path}`);
|
||||
assert.ok(!fs.existsSync(path.join(realTmpDir, 'CLAUDE.md')),
|
||||
'CLAUDE.md must not be created when GSD_RUNTIME=codex');
|
||||
});
|
||||
|
||||
test('project scope: --output flag overrides runtime detection when explicitly provided', () => {
|
||||
writeConfig({ runtime: 'codex', claude_md_path: './CLAUDE.md' });
|
||||
const analysisPath = writeAnalysis();
|
||||
|
||||
const result = runGsdTools(
|
||||
['generate-claude-profile', '--analysis', analysisPath, '--output', 'EXPLICIT-OUTPUT.md'],
|
||||
tmpDir,
|
||||
{ HOME: tmpDir }
|
||||
);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const parsed = JSON.parse(result.output);
|
||||
const realTmpDir = fs.realpathSync(tmpDir);
|
||||
assert.strictEqual(parsed.claude_md_path, path.join(realTmpDir, 'EXPLICIT-OUTPUT.md'),
|
||||
`Expected explicit --output honoured but got: ${parsed.claude_md_path}`);
|
||||
});
|
||||
|
||||
test('project scope: claude runtime still writes .claude/CLAUDE.md', () => {
|
||||
writeConfig({ runtime: 'claude', claude_md_path: './.claude/CLAUDE.md' });
|
||||
const analysisPath = writeAnalysis();
|
||||
|
||||
const result = runGsdTools(
|
||||
['generate-claude-profile', '--analysis', analysisPath],
|
||||
tmpDir,
|
||||
{ HOME: tmpDir }
|
||||
);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const parsed = JSON.parse(result.output);
|
||||
const realTmpDir = fs.realpathSync(tmpDir);
|
||||
assert.strictEqual(parsed.claude_md_path, path.join(realTmpDir, '.claude', 'CLAUDE.md'),
|
||||
`Expected .claude/CLAUDE.md for claude runtime but got: ${parsed.claude_md_path}`);
|
||||
assert.ok(fs.existsSync(path.join(realTmpDir, '.claude', 'CLAUDE.md')),
|
||||
'.claude/CLAUDE.md must exist for claude runtime');
|
||||
assert.ok(!fs.existsSync(path.join(realTmpDir, 'AGENTS.md')),
|
||||
'AGENTS.md must not be created for claude runtime');
|
||||
});
|
||||
|
||||
test('global scope: codex runtime writes to <CODEX_HOME>/AGENTS.md', () => {
|
||||
writeConfig({ runtime: 'codex' });
|
||||
const analysisPath = writeAnalysis();
|
||||
const codexHome = path.join(tmpDir, 'codex-config');
|
||||
|
||||
const result = runGsdTools(
|
||||
['generate-claude-profile', '--analysis', analysisPath, '--global'],
|
||||
tmpDir,
|
||||
{ HOME: tmpDir, CODEX_HOME: codexHome }
|
||||
);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const parsed = JSON.parse(result.output);
|
||||
const expected = path.join(codexHome, 'AGENTS.md');
|
||||
assert.strictEqual(parsed.claude_md_path, expected,
|
||||
`Expected <CODEX_HOME>/AGENTS.md but got: ${parsed.claude_md_path}`);
|
||||
assert.ok(fs.existsSync(expected), '<CODEX_HOME>/AGENTS.md must exist after generation');
|
||||
assert.ok(!fs.existsSync(path.join(tmpDir, '.claude', 'CLAUDE.md')),
|
||||
'~/.claude/CLAUDE.md must not be created for Codex global profile');
|
||||
});
|
||||
|
||||
test('global scope: claude runtime writes to ~/.claude/CLAUDE.md (preserved, no env drift)', () => {
|
||||
writeConfig({ runtime: 'claude' });
|
||||
const analysisPath = writeAnalysis();
|
||||
|
||||
// #2659: set USERPROFILE alongside HOME so os.homedir() resolves to tmpDir
|
||||
// on Windows too — Node's os.homedir() reads USERPROFILE on Windows and
|
||||
// HOME on POSIX, so HOME alone leaves the real user profile in place on
|
||||
// Windows and the path assertion fails cross-platform.
|
||||
const result = runGsdTools(
|
||||
['generate-claude-profile', '--analysis', analysisPath, '--global'],
|
||||
tmpDir,
|
||||
{ HOME: tmpDir, USERPROFILE: tmpDir }
|
||||
);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const parsed = JSON.parse(result.output);
|
||||
assert.strictEqual(parsed.claude_md_path, path.join(tmpDir, '.claude', 'CLAUDE.md'),
|
||||
`Expected ~/.claude/CLAUDE.md for claude global but got: ${parsed.claude_md_path}`);
|
||||
assert.ok(fs.existsSync(path.join(tmpDir, '.claude', 'CLAUDE.md')),
|
||||
'~/.claude/CLAUDE.md must exist for claude global profile');
|
||||
});
|
||||
|
||||
// Parity guard (CLAUDE.md "Generative Fix Divergence"): both
|
||||
// generate-claude-md and generate-claude-profile MUST resolve the SAME
|
||||
// project instruction path for the same runtime. Fails loudly if either
|
||||
// handler re-diverges (the original #3163 -> #2565 drift).
|
||||
test('parity: generate-claude-md and generate-claude-profile agree on project instruction path for codex', () => {
|
||||
writeConfig({ runtime: 'codex' });
|
||||
const analysisPath = writeAnalysis();
|
||||
|
||||
const mdResult = runGsdTools('generate-claude-md', tmpDir, { HOME: tmpDir });
|
||||
assert.ok(mdResult.success, `generate-claude-md failed: ${mdResult.error}`);
|
||||
const mdParsed = JSON.parse(mdResult.output);
|
||||
|
||||
// generate-claude-md created AGENTS.md; remove it so generate-claude-profile
|
||||
// starts from the same empty-project state (independent parity read).
|
||||
try { fs.unlinkSync(mdParsed.claude_md_path); } catch { /* already gone */ }
|
||||
|
||||
const profileResult = runGsdTools(
|
||||
['generate-claude-profile', '--analysis', analysisPath],
|
||||
tmpDir,
|
||||
{ HOME: tmpDir }
|
||||
);
|
||||
assert.ok(profileResult.success, `generate-claude-profile failed: ${profileResult.error}`);
|
||||
const profileParsed = JSON.parse(profileResult.output);
|
||||
|
||||
assert.strictEqual(profileParsed.claude_md_path, mdParsed.claude_md_path,
|
||||
`Divergence: generate-claude-md targeted ${mdParsed.claude_md_path} but ` +
|
||||
`generate-claude-profile targeted ${profileParsed.claude_md_path}. ` +
|
||||
`Both handlers must share the runtime-aware instruction-file policy.`);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user