mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-04-25 17:25:23 +02:00
feat(assembly): add link mode for CLAUDE.md @-reference sections (#2484)
* feat(assembly): add link mode for CLAUDE.md @-reference sections (#2415) Adds `claude_md_assembly.mode: "link"` config option that writes `@.planning/<source>` instead of inlining content between GSD markers, reducing typical CLAUDE.md size by ~65%. Per-block overrides available via `claude_md_assembly.blocks.<section>`. Falls back to embed for sections without a real source file (workflow, fallbacks). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(test): add positive assertion for embedded workflow content (CodeRabbit #2484) The negative assertion only confirmed @GSD defaults wasn't written. Add assert.ok(content.includes('GSD Workflow Enforcement')) to verify the workflow section is actually embedded inline when link mode falls back. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -113,6 +113,7 @@ GSD stores project settings in `.planning/config.json`. Created during `/gsd-new
|
||||
| `response_language` | string | language code | (none) | Language for agent responses (e.g., `"pt"`, `"ko"`, `"ja"`). Propagates to all spawned agents for cross-phase language consistency. Added in v1.32 |
|
||||
| `context_profile` | string | `dev`, `research`, `review` | (none) | Execution context preset that applies a pre-configured bundle of mode, model, and workflow settings for the current type of work. Added in v1.34 |
|
||||
| `claude_md_path` | string | any file path | `./CLAUDE.md` | Custom output path for the generated CLAUDE.md file. Useful for monorepos or projects that need CLAUDE.md in a non-root location. Defaults to `./CLAUDE.md` at the project root. Added in v1.36 |
|
||||
| `claude_md_assembly.mode` | enum | `embed`, `link` | `embed` | Controls how managed sections are written into CLAUDE.md. `embed` (default) inlines content between GSD markers. `link` writes `@.planning/<source-path>` instead — Claude Code expands the reference at runtime, reducing CLAUDE.md size by ~65% on typical projects. `link` only applies to sections that have a real source file; `workflow` and fallback sections always embed. Per-block overrides: `claude_md_assembly.blocks.<section>` (e.g. `claude_md_assembly.blocks.architecture: link`). Added in v1.38 |
|
||||
| `context` | string | any text | (none) | Custom context string injected into every agent prompt for the project. Use to provide persistent project-specific guidance (e.g., coding conventions, team practices) that every agent should be aware of |
|
||||
| `phase_naming` | string | any string | (none) | Custom prefix for phase directory names. When set, overrides the auto-generated phase slug (e.g., `"feature"` produces `feature-01-setup/` instead of the roadmap-derived slug) |
|
||||
| `brave_search` | boolean | `true`/`false` | auto-detected | Override auto-detection of Brave Search API availability. When unset, GSD checks for `BRAVE_API_KEY` env var or `~/.gsd/brave_api_key` file |
|
||||
|
||||
@@ -54,6 +54,7 @@ const VALID_CONFIG_KEYS = new Set([
|
||||
'graphify.enabled',
|
||||
'graphify.build_timeout',
|
||||
'claude_md_path',
|
||||
'claude_md_assembly.mode',
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -61,9 +62,10 @@ const VALID_CONFIG_KEYS = new Set([
|
||||
* Each entry has a `test` function and a human-readable `description`.
|
||||
*/
|
||||
const DYNAMIC_KEY_PATTERNS = [
|
||||
{ test: (k) => /^agent_skills\.[a-zA-Z0-9_-]+$/.test(k), description: 'agent_skills.<agent-type>' },
|
||||
{ test: (k) => /^review\.models\.[a-zA-Z0-9_-]+$/.test(k), description: 'review.models.<cli-name>' },
|
||||
{ test: (k) => /^features\.[a-zA-Z0-9_]+$/.test(k), description: 'features.<feature_name>' },
|
||||
{ test: (k) => /^agent_skills\.[a-zA-Z0-9_-]+$/.test(k), description: 'agent_skills.<agent-type>' },
|
||||
{ test: (k) => /^review\.models\.[a-zA-Z0-9_-]+$/.test(k), description: 'review.models.<cli-name>' },
|
||||
{ test: (k) => /^features\.[a-zA-Z0-9_]+$/.test(k), description: 'features.<feature_name>' },
|
||||
{ test: (k) => /^claude_md_assembly\.blocks\.[a-zA-Z0-9_]+$/.test(k), description: 'claude_md_assembly.blocks.<section>' },
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -394,6 +394,7 @@ function loadConfig(cwd) {
|
||||
manager: parsed.manager || {},
|
||||
response_language: get('response_language') || null,
|
||||
claude_md_path: get('claude_md_path') || null,
|
||||
claude_md_assembly: parsed.claude_md_assembly || null,
|
||||
};
|
||||
} catch {
|
||||
// Fall back to ~/.gsd/defaults.json only for truly pre-project contexts (#1683)
|
||||
|
||||
@@ -285,7 +285,7 @@ function generateProjectSection(cwd) {
|
||||
const projectPath = path.join(cwd, '.planning', 'PROJECT.md');
|
||||
const content = safeReadFile(projectPath);
|
||||
if (!content) {
|
||||
return { content: CLAUDE_MD_FALLBACKS.project, source: 'PROJECT.md', hasFallback: true };
|
||||
return { content: CLAUDE_MD_FALLBACKS.project, source: 'PROJECT.md', linkPath: null, hasFallback: true };
|
||||
}
|
||||
const parts = [];
|
||||
const h1Match = content.match(/^# (.+)$/m);
|
||||
@@ -306,9 +306,9 @@ function generateProjectSection(cwd) {
|
||||
if (body) parts.push(`### Constraints\n\n${body}`);
|
||||
}
|
||||
if (parts.length === 0) {
|
||||
return { content: CLAUDE_MD_FALLBACKS.project, source: 'PROJECT.md', hasFallback: true };
|
||||
return { content: CLAUDE_MD_FALLBACKS.project, source: 'PROJECT.md', linkPath: null, hasFallback: true };
|
||||
}
|
||||
return { content: parts.join('\n\n'), source: 'PROJECT.md', hasFallback: false };
|
||||
return { content: parts.join('\n\n'), source: 'PROJECT.md', linkPath: '.planning/PROJECT.md', hasFallback: false };
|
||||
}
|
||||
|
||||
function generateStackSection(cwd) {
|
||||
@@ -316,12 +316,14 @@ function generateStackSection(cwd) {
|
||||
const researchPath = path.join(cwd, '.planning', 'research', 'STACK.md');
|
||||
let content = safeReadFile(codebasePath);
|
||||
let source = 'codebase/STACK.md';
|
||||
let linkPath = '.planning/codebase/STACK.md';
|
||||
if (!content) {
|
||||
content = safeReadFile(researchPath);
|
||||
source = 'research/STACK.md';
|
||||
linkPath = '.planning/research/STACK.md';
|
||||
}
|
||||
if (!content) {
|
||||
return { content: CLAUDE_MD_FALLBACKS.stack, source: 'STACK.md', hasFallback: true };
|
||||
return { content: CLAUDE_MD_FALLBACKS.stack, source: 'STACK.md', linkPath: null, hasFallback: true };
|
||||
}
|
||||
const lines = content.split('\n');
|
||||
const summaryLines = [];
|
||||
@@ -336,14 +338,14 @@ function generateStackSection(cwd) {
|
||||
if (line.startsWith('- ') || line.startsWith('* ')) summaryLines.push(line);
|
||||
}
|
||||
const summary = summaryLines.length > 0 ? summaryLines.join('\n') : content.trim();
|
||||
return { content: summary, source, hasFallback: false };
|
||||
return { content: summary, source, linkPath, hasFallback: false };
|
||||
}
|
||||
|
||||
function generateConventionsSection(cwd) {
|
||||
const conventionsPath = path.join(cwd, '.planning', 'codebase', 'CONVENTIONS.md');
|
||||
const content = safeReadFile(conventionsPath);
|
||||
if (!content) {
|
||||
return { content: CLAUDE_MD_FALLBACKS.conventions, source: 'CONVENTIONS.md', hasFallback: true };
|
||||
return { content: CLAUDE_MD_FALLBACKS.conventions, source: 'CONVENTIONS.md', linkPath: null, hasFallback: true };
|
||||
}
|
||||
const lines = content.split('\n');
|
||||
const summaryLines = [];
|
||||
@@ -352,14 +354,14 @@ function generateConventionsSection(cwd) {
|
||||
if (line.startsWith('- ') || line.startsWith('* ') || line.startsWith('|')) summaryLines.push(line);
|
||||
}
|
||||
const summary = summaryLines.length > 0 ? summaryLines.join('\n') : content.trim();
|
||||
return { content: summary, source: 'CONVENTIONS.md', hasFallback: false };
|
||||
return { content: summary, source: 'CONVENTIONS.md', linkPath: '.planning/codebase/CONVENTIONS.md', hasFallback: false };
|
||||
}
|
||||
|
||||
function generateArchitectureSection(cwd) {
|
||||
const architecturePath = path.join(cwd, '.planning', 'codebase', 'ARCHITECTURE.md');
|
||||
const content = safeReadFile(architecturePath);
|
||||
if (!content) {
|
||||
return { content: CLAUDE_MD_FALLBACKS.architecture, source: 'ARCHITECTURE.md', hasFallback: true };
|
||||
return { content: CLAUDE_MD_FALLBACKS.architecture, source: 'ARCHITECTURE.md', linkPath: null, hasFallback: true };
|
||||
}
|
||||
const lines = content.split('\n');
|
||||
const summaryLines = [];
|
||||
@@ -368,13 +370,14 @@ function generateArchitectureSection(cwd) {
|
||||
if (line.startsWith('- ') || line.startsWith('* ') || line.startsWith('|') || line.startsWith('```')) summaryLines.push(line);
|
||||
}
|
||||
const summary = summaryLines.length > 0 ? summaryLines.join('\n') : content.trim();
|
||||
return { content: summary, source: 'ARCHITECTURE.md', hasFallback: false };
|
||||
return { content: summary, source: 'ARCHITECTURE.md', linkPath: '.planning/codebase/ARCHITECTURE.md', hasFallback: false };
|
||||
}
|
||||
|
||||
function generateWorkflowSection() {
|
||||
return {
|
||||
content: CLAUDE_MD_WORKFLOW_ENFORCEMENT,
|
||||
source: 'GSD defaults',
|
||||
linkPath: null,
|
||||
hasFallback: false,
|
||||
};
|
||||
}
|
||||
@@ -948,19 +951,35 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
|
||||
}
|
||||
}
|
||||
|
||||
let assemblyConfig = {};
|
||||
let configClaudeMdPath = './CLAUDE.md';
|
||||
try {
|
||||
const config = loadConfig(cwd);
|
||||
if (config.claude_md_path) configClaudeMdPath = config.claude_md_path;
|
||||
if (config.claude_md_assembly) assemblyConfig = config.claude_md_assembly;
|
||||
} catch { /* use default */ }
|
||||
|
||||
let outputPath = options.output;
|
||||
if (!outputPath) {
|
||||
// Read claude_md_path from config, default to ./CLAUDE.md
|
||||
let configClaudeMdPath = './CLAUDE.md';
|
||||
try {
|
||||
const config = loadConfig(cwd);
|
||||
if (config.claude_md_path) configClaudeMdPath = config.claude_md_path;
|
||||
} catch { /* use default */ }
|
||||
outputPath = path.isAbsolute(configClaudeMdPath) ? configClaudeMdPath : path.join(cwd, configClaudeMdPath);
|
||||
} else if (!path.isAbsolute(outputPath)) {
|
||||
outputPath = path.join(cwd, outputPath);
|
||||
}
|
||||
|
||||
const globalAssemblyMode = assemblyConfig.mode || 'embed';
|
||||
const blockModes = assemblyConfig.blocks || {};
|
||||
|
||||
// Return the assembled content for a section, respecting link vs embed mode.
|
||||
// "link" mode writes `@<linkPath>` when the generator has a real source file.
|
||||
// Falls back to "embed" for sections without a linkable source (workflow, fallbacks).
|
||||
function buildSectionContent(name, gen, heading) {
|
||||
const effectiveMode = blockModes[name] || globalAssemblyMode;
|
||||
if (effectiveMode === 'link' && gen.linkPath && !gen.hasFallback) {
|
||||
return buildSection(name, gen.source, `${heading}\n\n@${gen.linkPath}`);
|
||||
}
|
||||
return buildSection(name, gen.source, `${heading}\n\n${gen.content}`);
|
||||
}
|
||||
|
||||
let existingContent = safeReadFile(outputPath);
|
||||
let action;
|
||||
|
||||
@@ -969,8 +988,7 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
|
||||
for (const name of MANAGED_SECTIONS) {
|
||||
const gen = generated[name];
|
||||
const heading = sectionHeadings[name];
|
||||
const body = `${heading}\n\n${gen.content}`;
|
||||
sections.push(buildSection(name, gen.source, body));
|
||||
sections.push(buildSectionContent(name, gen, heading));
|
||||
}
|
||||
sections.push('');
|
||||
sections.push(CLAUDE_MD_PROFILE_PLACEHOLDER);
|
||||
@@ -985,13 +1003,15 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
|
||||
for (const name of MANAGED_SECTIONS) {
|
||||
const gen = generated[name];
|
||||
const heading = sectionHeadings[name];
|
||||
const body = `${heading}\n\n${gen.content}`;
|
||||
const fullSection = buildSection(name, gen.source, body);
|
||||
const fullSection = buildSectionContent(name, gen, heading);
|
||||
const hasMarkers = fileContent.indexOf(`<!-- GSD:${name}-start`) !== -1;
|
||||
|
||||
if (hasMarkers) {
|
||||
if (options.auto) {
|
||||
const expectedBody = `${heading}\n\n${gen.content}`;
|
||||
const effectiveMode = blockModes[name] || globalAssemblyMode;
|
||||
const expectedBody = (effectiveMode === 'link' && gen.linkPath && !gen.hasFallback)
|
||||
? `${heading}\n\n@${gen.linkPath}`
|
||||
: `${heading}\n\n${gen.content}`;
|
||||
if (detectManualEdit(fileContent, name, expectedBody)) {
|
||||
sectionsSkipped.push(name);
|
||||
const genIdx = sectionsGenerated.indexOf(name);
|
||||
|
||||
104
tests/enh-2415-claude-md-link-mode.test.cjs
Normal file
104
tests/enh-2415-claude-md-link-mode.test.cjs
Normal file
@@ -0,0 +1,104 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Tests for claude_md_assembly "link" mode (#2415).
|
||||
* Verifies that generate-claude-md writes @-references instead of inlined
|
||||
* content when claude_md_assembly.mode is "link".
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const { cmdGenerateClaudeMd } = require('../get-shit-done/bin/lib/profile-output.cjs');
|
||||
|
||||
function makeTempProject(files = {}) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2415-'));
|
||||
fs.mkdirSync(path.join(dir, '.planning', 'codebase'), { recursive: true });
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const abs = path.join(dir, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content, 'utf-8');
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
test('link mode writes @-reference for architecture section', () => {
|
||||
const dir = makeTempProject({
|
||||
'.planning/codebase/ARCHITECTURE.md': '# Architecture\n\n- layered\n',
|
||||
'.planning/config.json': JSON.stringify({ claude_md_assembly: { mode: 'link' } }),
|
||||
});
|
||||
|
||||
cmdGenerateClaudeMd(dir, { output: path.join(dir, 'CLAUDE.md') }, false);
|
||||
|
||||
const content = fs.readFileSync(path.join(dir, 'CLAUDE.md'), 'utf-8');
|
||||
assert.ok(content.includes('@.planning/codebase/ARCHITECTURE.md'), 'should contain @-reference');
|
||||
assert.ok(!content.includes('- layered'), 'should not inline architecture content');
|
||||
});
|
||||
|
||||
test('link mode writes @-reference for project section', () => {
|
||||
const dir = makeTempProject({
|
||||
'.planning/PROJECT.md': '# My Project\n\n## What This Is\n\nA great app.\n',
|
||||
'.planning/config.json': JSON.stringify({ claude_md_assembly: { mode: 'link' } }),
|
||||
});
|
||||
|
||||
cmdGenerateClaudeMd(dir, { output: path.join(dir, 'CLAUDE.md') }, false);
|
||||
|
||||
const content = fs.readFileSync(path.join(dir, 'CLAUDE.md'), 'utf-8');
|
||||
assert.ok(content.includes('@.planning/PROJECT.md'), 'should contain @-reference for project');
|
||||
assert.ok(!content.includes('A great app.'), 'should not inline project content');
|
||||
});
|
||||
|
||||
test('embed mode (default) inlines content as before', () => {
|
||||
const dir = makeTempProject({
|
||||
'.planning/codebase/ARCHITECTURE.md': '# Architecture\n\n- monolith\n',
|
||||
});
|
||||
|
||||
cmdGenerateClaudeMd(dir, { output: path.join(dir, 'CLAUDE.md') }, false);
|
||||
|
||||
const content = fs.readFileSync(path.join(dir, 'CLAUDE.md'), 'utf-8');
|
||||
assert.ok(content.includes('- monolith'), 'embed mode should inline content');
|
||||
assert.ok(!content.includes('@.planning/codebase/ARCHITECTURE.md'), 'embed mode should not write @-reference');
|
||||
});
|
||||
|
||||
test('per-block override: link only architecture, embed others', () => {
|
||||
const dir = makeTempProject({
|
||||
'.planning/PROJECT.md': '# Proj\n\n## What This Is\n\nApp.\n',
|
||||
'.planning/codebase/ARCHITECTURE.md': '# Arch\n\n- layers\n',
|
||||
'.planning/config.json': JSON.stringify({ claude_md_assembly: { mode: 'embed', blocks: { architecture: 'link' } } }),
|
||||
});
|
||||
|
||||
cmdGenerateClaudeMd(dir, { output: path.join(dir, 'CLAUDE.md') }, false);
|
||||
|
||||
const content = fs.readFileSync(path.join(dir, 'CLAUDE.md'), 'utf-8');
|
||||
assert.ok(content.includes('@.planning/codebase/ARCHITECTURE.md'), 'architecture should use link');
|
||||
assert.ok(!content.includes('@.planning/PROJECT.md'), 'project should use embed');
|
||||
assert.ok(content.includes('App.'), 'project content should be inlined');
|
||||
});
|
||||
|
||||
test('link mode falls back to embed for workflow section (no linkable source)', () => {
|
||||
const dir = makeTempProject({
|
||||
'.planning/config.json': JSON.stringify({ claude_md_assembly: { mode: 'link' } }),
|
||||
});
|
||||
|
||||
cmdGenerateClaudeMd(dir, { output: path.join(dir, 'CLAUDE.md') }, false);
|
||||
|
||||
const content = fs.readFileSync(path.join(dir, 'CLAUDE.md'), 'utf-8');
|
||||
// workflow section should still be inlined (it has no linkPath)
|
||||
assert.ok(!content.includes('@GSD defaults'), 'workflow should not write @GSD defaults');
|
||||
assert.ok(content.includes('GSD Workflow Enforcement'), 'workflow content should be embedded inline');
|
||||
});
|
||||
|
||||
test('link mode falls back to embed when source file is missing (hasFallback)', () => {
|
||||
const dir = makeTempProject({
|
||||
'.planning/config.json': JSON.stringify({ claude_md_assembly: { mode: 'link' } }),
|
||||
});
|
||||
// No .planning/codebase/ARCHITECTURE.md — generator will use fallback
|
||||
|
||||
cmdGenerateClaudeMd(dir, { output: path.join(dir, 'CLAUDE.md') }, false);
|
||||
|
||||
const content = fs.readFileSync(path.join(dir, 'CLAUDE.md'), 'utf-8');
|
||||
assert.ok(!content.includes('@.planning/codebase/ARCHITECTURE.md'), 'fallback section should not write @-reference');
|
||||
});
|
||||
Reference in New Issue
Block a user