Curso / Lição 10
Lição 10 · Mergulho profundo · subsistema 4 de 7

ClarifyGateway — o gate humano T4

Quando o agente precisa de uma decisão humana antes de prosseguir, ele levanta uma pausa estruturada: uma pergunta (múltipla escolha ou aberta), e bloqueia na resposta. Em termos Alembic, esta é a superfície do gate humano T4 (ADR-0005). O Python bloqueia uma thread num threading.Event; o Node não tem thread bloqueante, então o equivalente fiel é uma promise + um registro de resolvers + um timeout. Um CLONE do clarify_tool.py + clarify_gateway.py do Hermes.

Leia primeiro (fonte de verdade)
packages/hermes/src/clarify/ — gateway.ts · types.ts · clarify.test.ts

Esta lição destila o código real do CLONE, lido linha a linha (citações no rodapé). Por que importa pra missão: é a única costura por onde o agente pausa e devolve o controle ao humano — o ponto onde um gasto irreversível ou um ship esperam por um "vai" antes de acontecer.

Objetivos desta lição
  • Explicar por que "bloquear" no Node é uma Promise + registro de resolvers, não uma thread.
  • Seguir o caminho de uma pergunta: ask → pendente → resolve (válido) ou timeout.
  • Distinguir o que o schema Zod valida (forma) do que validateResponse valida (cruzado).
  • Justificar a regra "deixe pendente" e o monotonicIdFactory determinístico.
0
MAX_CHOICES · teto de opções
0 ms
timeout padrão (10 min)
0
casos de teste verdes
T4
o tier do gate humano (ADR-0005)

01 · O problema: como "bloquear" sem uma thread

Imagine um caixa de banco que precisa da assinatura do gerente antes de liberar um saque grande. No mundo do Python, o caixa simplesmente congela na janela — a thread dele dorme num threading.Event — até o gerente apertar o botão. Limpo, mas só funciona porque existe uma thread para congelar.

O Node é single-threaded: não há thread para congelar — se você "dormir", o programa inteiro para. Então o equivalente fiel troca o congelamento por uma promessa de papel: o caixa entrega ao chamador um comprovante (Promise) que diz "te aviso quando o gerente decidir", guarda o pedido numa gaveta indexada (Map<id, pending>), e segue atendendo. Quando a decisão chega — por id — a promessa é liquidada. E um despertador (setTimeout) garante que, se ninguém decidir, o comprovante não fica pendurado para sempre.

O MESMO BLOQUEIO, DOIS RUNTIMES · Python congela a thread · Node devolve uma promise
Python (fonte) · threading.Event thread do agente CONGELA no Event callback do botão → Event.set() thread acorda c/ resposta Node (CLONE) · Promise + registry ask() retorna Promise · registra entry no Map<id, pending> · arma timer resolve(id, resp) → settle(ok) a Promise resolve c/ resposta
A tradução, em uma frase: o threading.Event do Python vira o callback resolve da promise guardada; o dicionário module-level _entries vira this.entries; e a deadline de polling vira o timer do setTimeout. Mesmo comportamento, mecânica nativa do runtime.

02 · O mecanismo: ask registra, resolve liquida

O coração do gateway são dois métodos e um descarte automático. ask valida a pergunta, cunha um id, arma o despertador e devolve a promise. resolve recebe uma resposta por id e — se ela for válida — liquida a promise. O timeout é o terceiro caminho: se ninguém responder a tempo, ele descarta a entry e resolve com err. Veja os três caminhos de uma só pergunta:

ask REGISTRA uma promise pendente · resolve a LIQUIDA · timeout a DESCARTA
ask(question) validar · cunhar id · armar timer entries: Map<id, pending> { id, question, settle, timer } registrar resolve(id, resp) callback da plataforma validar vs pergunta → settle(ok) timeout dispara deletar entry · resolve(err)

Em código — o ask condensado, com os dois detalhes que o tornam à prova de vazamento:

