Curso / Lição 09
Lição 09 · Mergulho profundo · subsistema 3 de 7

UsageStore + runCurator — a metade da disposição

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.

Leia primeiro (fonte no repositório, lida literalmente)
packages/hermes/src/curator/ — curator.ts · usage-store.ts · types.ts · curator.test.ts

Esta lição destila esses arquivos e o docs/hermes-complete-map.md §3.3 + §1.9/§1.10. Todo número e toda linha de código têm fonte (rodapé). Por que importa pra missão: sem disposição, a memória procedural do agente só cresce — o curador é o que mantém o conjunto de skills enxuto e auditável, sem nunca destruir nada.

Objetivos desta lição
  • Explicar por que o loop precisa de uma metade de disposição e como o curador a cumpre sem deletar.
  • Percorrer o ciclo active → stale → archived e o caminho de reativação de cabeça.
  • Defender por que a âncora Number.POSITIVE_INFINITY e a ordem dos ramos (archive → stale → reativar) são estruturais.
  • Distinguir os dois portões (proveniência e pinned) e a assimetria leitura-best-effort/escrita-fail-closed do sidecar.
0d
corte de stale (active → stale)
0d
corte de archive (terminal)
0
casos em curator.test.ts
âncora de quem nunca foi usado

01 · As duas metades do loop

Pense numa oficina: alguém fabrica ferramentas novas conforme a necessidade aparece, e alguém arruma a bancada, recolhendo o que ninguém pega há meses. Se só houvesse o fabricante, a bancada viraria um caos em semanas. O ciclo fechado de skills do Alembic tem exatamente esses dois papéis — e esta lição é sobre o arrumador.

O LOOP FECHADO DE SKILLS · uma metade autora, a outra dispõe (esta lição)
SkillStore autora (Lição 12) usa skill UsageStore conta use/view/patch lê telemetria runCurator dispõe (esta lição) arquiva o ocioso → o conjunto de skills fica enxuto sem nada ser destruído

Dois pedaços, dois deveres distintos: o UsageStore é puro registro — toda vez que uma skill é usada, vista ou corrigida, ele incrementa um contador e carimba o horário. O runCurator nunca observa o vivo; ele só lê esse registro frio e decide, por skill, se ela já ficou ociosa o bastante para mudar de estado. Nenhum dos dois jamais apaga uma skill — esse é o invariante central, e nós voltamos a ele na seção 10.

Preveja antes de continuar
Uma skill autorada pelo agente fica 120 dias sem ser usada (cortes: stale = 30d, archive = 90d). Em qual estado o runCurator a coloca — e o que acontece com o conteúdo dela?
archived — e o conteúdo continua intacto. 120d > 90d, então a âncora de inatividade cruza o corte de archive; como esse ramo vence primeiro (seção 04), a skill vai direto para archived mesmo que estivesse active. Mas nada é deletado: archive é o estado terminal e recuperável por invariante ("max action = archive"). Se você esperava "deletada para liberar espaço", esse é justamente o erro que o subsistema foi desenhado para nunca cometer.

02 · O ciclo de vida

Toda skill gerida pelo curador vive numa máquina de estados de três posições. Ela nasce active, escorrega para stale se ficar ociosa além do corte de stale, e cai em archived se ficar ociosa além do corte de archive. Duas setas extras tornam a máquina honesta: uma skill stale usada de novo reativa, e uma active velha o bastante pula stale e vai direto para archived.

CICLO DE VIDA · active → stale → archived, com reativação e o atalho direto
active criado aqui stale ocioso ≥ 30d archived ocioso ≥ 90d · terminal corte de stale corte de archive usado de novo ⇒ reativa velho o bastante ⇒ active pula direto para archived (pula stale)
Por que três estados e não dois? stale é uma zona de carência. Uma skill que caiu de moda por um mês pode voltar à moda — e o curador a traz de volta para active de graça se ela for usada antes do corte de archive. Só depois de 90 dias inteiros de silêncio o subsistema desiste e move para archived. Sem o degrau stale, não haveria janela de reativação.

As setas extras, em palavras simples

Reativação (stale → active): se a âncora de inatividade ficou maior que o corte de stale (ou seja, houve atividade recente) e o estado atual é stale, a skill volta a active. É a recompensa por voltar a ser útil.
Atalho (active → archived): se uma skill active está parada há mais que o corte de archive, ela não dá o passinho para stale — vai direto para archived. Faz sentido: já passou muito do ponto de carência.

