diff --git a/.agents/skills/audio-recap/SKILL.md b/.agents/skills/audio-recap/SKILL.md index 2e3aa14..ded36af 100644 --- a/.agents/skills/audio-recap/SKILL.md +++ b/.agents/skills/audio-recap/SKILL.md @@ -3,7 +3,7 @@ name: audio-recap description: Turn changes made by an agent into a concise Markdown script for text-to-speech. Use only when the user explicitly asks for an audio recap, spoken recap, TTS-ready change summary, or invokes /audio-recap. depends-on: [] chains-to: null -suggests: ["using-elevenlabs-tts", "audio-plan", "visual-recap"] +suggests: ["using-elevenlabs-tts", "audio-plan"] --- # Audio Recap diff --git a/.agents/skills/human-typed-plan/SKILL.md b/.agents/skills/human-typed-plan/SKILL.md index 2b1a521..146b4fc 100644 --- a/.agents/skills/human-typed-plan/SKILL.md +++ b/.agents/skills/human-typed-plan/SKILL.md @@ -1,7 +1,7 @@ --- name: human-typed-plan description: Use when a developer wants AI help with a feature, bugfix, refactor, or implementation approach while keeping human ownership of code, typing, decisions, and understanding; especially requests to plan, explain logic, compare approaches, or provide implementation steps for the developer to write. -depends-on: ["test-driven-development"] +depends-on: [] chains-to: null suggests: ["ideal-example-clone", "refactor-transaction"] --- diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 9bb00d3..f0c502f 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "void-grimoire", "description": "Additive coding-agent skills: session journaling, learned-correction persistence, prompt expansion, context discipline, safety checks, lookup helpers, and workflow assists", - "version": "4.1.0", + "version": "4.1.1", "source": "./", "author": { "name": "Dmytro L.", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 3684e74..b468ee0 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "void-grimoire", "description": "Additive coding-agent skills: session journaling, learned-correction persistence, prompt expansion, context discipline, safety checks, lookup helpers, and workflow assists", - "version": "4.1.0", + "version": "4.1.1", "author": { "name": "Dmytro L.", "email": "dmltdev@proton.me" diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 0c81804..d1f6dfa 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "void-grimoire", - "version": "4.1.0", + "version": "4.1.1", "description": "Additive coding-agent skills: session journaling, learned-correction persistence, prompt expansion, context discipline, safety checks, lookup helpers, and workflow assists", "author": { "name": "Dmytro L.", diff --git a/.github/workflows/skill-references.yml b/.github/workflows/skill-references.yml new file mode 100644 index 0000000..9ddb264 --- /dev/null +++ b/.github/workflows/skill-references.yml @@ -0,0 +1,19 @@ +name: Skill references + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + skill-references: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - run: npm test diff --git a/.omp-plugin/marketplace.json b/.omp-plugin/marketplace.json index 9bb00d3..f0c502f 100644 --- a/.omp-plugin/marketplace.json +++ b/.omp-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "void-grimoire", "description": "Additive coding-agent skills: session journaling, learned-correction persistence, prompt expansion, context discipline, safety checks, lookup helpers, and workflow assists", - "version": "4.1.0", + "version": "4.1.1", "source": "./", "author": { "name": "Dmytro L.", diff --git a/README.md b/README.md index 7867395..4ff60e8 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,9 @@ Composition still works: - `chains-to` — the named skill is invoked after this one completes - `suggests` — soft recommendation, agent checks if relevant +Composition fields refer to canonical shipped skill names, checked with `npm test`. +If a skill intentionally uses an external helper, its body must identify the provider, installation source, required or optional status, and behavior when the helper is unavailable. Do not encode an external helper as an unresolved internal composition edge. + The categorized `skills/` directory tree is the single domain map for shipped skills. The README mirrors that structure for humans. Claude Code loads relevant skills automatically via their descriptions; invoke any skill by name when you want it explicitly. Repo-local operational skills live in `.agents/skills`; `.claude/skills` points there by symlink. diff --git a/package.json b/package.json index 2af1b8e..c49140b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "void-grimoire", - "version": "4.1.0", + "version": "4.1.1", "description": "Utility skills for coding agents", "private": true, "license": "MIT", @@ -16,6 +16,9 @@ "session-summary", "prompt-expansion" ], + "scripts": { + "test": "node --test tests/skill-references.test.mjs" + }, "pi": { "skills": ["skills", ".agents/skills/void-install-skills"] } diff --git a/skills-lock.json b/skills-lock.json index 9c85b14..dfe54e8 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -14,7 +14,7 @@ "audio-recap": { "source": ".", "sourceType": "local", - "computedHash": "f72d2b151477cd8bc0db8c5be187f5d1ea6226ec6d66893ddd9b52450758cec9" + "computedHash": "e6d65aa9c0985261e6b4a617c6883ae9dc05a16a009b89d8fd842abce81ed839" }, "autonomous": { "source": ".", @@ -24,7 +24,7 @@ "autoresearch": { "source": ".", "sourceType": "local", - "computedHash": "e8f9e43a768f0e06244c38bdf2cb1f7940450395def9ededff1c5db7d5835b46" + "computedHash": "0c2f92e4ff350d1a95ece74852563804713ebcf33bb9bf4d578ea34d442e11a1" }, "brief": { "source": ".", @@ -119,7 +119,7 @@ "human-typed-plan": { "source": ".", "sourceType": "local", - "computedHash": "5eb9b9e12b09d0147d7de1c34bf07b76d3fd2926b45d00e11a14deee7b25eb15" + "computedHash": "bb52c31f632f801fb6dccc78df5bd83952e66378b80b46ef07f59e1d35fa3e8f" }, "ideal-example-clone": { "source": ".", diff --git a/skills/communication/audio-recap/SKILL.md b/skills/communication/audio-recap/SKILL.md index 2e3aa14..ded36af 100644 --- a/skills/communication/audio-recap/SKILL.md +++ b/skills/communication/audio-recap/SKILL.md @@ -3,7 +3,7 @@ name: audio-recap description: Turn changes made by an agent into a concise Markdown script for text-to-speech. Use only when the user explicitly asks for an audio recap, spoken recap, TTS-ready change summary, or invokes /audio-recap. depends-on: [] chains-to: null -suggests: ["using-elevenlabs-tts", "audio-plan", "visual-recap"] +suggests: ["using-elevenlabs-tts", "audio-plan"] --- # Audio Recap diff --git a/skills/planning/human-typed-plan/SKILL.md b/skills/planning/human-typed-plan/SKILL.md index 2b1a521..146b4fc 100644 --- a/skills/planning/human-typed-plan/SKILL.md +++ b/skills/planning/human-typed-plan/SKILL.md @@ -1,7 +1,7 @@ --- name: human-typed-plan description: Use when a developer wants AI help with a feature, bugfix, refactor, or implementation approach while keeping human ownership of code, typing, decisions, and understanding; especially requests to plan, explain logic, compare approaches, or provide implementation steps for the developer to write. -depends-on: ["test-driven-development"] +depends-on: [] chains-to: null suggests: ["ideal-example-clone", "refactor-transaction"] --- diff --git a/tests/skill-references.test.mjs b/tests/skill-references.test.mjs new file mode 100644 index 0000000..ceada08 --- /dev/null +++ b/tests/skill-references.test.mjs @@ -0,0 +1,180 @@ +import assert from 'node:assert/strict'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import test from 'node:test'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const relations = ['depends-on', 'chains-to', 'suggests']; +const tracked = new Set(['name', ...relations]); + +function discoverCatalog() { + const directories = path => readdirSync(path, { withFileTypes: true }) + .filter(entry => entry.isDirectory()).map(entry => entry.name).sort(); + const catalog = []; + for (const domain of directories(join(root, 'skills'))) { + for (const directory of directories(join(root, 'skills', domain))) { + const file = `skills/${domain}/${directory}/SKILL.md`; + if (!readdirSync(join(root, 'skills', domain, directory), { withFileTypes: true }) + .some(entry => entry.name === 'SKILL.md' && entry.isFile())) continue; + catalog.push({ file, directory, content: readFileSync(join(root, file), 'utf8') }); + } + } + return catalog; +} + +// This deliberately supports only the repository's plain keys and simple flow values. +// New graph syntax must fail visibly, not disappear from reference validation. +function parseValue(field, value) { + const skillName = token => { + if (!/^(?:[a-z0-9]+(?:-[a-z0-9]+)*|"[a-z0-9]+(?:-[a-z0-9]+)*"|'[a-z0-9]+(?:-[a-z0-9]+)*')$/.test(token)) { + throw new Error(`unsupported or malformed skill name: ${token}`); + } + return token.replace(/^['"]|['"]$/g, ''); + }; + value = value.replace(/\s+#.*$/, '').trim(); + if (field === 'name') return [skillName(value)]; + if (value === '' || value === 'null' || value === '~') return []; + if (value.startsWith('[') && value.endsWith(']')) { + const contents = value.slice(1, -1).trim(); + return contents === '' ? [] : contents.split(',').map(token => skillName(token.trim())); + } + if (field === 'chains-to') return [skillName(value)]; + throw new Error(`unsupported or malformed graph value: ${value}`); +} + +function extractFrontmatter({ file, content }, errors) { + const lines = content.split(/\r?\n/); + const end = lines.indexOf('---', 1); + const fields = new Map(); + if (lines[0] !== '---' || end < 0) { + errors.push(`${file}: missing opening or closing frontmatter delimiter`); + return fields; + } + const seen = new Set(); + let activeField; + for (const line of lines.slice(1, end)) { + if (line.trim() === '' || line.trimStart().startsWith('#')) continue; + if (/^\s/.test(line)) { + if (tracked.has(activeField)) errors.push(`${file}: ${activeField}: unsupported block or multiline value`); + continue; + } + const key = /^([A-Za-z][\w-]*):(?:[ \t]+(.*))?$/.exec(line); + if (!key) { + errors.push(`${file}: unsupported frontmatter key syntax: ${line}`); + activeField = undefined; + continue; + } + activeField = key[1]; + if (!tracked.has(activeField)) continue; + if (seen.has(activeField)) { + errors.push(`${file}: ${activeField}: duplicate tracked key`); + continue; + } + seen.add(activeField); + try { + fields.set(activeField, parseValue(activeField, key[2] ?? '')); + } catch (error) { + errors.push(`${file}: ${activeField}: ${error.message}`); + } + } + return fields; +} + +function validateCatalog(catalog) { + const errors = []; + if (catalog.length === 0) errors.push('Canonical skill catalog is empty'); + const parsed = catalog.map(source => ({ ...source, fields: extractFrontmatter(source, errors) })); + const names = new Map(); + for (const { file, directory, fields } of parsed) { + const name = fields.get('name')?.[0]; + if (!name) { + errors.push(`${file}: name: missing valid canonical name`); + continue; + } + if (name !== directory) errors.push(`${file}: name: ${name} does not match directory ${directory}`); + if (names.has(name)) errors.push(`${file}: name: duplicate canonical name ${name} (also ${names.get(name)})`); + names.set(name, file); + } + // Build all names first so file ordering cannot break valid forward references. + for (const { file, fields } of parsed) { + for (const field of relations) { + for (const target of fields.get(field) ?? []) { + if (!names.has(target)) errors.push(`${file}: ${field}: missing canonical target ${target}`); + } + } + } + return errors; +} + +const fixture = (directory, metadata = '', body = '') => ({ + file: `skills/fixtures/${directory}/SKILL.md`, + directory, + content: `---\nname: ${directory}\n${metadata}\n---\n${body}`, +}); + +test('every canonical composition target resolves to a shipped skill', () => { + const errors = validateCatalog(discoverCatalog()); + assert.deepEqual(errors, [], errors.join('\n')); +}); + +test('forward references and supported graph values resolve without reading unrelated fields or bodies', () => { + assert.deepEqual(validateCatalog([ + fixture('first', 'depends-on: ["second", third]\nchains-to: second\nsuggests: [\'third\']\ndescription: |\n suggests: [not-a-skill]\nargs:\n - name: input'), + fixture('second', 'depends-on: null\nchains-to: []\nsuggests:'), + fixture('third', 'depends-on: []\nchains-to: ~\nsuggests: null', '```yaml\nsuggests: [body-only]\n```'), + ]), []); +}); + +for (const [field, target] of [ + ['depends-on', 'test-driven-development'], + ['chains-to', 'missing-successor'], + ['suggests', 'visual-recap'], +]) { + test(`${field} reports its source and unresolved target`, () => { + assert.deepEqual(validateCatalog([fixture('source', `${field}: ["${target}"]`)]), [ + `skills/fixtures/source/SKILL.md: ${field}: missing canonical target ${target}`, + ]); + }); +} + +test('all unresolved edges are reported in one run', () => { + assert.deepEqual(validateCatalog([ + fixture('source', 'depends-on: [missing-prerequisite]\nchains-to: missing-successor\nsuggests: [missing-helper]'), + ]), [ + 'skills/fixtures/source/SKILL.md: depends-on: missing canonical target missing-prerequisite', + 'skills/fixtures/source/SKILL.md: chains-to: missing canonical target missing-successor', + 'skills/fixtures/source/SKILL.md: suggests: missing canonical target missing-helper', + ]); +}); + +for (const [label, metadata, expected] of [ + ['block list', 'suggests:\n - absent', /suggests: unsupported block/], + ['unterminated flow list', 'depends-on: [absent', /depends-on: unsupported or malformed/], + ['empty flow entry', 'suggests: [known,,absent]', /suggests: unsupported or malformed/], + ['mapping value', 'chains-to: {next: absent}', /chains-to: unsupported or malformed/], + ['anchor', 'suggests: &helpers [absent]', /suggests: unsupported or malformed/], + ['alias', 'suggests: *helpers', /suggests: unsupported or malformed/], + ['merge indirection', 'defaults: &helpers\n suggests: [absent]\n<<: *helpers', /unsupported frontmatter key syntax: < { + const errors = validateCatalog([fixture('source', metadata), fixture('known')]); + assert.match(errors.join('\n'), expected); + if (label === 'duplicate key') { + assert.ok(errors.includes('skills/fixtures/source/SKILL.md: suggests: missing canonical target absent')); + } + }); +} + +for (const [label, catalog, expected] of [ + ['empty catalog', [], /catalog is empty/], + ['duplicate canonical names', [fixture('source'), { ...fixture('other'), content: '---\nname: source\n---' }], /duplicate canonical name source/], + ['name mismatch', [{ ...fixture('source'), content: '---\nname: other\n---' }], /other does not match directory source/], + ['missing frontmatter', [{ ...fixture('source'), content: '# Skill\nsuggests: [absent]' }], /frontmatter delimiter/], +]) { + test(`${label} rejects an invalid canonical catalog`, () => { + assert.match(validateCatalog(catalog).join('\n'), expected); + }); +}