openvibers/openvibe.codes Commits
Load repository skills by metadata and trigger (#402)
Changes
@@@ -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`).nnew file mode 100644@@@ -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,++};@@@ -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, tmsay({ 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, onlynnew file mode 100644@@@ -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); });
@@ -267,6 +267,27 @@ const again = fromReview('{"verdict":"approved","findings":[]}', first.findings)
@@ -267,6 +267,27 @@ const again = fromReview('{"verdict":"approved","findings":[]}', first.findings)
// again.line = 'Findings: 1 addressed, 0 reappeared, 0 still open, 0 new'
// 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
## Tests
`test/engine-*.test.js` in the Codes repo, run by the repo's own runner (`npm test`).
`test/engine-*.test.js` in the Codes repo, run by the repo's own runner (`npm test`).
new file mode 100644
new file mode 100644
@@ -0,0 +1,325 @@
@@ -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,
+};
@@ -23,6 +23,7 @@ const { collectProof } = require('../engine/proof');
@@ -23,6 +23,7 @@ const { collectProof } = require('../engine/proof');
const { createCheckpoints, toolPaths } = require('./checkpoints');
const { createCheckpoints, toolPaths } = require('./checkpoints');
const { runCheck } = require('./goal-check');
const { runCheck } = require('./goal-check');
const { boundedEvidence } = require('../engine/goal');
const { boundedEvidence } = require('../engine/goal');
+const skills = require('../engine/skills');
const JOB_PROTOCOL = '1.0.0';
const JOB_PROTOCOL = '1.0.0';
/** The agents a conductor's turn may run on: every harness Codes offers. */
/** The agents a conductor's turn may run on: every harness Codes offers. */
@@ -289,6 +290,9 @@ function parseArgs(raw, env = process.env) {
@@ -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.
// 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.
// 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) } : {}),
...(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.
// 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 } : {}),
...(typeof a.base_patch === 'string' && a.base_patch.trim() && a.base_patch.length <= MAX_PATCH ? { base_patch: a.base_patch } : {}),
...(goal ? { goal } : {}),
...(goal ? { goal } : {}),
@@ -672,10 +676,18 @@ async function pushBranch(dir, job, { env, fetchImpl, base }) {
@@ -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,
* 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.
* so this is the same for a CLI harness and an API model.
*/
*/
-function agentPrompt(job) {
+function agentPrompt(job, cwd = null) {
const parts = [];
const parts = [];
if (job.knowledge) parts.push(job.knowledge);
if (job.knowledge) parts.push(job.knowledge);
parts.push(job.prompt);
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') {
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.');
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
@@ -805,7 +817,7 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
say({ t: 'step', text: 'Preparing an empty benchmark workspace' });
say({ t: 'step', text: 'Preparing an empty benchmark workspace' });
}
}
const run = harness.run({
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,
advisor: job.advisor || null, cwd: work,
permission: job.permission, handoff: !job.agent, maxAttempts: job.kind === 'conductor' ? 1 : 3,
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
// A job on the person's Node runs in its sandbox (own namespaces, a read-only root, no home folder, only
new file mode 100644
new file mode 100644
@@ -0,0 +1,144 @@
@@ -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); });