Skills são "memória procedural estreita e acionável" — a metade durável do ciclo de auto-aperfeiçoamento, complementando a memória declarativa ampla do MemoryStore. Uma skill é um diretório: um SKILL.md (frontmatter + corpo) mais arquivos opcionais sob references/templates/scripts/assets. A ideia definidora é a divulgação progressiva: list() lê só metadados baratos; view() carrega o corpo completo sob demanda. Um CLONE do skills_tool.py (1638 LOC) + skill_manager_tool.py (1233 LOC) do Hermes.
// packages/hermes/src/skills/skill-store.ts:80-96 — list (nível 1) async list(): Promise<Result<readonly SkillMetadata[], Error>> { // … readDir(baseDir) … for (const entry of entries.value) { if (!entry.isDirectory) continue; const meta = await this.readMetadata(entry.name); // só frontmatter if (meta.ok) found.push(meta.value); // pula dirs não-skill silenciosamente } found.sort((a, b) => a.name.localeCompare(b.name)); return ok(found); }
Um diretório sem um SKILL.md legível e de frontmatter válido é simplesmente pulado — não é uma skill (a tolerância do _find_all_skills da fonte). Os dois tetos de metadados são carregados literalmente: MAX_NAME_LENGTH = 64, MAX_DESCRIPTION_LENGTH = 1024.
A fonte parseia frontmatter com YAML completo (CSafeLoader), com um fallback ingênuo de key: value quando o YAML dá erro. Para ficar sem dependências — adicionar uma dep yaml é uma condição de parada para esta run — o clone reproduz exatamente esse fallback e nada mais: só pares escalares de nível superior:
// packages/hermes/src/skills/frontmatter.ts:60-70 — parseScalarBlock for (const line of block.split('\n')) { const colon = line.indexOf(':'); if (colon === -1) continue; // sem dois-pontos ⇒ pula a linha const key = line.slice(0, colon).trim(); if (key.length === 0) continue; out[key] = line.slice(colon + 1).trim(); // divide no PRIMEIRO dois-pontos; chaves posteriores vencem }
A detecção de fronteira do bloco espelha a fonte: o documento deve começar com ---, e o bloco fecha na primeira linha que é --- (casada como /\n---[ \t]*\n/ a partir do offset 3). Um documento sem fence de abertura retorna ({}, content) — tudo é o corpo. Mapeamentos aninhados, listas, aspas-com-dois-pontos são deliberadamente fora de escopo.
Arquivos de suporte são confinados. Um caminho relativo não pode conter segmento .. ou ., não pode ser absoluto, deve usar barras normais, e seu primeiro segmento deve ser um dos quatro subdirs de suporte:
// packages/hermes/src/skills/skill-store.ts:404-437 — validateSupportPath (condensado) if (relPath.includes('\\')) return err(…'use forward slashes'); if (relPath.startsWith('/')) return err(…'must be relative'); const segments = relPath.split('/').filter((s) => s.length > 0); if (segments.length < 2) return err(…"must be under references/templates/scripts/assets"); for (const segment of segments) if (segment === '..' || segment === '.') return err(…'path traversal is not allowed'); if (!isSupportDir(segments[0])) return err(…'first segment must be one of …');
../../etc/...) seria uma primitiva de escrever-em-qualquer-lugar. Confinar todo caminho de arquivo de suporte sob um subdir permitido da única skill transforma "escrever um arquivo" numa operação delimitada e auditável. Quatro testes a protegem: um escape .., um caminho absoluto, um caminho com barra invertida, e um arquivo fora dos subdirs permitidos são cada um recusados.patch faz um find-and-replace direcionado por substring ÚNICA — e deliberadamente reusa o mesmo motor fail-closed que o memory store usa (match exato; zero ou múltiplas ocorrências → err), não o matcher fuzzy normalizador-de-espaços da fonte. A escolha é fidelidade à convenção entregue do Alembic em vez do helper Python:
// packages/hermes/src/skills/skill-store.ts:449-461 — replaceUnique const first = content.indexOf(find); if (first === -1) return err(new Error(`No match for '${find}' in SKILL.md.`)); const second = content.indexOf(find, first + find.length); if (second !== -1) return err(new Error(`Multiple matches for '${find}' …`)); return ok(content.slice(0, first) + replace + content.slice(first + find.length));
Depois de substituir, patch re-parseia o resultado e re-valida o frontmatter; se a edição quebrou a estrutura do SKILL.md em metadados inválidos, a escrita é recusada com "Patch would break SKILL.md structure" — a edição nunca cai. O teste "refuses a patch that would corrupt the frontmatter" prova. Mais dois desvios a notar: o FsPort não expõe unlink, então delete/removeFile limpam o arquivo para vazio (o que o torna invisível a list()/view() — frontmatter vazio), e create recusa uma colisão de nome enquanto view/edit/patch/delete recusam uma skill ausente.
list() sobre um diretório de 50 skills. O que ele lê do disco?list() lê só frontmatter via readMetadata — nunca o corpo. Você paga por um corpo só ao view(name), e por um arquivo vinculado só ao abri-lo. O teste "returns metadata only — name + description, never the body" confirma.writeFile('my-skill', '../../secrets.txt', …) é chamado. O resultado?.././absoluto/barra-invertida. Isto transforma "escrever um arquivo" numa operação delimitada, nunca uma primitiva de escrever-em-qualquer-lugar.patch é pedido para substituir uma substring que aparece duas vezes no SKILL.md. O que acontece?replaceUnique exige exatamente uma ocorrência; zero ou múltiplas → err. O skill store deliberadamente reusa o motor de substring única do Alembic, não o matcher fuzzy do Python, por fidelidade à convenção entregue.key: value de nível superior, um clone fiel do caminho de fallback da fonte. YAML mais rico (metadata aninhado, listas) é adiado até um humano aprovar uma dependência yaml — adicionar uma é uma condição de parada para esta run.delete remove o diretório." Não — o FsPort não tem unlink recursivo, então delete limpa o SKILL.md para vazio, o que torna a skill invisível a list()/view() (frontmatter vazio falha a validação). Arquivos de suporte são geridos individualmente via removeFile.