// packages/hermes/src/clarify/gateway.ts:82-110 — ask (condensado)
const parsed = clarifyQuestionSchema.safeParse(question);
if (!parsed.success) return err(new Error(`Invalid clarify question: …`)); // falha fechado SINCRONAMENTE
const id = this.mintId();
return new Promise((resolvePromise) => {
  const timer = setTimeout(() => {
    this.entries.delete(id);                       // descarta a entry…
    resolvePromise(err(new Error('clarify timed out')));  // …nunca trava
  }, timeoutMs);
  timer.unref?.();                                  // deixa o processo sair enquanto pendente
  const settle = (result) => { clearTimeout(timer); this.entries.delete(id); resolvePromise(result); };
  this.entries.set(id, { id, question: valid, settle, timer });
});
Dois detalhes que importam. (1) Uma pergunta inválida falha fechado sincronamente — antes de qualquer entry ser registrada, então uma pergunta malformada nunca deixa um pedido pendente pendurado (teste: "returns err for a >MAX_CHOICES question and registers nothing"). (2) timer.unref() deixa o processo Node sair mesmo enquanto um clarify está pendente — um prompt humano travado não mantém o runtime vivo para sempre.

A ordem importa: valida ANTES de registrar

Repare na sequência dentro de ask — ela é deliberada. A validação vem primeiro, antes do id ser cunhado e da entry entrar no Map. Por quê? Porque se a pergunta é lixo, queremos rejeitá-la sem deixar rastro — nenhuma entry pendente, nenhum timer armado, nada para limpar depois:

SEQUÊNCIA DO ask · validar → (falha aqui sai limpo) → cunhar id → armar timer → registrar
safeParse(question) valida no boundary !success return err · registra NADA ok id = mintId() setTimeout(timer) entries.set(id, …) agora está pendente Promise A falha desvia para cima e sai antes de cunhar id/armar timer — por isso "registra nada". A validação tardia deixaria sujeira para limpar.

O ciclo de vida de uma entry no Map

Cada pergunta viva é uma entry no entries: Map<id, pending>. O método pending() é só a janela para essa gaveta — [...this.entries.keys()], na ordem de inserção. Veja os estados pelos quais uma entry passa:

CICLO DE VIDA DA ENTRY · ausente → pendente → (settle válido | timeout) → ausente
AUSENTE não está no Map ask() · set(id) PENDENTE pending() lista o id resolve inválido → err (fica pendente) resolve válido → settle timeout → delete AUSENTE de novo delete(id) · resolve once Só duas portas saem de PENDENTE: a resposta válida ou o timeout. A resposta inválida volta para si mesma — a entry sobrevive.

03 · Linha do tempo: quem ganha, settle ou timeout?

Os dois finais de uma pergunta — resposta válida ou timeout — são uma corrida: o que chegar primeiro liquida a promise; o outro caminho some (o clearTimeout cancela o despertador, ou o delete remove a entry). Arraste para escolher quando a resposta humana chega e veja o veredito mudar:

0 14 min deadline · timeout 10 min resposta · 3 min Antes da linha = settle vence (clearTimeout cancela o despertador). Depois = timeout vence (entry já deletada → resolve(err)).

Por que "nunca trava" é uma garantia, não uma esperança
O caixa do banco do começo da lição pode esperar o gerente para sempre — humanos somem. O setTimeout é o seguro: passada a deadline, ele descarta a entry e resolve a promise com err('clarify timed out'). O chamador sempre recebe uma resposta — um Result de sucesso ou um de erro — nunca um silêncio eterno. É a diferença entre "geralmente funciona" e "fail-closed".

04 · O contrato de dados: choice ou open, limitado a 4

Antes de bloquear em qualquer coisa, o gateway precisa saber o que está sendo perguntado. Uma pergunta é uma união discriminada em kind — ou ela traz uma lista de opções (choice), ou é texto livre (open). Pense numa enquete: ou você marca um quadradinho de uma lista, ou você escreve sua resposta no campo aberto. Nunca os dois.

A UNIÃO DISCRIMINADA · um kind decide a forma da pergunta E da resposta
kind discriminador 'choice' pergunta: prompt + choices[] .min(1) · .max(MAX_CHOICES = 4) resposta: { kind:'choice', index } index ≥ 0 (faixa checada no gateway) 'open' pergunta: prompt (texto livre) sem choices resposta: { kind:'open', text } text não-vazio (.min(1))
// packages/hermes/src/clarify/types.ts:48-61
export const clarifyQuestionSchema = z.discriminatedUnion('kind', [
  z.object({
    kind: z.literal('choice'),
    prompt: z.string().min(1, 'prompt cannot be empty'),
    choices: z.array(z.string().min(1, …))
      .min(1, 'choice question needs at least one choice')
      .max(MAX_CHOICES, `choice question allows at most ${MAX_CHOICES} choices`),
  }),
  z.object({ kind: z.literal('open'), prompt: z.string().min(1, …) }),
]);