As três faixas de ociosidade, numa linha do tempo

Os dois cortes recortam a linha do tempo de inatividade em três faixas. Onde a âncora cai decide o estado:

FAIXAS DE OCIOSIDADE · 0–30d active · 30–90d stale · 90d+ archived (terminal)
active stale (janela de reativação) archived · terminal 30d 90d A reativação encurta a história: usar a skill na faixa stale puxa a âncora de volta para a faixa active.

03 · A âncora +Infinity — o detalhe que evita um arquivamento absurdo

Aqui mora a parte mais sutil do subsistema. A decisão de estado é uma função pura sobre uma âncora de inatividade (o último horário de atividade) e os dois cortes. Mas e uma skill que nunca foi usada? Ela tem lastActivityAt = 0, porque criar não conta como atividade. Se 0 fosse usado literalmente como âncora, ele seria ≤ todo corte — e a skill arquivaria no primeiro passo do curador. Absurdo: ela acabou de nascer.

O BUG QUE A ÂNCORA EVITA · lastActivityAt = 0 literal vs ancorado a +Infinity
ingênuo: âncora = 0 0 ≤ archiveCutoff (qualquer corte) ⇒ ramo de archive dispara ✗ arquiva no 1º passo skill recém-nascida = morta real: âncora = +Infinity +∞ > todo corte ⇒ nenhum ramo de obsolescência ✓ fica active até ser usada e depois ficar ociosa "nunca-ativo ⇒ nunca velho": tratar 0 como o instante mais novo possível, não o mais antigo.

A correção é de uma linha: um registro nunca-ativo é ancorado a Number.POSITIVE_INFINITY — mais novo que qualquer corte concebível — garantindo que ele não fique nem stale nem archived até ter sido de fato usado e depois ficado ocioso. Veja a função inteira:

// 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;
};
Comentário do cabeçalho ≠ implementação (mesmo efeito)

O comentário do cabeçalho fraseia isto como "tratado como agora"; a implementação usa +Infinity. Os dois têm o mesmo efeito — manter a skill recém-criada longe de qualquer corte — mas +Infinity é independente da ordem: não importa qual valor de "agora" você usaria, +∞ é sempre maior. O teste "never archives a brand-new, never-active skill (lastActivityAt = 0)" prova exatamente este comportamento.

04 · A ordem dos ramos é estrutural — archive primeiro

Repare nos três if de nextState: eles são testados em ordem fixa — archive, depois stale, depois reativar. Essa ordem não é estética; ela decide o resultado para uma skill active muito velha. Se o teste de stale viesse primeiro, uma active parada há 120 dias só daria o passinho para stale neste passo — em vez de pular direto para archived, como deve.

FLUXOGRAMA · nextState para uma skill active parada há 120d (cortes 30/90)
record (active, ocioso 120d) anchor = lastActivityAt ① anchor ≤ archiveCutoff e state ≠ 'archived'? SIM (120>90) archived pulou stale NÃO ② anchor ≤ staleCutoff e state === 'active'? SIM stale NÃO ③ anchor > staleCutoff e state === 'stale'? → reativa; senão mantém ramo ① vence ⇒ archive direto

Para a skill active de 120 dias, o ramo ① (archive) já é verdadeiro, então a função retorna 'archived' e nunca chega ao ramo ② (stale). Se a ordem fosse invertida, o ramo de stale capturaria a skill primeiro e o resultado seria 'stale' — errado. O teste "jumps active → archived directly when old enough (skips stale)" protege essa ordem, espelhando o apply_automatic_transitions do Hermes exatamente.

Simples vs Técnico

Em uma frase: "se já passou muito do prazo, arquive de uma vez; só use o estado intermediário stale para quem passou pouco do prazo." A ordem dos if codifica exatamente essa prioridade — o caso mais severo (archive) é checado antes do mais brando (stale).
Precisamente: os ramos não são mutuamente exclusivos por construção — uma active com anchor ≤ archiveCutoff também satisfaz anchor ≤ staleCutoff (pois archiveCutoff < staleCutoff na linha do tempo de "instante de corte"). A desambiguação é puramente posicional: o primeiro if que casa retorna. Por isso a sequência archive → stale → reativar é parte do contrato, e não um detalhe de estilo.

05 · Rode nextState na mão (passo a passo → agora você)

