
為不屬於我的儲存庫寫一個 Claude Code 審查 skill — 自我審查、PR 內文與差異 hunk
把 Claude Code 的發布 skill 移植到一個我無法合併也無法打標籤的儲存庫:什麼被砍掉、為什麼 git diff HEAD 讓審查能在提交前跑、忽略規則該放在哪裡。
本頁目錄
引言
我在這個部落格上有一個 /release skill,它能在我不碰鍵盤的情況下跑完整套發布:開一個 GitHub issue、切分支、驗證、審查差異、開 pull request、squash 合併、對 main 打標籤,然後發布 release notes。上週我想在公司的 monorepo 上伸手拿同一套東西,卻在第一步就停住了。那個儲存庫屬於組織,不屬於我。我不會合併自己的工作,不會打標籤,發布節奏也是別人的決定。
有用的發現是,砍完之後留下來的並不是一個比較小的發布 skill。它是另一種產物,因為它產出的東西不再是一次合併,而是一份同事在批准任何事情之前會讀的交接文件。本文將整理哪些步驟被拿掉、什麼取代了它們,以及在所有權消失之後才浮現的三個設計決定。
發布 skill 假設自己擁有這個儲存庫
我部落格的發布 skill 會跑完橫跨四個階段的十七個編號步驟。攤開來看,其中多數與其說是工程,不如說是所有權的主張。
| 步驟 | 它需要什麼 |
|---|---|
| 建立 GitHub issue、指定里程碑 | issue 的寫入權限,以及一套屬於我的里程碑慣例 |
更新 package.json 的版本號 |
決定版本號的權限 |
把 CHANGELOG.md 的 [Unreleased] 升級 |
決定變更紀錄格式的權限 |
| squash 合併 pull request | 預設分支的合併權限 |
對 main 打標籤並推送標籤 |
ref 的寫入權限 |
| 建立 GitHub release | 發布的權限 |
| 附上 released-in 留言並關閉 issue | 再一次的 issue 寫入權限 |
十七個步驟裡有七個,在還沒考慮任何程式碼之前就消失了。 剩下的十個才是真正在做事的:讀取目前狀態、驗證、審查、開分支、提交、推送,以及回報。
發布 skill 花了整整一節去辯護的標籤順序規則,是個好例子。它存在的原因是 squash 合併會把差異當成 main 上的一個新提交重播,並丟掉原本的分支,所以在合併前建立的標籤會指向一個不是 main 祖先的提交,git describe 再也看不到它。那確實是個細微的陷阱,而它對一個我從不打標籤的儲存庫完全無關。 發布 skill 那些辛苦換來的知識大多是這個形狀:正確、學費昂貴,而且範圍僅限於我掌控的儲存庫。
砍掉那些需要別人寫入權限的步驟
留下來的東西分成六個階段:讀取目前狀態、自我審查差異、確認遠端有沒有動過、驗證,接著是分支與提交與推送,最後撰寫審查用文件。最後這個階段在發布 skill 裡根本不存在。
在我自己的儲存庫上,一次成功執行的產出是一個標籤和一份已發布的 release。在團隊的儲存庫上,產出是兩份 Markdown 文件:一份 pull request 內文,以及一份逐個 hunk 解釋差異的說明。我給了代理人兩份來自過去 pull request 的真實範例,並要它把那兩份當成格式規格,而不是自己發明一套範本,因為那些文件已經有一個通過審查考驗的形狀。依閱讀順序共五節:問題與追溯到的根本原因、一張列出實際跑了什麼的驗證表、依變更而非依檔案整理的工作內容、這次變更的代價,以及補充說明。
skill 在 git push 之後就停下來。 它從 git remote get-url origin 組出比較用的 URL 並交出去,而且從不呼叫 gh。pull request 由我自己開,這不是我繞過的限制,而是在一個「開 pull request 既是技術行為也是社交行為」的儲存庫裡,正確的行為。
「沒有提交要怎麼審查?」
當代理人提議在提交前先跑自我審查時,我反對了。那看起來像是範疇上的錯誤:審查讀的是差異,差異是提交產生的,所以在提交前審查聽起來就是在審查空氣。
答案是 git diff HEAD 會把已暫存與未暫存的變更一起拿來,跟上一個提交比對。 沒有什麼好等的。要讀哪一道指令,只取決於手上正在進行的是什麼。
這件事的份量比初看時更重。如果審查跑在提交之後,它產出的每一項意見都只能落成第二個提交,分支上就會多出一個「處理審查意見」的提交,而人類審查者得先讀它再在腦中把它丟掉。讓審查先跑,意味著那些修正與原本的工作再也分不出來,因為它們本來就是同一件事。審查者讀到的是一份完整的變更,而不是一份變更加上它的勘誤表。
我對機制的理解是錯的,但那個反對仍然值得提出,因為它迫使這個順序被論證,而不是被預設。
check:fix 會寫入工作目錄,所以它跑在提交之前
這個 monorepo 用的是 Biome,而儲存庫標準化採用的指令碼是 check:fix,也就是 biome check --write。它不回報問題。它改寫檔案。
光是這一件事就把階段順序定死了。 驗證不能排在提交之後,否則格式化的變更會被留在工作目錄裡,然後需要第二個提交去收拾。它也不能換成唯讀的 check,因為這個儲存庫自己的慣例就是把格式套用下去,而不是抱怨它。於是順序被強制決定:審查、套用修正、驗證、提交、推送、撰寫文件。
代理人提議偵測一次變更碰到了哪些工作區,而不是每次都跑完整套,我接受了。這個對應是機械式的,從變更的路徑對到套件的過濾條件,只有一個例外是它主張的,而我認為那是對的:共用的 common 套件底下的變更會升級成驗證全部。 三個應用程式都吃這個套件的建置產物,所以一個被改動的型別在 common 裡編譯得乾乾淨淨,卻在使用端的 tsc 壞掉。偵測範圍太窄,就會對一個根本建置不起來的變更回報綠燈。
有個細節第一次看到會很嚇人,值得寫下來:client 與 admin 的建置輸出到已經被忽略的目錄,server 的建置也是。跑完整套驗證之後 git status 仍然是乾淨的。 如果建置產物出現在那裡,那代表某個路徑出了問題,而不是代表建置大聲地成功了。
忽略規則放在 .git/info/exclude,不是 .gitignore
那兩份生成的文件總得放在某個地方,而最直覺的答案是儲存庫裡的一個暫存目錄加上 .gitignore 的一行。那個答案錯了,理由跟那些發布步驟錯掉的理由一樣。
.gitignore 是被追蹤的檔案。 把一個私人的暫存目錄加進去,意味著我的 pull request 裡多了一段審查者得讀的差異、一行被提交進一個不屬於我的儲存庫,以及把我的本機慣例強加給每一個複製這個儲存庫的人。這些都不是我要的。我要的只是一個放草稿的地方。
.git/info/exclude 有相同的語法與相同的效果,而且它是每份複本各自持有、不被追蹤的:
echo '.ignore/' >> .git/info/exclude驗證方式跟任何其他忽略規則一樣,而 git check-ignore 會告訴你這條規則是哪個檔案提供的:
$ git check-ignore -v .ignore/pr-body.md.git/info/exclude:8:.ignore/ .ignore/pr-body.md同一個檔案最後也收留了那個 skill 本身。在這個儲存庫裡,.claude/ 不是個人目錄 ,它已經追蹤著共用的代理人與斜線指令,所以在它底下開一個新資料夾,會被讀成某個團隊應該接手的東西。一個把單一個人偏好編碼進去的工作流程 skill 不是那種東西,至少在有人開口要它之前不是:
$ tail -3 .git/info/excludescripts/.ignore/.claude/skills/review三個項目,沒有任何一個是別人得審查的行。
代價是這條規則帶不走。 別人的複本上沒有它,我自己重新複製一份之後也不會有。對一個放著草稿、而那些草稿注定要被貼進網頁表單然後遺忘的暫存目錄來說,那是正確的取捨,而且跟不打標籤是同一個判斷:當儲存庫不是我的,預設就是不在裡面留下痕跡。
一份指名這個程式碼庫的檢查清單,找得到通用清單找不到的東西
一個只被交代「找 bug」的審查代理人,會產出一串不是 bug 的東西。 代理人是從儲存庫本身導出這份檢查清單的,而其中真正站得住腳的面向,正是從外面猜不出來的那些。
那個不留痕跡的區塊
最強的例子是 server 裡的一個內容區塊 reducer。它走訪一串區塊,並透過一張回呼表分派每一個,而在那張表裡 除了 text 的處理器之外,每一個處理器都是選用的,每個分支的守門條件都是「是這個區塊型別,而且有對應的回呼存在」。
因此,一個從未為某種區塊型別定義處理器的供應商整合,會無聲地丟掉那個區塊。不會拋出例外。不會留下記錄。 內容就是沒有送達模型,而失敗會在稍後以一段畸形的對話、或是一個看不出原因的 API 驗證錯誤浮現。
這個形狀在這個程式碼庫裡已經造成過兩次上線的 bug,所以「新的區塊型別出現,卻沒有在每個供應商裡加上對應的處理器」值得列為高嚴重度的發現。通用的審查器永遠不會去找它,因為程式碼裡沒有任何一處看起來像是錯誤。
哪個檔案註冊了路由,決定了誰能到達它
後端把路由註冊在各個前綴底下,而其中只有兩個帶著驗證用的中介軟體。一個會碰到物件儲存或資料庫的處理器,在那兩個前綴底下是安全的,放在其他任何地方就是公開可達的,這使得 「哪個檔案註冊了這個路由」是一個資安問題,而不是一個整理問題。
這個儲存庫自己的路由進入點上,已經有一段註解寫著完全一樣的話。把一段既有的註解編碼成審查面向不花什麼成本,而且抓到的正是那個真正會出事的錯誤。
這份檢查清單拒絕回報什麼
同樣有用的是它排除掉的那串發現,因為每一項都已經由下游的某個東西保證了:
| 不回報 | 已經由誰處理 |
|---|---|
| 格式、import 順序、引號風格 | 兩個階段之後改寫它們的 check:fix |
未使用的 import、應該是 const 的 let |
同一階段的 Biome lint 規則 |
| 型別錯誤 | 每個工作區建置裡的 tsc 步驟 |
一份回報這些東西的審查,是在把讀者的注意力花在根本到不了 pull request 的發現上,並把那些真正需要人類判斷的發現埋在它們底下。
一個語言設定原來是兩個
我要求所有生成的內容都用同事讀的語言。代理人在答應之前先查了儲存庫的歷史,然後帶著一個矛盾回來:日誌裡的每一個提交都是英文,都是 Conventional Commits 的形式,包括其他所有人的。 用另一種語言寫的提交訊息,會是那裡唯一的異類。
所以這個切分是依讀者而不是依偏好。審查用的文件跟著審查者走,因為那是一個人讀一次然後做出決定的東西。提交訊息維持英文,因為它們加入的是一份比這次審查活得更久、而且屬於所有人的共用日誌。我一直把「語言」當成一個設定,但它其實是兩個,而代理人之所以注意到,是因為它讀了日誌而不是讀我的指示。
這是整件事裡我自己到不了的部分,而誰做了什麼值得寫清楚:範圍、格式規格,以及每一個最終決定都是我的,而那個我沒看見的矛盾,是代理人該找出來的。
那份裝不下自己範例的範本
有一個失敗值得記下來,因為修法不是最明顯的那一種。
那兩份文件範本裡含有實作範例,而那些範例本身就是程式碼圍籬。一個 ```markdown 區塊裡包著一個 ```diff 區塊時,會在內層的圍籬處被終止。代理人的第一個嘗試是在每個內層圍籬前面加一個零寬空格,它渲染出來看不見,又能阻止解析器比對成功。這確實有效,代價是在一份別人會讀的檔案裡撒滿看不見的字元。
要把它們再拿掉的過程並不順利:
perl -i -pe 's/\x{200b}//g' templates/diff-explanation.md那道指令回傳 0,而且什麼都沒改。 少了 -CSD,Perl 會把檔案當成位元組而不是 UTF-8 來讀,所以 \x{200b} 永遠比不上檔案裡實際存在的那三個位元組。線索是:在一次連錯誤都沒回報的替換之後,grep -c 仍然數到四個。
remaining: 4兩個修正,都很小。perl -CSD 讓替換看到的是字元而不是位元組。而外層圍籬改成 ~~~,Markdown 接受它作為替代的圍籬記號,於是內層那些反引號圍籬完全不需要跳脫,檔案裡也不再有任何看不見的字元。第二個修正意味著第一個從來就不需要,這正是這類問題慣常的形狀。
總結
把一個發布 skill 移植到一個不屬於我的儲存庫,結果是一場帶著一項加法的減法。十七個步驟裡有七個被拿掉,因為它們主張的是所有權:issue、里程碑、版本號、變更紀錄的權限、合併、標籤、發布。取代它們的是一個撰寫文件的階段,因為現在的最後一步是一位同事讀 pull request 的內文,而不是一次合併上的綠色勾勾。
有三個決定是在所有權消失之後才看得見的。審查跑在提交之前,因為 git diff HEAD 讓它成為可能,而把修正折進原本的提交,能讓審查者只需要讀一份變更。驗證也跑在提交之前,因為這個儲存庫的格式化工具是寫入而不是回報。至於暫存目錄則是透過 .git/info/exclude 而不是 .gitignore 被忽略,因為一個被追蹤的忽略檔,等於是我在別人的儲存庫裡又多改了一樣東西。
如果有一個可以一般化的形式,那會是:一個工作流程 skill 編碼的是一組權限,其程度不亞於它編碼的一組步驟,而能在搬家之後活下來的步驟,是那些從一開始就與權限無關的。