O helper tolerante: coerceChoices

Um helper sutil de robustez vem junto: coerceChoices, um clone do _flatten_choice da fonte. LLMs às vezes emitem choices em forma de dict ([{description:'…'}]) em vez de strings puras; isto as desembrulha por chaves canônicas de label, em ordem de prioridade. Veja a versão Simples e a Técnica:

A analogia: imagine um formulário onde alguns campos vieram preenchidos como etiqueta limpa ("Reembolsar") e outros como uma caixinha embrulhada ({description:"Reembolsar"}). coerceChoices abre cada caixinha e tira a etiqueta de dentro — procurando, nesta ordem, por label, description, text, title. Se a caixa não tem nenhuma dessas, ela é jogada fora (vira '' e some), porque uma etiqueta-lixo é pior que opção nenhuma.
// packages/hermes/src/clarify/types.ts:104-121 — flattenChoice
const CHOICE_LABEL_KEYS = ['label', 'description', 'text', 'title'] as const; // name/value EXCLUÍDAS
// string ⇒ trimada; dict ⇒ primeira chave canônica não-vazia; senão ⇒ '' (descartada)
const flattenChoice = (raw) => {
  if (raw == null) return '';
  if (typeof raw === 'string') return raw.trim();
  for (const key of CHOICE_LABEL_KEYS) { … }   // primeira não-vazia ganha
  return '';
};

name/value são deliberadamente excluídas — carregam valores de enum/identificadores brutos, não labels humanos, e um label-lixo é pior que nenhuma choice (o dict colapsa para '' e é descartado). Note que coerceChoices não limita a 4 — limitar é o trabalho do schema, então uma lista longa demais falha fechado na validação em vez de ser silenciosamente truncada.

coerceChoices · o pipeline de limpeza (entrada bagunçada do LLM → string[] limpa)
entrada crua (LLM) ' Sim ' {description:'Não'} {name:'x'} null {title:'Talvez'} .map flattenChoice string ⇒ .trim() dict ⇒ 1ª chave canônica (label/description/text/title) senão ⇒ '' .filter descarta length 0 '' (name) ✗ '' (null) ✗ string[] 'Sim' 'Não' 'Talvez' 3 limpas saem · note: 3 ≤ 4, então passa no schema. Se a entrada limpa tivesse 5, o cap do schema rejeitaria — coerceChoices não corta.
Preveja antes de continuar
Uma pergunta clarify chega do LLM com 5 choices. O coerceChoices roda primeiro. Quantas choices restam, e a pergunta é aceita?
Restam 5 — e a pergunta é REJEITADA. coerceChoices só limpa (desembrulha dicts, descarta vazias); ele não corta para 4. Então as 5 choices limpas chegam intactas ao clarifyQuestionSchema, cujo .max(MAX_CHOICES) falha — e ask retorna err sincronamente, registrando nada. Se você chutou "ele trima para 4", caiu na armadilha clássica: truncar silenciosamente entrada não confiável esconde o bug em vez de falhar fechado. Limitar é trabalho do schema; limpar é trabalho do helper.
O CAP MAX_CHOICES = 4 · 1..4 passam (verde) · 0 ou ≥5 falham fechado (vermelho)
faixa válida · .min(1) … .max(4) 0 ✗ 1 ✓ 2 ✓ 3 ✓ 4 ✓ 5 ✗ 6 ✗ 0 viola .min(1) ("needs at least one choice"); 5+ viola .max(MAX_CHOICES). A 5ª opção "Other" da UI é apresentação — não conta no dado.

05 · Validação cruzada: a resposta tem que servir à pergunta

