実践記事で一番しんどいのは素材集め——git log は commit message だけ、session の肝心な部分は消える。4 つの hook と 2 つの slash command で session を自動的に raw.md に変える、限界コストゼロ。
「Claude に X をやらせてみた」系の実践記事を書くとき、一番しんどいのは書くことじゃない、素材集めだ。あとから git log を漁っても commit message しか残っていない。session で却下したアプローチ、失敗した試行、3 回書き直したプロンプト、エラー出力——記事を面白くする部分は全部消えている。
さらに悪いことに、記事を書く頃にはなぜ A を選び B を選ばなかったのか思い出せない。「理由があった気がする」と「その理由は X だ」の差が、記事の信頼度を一段落とす。
議事録を取るコストは記事を書くコストより高い。だから記録しない。唯一の解は、記録を勝手に起こさせること——hook だ。
Claude に 4 つの hook と 2 つの slash command を書かせて、開発 session を自動的に docs/notes/<feature>/raw.md に変換している。前回の記事「Claude に本番デプロイをやらせる」で引用した commit、bash コマンド、エラー断片はほぼ全てこの pipeline からきた。この記事もそうだ。
4 つの hook + 2 つの手動 slash command が 1 本のパイプラインを形成する:
| トリガー | 仕事 |
|---|---|
| PostToolUse(Edit\ | Write\ |
| PostToolUse(Bash) | master 復帰+クリーンなら録画停止 |
| git post-commit | コミットごとに checkpoint を書く |
| Stop | session 終了時に transcript からイベント抽出 |
/record-feature NAME /stop-recording |
手動オーバーライド |
状態ファイルはひとつ:docs/notes/.state.json。全 hook がこれを読み書きするだけで、他の調整は要らない。
"PostToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/bin/recording-state maybe-start"
}]
}
]
maybe-start の中核:
def cmd_maybe_start():
if load_state(): return # すでに録画中
if not tree_is_dirty(): return # 何も変えてないなら無視
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") は "pro" を返す。開発者はすでに branch で作業を分けているので、その心的境界をそのまま流用する。この pipeline が続く根本理由は、ユーザーに新しい記憶負担を一切かけないこと。master で書き捨てスクリプトを書く場合はタイムスタンプに fallback。
Edit/Write に引っかけた理由:コードを書いていることが session で「本当に手を動かしている」シグナルだから。質問する、ファイルを読む、テストを走らせる——これらは該当しない。
{
"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()
3 つの関門:手動録画は止めない、feature branch にいるうちは止めない、ツリーが汚れていれば止めない。
Edit じゃなく Bash に引っかけた理由:master に戻ると Edit はもう打たないが、git status / git log / bin/rails test は必ず叩く——何かしらコマンドを打った瞬間に hook が「そろそろ終わり」と判断できる。
auto_started フラグが鍵。/record-feature pro-launch で手動録画を開始して複数 branch、複数マージをまたがる場合、自動停止ルールがあると誤ってキルされる。手動で始めたものは自動停止しない、止めるには /stop-recording だけ。
Claude Code の hook ではなく、.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 は commit message を丸ごと 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
> ...
なぜ commit message が重要か:これは機能を作り終えて文脈がフルに揃った瞬間に 人間(か Claude)が手で書いた要約 だ。後で思い出すより正確、transcript より短い、diff より抽象的。
Claude が commit message を書くとき、実質あなたの将来の記事の素材要約を書いていることになる——最初に良く書けばいい、後で書き直さなくていい。
"Stop": [{
"hooks": [{ "command": "$CLAUDE_PROJECT_DIR/bin/extract-session-notes" }]
}]
session が終わると、Claude Code は transcript_path を stdin JSON で hook に渡す。extract-session-notes はその jsonl を開いて、1 行ずつパースしてバケット分け:
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})
ホワイトリストに入った bash だけ保存する。コードを書く session の 90% は ls / cat / grep / head——記事で使わない、全部捨てる。残るもの(テスト、kamal、rails runner、curl で API 叩く)が物語のあるコマンド。
ユーザープロンプトのうち <command-*> と <system-*> で包まれたものは全部除外、本物の入力だけ残す。Edit/Write のパスは set に重複排除。エラーは先頭 400 文字だけ。Task サブ agent 呼び出しは保存(プロンプトは 2000 文字で切るが、サブ agent が何をしたか見るには十分)。
Stop hook は録画期間ごとに 1 回ではなく、session 終了のたびに発火する。同じ feature branch で Claude Code を 5、6 回立ち上げ直すと、毎回 transcript を丸ごと再解析して書き込んでいたら raw.md は重複で溢れる。
解決策:state file に last_extracted_at というカーソルを置く:
filter_after = state.get("last_extracted_at") or state.get("started_at")
events = extract_events(transcript_path, filter_after)
# ...書き終わった後...
state["last_extracted_at"] = datetime.now().astimezone().isoformat()
save_state(project_dir, state)
毎回カーソル以降のイベントだけ抽出する。シンプルだが、これを忘れると数百行の重複が一瞬で出る。
/record-feature NAME:
{
"feature": "NAME",
"started_at": "ISO timestamp",
"branch": "current branch",
"auto_started": false
}
auto_started: false の 1 行が自動停止を無効化する。用途:master で書き捨てスクリプトを書きつつ記録も残したい、複数 branch にまたがる大きな機能、「これは記事用だ」と明示したいとき。
/stop-recording:最新の jsonl に対して最終抽出を 1 回走らせ、state file を消す。
実物の切り抜き(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)
記事を書くときは grep だけ。「ウォレットアドレスのバグ修正はどの commit?」→ eba9ac9。「リファクタのサブ agent にどうプロンプト書いた?」→ ### Sub-agent invocations の下。
docs/notes/ は .gitignore されている——記事用の下書き材料であって、ソースコードではない。
1. ブランチを feature 境界にする、別途メタデータを持たない。 開発者はすでに branch で仕事を区切っている。「機能名」という別レイヤーを足せば必ず drift する。既存境界を流用して、認知負担ゼロ。
2. bash はホワイトリスト、ブラックリストではない。 transcript の 95% の bash はノイズ。「残す価値があるもの」のリストは長年安定している、「捨てるべきもの」のリストは 10 倍不安定。
3. cursor は state file にある、transcript 側には置かない。 transcript は Claude Code のもの、state は pipeline のもの。デカップリング——Claude Code が transcript フォーマットを変えても自分は無傷、pipeline の抽出ロジックを書き換えても transcript に触る必要がない。
1500 字の記事を書くには、だいたい 30 個の commit、4 回の session、十数個の重要な bash コマンド分の素材が要る。これを手で集めると 1 時間はかかる——次回は適当に埋めてしまう誘惑が充分に強い。
hook に自動でやらせれば限界コストはゼロ。一度仕組みを仕込んでおけば、あとは普通に branch を切ってコードを書いて出荷するだけで、記事の素材が勝手に積み上がる。
この記事で引用した全ての commit、bash 断片、エラー、ファイルパスは docs/notes/pro/raw.md から来た——2 日前に feature/pro を切った瞬間から hook が自分で記録し続けた下書き素材だ。いざ書く段になってファイルを開くと、必要なものは全部あった。
Claude に Claude Code についての記事を書かせるなら、まず Claude に自分を記録する hook を書かせるのが一番いい。