openvibers/openvibe.codes Commits

Load repository skills by metadata and trigger (#402)

OP OpenVibers committed 2 days ago · 1ea8e5a096 · 4 files, +504 −2

Changes

ddiff --git a/engine/README.md b/engine/README.md
iindex 6860614..a5817ca 100644
---- a/engine/README.md
++++ b/engine/README.md
@@@ -267,6 +267,27 @@ const again = fromReview('{"verdict":"approved","findings":[]}', first.findings)
// again.line = 'Findings: 1 addressed, 0 reappeared, 0 still open, 0 new'
```
++### `skills.js` — metadata always, the procedure only when the task matches
++
++A skill is a folder with a `SKILL.md` under `.openvibe/skills`, `.claude/skills`, `.agents/skills` or `skills`. The
++frontmatter is small and deliberate: `name`, `description`, `invocation` (`model` or `user`), `when_to_use`,
++`references`, `scripts`. `index(root)` reads every skill and validates it — a 500-line body cap, the name against its
++folder, every `references/…` and `scripts/…` pointer really inside the folder — and a broken skill is reported, never
++prompted from. `brief(skills, { prompt })` always lists the metadata, loads the body only for a skill the task names or
++its when-to-use words match (at most three, 24,000 characters), and names the reference and script files instead of
++pasting them; a `user` skill waits for its name. The harness jobs opt in with `"skills": true` (or
++`CODES_SKILLS_LOADER=1`), so an existing prompt does not change.
++
++```js
++const skills = require('./engine/skills');
++
++const { skills: found } = skills.index(cwd);
++const { block } = skills.brief(found, { prompt: job.prompt, root: cwd });
++// '## Skills in this repository' + one metadata line per skill + the triggered bodies
++skills.load(found[0]); // the body, on demand
++skills.readReference(found[0], 'references/traps.md');
++```
++
## Tests
`test/engine-*.test.js` in the Codes repo, run by the repo's own runner (`npm test`).
ddiff --git a/engine/skills.js b/engine/skills.js
nnew file mode 100644
iindex 0000000..8f28214
---- /dev/null
++++ b/engine/skills.js
@@@ -0,0 +1,325 @@
++'use strict';
++
++/**
++ * The skill loader: procedures kept as files in the repository, with their metadata always at hand and their body
++ * only when the task matches.
++ *
++ * A skill is a folder with a `SKILL.md` in one of the conventional roots (`.openvibe/skills`, `.claude/skills`,
++ * `.agents/skills`, `skills`). The file starts with a small frontmatter block:
++ *
++ * ---
++ * name: ship-it
++ * description: Merge a green PR and deploy its service
++ * invocation: model # model (the loader may load it) or user (only when the person names it)
++ * when_to_use: ship, deploy, merge the pull request
++ * references: references/traps.md
++ * scripts: scripts/ship.sh
++ * ---
++ *
++ * # Ship it
++ * …the procedure…
++ *
++ * Why split it: a repository with thirty skills would put thirty procedures in every prompt, and most of them are
++ * irrelevant to the task at hand. The metadata (name, one line, where the body is) is cheap and always loaded; a
++ * matching skill's body is loaded by `brief`; the references and scripts stay files the agent opens itself, so a
++ * large reference never costs tokens until it is needed.
++ *
++ * parse(text) { meta, body, errors } from a SKILL.md
++ * index(root) every skill under the conventional roots, with validation errors
++ * validate(skill) { valid, errors }: name, body length, pointers, invocation
++ * load(skill) the body, on demand (a skill that failed validation is not loaded)
++ * readReference(skill, name) one reference or script file, on demand
++ * brief(skills, { prompt }) { block, loaded, skipped }: metadata + the bodies this task triggered
++ *
++ * Default off: the harness passes `skills: true` on the job (or sets CODES_SKILLS_LOADER=1) before a prompt gets a
++ * skills block. A repository with no skills is normal and produces nothing.
++ */
++
++const fs = require('fs');
++const path = require('path');
++
++const ROOT_DIRS = ['.openvibe/skills', '.claude/skills', '.agents/skills', 'skills'];
++const MAX_LINES = 500;
++const MAX_NAME = 64;
++const MAX_DESCRIPTION = 500;
++const MAX_LOADED = 3;
++const MAX_LOADED_CHARS = 24_000;
++const NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
++const INVOCATIONS = ['model', 'user'];
++const POINTER = /(?:^|[\s(`'"[<])((?:references|scripts)\/[A-Za-z0-9._/-]+)/g;
++
++const clean = (v) => String(v == null ? '' : v).replace(/\r\n/g, '\n');
++const linesOf = (text) => clean(text).split('\n').length;
++
++/** A pointer list from `a, b` or `[a, b]` or a YAML dash block folded into one line. */
++function listOf(value) {
++ const raw = String(value == null ? '' : value).trim().replace(/^\[|\]$/g, '');
++ if (!raw) return [];
++ return raw.split(',').map((s) => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
++}
++
++/**
++ * Parse one SKILL.md. The frontmatter is deliberately small: `key: value` lines (and `[a, b]` lists), because a
++ * skill is a procedure, not a YAML document. An unknown key is kept in `meta` and not an error; a missing opening
++ * or closing fence is.
++ *
++ * @param {string} text the file
++ * @returns {{ meta: object, body: string, errors: string[] }} the parsed skill
++ */
++function parse(text) {
++ const raw = clean(text);
++ const errors = [];
++ if (!raw.startsWith('---\n')) return { meta: {}, body: raw, errors: ['SKILL.md must start with a --- frontmatter block'] };
++ const end = raw.indexOf('\n---', 4);
++ if (end < 0) return { meta: {}, body: raw, errors: ['SKILL.md frontmatter has no closing --- line'] };
++ const head = raw.slice(4, end).split('\n');
++ const body = raw.slice(end + 4).replace(/^\n/, '');
++ const meta = {};
++ for (const line of head) {
++ const s = line.trim();
++ if (!s || s.startsWith('#')) continue;
++ const at = s.indexOf(':');
++ if (at <= 0) { errors.push(`frontmatter line is not key: value — ${s.slice(0, 80)}`); continue; }
++ const key = s.slice(0, at).trim();
++ const value = s.slice(at + 1).trim().replace(/^['"]|['"]$/g, '');
++ if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(key)) { errors.push(`frontmatter key is not a name — ${key.slice(0, 40)}`); continue; }
++ meta[key] = value;
++ }
++ return { meta, body, errors };
++}
++
++/** The path under the skill's folder, or null when it would leave it. */
++function inside(dir, rel) {
++ const p = path.resolve(dir, rel);
++ const root = path.resolve(dir) + path.sep;
++ return p.startsWith(root) ? p : null;
++}
++
++/** The references and scripts a skill names, each with whether it is really there. */
++function filesOf(dir, meta, body) {
++ const declared = {
++ references: listOf(meta.references),
++ scripts: listOf(meta.scripts),
++ };
++ const used = new Set();
++ const seen = new Set();
++ let m;
++ POINTER.lastIndex = 0;
++ while ((m = POINTER.exec(clean(body))) !== null) {
++ const rel = m[1].replace(/[.,;:)]+$/, ''); // a pointer at the end of a sentence keeps no full stop
++ if (!seen.has(rel)) { seen.add(rel); used.add(rel); }
++ }
++ const out = [];
++ for (const [kind, list] of Object.entries(declared)) {
++ for (const rel of list) {
++ const full = inside(dir, rel);
++ out.push({ kind, name: rel, path: full, exists: !!full && fs.existsSync(full) });
++ }
++ }
++ for (const rel of used) {
++ if (out.some((f) => f.name === rel)) continue;
++ const kind = rel.startsWith('scripts/') ? 'scripts' : 'references';
++ const full = inside(dir, rel);
++ out.push({ kind, name: rel, path: full, exists: !!full && fs.existsSync(full) });
++ }
++ return out;
++}
++
++/**
++ * Read one skill folder (or one SKILL.md path) into a record. A thin folder with no SKILL.md is an error and not a
++ * skill.
++ *
++ * @param {string} file the SKILL.md (or its folder)
++ * @returns {{ skill: object|null, error: string|null }} the record or why there is none
++ */
++function read(file) {
++ const p = path.resolve(String(file || ''));
++ const skillFile = fs.existsSync(p) && fs.statSync(p).isDirectory() ? path.join(p, 'SKILL.md') : p;
++ let text;
++ try { text = fs.readFileSync(skillFile, 'utf8'); } catch (err) { return { skill: null, error: `cannot read ${skillFile}: ${err.message}` }; }
++ const dir = path.dirname(skillFile);
++ const parsed = parse(text);
++ const meta = parsed.meta;
++ const name = String(meta.name || path.basename(dir)).trim();
++ const invocation = String(meta.invocation || meta['invoked-by'] || 'model').trim().toLowerCase();
++ const skill = {
++ name,
++ description: String(meta.description || '').trim(),
++ invocation,
++ when_to_use: String(meta.when_to_use || meta['when-to-use'] || '').trim(),
++ dir,
++ file: skillFile,
++ rel: path.relative(process.cwd(), skillFile).split(path.sep).join('/'),
++ body_lines: linesOf(parsed.body),
++ body: parsed.body,
++ files: filesOf(dir, meta, parsed.body),
++ meta,
++ parse_errors: parsed.errors,
++ };
++ const check = validate(skill);
++ skill.valid = check.valid;
++ skill.errors = check.errors;
++ return { skill, error: null };
++}
++
++/**
++ * Validate one skill record: the name, the one-line description, the 500-line body, the invocation split, and every
++ * pointer (`references/…`, `scripts/…`) really inside the folder.
++ *
++ * @param {object} skill a record from read()
++ * @returns {{ valid: boolean, errors: string[] }}
++ */
++function validate(skill) {
++ const errors = [];
++ if (!skill || typeof skill !== 'object') return { valid: false, errors: ['a skill must be an object'] };
++ for (const e of skill.parse_errors || []) errors.push(e);
++ if (!NAME.test(String(skill.name || ''))) errors.push(`name must be ${MAX_NAME} lowercase letters, digits and dashes`);
++ if (String(skill.name || '').length > MAX_NAME) errors.push(`name is longer than ${MAX_NAME} characters`);
++ if (!String(skill.description || '').trim()) errors.push('description is required');
++ else if (skill.description.length > MAX_DESCRIPTION) errors.push(`description is longer than ${MAX_DESCRIPTION} characters`);
++ if (!INVOCATIONS.includes(String(skill.invocation || ''))) errors.push(`invocation is one of ${INVOCATIONS.join(', ')}`);
++ if (Number(skill.body_lines) > MAX_LINES) errors.push(`body is ${skill.body_lines} lines; the limit is ${MAX_LINES} (move detail into references/)`);
++ if (skill.dir && path.basename(skill.dir) !== skill.name) errors.push(`name ${skill.name} does not match its folder ${path.basename(skill.dir)}`);
++ for (const f of skill.files || []) {
++ if (!f.path) errors.push(`${f.kind} pointer ${f.name} leaves the skill folder`);
++ else if (!f.exists) errors.push(`${f.kind} pointer ${f.name} is missing`);
++ }
++ return { valid: errors.length === 0, errors };
++}
++
++/**
++ * Every skill in a checkout, under the conventional roots (and the roots a caller adds). A broken skill is reported
++ * with its errors and left out of `skills`, so a loader never prompts from a file it could not check.
++ *
++ * @param {string} root the repository folder
++ * @param {object} [opts]
++ * @param {string[]} [opts.roots] extra folders, relative to root
++ * @returns {{ skills: object[], errors: object[], roots: string[] }} the index
++ */
++function index(root, { roots = [] } = {}) {
++ const base = path.resolve(String(root || '.'));
++ const dirs = [...ROOT_DIRS, ...roots.map(String)].filter((v, i, a) => v && a.indexOf(v) === i);
++ const skills = [];
++ const errors = [];
++ const found = [];
++ for (const rel of dirs) {
++ const dir = path.join(base, rel);
++ let names = [];
++ try { names = fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name); } catch { continue; }
++ for (const name of names) {
++ const got = read(path.join(dir, name));
++ if (!got.skill) { errors.push({ file: path.join(dir, name), errors: [got.error] }); continue; }
++ if (!got.skill.valid) { errors.push({ file: got.skill.file, name: got.skill.name, errors: got.skill.errors }); continue; }
++ if (found.includes(got.skill.name)) { errors.push({ file: got.skill.file, name: got.skill.name, errors: [`name ${got.skill.name} is used by two skills`] }); continue; }
++ found.push(got.skill.name);
++ skills.push(got.skill);
++ }
++ }
++ return { skills, errors, roots: dirs };
++}
++
++/** The body, on demand. A skill that did not validate has no body to load. */
++function load(skill) {
++ if (!skill || !skill.valid) return null;
++ return String(skill.body || '');
++}
++
++/** One reference or script, on demand; null when the skill did not declare it or the file is gone. */
++function readReference(skill, name) {
++ if (!skill || !skill.valid) return null;
++ const f = (skill.files || []).find((x) => x.name === name && x.exists && x.path);
++ if (!f) return null;
++ try { return fs.readFileSync(f.path, 'utf8'); } catch { return null; }
++}
++
++/** The words that trigger a skill: its own name, and each when-to-use phrase of three letters or more. */
++function triggersOf(skill) {
++ const out = [String(skill.name || '').toLowerCase()];
++ for (const phrase of listOf(skill.when_to_use)) if (phrase.length >= 3) out.push(phrase.toLowerCase());
++ return out.filter(Boolean);
++}
++
++/** Is this skill's name said in the task? That is how a person invokes a skill explicitly. */
++function named(skill, prompt) {
++ const p = String(prompt || '').toLowerCase();
++ if (!p || !skill || !skill.name) return false;
++ const t = String(skill.name).toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
++ return new RegExp(`(^|[^a-z0-9-])${t}([^a-z0-9-]|$)`, 'i').test(p);
++}
++
++/** Does one of the skill's when-to-use phrases appear in the task? */
++function phraseMatched(skill, prompt) {
++ const p = String(prompt || '').toLowerCase();
++ if (!p) return false;
++ return listOf(skill && skill.when_to_use).some((t) => t.length >= 3 && p.includes(t.toLowerCase()));
++}
++
++/**
++ * Does the loader load this skill for this task? A skill the person names by name always; a model-invoked skill also
++ * when one of its when-to-use phrases matches; a user-invoked skill never on a phrase alone.
++ */
++function triggered(skill, prompt) {
++ if (named(skill, prompt)) return true;
++ return String(skill && skill.invocation) !== 'user' && phraseMatched(skill, prompt);
++}
++
++/** A path relative to the checkout, for words a person reads; absolute outside it. */
++function shown(file, root) {
++ const rel = root ? path.relative(root, file) : path.relative(process.cwd(), file);
++ return rel && !rel.startsWith('..') ? rel.split(path.sep).join('/') : file;
++}
++
++/**
++ * The block a prompt gets: one line per model-invoked skill (name, description, body path), then the full body of
++ * the skills this task triggered (at most three, 24,000 characters) and, for a user-invoked skill, its body only when
++ * the person named it. References and scripts are named, never pasted.
++ *
++ * @param {object[]} skills validated records from index()
++ * @param {object} [opts]
++ * @param {string} [opts.prompt] the task's brief, for trigger matching
++ * @param {string} [opts.root] the checkout, for relative paths
++ * @returns {{ block: string, loaded: string[], skipped: string[] }} the prompt block and what was loaded
++ */
++function brief(skills = [], { prompt = '', root = '' } = {}) {
++ const list = Array.isArray(skills) ? skills.filter((s) => s && s.valid !== false) : [];
++ if (!list.length) return { block: '', loaded: [], skipped: [] };
++ const meta = [];
++ const bodies = [];
++ const loaded = [];
++ const skipped = [];
++ let used = 0;
++ for (const skill of list) {
++ const hit = triggered(skill, prompt);
++ const metaLine = `- ${skill.name} (${skill.invocation})${skill.description ? `: ${skill.description}` : ''} — body: ${shown(skill.file, root)}`;
++ if (!hit) {
++ meta.push(metaLine);
++ continue;
++ }
++ const body = load(skill);
++ if (!body) { meta.push(metaLine); skipped.push(skill.name); continue; }
++ if (loaded.length >= MAX_LOADED || used + body.length > MAX_LOADED_CHARS) {
++ meta.push(metaLine);
++ skipped.push(skill.name);
++ continue;
++ }
++ const refs = (skill.files || []).map((f) => f.name).filter(Boolean);
++ meta.push(metaLine);
++ bodies.push([`### ${skill.name}`, body.trim(), refs.length ? `Files this skill may need (open only what the step needs): ${refs.join(', ')}` : ''].filter(Boolean).join('\n\n'));
++ loaded.push(skill.name);
++ used += body.length;
++ }
++ if (!meta.length && !bodies.length) return { block: '', loaded: [], skipped: [] };
++ const block = [
++ '## Skills in this repository',
++ 'Metadata is listed here; a triggered procedure is included in full. Open a skill\'s body only when what you are about to do matches it, and open its references or scripts only when a step needs them.',
++ ...meta,
++ ...bodies,
++ skipped.length ? `Not included (over the three-skill or ${MAX_LOADED_CHARS}-character limit): ${skipped.join(', ')}. Open one by the path above if the task needs it.` : '',
++ ].filter(Boolean).join('\n\n');
++ return { block, loaded, skipped };
++}
++
++module.exports = {
++ ROOT_DIRS, MAX_LINES, MAX_DESCRIPTION, MAX_LOADED, MAX_LOADED_CHARS, INVOCATIONS,
++ parse, read, index, validate, load, readReference, triggersOf, triggered, brief,
++};
ddiff --git a/harness/job.js b/harness/job.js
iindex 5b19a7f..261f05b 100644
---- a/harness/job.js
++++ b/harness/job.js
@@@ -23,6 +23,7 @@ const { collectProof } = require('../engine/proof');
const { createCheckpoints, toolPaths } = require('./checkpoints');
const { runCheck } = require('./goal-check');
const { boundedEvidence } = require('../engine/goal');
++const skills = require('../engine/skills');
const JOB_PROTOCOL = '1.0.0';
/** The agents a conductor's turn may run on: every harness Codes offers. */
@@@ -289,6 +290,9 @@ function parseArgs(raw, env = process.env) {
// Know K5: the lessons and facts earlier runs learned, from the server's own store and already a short block.
// It is prepended to the prompt in runJob, so every harness (CLI or API) gets it the same way.
...(typeof a.knowledge === 'string' && a.knowledge.trim() ? { knowledge: a.knowledge.trim().slice(0, 4000) } : {}),
++ // The repository's own skills (Release 2, feat 9): metadata in the prompt, a body only when the task matches.
++ // Off unless the job asks (or CODES_SKILLS_LOADER=1): an existing prompt must not grow silently.
++ ...(a.skills === true || /^(1|true|on|yes)$/i.test(String(env.CODES_SKILLS_LOADER || '')) ? { skills: true } : {}),
// The conversation's work so far (Codes L31 CH4): applied to the clone before the agent starts.
...(typeof a.base_patch === 'string' && a.base_patch.trim() && a.base_patch.length <= MAX_PATCH ? { base_patch: a.base_patch } : {}),
...(goal ? { goal } : {}),
@@@ -672,10 +676,18 @@ async function pushBranch(dir, job, { env, fetchImpl, base }) {
* a code task — the one line that lets the next run learn from this one. Every adapter takes the prompt as its brief,
* so this is the same for a CLI harness and an API model.
*/
--function agentPrompt(job) {
++function agentPrompt(job, cwd = null) {
const parts = [];
if (job.knowledge) parts.push(job.knowledge);
parts.push(job.prompt);
++ // Skills live in the checkout; discovery runs after the clone. A skill body is loaded only when the task names
++ // the skill or matches its when-to-use words, and references stay files the agent opens itself.
++ if (job.skills && cwd) {
++ try {
++ const loaded = skills.brief(skills.index(cwd).skills, { prompt: job.prompt, root: cwd });
++ if (loaded.block) parts.push(loaded.block);
++ } catch { /* an unreadable skills folder never fails the task */ }
++ }
if (job.kind === 'code') {
parts.push('---\n## Before you finish (OpenVibe.Codes Knowledge)\n\nEnd with a `Learned:` block of at most three lines: one fact the next agent on this repository should know, each with the file, command or URL that shows it. Leave it out when nothing was new.');
}
@@@ -805,7 +817,7 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
say({ t: 'step', text: 'Preparing an empty benchmark workspace' });
}
const run = harness.run({
-- prompt: agentPrompt(job), task: 'edit', agent: job.agent, model: job.model, effort: job.effort || null,
++ prompt: agentPrompt(job, work), task: 'edit', agent: job.agent, model: job.model, effort: job.effort || null,
advisor: job.advisor || null, cwd: work,
permission: job.permission, handoff: !job.agent, maxAttempts: job.kind === 'conductor' ? 1 : 3,
// A job on the person's Node runs in its sandbox (own namespaces, a read-only root, no home folder, only
ddiff --git a/test/engine-skills.test.js b/test/engine-skills.test.js
nnew file mode 100644
iindex 0000000..81e24b5
---- /dev/null
++++ b/test/engine-skills.test.js
@@@ -0,0 +1,144 @@
++'use strict';
++// The skill loader: metadata always, the body only when the task matches, references and scripts on demand.
++
++const assert = require('node:assert/strict');
++const fs = require('node:fs');
++const os = require('node:os');
++const path = require('node:path');
++const { check, done } = require('./helpers/boot');
++const skills = require('../engine/skills');
++const { agentPrompt, parseArgs } = require('../harness/job');
++
++const tmp = [];
++function checkout() {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codes-skills-'));
++ tmp.push(dir);
++ return dir;
++}
++
++/** Write one skill folder under a root, with optional extra files. */
++function put(root, name, body, meta = {}, files = {}, at = '.openvibe/skills') {
++ const dir = path.join(root, at, name);
++ fs.mkdirSync(dir, { recursive: true });
++ const head = [`name: ${name}`, ...Object.entries(meta).map(([k, v]) => `${k}: ${v}`)];
++ fs.writeFileSync(path.join(dir, 'SKILL.md'), `---\n${head.join('\n')}\n---\n\n${body}\n`);
++ for (const [rel, text] of Object.entries(files)) {
++ fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
++ fs.writeFileSync(path.join(dir, rel), text);
++ }
++ return dir;
++}
++
++(async () => {
++ await check('frontmatter names the skill and its body stays out of the metadata', () => {
++ const parsed = skills.parse('---\nname: ship-it\ndescription: Merge and deploy\ninvocation: model\n---\n\n# Ship\nRun the tests.');
++ assert.equal(parsed.meta.name, 'ship-it');
++ assert.equal(parsed.meta.description, 'Merge and deploy');
++ assert.equal(parsed.meta.invocation, 'model');
++ assert.match(parsed.body, /Run the tests/);
++ assert.deepEqual(parsed.errors, []);
++ const broken = skills.parse('name: no-fence\n');
++ assert.match(broken.errors.join('; '), /must start with a --- frontmatter/);
++ assert.match(skills.parse('---\nname: x\n').errors.join('; '), /no closing ---/);
++ });
++
++ await check('index finds every conventional root and leaves a broken skill out', () => {
++ const dir = checkout();
++ put(dir, 'ship-it', '# Ship\nRun the tests, then merge.', { description: 'Merge a green PR' });
++ put(dir, 'review-it', '# Review\nRead the diff.', { description: 'Review a change', invocation: 'user' }, {}, '.claude/skills');
++ put(dir, 'broken', '# Broken', { description: 'Has a missing pointer', references: 'references/gone.md' });
++ const idx = skills.index(dir);
++ assert.deepEqual(idx.skills.map((s) => s.name).sort(), ['review-it', 'ship-it']);
++ assert.equal(idx.errors.length, 1);
++ assert.match(idx.errors[0].errors.join('; '), /references\/gone\.md is missing/);
++ assert.equal(skills.index(checkout()).skills.length, 0, 'a repository without skills is normal');
++ });
++
++ await check('validate holds the 500-line body, the folder name and the pointer inside it', () => {
++ const dir = checkout();
++ put(dir, 'long-one', Array.from({ length: 501 }, (_, i) => `line ${i}`).join('\n'), { description: 'Too long' });
++ put(dir, 'escaping', '# Escape', { description: 'A pointer out', references: '../outside.md' }, { '../outside.md': 'x' });
++ const idx = skills.index(dir);
++ const byName = Object.fromEntries(idx.errors.map((e) => [e.name, e.errors.join('; ')]));
++ assert.match(byName['long-one'], /body is \d+ lines; the limit is 500/);
++ assert.match(byName.escaping, /leaves the skill folder/);
++ const good = put(dir, 'small', '# Small\nSee references/traps.md.', { description: 'Small', references: 'references/traps.md' }, { 'references/traps.md': 'the traps' });
++ const got = skills.read(good).skill;
++ assert.equal(got.valid, true);
++ assert.equal(got.files.find((f) => f.name === 'references/traps.md').exists, true);
++ });
++
++ await check('brief always lists metadata and loads a body only when the task matches', () => {
++ const dir = checkout();
++ put(dir, 'ship-it', '# Ship\nRun the tests then merge.', { description: 'Merge a green PR', when_to_use: 'ship, deploy' });
++ put(dir, 'notes', '# Notes\nThe owner prefers small PRs.', { description: 'Owner notes' });
++ const idx = skills.index(dir, { roots: [] });
++ const quiet = skills.brief(idx.skills, { prompt: 'Add a parser and its tests', root: dir });
++ assert.match(quiet.block, /ship-it \(model\): Merge a green PR/);
++ assert.match(quiet.block, /body: \.openvibe\/skills\/ship-it\/SKILL.md/);
++ assert.ok(!quiet.block.includes('Run the tests then merge'), 'an unrelated task does not get the procedure');
++ assert.deepEqual(quiet.loaded, []);
++ const hitting = skills.brief(idx.skills, { prompt: 'ship this change', root: dir });
++ assert.deepEqual(hitting.loaded, ['ship-it']);
++ assert.match(hitting.block, /### ship-it[\s\S]*Run the tests then merge/);
++ assert.match(hitting.block, /Skills in this repository/);
++ });
++
++ await check('a user-invoked skill waits for its name; a model one matches its when-to-use words', () => {
++ const dir = checkout();
++ put(dir, 'review-it', '# Review\nRead the diff before approving.', { description: 'Review a change', invocation: 'user', when_to_use: 'review, check my work' });
++ put(dir, 'deploy-it', '# Deploy\nRun the deploy script.', { description: 'Deploy', when_to_use: 'deploy' });
++ const idx = skills.index(dir);
++ const phrase = skills.brief(idx.skills, { prompt: 'review my work please', root: dir });
++ assert.deepEqual(phrase.loaded, []);
++ assert.ok(!phrase.block.includes('Read the diff before approving'));
++ const named = skills.brief(idx.skills, { prompt: 'use review-it on this branch', root: dir });
++ assert.deepEqual(named.loaded, ['review-it']);
++ assert.match(named.block, /Read the diff before approving/);
++ const model = skills.brief(idx.skills, { prompt: 'deploy the service', root: dir });
++ assert.deepEqual(model.loaded, ['deploy-it']);
++ });
++
++ await check('at most three bodies are loaded and references are named, never pasted', () => {
++ const dir = checkout();
++ for (let i = 0; i < 5; i++) {
++ put(dir, `skill-${i}`, `# Skill ${i}\nThe procedure ${i}.`, { description: `Skill ${i}`, when_to_use: 'do the thing' });
++ }
++ put(dir, 'with-files', '# Files\nRead references/traps.md.', { description: 'Files', when_to_use: 'do the thing' }, { 'references/traps.md': 'SECRET TRAP TEXT' });
++ const idx = skills.index(dir);
++ const got = skills.brief(idx.skills, { prompt: 'do the thing', root: dir });
++ assert.equal(got.loaded.length, 3);
++ assert.equal(got.skipped.length, 3);
++ const withFiles = skills.brief(idx.skills, { prompt: 'with-files', root: dir });
++ assert.deepEqual(withFiles.loaded, ['with-files']);
++ assert.match(withFiles.block, /references\/traps\.md/);
++ assert.ok(!withFiles.block.includes('SECRET TRAP TEXT'), 'a reference is a file the agent opens itself');
++ });
++
++ await check('the body and one reference are readable on demand, and only from a valid skill', () => {
++ const dir = checkout();
++ const at = put(dir, 'ship-it', '# Ship\nRead references/traps.md first.', { description: 'Ship', references: 'references/traps.md' }, { 'references/traps.md': 'always gitleaks first' });
++ const skill = skills.read(at).skill;
++ assert.match(skills.load(skill), /Read references\/traps\.md first/);
++ assert.match(skills.readReference(skill, 'references/traps.md'), /gitleaks/);
++ assert.equal(skills.readReference(skill, 'references/nope.md'), null);
++ assert.equal(skills.load({ ...skill, valid: false }), null);
++ });
++
++ await check('the harness prompt carries the skills block only when the job asks for it', () => {
++ const dir = checkout();
++ put(dir, 'ship-it', '# Ship\nRun the tests then merge.', { description: 'Merge a green PR', when_to_use: 'ship' });
++ const on = agentPrompt({ kind: 'code', prompt: 'ship this change', skills: true }, dir);
++ assert.match(on, /## Skills in this repository/);
++ assert.match(on, /### ship-it[\s\S]*Run the tests then merge/);
++ const off = agentPrompt({ kind: 'code', prompt: 'ship this change' }, dir);
++ assert.ok(!off.includes('## Skills in this repository'), 'the prompt is unchanged unless a job asks');
++ const repo = 'https://github.com/Owner/Repo';
++ assert.equal(parseArgs(JSON.stringify({ repo, prompt: 'x', skills: true }), {}).skills, true);
++ assert.equal(parseArgs(JSON.stringify({ repo, prompt: 'x' }), {}).skills, undefined);
++ assert.equal(parseArgs(JSON.stringify({ repo, prompt: 'x' }), { CODES_SKILLS_LOADER: '1' }).skills, true);
++ });
++
++ for (const dir of tmp) fs.rmSync(dir, { recursive: true, force: true });
++ await done();
++})().catch((err) => { for (const dir of tmp) fs.rmSync(dir, { recursive: true, force: true }); done(err); });