Aqui está uma distinção que separa código robusto de código ingênuo. O Zod consegue checar a forma de uma resposta isolada — "é um objeto com kind:'choice' e um index inteiro ≥ 0?". Mas ele não consegue saber se essa resposta serve à pergunta viva: a pergunta era de choice ou open? O índice cabe na lista real de opções? Essas são invariantes cruzadas — entre resposta e pergunta — e quem as impõe é o validateResponse:

DUAS CAMADAS DE VALIDAÇÃO · o schema checa a FORMA · validateResponse checa o ENCAIXE
Camada 1 · schema Zod (forma) clarifyResponseSchema.safeParse "é { kind:'choice', index:int≥0 }?" vê só a resposta — não a pergunta forma ok Camada 2 · validateResponse (encaixe) resp.kind === question.kind ? index < question.choices.length ? compara resposta CONTRA a pergunta viva só então: settle(ok) O schema sozinho deixaria passar um índice 9 numa lista de 2 opções — a forma é válida, o encaixe não.
// packages/hermes/src/clarify/gateway.ts:143-168 — validateResponse
if (value.kind !== question.kind)
  return err(new Error(`Response kind '${value.kind}' does not match question kind '${question.kind}'.`));
if (value.kind === 'choice' && question.kind === 'choice') {
  if (value.index >= question.choices.length)
    return err(new Error(`Choice index ${value.index} out of range …`));
}
Resposta recebidaPergunta vivaCamada que rejeitaResultado
{ kind:'choice', index:-1 }choice, 3 opçõesCamada 1 (Zod .min(0))err — forma inválida
{ kind:'open', text:'sim' }choice, 3 opçõesCamada 2 (kind mismatch)err — kind não bate
{ kind:'choice', index:9 }choice, 2 opçõesCamada 2 (índice fora da faixa)err — out of range
{ kind:'choice', index:1 }choice, 3 opçõespassa as duas✓ settle(ok)

06 · A regra "deixe pendente" — um modo de falha pensado

O que acontece quando uma resposta chega inválida? A escolha ingênua seria cancelar a pergunta — mas o gateway faz algo mais cuidadoso. Quando resolve recebe uma resposta inválida (kind errado, índice fora da faixa), ele retorna err mas deixa a entry pendente — para que uma resposta corrigida ainda possa chegar e liquidar a mesma promise. Só uma resposta válida liquida (e remove) a entry. Siga o fluxograma — cada losango é uma decisão:

FLUXOGRAMA · resolve(id, resp) — o caminho de cada resposta até settle, err, ou re-prompt
resolve(id, response) entry = entries.get(id) id existe no Map? (entry !== undefined) NÃO ✗ err "Unknown or already-resolved" SIM validateResponse é válida (encaixa)? NÃO → err, MAS entry FICA pendente (re-prompt) SIM ✓ entry.settle(ok) · ok() clearTimeout · delete(id) · promise resolve a regra "deixe pendente": resposta errada → err, mas a pergunta segue viva p/ correção. Só a válida (ou o timeout) remove.
O teste que prova a regra

Um resolve com kind incompatível falha mas pending() ainda lista o id; um resolve correto subsequente então o liquida. Em contraste, um id desconhecido ou já-liquidado é um err simples ("Unknown or already-resolved"), e um double-resolve falha porque o primeiro já removeu a entry. Casos verificados: kind-mismatch-leaves-pending (124–135), out-of-range (137–144), double-resolve (161–169).

O Map DEPOIS DE CADA resolve · inválido mantém a entry · válido a remove · 2º resolve não acha
após resolve INVÁLIDO entries: clarify-1 → pendente retornou err, mas FICOU resolve válido após resolve VÁLIDO entries: ∅ (vazio) settle → clearTimeout + delete resolve 2º resolve no mesmo id entries.get(id) === undefined → err "Unknown or…" o 1º já removeu a entry Idempotência por construção: a entry só liquida UMA vez. O segundo resolve não tem o que liquidar — falha limpa, sem double-settle.

07 · Determinismo: ids monotônicos, sem Math.random()

Cada pergunta precisa de um id único para ser encontrada na gaveta. A escolha óbvia seria um id aleatório — mas o motor Alembic proíbe Math.random() e Date.now() em código de plano (a plan VM os rejeita), e ids aleatórios quebrariam o replay (rodar duas vezes daria ids diferentes, e o replay não casaria). A solução é a mesma disciplina do Clock injetado do curador: trocar um global não-determinístico por uma costura injetável, com padrão de contador monotônico:

// packages/hermes/src/clarify/gateway.ts:176-182
export const monotonicIdFactory = (prefix = 'clarify'): (() => ClarifyId) => {
  let n = 0;
  return () => { n += 1; return `${prefix}-${n}`; };
};
ALEATÓRIO vs MONOTÔNICO · por que a costura injetável vence (mesma run, duas vezes)
Math.random() — quebra replay run 1: id-9f3a · id-71be (imprevisível) run 2: id-c08d · id-44ef (diferente!) ✗ ids divergem → replay não casa monotonicIdFactory — reproduzível run 1: clarify-1 · clarify-2 contador n += 1 run 2: clarify-1 · clarify-2 (idêntico!) ✓ ids iguais → replay casa · testes nomeiam q-1

O timeout padrão é DEFAULT_CLARIFY_TIMEOUT_MS = 600_000 (10 minutos), espelhando os 600s da fonte. Os testes o dirigem com fake timers do vitest, avançando além do prazo para provar que a entry é descartada e a promise resolve para err — sem leak, sem trava.

08 · O gate T4 no motor: onde a pausa se encaixa

Por que esse subsistema existe? Porque o Alembic roda autônomo — mas há ações que ele não deve tomar sozinho: um ship, um gasto irreversível. O ADR-0005 fixa que "o gate humano fica no ship / gasto irreversível". O ClarifyGateway é a superfície de dados desse gate — o ponto exato onde o agente para, levanta a pergunta e espera o humano. Veja onde ele se senta entre os tiers:

O GATE T4 (ADR-0005) · tarefas autônomas vs a pausa que devolve o controle ao humano
T1 · T2 · T3 — autônomo agente decide e executa sozinho ação ship / gasto irreversível? NÃO ✓ prossegue SIM GATE T4 · ClarifyGateway.ask() levanta a pergunta · BLOQUEIA na promise (default: T4-park) humano: resolve(id, resp) timeout → err fail-closed (T4-park) Sem humano e sem timeout, nada irreversível escapa: a pausa é a única porta.

E o gate nunca lança através da fronteira: cada caminho falível devolve um Result<T, Error> do @alembic/contractsok(valor) ou err(erro). Quem chama trata o erro como um valor, não como uma exceção que pode escapar e derrubar o loop. É a disciplina fail-closed do projeto inteiro, aplicada aqui:

FAIL-CLOSED · todo caminho do gateway vira um Result (nunca um throw que escapa)
ask() / resolve() qualquer caminho falível ok(value) err(Error) chamador trata os dois if (!r.ok) … else r.value ✗ throw que escapa Erro é dado, não exceção: o loop autônomo nunca é derrubado por uma resposta malformada.

Confusões comuns

"Ele usa threads reais para bloquear." Não — o Node é single-threaded. O "bloqueio" é uma Promise retornada que quem chamou aguarda; um registro de resolvers (Map<id, pending>) deixa um callback de plataforma liquidá-la por id, e um setTimeout garante que nunca trave. É o equivalente fiel do threading.Event do Python num runtime async.
"Uma resposta inválida cancela a pergunta." Não — uma resposta inválida é rejeitada com err enquanto a pergunta fica pendente para uma resposta corrigida. Só uma resposta válida (ou o timeout) remove a entry.

09 · Trace a chamada, passo a passo (worked → agora você)

Você já viu cada peça isolada. Agora trace uma chamada inteira, devagar — depois um caso é seu. Recuperar a sequência (não só ver o resultado) é o que fixa.

