feat: add project skills discovery section to CLAUDE.md generation
Auto-discover project skills from .claude/skills/, .agents/skills/, .cursor/skills/, and .github/skills/ directories and surface them in CLAUDE.md as a managed section with name, description, and path. This enables Layer 1 (discovery) at session startup — agents now know which project-specific skills are available without waiting for subagent injection via agent-skills at execution time. Behavior: - Scans standard skill directories for subdirectories containing SKILL.md - Extracts name and description from YAML frontmatter - Supports multi-line descriptions (indented continuation lines) - Skips GSD's own gsd-* prefixed skill directories - Deduplicates by skill name across directories - Falls back to actionable guidance when no skills found - Section is placed between Architecture and Workflow Enforcement - sections_total bumped from 5 to 6
This commit is contained in:
@@ -177,8 +177,12 @@ const CLAUDE_MD_FALLBACKS = {
|
||||
stack: 'Technology stack not yet documented. Will populate after codebase mapping or first phase.',
|
||||
conventions: 'Conventions not yet established. Will populate as patterns emerge during development.',
|
||||
architecture: 'Architecture not yet mapped. Follow existing patterns found in the codebase.',
|
||||
skills: 'No project skills found. Add skills to `.claude/skills/` or `.agents/skills/` with a `SKILL.md` index file.',
|
||||
};
|
||||
|
||||
// Directories where project skills may live (checked in order)
|
||||
const SKILL_SEARCH_DIRS = ['.claude/skills', '.agents/skills', '.cursor/skills', '.github/skills'];
|
||||
|
||||
const CLAUDE_MD_WORKFLOW_ENFORCEMENT = [
|
||||
'Before using Edit, Write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync.',
|
||||
'',
|
||||
@@ -375,6 +379,96 @@ function generateWorkflowSection() {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover project skills from standard directories and extract frontmatter
|
||||
* (name + description) for each. Returns a table summary for CLAUDE.md so
|
||||
* agents know which skills are available at session startup (Layer 1 discovery).
|
||||
*/
|
||||
function generateSkillsSection(cwd) {
|
||||
const discovered = [];
|
||||
|
||||
for (const dir of SKILL_SEARCH_DIRS) {
|
||||
const absDir = path.join(cwd, dir);
|
||||
if (!fs.existsSync(absDir)) continue;
|
||||
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
// Skip GSD's own installed skills — only surface project-specific skills
|
||||
if (entry.name.startsWith('gsd-')) continue;
|
||||
|
||||
const skillMdPath = path.join(absDir, entry.name, 'SKILL.md');
|
||||
if (!fs.existsSync(skillMdPath)) continue;
|
||||
|
||||
const content = safeReadFile(skillMdPath);
|
||||
if (!content) continue;
|
||||
|
||||
const frontmatter = extractSkillFrontmatter(content);
|
||||
const name = frontmatter.name || entry.name;
|
||||
const description = frontmatter.description || '';
|
||||
|
||||
// Avoid duplicates when same skill dir is symlinked from multiple locations
|
||||
if (discovered.some(s => s.name === name)) continue;
|
||||
|
||||
discovered.push({ name, description, path: `${dir}/${entry.name}` });
|
||||
}
|
||||
}
|
||||
|
||||
if (discovered.length === 0) {
|
||||
return { content: CLAUDE_MD_FALLBACKS.skills, source: 'skills/', hasFallback: true };
|
||||
}
|
||||
|
||||
const lines = ['| Skill | Description | Path |', '|-------|-------------|------|'];
|
||||
for (const skill of discovered) {
|
||||
// Sanitize table cell content (escape pipes)
|
||||
const desc = skill.description.replace(/\|/g, '\\|').replace(/\n/g, ' ').trim();
|
||||
const safeName = skill.name.replace(/\|/g, '\\|');
|
||||
lines.push(`| ${safeName} | ${desc} | \`${skill.path}/SKILL.md\` |`);
|
||||
}
|
||||
|
||||
return { content: lines.join('\n'), source: 'skills/', hasFallback: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract name and description from YAML-like frontmatter in a SKILL.md file.
|
||||
* Handles multi-line description values (continuation lines indented with spaces).
|
||||
*/
|
||||
function extractSkillFrontmatter(content) {
|
||||
const result = { name: '', description: '' };
|
||||
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
||||
if (!fmMatch) return result;
|
||||
|
||||
const fmBlock = fmMatch[1];
|
||||
const lines = fmBlock.split('\n');
|
||||
|
||||
let currentKey = '';
|
||||
for (const line of lines) {
|
||||
// Top-level key: value
|
||||
const kvMatch = line.match(/^(\w[\w-]*):\s*(.*)/);
|
||||
if (kvMatch) {
|
||||
currentKey = kvMatch[1];
|
||||
const value = kvMatch[2].trim();
|
||||
if (currentKey === 'name') result.name = value;
|
||||
if (currentKey === 'description') result.description = value;
|
||||
continue;
|
||||
}
|
||||
// Continuation line (indented) for multi-line values
|
||||
if (currentKey === 'description' && /^\s+/.test(line)) {
|
||||
result.description += ' ' + line.trim();
|
||||
} else {
|
||||
currentKey = '';
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Commands ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function cmdWriteProfile(cwd, options, raw) {
|
||||
@@ -815,12 +909,13 @@ function cmdGenerateClaudeProfile(cwd, options, raw) {
|
||||
}
|
||||
|
||||
function cmdGenerateClaudeMd(cwd, options, raw) {
|
||||
const MANAGED_SECTIONS = ['project', 'stack', 'conventions', 'architecture', 'workflow'];
|
||||
const MANAGED_SECTIONS = ['project', 'stack', 'conventions', 'architecture', 'skills', 'workflow'];
|
||||
const generators = {
|
||||
project: generateProjectSection,
|
||||
stack: generateStackSection,
|
||||
conventions: generateConventionsSection,
|
||||
architecture: generateArchitectureSection,
|
||||
skills: generateSkillsSection,
|
||||
workflow: generateWorkflowSection,
|
||||
};
|
||||
const sectionHeadings = {
|
||||
@@ -828,6 +923,7 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
|
||||
stack: '## Technology Stack',
|
||||
conventions: '## Conventions',
|
||||
architecture: '## Architecture',
|
||||
skills: '## Project Skills',
|
||||
workflow: '## GSD Workflow Enforcement',
|
||||
};
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
Template for project-root `CLAUDE.md` — auto-generated by `gsd-tools generate-claude-md`.
|
||||
|
||||
Contains 6 marker-bounded sections. Each section is independently updatable.
|
||||
The `generate-claude-md` subcommand manages 5 sections (project, stack, conventions, architecture, workflow enforcement).
|
||||
Contains 7 marker-bounded sections. Each section is independently updatable.
|
||||
The `generate-claude-md` subcommand manages 6 sections (project, stack, conventions, architecture, skills, workflow enforcement).
|
||||
The profile section is managed exclusively by `generate-claude-profile`.
|
||||
|
||||
---
|
||||
@@ -66,6 +66,28 @@ Conventions not yet established. Will populate as patterns emerge during develop
|
||||
Architecture not yet mapped. Follow existing patterns found in the codebase.
|
||||
```
|
||||
|
||||
### Skills Section
|
||||
```
|
||||
<!-- GSD:skills-start source:skills/ -->
|
||||
## Project Skills
|
||||
|
||||
| Skill | Description | Path |
|
||||
| -------------- | --------------------- | ------------------------- |
|
||||
| {{skill_name}} | {{skill_description}} | `{{skill_path}}/SKILL.md` |
|
||||
<!-- GSD:skills-end -->
|
||||
```
|
||||
|
||||
**Fallback text:**
|
||||
```
|
||||
No project skills found. Add skills to `.claude/skills/` or `.agents/skills/` with a `SKILL.md` index file.
|
||||
```
|
||||
|
||||
**Discovery behavior:**
|
||||
- Scans `.claude/skills/`, `.agents/skills/`, `.cursor/skills/`, `.github/skills/` for subdirectories containing `SKILL.md`
|
||||
- Extracts `name` and `description` from YAML frontmatter (supports multi-line descriptions)
|
||||
- Skips GSD's own installed skills (directories starting with `gsd-`)
|
||||
- Deduplicates by skill name across directories
|
||||
|
||||
### Workflow Enforcement Section
|
||||
```
|
||||
<!-- GSD:workflow-start source:GSD defaults -->
|
||||
@@ -104,8 +126,9 @@ CLAUDE.md file and no profile section exists yet.
|
||||
2. **Stack** — Technology choices (what tools are used)
|
||||
3. **Conventions** — Code patterns and rules (how code is written)
|
||||
4. **Architecture** — System structure (how components fit together)
|
||||
5. **Workflow Enforcement** — Default GSD entry points for file-changing work
|
||||
6. **Profile** — Developer behavioral preferences (how to interact)
|
||||
5. **Skills** — Discovered project skills with name and description (what domain knowledge is available)
|
||||
6. **Workflow Enforcement** — Default GSD entry points for file-changing work
|
||||
7. **Profile** — Developer behavioral preferences (how to interact)
|
||||
|
||||
## Marker Format
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('generate-claude-md', () => {
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.action, 'created');
|
||||
assert.strictEqual(output.sections_total, 5);
|
||||
assert.strictEqual(output.sections_total, 6);
|
||||
assert.ok(output.sections_generated.includes('workflow'));
|
||||
|
||||
const claudePath = path.join(tmpDir, 'CLAUDE.md');
|
||||
@@ -80,3 +80,170 @@ describe('new-project workflow includes CLAUDE.md generation', () => {
|
||||
assert.ok(commandsContent.includes('`CLAUDE.md`'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('generate-claude-md skills section', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'PROJECT.md'),
|
||||
'# Test Project\n\n## What This Is\n\nA test project.\n'
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('includes skills fallback when no skills directories exist', () => {
|
||||
const result = runGsdTools('generate-claude-md', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(output.sections_fallback.includes('skills'));
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, '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'));
|
||||
});
|
||||
|
||||
test('discovers skills from .claude/skills/ directory', () => {
|
||||
const skillDir = path.join(tmpDir, '.claude', 'skills', 'api-payments');
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(skillDir, 'SKILL.md'),
|
||||
'---\nname: api-payments\ndescription: Payment gateway integration.\n---\n\n# API Payments\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.sections_generated.includes('skills'));
|
||||
assert.ok(!output.sections_fallback.includes('skills'));
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, 'CLAUDE.md'), 'utf-8');
|
||||
assert.ok(content.includes('api-payments'));
|
||||
assert.ok(content.includes('Payment gateway integration'));
|
||||
assert.ok(content.includes('## Project Skills'));
|
||||
});
|
||||
|
||||
test('discovers skills from .agents/skills/ directory', () => {
|
||||
const skillDir = path.join(tmpDir, '.agents', 'skills', 'data-sync');
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(skillDir, 'SKILL.md'),
|
||||
'---\nname: data-sync\ndescription: ERP synchronization flows.\n---\n\n# Data Sync\n'
|
||||
);
|
||||
|
||||
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');
|
||||
assert.ok(content.includes('data-sync'));
|
||||
assert.ok(content.includes('ERP synchronization flows'));
|
||||
});
|
||||
|
||||
test('skips gsd- prefixed skill directories', () => {
|
||||
const gsdSkillDir = path.join(tmpDir, '.claude', 'skills', 'gsd-plan-phase');
|
||||
const userSkillDir = path.join(tmpDir, '.claude', 'skills', 'my-feature');
|
||||
fs.mkdirSync(gsdSkillDir, { recursive: true });
|
||||
fs.mkdirSync(userSkillDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(gsdSkillDir, 'SKILL.md'),
|
||||
'---\nname: gsd-plan-phase\ndescription: GSD internal skill.\n---\n'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(userSkillDir, 'SKILL.md'),
|
||||
'---\nname: my-feature\ndescription: Custom project skill.\n---\n'
|
||||
);
|
||||
|
||||
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');
|
||||
assert.ok(!content.includes('gsd-plan-phase'));
|
||||
assert.ok(content.includes('my-feature'));
|
||||
assert.ok(content.includes('Custom project skill'));
|
||||
});
|
||||
|
||||
test('handles multi-line description in frontmatter', () => {
|
||||
const skillDir = path.join(tmpDir, '.claude', 'skills', 'complex-skill');
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(skillDir, 'SKILL.md'),
|
||||
'---\nname: complex-skill\ndescription: First line of description.\n Continued on second line.\n And a third line.\n---\n'
|
||||
);
|
||||
|
||||
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');
|
||||
assert.ok(content.includes('First line of description'));
|
||||
assert.ok(content.includes('Continued on second line'));
|
||||
assert.ok(content.includes('And a third line'));
|
||||
});
|
||||
|
||||
test('deduplicates skills found in multiple directories', () => {
|
||||
// Same skill in both .claude/skills/ and .agents/skills/
|
||||
const dir1 = path.join(tmpDir, '.claude', 'skills', 'shared-skill');
|
||||
const dir2 = path.join(tmpDir, '.agents', 'skills', 'shared-skill');
|
||||
fs.mkdirSync(dir1, { recursive: true });
|
||||
fs.mkdirSync(dir2, { recursive: true });
|
||||
const skillContent = '---\nname: shared-skill\ndescription: Appears twice.\n---\n';
|
||||
fs.writeFileSync(path.join(dir1, 'SKILL.md'), skillContent);
|
||||
fs.writeFileSync(path.join(dir2, 'SKILL.md'), skillContent);
|
||||
|
||||
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 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);
|
||||
});
|
||||
|
||||
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');
|
||||
assert.ok(content.includes('No project skills found'));
|
||||
|
||||
// Add a skill and regenerate
|
||||
const skillDir = path.join(tmpDir, '.claude', 'skills', 'new-skill');
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(skillDir, 'SKILL.md'),
|
||||
'---\nname: new-skill\ndescription: Just added.\n---\n'
|
||||
);
|
||||
|
||||
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');
|
||||
assert.ok(!content.includes('No project skills found'));
|
||||
assert.ok(content.includes('new-skill'));
|
||||
assert.ok(content.includes('Just added'));
|
||||
});
|
||||
|
||||
test('skills section appears between architecture and workflow', () => {
|
||||
const skillDir = path.join(tmpDir, '.claude', 'skills', 'ordering-test');
|
||||
fs.mkdirSync(skillDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(skillDir, 'SKILL.md'),
|
||||
'---\nname: ordering-test\ndescription: Verify section order.\n---\n'
|
||||
);
|
||||
|
||||
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 archIdx = content.indexOf('## Architecture');
|
||||
const skillsIdx = content.indexOf('## Project Skills');
|
||||
const workflowIdx = content.indexOf('## GSD Workflow Enforcement');
|
||||
assert.ok(archIdx < skillsIdx, 'Skills section should come after Architecture');
|
||||
assert.ok(skillsIdx < workflowIdx, 'Skills section should come before Workflow Enforcement');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user