fix(#1098): guard generate-claude-md against clobbering hand-crafted files; redirect default to .claude/CLAUDE.md (#1118)
/gsd-new-project wrote a repo-root CLAUDE.md full of broad project docs, overwriting/diluting a hand-crafted instruction file. --force was parsed but silently dropped, and nothing guarded an existing non-GSD file. - Guard: an existing instruction file with no `<!-- GSD:<section>-start` markers (hand-crafted) is left untouched; report action:"skipped". --force (now wired through CmdGenerateClaudeMdOptions) overwrites intentionally. The marker check uses /<!-- GSD:[a-z]+-start/ so a file merely documenting GSD syntax is safe. - Redirect: the Claude-family default output is now ./.claude/CLAUDE.md (a valid auto-loaded project-memory location) instead of repo-root ./CLAUDE.md. Aligned across the handler default, config-defaults.manifest.json, buildNewProjectConfig, the config template, new-project.md, and cmdGenerateClaudeProfile; advisory read-CLAUDE.md hints in plan-phase/quick/profile-user updated. Codex still writes AGENTS.md. Closes #1098 Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,7 @@ describe('claude_md_path config key', () => {
|
||||
test('config template includes claude_md_path', () => {
|
||||
const templatePath = path.join(__dirname, '..', 'gsd-core', 'templates', 'config.json');
|
||||
const template = JSON.parse(fs.readFileSync(templatePath, 'utf-8'));
|
||||
assert.strictEqual(template.claude_md_path, './CLAUDE.md');
|
||||
assert.strictEqual(template.claude_md_path, './.claude/CLAUDE.md');
|
||||
});
|
||||
|
||||
test('config-get claude_md_path returns default value when not set', () => {
|
||||
@@ -59,7 +59,7 @@ describe('claude_md_path config key', () => {
|
||||
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
||||
assert.strictEqual(config.claude_md_path, './CLAUDE.md');
|
||||
assert.strictEqual(config.claude_md_path, './.claude/CLAUDE.md');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -186,7 +186,7 @@ describe('cmdGenerateClaudeMd reads claude_md_path from config', () => {
|
||||
assert.strictEqual(parsed.claude_md_path, path.join(realTmpDir, outputFile));
|
||||
});
|
||||
|
||||
test('defaults to ./CLAUDE.md when config has no claude_md_path', () => {
|
||||
test('defaults to .claude/CLAUDE.md when config has no claude_md_path (#1098)', () => {
|
||||
// Set up config without claude_md_path
|
||||
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
||||
fs.writeFileSync(configPath, JSON.stringify({ mode: 'interactive' }), 'utf-8');
|
||||
@@ -196,6 +196,6 @@ describe('cmdGenerateClaudeMd reads claude_md_path from config', () => {
|
||||
|
||||
const parsed = JSON.parse(result.output);
|
||||
const realTmpDir = fs.realpathSync(tmpDir);
|
||||
assert.strictEqual(parsed.claude_md_path, path.join(realTmpDir, 'CLAUDE.md'));
|
||||
assert.strictEqual(parsed.claude_md_path, path.join(realTmpDir, '.claude', 'CLAUDE.md'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,7 +37,7 @@ describe('generate-claude-md', () => {
|
||||
assert.strictEqual(output.sections_total, 6);
|
||||
assert.ok(output.sections_generated.includes('workflow'));
|
||||
|
||||
const claudePath = path.join(tmpDir, 'CLAUDE.md');
|
||||
const claudePath = path.join(tmpDir, '.claude', 'CLAUDE.md');
|
||||
const content = fs.readFileSync(claudePath, 'utf-8');
|
||||
assert.ok(content.includes('## GSD Workflow Enforcement'));
|
||||
// #3584: generated CLAUDE.md must emit the runtime-routable hyphen-form
|
||||
@@ -51,23 +51,77 @@ describe('generate-claude-md', () => {
|
||||
assert.ok(content.includes('Do not make direct repo edits outside a GSD workflow'));
|
||||
});
|
||||
|
||||
test('adds workflow enforcement section when updating an existing CLAUDE.md', () => {
|
||||
test('adds workflow enforcement section when force-updating an existing marker-less CLAUDE.md', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'PROJECT.md'),
|
||||
'# Test Project\n\n## What This Is\n\nA small test project.\n'
|
||||
);
|
||||
fs.writeFileSync(path.join(tmpDir, 'CLAUDE.md'), '## Local Notes\n\nKeep this intro.\n');
|
||||
// #1098: a hand-crafted file (no GSD markers) is only overwritten with --force.
|
||||
fs.mkdirSync(path.join(tmpDir, '.claude'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), '## Local Notes\n\nKeep this intro.\n');
|
||||
|
||||
const result = runGsdTools('generate-claude-md', tmpDir);
|
||||
const result = runGsdTools('generate-claude-md --force', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.action, 'updated');
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
||||
const content = fs.readFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), 'utf-8');
|
||||
assert.ok(content.includes('## Local Notes'));
|
||||
assert.ok(content.includes('## GSD Workflow Enforcement'));
|
||||
});
|
||||
|
||||
test('#1098: does NOT clobber an existing marker-less CLAUDE.md without --force', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'PROJECT.md'),
|
||||
'# Test Project\n\n## What This Is\n\nA small test project.\n'
|
||||
);
|
||||
const handCrafted = '## My Hand-Crafted Instructions\n\nDo exactly what I say.\n';
|
||||
fs.mkdirSync(path.join(tmpDir, '.claude'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), handCrafted);
|
||||
|
||||
const result = runGsdTools('generate-claude-md', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.action, 'skipped', 'must skip a hand-crafted file without --force');
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), 'utf-8');
|
||||
assert.strictEqual(content, handCrafted, 'hand-crafted file must be left byte-identical');
|
||||
assert.ok(!content.includes('## GSD Workflow Enforcement'), 'GSD sections must NOT be injected');
|
||||
});
|
||||
|
||||
test('#1098: updates an existing GSD-managed CLAUDE.md (has markers) without --force', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'PROJECT.md'),
|
||||
'# Test Project\n\n## What This Is\n\nA small test project.\n'
|
||||
);
|
||||
// First generation creates a GSD-managed file (with markers).
|
||||
const first = runGsdTools('generate-claude-md', tmpDir);
|
||||
assert.ok(first.success, `Command failed: ${first.error}`);
|
||||
assert.strictEqual(JSON.parse(first.output).action, 'created');
|
||||
|
||||
// Re-running updates it in place — markers present, so no --force needed.
|
||||
const second = runGsdTools('generate-claude-md', tmpDir);
|
||||
assert.ok(second.success, `Command failed: ${second.error}`);
|
||||
assert.strictEqual(JSON.parse(second.output).action, 'updated');
|
||||
});
|
||||
|
||||
test('#1098: defaults the Claude-runtime output to .claude/CLAUDE.md (not repo root)', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'PROJECT.md'),
|
||||
'# Test Project\n\n## What This Is\n\nA small test project.\n'
|
||||
);
|
||||
const result = runGsdTools('generate-claude-md', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(
|
||||
output.claude_md_path.replace(/\\/g, '/').endsWith('/.claude/CLAUDE.md'),
|
||||
`default output must be .claude/CLAUDE.md; got ${output.claude_md_path}`
|
||||
);
|
||||
assert.ok(!fs.existsSync(path.join(tmpDir, 'CLAUDE.md')), 'must NOT create a repo-root CLAUDE.md');
|
||||
assert.ok(fs.existsSync(path.join(tmpDir, '.claude', 'CLAUDE.md')), 'must create .claude/CLAUDE.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('new-project workflow includes CLAUDE.md generation', () => {
|
||||
@@ -117,7 +171,7 @@ describe('generate-claude-md skills section', () => {
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(output.sections_fallback.includes('skills'));
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
||||
const content = fs.readFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), 'utf-8');
|
||||
assert.ok(content.includes('<!-- GSD:skills-start'));
|
||||
assert.ok(content.includes('<!-- GSD:skills-end -->'));
|
||||
assert.ok(content.includes('No project skills found. Add skills to any of'));
|
||||
@@ -138,7 +192,7 @@ describe('generate-claude-md skills section', () => {
|
||||
assert.ok(output.sections_generated.includes('skills'));
|
||||
assert.ok(!output.sections_fallback.includes('skills'));
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
||||
const content = fs.readFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), 'utf-8');
|
||||
assert.ok(content.includes('api-payments'));
|
||||
assert.ok(content.includes('Payment gateway integration'));
|
||||
assert.ok(content.includes('## Project Skills'));
|
||||
@@ -155,7 +209,7 @@ describe('generate-claude-md skills section', () => {
|
||||
const result = runGsdTools('generate-claude-md', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
||||
const content = fs.readFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), 'utf-8');
|
||||
assert.ok(content.includes('data-sync'));
|
||||
assert.ok(content.includes('ERP synchronization flows'));
|
||||
});
|
||||
@@ -182,7 +236,7 @@ describe('generate-claude-md skills section', () => {
|
||||
const result = runGsdTools('generate-claude-md', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
||||
const content = fs.readFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), 'utf-8');
|
||||
assert.ok(content.includes('automation'));
|
||||
assert.ok(content.includes('Project Codex skill'));
|
||||
assert.ok(!content.includes('import-only'));
|
||||
@@ -209,7 +263,7 @@ describe('generate-claude-md skills section', () => {
|
||||
const result = runGsdTools('generate-claude-md', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
||||
const content = fs.readFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), 'utf-8');
|
||||
assert.ok(!content.includes('gsd-plan-phase'));
|
||||
assert.ok(content.includes('my-feature'));
|
||||
assert.ok(content.includes('Custom project skill'));
|
||||
@@ -226,7 +280,7 @@ describe('generate-claude-md skills section', () => {
|
||||
const result = runGsdTools('generate-claude-md', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
||||
const content = fs.readFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), 'utf-8');
|
||||
assert.ok(content.includes('First line of description'));
|
||||
assert.ok(content.includes('Continued on second line'));
|
||||
assert.ok(content.includes('And a third line'));
|
||||
@@ -245,7 +299,7 @@ describe('generate-claude-md skills section', () => {
|
||||
const result = runGsdTools('generate-claude-md', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
||||
const content = fs.readFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), 'utf-8');
|
||||
const matches = content.match(/shared-skill/g);
|
||||
// Should appear exactly twice: once in name column, once in path column (single row)
|
||||
assert.strictEqual(matches.length, 2);
|
||||
@@ -254,7 +308,7 @@ describe('generate-claude-md skills section', () => {
|
||||
test('updates existing skills section on regeneration', () => {
|
||||
// First generation — no skills
|
||||
runGsdTools('generate-claude-md', tmpDir);
|
||||
let content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
||||
let content = fs.readFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), 'utf-8');
|
||||
assert.ok(content.includes('No project skills found'));
|
||||
|
||||
// Add a skill and regenerate
|
||||
@@ -268,7 +322,7 @@ describe('generate-claude-md skills section', () => {
|
||||
const result = runGsdTools('generate-claude-md', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
||||
content = fs.readFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), 'utf-8');
|
||||
assert.ok(!content.includes('No project skills found'));
|
||||
assert.ok(content.includes('new-skill'));
|
||||
assert.ok(content.includes('Just added'));
|
||||
@@ -285,7 +339,7 @@ describe('generate-claude-md skills section', () => {
|
||||
const result = runGsdTools('generate-claude-md', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
||||
const content = fs.readFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), 'utf-8');
|
||||
const archIdx = content.indexOf('## Architecture');
|
||||
const skillsIdx = content.indexOf('## Project Skills');
|
||||
const workflowIdx = content.indexOf('## GSD Workflow Enforcement');
|
||||
|
||||
@@ -219,21 +219,39 @@ describe('generate-claude-md command', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('does not overwrite existing CLAUDE.md without --force', () => {
|
||||
test('does not overwrite existing marker-less CLAUDE.md without --force (#1098)', () => {
|
||||
const outputPath = path.join(tmpDir, 'CLAUDE.md');
|
||||
const original = '# Custom CLAUDE.md\n\nUser content.\n';
|
||||
fs.writeFileSync(outputPath, original);
|
||||
|
||||
// No GSD markers in the file → the #1098 guard must leave it untouched.
|
||||
const result = runGsdTools(['generate-claude-md', '--output', outputPath, '--auto'], tmpDir);
|
||||
assert.ok(result.success, `command should exit 0 even when skipping: ${result.error}`);
|
||||
assert.strictEqual(JSON.parse(result.output).action, 'skipped');
|
||||
|
||||
const content = fs.readFileSync(outputPath, 'utf-8');
|
||||
assert.strictEqual(content, original, 'hand-crafted file must be byte-identical (not overwritten)');
|
||||
});
|
||||
|
||||
test('overwrites existing marker-less CLAUDE.md with --force (#1098)', () => {
|
||||
const outputPath = path.join(tmpDir, 'CLAUDE.md');
|
||||
fs.writeFileSync(outputPath, '# Custom CLAUDE.md\n\nUser content.\n');
|
||||
|
||||
runGsdTools(['generate-claude-md', '--output', outputPath, '--auto', '--raw'], tmpDir);
|
||||
// Should merge, not overwrite
|
||||
const result = runGsdTools(['generate-claude-md', '--output', outputPath, '--force'], tmpDir);
|
||||
assert.ok(result.success, `Failed: ${result.error}`);
|
||||
assert.strictEqual(JSON.parse(result.output).action, 'updated');
|
||||
|
||||
const content = fs.readFileSync(outputPath, 'utf-8');
|
||||
assert.ok(content.length > 0, 'should still have content');
|
||||
assert.ok(content.includes('User content.'), '--force preserves existing content while adding sections');
|
||||
assert.ok(content.includes('## GSD Workflow Enforcement'), '--force injects GSD sections');
|
||||
});
|
||||
|
||||
test('skills fallback mentions the normalized project roots', () => {
|
||||
const result = runGsdTools('generate-claude-md', tmpDir);
|
||||
assert.ok(result.success, `Failed: ${result.error}`);
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
||||
// #1098: default Claude output is now .claude/CLAUDE.md
|
||||
const content = fs.readFileSync(path.join(tmpDir, '.claude', 'CLAUDE.md'), 'utf-8');
|
||||
assert.ok(content.includes('.claude/skills/'));
|
||||
assert.ok(content.includes('.agents/skills/'));
|
||||
assert.ok(content.includes('.cursor/skills/'));
|
||||
|
||||
@@ -44,20 +44,20 @@
|
||||
"milestone-summary.md": 11774,
|
||||
"mvp-phase.md": 13582,
|
||||
"new-milestone.md": 32422,
|
||||
"new-project.md": 61690,
|
||||
"new-project.md": 61802,
|
||||
"new-workspace.md": 11254,
|
||||
"next.md": 17868,
|
||||
"node-repair.md": 4173,
|
||||
"note.md": 6563,
|
||||
"pause-work.md": 13654,
|
||||
"plan-milestone-gaps.md": 11765,
|
||||
"plan-phase.md": 94253,
|
||||
"plan-phase.md": 94343,
|
||||
"plan-review-convergence.md": 22949,
|
||||
"plant-seed.md": 11741,
|
||||
"pr-branch.md": 4994,
|
||||
"profile-user.md": 20457,
|
||||
"profile-user.md": 20650,
|
||||
"progress.md": 29387,
|
||||
"quick.md": 46213,
|
||||
"quick.md": 46282,
|
||||
"reapply-patches.md": 20393,
|
||||
"remove-phase.md": 8469,
|
||||
"remove-workspace.md": 7507,
|
||||
|
||||
Reference in New Issue
Block a user