- Section 3.5 "Team Configuration at Scale" in ultimate-guide.md: profiles YAML + shared modules + skeleton + assembler script; 59% context token reduction measured on 5-dev production team; includes CI drift detection, 5-step replication guide, trade-offs - New workflow: guide/workflows/team-ai-instructions.md (6 phases, scaling thresholds, troubleshooting table) - New templates: examples/team-config/ (profile-template.yaml, claude-skeleton.md, sync-script.ts) - reference.yaml: 9 new entries for team_ai_instructions_* - README: templates count 161 → 164, date Feb 19 → Feb 20 - CHANGELOG [Unreleased]: resource evaluations (AGENTS.md ETH Zürich 4/5, Sylvain Chabaud 3/5), spec-first Task Granularity section, methodologies ATDD expansion Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
213 lines
7 KiB
TypeScript
213 lines
7 KiB
TypeScript
#!/usr/bin/env npx ts-node
|
|
/**
|
|
* sync-ai-instructions.ts
|
|
* Profile-Based Module Assembly for AI instructions
|
|
*
|
|
* Usage:
|
|
* npx ts-node sync-ai-instructions.ts # Generate all profiles
|
|
* npx ts-node sync-ai-instructions.ts alice # Single profile
|
|
* npx ts-node sync-ai-instructions.ts --check # Dry-run + drift detection
|
|
*
|
|
* Directory structure expected:
|
|
* profiles/ Developer YAML profiles
|
|
* modules/ Reusable markdown modules
|
|
* skeleton/claude.md Template with {{PLACEHOLDERS}}
|
|
* output/<dev>/ Generated CLAUDE.md files
|
|
*/
|
|
|
|
import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from 'fs'
|
|
import { join, basename } from 'path'
|
|
import { parse } from 'yaml'
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
interface Profile {
|
|
name: string
|
|
os: 'macos' | 'linux' | 'windows'
|
|
tools: string[]
|
|
communication_style: 'verbose' | 'concise' | 'terse'
|
|
modules: {
|
|
core: string[]
|
|
conditional: string[]
|
|
}
|
|
preferences?: {
|
|
language?: string
|
|
token_budget?: 'low' | 'medium' | 'high'
|
|
}
|
|
}
|
|
|
|
// ─── Module Resolution ───────────────────────────────────────────────────────
|
|
|
|
function isModuleApplicable(moduleName: string, profile: Profile): boolean {
|
|
// OS-specific modules
|
|
if (moduleName.endsWith('-paths')) {
|
|
return moduleName === `${profile.os}-paths`
|
|
}
|
|
|
|
// Tool-specific modules
|
|
const toolModuleMap: Record<string, string> = {
|
|
'cursor-rules': 'cursor',
|
|
'windsurf-rules': 'windsurf',
|
|
}
|
|
if (toolModuleMap[moduleName]) {
|
|
return profile.tools.includes(toolModuleMap[moduleName])
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
function resolveModules(profile: Profile): string[] {
|
|
const core = profile.modules.core
|
|
const conditional = profile.modules.conditional.filter(m =>
|
|
isModuleApplicable(m, profile)
|
|
)
|
|
return [...core, ...conditional]
|
|
}
|
|
|
|
// ─── Template Processing ─────────────────────────────────────────────────────
|
|
|
|
function processConditionalBlocks(content: string, profile: Profile): string {
|
|
const hasTypescript = profile.modules.core.includes('typescript-rules') ||
|
|
profile.modules.conditional.includes('typescript-rules')
|
|
const hasPython = profile.modules.core.includes('python-rules') ||
|
|
profile.modules.conditional.includes('python-rules')
|
|
|
|
const flags: Record<string, boolean> = {
|
|
typescript: hasTypescript,
|
|
python: hasPython,
|
|
cursor: profile.tools.includes('cursor'),
|
|
windsurf: profile.tools.includes('windsurf'),
|
|
verbose: profile.communication_style === 'verbose',
|
|
concise: profile.communication_style === 'concise',
|
|
terse: profile.communication_style === 'terse',
|
|
}
|
|
|
|
// Process {{#if flag}}...{{/if}} blocks
|
|
return content.replace(
|
|
/\{\{#if (\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g,
|
|
(_match, flag, block) => flags[flag] ? block : ''
|
|
)
|
|
}
|
|
|
|
function injectModules(
|
|
content: string,
|
|
modules: string[],
|
|
modulesDir: string
|
|
): string {
|
|
// Inject named modules: {{MODULE:module-name}}
|
|
let result = content.replace(/\{\{MODULE:([^}]+)\}\}/g, (_match, moduleName) => {
|
|
const modulePath = join(modulesDir, `${moduleName}.md`)
|
|
if (!existsSync(modulePath)) {
|
|
console.warn(` ⚠ Module not found: ${moduleName}`)
|
|
return `<!-- MODULE NOT FOUND: ${moduleName} -->`
|
|
}
|
|
return readFileSync(modulePath, 'utf-8').trim()
|
|
})
|
|
|
|
return result
|
|
}
|
|
|
|
// ─── Assembler ───────────────────────────────────────────────────────────────
|
|
|
|
function assembleInstructions(
|
|
profilePath: string,
|
|
skeletonPath: string,
|
|
modulesDir: string
|
|
): string {
|
|
const profile = parse(readFileSync(profilePath, 'utf-8')) as Profile
|
|
const skeleton = readFileSync(skeletonPath, 'utf-8')
|
|
|
|
const slug = basename(profilePath, '.yaml')
|
|
const modules = resolveModules(profile)
|
|
|
|
// Replace simple placeholders
|
|
let output = skeleton
|
|
.replace(/\{\{DEVELOPER_NAME\}\}/g, profile.name)
|
|
.replace(/\{\{DEVELOPER_SLUG\}\}/g, slug)
|
|
.replace(/\{\{OS\}\}/g, profile.os)
|
|
.replace(/\{\{TOOL\}\}/g, profile.tools[0] ?? 'claude-code')
|
|
.replace(/\{\{GENERATED_DATE\}\}/g, new Date().toISOString().split('T')[0])
|
|
|
|
// Process conditional blocks
|
|
output = processConditionalBlocks(output, profile)
|
|
|
|
// Inject module content
|
|
output = injectModules(output, modules, modulesDir)
|
|
|
|
// Clean up empty sections
|
|
output = output.replace(/\n{3,}/g, '\n\n').trim()
|
|
|
|
return output
|
|
}
|
|
|
|
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2)
|
|
const isDryRun = args.includes('--check') || args.includes('--dry-run')
|
|
const targetProfile = args.find(a => !a.startsWith('--'))
|
|
|
|
const profilesDir = 'profiles'
|
|
const skeletonPath = 'skeleton/claude.md'
|
|
const modulesDir = 'modules'
|
|
const outputDir = 'output'
|
|
|
|
// Validate structure
|
|
for (const dir of [profilesDir, modulesDir, 'skeleton']) {
|
|
if (!existsSync(dir)) {
|
|
console.error(`❌ Directory not found: ${dir}`)
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
// Collect profiles
|
|
const profileFiles = targetProfile
|
|
? [`${targetProfile}.yaml`]
|
|
: readdirSync(profilesDir).filter(f => f.endsWith('.yaml'))
|
|
|
|
let driftDetected = false
|
|
|
|
for (const profileFile of profileFiles) {
|
|
const profilePath = join(profilesDir, profileFile)
|
|
const slug = basename(profileFile, '.yaml')
|
|
const outputPath = join(outputDir, slug, 'CLAUDE.md')
|
|
|
|
console.log(`\n→ Processing: ${slug}`)
|
|
|
|
const generated = assembleInstructions(profilePath, skeletonPath, modulesDir)
|
|
|
|
if (isDryRun) {
|
|
const existing = existsSync(outputPath)
|
|
? readFileSync(outputPath, 'utf-8')
|
|
: null
|
|
|
|
if (!existing) {
|
|
console.log(` ⚠ No existing file at ${outputPath}`)
|
|
driftDetected = true
|
|
} else if (existing !== generated) {
|
|
console.log(` ❌ Drift detected — ${outputPath} is out of sync`)
|
|
driftDetected = true
|
|
} else {
|
|
console.log(` ✓ In sync`)
|
|
}
|
|
} else {
|
|
const devOutputDir = join(outputDir, slug)
|
|
if (!existsSync(devOutputDir)) mkdirSync(devOutputDir, { recursive: true })
|
|
|
|
writeFileSync(outputPath, generated)
|
|
const lines = generated.split('\n').length
|
|
console.log(` ✓ Written: ${outputPath} (${lines} lines)`)
|
|
}
|
|
}
|
|
|
|
if (isDryRun && driftDetected) {
|
|
console.error('\n❌ Drift detected. Run: npx ts-node sync-ai-instructions.ts')
|
|
process.exit(1)
|
|
}
|
|
|
|
if (!isDryRun) {
|
|
console.log('\n✅ All profiles assembled successfully')
|
|
}
|
|
}
|
|
|
|
main().catch(console.error)
|