Exemplo resolvido · ask de uma pergunta choice, resposta válida no índice 1
1
Chame ask. ask({ kind:'choice', prompt:'Fazer deploy?', choices:['Sim','Não'] }). O schema valida (2 choices ≤ 4 ✓), mintId() devolve clarify-1, o timer é armado em 600 000 ms, a entry entra no Map e a Promise é retornada (ainda pendente).
2
Confira o estado. pending() agora retorna ['clarify-1'] — a pergunta está viva, esperando.
3
Chegue uma resposta inválida primeiro. resolve('clarify-1', { kind:'open', text:'talvez' }). validateResponse vê kind 'open''choice' → retorna err. Mas a entry fica. pending() ainda lista ['clarify-1'].
4
Chegue a resposta correta. resolve('clarify-1', { kind:'choice', index:1 }). Kind bate, 1 < 2 (faixa ok) → entry.settle(ok({kind:'choice',index:1})): clearTimeout, delete('clarify-1'), a promise resolve.
5
Veredito. O await ask(...) retorna ok({ kind:'choice', index:1 }); pending() agora é []. Um terceiro resolve no mesmo id daria err("Unknown or already-resolved") — a entry sumiu.
Agora você: você chama ask de uma pergunta open e ninguém responde. Os fake timers do vitest avançam 601 000 ms. O que o await ask(...) retorna, e o que pending() retorna depois? Responda antes de revelar.
O timer (armado em 600 000 ms) dispara antes dos 601 000: ele faz entries.delete(id) e resolvePromise(err(new Error('clarify timed out'))). Então await ask(...) retorna err('clarify timed out') e pending() retorna [] — a entry foi descartada, sem leak e sem trava. Dica: é o caso de teste "resolves to err and drops the entry once timeoutMs elapses" (177–190).

10 · Como isso se encaixa

Você dissecou o gateway por dentro. Agora dê um passo atrás: onde ele liga no resto da máquina? O ClarifyGateway é a superfície de dados de uma das portas de saída do pipeline — o gate humano T4. Ele não fica solto: liga na ponta onde o swarm está executando uma unidade e bate numa ação que o motor não deve tomar sozinho (um ship, um gasto irreversível). Nesse ponto a unidade é estacionada (T4-park) em vez de executar; o humano então decide; e só a decisão dele destrava o fluxo. Em comandos reais, esse ciclo é proposeapprove / reject.

O GATE T4 NA TUBULAÇÃO · swarm executa → ação irreversível → ESTE gate (park + humano) → o fluxo retoma · clique para percorrer
swarm executa a unidade sob a pipeline de gates (Lição 19 · o swarm) ação irreversível ESTE gate T4 (Lição 10) unidade ESTACIONADA · t4-parked.jsonl ClarifyGateway.ask() · BLOQUEIA humano: approve / reject só a decisão humana destrava approve (o "vai") o fluxo retoma propose reabre a unidade aprovada o ciclo fecha: a unidade aprovada volta ao swarm como uma run proposta e executa Upstream = o swarm sob os gates (Lições 17, 19). Esta peça (Lição 10) PARA e devolve o controle. Downstream = a retomada. Sem a decisão humana (ou o timeout), nada irreversível escapa — a pausa é a única porta de saída.

Onde você está na metodologia: as Lições 01–03 montaram o motor, a engenharia reversa do Hermes e a matriz de fusão; as Lições 14–19 detalham a cintura estreita, o funil, os gates, o Council e o swarm que executam uma run. Esta Lição 10 é a peça que, dentro dessa execução, para o robô e chama o humano: a superfície T4 onde um gasto irreversível ou um ship esperam por um "vai". Para ver as 30 peças no mesmo mapa e como cada uma liga na seguinte, abra o mapa interativo da metodologia.

As peças que este gate conecta — cada link abre o mergulho na peça vizinha, e a frase diz por que elas se tocam:
  • Lição 17 · A pipeline de gateso T4 é o último e mais conservador dos gates dessa pipeline (Scope → Council → Proof → Validator → T4); enquanto Proof e Validator barram código ruim, o T4 barra o que nem código consegue decidir — uma ação irreversível.
  • Lição 19 · O swarmé o swarm que executa as unidades e, ao bater numa unidade T4, a estaciona em t4-parked.jsonl em vez de executá-la; este gateway é a costura por onde essa pausa devolve o controle.
  • Lição 26 · Proveniência & segurançacada approve/reject é gravado append-only (approvals.jsonl / rejections.jsonl) com timestamp — a decisão humana fica auditável, parte da mesma disciplina de proveniência.
  • Lição 04 · O loop fechadoo mesmo padrão "conservador por padrão" e "propor → dispor": lá o Validador dispõe escritas de memória, aqui o humano dispõe ações irreversíveis. Ambos preferem parar a vazar.