Você viu o fluxograma. Agora execute a função na mão para um caso, devagar — depois um é seu. Recuperar o procedimento fixa mais que ver o retorno pronto.

Exemplo resolvido · nextState para uma skill stale ociosa há 45 dias (cortes 30/90)
1
Monte a âncora. A skill tem lastActivityAt > 0 (já foi usada), então anchor = lastActivityAt — não cai no caso +Infinity. Em termos de inatividade, ela está parada há 45 dias.
2
Ramo ① (archive). 45d < 90d ⇒ a âncora ainda é mais nova que o corte de archive ⇒ anchor ≤ archiveCutoff é falso. Pula.
3
Ramo ② (stale). A condição exige current === 'active', mas o estado atual é stalefalso. Pula. (Mesmo que 45d > 30d, o ramo só promove quem está active.)
4
Ramo ③ (reativar). Exige anchor > staleCutoff (atividade recente) e current === 'stale'. Aqui a âncora é mais antiga que o corte de stale (segue ociosa), então anchor > staleCutoff é falso. Pula.
5
Veredito. Nenhum ramo casou ⇒ return current ⇒ permanece stale. No runCurator isso vira um skip com a razão no-change (seção 06).
Agora você: uma skill stale que foi usada ontem (atividade muito recente). Qual ramo casa e qual o retorno? Faça antes de revelar.
Ramo ③. Atividade de ontem ⇒ anchor > staleCutoff é verdadeiro, e current === 'stale' também ⇒ retorna 'active' — a skill reativa. Ramos ① e ② falham (a âncora recente não é ≤ corte algum). Dica: o procedimento é sempre o mesmo — testar os três if em ordem; o primeiro que casa decide.

06 · Os dois portões: proveniência e pin

Antes de sequer chamar nextState, o runCurator aplica dois opt-outs ortogonais. Só skills autoradas pelo agente são geridas pelo curador (proveniência), e uma skill pinned é isenta em todo caminho. Os dois são portões de guarda: quem não passa é registrado como skip, com uma razão tipada — e nunca silenciosamente ignorado.

FLUXOGRAMA · o laço por skill: dois portões → nextState → persistência fail-closed
para cada skill (em ordem de nome) createdBy === 'agent'? NÃO ↓ SIM pinned? SIM ↓ to = nextState(record…) to === state? SIM → NÃO usage.put(...) ok? SIM → transitioned[] NÃO → return err skipped.push({ name, reason }) not-agent-created · pinned · no-change
// 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. É o mesmo princípio fail-closed do resto do harness.

O relatório, em duas listas

CuratorReport · toda skill cai em transitioned[] OU skipped[] (nenhuma some)
transitioned[] { name, from: 'active', to: 'stale' } { name, from: 'active', to: 'archived' } { name, from: 'stale', to: 'active' } mudanças que de fato persistiram skipped[] { name, reason: 'pinned' } { name, reason: 'not-agent-created' } { name, reason: 'no-change' } por que cada skill foi deixada como estava

07 · O sidecar de telemetria — leituras best-effort, escritas atômicas

Agora a outra metade do par: o UsageStore. Ele registra eventos incrementando um contador e carimbando lastActivityAt = clock(). O ponto fino é a assimetria leitura/escrita, herdada do memory store: um sidecar corrompido como vazio (nunca quebra um caminho quente), mas uma escrita que falha emerge como err. Telemetria quebrada nunca pode derrubar a chamada de skill do host.

FLUXOGRAMA · load() best-effort — quatro portas de saída, todas para o mapa vazio
load() stat ok? readText ok? JSON ok? schema ok? SIM SIM SIM NÃO (ausente / IO / JSON ruim / forma errada) return {} mapa vazio — nunca lança SIM ↓ return valid.data só aqui o sidecar é confiável
// 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;
}
Quatro portas, um destino. Ausente, ilegível, JSON malformado, forma errada — todos os quatro caminhos de erro colapsam para o mesmo {}. Só o quinto caso (tudo válido) devolve dados reais. É essa convergência que garante: nenhuma falha de leitura jamais propaga uma exceção para a chamada de skill.

O lado da escrita: record() incrementa e carimba

Quando uma skill é usada, vista ou corrigida, o record faz duas coisas simples sobre o registro: bate o contador do evento e carimba o horário com o clock() injetado. É esse carimbo que vira a âncora lida pelo curador depois.

