昨天那個測試護欄擋的是「它做得對不對」。今天要擋的是另一種東西 —— 它做得對,但你還沒同意。
這兩件事完全不同。測試全綠只能說 code 沒問題;它不知道你 review 了沒,也不知道這個 commit 該不該進 main。
大部分人把 AI 能做的事分成兩堆:可以的、不可以的。實際上還有第三堆,而且它是最重要的一堆。
| 等級 | 操作 | 為什麼 |
|---|---|---|
| ✅ 放手 | git status / diff / log / add / commit;push 到 feature 分支、開 PR |
可逆。commit 錯了可以 reset;PR 可以關、分支可以刪,什麼都還沒進 main |
| 🟡 要問 | git checkout <branch> / merge / rebase / stash |
可逆,但會改變你眼前的工作狀態。你正在看的檔案突然變了,比 code 寫錯還難察覺 |
| ❌ 絕不 | push main / push --force / gh pr merge / reset --hard / clean -fd |
不可逆,或沒經過人 review 就進 main。兩種性質沾到一個就進這一格 |
怎麼分?一句話:
可逆的放手,改變眼前狀態的要問,不可逆或沒經人 review 就進 main 的絕不。
注意分界線不是「有沒有跨出本機」。push 到 feature 分支也跨出本機,但它可逆 —— PR 本身就是 review 的關卡,AI 開 PR 不等於 AI 決定合併。真正要守的那條線是 main:進去的 commit 會進別人的 clone、進 CI、進 PR 通知。這個系列在講的「不驗過就別收」,在 Git 上的具體形式就是「不經 review 就別進 main」。
這一段可以直接照抄。在專案的 .claude/settings.json:
{
"permissions": {
"deny": [
"Bash(git push origin main:*)",
"Bash(git push --force:*)",
"Bash(git push -f:*)",
"Bash(gh pr merge:*)",
"Bash(git reset:*)",
"Bash(git clean:*)"
],
"ask": [
"Bash(git checkout:*)",
"Bash(git merge:*)",
"Bash(git rebase:*)",
"Bash(git stash:*)"
],
"allow": [
"Bash(git status:*)",
"Bash(git diff:*)",
"Bash(git log:*)",
"Bash(git push origin feature/*)",
"Bash(git push -u origin feature/*)",
"Bash(gh pr create:*)"
]
}
}
三個欄位的差別:deny 直接拒絕,ask 每次跳出來問你,allow 不問直接放行。**判定順序是 deny → ask → allow,先中的先算,而且「比較具體」不會贏。**一條寬的 deny 會蓋掉一條窄的 allow —— 所以 deny 裡不能留例外。
⚠️ **注意我寫的是
Bash(git reset:*),不是Bash(git reset --hard:*)。**規則比對的方式是「*之前的每個字照字面比」,所以git reset --hard HEAD~1擋得住,而git reset HEAD~1 --hard一條規則都沒中 —— 同一件事換個寫法就繞開了deny,掉進「沒有規則命中」那一格(下面會驗)。列選項不如列子指令;連--soft一起擋的代價,遠小於留一個洞。
還有兩個更細的::* 和結尾的 * 不一樣。Bash(git push origin feature/:*) 的意思是「前綴到此為止,後面接空格再任意」,所以它對不到 feature/due-at;要對到分支名,得寫 Bash(git push origin feature/*)。官方文件的例子講的就是這件事:Bash(ls *) 對不到 lsof,Bash(ls*) 才對。旗標也會斷前綴:它習慣下 git push -u origin …,-u 一插進來,git push origin feature/* 就對不到了 —— 上面 allow 列兩條就是這麼來的。
為什麼要寫進檔案,而不是「我記得不要讓它推」?
因為自律是有狀態的。你在第三天記得,第十七天趕著發文的時候不會記得。而 settings.json 沒有狀態 —— 它在第十七天跟第三天一樣有效。
而且它不是只做字串比對 —— 它懂 shell:
deny和ask規則只要任何一個子指令中了就會生效,包含藏在子 shell、命令替換、甚至for迴圈裡的。
也就是說 cd /tmp && git push origin main、echo "$(git push origin main)" 這種寫法一樣擋得住。這是「寫進設定」跟「寫進 CLAUDE.md」最實際的差別 —— 前者是執行環境在比對整棵指令樹,後者只是一句它讀過的話。
這跟 Day 6 那份 CLAUDE.md 是同一個思路,但有一個關鍵差別值得說清楚:
**CLAUDE.md 是講給 AI 聽的,
settings.json是講給執行環境聽的。**前者它可以「不小心忘記」,後者它做不到。
規則寫在哪一邊,決定了它是建議還是規則。
這份檔案值得 commit 進 repo,而且有一個剛好對我們有利的性質:
| 什麼時候生效 | |
|---|---|
deny / ask |
clone 下來就生效 |
allow |
要等每個人信任這個資料夾之後 |
換句話說,擋人的規則是立即的,放行的規則才要等信任。對 ❌ 那一格來說,這個順序剛剛好。這一格我後面撞到了證據:乾淨 clone 從沒開過互動 session,deny 照擋,allow 全部不認 —— 要在指令列用 --allowedTools 再放行一次才推得出去。
設定檔寫完不算完,要驗。我用乾淨 clone 放上這份 settings.json,claude -p 一次叫它跑一條指令,看它拿到什麼回覆:
| 我叫它跑 | 中哪條 | 它拿到的回覆 |
|---|---|---|
git push origin main |
deny |
Permission to use Bash with command … has been denied. |
git push --force origin main |
deny |
同上 |
gh pr merge 1 --squash |
deny |
同上 |
cd /tmp && git push origin main |
deny(子指令) |
同上 |
echo "$(git push origin main)" |
deny(命令替換) |
同上 |
git checkout -b probe-branch |
ask |
Claude requested permissions to use Bash, but you haven't granted it yet. |
git stash |
ask |
同上 |
git push origin HEAD:main |
一條都沒中 | This command requires approval |
git status |
allow |
直接跑,回 On branch main … |
三種回覆的句子不一樣,那才是要看的東西:「has been denied」是 deny 中了;「haven't granted」是 ask 中了;「requires approval」是什麼規則都沒中,掉進預設流程。在 -p 裡三種都跑不了,所以光看「它沒跑」分不出來 —— 要看句子。
reset 那條也驗了:把 deny 裡的 Bash(git reset:*) 換成窄的 Bash(git reset --hard:*),再跑兩條 ——
| 我叫它跑 | 中哪條 | 它拿到的回覆 |
|---|---|---|
git reset --hard HEAD~1 |
deny |
… has been denied. |
git reset HEAD~1 --hard |
一條都沒中 | This command requires approval |
窄規則放走了換順序的寫法;它不是被規則擋下,是沒有規則命中、又剛好沒人可問。
上面那張表倒數第二列是同一件事的另一個樣子:git push origin HEAD:main 進的還是 main,但字面上沒有 git push origin main,deny 對不到。規則永遠有寫不全的一天 —— 所以下一節要把裁判放到規則外面。
而「掉進預設流程」會發生什麼,看模式,而且現在的預設模式變了:Pro / Max / Team 的起始模式已經是 auto mode —— 沒中規則的指令不是問你,是交給一個分類器模型審。官方文件把三件事寫死:deny 在所有模式都擋,連 bypassPermissions 也擋;ask 在所有模式都一定停下來問;沒中規則的,Manual 問你、auto 交給分類器、-p 視同拒絕。所以 deny 清單要寫全 —— 它是三格裡唯一不看模式的那一格。
settings.json 擋的是「這台機器上的 Claude Code」。你自己手滑、別的工具、換一台電腦,它都管不到;而且上面才證明過規則有洞。「不經 review 就別進 main」這條,真正的裁判在 GitHub 那一端。
Settings → Branches → Add classic branch protection rule,pattern 填 main,三個勾:
設完我從乾淨 clone 直接 git push origin main 一次:
remote: error: GH006: Protected branch update failed for refs/heads/main.
remote: - Changes must be made through a pull request.
! [remote rejected] main -> main (protected branch hook declined)
這行拒絕跟 settings.json 的拒絕來源不同:前者是 server 端,誰來推都一樣;後者是本機的規則。前一節那條 HEAD:main 穿過了 deny,到這裡還是會被擋。
順手兩個設定,在 Settings → General → Pull Requests:合併方式只留 Squash and merge(AI 開的 PR 常是一串小 commit,squash 後進 main 就是一個);勾 Always suggest updating pull request branches(分支落後 main 時 PR 頁直接給按鈕)。這兩個是方便,不是裁判。
有了上面那兩層,就可以放手讓它做一整件事 —— 從空分支到開出 PR,人只在最後一步出現。
# 開一個獨立的工作目錄 + 分支,原本的目錄完全不動
git worktree add ../library-loan-due-at -b feature/due-at
cd ../library-loan-due-at
# 讓 AI 在那邊工作:寫測試、實作、commit、push、開 PR(完整 prompt 在 raw 的 flow-prompt.txt)
claude -p "實作 dueAt(now, loanDays)…先寫紅測試…commit…push 到 feature/due-at…gh pr create…不要碰 main,不要 merge"
# 它開完 PR,你回 GitHub review;不要了就整個丟掉
git worktree remove ../library-loan-due-at
worktree 跟開新分支的差別在於:它多了一個實體工作目錄。git checkout 是把同一個目錄的內容換掉,你正在編輯的檔案會在腳下變形;worktree 是另一個資料夾,你這邊完全不受影響。失敗的成本變成一行 git worktree remove。
跟 Day 4 那三級擺在一起,worktree 比第一級還輕 —— 它跟主目錄共用同一個 .git 和所有設定,隔的只有工作目錄。所以它擋的不是 AI,是你自己正在編輯的檔案不被弄亂;要擋 AI,還是上面那兩層。
題目是圖書館練習的下一支 domain 函式 dueAt(now, loanDays)。它讀了規格,先寫 9 題紅測試,再實作,15 題全綠後 commit —— 然後在 push 那一步停下來:它下的是 git push -u origin feature/due-at,我的 allow 規則沒料到 -u,前綴一斷就落到 ask,它拿到「haven't granted」,老實回報「非互動模式沒人可問,我停在這裡」,沒有繞。接著又撞到 feature/:* 對不到分支名、allow 在沒信任過的資料夾不生效 —— 練習一那幾個坑全是這一趟踩出來的。規則改對、--resume 接回同一個 session,它推上去、開了 PR:
https://github.com/n913239/library-loan-lab/pull/1
我看 diff:2 個新檔、domain 沒有 Date.now()、測試涵蓋 14 天/帶毫秒/不讀時鐘。留一句 LGTM。本機看過 diff…npx vitest run 15/15,Squash and merge。叫它 gh pr merge 試一次 —— has been denied。最後一步只能是人,這不是靠它自律,是 deny + branch protection 兩層。
第二支函式 book-state.js(書的狀態機),同一條流程,PR #2。這次我照規格逐行對,有兩處該退:
node_modules,npx 抓了快取裡的版本,repo 要的是 ^3.2.4。它自己在 PR 內文老實寫了,但接著說「CI 用 lock 版本跑一次就能確認」—— repo 沒有 lock 檔、也沒有 CI,那是不存在的裁判。IllegalTransitionError。理由合理,但照 Day 9 的做法,這個決定要補進 spec.md,不然是 AI 的行為不是規格的行為。行內留 comment、總評寫 blocking:。一件事要先知道:AI 用你的身分開 PR,GitHub 就把你當作者,Request changes 和 Approve 兩個鈕都按不了,只能用 Comment —— 要讓「駁回」在系統層成立,AI 得用自己的身分(GitHub App 或 bot 帳號),那是團隊版的事。
然後讓它回同一個 worktree 處理:npm install 後 vitest 3.2.7 重跑 28/28、版本與輸出貼回 PR、spec.md 補一列、同一分支再 commit 一次(不 force push、不開新 PR)、逐條回覆 reviewer 的原話、把 PR 內文裡已經不對的句子改掉。

它還多回報一件我沒問的:npm install 生出的 package-lock.json 它沒 commit,因為 CLAUDE.md 說不主動加東西,「lock 檔要不要進 repo 是另一個決定,你說了算」。這是對的。
我 resolve 那條 thread、留 LGTM、Squash and merge。時間軸長這樣:

一個 PR、兩個 commit,squash 後 main 上只留一個。討論留在同一頁,歷史乾淨。這一趟的重點不在它修得對不對,在它被退的兩件事都是「綠燈以外的東西」 —— 版本不對的綠燈、規格沒寫的決定 —— 測試抓不到,只有人拿規格對著 diff 看才抓得到。
merge 之後畫面上有一顆 Revert:按下去 GitHub 會開一個反向的新 PR,再 review、再 merge,main 就退回去 —— 歷史留著、不用 reset --hard、不用 force push。這就是「PR 可逆」的可逆之處。
讓 AI 寫 commit message 很方便,但預設會在尾巴加一行 Co-Authored-By: Claude …。我的文章 repo 有固定格式,這行會破壞它,而且它是一行一行累積的 —— 發現時已經一串了。當時我不知道有設定可以關,就寫進 CLAUDE.md。回頭數兩個 repo、66 個 commit,0 個帶署名 —— 它有效。但它放錯層了:每開一個新 repo 都要再寫一次,而且那個 0 沒有裁判,是「還沒破」不是「不會破」。
{
"attribution": {
"commit": "",
"pr": ""
}
}
放進 ~/.claude/settings.json,所有專案一次解決。但這裡有個「你以為設好了」的坑,我實測了三種寫法:
| 你寫的值 | commit message 尾巴 |
|---|---|
| 不設定 | Co-Authored-By: Claude Opus 5 … |
false(布林) |
原樣簽名 |
"false"(字串) |
多一行 false |
""(空字串) |
什麼都沒有 ✅ |
那個欄位不是開關,是內容。 "false" 的意思是「把簽名改成 false」;false 是型別不符,被靜默忽略 —— 設定檔看起來很對,行為完全沒變,你要去翻 commit 才會發現。官方文件頁當時寫的是 false,照著寫兩次都沒生效;去翻 JSON schema 才看到 "type": "string"。文件跟 schema 不一致的時候,schema 才是執行的那一份。(9/23 回頭再查,那一頁已經改成 Type: string 了 —— 不一致修掉了,但當時踩到的是真的。)
commit-msg hook設定管得住 Claude Code,管不住你自己貼上去的、或別的工具加的。所以我還寫了一支 commit-msg hook:用 grep 攔 Co-Authored-By / Generated with 這類 trailer,中了就 exit 1,四筆測資都對。但它擋得住手滑,擋不住 --no-verify —— 防的是手滑,不是惡意。最後沒裝:我這幾個 repo 只有我一個人 commit,設定那層已經驗過有效,hook 的維護成本大於它擋的殘餘風險。
| 層 | 我怎麼做的 | 結果 |
|---|---|---|
CLAUDE.md |
兩個 repo 各寫一次 | 有效,但每個新專案都要重寫 |
settings.json |
attribution.commit: "" |
一次設定,全部專案生效 |
commit-msg hook |
寫了、驗了、沒裝 | 擋得住手滑,擋不住 --no-verify;設定夠用時不需要 |
**先找設定,找不到才寫規則,寫了規則還擋不住才上 hook。**順序搞反的代價不是做白工,是你以為擋住了。
Day 6 到今天,護欄已經有三層了:
| 層 | 檔案 | 擋什麼 |
|---|---|---|
| 意圖 | CLAUDE.md |
它該怎麼做 |
| 正確性 | 測試 + CI | 它做得對不對 |
| 權限 | settings.json + branch protection |
它做得到什麼 |
前兩層可以被「它忘了」或「它繞過去了」破掉。第三層不行 —— 它不靠 AI 配合,所以真正不能出事的東西要放在那裡。而第三層自己又分兩邊:settings.json 是本機的規則,有寫不全的一天(HEAD:main 那條);branch protection 在 server 端,誰來推都一樣。今天的 settings.json 管跑什麼指令,Day 4 的 /sandbox 管寫到哪裡 —— 都不靠 AI 配合。
有了這三層,工作流就能反過來寫:開 PR 之前的事全交給它,進 main 那一步留給人。 worktree 開分支 → 它寫測試、實作、commit、push、開 PR → 你拿規格對著 diff 看 → 該退就退,它在同一分支修 → 你 merge。兩趟跑下來,它被退的都是綠燈以外的東西,這正是人該站的位置。
但第三層有它自己的失敗方式,而且更安靜:你以為你設好了。
attribution 那個 false 是一種 —— 型別不符、靜默忽略、設定檔看起來完全正確。feature/:* 對不到分支名是一種。allow 在沒信任過的資料夾裡不生效又是一種。所以三層護欄後面還有一句:
每一層都要驗過。 前兩層驗「它有沒有照做」,第三層驗「這條規則有沒有真的生效」—— 故意做一件該被擋的事,看它擋不擋。
我這一篇裡的每一格都是這樣驗出來的,包括我自己寫錯的那幾格。
這一篇留下的心法:
哪些讓 AI 做,不是靠交代,是靠設定檔來擋;擋了還要故意做一件該被擋的事,看它擋不擋。分界線是
main,不是本機與網路:它一路做到開 PR,合併那一步留給人;這條線要靠 server 端的裁判,不是靠它自律。
明天:把每天要講三次的話寫成一個檔案 —— 哪些話值得做成指令、一條可以直接抄走的 /commit,和一條給健忘的人的 /mike。
dueAt,一趟到底)、PR #2(book-state,退一次再合);「故意做一件該被擋的事」每一條與兩趟 PR 的 claude -p 原始輸出、每一版 settings.json:devlog/raw/exp-day10-perm(Claude Code 2.1.278,2026-09-21)allow / ask / deny 語法、:* 與 * 的差別、什麼寫法對不到)、Permission modes(auto mode 是 Pro / Max / Team 的起始模式;deny 在所有模式都擋、ask 在所有模式都問)、All settings(attribution 的型別與各子鍵);2026-09-23 查git worktree 官方文件:git-scm.com/docs/git-worktree