UNPKG

aiwg

Version:

Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo

367 lines 14.1 kB
/** * skill-lint — rubric-based quality scoring for SKILL.md files. * * Complements `validate-metadata` (#1014's CI gate, schema-only) * with a richer per-skill quality score across multiple dimensions. * Designed for headless use: `--json` outputs structured results for * CI consumption; exit code 0/1 reflects whether all checked files * meet the chosen rubric threshold. * * @module src/cli/handlers/skill-lint */ import * as fs from 'fs/promises'; import * as path from 'path'; import * as yaml from 'js-yaml'; import { validateSkillFrontmatter } from '../../extensions/validation.js'; const THRESHOLDS = { strict: 80, standard: 60, lenient: 40, }; const DIMENSION_WEIGHTS = { schema: 0.4, description: 0.2, discoverability: 0.2, body: 0.2, }; function scoreDimension(passes, notes = []) { return passes ? { score: 100, notes: [] } : { score: 0, notes }; } function partialDimension(score, notes) { return { score: Math.max(0, Math.min(100, Math.round(score))), notes }; } /** * Score the schema dimension. Binary: pass if `SkillFrontmatterSchema` * validates clean, fail otherwise. We treat schema as a hard gate * because the cleanup work in #1015 made schema correctness a baseline * expectation across the corpus. */ function scoreSchema(frontmatter) { const result = validateSkillFrontmatter(frontmatter); if (result.success) return scoreDimension(true); const notes = result.errors.errors.map((e) => `${e.path.join('.') || '<root>'}: ${e.message}`); return scoreDimension(false, notes); } /** * Score the description. Looks for non-empty content, sufficient * length, and action-oriented phrasing ("Use when…", verb-leading) * that helps NL routing identify when to invoke the skill. */ function scoreDescription(fm) { const desc = typeof fm.description === 'string' ? fm.description.trim() : ''; if (!desc) return scoreDimension(false, ['description is missing or empty']); const notes = []; let score = 100; if (desc.length < 30) { score -= 40; notes.push(`description is ${desc.length} chars; aim for ≥30`); } // Action-oriented phrasing — either explicit "Use when…" or a verb-leading sentence. const hasUseWhen = /\buse when\b/i.test(desc); const startsWithVerb = /^[A-Z]?[a-z]+(?:s|e|t|d|n)\s/.test(desc); // crude verb heuristic if (!hasUseWhen && !startsWithVerb) { score -= 30; notes.push('description missing "Use when…" clause or action-leading verb'); } return partialDimension(score, notes); } /** * Score discoverability. A skill is discoverable when one of: * - it has a `triggers:` array of ≥2 entries (NL routing surface) * - or it is explicitly not user-invocable (agent-only / library skills) * * Skills that are user-invocable but lack triggers won't be found by NL * routing, which is the failure mode this dimension guards against. */ function scoreDiscoverability(fm) { const triggers = Array.isArray(fm.triggers) ? fm.triggers : []; const userInv = fm.userInvocable === true || fm['user-invocable'] === true; if (!userInv) { // Agent-only skill — discoverability not strictly needed. return scoreDimension(true); } if (triggers.length >= 2) return scoreDimension(true); if (triggers.length === 1) { return partialDimension(60, ['only 1 trigger phrase; aim for ≥2 for NL routing diversity']); } return scoreDimension(false, [ 'user-invocable skill has no triggers — NL routing will not find it', ]); } /** * Score the body. Looks for a non-trivial amount of skill content * after the frontmatter. Very short bodies usually indicate * placeholder or stub skills. * * Also flags post-kernel-pivot regressions: slash-prefix references to * non-kernel AIWG skills (e.g. `/issue-list`). Only kernel-listed skills * are platform-invokable; everything else must be reached via * `aiwg discover` / `aiwg show`. See agentic/code/addons/aiwg-utils/rules/skill-discovery.md * and issue #1260. */ function scoreBody(body) { const text = body.trim(); const wordCount = text.split(/\s+/).filter(Boolean).length; // Kernel / built-in skills are still legitimately invokable as // slash commands; references to these should not be flagged. The // generated-output exception (templates like aiwg-regenerate-claude // that emit user-facing CLAUDE.md) is handled by the marker below. const KERNEL_SKILLS = new Set([ 'aiwg-doctor', 'aiwg-refresh', 'aiwg-status', 'aiwg-help', 'use', 'steward', // Claude Code built-ins 'help', 'clear', 'init', 'review', 'security-review', ]); const slashRefRe = /`\/([a-z][a-z0-9-]+)`/g; const offenders = new Set(); // Skip lines after the "Available Slash Commands" marker — that block // is template output and slash prefixes there are intentional. const lines = body.split('\n'); let inTemplateBlock = false; for (const line of lines) { if (/Available Slash Commands/i.test(line)) { inTemplateBlock = true; continue; } if (inTemplateBlock && /^#{1,3}\s/.test(line)) { // Next heading closes the template block. inTemplateBlock = false; } if (inTemplateBlock) continue; for (const m of line.matchAll(slashRefRe)) { const name = m[1]; if (!KERNEL_SKILLS.has(name)) offenders.add(name); } } const notes = []; if (offenders.size > 0) { notes.push(`slash-prefix refs to non-kernel skills: ${[...offenders].slice(0, 8).join(', ')}` + (offenders.size > 8 ? ` (+${offenders.size - 8} more)` : '') + ' — drop the "/" prefix; non-kernel skills are reached via `aiwg discover` + `aiwg show` (#1260)'); } if (wordCount >= 100) { return offenders.size > 0 ? partialDimension(80, notes) : scoreDimension(true); } if (wordCount >= 30) { notes.unshift(`body has ${wordCount} words; aim for ≥100`); return partialDimension(offenders.size > 0 ? 45 : 60, notes); } notes.unshift(`body has only ${wordCount} words — likely a stub`); return scoreDimension(false, notes); } function combineScore(dims) { return Math.round(dims.schema.score * DIMENSION_WEIGHTS.schema + dims.description.score * DIMENSION_WEIGHTS.description + dims.discoverability.score * DIMENSION_WEIGHTS.discoverability + dims.body.score * DIMENSION_WEIGHTS.body); } const AIWG_COMMAND_RE = /`(aiwg\s+[^`\n]+)`/g; const WORKFLOW_SUPPORT_RE = /\b(skill|workflow|orchestrat|review|interpret|summari[sz]e|validate|report|decide|triage|guide)\b/i; const REPLACEMENT_RISK_RE = /\b(?:just|only|simply)\s+run\s+`?aiwg\b|\b(?:replaces?|instead of)\s+(?:the\s+)?skill\b|\bdo not use this skill\b/i; function inventoryCompanionCli(filePath, body) { const commands = [...body.matchAll(AIWG_COMMAND_RE)] .map((m) => m[1].replace(/\s+/g, ' ').trim()); const uniqueCommands = [...new Set(commands)]; if (uniqueCommands.length === 0) return null; const workflowLanguage = WORKFLOW_SUPPORT_RE.test(body); const notes = []; if (!workflowLanguage) { notes.push('mentions aiwg CLI commands but does not describe the surrounding skill/workflow judgment'); } if (REPLACEMENT_RISK_RE.test(body)) { notes.push('wording may position the CLI command as replacing the skill workflow'); } return { file: filePath, commands: uniqueCommands.slice(0, 12), workflowLanguage, risk: notes.length > 0 ? 'review' : 'ok', notes, }; } /** * Lint a single SKILL.md file end-to-end. * * @param filePath - Absolute or relative path to the SKILL.md * @param rubric - Strictness level (drives the pass threshold) */ export async function lintSkillFile(filePath, rubric = 'standard') { const content = await fs.readFile(filePath, 'utf-8'); const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/); if (!fmMatch) { return { file: filePath, score: 0, passes: false, dimensions: { schema: scoreDimension(false, ['no YAML frontmatter found']), description: scoreDimension(false, ['no frontmatter to derive description from']), discoverability: scoreDimension(false, ['no frontmatter']), body: scoreBody(content), }, }; } const [, fmText, body = ''] = fmMatch; let frontmatter = {}; let yamlError = null; try { const parsed = yaml.load(fmText); if (parsed && typeof parsed === 'object') { frontmatter = parsed; } } catch (e) { yamlError = e.message.split('\n')[0]; } const dimensions = { schema: yamlError ? scoreDimension(false, [`YAML parse error: ${yamlError}`]) : scoreSchema(frontmatter), description: scoreDescription(frontmatter), discoverability: scoreDiscoverability(frontmatter), body: scoreBody(body), }; const score = combineScore(dimensions); return { file: filePath, score, passes: score >= THRESHOLDS[rubric], dimensions, }; } async function* walkSkillFiles(rootPath) { const stat = await fs.stat(rootPath); if (stat.isFile()) { if (path.basename(rootPath) === 'SKILL.md') yield rootPath; return; } if (!stat.isDirectory()) return; const entries = await fs.readdir(rootPath, { withFileTypes: true }); for (const e of entries) { if (e.name.startsWith('.') || e.name === 'node_modules') continue; const p = path.join(rootPath, e.name); if (e.isDirectory()) { yield* walkSkillFiles(p); } else if (e.isFile() && e.name === 'SKILL.md') { yield p; } } } /** * Lint all SKILL.md files under one or more paths. Returns a structured * report. Each path may be a directory (walked recursively) or a * specific SKILL.md file. Files in `failedCount` and the per-file list * are deduplicated. * * Pure function — does not print or exit. The CLI handler renders * output and translates the report into an exit code. */ export async function lintSkills(targetPaths, rubric = 'standard') { const targets = Array.isArray(targetPaths) ? targetPaths : [targetPaths]; const seen = new Set(); const files = []; for (const target of targets) { for await (const f of walkSkillFiles(target)) { const resolved = path.resolve(f); if (seen.has(resolved)) continue; seen.add(resolved); files.push(await lintSkillFile(f, rubric)); } } const total = files.reduce((sum, f) => sum + f.score, 0); const companionItems = []; for (const file of seen) { const content = await fs.readFile(file, 'utf-8'); const body = content.match(/^---\n[\s\S]*?\n---\n?([\s\S]*)$/)?.[1] ?? content; const item = inventoryCompanionCli(file, body); if (item) companionItems.push(item); } return { rubric, threshold: THRESHOLDS[rubric], files, averageScore: files.length > 0 ? Math.round(total / files.length) : 0, failedCount: files.filter((f) => !f.passes).length, companionCli: { total: companionItems.length, reviewCount: companionItems.filter((i) => i.risk === 'review').length, items: companionItems, }, }; } function parseArgs(args) { const targets = []; let rubric = 'standard'; let json = false; for (let i = 0; i < args.length; i++) { const a = args[i]; if (a === '--json') { json = true; } else if (a === '--rubric' && i + 1 < args.length) { const next = args[++i]; if (next === 'strict' || next === 'standard' || next === 'lenient') { rubric = next; } } else if (!a.startsWith('-')) { targets.push(a); } } if (targets.length === 0) targets.push('agentic/code'); return { targets, rubric, json }; } function renderTextReport(report) { const { files, threshold, rubric, averageScore, failedCount } = report; for (const f of files) { if (f.passes) continue; console.log(`✗ ${f.file} (${f.score}/100)`); for (const [name, dim] of Object.entries(f.dimensions)) { if (dim.score < 100) { console.log(` ${name} (${dim.score}): ${dim.notes.join('; ')}`); } } } console.log(`\nskill-lint (rubric=${rubric}, threshold=${threshold}):`); console.log(` ${files.length} file(s) scanned`); console.log(` ${failedCount} below threshold`); console.log(` average score: ${averageScore}/100`); console.log(` companion CLI workflows: ${report.companionCli.total} inventoried, ${report.companionCli.reviewCount} need review`); for (const item of report.companionCli.items.filter((i) => i.risk === 'review').slice(0, 10)) { console.log(` review ${item.file}: ${item.notes.join('; ')}`); } } export const skillLintHandler = { id: 'skill-lint', name: 'Skill Lint', description: 'Score SKILL.md files against a quality rubric', category: 'utility', aliases: ['-skill-lint', '--skill-lint'], async execute(ctx) { const { targets, rubric, json } = parseArgs(ctx.args); const report = await lintSkills(targets, rubric); if (json) { console.log(JSON.stringify(report, null, 2)); } else { renderTextReport(report); } return { exitCode: report.failedCount > 0 ? 1 : 0, }; }, }; //# sourceMappingURL=skill-lint.js.map