UsageStore.record · um evento bate o contador e atualiza lastActivityAt = clock()
evento use · view · patch counts[tipo] += 1 incrementa o contador lastActivityAt = clock() carimbo = futura âncora save() atômico falha ⇒ err (fail-closed)

A assimetria, lado a lado

É a mesma assimetria do memory store, e vale tornar explícita: leitura e escrita tratam o erro de formas opostas — de propósito.

LEITURA (best-effort) vs ESCRITA (fail-closed) — erro tratado ao contrário, de propósito
LEITURA · best-effort erro (ausente / IO / JSON / forma) ⇒ {} e segue nunca quebra a chamada de skill ESCRITA · fail-closed erro (disco / permissão) ⇒ err e aborta não reporta trabalho não salvo

08 · Um clock, dois leitores — e a escrita byte-estável

O determinismo do subsistema depende de um detalhe de fiação: o mesmo Clock é injetado no UsageStore e no runCurator (curator.ts:53-59 diz isto explicitamente). Assim, "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.

UM CLOCK INJETADO · zero skew entre o carimbo e a decisão de obsolescência
Clock (injetado) nunca Date.now() UsageStore.record lastActivityAt = clock() runCurator cortes derivados de clock()

A escrita do sidecar fecha o ciclo de determinismo: ela é serializada com chaves ordenadas + indent de 2 espaços, espelhando o json.dump(..., sort_keys=True, indent=2) do Python. O resultado é byte-estável e amigável a diff — rodar o curador duas vezes sobre o mesmo estado produz exatamente o mesmo arquivo.

SAVE DETERMINÍSTICO · ordem de inserção vira ordem de chave (diff estável)
sem ordenar "zeta": {...}, "alfa": {...}, "mu": {...} ordem instável ⇒ diffs ruidosos sort_keys + indent 2 "alfa": {...}, "mu": {...}, "zeta": {...} byte-estável ⇒ idempotente

09 · A linha do tempo — arraste a inatividade

Junte tudo numa única alavanca. Arraste os dias ociosos de uma skill active de agente e veja a âncora cruzar os cortes: até 30d fica active, entre 30 e 90 vira stale, e a partir de 90 cai em archived (terminal). O ponto na régua é a âncora; a etiqueta mostra o estado que nextState retornaria.

0d 180d 30d · corte stale 90d · corte archive (terminal) A régua mapeia 180d em 820px · a âncora de uma skill nunca-ativa (lastActivityAt=0) seria +∞ → fora da régua, sempre active.

Leia ao contrário: esta régua mostra uma skill já usada. Para uma skill nunca-ativa, a âncora não está em "0 dias ociosos" — ela é +Infinity, que cairia infinitamente à esquerda da régua (o instante mais novo possível), e por isso permanece active. É o mesmo mecanismo da seção 03, agora visível na alavanca.

10 · Archive vs delete — o invariante "nunca deletar"

Toda a lição converge para um invariante: o curador nunca deleta. archived é o estado terminal — recuperável, auditável, presente no disco. Compare as duas filosofias lado a lado; a diferença é a coluna inteira da reversibilidade:

ARCHIVE (o que o curador faz) vs DELETE (o que ele nunca faz)
✓ ARCHIVE (max action) • reversível (reativa antes do corte) • conteúdo intacto no disco • histórico/proveniência preservados • estado terminal, não destruição o core portável nunca remove nada ✗ DELETE (fora do contrato) • irreversível • conteúdo perdido • histórico apagado • não há volta se foi engano o subsistema simplesmente não tem esse caminho
"Archived significa deletado." Não — archive é recuperável e é o estado terminal por invariante ("max action = archive"). O core portável nunca remove nada; a movimentação para o diretório .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.
"Um sidecar corrompido vai quebrar as chamadas de skill." Não — leituras são best-effort: um sidecar ausente, ilegível, com JSON malformado ou de forma errada todos colapsam para um mapa vazio (seção 07). O ponto inteiro é que um arquivo de telemetria quebrado nunca quebre a chamada de skill do host. Só uma falha explícita de escrita emerge como err.

O CLONE: de onde isto veio no Hermes

CLONE · skill_usage.py + curator.py (Python) → curator/ (TypeScript, Result)
Hermes (Python) Alembic (TS · Result) skill_usage.py apply_automatic_transitions json.dump(sort_keys=True) usage-store.ts nextState (branch order) save byte-estável

11 · Como isso se encaixa

