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:
Jhony Miler
2026-03-31 15:51:14 -03:00
parent 1421dc07bc
commit b5992684e4
3 changed files with 292 additions and 6 deletions

View File

@@ -177,8 +177,12 @@ const CLAUDE_MD_FALLBACKS = {
stack: 'Technology stack not yet documented. Will populate after codebase mapping or first phase.', 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.', conventions: 'Conventions not yet established. Will populate as patterns emerge during development.',
architecture: 'Architecture not yet mapped. Follow existing patterns found in the codebase.', 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 = [ 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.', '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 ───────────────────────────────────────────────────────────────── // ─── Commands ─────────────────────────────────────────────────────────────────
function cmdWriteProfile(cwd, options, raw) { function cmdWriteProfile(cwd, options, raw) {
@@ -815,12 +909,13 @@ function cmdGenerateClaudeProfile(cwd, options, raw) {
} }
function cmdGenerateClaudeMd(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 = { const generators = {
project: generateProjectSection, project: generateProjectSection,
stack: generateStackSection, stack: generateStackSection,
conventions: generateConventionsSection, conventions: generateConventionsSection,
architecture: generateArchitectureSection, architecture: generateArchitectureSection,
skills: generateSkillsSection,
workflow: generateWorkflowSection, workflow: generateWorkflowSection,
}; };
const sectionHeadings = { const sectionHeadings = {
@@ -828,6 +923,7 @@ function cmdGenerateClaudeMd(cwd, options, raw) {
stack: '## Technology Stack', stack: '## Technology Stack',
conventions: '## Conventions', conventions: '## Conventions',
architecture: '## Architecture', architecture: '## Architecture',
skills: '## Project Skills',
workflow: '## GSD Workflow Enforcement', workflow: '## GSD Workflow Enforcement',
}; };

View File

@@ -2,8 +2,8 @@
Template for project-root `CLAUDE.md` — auto-generated by `gsd-tools generate-claude-md`. Template for project-root `CLAUDE.md` — auto-generated by `gsd-tools generate-claude-md`.
Contains 6 marker-bounded sections. Each section is independently updatable. Contains 7 marker-bounded sections. Each section is independently updatable.
The `generate-claude-md` subcommand manages 5 sections (project, stack, conventions, architecture, workflow enforcement). 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`. 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. 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 ### Workflow Enforcement Section
``` ```
<!-- GSD:workflow-start source:GSD defaults --> <!-- 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) 2. **Stack** — Technology choices (what tools are used)
3. **Conventions** — Code patterns and rules (how code is written) 3. **Conventions** — Code patterns and rules (how code is written)
4. **Architecture** — System structure (how components fit together) 4. **Architecture** — System structure (how components fit together)
5. **Workflow Enforcement** — Default GSD entry points for file-changing work 5. **Skills** — Discovered project skills with name and description (what domain knowledge is available)
6. **Profile** — Developer behavioral preferences (how to interact) 6. **Workflow Enforcement** — Default GSD entry points for file-changing work
7. **Profile** — Developer behavioral preferences (how to interact)
## Marker Format ## Marker Format

View File

@@ -30,7 +30,7 @@ describe('generate-claude-md', () => {
const output = JSON.parse(result.output); const output = JSON.parse(result.output);
assert.strictEqual(output.action, 'created'); assert.strictEqual(output.action, 'created');
assert.strictEqual(output.sections_total, 5); assert.strictEqual(output.sections_total, 6);
assert.ok(output.sections_generated.includes('workflow')); assert.ok(output.sections_generated.includes('workflow'));
const claudePath = path.join(tmpDir, 'CLAUDE.md'); 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`')); 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');
});
});