Curso / Lição 12
Lição 12 · Mergulho profundo · subsistema 6 de 7

SkillStore — disclosure progressiva

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 disclosure progressiva: list() lê só metadados baratos; view() carrega o corpo completo sob demanda. Um CLONE do skills_tool.py (1.638 LOC) + skill_manager_tool.py (1.233 LOC) do Hermes.

Leia primeiro (fonte de registro)
packages/hermes/src/skills/ — skill-store.ts · frontmatter.ts · types.ts

Esta lição destila o código real do subsistema (rodapé com linhas verbatim) + a proveniência em docs/hermes-complete-map.md §3.3. Por que importa pra missão: é a memória procedural que deixa o Alembic guardar um procedimento aprendido e recuperá-lo barato depois — sem reler tudo a cada vez.

Objetivos desta lição
  • Explicar a disclosure progressiva em três níveis e por que list() nunca lê o corpo.
  • Ler o parser de frontmatter sem dependências (só key: value escalar) e seus limites deliberados.
  • Rastrear o validateSupportPath e por que confinar caminhos vira segurança.
  • Distinguir as cinco operações de CRUD e por que patch reusa o matcher de substring única.
0
níveis de disclosure (list/view/file)
0
operações de CRUD (list…removeFile)
0
casos de teste no skill-store.test.ts
0 / 0
teto de name / description (chars)

01 · O que é, de fato, uma "skill"

Antes do mecanismo, o objeto. Uma skill não é uma linha num banco — é um diretório no disco. No fundo dele há um arquivo obrigatório, SKILL.md, com duas partes: um cabeçalho --- … --- (o frontmatter, que guarda name e description) e o corpo de instruções abaixo. Em volta, quatro pastas opcionais carregam material de apoio.

A ANATOMIA DE UMA SKILL · um diretório <base>/<name>/ no disco
<base>/<name>/ — o diretório da skill SKILL.md (obrigatório) frontmatter --- … --- name (≤64) · description (≤1024) corpo (instruções) tudo depois do --- de fechamento pastas de apoio (opcionais) references/ · docs e contexto vinculados templates/ · modelos reutilizáveis scripts/ · automação que o agente autora assets/ · arquivos binários/auxiliares SKILL_SUPPORT_DIRS = ['references','templates','scripts','assets'] · types.ts:54–59

Um SkillStore é apoiado por um FsPort injetado (a fronteira de IO do @alembic/etl) sobre um único diretório-base — o análogo do ~/.hermes/skills/ da fonte. Isso é a invariante de injeção da Lição 05: o store nunca toca fs direto, então é testável com um FsPort em memória.

Procedural vs declarativa: skill não é memória

Por que um subsistema separado, e não só guardar isso no MemoryStore? Porque os dois guardam coisas diferentes, com formas diferentes. A memória é ampla e declarativa ("o que eu sei"); a skill é estreita e procedural ("como eu faço"). Veja lado a lado:

MemoryStore · memória declarativa ampla
fatos curtos · "o que eu sei" muitos registros, cada um pequeno fato fato fato amplo: cobre qualquer assunto → Lição 07
SkillStore · memória procedural estreita
procedimento longo · "como eu faço" poucos diretórios, cada um rico SKILL.md + references/templates/scripts/assets estreito e acionável durável: sobrevive entre runs

02 · Disclosure progressiva: três níveis de custo

Aqui está a ideia central. Carregar o corpo de toda skill toda vez que você quer só saber que elas existem seria caro — desperdício de contexto e de tokens. A disclosure progressiva resolve isso pagando o custo em três níveis: você lista de graça, e só paga pelo conteúdo no momento exato em que abre.

TRÊS NÍVEIS DE CUSTO · barato listar tudo; pague pelo corpo só ao abri-lo
nível 1 · list() lê SÓ o frontmatter name ≤64 · description ≤1024 nunca o corpo nível 2 · view(name) corpo completo do SKILL.md + lista de arquivos vinculados pago só ao abrir nível 3 · arquivo vinculado references/api.md etc. por caminho relativo pago só ao precisar cada nível à direita custa mais e é carregado mais tarde — só quando a decisão exige