Recue um passo e olhe a máquina inteira. O curador não é uma engrenagem solta: ele é a metade de disposição do loop fechado de aprendizado do Alembic. Cada turno de agente que dá certo pode autorar uma skill procedural; sem nada que retire o ocioso, o conjunto de skills só inflaria. O curador é o contrapeso — lê a telemetria fria do UsageStore e move o esquecido active → stale → archived, sem nunca destruir. É o que mantém a memória procedural enxuta e auditável ao longo de muitas sessões.

ONDE O CURADOR SE ENCAIXA · o loop de aprendizado: autorar (à esquerda) → dispor (esta lição, destacado) → snapshot enxuto da próxima sessão
turno de agente reviewAndLearn (Lição 08) SkillStore autora skill (Lição 12) UsageStore conta use/view/patch lê telemetria fria runCurator — esta lição dois portões → nextState → active → stale → archived nunca deleta · Clock injetado conjunto enxuto só o vivo em primeiro plano próxima sessão lê snapshot refrescado (loop, Lição 04) o loop recomeça com o conjunto curado

As peças que tocam esta — e por que conectam

Onde você está na metodologia. Na máquina inteira — contratos → adapters → council → swarm → harness → gates → loop de aprendizado — esta lição vive bem no fim, dentro do loop de aprendizado: depois que os gates aprovam o trabalho e o passe de aprendizado sedimenta o que deu certo, o curador é o zelador que poda o que envelheceu, para que a próxima sessão comece de um conjunto enxuto em vez de um depósito. Veja a peça acesa dentro do todo no mapa interativo: A metodologia (mapa interativo) →.

12 · Na prática

O curador é um subsistema interno do @alembic/hermes — ele ainda não tem um verbo alembic de primeira classe próprio. Mas você pode tocar com as mãos os mesmos invariantes que esta lição ensina por dois caminhos reais: inspecionar a memória procedural (skills são memória procedural — a categoria exata que o curador governa) pela CLI, e rodar a suíte que exercita o runCurator de ponta a ponta. Os dois compartilham a fiação do pacote: Clock injetado, append-only, nada deletado.

Inspecionar a memória procedural que o curador governa

alembic memory é o verbo real sobre os multi-stores do @alembic/hermes (episodic | semantic | procedural | decision | transcript). A skill é memória procedural; listar esse store mostra os registros append-only com o carimbo de tempo (vindo do mesmo tipo de Clock que o curador usa para a âncora). Repare: list nunca remove nada — é a face de leitura do mesmo invariante "nunca deletar".

# lista os registros do store procedural (newest-first), offline e hermético
$ alembic memory procedural list

# saída esperada (vazio numa máquina nova; o caminho é sempre impresso):
# memory procedural: 0 record(s) under ~/.alembic/memory/procedural.jsonl

# grave um registro de exemplo (carimbado pelo Clock injetado + id-factory):
$ alembic memory procedural add --record '{"content":"prefira early-return em guards"}'
# memory procedural: appended mem_… (agent cli)

# liste de novo — o registro aparece com id, agente e horário do Clock:
$ alembic memory procedural list
# memory procedural: 1 record(s) under ~/.alembic/memory/procedural.jsonl
#   mem_… [agent cli] at 2025-01-01T00:00:00.000Z

[uncertain] o caminho exato do --dir padrão depende da resolução de dataDir da sua máquina; o store usa <dataDir>/memory/<substore>.jsonl (passe --dir <path> para fixar). O timestamp acima é ilustrativo — ele vem do Clock injetado, não de Date.now().

Rodar o curador de ponta a ponta (a suíte real)

Para ver o runCurator e o UsageStore de verdade — as transições active → stale → archived, a âncora +Infinity, os dois portões, o fail-closed na escrita — rode a suíte do pacote. São os 21 casos de curator.test.ts que esta lição destilou; eles exercitam cada ramo de nextState com um Clock falso (tempo controlado, zero Date.now()).

# roda só os testes do pacote @alembic/hermes (curador + memory + clarify + …)
$ pnpm --filter @alembic/hermes test

