Files
ISEP/system-dev/wiki/mistakes.md
T
isep-hand 7cacfdcf11 一個 repo 只有一個資料夾:分身住 repo 裡面的 .worktrees/,收工由同一支閘收掉(inkstone/ISEP#147)
leo 2026-09-07:「一個 repo 只看得到一個資料夾」「禁止這樣產出一大堆資料夾」。
根因:line-needs-own-worktree.sh 教工人開在 repo 旁邊,「收工 remove」只寫在閘訊息裡、沒機制驗,
每條線留一份 ⇒ tech_projects/ 長出 ISEP-wt-117/InkStoneCo-wt-112…一排。規則在文字裡不在機器上。

- scripts/worktree(新):open/close/list/sweep 四個動詞。分身住 <repo>/.worktrees/<票 repo>-<N>
  (走 .git/info/exclude,不動被追蹤的檔);「推了沒」只有這一份判準(三態:有/沒有/問不到,
  問不到不當成沒推也不收);sweep 預設只列不動,--apply 只收「推了且乾淨」,--skip 是操作者點名。
- hooks/line-needs-own-worktree.sh:擋下時印 `scripts/worktree open`(絕對路徑,貼了就跑);
  同一支多掛 PostToolUse Agent|Task——從【工單】認出分身,推了收、沒推點名分支+路徑(exit 2),
  不 force、不刪。+0 支、+1 條(61 支/86 條,當場數的)。
- docs/governance/worktree-placement.md(新)+ principles 一行:禁令與閘互指。
- 測試:line-needs-own-worktree.test.sh 77→110(G 群:閘印的 open 那行原樣跑、旁邊不多任何東西;
  H 群:PostToolUse 推了收/沒推點名/安靜的方向);scripts/test-worktree.sh 42 條(七個分身的 fixture,
  名字照 leo 看到的那排)。舊閘跑新測試 90/110,紅的 20 條正是這次改到的行為。
- permissions-allow.json 加 worktree 四種形狀(出路在雲端被分類器擋就走不通)。
- README/plugin.json/hooks-inventory 的數字在這棵樹上實數:61 支、86 條、62 支腳本。

待總管定版(改了會被載入的東西)。沒碰 hooks/lib/mainline.py。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0178ef1fGw3XeZtpN7LaZrm4
2026-09-07 07:13:21 +00:00

444 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 已知誤解 / 踩過的坑
> 這是 ISEP 自己的坑,不是 InkStoneCo 的(不轉抄,複製即 fork,fork 即漂移)。
> 撞到新坑就 append 一條;session 開場只推最近幾條標題(見 `hooks/session-start-recall.sh` push 5/5),全文在這裡。
⚠️ MISTAKE: 「環境設定」曾經拆成兩份,各自會漂
症狀: 本機在跑 `InkStoneCo/.claude/`(真身),雲端跑的是 `generate-shell-payload.py`
另外產生塞進 GitHub 私 repo 的一份(薄殼)。`inkstone/InkStoneCo#57` 實測:薄殼比真身
少 7 支閘,其中兩支是前一天才立的;`#14` 更早查到雲端 33 支 guard 一支都沒生效。
正確做法: 只留一份——ISEP 這個 repo 本身就是唯一真相源,本機與雲端裝同一個 plugin。
改動一律只改這裡,然後兩邊各自 `/plugin update`。不要再改
`InkStoneCo/.claude/hooks/`(退場中,早晚會刪)。
原因: 「一份東西兩個副本」在沒有機制強制同步的情況下必然漂移——差異不是誰疏忽,
是結構本身允許漂移發生。
日期: 2026-08-20ISEP `c263866` 建立時就是為了解這個病)
⚠️ MISTAKE: hook 路徑寫死會在雲端斷
症狀: 舊版 hook 若用 `$CLAUDE_PROJECT_DIR/.claude/hooks/...` 或寫死的絕對路徑指向 hook
腳本自己,雲端執行時的 cwd 不是本機那個真身目錄,路徑就對不到、hook 直接失效
(正是上一條「雲端 33 支 guard 一支都沒生效」的根因)。
正確做法: hook 指自己(找到自己在哪、要 source 的其他 hook 檔)一律用官方
`${CLAUDE_PLUGIN_ROOT}`。腳本內部要指**專案裡的檔案**(如 `system-dev/wiki/`
`system-dev/docs/`)才用 `$CLAUDE_PROJECT_DIR`——那些檔案本來就该住在被操作的
那個 repo 裡,跟 hook 自己的路徑是两回事,别混。
原因: 兩種路徑指的是完全不同的東西(「plugin 安裝到哪」vs「正在操作哪個專案」),
混用就是這條坑的直接原因。
日期: 2026-08-20README「路徑規約」段記錄,ISEP 0.1.0 把 51 條 hook 路徑全部改過一輪)
## ⚠️ MISTAKE: 「裝好了」不等於「它在跑」
2026-08-20 實查:ISEP repo 建好、README 寫著 0.1.0、41 支閘都在裡面——
`claude plugin list` 裡**根本沒有 ISEP**。本機仍然在跑 `InkStoneCo/.claude/`
Gitea 上**一個 release tag 都沒有**。
⇒ 「東西做出來了」與「有人在用它」是兩件事,而只有後者算交付。
⇒ 判準:**去執行環境查它有沒有被載入**(`claude plugin list` / `claude plugin details`),
不要從 repo 裡有什麼檔案去推論。
## ⚠️ MISTAKE: 判準寫在閘裡了,但那個閘掛在**做完之後**才跑的時機上
票: `inkstone/InkStoneCo#55`
日期: 2026-08-26
症狀: leo 一天內好幾次被丟純技術路徑選擇,當場問「**今天已經好幾次問我,
為什麼 hooks 沒有攔下來?**」,其中一次他直接說「這種問題不要問我,
我要的是你解決了以後給我 prod」。
實查: 總管問 leo 走的動作是 `AskUserQuestion` 這個工具,而
`hooks.json``AskUserQuestion` 出現 **0 次**——沒有任何 matcher,它是裸的。
判準其實早就寫好了(`self-drive-police.sh` / `self-drive-judge.sh` 用的就是四題公式),
但那兩支只掛在 `Stop``SubagentStop`
原因: **判準對了,時機錯了。** `Stop` 是回合結束後才跑——問題早就送到 leo 眼前、
他早就被打斷了,這時再反問 AI「你查過了嗎」,成本已經轉嫁出去了。
正確做法: 攔截點要長在**那個動作發生的那一刻**(`PreToolUse` / `AskUserQuestion`)。
新增 `hooks/ask-user-question-guard.sh`
🔴 **推廣**:以後看到「規則寫了卻沒被攔下來」,先問的不是「判準對不對」,
而是「**這支閘掛在哪個事件上、那個事件發生時傷害造成了沒有**」。
## ⚠️ MISTAKE: hook 訊息用沒加引號的 heredoc,反引號會被當成命令執行
票: `inkstone/InkStoneCo#55`
日期: 2026-08-26
症狀: `ask-user-question-guard.sh` 擋下之後,stderr 冒出
`line 218: system-dev/wiki/: is a directory`,而訊息裡
「去查 `system-dev/wiki/`」和「`touch /tmp/.ask-ok-<session_id>`」兩行
**變成空白**。閘照擋 exit 2,所以測試若只看離開碼**完全看不出來**。
原因: 寫成 `cat >&2 <<EOF`(heredoc 標記沒加引號)⇒ shell 會對內容做展開,
而本 repo 的 hook 訊息**慣例上大量使用反引號**標路徑與指令
⇒ 每一組反引號都被當成命令替換真的去執行。
正確做法: hook 的訊息一律用 `cat <<'EOF'`(標記加單引號)。
需要塞變數就留 `__PLACEHOLDER__`,事後用 python 換掉——
**不要用 sed**,正體中文加上訊息裡的 `/``&``\` 讓跳脫非常脆。
迴歸測試要**檢查訊息內容**,不能只檢查離開碼
`hooks/tests/ask-user-question-guard.test.sh` 的 ⑩b 就是這一條)。
## ⚠️ MISTAKE: 閘只驗了規則的**殼**,沒驗規則本身
`no-ticket-no-dispatch.sh` 掛在派工的當下,檢查「派工單裡有沒有一行 `【工單】owner/repo#N`」。
規則的原文卻是「**不准把票上已經有的東西再抄一遍進派工單**⋯⋯派工單只寫票號」。
⇒ 於是可以**把 40 行任務全寫在 prompt 裡、票號補一行**,閘照樣放行。
⇒ 2026-08-27 一天之內這樣做了 5 次,每一次票上都沒有那份任務。
leo:「**這些話票上都沒有,你根本沒照規則做事,你的 hook 讓你這樣搞?**」
**根因不是那支閘寫壞了,是它驗的東西比規則小。**
「有沒有票號」是規則最容易機械化的那一格,所以它被實作了;
「任務有沒有真的落在票上」比較難,所以沒有——而漏掉的那格才是規則的本體。
**判準:寫完一支閘,回頭把規則原文逐句對一次,問「這一句被驗到了嗎」。**
只驗得到最容易的那一格 ⇒ 那支閘會製造「有在管」的錯覺,比沒有閘更危險。
⇒ 同款:history-firstKBDB-firststage-first`AskUserQuestion` 裸奔,全是這個形狀。
修法(v0.5.0):`dispatch-format-guard.sh`——**派工單 = 票號,多一個字都擋**。
規則變得比原本更嚴,反而更好驗:判準從「內容夠不夠」變成「這一行是不是【工單】欄位」,
純結構、不用語意判官、每次結果一樣。
規約:`docs/governance/dispatch-and-reply-format.md`
日期: 2026-08-27`inkstone/ISEP#30` comment 432243254327
## ⚠️ MISTAKE: 「這是 session 才知道的事」被當成寫進 prompt 的正當理由
派工鐵律允許派工單帶「這個 session 才知道、票上還沒有的事」。
總管照字面理解,把 TCC 權限、`main` 是哪顆 commit、正本能從哪裡 clone 三件事寫進 prompt。
leo 當場:「**這些為什麼不寫到票裡?**」
**「票上還沒有」不是把它寫進 prompt 的理由——它就是「去把它寫上票」的指令。**
⇒ 實害(同日):總管停掉重派 3 次,**前兩次的任務與 session 事實全部隨 prompt 蒸發**。
票活得比任何一個 agent 久,prompt 不是。
判準:**「這句話換一張票還成立嗎?」**
還成立 ⇒ 共通規定(`docs/governance/dispatch-and-reply-format.md` §2,機器自動注入)。
只有這次成立 ⇒ 寫進那張票。**兩種都不進派工單。**
日期: 2026-08-27`inkstone/ISEP#30` comment 4327
## ⚠️ MISTAKE: fail-closed 的閘,會把「執行裁決」這個動作本身變成不可執行
票: `inkstone/ISEP#91`(裁決原文在 `inkstone/InkStoneCo#40` → comment 2942
日期: 2026-08-31
症狀: leo 2026-08-16 裁定「**取消 Active SDD**」——任務狀態搬到 Gitea 管,
SDD 只記「起初的樣子」,文件不帶任務 checkbox。
**11 天過去,這個裁決一個字都沒被執行。**
實查: `sdd-guard.sh` 是 fail-closed 的:動 code 檔時 `status: active` 的 SDD
**不是恰好 1 份就擋(0 份也擋)**,路徑所在的 repo 沒有 `3-specs` 也擋。
⇒ 照裁決把最後那份 active 拿掉 → 變 0 份 → **任何人動任何
`.ts``.py``.go` 全部被擋**。當時沒出事,純粹是因為剛好還剩 1 份。
**ISEP 這個 repo 自己根本沒有 `3-specs`**——本票開工第一件事就實測到:
改自己的 `hooks/lib/*.py` 當場被這支閘擋下(exit 2)。**它一直在誤攔,只是沒人回報。**
原因: **不是有人偷懶,是沒有人把裁決和那支閘連起來看。**
裁決被讀到了、也沒人反對,但執行它的第一步會當場鎖死自己,
於是每個人都在那一步前面停下來,而**「我停下來了」不會留下任何痕跡**。
正確做法:
- 收到「取消某個制度」的裁決,第一個動作是**去數還有幾支閘在執行那個制度**,
並且**先問那些閘是 fail-open 還是 fail-closed**。
fail-closed 的那幾支決定了執行順序:**先讓閘退役,再拿掉它要的東西**,顛倒就鎖死。
- 退役要驗的不是「檔案刪了沒有」,是「**那個擋還會不會發生**」。
`hooks/tests/sdd-guard-retired.test.sh` 把「裁決執行完之後的世界」丟給
`hooks.json` 上整組寫檔閘(清單當場從 `hooks.json` 讀,不寫死),
**不准有任何一支用 SDD 當理由擋下來** ⇒ 換個檔名種回來照樣紅。
📌 一個沒人敢執行的裁決,看起來跟一個沒人記得的裁決一模一樣。
差別只有在**去問「執行它的第一步會發生什麼」**的時候才看得出來。
📌 KBDB 缺這一段: 2026-08-31 用 `kbdb_search(mode='semantic')`
「SDD 生命週期/單一活性/取消 Active SDD」**0 命中**(最接近的是 2026-08-09
一張講 SDD × Gitea 整合摩擦的卡,那是裁決之前)。⇒ 這條開發史還沒進 KBDB。
## ⚠️ MISTAKE: 閘擋對了,但它印出來的那條出路**跑不動**
票: `inkstone/ISEP#112`
日期: 2026-08-31
症狀: `comment-carries-task-guard.sh` 擋下留言時教你跑
`scripts/ticket subtask …`。照著貼,會印出一段「四個動詞」的說明然後
什麼都沒發生。被擋的人當下正在做別的事,最可能的反應是**找個理由繞過去**。
實查: 兩個**各自獨立**的原因,任何一個單獨存在都足以害死它——
**相對路徑**。閘印的是 `scripts/ticket`,而相對路徑是對
**貼上去那個人的 cwd** 解析的。總管的 cwd 是 `InkStoneCo/`
那裡的 `scripts/ticket` 是正本停在 2026-08-27 之前的舊複本:
`grep -c subtask InkStoneCo/scripts/ticket`**0**(正本是 10)。
⇒ 「`subtask` 這個子命令不存在」這個結論**在那個 cwd 底下是真的**。
**用法各寫一份**。閘手抄了一段用法,`cmd_subtask``die()` 又寫了一段。
兩份已經漂了:閘那份寫 `--assign <誰做>` 卻**沒有 `--next`**
而正本會因為「指派了人卻沒寫 --next」直接擋下(實測 exit 2)。
**就算路徑對了,那一行照樣跑不完。**
原因: 閘的訊息與它教的那支工具是**兩份各自維護的字**。
`hooks/lib/beacon_report.py` ② 早就會在 SessionStart 報「專案裡有舊複本」,
**但報告不會改掉閘印出來的那一行**——知道有這個坑,跟那一行會不會踩到它,
是兩件事。
正確做法:
- **路徑**:印出來要人複製的指令,一律用正本自己的 `__file__` 絕對路徑
`scripts/ticket``self_path()`)。印指令的人與跑指令的人是同一個檔案,
沒有第二種可能。
⚠️ 分界線是「**這行字會被誰複製、在哪台機器上跑?**」——
要**貼進 Gitea 留言**的指令反而要維持相對寫法(留言會被別台機器讀到)。
- **用法**:只留一份(`scripts/ticket``USAGE``usage_text()`),
閘去 `ticket usage subtask --example` 現要,不自己抄。
- **模板**:子票內文草稿由 `REQUIRED_SECTIONS` 現生(`body_template()`),
跟檢查同源 ⇒ 不可能生出「照著貼卻過不了自己那道閘」的票。
🔴 **驗法要驗到「它跑不跑得動」,不是「它有沒有擋」。**
`scripts/test-comment-carries-task-guard.sh` ⑱ 把閘 stderr 裡那塊指令
**原樣抽出來、只填三格、真的執行一次**:離開碼 1(走到網路才停)=過,
2(被某道閘擋下)=這個病復發。⑲ 是它的鑑別力對照組。
📌 推廣:**一支閘的價值是「擋下來 + 給一條走得通的路」。**
出路走不通時它不是幫你,是擋你——而被擋的人不會停下來修閘,
他會去找繞過去的方法。所以「出路能不能跑」要跟「判準準不準」一樣被測。
## ⚠️ MISTAKE: 「雲端該有哪些憑證」的清單是唯一真相源,卻沒有任何東西在跟機器對帳
票: `inkstone/ISEP#115`(→ comment `5522``5526`
日期: 2026-09-01
症狀: 雲端 session 要清空 youlin`env | grep -ciE 'cloudflare|^CF_|wrangler'`**0**
`credentials-map.md` 明明寫著那把 token 在 `InkStoneCo/.env`
實查: 不是雲端漏設。`scripts/make-cloud-env.sh``NEEDED` 當時**只有 1 個變數**
裡面從來沒有任何 CF 憑證。而 `inkstone/InkStoneCo#14` comment `3890`
2026-08-20)盤點出的 A 段是 **8 個**——那一版三段輸出的腳本,
到今天為止只活在**沒併進 `main` 的分支 `fix/cloud-env-parity-14`PR #43**上。
原因: 盤點做在票上、實作做在分支上、`main` 上的清單是另一回事——**三份,互不對帳**。
🔴 而清單漏一項的代價**在流程末端才付**:工人已經把腳本與測試都寫完了,
到要真的跑的那一刻才知道跑不了。
正確做法: 清單要有測試逼它跟這台機器對帳(`scripts/test-make-cloud-env.sh`
`docs/TESTING.md` A26)——B 段逐一去 `.env` 找值,找不到就紅。
🔴 而「不准混進正式環境憑證」這一條,判準一律是**機器算得出來的事實**,
**不是名字裡有沒有某個字**——兩條分工:
B8 看**值從哪個檔案拿到的**(出自 `polaris/mira/.env` = leo21c 現役正式環境就紅);
B9 看**這個值是不是就是 `CLOUDFLARE_API_TOKEN_leo21c`**(那把住在頂層 `.env`
B8 抓不到,而改個名字黑名單就放它過去——實跑證過)。黑名單擋不住沒被列進去的新名字,
「這個值出自那份 `.env`」是 grep 得出來的事實,換什麼名字都躲不掉。
同時釘住反面(B8x):拿一個已知住在那份 `.env` 的變數餵它,證明偵測**真的會亮**——
否則「全綠」有兩種可能(真的乾淨/偵測壞了),而分不開就等於沒驗。
**推廣**:repo 裡任何「這台機器該有什麼」的清單,都要有一支東西定期拿它去問機器。
清單自己不會知道它漏了什麼。
## ⚠️ MISTAKE: 兩支閘對同一條指令說不同的話,而讀的那一半從來沒被測過
票: `inkstone/ISEP#130`
日期: 2026-09-07
症狀: 09-04 雲端 Routine 的 run log:讀 `notify_leo` 定義被 `leo21c-write-guard.sh` 擋下。
閘的判準明寫「**讀可以,寫不行**」,卻連讀都擋 ⇒ 雲端什麼都拿不到。
實查: ① 舊判準 `-d[[:space:]]``| tr -d '\r'``cut -d=` 這種唯讀的 `-d` 當成 curl 的 body。
`prod-write-guard.sh` 在 2026-08-12 修過**一模一樣**的洞(它的檔頭寫著「一堆唯讀工具也用 -d」),
這支漏了——同一批補丁只改其中一份(`main-and-prod-push-guard.sh` 檔頭記過同款)。
`…/webhooks/named/<ns>/notify_leo/trigger``prod-write-guard.sh` 是白名單(ISEP#63),
在這支卻因為尾巴是 `/trigger` 照擋 ⇒ 撞人閘時發 Telegram 叫 leo 會被擋(InkStoneCo#110 的病)。
③ 這支閘從 08-20 建立到現在**沒有任何測試**,`hooks/tests/` 裡 30 支測試沒有它的——
「讀會不會被誤攔」這格從來沒被驗過,只有「寫會不會被擋」被人肉試過。
原因: 同一個判準(「這條指令會不會真的寫到那台」)散在三支閘裡各寫一份,修一份不會帶動另外兩份;
而沒有測試的那支,誤攔發生時**不會有任何東西喊一聲**——被擋的人在雲端、沒有人在旁邊。
正確做法:
- 修判準時 `grep -l` 找同一族的閘(`prod-write-guard``main-and-prod-push-guard``leo21c-write-guard`),
一次改齊;本次三支都補了 youlin 09-02 起的新子網域 `arcrun-yuga3bse`
- 每一支「擋」的閘都要有「不該擋」那半的測試,測資用**現場真的會下的指令**
(這次是 `cloud-worker.md``progress-guard.md` 裡的原句),不是自己挑好抓的例子。
- 修之前拿新測試跑舊閘,紅的那幾條才是真的改到的(本次 5 條紅);全綠就是測試順著新閘寫的。
## ⚠️ MISTAKE: 雲端連不到 youlin 被記成「HTTP 000」,而 000 有三種病,兩種都不是連線
票: `inkstone/ISEP#130`
日期: 2026-09-07
症狀: 09-04 run log「youlin 從雲端 HTTP 000」;09-07 總管查到舊子網域 `youlin-hsieh-dev` DNS 已死,
以為換名字就通。從雲端打新名 `arcrun-yuga3bse` **還是 000**
實查: `curl` 印的是 `CONNECT tunnel failed, response 403``$HTTPS_PROXY/__agentproxy/status` 記為
`connect_rejected … gateway answered 403 (policy denial)`。同一個 sessionleo21c 200、
git.uncle6.me 200、api.telegram.org 302——**只有 youlin 的主機沒被雲端 egress policy 放行**。
`/root/.ccr/README.md` 明寫:403/407 是組織的 egress policy,不要重試、不要繞路,回報主機名。
原因: `-w '%{http_code}'` 對「DNS 沒有」「proxy 拒絕 CONNECT」「對方沒回」三種都印 000,
而三種的修法各在不同人手上(改名字/leo 放行主機/看實例)。把它們記成同一個數字,
下一個人就會往錯的方向查(09-07 就差點只修名字收工)。
正確做法: 雲端看到 000 先跑 `curl -sS "$HTTPS_PROXY/__agentproxy/status" | jq .recentRelayFailures`
`connect_rejected` 403 ⇒ 是 policy,交給 leo 在 Cloud environment 放行 `*.arcrun-yuga3bse.workers.dev`
不是 ⇒ 才去查名字或實例。記 log 時寫 curl 的錯誤字串(`response 403``Could not resolve host`),不要只寫 000。
## ⚠️ MISTAKE: 正門工具吃 `owner/repo` 會回 404,於是人走了側門
票: `inkstone/ISEP#130` → comment 6120 第 2 件
日期: 2026-09-07
症狀: `scripts/ticket new inkstone/ISEP -F …` 回 Gitea 404;同一天 `ticket say` 對 InkStoneCo 正常。
總管當場改成直接打 API 開了四張票。
實查: `cmd_new` 把第一個參數原樣塞進 `/repos/inkstone/{repo}/issues``/repos/inkstone/inkstone/ISEP/issues`
404 讀起來像「ISEP 這個 repo 不存在」,跟真正的原因隔了一層。`subtask --to` 同款。
原因: 票號的鐵律是全稱 `owner/repo#N`(CLAUDE.md),人自然會把收件 repo 也寫成 `owner/repo`
工具只收短名、錯了又不講清楚 ⇒ **正門壞了,人就走 `ticket-api-bypass-guard.sh` 在防的那條側門**
一道閘把人逼去走它自己禁止的路,那道閘就是在製造違規(`docs/governance/cloud-wiring.md` ② 記過同一句)。
正確做法: `repo_arg()` 兩種寫法都收;org 寫錯(`Leo/`)當場講「org 是 inkstone」,不讓它變成一個要人猜的 404。
測試 `scripts/test-ticket-repo-arg.sh``api()` 換成假貨記路徑,離線驗路徑裡不准出現 `inkstone/inkstone`
## ⚠️ MISTAKE: 權限白名單是「逐條比前綴」,複合指令永遠對不上——而假伺服器照自己的想像回 200
票: `inkstone/ISEP#137`(母票 `inkstone/ISEP#130`
日期: 2026-09-07
症狀: 三條主線工人在雲端對 stage 的寫入全被 Claude Code auto mode 分類器擋:
`printf … | npx wrangler secret put …``python3 scripts/stage-deploy-artifacts.py all --confirm`
`curl -X POST …arcrun-yuga3bse.workers.dev/records`。同一回合連 `ticket where <含 token 字樣>` 都被擋。
leo:「為什麼會擋你,這就是要修復的問題」「本機環境跟你不同應該不行」。
實查: `docs/permissions-allow.json` 的每一條是**完全比對前綴**。`cd … && printf … | VAR=… npx …` 這種複合指令
沒有任何一條對得上,任意寫法的 curl 更不可能;放整類(`Bash(npx *)``Bash(curl *)`)又是紅線。
⇒ 分類器擋的不是「這件事」,是「這個形狀沒被事先放行」。
原因: 正門工具的價值在**形狀固定**——一條規則就能事先放行。之前四個正門(ticketmainlinegate-okgitea-pr-merge
都是這樣活下來的;對 stage 的寫入沒有正門,人就只能拼複合指令,而複合指令天生對不上任何前綴。
正確做法:
- 每一種要放行的動作收成一支固定形狀(`scripts/stage secret …``stage api …`),白名單放它四種呼叫形狀。
「只打 stage」寫死在程式裡(帳號 ID 現查、主機後綴固定),不靠變數名字裡有沒有 `leo21c`
- 別的 repo 已經有的正門(Arcrun `stage-deploy-artifacts.py` 自己寫死 youlin 帳號 ID)**直接放它的形狀**,不重造一支。
- 值只從環境變數名讀;`--value``--bearer``--header` 一律拒收——指令列會進 shell 歷史與 session log。
📌 同票第二個坑:**假伺服器要照真 API 回,不是照自己的想像回。**
離線測試的假 CF 對 secret PUT 回 20027/27 全綠;第一次真跑 CF 回 **201**,工具把它講成「失敗」——
而獨立用 CF API 一列,那把 secret 明明種進去了。全綠的測試證明的是「工具跟假伺服器合得來」,
不是「工具跟 CF 合得來」。⇒ 假伺服器的回應碼要抄真的(現在回 201),而且**離線全綠之後一定要真跑一次**。
## ⚠️ MISTAKE: 前置條件判的是「手邊的樹」,而要送出去的東西是「那個 tag 的樹」
票: `inkstone/ISEP#67` → comment 6574
日期: 2026-09-07
症狀: 總管在 v0.23.0 的 checkout 跑 `release-ship v0.23.0 --dry-run` 被擋,訊息說
「最新 tag 是 v0.24.0」。v0.23.0 的樹明明一致,只因為它不是最新的那個 tag,
就永遠建不了 release——而 09-07 缺 release 的正是 v0.23.0 與 v0.24.0 兩個。
實查: 09-05 那版 release-ship 的前置①直接跑 `check-version-consistency.sh`
(工作樹 `plugin.json` vs **本機最新** tag)當判準,理由寫著「不另立判準」。
但那支守的是「打 tag 那一刻 main 跟最新 tag 一致」(release-tag-guard.sh 的事),
建 release 這一刻要問的是「**要送的那個 tag 的樹**,自己講的版本對不對」——
工作樹可能是任何分支(release-ship 本身就是在一條 feature 分支上寫的),跟那個 tag 無關。
原因: 「重用既有判準」是對的方向,但重用之前要問「**它判的是哪個對象**」。
對象錯了,判準再對也只是在檢查一個跟交付物無關的東西——而且會用一條看起來
很有道理的訊息把正確的操作擋掉(誤攔)。
正確做法: 前置①改成問 Gitea `contents/.claude-plugin/plugin.json?ref=<tag>`
比的是 tag 自己的樹;工作樹的版本完全不參與(`scripts/test-release-ship.sh` 把工作樹
故意擺成 `9.9.9`,B 群全綠才算證明)。同時把「問 Gitea」收成一份
`hooks/lib/release_chain.py`),release-shiprelease-checkSessionStart 信標三處共用——
判準散在三處各寫一份,就是 `leo21c-write-guard.sh` 那一族漂掉的路。
📌 判準:**送出去之前,先問「我判的是不是我要送的那個東西」**。
工作樹、最新 tag、main——都不是;tag 指到的那棵樹才是。
## ⚠️ MISTAKE: 派工單的紅線建立在「Gitea 匿名讀得到」上,而那件事沒人打過一次
票: `inkstone/ISEP#140`(母票 `inkstone/ISEP#130`
日期: 2026-09-07
症狀: 工單 comment 6611 寫「主線指標在 Gitea 上讀得到(匿名讀 raw 檔或 API contentsD20 讀不擋)」,
紅線據此寫成「讀 Gitea 一律匿名,不帶 token」。開工第一個指令匿名打
`/api/v1/repos/inkstone/InkStoneCo/raw/…``/contents/…``/repos/inkstone/InkStoneCo`**三個都 404**
`inkstone/ISEP` 同樣的三個端點 200 ⇒ 不是網路,是 InkStoneCo 在 Gitea 是 private。
實查: 照紅線字面做(純匿名)⇒ 這張票在真環境永遠讀不到,測試卻可以全綠(假伺服器照自己的想像回 200,
同日 #137 那條坑的翻版)。而 SessionStart 同一條鏈上的 `countdown-milestone-refresh.sh`
`mainline refresh` 本來就帶 `GITEA_TOKEN_CLAUDE_CODE` 打 GiteaD20 管的是 GitHub 那個會被 flag 的帳號,
不是 Gitea 機器帳號的讀。
原因: 「讀得到」是一個可以一個指令驗掉的事實宣稱,卻被當成前提直接寫進紅線;收工方若不打那一下,
就會交出一個「測試全綠、雲端永遠退回舊線」的東西——形狀跟 #137「假伺服器回 200」一模一樣。
正確做法: 匿名優先、匿名被拒(401/403/404)且環境拿得到 token 才帶 token 再問一次(`fetch_gitea()`);
假伺服器要有 private 模式(匿名 404、帶 token 200)照真 Gitea 回;真跑兩條都跑(帶 token 讀到、拔 token 退回)。
收到「X 讀得到/X 不存在」這種宣稱先打一次再動手——那是一個 curl 的成本。
## ⚠️ MISTAKE: 閘看的是 hook 自己的環境/cwd,不是指令要動的那個 repo
票: `inkstone/ISEP#109`comment 65876629);`inkstone/ISEP#125` 是缺陷 A 與 D 的原票(09-02 開)
日期: 2026-09-07
症狀: 同一天兩支閘同一個病。① `line-needs-own-worktree.sh` 印的出路
`WORKTREE_OK=1 git -C … checkout …` 照貼三次三次被擋。
`github-contact-guard.sh``git -C <InkStoneCo> push origin x` 判成「remote origin 指向 GitHub」擋下
(總管站在薄殼 cwdInkStoneCo 的 origin 明明是 Gitea),連 `-C` 都沒看;
同日 arcrun-hand 在 `Arcrun#176` comment 6614 撞到同一支。當天只能改用完整 Gitea 網址推,
而那種形狀分類器又時過時不過。
實查: ① hook 讀的是 `$WORKTREE_OK` 這個**環境變數**。PreToolUse hook 跟指令不是同一個行程,
指令字串裡的 `WORKTREE_OK=1` 是給 git 的前綴賦值,hook 的行程裡永遠沒有它
⇒ 那條出路在 Claude Code 底下**結構上**走不通,不是偶爾。(`NOT_MY_BRANCH_OK` 同形狀,見下)
② hook 拿 **payload 的 cwd**`git remote get-url origin`,而 `-C``cd … &&` 改的是
指令自己的目錄。`main-and-prod-push-guard.sh` 08-23 就為同一個病接了
`lib/push_target_dir.py``inkstone/ISEP#30` comment 3949)——**同一族的另一支沒跟上**,
`leo21c-write-guard.sh` 那條坑一模一樣(修一份不會帶動另外兩份)。
修之前拿新測試跑舊閘:① 3 條紅;② **7 條紅——4 條誤攔之外還有 3 條漏擋**
(站在 Gitea 那份 `git -C <薄殼> push origin x` 舊版放行)。cwd 不只讓它誤擋,也讓 D20 有洞。
原因: 兩支都在回答「這條指令會動到哪個 repo」,卻拿「hook 自己站在哪」當答案。
本機一份目錄時兩者恰好相等,所以測試全綠;雲端薄殼+真身兩份目錄一分開就露餡。
而「出路走不走得通」那一格從來沒被測過——測的只有「擋不擋」。
正確做法:
- 出路要是 hook **讀得到**的形狀:認指令字串裡的字面前綴(判準是位置——前綴必須掛在會移動 HEAD
的那條 git 指令上;印個字、掛在別的指令上、寫在引號裡都不算),或 `gate-ok` 同款的戳記檔。
- 「這條指令在哪個 repo 執行」一律走 `hooks/lib/push_target_dir.py``checkout_target_dir.py`
解 cd 鏈與 `-C`,解不出來才退回 cwd(退回的方向是照舊,不是放寬)。改判準時 `grep -l` 找同一族
`main-and-prod-push-guard``github-contact-guard``line-needs-own-worktree``not-my-branch-guard`)一次看齊。
- 測試要有一條「把閘印出來的那一行原樣餵回去」(A24 (61)–(63)),還要有 payload 帶 `cwd`
cwd 與目標 repo **不同**的案例(A39 後 12 條)——本機一份目錄的測試驗不出這個病。
📌 沒在本票修的同形狀:`not-my-branch-guard.sh``NOT_MY_BRANCH_OK=1 git commit …` 同樣只讀環境變數
(第 28 行),沒實測、沒動——它認的動作是 commit,不是 push_target_dir 那條線的事,另報。
## ⚠️ MISTAKE: 閘教了「開」,「收」只寫在訊息裡——每條線留一份,leo 在 Finder 看到一排分身
票: `inkstone/ISEP#147`
日期: 2026-09-07
症狀: leo 打開 `tech_projects/`,一個 repo 旁邊躺著 `ISEP-wt-122ship``ISEP-wt-117``ISEP-wt115`
`InkStoneCo-wt-112``course_gen-ticket-3-notes``ax-courses-ticket1`…。他原話:
「你會產生大量的同一個頭不同尾巴的 repo,全部拆開就更亂了」「禁止這樣產出一大堆資料夾」。
實查: 那些不是 repo,是 `line-needs-own-worktree.sh`ISEP#10908-29)教工人開的 git worktree。
那支閘比較過「旁邊 vs /tmp」——/tmp 會被系統清掉、登記說謊;旁邊一份都沒掉。
**一份都沒掉,也一份都沒收**:「收工時 `worktree remove`」只寫在閘訊息裡,
沒有任何事件、任何 hook 在線收工那一刻去驗。
原因: 規則在文字裡,不在機器上(同日 ISEP#109 comment 6629 是同一種病的另一面:出路寫在訊息裡,機器讀不到)。
一道閘只管「開」不管「收」,等於把清理交給每一條線的記性——而線收工就消失了,記性跟著消失。
正確做法:
- 分身住 repo **裡面**`<repo>/.worktrees/<票 repo>-<票號>`,走 `.git/info/exclude`):Finder 看不到、
`git status` 看不到、`git worktree list` 找得到。判準是「leo 在 Finder 只看得到一個資料夾」。
- **同一支閘**多掛 PostToolUse `Agent|Task`:從派工單的【工單】認出分身,推了且乾淨就收;
沒推就點名分支與路徑(exit 2),不 force、不刪。教你開的和替你收的是同一件事的兩半,不拆成兩支。
- 「推了沒」只有一份實作(`scripts/worktree`,三態:有/沒有/問不到——問不到不當成沒推,也不收)。
- 現場整理是同一支工具的 `sweep`:預設只列不動,`--apply` 只收已推乾淨的;`--skip` 是操作者點名,不是判準。
- 規約:`docs/governance/worktree-placement.md`
📌 推廣:**一道閘若教人做一件「之後要還」的事(開 worktree、蓋戳記、借共用目錄),
「還」那一步也要長在某個事件上**,不然它必然被留下。收工的事件是 PostToolUse(Agent|Task)
票號就在那裡(`baton-handback-guard.sh``issue-status-autoflip.sh` 都站在同一個位置)。