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:
Tom Boucher
2026-04-20 18:27:55 -04:00
committed by GitHub
parent d0f4340807
commit 2b494407e5
5 changed files with 151 additions and 23 deletions

View File

@@ -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 |

View 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>' },
];
/**

View File

@@ -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)

View File

@@ -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);

View 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');
});