# saída esperada (recorte):
# ✓ src/curator/curator.test.ts  (21 tests)
#   ✓ never archives a brand-new, never-active skill (lastActivityAt = 0)
#   ✓ jumps active → archived directly when old enough (skips stale)
#   ✓ reactivates a stale skill used again before the archive cutoff
#   ✓ skips a pinned skill / skips a non-agent-created skill
Experimente · do clone à transição observada
1
Entre no monorepo. cd /Users/acf/Documents/Projects/appfy/alembic e garanta o baseline verde uma vez: pnpm -r typecheck && pnpm -r build && pnpm -w test.
2
Liste a memória procedural. Rode alembic memory procedural list. Olhe a linha … record(s) under …: é o caminho do JSONL append-only. Em máquina nova vem 0 record(s) — e nada foi deletado para chegar a esse zero.
3
Adicione e re-liste. alembic memory procedural add --record '{"content":"…"}' e depois list de novo. Procure o id novo, o agent e o at — o carimbo veio do Clock, não do relógio do SO.
4
Rode o curador. pnpm --filter @alembic/hermes test. Na saída, ache o arquivo src/curator/curator.test.ts e os nomes de caso citados acima — cada um é uma transição (ou um portão) desta lição provada na fronteira real.
O que observar: nenhum caminho de delete em lugar nenhum. A memória só cresce (append) e o curador só move estado (até archived, o terminal). É o invariante central, agora visível na sua máquina.

Fixe os conceitos (flashcards)

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

Âncora
A que valor um registro nunca-ativo é ancorado?
clique pra virar ↻
Resposta
Number.POSITIVE_INFINITY. lastActivityAt=0 (criação não é atividade); +∞ > todo corte ⇒ a skill nova não fica stale/archived até ser usada e ficar ociosa.
Ordem
Por que archive é checado antes de stale?
clique pra virar ↻
Resposta
Para uma active ociosa além do corte de archive pular stale e ir direto a archived. O 1º if que casa vence; ramos não são exclusivos.
Portões
Quais dois opt-outs precedem nextState?
clique pra virar ↻
Resposta
Proveniência (createdBy === 'agent') e pinned. Quem não passa vira skip com razão tipada: not-agent-created / pinned / no-change.
Assimetria
O que acontece se o sidecar está corrompido?
clique pra virar ↻
Resposta
Na leitura: colapsa para {} (best-effort, nunca lança). Na escrita: a falha emerge como err (fail-closed). Telemetria nunca quebra a chamada de skill.

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. Uma skill tem createdBy: 'user' e não foi tocada em um ano. O que runCurator faz?
Correto: c. O portão de proveniência toca só skills autoradas pelo agente; skills user/bundled/hub são reportadas como skipped com not-agent-created, antes de qualquer cálculo de estado. a ignora o portão — a ociosidade nem chega a ser avaliada. b viola o invariante central: nada é jamais deletado, archive é o terminal. d também pressupõe que nextState rodou, mas o portão corta antes — e mesmo se rodasse, um ano levaria a archived, não stale.
2. Uma skill de agente recém-criada tem lastActivityAt = 0. No primeiro passo do curador ela é…
Correto: b. Criação não é atividade, então 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. a descreve exatamente o bug que a âncora evita — usar 0 literalmente arquivaria a skill recém-nascida. c falha pela mesma razão: nenhum ramo de obsolescência dispara com âncora +∞. d reincide no invariante quebrado: o subsistema não tem caminho de delete.
3. Por que a ordem dos ramos em nextState (archive → stale → reativar) é estrutural?
Correto: d. Se stale fosse checado primeiro, uma skill 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. a é falso porque os ramos não são exclusivos: uma âncora ≤ archiveCutoff também é ≤ staleCutoff, logo a posição decide. b inverte tudo — reativar primeiro nunca impediria o archive, mas confundiria os casos ociosos. c trata como otimização o que é, na verdade, uma diferença de resultado.
4. Um put falha no meio de runCurator. O que acontece com o relatório?
Correto: a. A persistência é por-transição e fail-closed: if (!persisted.ok) return persisted; — o passo aborta com o err antes de empurrar para transitioned[], então o relatório nunca afirma uma mudança que não aconteceu duravelmente. b é o anti-padrão exato que o código evita: reportar trabalho não salvo. c viola o invariante "nunca deletar". d descreveria um best-effort tolerante, mas o subsistema escolhe parar no primeiro erro de escrita para manter o relatório fiel ao disco.
💬 Travou em algo? Eu sou seu professor neste curso — pergunte. "Por que stale existe se o destino é archived?", "O que muda se eu não injetar o mesmo Clock nos dois?", "Como o .archive/ se relaciona com o estado archived?". É só dizer.