😶 O problema: "parece certo sem estar"
Na trilha 1 você viu o hábito mais caro da IA quando ensina: entregar código com cara de pronto que nunca foi executado. O arquivo do agente já proíbe isso ("dizer 'funciona' sem uma 'Saída real'"). Mas proibição em texto depende de o modelo lembrar e obedecer. O portão de execução é a versão mecânica da mesma regra.
🆕 Novo aqui? Portão
Chamamos de portão um hook que pode dizer "ainda não" ao fim do turno. Tecnicamente, ele imprime {"decision": "block", "reason": "…"} no evento de parada. O Claude Code então não encerra: devolve o reason ao modelo como instrução, e ele continua trabalhando.
Como ler: o caminho vermelho de cima é o hábito: escreveu, declarou pronto, acabou. O caminho de baixo é o mesmo agente com o portão: a tentativa de parar volta com o recado, o código roda, e só então o turno termina. O portão não julga se o código está certo; ele exige que alguém o tenha executado, para que a Saída real exista.
Mecanismo
Não depende de obediência
Sem IA
Python stdlib, zero tokens
Exige execução
Não julga o resultado
Nunca derruba
Erro interno = exit 0
🧠 Estado por sessão: marca, limpa, bloqueia
Um script só, três papéis, conforme o evento que chega. Entre um evento e outro ele guarda uma lista de arquivos "sujos" (editados e ainda não executados) num arquivo de estado por sessão: .mentor/estado/<session_id>.json, fora do git. Estados com mais de 7 dias são apagados.
Como ler: as duas caixas da esquerda escrevem no estado (uma acrescenta, a outra remove); a da direita só lê e decide. Como o estado é por session_id, duas sessões abertas no mesmo projeto não se atrapalham. E o portão não lê o transcript, de propósito: o formato dele pode mudar entre versões do Claude Code.
| Configuração (padrão do kit) | Valor |
|---|---|
| O que conta como código | .py .js .mjs .cjs .jsx .ts .tsx .sh .go .rs .rb .c .cpp .java |
| Pastas ignoradas | raw wiki docs .claude .mentor |
| Onde mudar | mentor.config.json → "portao" |
💡 Por que .claude está na lista de ignoradas
Sem isso, editar o próprio portao-execucao.py ou um hook novo dentro de .claude/ marcaria o arquivo como sujo, e o portão pediria para "rodar" um script que só faz sentido recebendo um evento. Markdown, wiki e acervo também ficam de fora: o portão é sobre código.
🚫 O que NÃO conta como rodar
A parte difícil de um portão é não ser enganado. Olhar o arquivo, compilar sem executar ou commitar não provam nada sobre o comportamento. O script quebra cada comando Bash em segmentos (separados por &&, ;, |…) e só limpa o sujo se algum segmento realmente o executa.
✗ Não limpa (o portão continua bloqueando)
- ls -la · cat calc.py
- git commit -m "fix calc.py" · git add calc.py
- rm calc.py · chmod +x calc.py
- ruff check calc.py
- python3 -m py_compile calc.py
- bash -n x.sh
- echo "calc.py" > log.txt
- pip install pytest
- cat calc.py | python3
✓ Limpa (executou de verdade)
- python3 calc.py --x 2
- cd pasta && python3 x.py
- VAR=x python3 a.py
- python3 -m pacote.tokenizador
- python3 -m pytest -q (limpa todos os .py)
- npm test (limpa .js/.ts, inclusive .tsx)
Todos os exemplos acima são casos do arquivo de testes do portão, tests/test_portao.py.
🆕 Novo aqui? py_compile, bash -n e afins
python3 -m py_compile, bash -n, node --check e tsc --noEmit só conferem se o arquivo está bem escrito (a sintaxe). Não executam uma linha da lógica. Um script pode passar em todos eles e ainda dar a resposta errada, por isso o portão os trata como leitura. O mesmo vale para linters como ruff e eslint.
E o cat calc.py | python3?
Esse até executa o código, mas o portão não consegue provar isso só olhando o comando: o primeiro segmento é cat (leitura) e o segundo é python3 sem arquivo. Na dúvida, o portão continua bloqueando. Errar para o lado de "rode de novo, do jeito claro" custa um comando; errar para o outro lado deixa passar código nunca executado.
🔂 Anti-loop: no máximo um bloqueio por arquivo editado
O módulo 5.2 mostrou o que acontece com um hook de parada que ignora stop_hook_active: a sessão gira para sempre. O portão do kit nasceu com a defesa: quando o evento de parada chega com stop_hook_active=true, ele não bloqueia. Em vez disso, marca os sujos como "avisados" e sai com exit 0.
1º Stop com calc.py sujo → block. O agente recebe o recado.
2º Stop, agora com stop_hook_active=true → libera e grava calc.py em avisados.
Próximos Stops → não bloqueiam por calc.py de novo, mesmo sem a flag.
Editou calc.py de novo? Sai de "avisados" e volta a valer um bloqueio.
Estado gravado depois do 2º Stop, numa simulação feita para esta aula (caminho encurtado):
{"sujos": [".../calc.py"], "avisados": [".../calc.py"]}
O arquivo continua sujo (ninguém o rodou), mas já foi avisado. O próximo Stop dessa sessão termina com exit 0 e nada no stdout.
✓ O que o anti-loop garante
- ✓A sessão nunca fica presa pelo portão
- ✓Qualquer erro interno → exit 0: o portão nunca derruba a sessão
- ✓Stdin que não é JSON → exit 0 (há teste para isso)
✗ O que ele não garante
- ✗Que o agente rode depois do aviso: ele pode explicar e encerrar
- ✗Que o código esteja certo: isso é trabalho da Saída real e do validador
⚠️ "Stop hook error occurred" e a prova no piloto
Quando o portão bloqueia, a interface do Claude Code mostra o rótulo "Stop hook error occurred". Parece defeito do kit. Não é: é o portão devolvendo o turno com o recado "Você escreveu código e não rodou". O agente roda o código e segue. O piloto anotou essa confusão no diário, e o README do kit 1.2 ganhou uma seção explicando isso.
Sessão real, não simulação
No piloto, uma sessão claude -p dentro do projeto do mentor recebeu o pedido de escrever teste_portao.py sem rodar.
O Stop foi bloqueado
Com o recado "Você escreveu código e não rodou: teste_portao.py…".
O agente explicou e encerrou no 2º Stop
Como o pedido dizia "não rode", o agente explicou a situação e o 2º Stop (já com stop_hook_active) terminou o turno. Um bloqueio, como desenhado. Resultado no placar: "bloqueou certo, sem bloqueio indevido" ✅.
🧪 Testes automáticos do portão
Além da sessão real, o kit tem um arquivo só para o portão. Os casos 1 a 5 cobrem o básico (editou e rodou passa; editou e não rodou bloqueia; só Markdown passa; stop_hook_active passa; SubagentStop também bloqueia). Os outros cobrem as armadilhas do tópico 3.
cd <clone do kit mentor-especialista> python3 -m pytest tests/test_portao.py -q
Como verificar: a última linha diz 28 passed (medido no kit 1.3). O portão de previsão tem arquivo próprio, tests/test_previsao.py (9 passed), e o kit inteiro tem 81 testes (python3 -m pytest -q).
🔮 O segundo portão: previsão (kit 1.3)
O portão deste módulo olha para depois: escreveu código e não rodou, o Stop bloqueia. O kit 1.3 acrescentou um portão que olha para antes, no mesmo script .claude/hooks/portao-execucao.py, registrado também em PreToolUse com "matcher": "Bash".
- • Um comando que executa código só passa se o assistente já escreveu Previsão: … no turno. Se não, o hook nega e explica o que escrever.
- • Ler/listar (cat, ls, grep), os scripts do kit (tools/*.py de stats, validação e coleta) e --version passam direto.
- • Num subagente, ele lê a transcrição do próprio subagente: <sessão>/subagents/agent-<id>.jsonl.
- • Desligável em mentor.config.json → "portao": {"previsao": false}.
Foi esse portão que fechou o piloto em 5/5: no teste 2, rodada 4, ele negou a primeira tentativa de execução e o mentor só rodou depois de prever. A história completa está no módulo 6.1.
🧪 Provoque o portão de propósito
Só confie no portão depois de vê-lo bloquear. Há dois jeitos: dentro do Claude Code, no projeto do mentor, ou fora dele, entregando ao script os mesmos eventos JSON que o Claude Code entregaria. O segundo leva 30 segundos e não gasta nada.
🅰️ Dentro do Claude Code
Objetivo: ver o bloqueio numa sessão real, como no piloto.
Crie calc.py na raiz do projeto, com uma função que soma dois números e um print(soma(2, 3)) no final. NÃO rode o arquivo. Depois de criar, encerre.
Como verificar: a interface mostra "Stop hook error occurred" e o agente recebe "Você escreveu código e não rodou: calc.py…". Como você pediu para não rodar, ele deve explicar e encerrar no 2º Stop. Peça de novo sem o "NÃO rode": desta vez ele roda python3 calc.py, mostra 5 e termina sem bloqueio.
🅱️ Fora do Claude Code, só com o terminal
Objetivo: simular os três eventos (editou, tentou parar, rodou) e ler o estado.
mkdir -p aula-portao/.claude/hooks && cd aula-portao
cp <pasta do seu mentor>/.claude/hooks/portao-execucao.py .claude/hooks/
printf 'print(2+3)\n' > calc.py
H="python3 .claude/hooks/portao-execucao.py"; export CLAUDE_PROJECT_DIR=$PWD
echo '{"hook_event_name":"PostToolUse","tool_name":"Write","session_id":"aula","tool_input":{"file_path":"calc.py"}}' | $H
echo '{"hook_event_name":"Stop","session_id":"aula"}' | $H # 1) deve bloquear
echo '{"hook_event_name":"PostToolUse","tool_name":"Bash","session_id":"aula","tool_input":{"command":"cat calc.py"}}' | $H
echo '{"hook_event_name":"Stop","session_id":"aula"}' | $H # 2) cat não conta: bloqueia
echo '{"hook_event_name":"PostToolUse","tool_name":"Bash","session_id":"aula","tool_input":{"command":"python3 calc.py"}}' | $H
echo '{"hook_event_name":"Stop","session_id":"aula"}' | $H; echo "exit=$?" # 3) rodou: nada no stdout
cat .mentor/estado/aula.json
Como verificar: as linhas 1 e 2 imprimem o bloqueio (o ê é só o "ê" escapado no JSON):
{"decision": "block", "reason": "Você escreveu código e não rodou: calc.py. Rode cada um (ou os testes) e mostre a saída real antes de dizer que funciona."}
A linha 3 imprime só exit=0, e o estado final é {"sujos": [], "avisados": []}. Essa sequência foi rodada ao preparar esta aula e deu exatamente isso.
⚠️ Se o passo 1 não bloquear
Confira três coisas, nesta ordem: o calc.py está numa pasta ignorada (raw, wiki, docs, .claude, .mentor)? O CLAUDE_PROJECT_DIR aponta para a pasta onde está o calc.py? O session_id é o mesmo nos dois comandos? Qualquer erro interno vira exit 0 silencioso, então um bloqueio que não aparece quase sempre é uma dessas três.
Checagem rápida: o mentor editou calc.py e depois rodou python3 -m py_compile calc.py. O que o portão faz no Stop?
📌 Resumo do Módulo
Próxima Trilha:
6.1 - O piloto real e os testes de aceitação: o que passou, o que não passou e por que instrução não basta.