写实战文章最难的是凑素材: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 记录自己。