O list() percorre o diretório-base, lê apenas o frontmatter de cada SKILL.md via readMetadata, ignora pastas sem skill válida, e devolve a lista ordenada por nome. Note o que ele não faz: nunca toca no corpo.

// 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 em silêncio
  }
  found.sort((a, b) => a.name.localeCompare(b.name));
  return ok(found);
}
Por que "pular em silêncio"? Um diretório sem um SKILL.md legível e de frontmatter válido é simplesmente ignorado — não é uma skill. É a tolerância do _find_all_skills da fonte: misture skills e pastas avulsas no mesmo lugar, e list() só enxerga as válidas, sem quebrar. Os dois tetos de metadados são carregados literalmente: MAX_NAME_LENGTH = 64, MAX_DESCRIPTION_LENGTH = 1024 (types.ts:41/47).
Preveja antes de continuar
Um agente chama list() sobre um diretório com 50 skills, cada SKILL.md com ~6 KB de corpo e ~120 bytes de frontmatter. Quantos bytes de corpo o list() lê do disco? Chute antes de revelar.
Zero bytes de corpo. O list() lê só o frontmatter de cada skill — ~120 bytes × 50 ≈ 6 KB no total, contra os ~300 KB que custaria ler os 50 corpos. Se você chutou "300 KB", caiu na armadilha de assumir que listar = carregar. É justamente o que a disclosure progressiva evita: o nível 1 é deliberadamente barato para você poder listar tudo sem medo. [uncertain] os tamanhos por arquivo são ilustrativos; a invariante — "list não lê o corpo" — é o teste real "returns metadata only — name + description, never the body" (linha 122).

03 · list vs view — o comparativo que define o subsistema

Os dois lados da disclosure são list() e view(). Eles parecem irmãos, mas fazem leituras opostas do disco. Ver os dois lado a lado é entender o subsistema inteiro:

Aspectolist() — nível 1view(name) — nível 2/3
Lê do discosó o frontmatter de cada skillcorpo completo + lista de arquivos vinculados
Custobarato (poucos bytes × N)caro (proporcional ao corpo)
Quando"que skills existem?""abra esta skill agora"
DevolveSkillMetadata[] ordenado por nomeum Skill (metadata + body + linkedFiles)
Erra quandonunca por skill inválida (pula)nome inválido · skill ausente · frontmatter inválido
Fonteskill-store.ts:80–96skill-store.ts:103–122
list LÊ POUCO de MUITAS skills · view LÊ TUDO de UMA — leituras opostas
list() · pouco × muitas skill A · só frontmatter skill B · só frontmatter skill C · só frontmatter barra cheia = lido · vazio = NÃO lido só a faixinha do frontmatter de cada uma view(name) · tudo × uma skill B · COMPLETA frontmatter + corpo inteiro + linkedFiles (caminhos relativos) barra cheia = corpo carregado paga o custo de uma só skill, ao abrir Mesmas skills no disco — muda só o quanto você lê e quando. É a disclosure progressiva em uma imagem.

O view() também junta os linkedFiles: percorre as quatro pastas de apoio e lista os arquivos não vazios (size > 0) — detalhe que volta na seção 09 quando virmos como o delete funciona. O nível 3 é carregar um desses arquivos por caminho relativo, num view(name, relPath) posterior.

view → linkedFiles · só arquivos com size > 0 entram na lista (skill-store.ts:344–363)
api.md (5 KB) old.md (0 B) size > 0 ? (não-vazio?) SIM NÃO entra em linkedFiles ✓ arquivo limpo (vazio) some da lista — é assim que removeFile "apaga"

04 · O parser de frontmatter sem dependências

Como o frontmatter vira name e description? A fonte parseia 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 desta run — o clone reproduz exatamente esse fallback, e nada mais: só pares escalares de nível superior.

