Free

Để Claude viết hook tự ghi lại session của chính mình

Khó nhất của bài thực chiến là gom tư liệu: git log chỉ có commit message, chi tiết trong session mất sạch. 4 hook + 2 slash command biến mỗi session thành raw.md — chi phí cận biên bằng 0.


Phần khó nhất khi viết bài "Claude làm X cho tôi" không phải viết — mà là gom tư liệu. git log chỉ có commit message; những phương án bạn loại bỏ, những lần thử thất bại, ba lần sửa lại prompt đó, output lỗi — đó mới là phần làm bài viết đáng đọc, và đã mất hết.

Tệ hơn: lúc bạn viết bài, bạn không còn nhớ vì sao chọn A thay vì B nữa. "Tôi nhớ là có lý do" với "lý do đó là X" — chênh nhau cả mức độ tin cậy của bài.

Ghi biên bản tốn hơn viết bài, nên bạn không ghi. Cách duy nhất: để việc ghi lại tự xảy ra — hook.

Tôi để Claude viết 4 hook + 2 slash command, biến mỗi session dev thành docs/notes/<feature>/raw.md. Bài trước, Để Claude deploy lên production, lấy phần lớn commit, snippet bash, đoạn lỗi từ pipeline này. Bài này cũng vậy.


Toàn cảnh pipeline

4 hook + 2 slash command thủ công ghép thành một dây chuyền:

