O ciclo fechado tem duas metades: o SkillStore autora memória procedural, e o curador dispõe do que fica sem uso. UsageStore é um sidecar de telemetria que conta use/view/patch de skills; runCurator é um passo determinístico que move skills de agente há muito ociosas active → stale → archived. Dois invariantes o definem: nunca deletar (archive é o estado terminal), e TEMPO é um Clock injetado — nunca Date.now(). Um CLONE do skill_usage.py + curator.py do Hermes.
A decisão é uma função pura sobre uma âncora de inatividade e dois cortes. A ordem dos ramos — archive primeiro, depois stale, depois reativar — espelha o apply_automatic_transitions exatamente:
// packages/hermes/src/curator/curator.ts:123-136 — nextState const nextState = (record, staleCutoff, archiveCutoff): SkillState => { const anchor = record.lastActivityAt > 0 ? record.lastActivityAt : Number.POSITIVE_INFINITY; // nunca-ativo ⇒ nunca velho const current = record.state; if (anchor <= archiveCutoff && current !== 'archived') return 'archived'; if (anchor <= staleCutoff && current === 'active') return 'stale'; if (anchor > staleCutoff && current === 'stale') return 'active'; // reativa return current; };
Uma skill recém-criada tem lastActivityAt = 0 (criação não é atividade). Se 0 fosse usado literalmente como âncora, seria ≤ todo corte e a skill arquivaria no primeiro passo. Então o código ancora um registro nunca-ativo a Number.POSITIVE_INFINITY — mais novo que qualquer corte — garantindo que ele não fique nem stale nem archived até ter sido de fato usado e ficado ocioso. O teste "never archives a brand-new, never-active skill (lastActivityAt = 0)" prova. (O comentário do cabeçalho fraseia isto como "tratado como agora"; a implementação usa +Infinity, que tem o mesmo efeito e é independente da ordem.)
Por que archive-primeiro? Uma skill active ociosa além do corte de archive deve cair em archived diretamente — pulando stale — então o ramo de archive precisa vencer antes do ramo de stale. O teste "jumps active → archived directly when old enough (skips stale)" protege essa ordem.
Antes de qualquer transição, runCurator aplica dois opt-outs ortogonais. Só skills autoradas pelo agente são geridas pelo curador, e uma skill pinned é isenta em todo caminho:
// packages/hermes/src/curator/curator.ts:86-108 (condensado) for (const name of names) { const record = sidecar[name]; if (record.createdBy !== 'agent') { skipped.push({ name, reason: 'not-agent-created' }); continue; } if (record.pinned) { skipped.push({ name, reason: 'pinned' }); continue; } const to = nextState(record, staleCutoff, archiveCutoff); if (to === record.state) { skipped.push({ name, reason: 'no-change' }); continue; } const persisted = await deps.usage.put(name, { ...record, state: to }); if (!persisted.ok) return persisted; // fail-closed: não reporta trabalho não salvo transitioned.push({ name, from: record.state, to }); }
Skips são reportados, não escondidos — o CuratorReport carrega tanto transitioned[] quanto skipped[] (cada um com razão tipada: pinned/not-agent-created/no-change), ambos em ordem estável de nome. E a persistência é por-transição: se um put falha, o passo aborta com aquele err em vez de reportar uma mudança de estado que não aconteceu duravelmente.
UsageStore registra eventos incrementando um contador e carimbando lastActivityAt = clock(). Carrega a mesma assimetria leitura/escrita do memory store — um sidecar corrompido lê como vazio (nunca quebra um caminho quente), mas uma escrita que falha emerge como err:
// packages/hermes/src/curator/usage-store.ts:153-166 — load best-effort private async load(): Promise<UsageSidecar> { const stat = await tryCatchAsync(() => this.fs.stat(this.sidecarPath)); if (!stat.ok || !stat.value) return {}; // ausente ⇒ vazio const read = await tryCatchAsync(() => this.fs.readText(this.sidecarPath)); if (!read.ok) return {}; // erro de IO ⇒ vazio const parsed = tryParseJson(read.value); if (!parsed.ok) return {}; // JSON ruim ⇒ vazio const valid = usageSidecarSchema.safeParse(parsed.value); if (!valid.success) return {}; // forma errada ⇒ vazio return valid.data; }
Clock é injetado no UsageStore e no runCurator (curator.ts:53-59 diz isto explicitamente). Então "um evento registrado agora" e "uma transição decidida agora" concordam — não há skew entre quando a atividade foi carimbada e quando a obsolescência é julgada. O sidecar é serializado com chaves ordenadas + indent de 2 espaços (espelhando o json.dump(..., sort_keys=True, indent=2) do Python) para que as escritas sejam byte-estáveis e amigáveis a diff.createdBy: 'user' e não foi tocada em um ano. O que runCurator faz?not-agent-created. (E nada é jamais deletado — archive é o estado terminal.)lastActivityAt = 0. No primeiro passo do curador ela é…lastActivityAt fica 0; nextState ancora isso a Number.POSITIVE_INFINITY para que a skill não fique nem stale nem archived até ter sido genuinamente usada e depois ficado ociosa.nextState (archive → stale → reativar) é estrutural?active velha só daria um passo para stale neste passo em vez de pular para archived. O teste "jumps active → archived directly" protege a ordem, espelhando apply_automatic_transitions..archive/ é uma preocupação de transporte fora de escopo. Um caminho de reativação até traz uma skill stale de volta a active se ela for usada de novo antes do corte de archive.err.