11 · Na prática

Chega de diagrama — eis o gate T4 como comandos reais. Quando uma run estaciona unidades T4, elas ficam no ledger t4-parked.jsonl do dir da run, fora do caminho de execução. Três comandos operam sobre elas: propose reabre as unidades estacionadas como uma run de proposta; approve registra o "vai" de uma unidade; reject registra o "não". As duas decisões são gravações append-only e auditáveis.

# 1) Reabre TODAS as unidades T4 estacionadas de uma run como uma run de proposta.
#    --offline mantém tudo hermético e $0 (adapter offline determinístico).
alembic propose <run-id> --offline

# saída (uma linha de cabeçalho + uma de contagem):
#   proposal <novo-run-id> — Proposed execution of parked tasks from <run-id>
#     done: 1 | failed: 0 | parked: 0

Fonte da forma exata: apps/cli/src/commands.ts (runPropose) — lê t4-parked.jsonl, re-injeta cada unidade como proposed-<taskId> e imprime proposal <id> — <goal> seguido de done: N | failed: N | parked: N. Se não houver unidades estacionadas, falha fechado com run <id> has no parked T4 tasks.

Para decidir uma unidade individual sem reexecutar tudo, use approve ou reject apontando a unidade pelo id. Atenção à flag: o id da unidade vai em --task-id (não --task) — é o único nome que o parser de argumentos aceita.

# 2a) Aprova UMA unidade estacionada pelo id (registra o "vai", append-only).
alembic approve <run-id> --task-id <unit-id>
# saída:
#   approved task '<unit-id>' in run <run-id>
# grava uma linha em <run-dir>/approvals.jsonl :
#   {"taskId":"<unit-id>","decision":"approved","at":1719500400000}

# 2b) Rejeita UMA unidade estacionada pelo id (registra o "não", append-only).
alembic reject <run-id> --task-id <unit-id>
# saída:
#   rejected task '<unit-id>' in run <run-id>
# grava uma linha em <run-dir>/rejections.jsonl

Fonte: apps/cli/src/commands.ts (runApprove / runReject) e apps/cli/src/args.ts — o id da unidade é parseado da opção --task-id (campo taskId no schema; 'task-id' em PARSE_OPTIONS). A unidade precisa existir em t4-parked.jsonl, senão o comando falha fechado com task '<id>' is not parked in run <run-id>. Nota: o texto em prosa da CLAUDE.md escreve --task, mas a flag real aceita pelo CLI é --task-id — confira sempre o args.ts.

EXPERIMENTE · estacione, inspecione e decida uma unidade T4 (offline, $0)
  1. Clone e entre no repo, depois garanta o build verde:
    git clone <repo> alembic && cd alembic · pnpm -r typecheck && pnpm -r build && pnpm -w test
  2. Rode uma run que produza unidades T4 e liste as runs para achar o id:
    alembic run --goal GOAL.md --plan alembic.plan.ts --offline --yes · alembic runs list
  3. Inspecione o ledger: abra .alembic/runs/<run-id>/t4-parked.jsonl — cada linha tem o taskId da unidade estacionada e a reason.
  4. Decida uma unidade pelo id (use um taskId do ledger):
    alembic approve <run-id> --task-id <taskId> (ou reject).
  5. O que procurar na saída: a linha approved task '<taskId>' in run <run-id>; depois confira que .alembic/runs/<run-id>/approvals.jsonl ganhou uma linha com "decision":"approved".
  6. Prove o fail-closed: tente approve com um id que não está estacionado — o comando retorna task '<id>' is not parked in run <run-id> e não grava nada.