Trigger Làm gì
PostToolUse(Edit\ Write\
PostToolUse(Bash) Về master + sạch thì dừng ghi
git post-commit Mỗi commit ghi một checkpoint
Stop Phân tích transcript khi session kết thúc
/record-feature NAME /stop-recording Ghi đè thủ công

Một file state duy nhất: docs/notes/.state.json. Mọi hook đọc ghi lên đó. Không có sự phối hợp nào khác.

Hook #1: khi nào bắt đầu

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

Lõi của maybe-start:

def cmd_maybe_start():
    if load_state(): return          # đang ghi rồi
    if not tree_is_dirty(): return   # chưa đụng gì, bỏ qua
    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") trả về "pro". Dev đã quen tách công việc theo branch, vậy thì tái dùng ranh giới tư duy đó — lý do căn bản pipeline này sống được là không bắt người dùng nhớ thêm gì mới. Trên master, script một lần dùng fallback sang timestamp.

Vì sao matcher là Edit/Write/MultiEdit: viết code mới là tín hiệu thực sự "đang làm gì đó". Đọc file, chạy test, hỏi câu hỏi — không tính.

Hook #2: khi nào dừng

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

Ba cửa: ghi thủ công không dừng, đang trên feature branch không dừng, cây bẩn không dừng.

Vì sao móc vào Bash thay vì Edit: sau khi merge về master thường bạn không Edit nữa, nhưng chắc chắn sẽ chạy git status / git log / bin/rails test — bất kỳ lệnh nào cho hook cơ hội nhận ra "đến lúc kết thúc rồi".

Cờ auto_started là điểm tựa. Nếu bạn /record-feature pro-launch thủ công và đi qua nhiều branch nhiều lần merge, luật auto-stop sẽ giết nhầm giữa chừng. Ghi thủ công không bao giờ tự dừng — chỉ /stop-recording.

Hook #3: commit chính là checkpoint

Không phải Claude Code hook — đây là .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 ghi nguyên commit message vào 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
> ...

Vì sao commit message quan trọng: nó là bản tóm tắt viết tay mà bạn (hoặc Claude) tạo ra ngay khi feature vừa xong và ngữ cảnh còn đầy đủ. Chính xác hơn nhớ lại sau, ngắn hơn transcript, trừu tượng hơn diff.

Khi Claude viết commit message, thực chất đang viết tư liệu tóm tắt cho bài viết tương lai của bạn — bạn chỉ cần nó viết tốt từ đầu, không cần viết lại sau.

Hook #4: trích xuất sự kiện từ transcript

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

Khi session kết thúc, Claude Code truyền transcript_path cho hook qua stdin JSON. extract-session-notes mở jsonl đó, parse từng dòng, phân loại:

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

Chỉ bash trong whitelist mới được giữ. 90% bash trong session code là ls / cat / grep / head — bài viết không xài, lọc hết. Còn lại (chạy test, kamal, rails runner, curl API) mới là lệnh có câu chuyện.

Prompt user bọc trong <command-*> hay <system-*> bị bỏ — chỉ giữ input thật. Đường dẫn Edit/Write dedup vào set. Output lỗi cắt ở 400 ký tự. Lời gọi sub-agent Task được giữ (prompt cắt 2000 ký tự, đủ để thấy sub-agent làm gì).

Một bản ghi, nhiều Stop: dùng cursor

Stop hook không kích hoạt một lần mỗi bản ghi — nó kích hoạt mỗi lần session kết thúc. Trên cùng feature branch bạn có thể mở đóng Claude Code năm sáu lần. Nếu mỗi Stop re-parse cả transcript và ghi tất, raw.md chìm trong dữ liệu lặp.

Cách giải: nhét cursor last_extracted_at vào state file:

filter_after = state.get("last_extracted_at") or state.get("started_at")
events = extract_events(transcript_path, filter_after)
# ...sau khi ghi...
state["last_extracted_at"] = datetime.now().astimezone().isoformat()
save_state(project_dir, state)

Mỗi lượt chỉ lấy sự kiện sau cursor. Đơn giản, nhưng quên là hàng trăm dòng trùng đổ ra.

Ghi đè thủ công: hai slash command

/record-feature NAME:

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

Dòng auto_started: false vô hiệu hóa auto-stop. Trường hợp dùng: viết script một lần trên master nhưng vẫn muốn để dấu, feature trải qua nhiều branch, hoặc nói rõ "cái này để viết bài".

/stop-recording: chạy một lần trích xuất cuối với jsonl mới nhất, rồi xóa state file.

raw.md trông ra sao

Đoạn thật (từ 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)

Khi viết bài: chỉ cần grep. "Commit fix bug wallet address là gì nhỉ?" → eba9ac9. "Tôi đã prompt sub-agent refactor thế nào?" → dưới ### Sub-agent invocations.

docs/notes/ nằm trong .gitignore — là tư liệu nháp cho viết lách, không phải source code.

Ba quyết định thiết kế không hiển nhiên

1. Branch làm ranh giới feature, không dựng hệ metadata riêng. Dev đã chia việc theo branch; thêm một lớp "tên feature" chắc chắn drift. Tái dùng ranh giới có sẵn, không thêm tải nhận thức.

2. Whitelist bash, không blacklist. 95% bash trong transcript là nhiễu. Duy trì danh sách "đáng giữ" ổn định nhiều năm; duy trì danh sách "cần lọc bỏ" thì không.

3. Cursor sống trong state file, không trong transcript. Transcript thuộc Claude Code; state thuộc pipeline. Tách rời — Claude Code đổi format transcript không phá tôi, và tôi viết lại logic trích xuất không cần đụng transcript.

Đóng vòng

Viết một bài 1500 chữ ngốn tư liệu cỡ 30 commit, 4 session, hơn chục lệnh bash chính. Gom thủ công khoảng một giờ — đủ để lần sau bạn cắt xén.

Để hook làm: chi phí cận biên bằng 0. Cài đặt một lần rồi cứ làm bình thường — tách branch, viết code, deploy — tư liệu bài viết tự chồng lên.

Mọi commit, snippet bash, dòng lỗi, đường dẫn file trong bài này đều đến từ docs/notes/pro/raw.md — tư liệu nháp mà hook đó tự ghi từ lúc tôi tách feature/pro hai ngày trước. Khi cần viết, tôi mở file ra và mọi thứ cần thiết đều có.

Cách tốt nhất để Claude viết bài về Claude Code là trước tiên để Claude viết hook ghi lại chính nó.