Free

Laisser Claude écrire des hooks qui s'enregistrent eux-mêmes

Le plus dur des articles de cas, c'est rassembler la matière : le git log n'a que des messages de commit, les détails de session s'évaporent. Quatre hooks et deux slash commands transforment chaque session en raw.md — coût marginal zéro.


Le plus dur dans l'écriture d'un article "Claude m'a fait X", ce n'est pas d'écrire — c'est de rassembler la matière. Le git log n'a que les messages de commit ; les propositions que tu as rejetées, les tentatives qui ont échoué, les trois révisions de ce prompt-là, la sortie d'erreur — c'est ça qui rend un article digne d'être lu, et c'est tout perdu.

Pire : au moment où tu rédiges, tu ne te rappelles plus pourquoi tu as choisi A plutôt que B. "Je crois qu'il y avait une raison" vs "cette raison était X" fait toute la différence entre un article crédible et du remplissage.

Prendre des notes de réunion coûte plus cher que d'écrire l'article, alors tu n'en prends pas. La seule solution : laisser l'enregistrement se faire tout seul — des hooks.

J'ai fait écrire à Claude 4 hooks + 2 slash commands qui transforment chaque session de dev en docs/notes/<feature>/raw.md. L'article précédent, Laisser Claude déployer en production, a tiré la plupart de ses commits, bouts de bash et références d'erreur de ce pipeline. Celui-ci aussi.


Vue d'ensemble du pipeline

4 hooks + 2 slash commands manuels forment une seule chaîne :

Déclencheur Ce qu'il fait
PostToolUse(Edit\ Write\
PostToolUse(Bash) Arrête quand tu reviens sur master + propre
git post-commit Écrit un checkpoint par commit
Stop Parse le transcript à la fin de la session
/record-feature NAME /stop-recording Surcharge manuelle

Un seul fichier d'état : docs/notes/.state.json. Tous les hooks le lisent et l'écrivent. Pas d'autre coordination.

Hook #1 : quand démarrer

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

Cœur de maybe-start :

def cmd_maybe_start():
    if load_state(): return          # déjà en enregistrement
    if not tree_is_dirty(): return   # rien changé, on passe
    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") renvoie "pro". Les devs partitionnent déjà le travail par branche, donc on réutilise cette frontière mentale — la raison profonde qui fait tenir ce pipeline, c'est qu'il ne demande à l'utilisateur de se souvenir de rien de nouveau. Sur master, les scripts ponctuels retombent sur un timestamp.

Pourquoi le matcher est Edit/Write/MultiEdit : écrire du code est le vrai signal "je suis en train de faire quelque chose". Lire des fichiers, lancer des tests, poser des questions — ça ne compte pas.

Hook #2 : quand s'arrêter

{
  "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()

Trois portes : les enregistrements manuels ne s'arrêtent pas, les feature branches ne s'arrêtent pas, l'arbre sale ne s'arrête pas.

Pourquoi accroché à Bash et pas à Edit : après avoir mergé vers master tu n'édites plus vraiment, mais tu vas sûrement lancer git status / git log / bin/rails test — n'importe quelle commande donne au hook l'occasion de remarquer "c'est le moment de fermer".

Le flag auto_started est porteur. Si tu lances /record-feature pro-launch et que tu traverses plusieurs branches via plusieurs merges, les règles d'auto-stop tueraient l'enregistrement en plein milieu. Les enregistrements manuels ne s'auto-arrêtent jamais — seulement /stop-recording.

Hook #3 : le commit comme checkpoint

Pas un hook Claude Code — c'est .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 ajoute le message de commit complet à 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
> ...

Pourquoi les messages de commit comptent : ce sont le résumé écrit à la main que toi (ou Claude) as produit pile au moment où la feature venait de finir et le contexte était complet. Plus précis que se rappeler après, plus court que le transcript, plus abstrait que le diff.

Quand Claude écrit un message de commit, il écrit essentiellement de la matière de résumé pour ton article futur — tu as juste besoin qu'il soit bien fait du premier coup, pas de le réécrire plus tard.

Hook #4 : extraire des événements du transcript

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

Quand une session se termine, Claude Code passe transcript_path au hook via stdin JSON. extract-session-notes ouvre ce jsonl, le parcourt ligne par ligne, et classe :

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})