Em linguagem simples: imagine ler uma ficha de cadastro só com linhas "campo: valor". O parser olha cada linha, acha o primeiro ":", chama o que vem antes de chave e o que vem depois de valor. Linha sem ":"? Ignora. Sem chave? Ignora. É só isso — nada de listas, nada aninhado. Deliberadamente burro, deliberadamente sem dependências.
Tecnicamente: parseScalarBlock (frontmatter.ts:60–70) faz block.split('\n'), descarta linhas sem dois-pontos (indexOf(':') === -1), corta no primeiro dois-pontos, faz trim() dos dois lados e grava out[key] = value. Chaves repetidas: a última vence (semântica de sobrescrita de dict). Mapeamentos aninhados, listas, block scalars, âncoras e aspas-com-dois-pontos ficam fora de escopo até um humano aprovar a dep yaml.
// 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;          // chave vazia ⇒ pula
  out[key] = line.slice(colon + 1).trim();      // 1º 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, frontmatter.ts:22). Um documento sem fence de abertura retorna ({}, content) — tudo vira corpo (frontmatter.ts:40–52).

A FRONTEIRA DO BLOCO · onde termina o frontmatter, começa o corpo
---fence de ABERTURA (offset 0–3) · obrigatório, senão tudo é corpo name: pesquisar-web description: chama webSearch e resume os resultados ← bloco escalar (parseScalarBlock) ---fence de FECHAMENTO · /\n---[ \t]*\n/ a partir do offset 3 ## Como usar … (tudo daqui pra baixo = corpo, body.trim())

05 · Faça o parse na mão (passo a passo → agora você)

Você viu a regra. Agora rode-a na mão, devagar, sobre um frontmatter de verdade — depois um caso é seu. Recuperar o procedimento (não só ler a regra) é o que fixa.

Exemplo resolvido · parsear este frontmatter linha a linha
0
O input. O bloco entre os fences é: name: pesquisar-web / description: chama webSearch: e resume / (linha em branco). Repare no segundo dois-pontos na description — é a pegadinha.
1
Linha 1 — name: pesquisar-web. indexOf(':') = 4. Chave = "name" (slice 0..4, trim). Valor = "pesquisar-web" (slice 5, trim). Grava out.name = "pesquisar-web".
2
Linha 2 — description: chama webSearch: e resume. O indexOf(':') acha o primeiro dois-pontos (após "description"). Valor = "chama webSearch: e resume" — o segundo ":" fica dentro do valor, intacto. Grava out.description.
3
Linha 3 — em branco. indexOf(':') = -1 → continue. A linha é pulada sem erro.
4
Resultado. { name: "pesquisar-web", description: "chama webSearch: e resume" }. O corpo é tudo depois do --- de fechamento, com trim().
Agora você: o bloco é name: x / name: y / : solto. Qual o out.name final, e o que acontece com a terceira linha? Faça antes de revelar.
out.name = "y" — chaves posteriores vencem (a segunda linha sobrescreve a primeira). A terceira linha (: solto) tem dois-pontos no índice 0 → chave = "" (string vazia após trim) → key.length === 0continue, pulada. Dica: as duas guardas (sem-dois-pontos e chave-vazia) são exatamente o que torna o parser robusto a lixo sem precisar de uma lib de YAML.

06 · Segurança de caminho: sem traversal, sob um subdir permitido

Uma skill pode guardar scripts e assets que o agente autorou. Escrever um arquivo dentro de uma skill é, no fundo, "escrever um arquivo no disco" — e isso é perigoso se o caminho puder escapar. O validateSupportPath é o portão: ele transforma "escrever um arquivo" numa operação delimitada e auditável, recusando qualquer caminho que tente sair da skill.

Diante de um caminho relativo, siga o fluxograma de cabeça — cada losango é uma checagem que ou barra o caminho ou o deixa passar. O caminho malicioso ../../secrets.txt está destacado: ele cai no losango de traversal.