Faça na mão · qual comando para cada intenção?
1
"Quero reabrir todas as unidades T4 estacionadas de uma run."alembic propose <run-id> (com --offline para rodar hermético).
2
"Quero aprovar só UMA unidade pelo id."alembic approve <run-id> --task-id <unit-id>. (O id da unidade vai em --task-id, não --task.)
3
"Quero registrar que uma unidade NÃO deve prosseguir."alembic reject <run-id> --task-id <unit-id> (grava em rejections.jsonl).
Agora você: você rodou alembic approve run-42 --task <unit-id> e o CLI recusou os argumentos antes de qualquer gravação. Bug do CLI ou erro seu? Decida antes de revelar.
Erro seu — a flag é --task-id, não --task. O parser (parseArgs com strict:true) só conhece as opções de PARSE_OPTIONS, e lá consta 'task-id' — não há 'task'. Um token desconhecido faz a tokenização falhar e o comando retorna invalid arguments: … sem tocar em approvals.jsonl. Por que isso importa: é a mesma postura fail-closed do gateway — entrada malformada é rejeitada na fronteira, antes de qualquer efeito durável. Troque para --task-id e a aprovação grava.

Fixe os conceitos (flashcards)

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

Bloqueio
Como o Node "bloqueia" sem thread?
clique pra virar ↻
Resposta
ask retorna uma Promise; um registro Map<id, pending> deixa resolve liquidá-la por id; um setTimeout garante que nunca trave. Equivalente fiel do threading.Event.
Cap
Quem rejeita 5 choices, e por quê?
clique pra virar ↻
Resposta
O schema (.max(MAX_CHOICES = 4)), sincronamente em ask, registrando nada. coerceChoices só limpa, não corta — limitar é trabalho do schema.
Deixe pendente
O que faz uma resposta inválida?
clique pra virar ↻
Resposta
Retorna err mas deixa a entry pendente — uma resposta corrigida ainda liquida a mesma promise. Só a válida (ou o timeout) remove.
Determinismo
Por que monotonicIdFactory e não id aleatório?
clique pra virar ↻
Resposta
A plan VM proíbe Math.random()/Date.now(); ids aleatórios quebram replay. Contador injetável (clarify-1, clarify-2) torna runs reproduzíveis.

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. resolve é chamado com uma resposta de texto aberto para uma pergunta de choice. O que acontece?
Correto: c. validateResponse rejeita um kind incompatível com err, mas só uma resposta válida liquida a entry — uma inválida a deixa pendente para um re-prompt; pending() ainda lista o id depois. a erra porque o gateway nunca lança através da fronteira — toda falha vira Result (err), não exceção. b inventa uma coação que não existe: kind errado não vira índice 0; isso mascararia o erro. d contradiz a validação cruzada — um open jamais liquida um choice.
2. Uma pergunta clarify chega com 5 choices. Quando é rejeitada?
Correto: b. Limitar é trabalho do schema, não do coerceChoices. ask valida a pergunta primeiro e retorna err sincronamente, registrando nada — falhando fechado em vez de truncar silenciosamente entrada não confiável. a é a armadilha: coerceChoices só limpa (desembrulha dicts, descarta vazias), não corta. c erra a ordem — a rejeição é na pergunta, antes de existir qualquer resposta. d confunde dois caminhos: o timeout descarta entries pendentes; uma pergunta inválida nunca chega a ser registrada.
3. Por que o ClarifyGateway cunha ids via um monotonicIdFactory injetado em vez de um id aleatório?
Correto: d. Mesma disciplina do Clock injetado do curador: substituir um global não-determinístico por uma costura injetada. a e b são micro-otimizações irrelevantes — velocidade e tamanho de id não são o ponto; o ponto é reprodutibilidade. c é um efeito colateral bom (testes injetam monotonicIdFactory('q') e afirmam sobre q-1, q-2), mas não a razão: sem determinismo, o replay do motor quebraria.
4. Ninguém responde uma pergunta pendente e a deadline passa. O que o chamador de ask recebe?
Correto: a. No timeout, o timer faz entries.delete(id) e resolvePromise(err(new Error('clarify timed out'))) — sem leak, sem trava (teste 177–190). b é exatamente o que timer.unref() + o timeout existem para evitar: a promise sempre resolve, e o processo pode até sair enquanto pendente. c erra a forma de falha — o gateway nunca lança através da fronteira, devolve Result. d inventa um fallback inexistente: respostas inválidas são rejeitadas e nunca viram o valor de retorno.
💬 Travou em algo? Eu sou seu professor neste curso — pergunte. "Por que a entry inválida fica pendente em vez de ser cancelada?", "Como o transporte (não modelado) entrega a 5ª opção 'Other'?", "O que mais, além de clarify, é um gate T4?". É só dizer.