Free

Dejar que Claude escriba hooks que se graben a sí mismo

Lo más difícil de los artículos de caso real es juntar material: git log solo tiene commit messages, los detalles de sesión desaparecen. Cuatro hooks y dos slash commands convierten cada sesión en raw.md — coste marginal cero.


Lo más difícil de escribir un artículo "Claude hizo X por mí" no es escribirlo: es juntar el material. El git log solo tiene los mensajes de commit; las propuestas que descartaste, los intentos fallidos, las tres revisiones de aquel prompt, la salida de error — eso es lo que vale en un artículo, y se perdió todo.

Peor: cuando vas a escribir, ya no recuerdas por qué elegiste A en lugar de B. "Creo recordar que había una razón" frente a "esa razón fue X" es la diferencia entre un artículo creíble y relleno.

Tomar actas cuesta más que escribir el artículo, así que no las tomas. La única solución es que la grabación ocurra sola — hooks.

Hice que Claude escribiera 4 hooks + 2 slash commands que convierten cada sesión de desarrollo en docs/notes/<feature>/raw.md. El artículo anterior, Dejar que Claude despliegue a producción, sacó la mayoría de sus commits, fragmentos de bash y errores citados de este pipeline. Este también.


Vista general del pipeline

4 hooks + 2 slash commands manuales forman una sola cinta transportadora:

Disparador Qué hace
PostToolUse(Edit\ Write\
PostToolUse(Bash) Detiene cuando vuelves a master + limpio
git post-commit Escribe un checkpoint por cada commit
Stop Parsea el transcript al cerrar la sesión
/record-feature NAME /stop-recording Anulación manual

Un único archivo de estado: docs/notes/.state.json. Todos los hooks lo leen y escriben. Sin más coordinación.

Hook #1: cuándo empezar

"PostToolUse": [
  {
    "matcher": "Edit|Write|MultiEdit",
    "hooks": [{
      "type": "command",
      "command": "$CLAUDE_PROJECT_DIR/bin/recording-state maybe-start"
    }]
  }
]

Núcleo de maybe-start:

def cmd_maybe_start():
    if load_state(): return          # ya está grabando
    if not tree_is_dirty(): return   # nada cambió, omitir
    feature = branch_to_feature(current_branch())
    if not feature:
        feature = "session-" + datetime.now().strftime("%Y%m%d-%H%M")
    save_state({"feature": feature, "auto_started": True, ...})

branch_to_feature("feature/pro") devuelve "pro". Los desarrolladores ya separan el trabajo por branch, así que reutilizamos esa frontera mental — la razón profunda por la que este pipeline sobrevive es que no le pide al usuario recordar nada nuevo. En master, los scripts puntuales caen a un timestamp.

Por qué el matcher es Edit/Write/MultiEdit: escribir código es la señal real de "estoy haciendo algo". Leer archivos, correr tests, hacer preguntas — no cuentan.

Hook #2: cuándo parar

{
  "matcher": "Bash",
  "hooks": [{ "command": "$CLAUDE_PROJECT_DIR/bin/recording-state maybe-stop" }]
}
def cmd_maybe_stop():
    state = load_state()
    if not state or not state.get("auto_started"): return
    if current_branch() not in ("master", "main"): return
    if tree_is_dirty(): return
    clear_state()

Tres compuertas: las grabaciones manuales no se detienen, las feature branches no se detienen, los árboles sucios no se detienen.

Por qué se engancha en Bash y no en Edit: tras hacer merge a master normalmente ya no editas, pero seguro corres git status / git log / bin/rails test — cualquier comando le da al hook la oportunidad de notar "es hora de cerrar".

La bandera auto_started es crítica. Si lanzas /record-feature pro-launch y atraviesas múltiples branches y merges, las reglas de auto-stop matarían la grabación a media función. Las grabaciones manuales nunca se auto-detienen — solo /stop-recording.

Hook #3: commit como checkpoint

No es un hook de Claude Code — es .git/hooks/post-commit:

#!/bin/bash
ROOT=$(git rev-parse --show-toplevel) || exit 0
CLAUDE_PROJECT_DIR="$ROOT" "$ROOT/bin/recording-state" commit || true

recording-state commit añade el mensaje completo del commit a raw.md:

### Commit 2026-04-16 21:55: `71f38a1`
> Add pricing page and expand account UI (P6 phases 1-2)
> 
> Pricing (/pricing):
> - Displays all 6 plans in monthly/yearly grid with Stimulus toggle
> - Anonymous users see Subscribe → sign-in flow
> ...

Por qué importan los mensajes de commit: son el resumen escrito a mano que tú (o Claude) produjeron en el momento exacto en que la función estaba recién terminada y el contexto era completo. Más preciso que recordarlo después, más corto que el transcript, más abstracto que el diff.

Cuando Claude escribe un commit message, esencialmente está escribiendo material de resumen para tu futuro artículo — solo necesitas que lo haga bien la primera vez, no reescribirlo después.

Hook #4: extraer eventos del transcript

"Stop": [{
  "hooks": [{ "command": "$CLAUDE_PROJECT_DIR/bin/extract-session-notes" }]
}]

Cuando una sesión termina, Claude Code pasa el transcript_path al hook por stdin como JSON. extract-session-notes abre ese jsonl, lo recorre línea por línea y clasifica:

keep_patterns = (
    "test", "spec", "rspec", "minitest",
    "kamal", "git commit", "git push",
    "rails db", "rails routes", "rails runner",
    "migrate", "curl -X", "curl -s -X",
)
if any(kw in cmd for kw in keep_patterns):
    bash_cmds.append({"cmd": cmd[:400], "desc": desc})

Solo se capturan los comandos bash de la lista blanca. El 90% del bash de una sesión de código es ls / cat / grep / head — los artículos no usan nada de eso, todo se filtra. Lo que queda (correr tests, kamal, rails runner, curl contra APIs) son los comandos con historia detrás.

Los prompts de usuario envueltos en <command-*> o <system-*> se descartan — solo sobrevive la entrada real. Las rutas de Edit/Write se deduplican en un set. La salida de error se corta a 400 caracteres. Las llamadas a Task sub-agente se conservan (prompt cortado a 2000 caracteres, suficiente para ver qué hizo el sub-agente).

Una grabación, muchos Stops: usa un cursor

El hook Stop no se dispara una vez por grabación — se dispara cada vez que termina una sesión. En la misma feature branch puedes abrir y cerrar Claude Code cinco o seis veces. Si cada Stop re-parsea todo el transcript y lo escribe entero, raw.md se ahoga en duplicados.

Solución: meter un cursor last_extracted_at en el state file:

filter_after = state.get("last_extracted_at") or state.get("started_at")
events = extract_events(transcript_path, filter_after)
# ...después de escribir...
state["last_extracted_at"] = datetime.now().astimezone().isoformat()
save_state(project_dir, state)

Cada pasada solo recoge eventos posteriores al cursor. Simple, pero olvídalo y aparecen cientos de líneas duplicadas.

Anulación manual: dos slash commands

/record-feature NAME:

{
  "feature": "NAME",
  "started_at": "ISO timestamp",
  "branch": "current branch",
  "auto_started": false
}

Esa línea auto_started: false desactiva el auto-stop. Casos: escribir un script puntual en master pero querer dejar rastro, una feature que cruza múltiples branches, o declarar explícitamente "esto va para un artículo".

/stop-recording: corre una extracción final contra el último jsonl, luego limpia el state file.

Cómo se ve raw.md

Fragmento real (de docs/notes/pro/raw.md):

## Session 2026-04-16 21:52 (`7a81bf9d`)

### User prompts
- 根据环境变量直接写好对应只,不用传。

### Files edited/written
- `config/deploy.yml`
- `config/initializers/x402.rb`

### Commit 2026-04-17 00:13: `f87ea8e`
> Add production credentials (Stripe live + x402 mainnet)
> ...

### Commit 2026-04-17 00:57: `eba9ac9`
> Fix production x402 wallet_address (stray fullwidth '?' at end)

Para escribir un artículo: solo grep. "¿Qué commit fue el fix del wallet address?" → eba9ac9. "¿Cómo prompteé al sub-agente del refactor?" → bajo ### Sub-agent invocations.

docs/notes/ está en .gitignore — es material de borrador para tu escritura, no código fuente.

Tres decisiones de diseño no obvias

1. La branch como frontera de feature, no un sistema de metadata aparte. Los devs ya particionan el trabajo por branch; añadir otra capa "nombre de feature" derivaría inevitablemente. Reutilizar la frontera existente, cero carga cognitiva.

2. Lista blanca para bash, no negra. El 95% del bash en transcripts es ruido. Mantener una lista de "qué vale la pena guardar" se mantiene estable durante años; mantener una de "qué filtrar" no.

3. El cursor vive en el state file, no en el transcript. El transcript pertenece a Claude Code; el estado pertenece al pipeline. Desacoplados — Claude Code puede cambiar el formato de transcript sin romperme, y yo puedo reescribir la lógica de extracción sin tocar transcripts.

Cerrando el bucle

Escribir un artículo de 1500 palabras consume material de unos 30 commits, 4 sesiones, una docena de comandos bash clave. Juntarlo a mano cuesta como una hora — suficiente para que la próxima vez tomes atajos.

Que lo hagan los hooks: coste marginal cero. Instala esto una vez y simplemente trabaja normalmente — saca una branch, escribe código, despliega — y el material para el artículo se apila solo.

Cada referencia a commit, snippet de bash, línea de error y ruta de archivo en este artículo viene de docs/notes/pro/raw.md — material de borrador que el hook grabó solo desde el momento en que saqué feature/pro hace dos días. Cuando llegó la hora de escribir, abrí el archivo y todo lo que necesitaba estaba ahí.

La mejor manera de hacer que Claude escriba artículos sobre Claude Code es primero hacer que Claude escriba hooks para grabarse a sí mismo.