FLUXOGRAMA · validateSupportPath — o caminho é seguro? (skill-store.ts:404–437)
relPath entra ex.: '../../secrets.txt' tem '\\' ou começa com '/' ? SIM ✗ errforward slashes / relative NÃO < 2 segmentos? (precisa subdir/arquivo) SIM ✗ err'references/api.md' NÃO algum segmento é '..' ou '.' ? SIM ✗ traversal'path traversal not allowed' NÃO 1º segmento ∈ references/ templates/scripts/assets ? NÃO →✗ err (subdir) SIM ✓ ok(path)caminho confinado '../../secrets.txt' tem '..' → barra no losango de traversal
// 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 …');
Por que tão estrito? Um caminho relativo que escapasse do diretório da skill (../../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. Quatro testes a protegem: um escape .. (linha 378), um caminho absoluto e um com barra invertida (linha 386), e um arquivo fora dos subdirs permitidos (linha 399) — cada um recusado.

07 · CRUD: as cinco operações de mutação

Além de list/view (leitura), o store tem cinco operações que mudam o disco — todas devolvendo Result<T, Error>, nunca lançando exceção na fronteira pública. Cada uma tem regras de colisão/ausência próprias:

AS CINCO MUTAÇÕES · cada uma falha-fechada com sua própria regra
createrecusa nome existente edit (rewrite)preserva frontmatter patchsubstring única + re-parse deletelimpa SKILL.md p/ vazio writeFile(name, relPath)valida caminho + escreve apoio removeFile(name, relPath)valida caminho + limpa apoio regra comum: view/edit/patch/delete recusam skill AUSENTE create recusa colisão · tudo → Result<T, Error>, nunca throw
OperaçãoFalha-fechada quando…Fonte
createnome inválido · metadata inválida · corpo vazio · nome já existe129–159
editnome inválido · corpo vazio · skill ausente166–187
patchfind vazio · 0/múltiplas ocorrências · re-parse quebra · skill ausente196–220
deletenome inválido · skill ausente228–242
writeFile / removeFilecaminho inseguro · skill ausente · (removeFile) arquivo ausente249–284

Vistas como um todo, as operações desenham o ciclo de vida de uma skill: ela nasce com create, é descoberta e aberta por list/view, evolui por edit/patch, e some por delete. Siga as transições:

CICLO DE VIDA DE UMA SKILL · cada seta é uma operação que muda (ou lê) o estado
AUSENTE (sem SKILL.md / vazio) VIVA frontmatter válido + corpo visível a list() / view() create (recusa se já existe) delete → limpa p/ vazio edit (rewrite) · patch (substring) leitura list · view · não muta observa create entra · edit/patch mantêm viva · delete sai · list/view só observam (sem efeito no estado)

08 · patch: um matcher, reusado — substring única

O 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.

Por que isso importa? Compare as duas filosofias de matcher lado a lado:

FUZZY (fonte Python) vs ÚNICA (Alembic) · por que o clone escolhe a da direita
fuzzy_match (Python) normaliza espaços, adivinha a + próxima tolerante — mas pode acertar a ocorrência ERRADA conveniente, não-determinístico no limite replaceUnique (Alembic) match exato · exige EXATAMENTE 1 ocorrência 0 ou >1 → err · nada é escrito (fail-closed) previsível · força o autor a ser específico O clone usa o MESMO motor do memory store (memory-store.ts:locateUnique) — uma convenção, não duas.
// 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));
replaceUnique · quantas ocorrências de `find`? — o losango decide
indexOf(find)conta ocorrências quantas? (0 / 1 / >1) 0 ✗ err "No match" exatamente 1 ✓ ok(substituído)slice + replace + slice >1 ✗ err "Multiple matches… Be more specific"

09 · O guard de re-parse: um patch não pode corromper os metadados

Aqui está a sutileza que protege a skill de si mesma. Um patch opera sobre o texto bruto do SKILL.md — inclusive sobre o frontmatter. E se a substituição quebrar a estrutura do frontmatter em metadados inválidos? A resposta: depois de substituir, o patch re-parseia o resultado e re-valida; se quebrou, a escrita é recusada e a edição nunca cai.

GUARD DE RE-PARSE · substitui → re-parseia → só grava se ainda for válido (skill-store.ts:211–215)
replaceUnique oktexto candidato parseSkill(candidato)re-valida frontmatter ainda válido? (reparsed.ok?) SIM ✓ gravawriteAtomic NÃO ✗ err "Patch would break SKILL.md structure" — NADA grava
O teste que prova: "refuses a patch that would corrupt the frontmatter"

O caso na linha 294 do skill-store.test.ts aplica um patch que destruiria o frontmatter e verifica que a operação retorna err e o arquivo no disco fica intacto. É a garantia: você nunca consegue, via patch, deixar uma skill num estado em que list()/view() não conseguem mais lê-la.

10 · delete sem unlink: limpar para vazio

Última peça, e um desvio honesto. O FsPort do Alembic não expõe um unlink (nem remoção recursiva de diretório). Então como o delete "apaga"? Ele escreve uma string vazia por cima do SKILL.md. Um arquivo vazio tem frontmatter vazio → falha a validação → fica invisível a list() e view(). O marcador portável mais próximo de "deletado".

delete → writeAtomic(path, '') · antes vs depois (skill-store.ts:237–242)
ANTES --- name / description --- corpo de instruções ✓ aparece em list() e view() writeAtomic(path, '') DEPOIS (arquivo vazio) ✗ frontmatter vazio → invisível a list()/view()
Dois desvios na mesma linha: (1) delete e removeFile limpam o arquivo para vazio em vez de fazer unlink — o teste "makes the skill invisible to list and view" (linha 320) prova; (2) create recusa uma colisão de nome enquanto view/edit/patch/delete recusam uma skill ausente. São escolhas explícitas da portabilidade, documentadas no cabeçalho do skill-store.ts.

Confusões comuns

"Ele parseia frontmatter YAML completo." Não — só pares escalares key: value de nível superior, um clone fiel do caminho de fallback da fonte. YAML mais rico (metadata aninhado, listas, platforms, prerequisites) é 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.

11 · Como isso se encaixa

O SkillStore não é uma peça solta — é a metade durável do ciclo de auto-aperfeiçoamento do Alembic. Pense nele como a memória de longo prazo procedural: enquanto o MemoryStore (Lição 07) guarda o que o agente sabe, o SkillStore guarda como ele faz. Os dois alimentam o mesmo loop fechado (Lição 08), e a disclosure progressiva é o que torna esse acervo barato de carregar quando o agente decide agir. Veja o trajeto, da origem ao consumo:

UPSTREAM → SkillStore → DOWNSTREAM · onde a memória procedural entra no organismo
reviewAndLearn · upstream (Lição 08) após um run, propõe guardar um procedimento aprendido MemoryStore · a outra metade (Lição 07) declarativo "o que sei" — irmão, não a mesma coisa create / edit / patch complementa SkillStore (Lição 12) — esta peça · memória procedural durável cada skill = um diretório SKILL.md + references/templates/scripts/assets, sob um FsPort disclosure progressiva: list() lê só metadados · view() carrega o corpo só ao abrir ↓ repousa sobre: ports & injeção (Lição 05) — FsPort injetado, nunca toca fs direto é o que deixa o store rodar contra um FsPort em memória nos 35 testes list() barato o agente vê quais skills existem e abre só a relevante com view() — paga só ali aplica o procedimento run do harness / swarm (Lições 17, 19) a skill recuperada guia o trabalho — fecha o loop
Clique para acender peça a peça — de quem escreve a skill (reviewAndLearn) a quem a consome (o run).
Onde você está na metodologia. No mapa do curso, as Lições 07–13 abrem os sete subsistemas um a um; o SkillStore é o sexto, e o par durável do MemoryStore (Lição 07). Ele não fica na linha da pipeline de execução (escopo → council → proof → validator → publish); ele fica no eixo de aprendizado, que corre ao lado: um run termina, o reviewAndLearn (Lição 08) decide se um procedimento merece virar skill, e na próxima vez a disclosure progressiva o devolve barato. Veja o eixo de aprendizado dentro da máquina inteira no mapa interativo da metodologia.

12 · Na prática

Sendo honesto: não existe um comando alembic skills que exponha o SkillStore na CLI — ele é uma biblioteca interna do @alembic/hermes, consumida pelo runtime, não um subcomando. [uncertain] nenhum comando da CLI canônica exercita diretamente este subsistema hoje. O caminho real para vê-lo rodar é o pacote de testes — que exercita as três níveis de disclosure e os cinco CRUD contra um FsPort em memória, sem tocar disco real:

# exercita o SkillStore — os 35 casos de skill-store.test.ts rodam aqui
pnpm --filter @alembic/hermes test
# …
# ✓ src/skills/skill-store.test.ts  (FsPort em memória → zero IO real)
#   ✓ list returns metadata only — name + description, never the body
#   ✓ view loads the full body + linkedFiles
#   ✓ rejects path traversal '..' in writeFile
#   ✓ refuses a patch that would corrupt the frontmatter
#   ✓ delete makes the skill invisible to list and view
# Test Files  1 passed   (entre os do pacote)
# Tests       35 passed  ← todos com um FsPort FALSO, nada toca seu disco

Os nomes dos casos acima são os reais de skill-store.test.ts (rodapé). A contagem de 35 é a do repo na escrita desta lição — o que importa é a invariante que cada caso prova, não o número exato.

E como o SkillStore é usado como API? Você o constrói com um FsPort e um diretório-base, e chama os mesmos métodos da disclosure progressiva. Este é o uso real — a mesma forma do teste (new SkillStore(fs, BASE)):

// uso da API — list (nível 1, barato) → view (nível 2, só ao abrir)
import { SkillStore } from '@alembic/hermes';

const store = new SkillStore(fs, '/skills');   // fs: um FsPort injetado

const all = await store.list();              // só frontmatter de cada skill
if (!all.ok) return all;                     // fail-closed: trate o err antes
for (const meta of all.value)
  console.error(meta.name, '—', meta.description);

const one = await store.view('pesquisar-web'); // AGORA paga o corpo
if (one.ok) console.error(one.value.body, one.value.linkedFiles);

// segurança: um caminho que tenta escapar é recusado ANTES de qualquer IO
await store.writeFile('pesquisar-web', '../../secrets.txt', 'x');
// ⇒ err('path traversal is not allowed') — nada é escrito (Result, nunca throw)

Repare em console.error, não console.log — saída de diagnóstico fora do canal padrão é a convenção do projeto. E todo método devolve Result<T, Error>: o if (!r.ok) return r é obrigatório antes de ler r.value — o typecheck recusa o contrário.

Experimente · rode o SkillStore e veja a disclosure progressiva por dentro
1
Entre no repo e rode o pacote. cd na raiz do monorepo e rode pnpm --filter @alembic/hermes test. Termina em segundos, sem rede e sem escrever no seu disco — porque o teste injeta um FsPort em memória (MemFs).
2
Abra o teste e ache a costura. Em packages/hermes/src/skills/skill-store.test.ts, procure new SkillStore(fs, BASE): o fs é a classe MemFs implements FsPort do topo do arquivo — não node:fs. É a injeção da Lição 05, no teste.
3
Veja a disclosure no caso real. Ache o caso "returns metadata only — name + description, never the body": ele afirma que list() devolve o frontmatter. Compare com o caso de view, que carrega o corpo. É a diferença entre nível 1 e nível 2 — provada, não só desenhada.
4
Confirme a baseline do projeto. Rode pnpm -r typecheck && pnpm -r build && pnpm -w test na raiz. O typecheck é a metade silenciosa da fronteira: ele recusa compilar se você ler r.value sem ter tratado !r.ok antes — exatamente o que o snippet da API faz de propósito.
O que você acabou de provar: a disclosure progressiva e a segurança de caminho não são prosa — são casos de teste que rodam contra uma porta falsa, determinísticos, sem tocar o mundo. E se eu quiser ver isso num run real?
O SkillStore é consumido por dentro do runtime, não por um subcomando próprio. Um run inteiro — alembic run --goal GOAL.md --plan alembic.plan.ts --yes — exercita o motor cujos kernels (incluindo este) seguem a mesma forma; e o ciclo de aprendizado que escreve skills aparece quando o run roda com aprendizado ligado (Lição 08). [uncertain] esses comandos exercitam o motor inteiro, não isolam o SkillStore; para isolar esta peça, o caminho direto continua sendo o pnpm --filter @alembic/hermes test acima.

Fixe os conceitos (flashcards)

Clique pra virar. Tente lembrar a resposta antes de virar — recuperação ativa fixa mais que reler.

Disclosure
O que list() lê do disco?
clique pra virar ↻
Resposta
Só o frontmatter de cada skill (name + description) — nível 1. Nunca o corpo. Lista ordenada por nome; pula dirs sem skill válida.
Parser
Como parseScalarBlock trata uma linha sem dois-pontos?
clique pra virar ↻
Resposta
indexOf(':') === -1continue (pula). Corta no PRIMEIRO dois-pontos; chaves repetidas: a última vence. Só escalar top-level.
Segurança
Por que ../../secrets.txt é recusado?
clique pra virar ↻
Resposta
validateSupportPath rejeita qualquer segmento ../. (+ absoluto, barra invertida, <2 segmentos, 1º segmento fora dos 4 subdirs). Vira operação delimitada.
patch
O que faz patch com 2 ocorrências de find?
clique pra virar ↻
Resposta
err "Multiple matches… Be more specific" — substring única, fail-closed, nada escrito. Mesmo motor do memory store, não fuzzy.

Revisão cumulativa — recupere de memória

Antes de clicar: responda de cabeça. As quatro opções têm o mesmo tamanho de propósito — sem pista pela forma.

1. Um agente chama list() sobre um diretório de 50 skills. O que ele lê do disco?
Correto: b. list() lê só frontmatter via readMetadata — nunca o corpo (o teste "returns metadata only — name + description, never the body", linha 122). a inverte a ideia inteira da disclosure: ler o corpo de todas seria o desperdício que o subsistema existe para evitar. c inventa um cache que não existe — todo list() bate no disco (o store é stateless além do fs/baseDir). d confunde nível 1 com nível 3: arquivos de apoio só são carregados por caminho relativo, muito depois.
2. writeFile('my-skill', '../../secrets.txt', …) é chamado. O resultado?
Correto: c. O caminho cai no losango de traversal (segmento ..) e é recusado antes de qualquer IO. a é exatamente o ataque que a guarda existe para impedir — uma primitiva de escrever-em-qualquer-lugar. b "ignorar o .." seria pior: silenciosamente escreveria no lugar errado; o store prefere falhar alto. d erra a forma do erro: o store nunca lança na fronteira — devolve Result com err (o teste linha 378).
3. patch é pedido para substituir uma substring que aparece duas vezes no SKILL.md. O que acontece?
Correto: d. replaceUnique exige exatamente uma ocorrência; zero ou múltiplas → err (teste linha 286). a e b são justamente o não-determinismo que a regra evita: substituir "todas" ou "a primeira" esconde a ambiguidade do autor. c descreve o matcher fuzzy da fonte Python, que o clone deliberadamente NÃO usa — escolheu o motor de substring única do Alembic por previsibilidade.
4. Por que um patch que quebraria o frontmatter nunca chega ao disco?
Correto: a. Após substituir, o patch chama parseSkill no candidato; se reparsed.ok for falso, retorna "Patch would break SKILL.md structure" e a escrita nunca acontece (linhas 211–215; teste linha 294). b atribui ao FsPort uma inteligência que ele não tem — ele só faz IO; a validação é do store. c é falso: patch opera sobre o texto bruto inteiro, frontmatter incluído — é justamente por isso que o guard existe. d inventa concorrência: o problema aqui é integridade do conteúdo, não corrida entre escritas.
💬 Travou em algo? Eu sou seu professor neste curso — pergunte. "Por que reusar o matcher do memory store e não o fuzzy?", "Como o view monta os linkedFiles?", "O que muda quando aprovarem a dep yaml?". É só dizer.