voyage/lib/skills/skill-frontmatter.mjs
Kjell Tore Guttormsen cc832fd430
feat(skills): skill workshop, test-first and debugging preloaded into the step helper
The workshop (ordre 7a, contracts S1 and S2): a form check
(lib/skills/skill-lint.mjs) that follows Claude Code's own skill fields,
and a model-free routing test (lib/skills/routing.mjs) over every skill,
the 7 commands and the 21 spawnable agents, reading the eval cases
themselves (evals/skill-*, 3 fire / 2 quiet / 1 effect, tagged with the
event they came from). Thresholds pinned, never lowered.

The two skills were written against failures measured first in helpers
without them: red only as an import crash, the whole suite run from a
hand-made file list instead of the repo's own command, and an
unreproduced fix shipped under authority pressure. step-implementer
preloads both through its skills: line; no tool list change.

Credits: addyosmani/agent-skills @9d0c60d, obra/superpowers @8ca22db,
mattpocock/skills @c55ee46 (MIT), Anthropic skill-creator (Apache-2.0).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:38:58 +02:00

143 lines
6.6 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// lib/skills/skill-frontmatter.mjs
// Frontmatter reader for skill, command and agent files: the fields the skill
// workshop needs, with the line each one is on and the YAML traps it hits.
//
// Why not lib/util/frontmatter.mjs: that parser deliberately rejects
// multi-line strings, and Voyage's agents write `description: |`. This reader
// takes plain, quoted and block-scalar (`|`, `>`, with `-`/`+`) values, flow
// lists, and indented lists or maps (kept as raw text). It is not a YAML
// parser: it reads the subset a SKILL.md uses and reports what would break a
// real one. Claude Code loads a skill whose YAML does not parse "with no
// fields set", so the description is gone and the skill cannot match; a trap
// here is an error, not a style note.
const KEY_RE = /^([A-Za-z0-9_-]+):(.*)$/;
const BLOCK_RE = /^([|>])([+-]?)\s*$/;
/**
* Split a file into its frontmatter lines and body.
* @returns {{ ok: boolean, fmLines: string[], body: string, bodyStartLine: number }}
*/
export function splitFile(text) {
const src = String(text).replace(/^/, '');
const lines = src.split(/\r?\n/);
if (lines[0] !== '---') return { ok: false, fmLines: [], body: src, bodyStartLine: 1 };
const end = lines.indexOf('---', 1);
if (end < 0) return { ok: false, fmLines: [], body: src, bodyStartLine: 1 };
return { ok: true, fmLines: lines.slice(1, end), body: lines.slice(end + 1).join('\n'), bodyStartLine: end + 2 };
}
/** The YAML traps in one plain (unquoted, single-line) scalar value. */
export function plainScalarHazards(value) {
const out = [];
if (/:\s/.test(value) || /:$/.test(value)) out.push('unquoted ": " (YAML reads a mapping and the line fails)');
if (/\s#/.test(value)) out.push('" #" starts a YAML comment: the rest of the value is cut');
if (/^[`@%*&!|>,?{]/.test(value)) out.push(`a value may not start with "${value[0]}" unquoted`);
if (/^\[/.test(value) && !/\]$/.test(value)) out.push('an unclosed flow list');
if (/^-\s/.test(value)) out.push('"- " at the start reads as a list item');
return out;
}
function unquote(v) {
if (v.startsWith('"')) {
if (v.length < 2 || !v.endsWith('"') || v.endsWith('\\"')) return { error: 'unclosed double quote' };
try { return { value: JSON.parse(v) }; } catch { return { error: 'bad escape in a double-quoted value' }; }
}
if (v.startsWith("'")) {
if (v.length < 2 || !v.endsWith("'")) return { error: 'unclosed single quote' };
return { value: v.slice(1, -1).replace(/''/g, "'") };
}
return null;
}
function blockValue(lines, style, chomp) {
const nonBlank = lines.filter((l) => l.trim() !== '');
const indent = nonBlank.length ? Math.min(...nonBlank.map((l) => l.match(/^ */)[0].length)) : 0;
const stripped = lines.map((l) => l.slice(indent));
while (stripped.length && stripped[stripped.length - 1].trim() === '') stripped.pop();
let text;
if (style === '|') text = stripped.join('\n');
else {
// Folded: lines join with a space; a blank line is a newline.
text = '';
for (const l of stripped) {
if (l === '') text += '\n';
else text += (text === '' || text.endsWith('\n') ? '' : ' ') + l;
}
}
if (chomp === '-') return text;
return `${text}\n`;
}
/**
* Read the frontmatter of a skill/command/agent file.
* @returns {{ ok: boolean, fields: Object<string, {value: any, line: number, style: string}>,
* hazards: {line: number, message: string}[], error?: string }}
*/
export function readFrontmatterFields(text) {
const split = splitFile(text);
if (!split.ok) return { ok: false, fields: {}, hazards: [], error: 'no frontmatter: the file must start with a "---" line and close it' };
const { fmLines } = split;
const fields = {};
const hazards = [];
let i = 0;
while (i < fmLines.length) {
const line = fmLines[i];
const lineNo = i + 2; // line 1 is the opening ---
if (/\t/.test(line)) hazards.push({ line: lineNo, message: 'a tab in frontmatter: YAML forbids tabs for indentation' });
if (line.trim() === '' || line.trimStart().startsWith('#')) { i++; continue; }
if (/^\s/.test(line)) { hazards.push({ line: lineNo, message: 'an indented line that belongs to no key' }); i++; continue; }
const m = KEY_RE.exec(line);
if (!m) { hazards.push({ line: lineNo, message: `not a "key: value" line: ${line.slice(0, 60)}` }); i++; continue; }
const key = m[1];
const rest = m[2].replace(/^[ \t]+/, '');
if (m[2] !== '' && !/^[ \t]/.test(m[2])) hazards.push({ line: lineNo, message: `"${key}:" needs a space after the colon` });
if (key in fields) hazards.push({ line: lineNo, message: `duplicate key "${key}"` });
// Collect the indented continuation lines (block scalar, list or map).
let j = i + 1;
const cont = [];
while (j < fmLines.length && (fmLines[j].trim() === '' || /^\s/.test(fmLines[j]))) { cont.push(fmLines[j]); j++; }
while (cont.length && cont[cont.length - 1].trim() === '') { cont.pop(); }
const block = BLOCK_RE.exec(rest);
if (block) {
fields[key] = { value: blockValue(cont, block[1], block[2]), line: lineNo, style: 'block' };
} else if (rest === '') {
const items = cont.filter((l) => l.trim() !== '');
if (items.length && items.every((l) => /^\s+-\s/.test(l))) {
fields[key] = { value: items.map((l) => l.replace(/^\s+-\s+/, '').trim()), line: lineNo, style: 'list' };
} else {
fields[key] = { value: items.join('\n'), line: lineNo, style: items.length ? 'map' : 'empty' };
}
} else {
if (cont.length) hazards.push({ line: lineNo, message: `"${key}" has a value and indented lines under it` });
const q = unquote(rest);
if (q && q.error) {
hazards.push({ line: lineNo, message: q.error });
fields[key] = { value: rest, line: lineNo, style: 'broken' };
} else if (q) {
fields[key] = { value: q.value, line: lineNo, style: 'quoted' };
} else if (/^\[.*\]$/.test(rest)) {
const inner = rest.slice(1, -1).trim();
fields[key] = {
value: inner === '' ? [] : inner.split(',').map((s) => { const t = s.trim(); const u = unquote(t); return u && !u.error ? u.value : t; }),
line: lineNo, style: 'flow',
};
} else {
for (const h of plainScalarHazards(rest)) hazards.push({ line: lineNo, message: `${key}: ${h}` });
fields[key] = { value: rest, line: lineNo, style: 'plain' };
}
}
i = j;
}
return { ok: hazards.length === 0, fields, hazards };
}
/** The description of a file as one string ('' when absent). */
export function descriptionOf(text) {
const r = readFrontmatterFields(text);
const d = r.fields.description;
if (!d) return '';
return Array.isArray(d.value) ? d.value.join(' ') : String(d.value);
}