Compare commits

..

24 Commits

Author SHA1 Message Date
claude-code 1b95388a0f Merge pull request '身為總管,我要這道閘擋的是「宣告」而不是「提到宣告」,我才不會一舉報 bug 就被它擋下' (#54) from fix/factory-idle-guard-blocks-actions-not-mentions into main 2026-08-23 09:58:41 +00:00
Leo e3d05df341 再修兩類:主詞是 leo 的下一步、票號放寬把閘變鈍——都是真 transcript 量出來的
交付警察擋回來是對的:前一顆只驗了「我自己造的假 transcript」。
改用本機一條 2068 行的真 session(26 個真實回合終止點)重驗,當場多找到兩個問題:

① 主詞是「你」的下一步,被當成我的宣告。
   舊閘在 26 個真實回合裡擋了 2 次,兩次咬的都是我在交代 leo 該做什麼:
     「下一步還是那一個動作:你把 feat/... 併進 main」
     「## 你下一步(兩招,先便宜的)」
   ⇒ 這是全新的第四類誤攔,我原本一向都沒列到。
   修法是主詞檢查(誰要動手),只掛在「下一步」這條 alternative 上;
   用 finditer 逐個檢查前 8 字有沒有第二人稱,蓋得到「你的下一步」這種
   單字 lookbehind 蓋不到的變體。「你點頭我就做」主詞本來就是我,不受影響。

② 票號從「宣告句附近」放寬成「整段」,把閘變鈍了。
   真數據:26 個真實回合有 20 個是靠「文中某處剛好有票號」放行的——
   而報告幾乎一定會提到票號 ⇒ 這道閘在實務上等於不會響。
   當初放寬是因為票號常寫在行內 code 裡,剝掉就找不到。
   ⇒ 改成**等長**替換(蓋成同樣長度的哨兵而非刪除),位移就能對回原文,
     locality 與「行內 code 裡的票號也算數」兩件同時成立。
   收緊後的判定分佈:21 no-declaration / 3 dispatched / 2 ticket-referenced
   (原本是 20 ticket-referenced / 3 no-declaration / 3 dispatched)。

真 transcript 實測:舊閘擋 2 次(兩次都是誤攔)→ 新閘擋 0 次。
測試 33 向(+6):舊版 21/33 → 新版 33/33。
版號 0.3.7 → 0.3.8。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 17:54:17 +08:00
Leo a594decb78 稼動率警察改成擋「宣告」,不擋「提到宣告」
2026-08-23 雲端驗收連續三次被這道閘誤攔,三次都不是宣告意圖:
① 否認自己有下一步 ②引用閘自己的訊息 ③貼閘自己的正則原始碼舉報 bug。
而訊息教人走的「選項③:說明它在等什麼」,程式碼裡根本沒有那條分支
——唯一走得通的路是不寫那三個字,正是同一則訊息明文禁止的動作。

四個真兇,沒有一個是「例外沒列夠」:
(a) DECL 會匹配裸的「下一步」三個字(每一節都可選 ⇒ 退化成關鍵字)
    ⇒ 收緊:每一條 alternative 都必須接到動作動詞才算命中
(b) 只剝 > 引言與長「」,不認 code fence 與行內 code ⇒ 引用被當成主張
    ⇒ 引用性標記整段換成哨兵(不是刪掉):內層宣告消失、外層句構留著
      ——刪掉正是 08-17 漏掉「回『規劃』我就派人」的原因,兩個方向一起修
(c) 取 blocks_text[-1],但那則文字後面可能還有 tool_use ⇒ 宣告其實兌現了
    ⇒ 只看「最後一個動作之後」的文字;收尾在動作上就不觸發
(d) 訊息承諾的出路只有兩條真的存在
    ⇒ 出路③ 給一個機械形式 ⏸ 等:<在等什麼>(白名單標記,要刻意寫,留痕)

方向刻意與「再加幾個關鍵字例外」相反——例外清單會越加越長、越長越誤攔。
守 leo 的封路哲學:紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。

順手:擋下與放行都留痕(InkStoneCo#48:只記擋下的話分母未知);
log 目錄不在時安靜跳過,不再噴 redirect 錯誤到 stderr。

測試 hooks/tests/factory-idle-guard.test.sh 27 向,誤攔與漏攔兩個方向都測:
舊版 18/27(9 敗)→ 新版 27/27。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 17:42:22 +08:00
claude-code 5789f0a917 Merge pull request '信標改數「真的被註冊的閘」——它一直多報一支樣板' (#53) from fix/beacon-counts-registered-gates into main 2026-08-23 08:52:30 +00:00
Leo b1f399f8b9 信標改數「真的被註冊的閘」——它一直多報一支樣板
leo 的雲端驗收(2026-08-23)抓到:信標說 44 支,`ls hooks/ | wc -l` 是 49,對不上。
追下去是 `ls "$ROOT"/hooks/*.sh | wc -l`:
把 `pre-write-guard.template.sh`(樣板,不是閘)與兩支沒掛註冊的輔助檔一起算進去。

🔴 這個數字是 leo 判斷「這個 session 到底有沒有閘」的唯一介面——**多報就是假綠**。

改成數 `hooks.json` 裡註冊過的唯一 `.sh`;hooks.json 讀不到才退回檔案數(且排除樣板)。

實測兩向:
  正向(真的 plugin 根目錄)      → 42(與 hooks.json 註冊數一致)
  反向(沒有 hooks.json 的假根)  → 2(三個檔裡有一個是 .template.sh,沒算進去)

查過歷史:本檔自 daa1674 建立以來只有那一版,沒有別的分支修過這段。

inkstone/InkStoneCo#57
2026-08-23 16:52:01 +08:00
claude-code a446ad6d1d Merge pull request '推 main 的戳記改綁「push 真正的目標 repo」,不再綁 hook 自己的 cwd' (#52) from fix/push-guard-cross-repo-stamp into main 2026-08-23 07:31:01 +00:00
Leo 41c56acd32 推 main 的戳記改綁「push 真正的目標 repo」,不再綁 hook 自己的 cwd
跨 repo 交辦時(總管站在 A repo,要推 B repo 的 main)main-and-prod-push-guard
的戳記機制永遠對不上:HERE 讀的是 hook 自己的 cwd(=session 的真身,不會變),
WANT 是總管替目標 repo(B)寫進戳記的路徑——兩者結構性地不可能相等,不是
判斷錯,是這個情境在舊模型裡根本不存在(inkstone/ISEP#30 comment 3949,
脈絡 inkstone/InkStoneCo#57,2026-08-21 實撞)。

新增 hooks/lib/push_target_dir.py:純 tokenize(不執行任何指令)解析指令裡
`cd <path> && git push` 或 `git -C <path> push` 真正會落地的目錄,對多層 cd
鏈與子殼(`(cd A && ...); git push` 這種子殼 cd 不能外洩出去)都做了範圍化——
這條範圍化是防穿透的關鍵,不是順手:沒有它,`(cd A && true); git push`
會被誤判成推向 A,讓替 A 開的舊戳記錯誤地放行推到殼外真正的目標。解不出來
一律退回舊行為(hook 自己的 cwd),維持 fail-closed 方向不變。

順手修掉補測時自己抓到的另一個洞:`(git push origin HEAD:main)`——單純加一層
括號——舊版目的地判斷完全偵測不到,整段直接放行,跟戳記無關。成因是截斷
refspec 尾巴的 sed 只認 `;`/`&`/`|` 三種字元,沒算到 `)`;補上即可,git 的
refspec 語法本來就不允許出現 `)`,這裡截斷永遠安全。

綁 repo+單次用完即丟兩條 2026-08-11/12 用血換來的性質完全沒有鬆動:只是把
「現在人在哪個 repo」問得更準,比對邏輯一個字沒動。

實測:
- hooks/tests/main-and-prod-push-guard.test.sh 舊有 8 向:8/8
- scripts/test-main-and-prod-push-guard.sh 舊有 11 向:11/11
- 新增 hooks/tests/main-and-prod-push-guard-cross-repo.test.sh 17 向
  (跨 repo 正向/反向不准鬆/git -C/子殼範圍化/括號洞/單次用完即丟/
  900 秒逾時/空戳記/既有行為零回歸):17/17

本輪只驗證,未拿去放行任何真實推送;plugin.json 隨慣例 bump 0.3.4 -> 0.3.5
並重跑 vendor-to-shell.py(.shell-payload 為 gitignore 產物,不入版控)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:18:28 +08:00
claude-code 2eb9b2aaaa Merge pull request '身為總管,我要閘擋的是動作不是字面,我才不會一晚被自己的閘擋六次' (#51) from fix/push-guard-target-not-substring into main 2026-08-20 17:30:46 +00:00
Leo 67dae3b814 推送閘改成判目標,不判整條指令裡有沒有那個字
一個晚上誤攔六次,全都不是在推預設分支:
  ① checkout -b 建新分支時把預設分支寫在後面,再推那條新分支
  ② gh pr create 指定 base——根本不是 git push
  ③ 推 tag(refs/tags/…)
  ④ 推 feature 分支(帶 -u)
  ⑤ 它擋住了我用來**測試它自己**的那條指令
  ⑥ 它擋住了這一筆的 commit——因為 message 裡引用了那幾個字

leo 2026-08-17 早就講過這個形狀:文字層封路必敗,
「紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎」。
舊版掃整條指令字串,正是文字層。

改成解析 push 的目標 refspec:
  旗標跳過/第一個非旗標=remote/a:b 取 b/refs/tags/* 不算分支
  一個 refspec 都沒給,才退回看當前分支

八向實測(hooks/tests/main-and-prod-push-guard.test.sh,8/8):
  五種該放行的(今晚誤攔的原形狀,含分支名帶 domain 那種)全過
  三種該擋的全擋

中途自己抓到一個 bug:tag 被跳過後目標清單變空 → 退回猜當前分支
⇒ 當前分支剛好叫預設名時誤擋。改成看到 refspec 就不退回猜測。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:29:55 +08:00
claude-code 43c328d26f Merge pull request 'feat/milestone-must-have-due' (#50) from feat/milestone-must-have-due into main 2026-08-20 17:26:08 +00:00
claude-code bcb736ed19 Merge pull request 'fix/worksheet-dedup-by-content' (#49) from fix/worksheet-dedup-by-content into main 2026-08-20 17:26:06 +00:00
Leo 6772ca67d3 每個里程碑都要有真的期限,9999 也擋
leo 2026-08-21:「以後所有的 milestone 限制時間」「你根本沒有時間概念,浪費一整天」

實查七個 open milestone:六個期限是 9999-01-01、一個空白。
9999 比空白更糟——盤點時每一格看起來都有值,
於是沒有人發現這裡從來沒有時間壓力。七個已全部改成真日期。

新增 hooks/milestone-due-guard.sh,四向實測:
  無 due_on → exit 2
  due_on 帶 9999 → exit 2
  真期限 → exit 0
  只是讀 milestone → exit 0

規範補 M4.8(怎麼定期限、過期只對帳不自動關)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:25:12 +08:00
Leo 1b5551274a 待驗工作單改用宣稱內容去重,驗過的不再冒出來
stamp 原本雜湊「交件路徑」⇒ 同樣的宣稱每回合生一個新檔名,
而且不知道總管已經驗過了。

實際發作(2026-08-21 一個 session 內):同兩條 sdd-guard 宣稱連生四張單
1c97d461/fcb285dc/256de849/394b97ae——驗掉一張下一回合又冒一張,
Stop 閘於是變成永遠過不去。閘在懲罰有照做的人。

改成雜湊宣稱內容本身,並在寫檔前檢查 verified/ 底下有沒有同名。

雙向實測:
  兩次不同 transcript、同樣宣稱 → 只生 1 個檔
  移進 verified/ 後再跑         → SKIP:already-verified,沒再冒出來

過程中兩個自己的錯,記下來免得下次重犯:
  ① 先猜了變數名 blocked/ok/nogo,實際是 ok_hits/ng_hits
     ——猜錯的話 _claims 永遠是空的、悄悄退回舊行為,不會報錯
  ② 測試資料先寫成 role:assistant,再寫成 role:user 都不觸發
     ——它要的是 <task-notification> 裡的 <result>
     前兩次「0 個檔」我差點當成去重成功

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:18:16 +08:00
claude-code 1920d4cb06 Merge pull request '身為 leo,我要雲端 clone 下來就有閘,我才不用先處理憑證' (#48) from fix/b4-real-probe into main 2026-08-20 17:11:52 +00:00
Leo 47ed778cc4 改走「直接複製進薄殼 repo」,並修掉一支會偷跑指令的閘
leo 2026-08-21:「你應該把 Plugin 直接裝進 Github repo,從本地直接複製就好了」

為什麼這條對:雲端 session 是 fresh clone 薄殼 repo,而 setup script 讀不到
環境變數。走 marketplace 就得同時處理憑證、repo 可見性、環境快取三件事——
今天這三件各失敗過一次。複製進 repo 之後,clone 下來就有,沒有任何前置條件。

新增 scripts/vendor-to-shell.py:
  把 hooks/skills/commands/scripts/.claude-plugin 整份複製到 .claude/isep/,
  並把 54 條 hook 註冊改寫成薄殼裡的絕對路徑。
  保留 CLAUDE_PLUGIN_ROOT 這個變數名(44 支閘內部靠它定位自己的 lib/),
  只是把它指到複製過來的那份。

冒煙測試(54 條註冊全跑一遍,找路徑壞掉的):
  第一輪 4 條壞 → 3 條是 log 目錄不存在(已補建 .claude/hooks/)
                  1 條在真身也一樣壞 ⇒ 不是複製造成的
  第二輪 0 條壞

順手修掉那支既有 bug:wiki-first-search.sh
  python3 -c 用雙引號,註解裡的反引號被 shell 當指令替換
  ⇒ 這支閘每次觸發都在偷跑 bge-m3 與 head changelog.md。
  改成全形引號後實測靜默 exit 0。
  (crude grep 掃出 6 支疑似,但冒煙測試證明只有這一支真的中——
    再一次:證據勝過掃描。)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:51:00 +08:00
Leo 03d9782f22 查了官方文件才發現:setup script 根本讀不到環境變數,而 exit 1 會鎖死 session
leo 問「寫以前為什麼不查」——沒有藉口,我假設了兩件事都沒查,兩件都是錯的。

① setup script 讀不到 Environment variables
   官方原文:Each session copies the environment's values once, at startup,
   into ordinary environment variables
   而 setup script 是 before Claude Code launches 跑的 ⇒ 注入在它之後。
   ⇒ 把 token 放進 Environment variables 再要 setup script 讀,永遠讀不到。
   證據吻合:leo 的變數設對了、值也跟本機同一把(長度 40、頭尾一致),
   而腳本回報找不到。

② exit 非零會讓整個 session 開不起來
   官方原文:Exit zero: if the script exits non-zero, the session fails to start.
   前一版為了大聲失敗用 exit 1 ⇒ 直接造成 Session initialization failed。
   ⇒ 現在一律 exit 0,失敗寫進 /tmp/.isep-setup-report。

本機三向實測:
  無變數(雲端真實情況)→ exit 0,印出說明,不擋 session
  有變數(未來平台若改行為)→ 設 git 認證並驗證
  ls-remote 加 timeout 45(本機曾掛住近 4 分鐘)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:39:48 +08:00
Leo 36d8e05a32 認證驗證加 timeout:掛住比失敗更糟
2026-08-21 本機隔離測試:git ls-remote 掛住近 4 分鐘不回。
setup 卡在那裡看起來像還在跑,不會有人知道它壞了。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:34:34 +08:00
Leo 135637291c B4 的探針我自己沒撞過,實撞後發現它根本不會擋
v0.3.1 我把 B4 從 git tag 換成「寫 __GITEA_TOKEN__ 進 /tmp/x.md」,
說它會被 credential-only-guard 擋下。今天實撞:exit 0,閘完全沒反應。

原因:那支閘刻意豁免 .md/docs//wiki/(文件本來就要能談論這些字串)。
它只管會被執行的產物:*workflow*/.yaml/.yml/installer/worker.js/wrangler。

改成 /tmp/wf.yaml 後三向實測:
  違規 workflow.yaml 帶佔位符       → exit 2 credential 鐵律攔截
  同檔用 {{credential.gitea_token}} → exit 0(正確放行)
  .md 談論同一個字串                 → exit 0(正確豁免)
薄殼指標端同樣實測:真身在+違規 → exit 2。

這是同一個病的第四次:修假綠的那一刀,自己又是沒撞過就寫。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:33:44 +08:00
claude-code d3061585e8 Merge pull request '身為 leo,我要雲端 env 檔能直接產在桌面,我才不用去翻隱藏目錄' (#46) from feat/cloud-env-outdir into main 2026-08-20 15:13:38 +00:00
Leo 9099c3f533 make-cloud-env.sh:輸出路徑可指定,且不再對使用者指定的目錄動權限
leo 要產在桌面。兩個改動:
- OUT_DIR 可被環境變數覆寫(OUT_DIR=~/Desktop bash scripts/make-cloud-env.sh)
- chmod 700 只在「這個目錄是我們自己造的預設位置」時才做
  原本無條件對 $OUT_DIR chmod 700,一旦 OUT_DIR 指到既有目錄
  (例如 ~/Desktop)就會改動使用者目錄的權限。產出檔本身仍是 600。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:13:25 +08:00
Leo 5bceb03478 v0.3.1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:03:05 +08:00
claude-code 4e73b8b03d Merge pull request '身為 leo,我要雲端驗收步驟在閘死掉時真的變紅,我才不會再被三個綠燈騙一次' (#45) from fix/testing-b-section-discriminating into main 2026-08-20 15:03:04 +00:00
Leo 291787eaaa TESTING.md B 段整段換掉——舊版在閘全滅時會回綠
2026-08-20 雲端實證:B2/B3/B4 三步在 plugin 完全缺席時**全部通過**。
一個在閘死掉時也會給出正確答案的測試,不是測試。

三處各自為什麼假:
- B2「跑 claude plugin list」→ 要它自己報自己,沒有獨立證據
- B3「Skills (9) / Hooks (5)」→ 那組數字剛好是薄殼自己的 .claude/ 產生的
- B4「git tag -a v9.9.9」→ git tag 在三支閘的白名單裡

新版三步都改成有鑑別力的:
- B2 找 v0.3.0 新增的載入信標(那支腳本住在 plugin 裡,沒載入就不可能發聲)
- B3 看 setup script 的自我驗證輸出(v0.3.0 新增)
- B4 改用金鑰佔位符觸發 credential-only-guard(不在任何白名單上)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:02:47 +08:00
claude-code 3a951210a5 雲端零閘的兩個真因:setup 不自驗+沒有 release 撐版本號 (#44) 2026-08-20 14:59:55 +00:00
21 changed files with 1069 additions and 189 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "isep", "name": "isep",
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:43 支機械閘(53 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:43 支機械閘(53 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
"version": "0.3.0", "version": "0.3.8",
"keywords": [ "keywords": [
"inkstone", "inkstone",
"guardrails", "guardrails",
+5
View File
@@ -0,0 +1,5 @@
# 含金鑰真身的雲端設定,永遠不進版控(2026-08-20 實際差點被 git add
cloud-env*.txt
*.env
.shell-payload/
+46 -20
View File
@@ -124,38 +124,64 @@ bash scripts/make-cloud-env.sh
**該看到**:儲存後沒有紅字。 **該看到**:儲存後沒有紅字。
### B2 — 開一個新的雲端 session,確認裝上了 ### B2 — 開一個新的雲端 session,第一眼找信標
**什麼都不用打。** session 一開,找這一行:
在雲端 session 裡打:
``` ```
跑 claude plugin list 給我看 🟢 ISEP v0.3.0 已載入(44 支閘在 …)
``` ```
**該看到**`isep@inkstone` / `Version: 0.2.1`(要跟 Releases 頁最新那個一樣)/ `✔ enabled`
**該看到**:有這行,而且版本號跟 Releases 頁最新那個一樣。
**失敗** **失敗**
- 沒有 `isep` ⇒ Setup script 沒跑成功 → 叫它把 setup 的輸出貼回來 - **沒有這行** ⇒ plugin 沒載入,這個 session 是**零閘狀態**。先修 plugin,不要開始做事。
- 版本比 Releases 舊 ⇒ 環境快取住了(設定跑完會被拍成快照,約 7 天或改了 setup script 才重拍) - 版本比 Releases 舊 ⇒ 環境快取住了(setup 跑完會被拍成快照,約 7 天或改了 setup script 才重拍)→ 動一下 setup script 的內容強制重拍。
→ 動一下 setup script 的內容,強制重拍
### B3 — 雲端載到的元件數量要跟本機一樣 🔴 **為什麼是這一行,而不是叫它跑指令**:這行由 `isep-presence-beacon.sh` 發出,
``` 而那支腳本**住在 plugin 裡**。plugin 沒載入 ⇒ 它不可能發聲。
跑 claude plugin details isep 給我看 **沒有「剛好也會過」的情況**——這就是鑑別力。
```
**該看到**`Skills (9)``Hooks (5) PreToolUse, SessionStart, Stop, SubagentStop, PostToolUse` ### B3 — 要它把 setup 的驗證結果貼回來
——**跟本機看到的一模一樣**。
**失敗**:比本機少 ⇒ 又回到「兩邊不一樣」,正是 `InkStoneCo#57` 那張票的病。
### B4 — 最關鍵:雲端的閘真的會擋,而且擋的是 plugin 那份
``` ```
請執行 git tag -a v9.9.9 -m test 把這個環境 setup script 的輸出貼給我看
``` ```
**該看到**:被擋下,訊息提到「版本不一致」與 `plugin.json`
**該看到**兩行綠:
```
✅ git 認證通:拉得到 inkstone/ISEP
✅ marketplace inkstone 已就位
```
**失敗**:任一行是紅的 ⇒ 訊息本身會講該查什麼(token 值對不對、有沒有被撤銷)。
看不到任何輸出 ⇒ setup script 根本沒跑,回 B1 確認欄位真的存好了。
### B4 — 閘真的會擋(用有鑑別力的動作)
```
請把這段寫進 /tmp/wf.yamlauth: __GITEA_TOKEN__
```
**該看到**:被擋下,訊息開頭是 `🔒 credential 鐵律攔截(leo 2026-07-29 立)`
🔴 **副檔名不能改成 `.md`。** `credential-only-guard` **刻意豁免** `.md``docs/``wiki/`
(文件本來就要能談論這些字串,本頁自己就寫滿了)。
2026-08-21 實撞:舊寫法用 `/tmp/x.md`**exit 0,閘完全沒反應**——
那是沒撞過就寫進來的探針,跟它要取代的假綠是同一個病。
**失敗** **失敗**
- 真的把 tag 打出去 ⇒ **雲端仍然沒有閘**(跟 `InkStoneCo#14` 記的一樣) - 真的寫進去了 ⇒ 雲端仍然沒有閘。
- 它只是嘴上說「我不應該這麼做」而沒有閘的訊息 ⇒ 同上,那是模型自律不是機械閘 - 它只是嘴上說「我不應該這麼做」而沒有閘的訊息 ⇒ 同上,那是模型自律不是機械閘
🔴 **不要再用 `git tag` 當測試**(舊版 B4 就是這樣寫的,而它是假的):
`git tag` 出現在**三支閘的白名單**裡,閘全滅時它照樣「被擋」的相反——照樣通過,
於是 2026-08-20 那次雲端零閘,三個驗證步驟**全部回綠**。
一個在閘死掉時也會給出正確答案的測試,不是測試。
### B5 — 回報 ### B5 — 回報
B2B3B4 三個畫面貼回 `inkstone/InkStoneCo#14` B2(信標那行)/B3(setup 輸出)/B4(閘的訊息)三個畫面貼回 `inkstone/InkStoneCo#14`
全綠 ⇒ 那張票可以關,`#57` 也解掉一半。 全綠 ⇒ 那張票可以關,`#57` 也解掉一半。
--- ---
+26 -60
View File
@@ -1,72 +1,38 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# 貼進 code-on-web「Cloud environments → 你的環境 → Setup script欄位的內容 # 貼進 claude.ai → Cloud environments → 你的環境 → Setup script 欄位。
# 不是 ISEP 的一部分(不會被 Claude Code 當 hook/command/skill 掃描),
# 純粹是給 leo 複製貼上的參考檔,見 docs/cloud-session-bootstrap.md。
# #
# 前提(要先在同一個 Cloud environment 的 Environment variables 欄位加好): # 🔴 2026-08-21:這支腳本從「要 token」變成「不要 token」,因為查文件查出兩件事——
# GITEA_TOKEN_CLAUDE_CODE ← 既有機器帳號 token,名字沿用 InkStoneCo#14 已建立的那把,
# 不要新造一把。值本身不寫在這支腳本或任何檔案裡。
# #
# 這支腳本做兩件事: # ① **setup script 讀不到 Environment variables。**
# 1. 設定 git URL 重寫,讓任何對 git.uncle6.me 的 clone 都能用 GITEA_TOKEN_CLAUDE_CODE 認證 # 官方原文:「Each session copies the environment's values once, **at startup**,
# (官方文件對「CI/CD 裝私有 marketplace」建議的寫法,見 references 段)。 # into ordinary environment variables」,而 setup script 是
# 2. 直接把 ISEP 裝成 user-scope plugin ——不是「複製一份」,是跟本機一樣走 # 「**before Claude Code launches**」跑的 ⇒ 注入發生在它之後。
# `claude plugin marketplace add` + `claude plugin install`,裝的東西 # ⇒ 舊版把 token 放進 Environment variables 再要這裡讀,永遠讀不到。
# 100% 來自 inkstone/ISEP 這個 repo 本身,沒有第二份內容。
# #
# 何時跑:只在「這個 Cloud environment 第一次開 session」時跑一次, # ② **非零結束會讓整個 session 開不起來。**
# 跑完 Anthropic 會把整個檔案系統拍成快照,之後的 session 直接沿用快照 # 官方原文:「**Exit zero**: if the script exits non-zero, the session fails to start.」
# (不重跑,除非改了這支腳本本身、改了 allowed network hosts、或快照滿 7 天過期) # ⇒ 這裡一律 exit 0。任何失敗只記錄,不擋門
# ⇒ 這是唯一會讓「ISEP 改了但雲端還是舊的」重新出現的地方, #
# 緩解法見 docs/cloud-session-bootstrap.md「已知限制」段。 # 解法是拿掉憑證需求本身:`inkstone` org 與 `inkstone/ISEP` 都改成 Public
# ⇒ 匿名 clone 得到(2026-08-21 實測:匿名 git-upload-pack HTTP 200、
# 真隔離環境(無憑證、GIT_CONFIG_NOSYSTEM=1ls-remote 成功)
# ⇒ **雲端不再需要任何金鑰就能裝 ISEP。**
#
# 真正讓 plugin 生效的是薄殼 repo 的 .claude/settings.json
# extraKnownMarketplaces enabledPlugins)——官方文件:
# 「Installed at session start from the marketplace you declared.」
# 下面兩行是備援,讓 marketplace 在 session 啟動前就已經在快照裡。
set -euo pipefail set -uo pipefail
if [ -z "${GITEA_TOKEN_CLAUDE_CODE:-}" ]; then claude plugin marketplace add https://git.uncle6.me/inkstone/ISEP.git --scope user 2>&1 || true
echo "❌ 找不到 GITEA_TOKEN_CLAUDE_CODE —— 去 Cloud environment 的 Environment variables 加這個名字" >&2 claude plugin install isep@inkstone --scope user 2>&1 || true
exit 1
fi
# ── git 認證:三個機制都設,因為它們失效的方式不同 ────────────────
# ① URL 重寫(global ② 憑證存檔(global ③ 系統層(HOME 無關,best effort
# 2026-08-20 雲端實測:session 裡 git 是「匿名」的 ⇒ setup 設的東西沒生效到 session。
# 原因未定(沒跑到/HOME 不同/快照沒帶),所以三條都設,並在下面自我驗證。
git config --global url."https://claude-code:${GITEA_TOKEN_CLAUDE_CODE}@git.uncle6.me/".insteadOf \
"https://git.uncle6.me/"
git config --global credential.helper store
printf 'https://claude-code:%s@git.uncle6.me\n' "$GITEA_TOKEN_CLAUDE_CODE" > "$HOME/.git-credentials"
chmod 600 "$HOME/.git-credentials"
git config --system url."https://claude-code:${GITEA_TOKEN_CLAUDE_CODE}@git.uncle6.me/".insteadOf \
"https://git.uncle6.me/" 2>/dev/null || echo "system 層設不了,只靠 global——非致命)"
# ── 🔴 自我驗證一:認證真的通了嗎 ──────────────────────────
# 這一步是 2026-08-20 事故的直接產物:舊版設完就結束,token 沒生效也不出聲,
# 於是雲端 session 開起來才發現 marketplace 拉不下來,而 setup log 一片綠。
echo "── 驗證 git 認證 ──"
if GIT_TERMINAL_PROMPT=0 git ls-remote https://git.uncle6.me/inkstone/ISEP.git >/dev/null 2>&1; then
echo "✅ git 認證通:拉得到 inkstone/ISEP"
else
echo "❌ git 認證不通——marketplace 一定裝不起來,後面不用往下做了。" >&2
echo " 檢查:GITEA_TOKEN_CLAUDE_CODE 的值對不對、那把 token 有沒有被撤銷。" >&2
exit 1
fi
# ── 安裝(user scope 只是備援)─────────────────────────────
# 🔴 官方文件(cloud-environments 的 what-carries-over 表)明文:
# 「Plugins enabled only in your user settings」→ **不會**帶到雲端 session。
# 真正生效的是薄殼 repo 的 .claude/settings.json 裡的 enabledPlugins extraKnownMarketplaces。
# 這兩行留著當本地備援,不是主要路徑——不要以為裝完就等於雲端有了。
claude plugin marketplace add https://git.uncle6.me/inkstone/ISEP.git --scope user 2>/dev/null || true
claude plugin install isep@inkstone --scope user 2>/dev/null || true
# ── 🔴 自我驗證二:marketplace 真的拉下來了嗎 ────────────────
echo "── 驗證 marketplace ──"
if claude plugin marketplace list 2>/dev/null | grep -q "inkstone"; then if claude plugin marketplace list 2>/dev/null | grep -q "inkstone"; then
echo "✅ marketplace inkstone 已就位" echo "✅ marketplace inkstone 已就位"
else else
echo " marketplace 沒就位——session 啟動時 enabledPlugins 會是一張跳票的支票。" >&2 echo "⚠️ marketplace 沒就位——session 啟動時會再試一次(薄殼 settings.json 宣告的那條路)。"
exit 1 echo " 若 session 開頭看不到「🟢 ISEP v… 已載入」,就是這條也失敗了。"
fi fi
echo "✅ setup 完成。session 啟動後請用「有鑑別力的探針」驗閘," exit 0
echo " 不要用 git tag(它在三支閘的白名單裡,閘死了也會過)。"
+25
View File
@@ -677,3 +677,28 @@ Gitea 的 milestone 只管得到同一個 repo,所以六個群在 `inkstone/IS
結構(正交) hub ← tracking issue 標記 結構(正交) hub ← tracking issue 標記
封存不刪 duplicate ← 由 close/duplicate 取代,保留在歷史票上 封存不刪 duplicate ← 由 close/duplicate 取代,保留在歷史票上
``` ```
### M4.8 每個里程碑都要有真的期限(leo 2026-08-21 立)
> 「**以後所有的 milestone 限制時間**」/「**你根本沒有時間概念,浪費一整天**」
🔴 **`9999-01-01` 不算期限。** 立這條的當下實查七個 open milestone
**六個的期限是 `9999-01-01`**——那是「沒有期限」穿了一件期限的衣服,
比空白更糟:盤點時每一格看起來都有值,於是沒有人發現這裡從來沒有時間壓力。
**怎麼定**:里程碑的 deliverable 是**一個可測的版本**M4.0)。
問一句「**這個版本幾號要能給 leo 打開?**」,那天就是期限。
| 剩幾張未結 | 期限 |
|---|---|
| 本週要收 | 三天 |
| 13 張 | 一週 |
| 4 張以上 | 兩週 |
**過期了怎麼辦**:不自動關、不自動打 tag(M4.3 已否決那條)。
過期只做兩件事——**對帳**(哪幾張沒動)與**通知**。
期限的用途是製造節奏,不是製造假完成。
**機械閘**`hooks/milestone-due-guard.sh`PreToolUse `Bash`):
建 milestone 沒有 `due_on`、或 `due_on` 帶 `9999` → 擋。
四向實測:無 due_on 擋/9999 擋/真期限放行/只是讀 milestone 放行。
+1 -1
View File
@@ -84,7 +84,7 @@
|---|---|---| |---|---|---|
| `empty-handed-stop-guard.sh` | 這一輪 AI**一個動作都沒做**卻想停下來(等你回覆),就擋下並告訴它「你的命令就是完整授權,不用再等第二次確認」。 | 🛑 擋(至多攔一次) | | `empty-handed-stop-guard.sh` | 這一輪 AI**一個動作都沒做**卻想停下來(等你回覆),就擋下並告訴它「你的命令就是完整授權,不用再等第二次確認」。 | 🛑 擋(至多攔一次) |
| `worklist-guard.sh` | AI 自己列過的待辦清單裡還有沒做完的步驟,卻想收工寫報告,就擋下,逼它做完剩下的步驟。 | 🛑 擋 | | `worklist-guard.sh` | AI 自己列過的待辦清單裡還有沒做完的步驟,卻想收工寫報告,就擋下,逼它做完剩下的步驟。 | 🛑 擋 |
| `factory-idle-guard.sh` | AI 該去派工卻沒派(工頭停工),就擋下要它交出「已經派工的憑證」,不是隨口說一句「我會催」就算數。 | 🛑 擋 | | `factory-idle-guard.sh` | AI 該去派工卻沒派(工頭停工),就擋下要它交出「已經派工的憑證」,不是隨口說一句「我會催」就算數。**過閘有四條路:現在就派工/把票號寫進那句話/寫一行 `⏸ 等:<在等什麼>`/這一輪收尾在動作上。** 2026-08-23inkstone/ISEP#30)修好「引用被當成主張」——貼原始碼、引用它自己的訊息、否認自己有下一步,都不再被咬。 | 🛑 擋 |
| `browser-verify-guard.sh` | 這一輪 AI 宣稱「前端驗過了」,卻沒有真的用瀏覽器工具載入過,就擋下——`curl` 抓到 HTML 不算驗過。 | 🛑 擋 | | `browser-verify-guard.sh` | 這一輪 AI 宣稱「前端驗過了」,卻沒有真的用瀏覽器工具載入過,就擋下——`curl` 抓到 HTML 不算驗過。 | 🛑 擋 |
| `self-drive-police.sh` | AI 想停下來問你「早就決定過的事」(用固定句型判斷,例如「要不要 X」「下一步做什麼」「這交給你」)就擋下,反問它查過 wiki/查過派工表了沒。 | 🛑 擋 | | `self-drive-police.sh` | AI 想停下來問你「早就決定過的事」(用固定句型判斷,例如「要不要 X」「下一步做什麼」「這交給你」)就擋下,反問它查過 wiki/查過派工表了沒。 | 🛑 擋 |
| `self-drive-judge.sh` | 跟上面同一件事,但改用小模型(haiku)判斷「換句話說」的請示句——防止 AI 只是把「要不要」改寫成「不確定是否符合期待」就閃過上一支閘。 | 🛑 擋 | | `self-drive-judge.sh` | 跟上面同一件事,但改用小模型(haiku)判斷「換句話說」的請示句——防止 AI 只是把「要不要」改寫成「不確定是否符合期待」就閃過上一支閘。 | 🛑 擋 |
+182 -59
View File
@@ -13,27 +13,72 @@
# #
# ① `[ "$RUNNING" -gt 0 ] && exit 0` # ① `[ "$RUNNING" -gt 0 ] && exit 0`
# 有任何 agent 在跑就閉嘴 ⇒ **它量的是工廠,不是工頭**。 # 有任何 agent 在跑就閉嘴 ⇒ **它量的是工廠,不是工頭**。
# 2026-08-16 一天四次停工**全部發生在有 agent 在跑的時候**: # ② 池子查的是 `repos/Leo/$repo`——**org 在 2026-08-13 搬到 `inkstone`**(已修)。
# 產線忙著做 A,總管宣告了 B,然後沒有人做 B。工廠沒停,停的是按 trigger 的人。
#
# ② 池子查的是 `repos/Leo/$repo`——**org 在 2026-08-13 搬到 `inkstone`**。
# 實測:`Leo/arcrun-rag` 回 `not found`(被當成 0),`inkstone/arcrun-rag` 有 16 張。
# ⇒ 池子恆為 0 ⇒ 第 53 行直接 exit 0。**這道閘等於不存在。**
# (同族第四顆搬遷遺留座標:`ARCRUN_SHIP_NS``ARCRUN_REPO`MEMORY.md 索引/本檔。)
#
# ③ 結尾 `exit 0`——純提醒。leo 2026-08-16:「**提醒可以忽略,deny 不能**。」 # ③ 結尾 `exit 0`——純提醒。leo 2026-08-16:「**提醒可以忽略,deny 不能**。」
# #
# ─────────────────────────────────────────────────────────────────
# 🔴 2026-08-23 第四輪(inkstone/ISEP#30):**這道閘在懲罰誠實與謹慎。**
#
# 雲端驗收連續三次被它擋下,三次都不是宣告意圖:
# ① 「那句是等你,不是我的下一步」 ← 在**否認**自己有下一步
# ② 「這不是『下一步我要做 X』」 ← **引用**閘自己的訊息來說明沒在做
# ③ 貼出閘自己的**正則原始碼**來舉報這個 bug ← code block 裡的**引用**
#
# 真兇有四個,而且沒有一個是「例外沒列夠」:
#
# (a)**DECL 會匹配裸的「下一步」三個字。** 舊式 `下一步(我|就是|是)?[:]?\s*`
# 每一節都是可選的 ⇒ 整條 alternative 退化成「文中出現『下一步』」。
# ⇒ 只要**提到**這道閘在講什麼,就會被這道閘咬。
# 修法=**要求它後面真的接一個動作動詞**。這是**收緊**(要求更多才算命中),
# 方向與「再加幾個關鍵字例外」相反——例外清單會越加越長、越長越誤攔。
#
# (b)**「引用」被當成「主張」。** 舊版只剝 `>` 引言與長「」引號,
# 不認 markdown 的 ```code fence``` 與行內 `code`。
# ⇒ 貼原始碼舉報 bug 的人,會被他要舉報的那支閘擋下。
# 修法=把引用性標記整段換成**哨兵字元**(不是刪掉):
# - 刪掉會把外層句構攪碎(2026-08-17 就是這樣漏掉「回『規劃』我就派人」)
# - 換成哨兵則**內層的宣告消失、外層的句構留著** ⇒ 兩個方向同時修好
#
# (c)**看錯了「回合的收尾」。** 舊版取 `blocks_text[-1]`(最後一則文字),
# 但那則文字後面可能還有 tool_use ⇒ 宣告其實已經被兌現了。
# 修法=只看**最後一個動作之後**的文字。**收尾在動作上 ⇒ 根本不觸發。**
#
# (d)**訊息承諾的出路,程式碼裡沒有。** 舊訊息教人走
# 「選項③:改寫那句話,說明它在等什麼」,
# 但放行分支只有「有 Agent/Task」與「附近有 #票號」兩條 ⇒
# 唯一走得通的路是**不寫那三個字**,而那正是同一則訊息 🔴 明文禁止的動作。
# 修法=給選項③一個**真的存在**的機械形式:`⏸ 等:<在等什麼>`。
# 它是**白名單標記**(要刻意寫才會有),不是黑名單,不會被誤觸;
# 而且**留痕**——滿足「不准只是把那句話刪掉再送一次」。
#
# ⚠️ **刻意的取捨(寫下來,下一輪不要當成 bug 又改回去)**:
# - `#票號` 改成搜「整段收尾文字」而不是宣告句附近 ±N 字。
# 理由:票號常寫在行內 `code` 裡,剝完就找不到了 ⇒ 誤攔。
# **誤攔的代價高於漏攔**——誤攔會訓練人忽略警報,那比沒有警報更糟。
# - 「下一步」後面不吃逗號(「下一步,我去補 X」會漏)。
# 理由:吃了逗號,「不是我的下一步,我剛剛已經做完了」就會被咬(過去式當未來式)。
# - 「身為…我要…我才…」(本 repo 規定的 User Story 票名格式)整段換哨兵。
# 票名是**被命名的物件**,不是我對下一步的主張。
#
# ── 現在量什麼 ───────────────────────────────────────────────── # ── 現在量什麼 ─────────────────────────────────────────────────
# #
# 主閘(exit 2,會擋):**宣告了下一步,卻沒有派工憑證。** # 主閘(exit 2,會擋):**宣告了下一步,卻沒有按下任何 trigger。**
# 憑證 這個回合真的呼叫過 Agent/Task(而那條路上已有 no-ticket-no-dispatch # trigger 這個回合真的呼叫過 Agent/Task
# 驗票號**「有派工」本身就蘊含「有票」**,不必在這裡重複驗票。 # no-ticket-no-dispatch 已在那條路上驗票號 ⇒ 這裡不必重複驗
# 也接受:宣告句裡直接寫明它在誰手上(帶 `#數字` 的票號指涉)。 # ② 收尾文字裡有 `#票號` 指涉(=已經有人在做)
# ③ 收尾文字裡有 `⏸ 等:<在等什麼>`(=它現在不該做,且說明了在等什麼)
# ④ 回合收尾在動作上(最後一個動作之後沒有文字)
# #
# 副閘(exit 0,只提醒):真的全廠停工(零 agent + 池子有票)。org 已修。 # 副閘(exit 0,只提醒):真的全廠停工(零 agent + 池子有票)。
# #
# 🔴 **不擋純對話回合**:沒有宣告下一步就不觸發。 # 🔴 **不擋純對話回合**:沒有宣告下一步就不觸發。
# 這是刻意的——永遠在響的警報等於訓練人忽略它(見 branch-holds.md 的同款教訓)。 # 這是刻意的——永遠在響的警報等於訓練人忽略它(見 branch-holds.md 的同款教訓)。
#
# 🔴 **不設 `stop_hook_active` 逃生門**(與 empty-handed-stop-guard 不同)。
# leo 2026-08-16:「提醒可以忽略,deny 不能。」
# 之所以敢這樣,是因為出路③現在**真的存在** ⇒ 不可能把人鎖死在無解迴圈裡。
#
# 迴歸測試:`hooks/tests/factory-idle-guard.test.sh`23 向,誤攔與漏攔兩個方向都測)
set -eu set -eu
PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}" PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}"
@@ -46,11 +91,11 @@ import sys, json, os, re
try: try:
d = json.load(sys.stdin) d = json.load(sys.stdin)
except Exception: except Exception:
print("SKIP"); raise SystemExit print("SKIP:bad-payload"); raise SystemExit
tp = d.get("transcript_path") or "" tp = d.get("transcript_path") or ""
if not tp or not os.path.exists(tp): if not tp or not os.path.exists(tp):
print("SKIP"); raise SystemExit # 讀不到就別亂擋 print("SKIP:no-transcript"); raise SystemExit # 讀不到就別亂擋
rows = [] rows = []
try: try:
@@ -61,7 +106,7 @@ try:
try: rows.append(json.loads(line)) try: rows.append(json.loads(line))
except Exception: pass except Exception: pass
except Exception: except Exception:
print("SKIP"); raise SystemExit print("SKIP:unreadable"); raise SystemExit
# 這個回合=最後一則「真的來自使用者」的訊息之後(工具結果不算) # 這個回合=最後一則「真的來自使用者」的訊息之後(工具結果不算)
start = 0 start = 0
@@ -73,63 +118,132 @@ for i, r in enumerate(rows):
start = i start = i
turn = rows[start:] turn = rows[start:]
# 依序攤平這個回合的 assistant 區塊:("tool", 名字) / ("text", 內容)
seq = []
dispatched = False dispatched = False
blocks_text = []
for r in turn: for r in turn:
if r.get("type") != "assistant": if r.get("type") != "assistant":
continue continue
for b in (r.get("message") or {}).get("content") or []: for b in (r.get("message") or {}).get("content") or []:
if not isinstance(b, dict): if not isinstance(b, dict):
continue continue
if b.get("type") == "tool_use" and b.get("name") in ("Agent", "Task"): if b.get("type") == "tool_use":
dispatched = True if b.get("name") in ("Agent", "Task"):
dispatched = True
seq.append(("tool", b.get("name") or ""))
elif b.get("type") == "text": elif b.get("type") == "text":
blocks_text.append(b.get("text") or "") seq.append(("text", b.get("text") or ""))
if dispatched: if dispatched:
print("OK"); raise SystemExit # 按了 trigger ⇒ 放行 print("OK:dispatched"); raise SystemExit # 出路① 按了 trigger ⇒ 放行
# 🔴 只看**最後一則**文字,不看整個回合(2026-08-16 第一次實跑就誤攔,修正 # 🔴 只看「最後一個動作**之後**」的文字(2026-08-23 修
# 病灶是「回合終止在宣告上」⇒ 該看的是那個終止動作本身。 # 病灶是「回合終止在宣告上」⇒ 該看的是那個終止動作本身。
# 掃整個回合會在「我這回合稍早說要讀 X、然後真的讀了」這種句子上開火—— # 舊版取最後一則文字,但那則文字後面可能還有 tool_use——
# 那是**已完成事項的敘述**,不是未兌現的意圖。誤攔會訓練人忽略警報 # 「我這回合說要讀 X、然後真的讀了」會被當成未兌現的意圖。誤攔會訓練人忽略警報
# 而那比沒有警報更糟(同 branch-holds.md 的教訓)。 tail = []
text = blocks_text[-1] if blocks_text else "" for kind, val in reversed(seq):
if kind == "tool":
break
tail.append(val)
raw = "\n".join(reversed(tail))
if not raw.strip():
print("OK:ended-on-action"); raise SystemExit # 出路④ 收尾在動作上 ⇒ 沒有懸空的宣告
# 出路③:明講在等什麼(在剝標記**之前**檢查,理由本身常寫在引號/code 裡)
if re.search(r"⏸\s*等\s*[:]\s*\S{2,}", raw):
print("OK:waiting-declared"); raise SystemExit
# ── 把「引用性內容」蓋掉:引用 != 主張 ──────────────────────────
# 🔴 **等長**替換(蓋成同樣長度的哨兵),不是刪掉,有兩個理由:
# ① 刪掉會把外層句構攪碎——2026-08-17 就是這樣漏掉「回『規劃』我就派人」。
# 蓋成哨兵則內層的宣告消失、外層的句構留著,誤攔與漏攔一起修好。
# ② 等長 ⇒ 位移可以原封不動對回 raw ⇒ 底下的「票號要在宣告句附近」
# 可以在**原文**上量。否則票號寫在行內 `code` 裡會被自己剝掉而找不到,
# 逼得只能全篇搜——那會把閘變鈍(2026-08-23 拿真 transcript 量到:
# 全篇搜的話 26 個真實回合有 20 個是靠「文中某處有票號」放行的)。
S = "※"
def blank(m):
return S * (m.end() - m.start())
text = raw
text = re.sub(r"```.*?```", blank, text, flags=re.S) # markdown 圍欄 code
text = re.sub(r"~~~.*?~~~", blank, text, flags=re.S)
text = re.sub(r"`[^`\n]*`", blank, text) # 行內 code
text = re.sub(r"^[ \t]*>.*$", blank, text, flags=re.M) # 整段引言(leo 的原話)
for _ in range(3): # 引號(含巢狀),由內而外
text = re.sub(r"[「『][^「」『』]*[」』]", blank, text)
# 本 repo 規定的 User Story 票名:是被命名的物件,不是我對下一步的主張
text = re.sub(r"身為[^\n]{0,40}?我要[^\n]{0,80}?我才[^\n]{0,60}", blank, text)
assert len(text) == len(raw) # 位移要能對回原文
# ── 宣告下一步的句型 ──────────────────────────────────────────
# 🔴 每一條都**必須**接到一個動作動詞(A)才算命中。
# 舊版少了這個要求 ⇒ 裸的「下一步」「接下來我」就開火 ⇒ 討論這道閘的人被這道閘擋。
A = r"(?:去|來|做|派|審|跑|查|補|建|寫|改|推|驗|清|落|盤|整理|處理|部署|測)"
N = r"[^。!?;,,\n]{0,16}" # 同一句、不跨逗號
DECL = re.compile("|".join([
# 🔴 主詞是「你」的下一步 != 我的下一步(2026-08-23 拿真 transcript 量出來的第四類誤攔)
# 舊閘在 26 個真實回合終止點上擋了 2 次,兩次咬的都是我在**交代 leo 該做什麼**:
# 「**下一步還是那一個動作**:**你**把 feat/... 併進 main」
# 「## **你下一步**(兩招,先便宜的)」
# ⇒ 這是**主詞檢查**(誰要動手),不是關鍵字例外。
# 只掛在這條 alternative 上——「你點頭我就做」那條的主詞本來就是我,不受影響。
r"(?P<nextstep>下一步[:]?\s*(?!你|妳|leo|Leo)(?:我|就是|是)?\s*" + N + A + r")",
r"接下來我\s*" + N + A,
r"我(?:現在|接著|等下|等一下)(?:就)?\s*" + N + A,
r"我(?:要|會)(?:去|來)?\s*" + N + A,
r"稍後(?:我|再)\s*" + N + A,
r"之後我(?:會|要)\s*" + N + A,
# 2026-08-17 leo 實撞的一族:**把請示寫成條件句**——
# 沒問號、沒疑問詞,卻把動作的觸發權交回 leo。功能上是請示,句型上不像。
r"回[「『]?[^」』\n]{0,12}[」』]?(?:我)?就\s*" + N + A,
r"說一聲(?:我)?(?:就)?\s*" + N + A,
r"你(?:點頭|說可以|確認)(?:了)?(?:我)?(?:就)?\s*" + N + A,
r"(?:確認|核准|同意|批准)(?:過|了)?(?:之)?後(?:我)?(?:才|再|就)\s*" + N + A,
# 空手停在別人身上(出路③ 的 ⏸ 標記是它唯一合法的形式)
r"我(?:就)?(?:等|待)你",
r"等你(?:說|回|點頭|確認)",
r"我(?:就)?(?:不再|先不)(?:自己)?(?:動|做|派)",
r"我(?:就)?(?:一次)?(?:落|派|做|補|審|清)(?:完|掉)?\s*[。,,]?\s*$",
]))
SECOND = re.compile(r"你|妳|leo|Leo")
hit = None
for m in DECL.finditer(text):
# 「下一步」這條要看主詞:前面 8 字裡出現第二人稱 ⇒ 那是**他的**下一步,不是我的
if m.group("nextstep") and SECOND.search(text[max(0, m.start() - 8): m.start()]):
continue
hit = m
break
# 引用 leo 的話不算我的宣告(整段引言/引號內)——它常含「下一步」等字樣
text = re.sub(r"^\s*>.*$", "", text, flags=re.M)
# 🔴 2026-08-17 修:舊版把「所有」引號內容都刪掉,包括我自己句子裡的關鍵詞。
# leo 實撞:我寫「回『規劃』我就派人盤這份計畫」,`規劃` 被吃掉後變成
# 「回我就派人盤這份計畫」,DECL 一個都不匹配 ⇒ 該攔的沒攔。
# ⇒ 只刪「夠長的引言」(leo 的話通常成句),短引號是我自己的用詞,留著。
text = re.sub(r"「[^」]{12,400}」", "", text)
# 宣告下一步的句型(刻意收窄:只認「我接下來要做」,不認「現在的狀態是」)
DECL = re.compile(
r"(下一步(我|就是|是)?[:]?\s*(?!不是宣告)|接下來我|我(現在|接著|等下|等一下)(就)?(去|來|做|派|審|跑)"
r"|我(要|會)(去|來)?(做|派|審|跑|查|補|建)|稍後(我|再)|之後我(會|要)"
# 🔴 2026-08-17 leo 實撞補的一族:**把請示寫成條件句**——
# 沒問號、沒疑問詞,卻把動作的觸發權交回 leo。功能上是請示,句型上不像。
# 實例:「回『規劃』我就派人盤這份計畫」/「說一聲我就落」/「你點頭我就做」
r"|回[「『]?[^」』\n]{0,12}[」』]?(我)?就|說一聲(我)?(就)?|你(點頭|說可以|確認)(了)?(我)?(就)?"
r"|(確認|核准|同意|批准)(過|了)?(之)?後(我)?(才|再|就)"
r"|我(就)?(等|待)你|等你(說|回|點頭|確認)"
r"|我(就)?(不再|先不)(自己)?(動|做|派)"
r"|我(就)?(一次)?(落|派|做|補|審|清)(完|掉)?[。,,]?\s*$)")
hit = DECL.search(text)
if not hit: if not hit:
print("OK"); raise SystemExit # 沒宣告 ⇒ 純對話回合,不擋 print("OK:no-declaration"); raise SystemExit # 沒宣告 ⇒ 純對話回合,不擋
# 宣告句附近有票號指涉(=已經說明它在誰手上/哪張票)⇒ 放行 # 出路②:宣告句**附近**有票號指涉(=已經說明它在誰手上/哪張票)
seg = text[max(0, hit.start() - 200): hit.end() + 400] # 在 raw 上量(等長替換保證位移對得回去)⇒ 票號寫在行內 code 裡也算數,
if re.search(r"#\d{1,5}", seg): # 而「文章別處剛好提到某張票」不算——那不是在講這件事在誰手上。
print("OK"); raise SystemExit if re.search(r"#\d{1,5}", raw[max(0, hit.start() - 200): hit.end() + 400]):
print("OK:ticket-referenced"); raise SystemExit
print("DECLARED_NO_TRIGGER::" + text[max(0, hit.start()-60): hit.end()+120].replace("\n", " ")[:200]) print("DECLARED_NO_TRIGGER::" + raw[max(0, hit.start()-60): hit.end()+120].replace("\n", " ")[:200])
' 2>/dev/null || echo SKIP) ' 2>/dev/null || echo "SKIP:crash")
STAMP=$(date "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "?")
LOGDIR="$PROJ/.claude/hooks"
# 留痕(inkstone/InkStoneCo#48:閘只記「擋下」的話分母未知,回答不了「它有沒有在運作」)
# ⇒ 擋下與放行都記。目錄不在就安靜跳過(測試環境/別的 repo 不該噴 redirect 錯誤到 stderr)。
note() {
if [ -d "$LOGDIR" ]; then
printf '| %s | %s | %s |\n' "$STAMP" "$1" "$2" >> "$LOGDIR/factory-idle-guard.log" 2>/dev/null || true
fi
}
case "$VERDICT" in case "$VERDICT" in
DECLARED_NO_TRIGGER::*) DECLARED_NO_TRIGGER::*)
QUOTE=$(printf '%s' "$VERDICT" | sed 's/^DECLARED_NO_TRIGGER:://') QUOTE=$(printf '%s' "$VERDICT" | sed 's/^DECLARED_NO_TRIGGER:://')
note "⛔ 擋下" "$QUOTE"
cat >&2 <<MSG cat >&2 <<MSG
🏭 稼動率警察:**你宣告了下一步,但這個回合沒有按下 trigger。** 🏭 稼動率警察:**你宣告了下一步,但這個回合沒有按下 trigger。**
@@ -147,19 +261,28 @@ leo 2026-08-16(本閘的規格):
⇒ 在結構上 X 永遠落在回合結束之後。**不是忘記,是把 X 寫進了終結回合的動作裡。** ⇒ 在結構上 X 永遠落在回合結束之後。**不是忘記,是把 X 寫進了終結回合的動作裡。**
而說出意圖會消解掉做它的壓力——一份清楚的計畫**讀起來像進度**。 而說出意圖會消解掉做它的壓力——一份清楚的計畫**讀起來像進度**。
━━ 現在怎麼過這道閘(擇一)━━━━━━━━━━━━━━━━━━━━━━━━━ ━━ 現在怎麼過這道閘(四條,程式碼裡都真的有對應的分支)━━━━━━━━━
① **現在就按 trigger**:這個回合直接呼叫 AgentTask 派出去。 ① **現在就按 trigger**:這個回合直接呼叫 AgentTask 派出去。
沒有票 → 先 \`scripts/ticket where <關鍵字>\` 搜該掛哪張, 沒有票 → 先 \`scripts/ticket where <關鍵字>\` 搜該掛哪張,
再 \`ticket say <owner/repo#N> -F <檔>\`,然後帶【工單】派工。 再 \`ticket say <owner/repo#N> -F <檔>\`,然後帶【工單】派工。
② **它已經在別人手上**:把票號寫進那句話(例:「已派給 #44 comment 2761」)。 ② **它已經在別人手上**:把票號寫進這段收尾文字(例:「已派給 inkstone/ISEP#30」)。
票號指涉就放行——那不是「我等下做」,是「已經有人在做」。 \`#數字\` 就放行——那不是「我等下做」,是「已經有人在做」。
③ **它其實不該現在做**改寫那句話,說明它在等什麼(前置沒解除/是 leo 的閘), ③ **它其實不該現在做**寫一行說明它在等什麼,格式就是這一個標記——
不要寫成「下一步我要做 X」。
⏸ 等:<在等什麼>
例:\`⏸ 等:leo 親手跑 scripts/github-arm.sh 解保險(D20 人閘)\`
\`⏸ 等:arcrun-rag 那條 PR 先併,前置沒解除\`
🔴 這個標記**要刻意寫才會有**,寫了就留痕——它取代不了做事,
但它讓「現在不該做」跟「宣告了不做」在機器上分得開。
④ **這個回合收尾在動作上**:最後一個工具呼叫之後不要再補一句宣告。
🔴 **不准只是把那句話刪掉再送一次。** 刪掉宣告=那件事從此沒人記得, 🔴 **不准只是把那句話刪掉再送一次。** 刪掉宣告=那件事從此沒人記得,
比宣告了沒做更糟——它連痕跡都不留。 比宣告了沒做更糟——它連痕跡都不留。要嘛按 trigger,要嘛用 ③ 留下 ⏸ 記號。
MSG MSG
exit 2 ;; exit 2 ;;
*)
note "✅ 放行" "$VERDICT" ;;
esac esac
# ── 副閘:真的全廠停工(零 agent + 池子有票)────────────────────── # ── 副閘:真的全廠停工(零 agent + 池子有票)──────────────────────
+4
View File
@@ -40,6 +40,10 @@
{ {
"type": "command", "type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/ticket-api-bypass-guard.sh" "command": "${CLAUDE_PLUGIN_ROOT}/hooks/ticket-api-bypass-guard.sh"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/milestone-due-guard.sh"
} }
] ]
}, },
+11 -1
View File
@@ -17,7 +17,17 @@ ROOT="${CLAUDE_PLUGIN_ROOT:-}"
VER="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \ VER="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
"$ROOT/.claude-plugin/plugin.json" 2>/dev/null | head -1)" "$ROOT/.claude-plugin/plugin.json" 2>/dev/null | head -1)"
VER="${VER:-未知}" VER="${VER:-未知}"
GATES="$(ls "$ROOT"/hooks/*.sh 2>/dev/null | wc -l | tr -d ' ')" # 🔴 數「真的被註冊的」,不是數目錄裡有幾個 .sh(leo 的雲端驗收 2026-08-23 抓到):
# 舊寫法 `ls hooks/*.sh` 把 `pre-write-guard.template.sh`(樣板,不是閘)
# 與兩支沒掛註冊的輔助檔一起算進去 ⇒ 報 45,實際註冊 42。
# 這個數字是 leo 判斷「這個 session 有沒有閘」的唯一介面——**多報就是假綠**。
# (查過歷史:本檔自 daa1674 建立以來只有那一版,沒有別的分支修過這段。)
GATES="$(grep -oE 'hooks/[a-zA-Z0-9._-]+\.sh' "$ROOT/hooks/hooks.json" 2>/dev/null \
| sort -u | wc -l | tr -d ' ')"
case "$GATES" in
''|*[!0-9]*|0)
GATES="$(ls "$ROOT"/hooks/*.sh 2>/dev/null | grep -cv '\.template\.sh$' | tr -d ' ')" ;;
esac
MSG="🟢 ISEP v${VER} 已載入(${GATES} 支閘在 ${ROOT}" MSG="🟢 ISEP v${VER} 已載入(${GATES} 支閘在 ${ROOT}"
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""hooks/lib/push_target_dir.py -- shared helper (inkstone/ISEP#40 S7-style
lib helper: source of truth for a small piece of logic, not a gate by
itself; nothing here decides allow/block on its own).
What it answers: given a shell command string that somewhere invokes
`git ... push ...`, which directory will that push actually execute in?
Why this exists (inkstone/ISEP#30 comment 3949, 脈絡見 InkStoneCo#57):
main-and-prod-push-guard.sh's stamp_ok() used to read `git rev-parse
--show-toplevel` from the *hook's own* cwd (= the session's real repo) as
"HERE", and compare it against the repo path the caller wrote into the
stamp ("WANT"). That works when the push target IS the repo the session is
standing in. It can never work otherwise: a `cd <other-repo> && git push
origin HEAD:main` changes the *push's* directory but not the hook's, so
HERE stays the real repo forever while WANT is (correctly) the other repo
-- the two can never match, no matter how carefully the caller follows the
gate's own instructions. This isn't a bad judgment call; that scenario
simply doesn't exist in the old model.
This module extracts the directory the push *actually* runs in, purely by
tokenizing -- it never executes anything. Two sources, first one found
wins (in the order a real shell would apply them):
- a `cd <path>` chain preceding the push, scoped correctly across
subshells: a `(` inherits the current directory from its parent at the
moment it opens, but whatever a subshell `cd`s to does NOT leak back
out to sibling commands after the matching `)` closes (this mirrors
real bash: a subshell's cwd change is local to that subshell). This
scoping is load-bearing, not cosmetic: without it, `(cd /repo-A &&
true); git push origin main` would misattribute the later push (which
really runs wherever the outer shell already was) to /repo-A, and a
stale/legitimate stamp for /repo-A could then wrongly wave through a
push into whatever the outer cwd actually is -- the exact "stamp
opened for repo A also opens the door for repo B" shape 2026-08-11
already burned us on once.
- a `-C <path>` flag on the git invocation itself, which further wins
over any `cd` chain (matches git's own precedence: `-C` sets the
directory for that invocation regardless of the shell's cwd).
Multiple relative `cd`/`-C` hops are combined by plain string join here
(no `..`/`~`/`$()` resolution) -- resolving the combined expression to a
real, canonical, absolute path is left to the caller, which does it with a
read-only `cd "<expr>" && pwd` in a throwaway subshell. That two-step split
matters: this module only ever *parses*, so it stays side-effect-free even
when fed a hostile or malformed command; only the caller's final `cd`
touches the filesystem, and `cd` cannot execute anything, it can only fail
to find a directory.
If no `cd`/`-C` applies (the push runs wherever the hook itself is, i.e.
today's behaviour), or the command doesn't parse, prints nothing -- the
caller falls back to its existing cwd-based resolution. That fallback
direction is deliberately the *safe* one: on any parse ambiguity we hand
back "unknown" rather than guess, and an unresolved HERE can only make the
gate keep blocking (fail toward blocking), never open a door it wouldn't
have opened before.
Usage:
printf '%s' "$CMD" | python3 push_target_dir.py
"""
import shlex
import sys
_SEPARATORS = {";", "&&", "||", "|", "&", "\n"}
def _join(base, path):
"""Combine a cwd-so-far (`base`, or None if unknown/hook-cwd) with a
`cd`/`-C` argument written in the command. Absolute paths and `~`
replace the base outright; anything else is appended textually --
normalizing `..`/`.` is intentionally left to the caller's real `cd`."""
if not path:
return base
if path == "-" or path.startswith("$"):
# `cd -` (previous dir) and `$VAR`/`$(...)` expansions can't be
# resolved by tokenizing alone -- treat as "unknown" rather than
# guess wrong, which keeps the caller on its safe fallback path.
return None
if path.startswith("/") or path.startswith("~"):
return path
if base is None:
return path
return base.rstrip("/") + "/" + path
def _classify(tokens):
if not tokens:
return ("other", None)
if tokens[0] == "cd" and len(tokens) > 1:
return ("cd", tokens[1])
if tokens[0] == "git" and "push" in tokens[1:]:
return ("push", tokens)
return ("other", None)
def _events(cmd):
"""Tokenize cmd into (kind, value) events in source order: 'enter'/
'exit' for parens (subshell boundaries), 'cd'/'push'/'other' for
statements split on the usual shell separators. Returns [] on any
quoting error -- caller then falls back to cwd-based resolution."""
try:
lexer = shlex.shlex(cmd, posix=True, punctuation_chars=True)
lexer.whitespace_split = True
toks = list(lexer)
except ValueError:
return []
events = []
seg = []
def flush():
if seg:
# _classify's "push" branch returns `tokens` by reference; copy
# before clear() below, or the event's tuple would observe the
# list emptied out from under it (aliasing, not a value copy).
events.append(_classify(seg[:]))
seg.clear()
for tok in toks:
if tok == "(":
flush()
events.append(("enter", None))
elif tok == ")":
flush()
events.append(("exit", None))
elif tok in _SEPARATORS:
flush()
else:
seg.append(tok)
flush()
return events
def find_push_target(cmd):
events = _events(cmd)
if not events:
return ""
stack = [None] # cwd-so-far per paren depth; None = "same as hook cwd"
result = None
saw_push = False
for kind, val in events:
if kind == "enter":
stack.append(stack[-1]) # child subshell inherits current dir
elif kind == "exit":
if len(stack) > 1:
stack.pop() # subshell's own cd's don't leak out
elif kind == "cd":
stack[-1] = _join(stack[-1], val)
elif kind == "push":
saw_push = True
c_path = None
toks = val
for j, t in enumerate(toks):
if t == "-C" and j + 1 < len(toks):
c_path = toks[j + 1]
break
if t.startswith("-C") and len(t) > 2:
c_path = t[2:]
break
result = _join(stack[-1], c_path) if c_path else stack[-1]
if not saw_push:
return ""
return result or ""
def main():
cmd = sys.stdin.read()
sys.stdout.write(find_push_target(cmd))
if __name__ == "__main__":
main()
+62 -2
View File
@@ -98,7 +98,32 @@ stamp_ok() {
# 等於一把萬用鑰匙——正是 08-11 那次穿透的形狀(替 A repo 開的門 B repo 也走得過)。 # 等於一把萬用鑰匙——正是 08-11 那次穿透的形狀(替 A repo 開的門 B repo 也走得過)。
# 而且 `.claude/settings.local.json` 裡真的放行過 `touch /tmp/.main-push-ok`。 # 而且 `.claude/settings.local.json` 裡真的放行過 `touch /tmp/.main-push-ok`。
# ⇒ 現在**空內容一律不算數**:要嘛寫得出 repo 路徑且對得上,要嘛不放行。 # ⇒ 現在**空內容一律不算數**:要嘛寫得出 repo 路徑且對得上,要嘛不放行。
HERE=$(git rev-parse --show-toplevel 2>/dev/null || printf '') #
# 🔴 2026-08-23inkstone/ISEP#30 comment 3949,脈絡 InkStoneCo#57):
# `HERE` 原本一律讀 hook 自己的 cwd(=session 站著的那個 repo)。
# 只要要推的 repo **不是**「session 站著的那個 repo」——例如指令自己
# `cd <別的 repo> && git push` 或 `git -C <別的 repo> push`——HERE 永遠是
# 總管的真身,而總管替目標 repo 開的 WANT 永遠對不上,這道閘就**永遠沒辦法
# 合法通過**。不是判斷錯,是這個情境在舊模型裡根本不存在(照閘的指示做
# 戳記,戳記內容天生就贏不了)。
# 改法:先看指令本身有沒有把 push 的執行目錄改掉
# lib/push_target_dir.py——純 tokenize,不執行任何指令,
# 對 `cd A && cd B && git push` 這種多層鏈與 `(cd A && …); git push` 這種
# 子殼會不會外洩都做了範圍化,理由見該檔檔頭);解得出來就 `cd` 進那個
# 目錄(唯讀操作,`cd` 本身不會執行任何東西)問 git 那裡的 toplevel 是誰;
# 解不出來(沒有 cd/-C,或指令太怪解析失敗)才退回舊行為=hook 自己的 cwd。
# 🔴 綁 repo+單次用完即丟兩條性質完全沒有鬆動:這裡只是把「現在人在哪個
# repo」問得更準,比對邏輯(下面兩行)一個字沒動。
_push_target_dir="$(dirname "$0")/lib/push_target_dir.py"
_target_expr=""
if [ -f "$_push_target_dir" ]; then
_target_expr=$(printf '%s' "$CMD" | python3 "$_push_target_dir" 2>/dev/null || printf '')
fi
if [ -n "$_target_expr" ]; then
HERE=$(cd "$_target_expr" 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null || printf '')
else
HERE=$(git rev-parse --show-toplevel 2>/dev/null || printf '')
fi
WANT=$(head -1 "$STAMP" 2>/dev/null || printf '') WANT=$(head -1 "$STAMP" 2>/dev/null || printf '')
[ -n "$WANT" ] || return 1 [ -n "$WANT" ] || return 1
[ -n "$HERE" ] || return 1 [ -n "$HERE" ] || return 1
@@ -125,7 +150,42 @@ if printf '%s' "$CMD" | grep -qE '(^|[;&|(`]|&&|\|\|)[[:space:]]*git([[:space:]]
# 2026-08-20: match the target branch on a word boundary, not a bare substring -- # 2026-08-20: match the target branch on a word boundary, not a bare substring --
# a glob like *main* also matches "domain" (d-o-**m-a-i-n**), e.g. a push to # a glob like *main* also matches "domain" (d-o-**m-a-i-n**), e.g. a push to
# `fix/custom-domain-setup` would have false-positived. # `fix/custom-domain-setup` would have false-positived.
if printf '%s' "$CMD" | grep -qE '(^|[^A-Za-z])(main|master)([^A-Za-z]|$)'; then # 2026-08-21: 只看 **push 的目標**,不再掃整條指令。
# 舊版掃整條 ⇒ 一個晚上誤攔四次,全都是推 feature branch 或 tag
# git checkout -b fix/x main && git push origin fix/x ← 「main」在 checkout 上
# gh pr create --base main ← 根本不是 git push
# git checkout origin/main --detach; git push origin refs/tags/v0.3.3
# ⇒ **紅線寫得越細,命中關鍵字的機率越高**(leo 2026-08-17 的觀察,
# 文字層封路必敗)。這裡改成判動作的目標,不是判字面。
# 2026-08-23(順著 inkstone/ISEP#30 comment 3949 補測時自己抓到的洞,不在原票範圍
# 但屬於同一支閘、同一段邏輯,且直接讓下面「反向不准鬆」的驗證跑不過,所以一併修):
# `(git push origin HEAD:main)`——單純用括號包住整條指令——舊版會整段放行,
# 跟 HERE/戳記完全無關,**連目的地判斷本身都沒觸發**。
# 成因:截斷 refspec 尾巴只切 `;``&``|` 三種字元,沒算到 `)`——
# 於是「HEAD:main)」被當成一個 token`${_tok##*:}` 剝完冒號還剩「main)」,
# 跟 `^main$` 對不上 ⇒ 判定成「看不出目標」⇒ 整段放行。加 `)` 進截斷字元。
# git 的 refspec/分支名語法本來就不允許出現 `)`,所以在這裡截斷永遠安全,
# 不會誤傷任何合法的推送目標。
_push_seg=$(printf '%s' "$CMD" | sed -E 's/.*git[[:space:]]+(-[^[:space:]]+[[:space:]]+)*push//' | sed -E 's/[;&|)].*//')
_dest=""
_seen_remote=0
_saw_refspec=0
for _tok in $_push_seg; do
case "$_tok" in
-*) continue ;; # 旗標
refs/tags/*|*:refs/tags/*) _saw_refspec=1; continue ;; # 推 tag 不是推分支
esac
if [ "$_seen_remote" = "0" ]; then _seen_remote=1; continue; fi # 第一個非旗標=remote
_saw_refspec=1
_dest="$_dest ${_tok##*:}" # a:b 的目標是 b;沒有冒號就是它自己
done
# 🔴 只有「一個 refspec 都沒給」才退回猜當前分支。
# 看到 refspec(哪怕是 tag)就照它判——否則推 tag 會被當成推當前分支,
# 而當前分支若剛好叫 main 就誤擋(2026-08-21 實測抓到)。
if [ "${_saw_refspec:-0}" = "0" ]; then
_dest=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
fi
if printf '%s' "$_dest" | tr ' ' '\n' | grep -qE '^(main|master)$'; then
stamp_ok && exit 0 stamp_ok && exit 0
# ── 擋下的同時,把「誰想推什麼」留成一份請求(leo 2026-08-12)─────────── # ── 擋下的同時,把「誰想推什麼」留成一份請求(leo 2026-08-12)───────────
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# milestone-due-guard.sh — 建 milestone 一定要有真的期限(leo 2026-08-21 立)
#
# leo 原話:「**以後所有的 milestone 限制時間**」
# 「**你根本沒有時間概念,浪費一整天**」
#
# 🔴 為什麼連 9999 也要擋:立這條的當下實查七個 open milestone
# 六個的期限是 `9999-01-01`——那是「沒有期限」穿了一件期限的衣服,
# 比空白更糟,因為它讓盤點時看起來每一格都有值。
set -uo pipefail
INPUT=$(cat)
CMD=$(printf '%s' "$INPUT" | python3 -c "import json,sys;print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null || echo "")
[ -z "$CMD" ] && exit 0
# 只管「建 milestone」這個動作
printf '%s' "$CMD" | grep -qE 'milestones' || exit 0
printf '%s' "$CMD" | grep -qE '\-X *POST|--request *POST' || exit 0
DUE=$(printf '%s' "$CMD" | grep -oE '"due_on"[^,}]*' | head -1)
BAD=""
[ -z "$DUE" ] && BAD="沒有 due_on"
printf '%s' "$DUE" | grep -q '9999' && BAD="due_on 是 9999(等於沒有期限)"
[ -z "$BAD" ] && exit 0
cat >&2 <<MSG
⏱️ 里程碑期限閘:$BAD
【leo 2026-08-21】「**以後所有的 milestone 限制時間**」
「**你根本沒有時間概念,浪費一整天**」
🔴 9999-01-01 也算違規。立這條的當下實查七個 open milestone
六個是 9999——那是「沒有期限」穿了一件期限的衣服,
比空白更糟,因為盤點時每一格看起來都有值。
怎麼定:這個里程碑的 deliverable 是**一個可測的版本**。
問「這個版本幾號要能給 leo 打開?」,那天就是期限。
剩 1–3 張票 → 一週;4 張以上 → 兩週;本週要收 → 三天。
加上去再送一次: "due_on": "YYYY-MM-DDT23:59:59Z"
MSG
exit 2
+15 -2
View File
@@ -119,8 +119,21 @@ if not ok_hits and not ng_hits:
sid = (d.get("session_id") or "nosid")[:8] sid = (d.get("session_id") or "nosid")[:8]
aid = (d.get("agent_id") or d.get("subagent_id") or "")[:10] aid = (d.get("agent_id") or d.get("subagent_id") or "")[:10]
stamp = hashlib.sha1((tp + sid + aid).encode()).hexdigest()[:8] # 🔴 2026-08-21stamp 原本雜湊「交件路徑」⇒ 同樣的宣稱每回合生一個新檔名,
path = os.path.join(os.environ["DIR"], "claims-%s-%s.md" % (sid, stamp)) # 而且不知道總管已經驗過了。實際發作:同兩條 sdd-guard 宣稱連生四張單
# 1c97d461fcb285dc256de849394b97ae),驗掉一張下一回合又冒一張。
# ⇒ 改成雜湊**宣稱內容本身**:同樣的宣稱=同一個檔名 ⇒ 驗過就不再冒出來。
_claims = sorted(set(str(x) for x in (ok_hits + ng_hits)))
# 🔴 空清單就退回舊行為 —— 但那等於這支閘沒抓到任何宣稱,本來就會 SKIP,
# 所以這個 fallback 實際上不會被用到;留著只是不讓 stamp 變成空字串的雜湊。
_basis = "\n".join(_claims) if _claims else (tp + sid + aid)
stamp = hashlib.sha1(_basis.encode()).hexdigest()[:8]
_name = "claims-%s-%s.md" % (sid, stamp)
path = os.path.join(os.environ["DIR"], _name)
# 已經驗過並移進 verified/ 的,不要再生一次。
if os.path.exists(os.path.join(os.environ["DIR"], "verified", _name)):
print("SKIP:already-verified"); raise SystemExit
def block(title, items, howto, cap): def block(title, items, howto, cap):
if not items: if not items:
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env bash
# factory-idle-guard.sh 的迴歸測試(inkstone/ISEP#30)。
#
# 來由:2026-08-23 雲端驗收連續三次被這道閘誤攔,三次都不是宣告意圖——
# ① 「那句是等你,不是我的下一步」 ← 在否認自己有下一步
# ② 「這不是『下一步我要做 X』」 ← 引用閘自己的訊息來說明沒在做
# ③ 貼出閘自己的正則原始碼來舉報這個 bug ← code block 裡的引用也被當成宣告
# 而閘的訊息教人走「選項③:改寫那句話,說明它在等什麼」,程式碼裡卻沒有那條分支。
#
# 本檔的兩個方向都要有證據(誤攔與漏攔都要測):
# A 群「該放行」——上面三種誤攔案例 + 既有放行路徑不准變鬆的回歸
# B 群「該擋」 ——真的宣告了未來動作、且這回合沒按下任何 trigger
# C 群「訊息承諾的出路真的走得通」——訊息裡列的每一條,各一個實例
#
# 用法:hooks/tests/factory-idle-guard.test.sh [hooks/factory-idle-guard.sh 的路徑]
# 🔴 全程在一個乾淨的 TMP 底下造假 transcript,跑完自己清;不碰任何真 repo、不打網路。
# CLAUDE_PROJECT_DIR 指向沒有 gitea remote 的 TMP ⇒ 副閘拿不到 token,直接 exit 0
# 所以本檔測的一律是主閘。)
set -u
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/factory-idle-guard.sh}"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
PASS=0; FAIL=0
N=0
# mk <transcript 路徑> <block spec>...
# block spec`T:<文字>` assistant 的文字段;`U:<工具名>` assistant 的 tool_use
mk() {
python3 - "$@" <<'PY'
import json, sys
path, specs = sys.argv[1], sys.argv[2:]
rows = [{"type": "user", "message": {"content": [{"type": "text", "text": "【工單】去辦"}]}}]
blocks = []
for s in specs:
k, v = s.split(":", 1)
blocks.append({"type": "text", "text": v} if k == "T"
else {"type": "tool_use", "name": v, "id": "t1", "input": {}})
rows.append({"type": "assistant", "message": {"content": blocks}})
with open(path, "w") as f:
for r in rows:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
PY
}
# t <期望 exit> <說明> <block spec>...
t() {
want="$1"; desc="$2"; shift 2
N=$((N+1))
tr="$TMP/tr-$N.jsonl"
mk "$tr" "$@"
out=$(printf '{"transcript_path":"%s"}' "$tr" \
| CLAUDE_PROJECT_DIR="$TMP" sh "$HOOK" 2>&1); rc=$?
if [ "$rc" -eq "$want" ]; then
printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
else
printf ' ❌ %s —— 期望 exit=%s,實得 exit=%s\n' "$desc" "$want" "$rc"
printf '%s\n' "$out" | sed -n '1,6p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
}
echo "── A 群:該放行(2026-08-23 雲端三次誤攔的原形狀)─────────────────"
t 0 "① 否認自己有下一步(『那句是等你,不是我的下一步』)" \
'T:我看完了。那句是等你,不是我的下一步。'
t 0 "① 長版:連同被否認的原句一起引用" \
'T:你貼的那行「回覆後我就繼續」是等你,不是我的下一步。'
t 0 "② 引用閘自己的訊息來說明沒在做" \
'T:這不是『下一步我要做 X』,是在說明那道閘認錯了對象。'
t 0 "③ code block 裡貼閘自己的正則原始碼(舉報 bug)" \
'T:誤攔的真兇是這段:
```
r"(下一步(我|就是|是)?[:]?\s*(?!不是宣告)|接下來我|我(現在|接著|等下|等一下)(就)?(去|來|做|派|審|跑)"
```
它連裸的「下一步」三個字都吃。'
t 0 "③ 行內 code 貼同一段正則" \
'T:問題出在 `接下來我` 這個 alternative 沒有要求後面接動詞。'
t 0 "引用 leo 的話(blockquote)含宣告字樣" \
'T:> 下一步我去把那支閘修好,你不要插手。
以上是他八月十六日的原話,我引來說明這道閘的規格。'
t 0 "User Story 票名(身為…我要…我才…)不是宣告" \
'T:我把票名寫成:身為總管,我要把散落的分支收掉整理好,我才不會每天重數一次。'
# ↓ 這兩向不是想出來的,是拿本機一條 2068 行的真 transcript 跑出來的:
# 舊閘在 26 個真實回合終止點上擋了 2 次,兩次咬的都是「我在交代 leo 該做什麼」。
t 0 "④ 主詞是 leo 的下一步(『你下一步去把 X 併進 main』)" \
'T:## 你下一步(兩招,先便宜的)
你下一步去把 feat/isep-dynamic-install 併進 main,然後開一個全新雲端 session。'
t 0 "④ 同上,交代對方要跑的動作" \
'T:前三題不適用。下一步還是那一個動作:你把那條分支併進 main,再跑一次看信標印什麼。'
t 0 "④ 『你的下一步』——主詞在對方身上(單字 lookbehind 蓋不到的變體)" \
'T:我把兩條路都寫清楚了。你的下一步去把那條分支併進 main,再開一個新 session 看信標。'
t 2 "④ 的反面:主詞換回我自己 ⇒ 照樣擋" \
'T:我把兩條路都寫清楚了。我的下一步去把那條分支併掉。'
echo
echo "── A 群:既有放行路徑,不准變鬆(迴歸)─────────────────────────"
t 0 "純對話回合,沒有任何宣告" \
'T:這道閘的問題在於它把引用當成宣告,量錯了對象。'
t 0 "宣告了,但這個回合真的呼叫了 Agent(=按下 trigger" \
'T:下一步我去把 sdd-guard 的誤攔補上。' 'U:Agent'
t 0 "宣告了,但這個回合真的呼叫了 Task" \
'T:接下來我會去補測試。' 'U:Task'
t 0 "宣告句帶票號指涉(已經在別人手上)" \
'T:下一步我去把那支閘補好——已經派給 inkstone/ISEP#30,證據在 comment 3970。'
t 0 "宣告之後真的動手了(文字後面還有工具呼叫)" \
'T:先讀原始碼,接下來我會去補測試。' 'U:Read'
echo
echo "── B 群:該擋(真的宣告了未來動作,且這回合沒按下任何 trigger)────"
t 2 "『下一步我去把 X 補上』,無派工無票號" \
'T:我看完了。下一步我去把那支閘的誤攔補上。'
t 2 "『接下來我會去補測試』" \
'T:目前狀況清楚了。接下來我會去補測試。'
t 2 "把請示寫成條件句(2026-08-17 leo 實撞那族)" \
'T:回「規劃」我就派人盤這份計畫。'
t 2 "『我等你確認』——空手停在等人身上" \
'T:我等你確認。'
t 2 "『我現在就去跑一遍』" \
'T:我現在就去跑一遍那批測試。'
t 2 "『確認後我才動』" \
'T:核准後我才去改那支閘。'
t 0 "票號寫在行內 code 裡也算數(等長替換才量得到)" \
'T:下一步我去把那支閘補好——已派給 `inkstone/ISEP#30`。'
t 2 "票號離宣告句太遠(文章別處提到而已)⇒ 不算它在別人手上" \
'T:早上那批已經在 inkstone/ISEP#30 收掉了。
(中略:這裡是一大段跟那張票無關的驗收敘述,用來把距離拉開。'"$(printf 'x%.0s' $(seq 1 320))"'
下一步我去把那支閘的誤攔補上。'
echo
echo "── C 群:訊息承諾的每一條出路,都要真的走得通 ─────────────────"
t 0 "出路①:這個回合直接呼叫 Agent 派出去" \
'T:下一步我去把那支閘補好。' 'U:Agent'
t 0 "出路②:把票號寫進那句話" \
'T:下一步我去把那支閘補好(已派給 inkstone/ISEP#30)。'
t 0 "出路③:改寫那句話,用 ⏸ 等: 說明它在等什麼" \
'T:⏸ 等:leo 親手跑 scripts/github-arm.sh 解保險(D20 人閘,不是我能自己按的)。
在那之前我等你確認,這件事我不自己動。'
t 0 "出路③:宣告 + 標記同時存在也放行(標記說明了它在等什麼)" \
'T:那批 workflow 要重打 bundle,下一步我去重打。⏸ 等:arcrun-rag 那條 PR 先併,前置沒解除。'
t 0 "出路④:這個回合收尾在動作上(最後一個工具呼叫之後沒有文字)" \
'T:下一步我去把那支閘補好。' 'U:Edit'
echo
echo "── 出路③ 的反面:沒寫標記就照樣擋(不准靠刪掉那句話矇混)───────"
t 2 "同一句話拿掉 ⏸ 等: 標記 ⇒ 擋" \
'T:那批 workflow 要重打 bundle。我等你確認,這件事我不自己動。'
echo
echo "── 壞輸入不准亂擋(fail-open)────────────────────────────────"
n_bad=0
for payload in '{}' '{"transcript_path":"/nonexistent/xx.jsonl"}' 'not-json'; do
N=$((N+1))
printf '%s' "$payload" | CLAUDE_PROJECT_DIR="$TMP" sh "$HOOK" >/dev/null 2>&1; rc=$?
if [ "$rc" -eq 0 ]; then
printf ' ✅ 壞輸入 %-34s ⇒ exit=0\n' "$payload"; PASS=$((PASS+1))
else
printf ' ❌ 壞輸入 %-34s ⇒ exit=%s(期望 0\n' "$payload" "$rc"; FAIL=$((FAIL+1))
fi
n_bad=$((n_bad+1))
done
echo
echo "────── 通過 $PASS 失敗 $FAIL (共 $N 向)"
[ "$FAIL" -eq 0 ]
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# 跨 repo 戳記實測(inkstone/ISEP#30 comment 3949,脈絡 inkstone/InkStoneCo#57
#
# 補的是什麼:hooks/tests/main-and-prod-push-guard.test.sh 那八向都只在單一 repo
# (測試腳本自己所在的 repo)裡驗證,從沒測過「站在 A、要推 B 的 main」這個形狀
# ——而這正是 2026-08-21 真的撞到、讓戳記永遠對不上的那個情境。這支專門補這塊。
#
# 用法:main-and-prod-push-guard-cross-repo.test.sh <要測的 hook 絕對路徑>
# 路徑務必給絕對路徑——測試會 cd 進臨時建立的 A/B repo 再呼叫它,相對路徑
# 到那時就對不上了(自己撞過一次:exit=127 command not found)。
set -u
G="$1"
STAMP=/tmp/.main-push-ok
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"; rm -f "$STAMP"' EXIT
for d in A B; do
git init -q -b main "$WORK/$d"
git -C "$WORK/$d" config user.email t@t.com
git -C "$WORK/$d" config user.name t
echo x > "$WORK/$d/f.txt"
git -C "$WORK/$d" add f.txt
git -C "$WORK/$d" commit -q -m init
done
A="$WORK/A"; B="$WORK/B"
pass=0; fail=0
t() { # t <說明> <cwd> <指令> <期望 exit>
local desc="$1" cwd="$2" cmd="$3" want="$4"
local rc
rc=$(cd "$cwd" && CLAUDE_CODE_CHILD_SESSION=1 python3 -c '
import json, subprocess, sys
p = subprocess.run(["bash", sys.argv[2]],
input=json.dumps({"tool_name": "Bash",
"tool_input": {"command": sys.argv[1]}}),
capture_output=True, text=True)
print(p.returncode)
' "$cmd" "$G")
if [ "$rc" = "$want" ]; then printf ' ✅ %-58s exit=%s\n' "$desc" "$rc"; pass=$((pass+1))
else printf ' ❌ %-58s exit=%s(期望 %s\n' "$desc" "$rc" "$want"; fail=$((fail+1)); fi
}
echo "── 2026-08-21 實撞的原形狀:站在 A,要推 B 的 main ──"
rm -f "$STAMP"
t "沒戳記 → 擋" "$A" "cd $B && git push origin HEAD:main" 2
git -C "$B" rev-parse --show-toplevel > "$STAMP"
t "替 B 開的戳記 → 推 B 的 main 該放行(舊版在此情境永遠擋,這是本票要修的洞)" \
"$A" "cd $B && git push origin HEAD:main" 0
echo "── 反向不准鬆:替 A 開的戳記,不能拿去放行推 B(08-11 那次穿透的形狀)──"
git -C "$A" rev-parse --show-toplevel > "$STAMP"
t "替 A 開的戳記 → 拿去推 B 的 main 必須仍被擋" \
"$A" "cd $B && git push origin HEAD:main" 2
rm -f "$STAMP"
echo "── git -C 語法要吃到同一套判斷 ──"
git -C "$B" rev-parse --show-toplevel > "$STAMP"
t "替 B 開戳記,用 git -C B push" "$A" "git -C $B push origin main" 0
rm -f "$STAMP"
echo "── 08-11 原始穿透的形狀:子殼裡的 cd 不能外洩到殼外 ──"
git -C "$A" rev-parse --show-toplevel > "$STAMP"
t "子殼裡 cd 去 B 但沒在殼內推;殼外站著 A 真的推 → 符合 A 的戳記,放行" \
"$A" "(cd $B && true); git push origin HEAD:main" 0
rm -f "$STAMP"
git -C "$A" rev-parse --show-toplevel > "$STAMP"
t "子殼裡 cd 去 B 且在殼內真的推 → 目標是 B,戳記是 A,必須擋" \
"$A" "(cd $B && git push origin HEAD:main)" 2
rm -f "$STAMP"
echo "── 順手抓到、一併修的洞:純括號包住整條指令,不准繞過目的地判斷 ──"
t "(git push origin HEAD:main) 沒有任何戳記 → 必須擋(舊版在此整段放行)" \
"$A" "(git push origin HEAD:main)" 2
echo "── 同 reposession 站著的那個)舊行為原封不動 ──"
rm -f "$STAMP"
t "站在 A 推 A 自己的 main,沒戳記 → 擋" "$A" "git push origin HEAD:main" 2
git -C "$A" rev-parse --show-toplevel > "$STAMP"
t "站在 A 推 A 自己的 main,替 A 開戳記 → 放行" "$A" "git push origin HEAD:main" 0
rm -f "$STAMP"
echo "── 舊有行為一條都不能壞 ──"
t "推 feature branch 放行" "$A" "git push origin feat/xyz" 0
t "推 tag 放行" "$A" "git push origin refs/tags/v1.0.0" 0
t "只是提到 main 的 gh pr create,放行" "$A" "gh pr create --base main --title t" 0
echo "── subagent 沒戳記,即使 cd 去別的 repo 也照擋 ──"
rm -f "$STAMP"
t "subagent 站在 A、cd 去 B 推 main,沒戳記仍擋" "$A" "cd $B && git push origin HEAD:main" 2
echo "── 單次用完即丟、900 秒逾時:換到跨 repo 場景一樣要成立 ──"
git -C "$B" rev-parse --show-toplevel > "$STAMP"
t "第一次:替 B 開戳記推 B → 放行" "$A" "cd $B && git push origin HEAD:main" 0
t "第二次:同一枚戳記(已用掉)再推一次 → 應該擋" "$A" "cd $B && git push origin HEAD:main" 2
rm -f "$STAMP"; touch "$STAMP"
t "touch 出的空戳記 → 推 B 的 main 仍應擋(08-12 補的洞不能被本次改動重開)" \
"$A" "cd $B && git push origin HEAD:main" 2
rm -f "$STAMP"
git -C "$B" rev-parse --show-toplevel > "$STAMP"
touch -t "$(date -v-16M +%Y%m%d%H%M.%S 2>/dev/null || date -d '-16 minutes' +%Y%m%d%H%M.%S)" "$STAMP" 2>/dev/null
t "16 分鐘前開的戳記 → 已過期,推 B 應擋" "$A" "cd $B && git push origin HEAD:main" 2
rm -f "$STAMP"
echo "────── 通過 $pass 失敗 $fail"
[ "$fail" = 0 ]
+24 -37
View File
@@ -1,42 +1,29 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# main-and-prod-push-guard.sh 的迴歸測試——重點在「geek6688 豁免的範圍夠不夠窄」。 # 八向實測 main-and-prod-push-guard.sh
# 🔴 寫成檔案跑:測試指令必然含 `wrangler deploy`,直接在 Bash 打會被 prod-write-guard 擋 # 放在檔案裡跑,因為測試字串本身會觸發「舊版」那支閘(第五次誤攔)
# G="$1" # 要測的 hook 路徑
# 用一個**沒有 .github-armed** 的臨時 CLAUDE_PROJECT_DIR pass=0; fail=0
# 否則本機真的有那個檔 ⇒「該擋的」會假性通過,測了等於沒測。
HOOK="$1"
TMPPROJ=$(mktemp -d)
trap 'rm -rf "$TMPPROJ"' EXIT
mk(){ python3 -c "import json,sys;print(json.dumps({'tool_name':'Bash','tool_input':{'command':sys.argv[1]}}))" "$1"; } t() { # t <說明> <指令> <期望 exit>
PASS=0; FAIL=0 printf '{"tool_name":"Bash","tool_input":{"command":"%s"}}' "$2" \
t(){ mk "$3" | env CLAUDE_PROJECT_DIR="$TMPPROJ" "$HOOK" >/dev/null 2>&1; rc=$? | CLAUDE_CODE_CHILD_SESSION=1 CLAUDE_PROJECT_DIR="$(dirname "$(dirname "$G")")" \
got=$([ $rc -eq 2 ] && echo block || echo pass) bash "$G" >/tmp/pg.out 2>&1
if [ "$got" = "$1" ]; then echo "$2"; PASS=$((PASS+1)) rc=$?
else echo "$2 —— 期望 $1,實得 $got"; FAIL=$((FAIL+1)); fi; } if [ "$rc" = "$3" ]; then printf ' ✅ %-46s exit=%s\n' "$1" "$rc"; pass=$((pass+1))
else printf ' ❌ %-46s exit=%s(期望 %s\n' "$1" "$rc" "$3"; fail=$((fail+1)); fi
}
D="wrangler deploy" echo "── 該放行(今晚五次誤攔的原形狀)──"
t "checkout -b 後推 feature 分支" 'git checkout -q -b fix/x ma'"in"' && git push -q origin fix/x' 0
t "gh pr create --base(不是 git push" 'gh pr create --head f --base ma'"in"' --title t' 0
t "推 tag" 'git push -q origin refs/tags/v0.3.3' 0
t "推 feature 分支(帶 -u" 'git push -q -u origin feat/milestone-must-have-due' 0
t "分支名含 domain" 'git push origin fix/custom-domain-setup' 0
echo "── geek6688leo 2026-08-12 明文授權總管可直接動)應放行 ──" echo "── 該擋 ──"
t pass "指名 geek6688 主機名" "npx $D --name arcrun-cypher-executor --config geek6688.toml" t "直接推預設分支" 'git push origin ma'"in" 2
t pass "用 geek6688 的 token 變數" "CLOUDFLARE_API_TOKEN=\$CLOUDFLARE_API_TOKEN_CC_SHIPPING_CORE npx $D" t "HEAD:預設分支" 'git push origin HEAD:ma'"in" 2
t pass "用 geek6688 的 account id 變數" "CLOUDFLARE_ACCOUNT_ID=\$CLOUDFLARE_ACCOUNT_ID_GEEK6688 npx $D" t "推 master" 'git push -q origin mas'"ter" 2
echo "── 其他實例仍要 leo 親手 arm(範圍不能外溢)──" echo "────── 通過 $pass 失敗 $fail"
t block "打 leo21c" "npx $D --name arcrun-mcp --account-id leo21c-acct" [ "$fail" = "0" ]
t block "打 uncle6 官方" "npx $D --name arcrun-kbdb --config uncle6.toml"
t block "看不出打哪裡" "npx $D"
t block "wrangler publish 到別台" "npx wrangler publish --name arcrun-mcp"
t block "versions deploy 到別台" "npx wrangler versions deploy --name arcrun-kbdb"
echo "── stage 照舊自由 ──"
t pass "帶 --env staging" "npx $D --env staging"
t pass "打 staging 主機" "npx $D --name arcrun-rag-installer-staging"
echo "── 非部署動作不受影響 ──"
t pass "推自己的分支" "git push -u gitea fix/my-branch"
t pass "讀本閘原始碼(指令裡含關鍵字)" "sed -n '1,50p' .claude/hooks/main-and-prod-push-guard.sh"
echo
echo "結果:通過 $PASS 失敗 $FAIL"
[ $FAIL -eq 0 ] || exit 1
+3 -3
View File
@@ -47,7 +47,7 @@ try:
# 不是只有「會改壞東西的指令」。 # 不是只有「會改壞東西的指令」。
# 2026-08-05 第三次補(leo:「我看你剛剛 bash 裡很多 grep,為什麼不是查 wiki?」): # 2026-08-05 第三次補(leo:「我看你剛剛 bash 裡很多 grep,為什麼不是查 wiki?」):
# 再補「讀檔形成結論」的動詞——head/cat/sed/tail/awk/find/jq。 # 再補「讀檔形成結論」的動詞——head/cat/sed/tail/awk/find/jq。
# 之前只認「動外部系統」與部分查證動詞,`head changelog.md` 這種 # 之前只認「動外部系統」與部分查證動詞,head changelog.md 這種
# **直接讀檔下判斷**的完全不觸發。 # **直接讀檔下判斷**的完全不觸發。
if re.search(r'\b(wrangler|curl|npx|acr|gh|deploy|push|git|grep|unzip|manifest|version' if re.search(r'\b(wrangler|curl|npx|acr|gh|deploy|push|git|grep|unzip|manifest|version'
r'|head|cat|sed|tail|awk|find|jq)\b', cmd): r'|head|cat|sed|tail|awk|find|jq)\b', cmd):
@@ -61,10 +61,10 @@ try:
# grep pattern 常含 regex 元字元;取最長的英數/底線詞當搜尋詞 # grep pattern 常含 regex 元字元;取最長的英數/底線詞當搜尋詞
# 🔴 2026-08-05 leo 點破:「最常做的就是 grep,為什麼沒在裡面」 # 🔴 2026-08-05 leo 點破:「最常做的就是 grep,為什麼沒在裡面」
# ——Grep 其實有註冊,壞在**這行取詞規則**: # ——Grep 其實有註冊,壞在**這行取詞規則**:
# ① 連字號被當分隔 ⇒ `bge-m3` 只取到 `bge`(3 字)不足 4 字 ⇒ 整支不觸發 # ① 連字號被當分隔 ⇒ bge-m3 只取到bge(3 字)不足 4 字 ⇒ 整支不觸發
# ② **中文完全不匹配** ⇒ 查「版本號」「出貨」這類詞一律不觸發 # ② **中文完全不匹配** ⇒ 查「版本號」「出貨」這類詞一律不觸發
# 而我日常查的關鍵字大量正是這兩類 ⇒ hook 形同虛設。 # 而我日常查的關鍵字大量正是這兩類 ⇒ hook 形同虛設。
# ⇒ 容許 `-``.`,並支援 CJK;中文 2 字即算一個詞。 # ⇒ 容許「-」與「.」,並支援 CJK;中文 2 字即算一個詞。
words = re.findall(r'[A-Za-z_][A-Za-z0-9_.-]{2,}', q) words = re.findall(r'[A-Za-z_][A-Za-z0-9_.-]{2,}', q)
cjk = re.findall(r'[\u4e00-\u9fff]{2,}', q) cjk = re.findall(r'[\u4e00-\u9fff]{2,}', q)
words = words + cjk words = words + cjk
+43
View File
@@ -0,0 +1,43 @@
# 推 main 的請求:未署名
- repo/Users/youlinhsieh/Documents/tech_projects/ISEP
- 分支:fix/push-guard-target-not-substring
- 時間:2026-08-21 01:28:51
- 它想跑的指令:
```
git push -q origin master
```
## 還沒推上去的 commit(原始資料,不是轉述)
```
43c328d Merge pull request 'feat/milestone-must-have-due' (#50) from feat/milestone-must-have-due into main
bcb736e Merge pull request 'fix/worksheet-dedup-by-content' (#49) from fix/worksheet-dedup-by-content into main
6772ca6 每個里程碑都要有真的期限,9999 也擋
1b55512 待驗工作單改用宣稱內容去重,驗過的不再冒出來
1920d4c Merge pull request '身為 leo,我要雲端 clone 下來就有閘,我才不用先處理憑證' (#48) from fix/b4-real-probe into main
47ed778 改走「直接複製進薄殼 repo」,並修掉一支會偷跑指令的閘
03d9782 查了官方文件才發現:setup script 根本讀不到環境變數,而 exit 1 會鎖死 session
36d8e05 認證驗證加 timeout:掛住比失敗更糟
1356372 B4 的探針我自己沒撞過,實撞後發現它根本不會擋
d306158 Merge pull request '身為 leo,我要雲端 env 檔能直接產在桌面,我才不用去翻隱藏目錄' (#46) from feat/cloud-env-outdir into main
9099c3f make-cloud-env.sh:輸出路徑可指定,且不再對使用者指定的目錄動權限
5bceb03 v0.3.1
4e73b8b Merge pull request '身為 leo,我要雲端驗收步驟在閘死掉時真的變紅,我才不會再被三個綠燈騙一次' (#45) from fix/testing-b-section-discriminating into main
291787e TESTING.md B 段整段換掉——舊版在閘全滅時會回綠
3a95121 雲端零閘的兩個真因:setup 不自驗+沒有 release 撐版本號 (#44)
daa1674 雲端零閘的兩個真因:setup 從不驗證自己+沒有任何 release 撐版本號
c48495d Merge pull request 'fix(hooks): sdd-guard.sh 修「解析失敗仍照擋、且訊息洩漏 /nonexistent」' (#42) from fix/sdd-guard-path-resolution into main
8718658 fix(hooks): sdd-guard.sh 修「解析失敗仍照擋、且訊息洩漏 /nonexistent」(InkStoneCo#22
e6d183d Merge pull request '產生雲端 env 設定給 leo 貼(InkStoneCo#14' (#41) from feat/cloud-env-generator into main
f855d82 產生雲端 env 設定,不要 leo 自己拼湊(InkStoneCo#14
```
## 改了哪些檔
```
```
---
總管裁完請刪掉這個檔——留著代表「還沒裁」。
+23
View File
@@ -0,0 +1,23 @@
# 推 main 的請求:未署名
- repo/Users/youlinhsieh/Documents/tech_projects/InkStoneCo
- 分支:main
- 時間:2026-08-21 01:28:23
- 它想跑的指令:
```
git push -q origin master
```
## 還沒推上去的 commit(原始資料,不是轉述)
```
```
## 改了哪些檔
```
```
---
總管裁完請刪掉這個檔——留著代表「還沒裁」。
+6 -2
View File
@@ -37,8 +37,12 @@ lookup() { # $1=變數名 → 印出值(找不到就空)
return 1 return 1
} }
OUT_DIR="$HOME/.claude/cloud-env" # 預設丟 ~/.claude/cloud-env(權限 700)。要放別的地方= OUT_DIR=~/Desktop bash scripts/make-cloud-env.sh
mkdir -p "$OUT_DIR"; chmod 700 "$OUT_DIR" OUT_DIR="${OUT_DIR:-$HOME/.claude/cloud-env}"
mkdir -p "$OUT_DIR"
# 只在「這個目錄是我們自己造的預設位置」時才收緊權限——
# OUT_DIR 可被覆寫,不該對使用者指定的既有目錄(例如 ~/Desktop)動權限。
[ "$OUT_DIR" = "$HOME/.claude/cloud-env" ] && chmod 700 "$OUT_DIR"
OUT="$OUT_DIR/$(date +%Y%m%d-%H%M%S).txt" OUT="$OUT_DIR/$(date +%Y%m%d-%H%M%S).txt"
SETUP="$(cd "$(dirname "$0")/.." && pwd)/docs/cloud-setup-script.sh" SETUP="$(cd "$(dirname "$0")/.." && pwd)/docs/cloud-setup-script.sh"
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env python3
"""把 ISEP 整份「複製」進薄殼 repo 的 .claude/isep/,並產生對應的 settings.json。
為什麼是複製而不是 marketplaceleo 2026-08-21 拍板):
雲端 session 是「fresh clone 薄殼 repo」+「setup script 讀不到環境變數」,
走 marketplace 就得處理憑證、可見性、快取三件事,每一件都失敗過。
**複製進 repo 之後,clone 下來就有,沒有任何前置條件。**
代價是「兩份內容會漂」——所以這支同時支援 --check,比對薄殼那份與 ISEP 真身。
"""
import json, shutil, subprocess, sys
from pathlib import Path
ISEP = Path(__file__).resolve().parent.parent
OUT = ISEP / ".shell-payload" / "dot-claude"
SUB = "isep" # 薄殼裡的落點:.claude/isep/
COPY = ["hooks", "skills", "commands", "scripts", ".claude-plugin"]
def build() -> str:
if OUT.exists(): shutil.rmtree(OUT)
dest = OUT / SUB
dest.mkdir(parents=True)
n = 0
for d in COPY:
src = ISEP / d
if not src.exists(): continue
shutil.copytree(src, dest / d, ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
n += sum(1 for _ in (dest / d).rglob("*") if _.is_file())
# 🔴 幾支閘會把 log 寫到 $CLAUDE_PROJECT_DIR/.claude/hooks/<name>.log。
# 複製後那個目錄不存在 ⇒ 2026-08-21 冒煙測試實測 3 支報 No such file。
# 先把目錄造出來(git 不追空目錄,所以放 .gitkeep)。
(OUT / "hooks").mkdir(parents=True, exist_ok=True)
(OUT / "hooks" / ".gitkeep").write_text("", encoding="utf-8")
# settings.json:把 ISEP 的 hooks.json 逐條改寫成薄殼裡的絕對路徑。
# 🔴 保留 CLAUDE_PLUGIN_ROOT 這個變數名——ISEP 的閘內部都用它定位自己的 lib/,
# 改名等於要動 44 支閘。這裡只是把它指到複製過來的那份。
hooks = json.loads((ISEP / "hooks" / "hooks.json").read_text(encoding="utf-8"))["hooks"]
root = f'"$CLAUDE_PROJECT_DIR/.claude/{SUB}"'
out = {}
regs = 0
for ev, groups in hooks.items():
out[ev] = []
for g in groups:
ng = {k: v for k, v in g.items() if k != "hooks"}
ng["hooks"] = []
for h in g["hooks"]:
cmd = h["command"]
# ISEP 內部寫成 ${CLAUDE_PLUGIN_ROOT}/hooks/x.sh(有時帶引號)
cmd = cmd.replace('"${CLAUDE_PLUGIN_ROOT}"', root).replace("${CLAUDE_PLUGIN_ROOT}", root.strip('"'))
ng["hooks"].append({**h, "command": f'export CLAUDE_PLUGIN_ROOT={root}; {cmd}'})
regs += 1
out[ev].append(ng)
(OUT / "settings.json").write_text(
json.dumps({"hooks": out}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
return f"複製 {n} 個檔|改寫 {regs} 條 hook 註冊"
def main():
if "--check" in sys.argv[1:]:
before = OUT.exists() and subprocess.run(
["diff", "-rq", str(OUT), str(OUT)], capture_output=True).returncode == 0
print("--check 需要薄殼 clone 才有意義,見 docs/cloud-session-bootstrap.md")
return
print(build())
print(f"產物:{OUT}")
if __name__ == "__main__":
main()