Sistema de gravação do how2claude pra outro projeto Rails: 4 arquivos, 5 passos, camadas de hook.
how2claude tem um conjunto de hooks que auto-gravam as sessions do Claude Code — começa a gravação quando o trabalho arranca, anexa um checkpoint em cada commit e, no fim da session, extrai prompts / bash / lista de edits pro docs/notes/<feature>/raw.md. O artigo let-claude-record-itself cobriu como foi montado.
Problema: meu outro projeto (smarts, um site de docs pra smart contracts) não tinha nada disso. Toda vez que eu queria escrever um artigo depois, acabava escarafunchando git log e a memória, sentindo que tava perdendo o bom. Esse artigo conta como portei o sistema de gravação — 4 arquivos, 5 minutos no total — mas no caminho apareceu uma sacada real sobre camadas de hook: hooks do Claude Code e hooks do git operam em camadas completamente diferentes, e o que é capturado quando você mistura ferramentas (Amp + Claude Code, por exemplo) depende de em qual camada você instalou.
Tudo do how2claude relacionado à gravação:
| Arquivo | Camada | Papel |
|---|---|---|
bin/recording-state |
script | helper Python, gerencia o ciclo de vida do .state.json |
bin/extract-session-notes |
script | helper Python, lê o transcript do Claude Code → escreve raw.md |
.claude/settings.local.json |
hook do Claude Code | PostToolUse / Stop disparam os scripts acima |
.git/hooks/post-commit |
hook do git | cada commit chama recording-state commit pra um checkpoint |
.gitignore |
controle de ruído | mantém docs/notes/ fora do repo (notas são privadas/transitórias) |
4 peças, 4 camadas diferentes. Essa distinção volta abaixo.
Dois sistemas de hook rodando ao mesmo tempo, com escopos bem diferentes:
Hooks do Claude Code (definidos em .claude/settings.local.json):
- Escopo: só dispara dentro da ferramenta Claude Code
- Disparadores: PostToolUse / Stop / PreToolUse — eventos do ciclo de vida do Claude Code
- Info disponível: nome da tool, args, transcript_path (jsonl completo da session) — coisas que só Claude Code sabe
Hooks do git (scripts shell em .git/hooks/):
- Escopo: dispara em todo evento do git, sem importar quem disparou o git
- Disparadores: post-commit / pre-push / etc.
- Info disponível: o que o próprio git sabe (sha, autor, branch, diff)
Consequência real: escrever código + commit dentro do Claude Code dispara as duas camadas — info da session e info do commit aterrissam no raw.md. Trocar pro Amp (ou Cursor, ou digitar à mão) pra escrever + commit e só o hook do git dispara — raw.md recebe o esqueleto do commit mas nenhum prompt / bash / detalhe de edit da session.
Não é bug — é a restrição de design de cada ferramenta. Querer detalhe em nível de session sob qualquer ferramenta significa instalar sua própria camada de hook por ferramenta. O fallback do git te dá o "o que foi feito"; não te dá o "o que foi pensado, o que quebrou".
Guia de seleção:
- Esqueleto agnóstico de ferramenta (info de commit, mudanças de código) → põe num hook de git
- Carne específica do Claude Code (prompts completos, raciocínio) → põe num hook do Claude Code
- Os dois → instala em ambas as camadas
Instalando o mesmo setup de gravação no smarts (/home/bob/Work/smarts, um projeto Rails).
mkdir -p /home/bob/Work/smarts/bin
cp /home/bob/Work/how2claude/bin/recording-state \
/home/bob/Work/smarts/bin/recording-state
cp /home/bob/Work/how2claude/bin/extract-session-notes \
/home/bob/Work/smarts/bin/extract-session-notes
chmod +x /home/bob/Work/smarts/bin/{recording-state,extract-session-notes}
Scripts sem modificação — usam a env var $CLAUDE_PROJECT_DIR pra decidir onde escrever:
def project_dir():
return os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
def state_path():
return pathlib.Path(project_dir()) / "docs/notes/.state.json"
Essa é a abstração chave: Claude Code seta CLAUDE_PROJECT_DIR automaticamente ao disparar hooks; a gente seta na mão dentro do post-commit do git. Os dois lados honram a mesma env var, e o script não precisa descobrir "em qual projeto eu tô".
.claude/settings.local.jsonUma decisão aqui: portar só os hooks, não a lista de permissions.
O settings.local.json do how2claude tem mais de 100 entradas de permissions.allow — todas específicas do how2claude (curl localhost:3000, bin/rails runner, kamal app exec). Zero sentido levar isso pro smarts. smarts vai acumular suas próprias permissions organicamente conforme for usado.
Hooks são padrões — idênticos entre projetos. Permissions são estado de projeto — diferentes entre projetos.
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/bin/recording-state maybe-start"
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/bin/recording-state maybe-stop"
}
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/bin/extract-session-notes" }
]
}
]
}
}
Três pontos de disparo:
- Arquivo editado → tenta arrancar gravação (maybe-start se auto-checa: se já tá gravando, pula; se tree limpo, também pula)
- Comando bash rodado → tenta parar (maybe-stop é rigoroso: só para se "auto-arrancado E de volta no master E tree limpo" valem ao mesmo tempo)
- Session termina → extrai o transcript pro raw.md
.git/hooks/post-commit3 linhas:
#!/bin/bash
ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
CLAUDE_PROJECT_DIR="$ROOT" "$ROOT/bin/recording-state" commit || true
O export manual CLAUDE_PROJECT_DIR=$ROOT é o ponto exato onde o mundo do git e o mundo do Claude Code ficam conectados pela mesma env var. O || true garante que o hook nunca bloqueie um commit.
chmod +x /home/bob/Work/smarts/.git/hooks/post-commit
docs/notes/ ao .gitignore# Session recording notes (transient, for article material)
docs/notes/
Notas são transitórias + privadas — você não quer raw.md comitado em PRs; não quer .state.json poluindo git status. O gitignore do how2claude trata igual.
Disparar maybe-start manualmente logo após instalar:
$ cd /home/bob/Work/smarts && CLAUDE_PROJECT_DIR=$(pwd) ./bin/recording-state maybe-start
[recording] auto-started: contract-to-docs (branch: feat/contract-to-docs)
$ cat docs/notes/.state.json
{
"feature": "contract-to-docs",
"started_at": "2026-04-20T17:47:18-04:00",
"branch": "feat/contract-to-docs",
"auto_started": true
}
O script sacou corretamente que smarts por acaso tava em feat/contract-to-docs com tree sujo — auto-arrancou gravação, inferiu o nome de feature contract-to-docs da branch feat/contract-to-docs. Essa lógica no script:
def branch_to_feature(branch):
if not branch or branch in ("master", "main"):
return None
if "/" in branch:
return branch.split("/", 1)[1]
return branch
feat/X → X, feature/X → X, fix-y puro → fix-y, master/main → None (que cai num nome com timestamp session-YYYYMMDD-HHMM).
A heurística é deliberadamente descerebrada — o nome da branch é o assunto do trabalho, não faz falta você nomear de novo.
Depois de instalar me perguntei: às vezes trabalho no Amp — isso fica capturado? A resposta é exatamente o que a história do layering de hook prevê:
| Cenário | Hook Claude Code | Hook git | O que aterrissa nas notas |
|---|---|---|---|
| Trabalho Claude Code + commit | ✅ dispara | ✅ dispara | detalhe de session + esqueleto de commit |
| Trabalho Amp + commit | ❌ no-op | ✅ dispara | só esqueleto de commit |
| Digitação manual + commit | ❌ no-op | ✅ dispara | só esqueleto de commit |
| Trabalho Claude Code, sem commit ainda | ✅ arranca gravação | — | detalhe de session (entrada de commit espera o próximo commit) |
Conclusão: dá pro gasto se Claude Code for o motorista principal. Esqueleto de commit aterrissa sempre; detalhe de session aterrissa só na rota Claude Code. Pra artigos "o que foi feito" você apoia principalmente no body do commit — os prompts / rastros bash da session são bônus, bom ter mas não obrigatório.
Se você usa Amp pesado, Amp tem seu próprio sistema de hook (não entrei em detalhes); um script pequeno de encaminhamento disparando recording-state maybe-start/maybe-stop funcionaria igual.
Portar session recording do Claude Code pra outro projeto — os 5 movimentos:
cp dois scripts Python pro bin/ do projeto alvo. Sem modificação — scripts honram $CLAUDE_PROJECT_DIR, portáveis entre projetos por design..claude/settings.local.json, só hooks. Não porte a lista de permissions — permissions são estado de projeto (diferentes por projeto); hooks são padrões (idênticos entre projetos)..git/hooks/post-commit (3 linhas), export CLAUDE_PROJECT_DIR=$ROOT na mão e chamar recording-state commit. Esse é o único ponto onde o mundo do git e o mundo do Claude Code são conectados pela mesma env var.docs/notes/ ao .gitignore. Notas são transitórias + privadas, não parte do repo.CLAUDE_PROJECT_DIR=$(pwd) ./bin/recording-state maybe-start, verifica se a inferência branch→feature tá certa. Se tree limpo, o script é projetado pra pular — não é bug.A decisão de design real não é "como eu porto" — portar é quase cp. É dividir a lógica de gravação em 4 camadas distintas, cada uma fazendo uma coisa clara:
CLAUDE_PROJECT_DIRCada uma das 4 pode ser portada, substituída ou pulada independentemente (quer suporte pra Amp? adiciona uma camada de hook Amp. Mudar formato de nota? edita o Python. Não quer git trackear notas? remove o post-commit). Claude pode escrever o código certo — mas o julgamento sobre "a qual camada essa funcionalidade pertence" não dá pra ele fazer por você. Esse é um julgamento sobre suas fronteiras de tooling, e é seu.