寫實戰文章最難的是湊素材:git log 只有 commit message,session 裡的關鍵細節全丟。4 個 hook + 2 個 slash command 把 session 自動變成 raw.md,素材 0 邊際成本堆出來。
寫一篇「Claude 幫我做了 X」的實戰文章,最難的不是寫,是湊素材。回頭扒 git log 只有 commit message,session 裡被你否決的方案、失敗的嘗試、改過三次的提示詞、報錯的輸出——這些才是文章最值錢的部分,全丟了。
更糟的是:你寫文章時已經記不清當時為什麼選 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 串起一條流水線:
| 觸發器 | 幹啥 |
|---|---|
| PostToolUse(Edit\ | Write\ |
| PostToolUse(Bash) | 回 master + 乾淨就停錄 |
| git post-commit | 每個 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()
三道閘:手動啟動的不停,feature branch 上不停,樹髒不停。
掛在 Bash 上而不是 Edit 上的原因:合併回 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,按行解析,分類提取:
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% 的 bash 是 ls / cat / grep / head——文章用不到,全過濾掉。剩下的(跑測試、跑 kamal、跑 runner、curl 接口)才是有故事的命令。
使用者 prompt 裡 <command-*> 和 <system-*> 包裹的全部丟掉,只留真實輸入。Edit/Write 的路徑去重存 set。報錯只留前 400 字。Task 子 agent 呼叫保留(prompt 截到 2000 字元,看清子 agent 幹了啥足夠)。
Stop hook 不是一個 recording 週期觸發一次,是每次 session 結束都觸發。同一個 feature branch 上你可能開關 Claude Code 五六次,如果每次都 re-parse 整個 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 這一行讓自動停止失效。適合:在 master 上寫一次性腳本但還想留記錄、跨多個 branch 的大功能、明確「這是要寫文章的」。
/stop-recording:手動跑一次 final extraction(最近一個 jsonl),清掉 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:「要找錢包位址那個 bug 是哪個 commit?」→ eba9ac9。「那次重構子 agent 的 prompt 是怎麼寫的?」→ 在 ### Sub-agent invocations 裡。
docs/notes/ 在 .gitignore 裡——是給你寫文章用的草稿料,不是原始碼。
1. branch 作為 feature 邊界,而不是另搞一套元資料。 使用者已經在用 branch 切分工作,多套一層「功能名」必然 drift。複用現有邊界,0 額外認知負擔。
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 自動記,0 邊際成本。這套 pipeline 裝好之後,你只要正常切 branch 寫程式,文章素材自己就堆出來了。
本文引用的 commit、bash 命令、錯誤片段、檔案路徑,全部來自 docs/notes/pro/raw.md——這份草稿料,是從兩天前切 feature/pro 的那一刻起 hook 自己記下來的。等到要寫文章才打開看,發現該有的都有。
讓 Claude 寫文章關於 Claude Code,最好的方式是先讓 Claude 寫 hook 記錄自己。