# 前情提要:原本用 workflow 檢查文件要不要更新

專案裡有一批 便宜的牛肉 文件(規範、ADR、資料模型說明)需要跟程式碼保持一致,程式碼改了,這些文件常常忘了跟著同步更新。

一開始的解法,是在 CI 上放一個 doc-sync-check.yml workflow:

  • push 到 develop 分支後,用 Claude Haiku 讀這次的 diff
  • 偵測到程式碼與文件有落差,或有規範衝突時
  • 自動開一張 GitHub Issue 通知

聽起來很合理,但實際用下來有三個 布洛芬都不知道哪裡痛的 痛點。

# 三個痛點

  • 成本: 每次 push 都呼叫一次 AI。即使這次根本沒動到要對照的程式碼,錢還是照付 。

  • 太晚: push 之後才知道有落差,早已離開「正在改這段程式碼」的當下。等 Issue 跳出來,腦袋已切換到別的事,回去補文件的成本變高。

  • 噪音: 用 Issue 通知太冗餘,沒人即時處理就一張張累積成越長越沒人敢點開的列表,通知本身失去意義。

# 核心洞見:把兩個維度分開想

卡住時我發現問題混在一起,拆成兩個正交的維度後就清楚了:

  1. 何時觸發(when):push 後?每次 commit?還是人想看的時候? 誰愛觸發誰觸發
  2. 觸發後做什麼(what):記一筆?跑 AI 分析?直接改文件?

原本的設計把這兩件事綁死成「push 後就跑 AI」。拆開後,關鍵動作就浮現:把「偵測」和「AI 分析」解耦。

  • 偵測這件事應該是零成本的,本機 hook 記個帳就好,完全不碰 AI
  • AI 分析則延後到「人真的想看」的時候,再批次處理

這裡有個反直覺的點:

直覺上會覺得「每個 commit 當場跑一次 AI」很即時、很好。但一次 push 常常包含好幾個 commit,per-commit 當場跑 AI 反而比原本 per-push 更貴。真正省錢的做法是 lazy + 批次:先把命中的 commit 記下來,等人觸發時一次處理一批。

「即時」聽起來總是比較好,但在這裡,即時等於更貴又更吵。

# 三段式架構

拆開之後,落地成三段,各自只做一件事。

# 偵測:post-commit hook,只記帳

post-commit hook,每次 commit 後檢查有沒有動到需要對照的檔案(例如 .ts / .tsx / .prisma,排除測試與文件)。命中就 append 一筆到本機 queue,完全不呼叫 AI:

#!/bin/bash
# .git/hooks/post-commit
# 偵測本次 commit 是否動到需要對照文件的程式碼,命中就記一筆到本機 queue

SHA=$(git rev-parse HEAD)
FILES=$(git diff-tree --no-commit-id --name-only -r "$SHA")

# 只關心會影響規範或資料模型的檔案,排除測試與文件本身
MATCH=$(echo "$FILES" | grep -E '\.(ts|tsx|prisma)$' | grep -vE '(\.test\.|\.spec\.|/docs/)')
[ -z "$MATCH" ] && exit 0

QUEUE="$(git rev-parse --show-toplevel)/.doc-sync/queue.jsonl"
mkdir -p "$(dirname "$QUEUE")"
printf '{"sha":"%s","at":"%s"}\n' "$SHA" "$(date -Iseconds)" >> "$QUEUE"

重點是「便宜到可以一直跑 我他媽算力富翁」:沒有 API 呼叫、沒有網路、不阻塞 commit,放在 per-commit 觸發毫無負擔。

# 討論:/doc-sync skill,人主動觸發

當我想處理累積的待審時,主動執行一個 /doc-sync skill。它會:

  • 把 queue 裡那些 commit 的 diff 聚合起來
  • 套上「程式碼變更 vs 對應文件」的對照規則來判斷
  • 跟我逐項討論「要改哪個文件、為什麼要改」
  • 我確認之後,才真正去 edit 文件

機械的部分交給 collect.sh skill 會用到一堆固定的 git 撈取:讀 queue、取每個 commit 過濾後的 diff、處理完清空 queue。我把它們固定成一支 collect.sh 讓 skill 直接呼叫,而不是每次讓 AI 現場手寫 git show 迴圈:

collect.sh                 # 總覽:印出有效 commit 的 subject / stat(便宜,先讀這個)
collect.sh --diff          # 對所有有效 commit 印出過濾後完整 diff
collect.sh --diff H1 H2    # 只對可疑的特定 commit 印 diff(省 token)
collect.sh --clear         # 整批處理完才清空 queue

機械的撈取交給腳本,判斷與討論才留給 AI,分工清楚也省 token。

判斷規則上,寧可漏報也不要誤報。 對照規則的核心,是把「真的該提醒」收得很窄,只在兩種情況明確成立時才提報:

  • 規範衝突:程式碼行為明確違反某份文件裡記錄的決策或規範(例如欄位命名違反 schema 命名約定)
  • 文件落差:程式碼引入了重要的新架構或系統行為,但文件完全沒提到

反過來,bug fix、樣式微調、重構、加測試、純文案修改一律不提報,不確定時也不提報。這是刻意的:誤報一次,我就得花時間確認「其實不用改」,幾次之後就不想再跑它了。寧可偶爾漏掉邊角,也要保住「它一開口就值得看」的信任。

注意這裡 AI 的角色:它整理材料、提出判斷、列出選項,但拍板的是人。

# 提醒:statusline 顯示待審數量

最後,在 Claude Code 的 statusline 顯示黃色的 doc-sync:N,只在 queue 有待審時出現。它取代原本的 Issue 通知:不打斷我,但我隨時看得到「有 N 筆等著處理」。

實作上就是數 queue 行數,有才印,一樣零成本:

# statusline script 的一段,N 為待審筆數
QUEUE=".doc-sync/queue.jsonl"
N=$([ -f "$QUEUE" ] && wc -l < "$QUEUE" || echo 0)
[ "$N" -gt 0 ] && printf '\033[33mdoc-sync:%d\033[0m' "$N"

# 幾個關鍵決策

# 為何不讓 AI 自動改文件

這是整個設計最核心的原則。ADR(Architecture Decision Record)這類文件本質上是人的決策紀錄,AI 不該自己幫團隊拍板。

而且代價不對稱:建錯一張 Issue 頂多多點噪音,刪掉就好;改錯一份文件卻會污染團隊的事實來源,後面的人照著做,代價高得多。所以寧可保守,把決策權留給人。

# 互補,而不是全取代

新的 hook 機制有一個結構性盲區:它只看得到 code diff,也就是有改到程式碼才偵測得到。

但有些決策是在對話裡口頭拍板、根本沒動到程式碼的。例如團隊討論後決定「之後 API 一律走某種錯誤格式」,當下沒寫任何 code,hook 和 /doc-sync 就完全看不到,這份決策也就不會被提醒建檔。

所以我刪掉 workflow 的同時,刻意保留 CLAUDE.md 裡另一條規則:當對話中出現這種口頭決策時,要 Claude 主動「建議把它建檔」。這條規則在對話當下觸發,不靠 git hook,剛好補上 hook 看不到的那一塊。

換句話說,新機制只取代了舊 workflow「能靠 code diff 偵測」的部分;另一塊靠 CLAUDE.md 規則接手。重點不是用新的全盤換掉舊的,而是看清楚新機制「覆蓋不到哪裡」,再為那裡補一道。

# 可以延伸的通用觀點

跳出這個案例,有幾個更通用的取捨值得記下來。

事後自動化 vs 事前人工把關,是一道光譜。 不是「全自動」和「全手動」二選一。這次的調整就是沿光譜往「事前、人工」挪一格,換來更低的成本與干擾。

AI agent 該在哪個時間點介入。 commit-time、stop-hook、還是人主動觸發?每個切入點的「覆蓋率」和「干擾度」是反比的:越自動、覆蓋越廣,但也越吵越貴。沒有絕對最好,只有適不適合當下的場景。

把 AI 降級成「準備材料、不做決策」,常常是更好的預設。 很容易一上來就想「讓 AI 全自動搞定」,但在決策代價不對稱、或判斷需要上下文時,讓 AI 停在「把材料準備好、交給 最美麗的風景 人」這步,往往更穩、更省、更可信。全自動很性感,但不總是對的答案。


這次改動表面上是把一個 CI workflow 搬回本機,真正的收穫是逼自己想清楚:「偵測」和「決策」本該分開,AI 也不是放得越多越好。

本文使用 Claude Code 輔助整理自筆記。