Seules les commandes bash de la whitelist sont capturées. 90% du bash d'une session de code est ls / cat / grep / head — inutilisé dans les articles, tout est filtré. Le reste (lancer les tests, kamal, rails runner, curl contre des APIs) ce sont les commandes avec une histoire derrière.

Les prompts utilisateur emballés dans <command-*> ou <system-*> sont jetés — seule l'entrée réelle survit. Les chemins Edit/Write sont dédupliqués dans un set. La sortie d'erreur coupée à 400 caractères. Les appels sub-agent Task sont conservés (prompt coupé à 2000 caractères, assez pour voir ce que le sub-agent a fait).

Un enregistrement, plusieurs Stops : utilise un curseur

Le hook Stop ne se déclenche pas une fois par enregistrement — il se déclenche à chaque fin de session. Sur la même feature branch tu peux ouvrir et fermer Claude Code cinq ou six fois. Si chaque Stop reparse tout le transcript et écrit tout, raw.md se noie dans les doublons.

Solution : mettre un curseur last_extracted_at dans le fichier d'état :

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

Chaque passe ne prend que les événements postérieurs au curseur. Simple, mais oublie-le et tu te retrouves avec des centaines de lignes dupliquées.

Surcharge manuelle : deux slash commands

/record-feature NAME :

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

Cette ligne auto_started: false désactive l'auto-stop. Cas d'usage : écrire un script ponctuel sur master mais vouloir garder une trace, une feature qui traverse plusieurs branches, ou déclarer explicitement "celle-là c'est pour un article".

/stop-recording : lance une extraction finale sur le jsonl le plus récent, puis nettoie le fichier d'état.

À quoi ressemble raw.md

Extrait réel (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)

Pour écrire un article : juste grep. "Quel commit était le fix du wallet address ?" → eba9ac9. "Comment j'ai prompté le sub-agent du refactor ?" → sous ### Sub-agent invocations.

docs/notes/ est dans .gitignore — c'est de la matière brouillon pour ton écriture, pas du code source.

Trois décisions de design non évidentes

1. La branche comme frontière de feature, pas un système de metadata séparé. Les devs partitionnent déjà le travail par branche ; ajouter une autre couche "nom de feature" dériverait inévitablement. Réutiliser la frontière existante, charge cognitive nulle.

2. Whitelist pour bash, pas blacklist. 95% du bash dans les transcripts est du bruit. Maintenir une liste "ce qui vaut la peine d'être gardé" reste stable sur des années ; maintenir une liste "ce qu'il faut filtrer" non.

3. Le curseur vit dans le fichier d'état, pas dans le transcript. Le transcript appartient à Claude Code ; l'état appartient au pipeline. Découplé — Claude Code peut changer le format du transcript sans me casser, et je peux réécrire la logique d'extraction sans toucher aux transcripts.

Fermer la boucle

Écrire un article de 1500 mots consomme la matière d'environ 30 commits, 4 sessions, une douzaine de commandes bash clés. La rassembler à la main coûte à peu près une heure — assez pour que tu coupes les coins la prochaine fois.

Laisse les hooks le faire : coût marginal zéro. Installe ça une fois et travaille juste normalement — tire une branche, écris du code, déploie — et la matière d'article s'empile toute seule.

Chaque référence de commit, extrait de bash, ligne d'erreur et chemin de fichier dans cet article vient de docs/notes/pro/raw.md — matière brouillon que le hook a enregistrée lui-même depuis le moment où j'ai tiré feature/pro il y a deux jours. Au moment de l'écriture, j'ai ouvert le fichier et tout ce dont j'avais besoin était là.

La meilleure façon de faire écrire à Claude des articles sur Claude Code, c'est d'abord de lui faire écrire des hooks pour s'enregistrer lui-même.