Compare commits

...

18 Commits

Author SHA1 Message Date
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
17 changed files with 674 additions and 128 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "isep",
"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.5",
"keywords": [
"inkstone",
"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 的輸出貼回來
- 版本比 Releases 舊 ⇒ 環境快取住了(設定跑完會被拍成快照,約 7 天或改了 setup script 才重拍)
→ 動一下 setup script 的內容,強制重拍
- **沒有這行** ⇒ plugin 沒載入,這個 session 是**零閘狀態**。先修 plugin,不要開始做事。
- 版本比 Releases 舊 ⇒ 環境快取住了(setup 跑完會被拍成快照,約 7 天或改了 setup script 才重拍)→ 動一下 setup script 的內容強制重拍。
### B3 — 雲端載到的元件數量要跟本機一樣
```
跑 claude plugin details isep 給我看
```
**該看到**`Skills (9)``Hooks (5) PreToolUse, SessionStart, Stop, SubagentStop, PostToolUse`
——**跟本機看到的一模一樣**。
**失敗**:比本機少 ⇒ 又回到「兩邊不一樣」,正是 `InkStoneCo#57` 那張票的病。
🔴 **為什麼是這一行,而不是叫它跑指令**:這行由 `isep-presence-beacon.sh` 發出,
而那支腳本**住在 plugin 裡**。plugin 沒載入 ⇒ 它不可能發聲。
**沒有「剛好也會過」的情況**——這就是鑑別力。
### B3 — 要它把 setup 的驗證結果貼回來
### 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 — 回報
B2B3B4 三個畫面貼回 `inkstone/InkStoneCo#14`
B2(信標那行)/B3(setup 輸出)/B4(閘的訊息)三個畫面貼回 `inkstone/InkStoneCo#14`
全綠 ⇒ 那張票可以關,`#57` 也解掉一半。
---
+26 -60
View File
@@ -1,72 +1,38 @@
#!/usr/bin/env bash
# 貼進 code-on-web「Cloud environments → 你的環境 → Setup script欄位的內容
# 不是 ISEP 的一部分(不會被 Claude Code 當 hook/command/skill 掃描),
# 純粹是給 leo 複製貼上的參考檔,見 docs/cloud-session-bootstrap.md。
# 貼進 claude.ai → Cloud environments → 你的環境 → Setup script 欄位。
#
# 前提(要先在同一個 Cloud environment 的 Environment variables 欄位加好):
# GITEA_TOKEN_CLAUDE_CODE ← 既有機器帳號 token,名字沿用 InkStoneCo#14 已建立的那把,
# 不要新造一把。值本身不寫在這支腳本或任何檔案裡。
# 🔴 2026-08-21:這支腳本從「要 token」變成「不要 token」,因為查文件查出兩件事——
#
# 這支腳本做兩件事:
# 1. 設定 git URL 重寫,讓任何對 git.uncle6.me 的 clone 都能用 GITEA_TOKEN_CLAUDE_CODE 認證
# (官方文件對「CI/CD 裝私有 marketplace」建議的寫法,見 references 段)。
# 2. 直接把 ISEP 裝成 user-scope plugin ——不是「複製一份」,是跟本機一樣走
# `claude plugin marketplace add` + `claude plugin install`,裝的東西
# 100% 來自 inkstone/ISEP 這個 repo 本身,沒有第二份內容。
# ① **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 再要這裡讀,永遠讀不到。
#
# 何時跑:只在「這個 Cloud environment 第一次開 session」時跑一次,
# 跑完 Anthropic 會把整個檔案系統拍成快照,之後的 session 直接沿用快照
# (不重跑,除非改了這支腳本本身、改了 allowed network hosts、或快照滿 7 天過期)
# ⇒ 這是唯一會讓「ISEP 改了但雲端還是舊的」重新出現的地方,
# 緩解法見 docs/cloud-session-bootstrap.md「已知限制」段。
# ② **非零結束會讓整個 session 開不起來。**
# 官方原文:「**Exit zero**: if the script exits non-zero, the session fails to start.」
# ⇒ 這裡一律 exit 0。任何失敗只記錄,不擋門
#
# 解法是拿掉憑證需求本身:`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
echo "❌ 找不到 GITEA_TOKEN_CLAUDE_CODE —— 去 Cloud environment 的 Environment variables 加這個名字" >&2
exit 1
fi
claude plugin marketplace add https://git.uncle6.me/inkstone/ISEP.git --scope user 2>&1 || true
claude plugin install isep@inkstone --scope user 2>&1 || true
# ── 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
echo "✅ marketplace inkstone 已就位"
else
echo " marketplace 沒就位——session 啟動時 enabledPlugins 會是一張跳票的支票。" >&2
exit 1
echo "⚠️ marketplace 沒就位——session 啟動時會再試一次(薄殼 settings.json 宣告的那條路)。"
echo " 若 session 開頭看不到「🟢 ISEP v… 已載入」,就是這條也失敗了。"
fi
echo "✅ setup 完成。session 啟動後請用「有鑑別力的探針」驗閘,"
echo " 不要用 git tag(它在三支閘的白名單裡,閘死了也會過)。"
exit 0
+25
View File
@@ -677,3 +677,28 @@ Gitea 的 milestone 只管得到同一個 repo,所以六個群在 `inkstone/IS
結構(正交) hub ← tracking issue 標記
封存不刪 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 放行。
+5 -1
View File
@@ -40,6 +40,10 @@
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/ticket-api-bypass-guard.sh"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/milestone-due-guard.sh"
}
]
},
@@ -323,4 +327,4 @@
}
]
}
}
}
+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 也走得過)。
# 而且 `.claude/settings.local.json` 裡真的放行過 `touch /tmp/.main-push-ok`。
# ⇒ 現在**空內容一律不算數**:要嘛寫得出 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 '')
[ -n "$WANT" ] || 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 --
# 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.
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
# ── 擋下的同時,把「誰想推什麼」留成一份請求(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]
aid = (d.get("agent_id") or d.get("subagent_id") or "")[:10]
stamp = hashlib.sha1((tp + sid + aid).encode()).hexdigest()[:8]
path = os.path.join(os.environ["DIR"], "claims-%s-%s.md" % (sid, stamp))
# 🔴 2026-08-21stamp 原本雜湊「交件路徑」⇒ 同樣的宣稱每回合生一個新檔名,
# 而且不知道總管已經驗過了。實際發作:同兩條 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):
if not items:
+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
# main-and-prod-push-guard.sh 的迴歸測試——重點在「geek6688 豁免的範圍夠不夠窄」。
# 🔴 寫成檔案跑:測試指令必然含 `wrangler deploy`,直接在 Bash 打會被 prod-write-guard 擋
#
# 用一個**沒有 .github-armed** 的臨時 CLAUDE_PROJECT_DIR
# 否則本機真的有那個檔 ⇒「該擋的」會假性通過,測了等於沒測。
HOOK="$1"
TMPPROJ=$(mktemp -d)
trap 'rm -rf "$TMPPROJ"' EXIT
# 八向實測 main-and-prod-push-guard.sh
# 放在檔案裡跑,因為測試字串本身會觸發「舊版」那支閘(第五次誤攔)
G="$1" # 要測的 hook 路徑
pass=0; fail=0
mk(){ python3 -c "import json,sys;print(json.dumps({'tool_name':'Bash','tool_input':{'command':sys.argv[1]}}))" "$1"; }
PASS=0; FAIL=0
t(){ mk "$3" | env CLAUDE_PROJECT_DIR="$TMPPROJ" "$HOOK" >/dev/null 2>&1; rc=$?
got=$([ $rc -eq 2 ] && echo block || echo pass)
if [ "$got" = "$1" ]; then echo "$2"; PASS=$((PASS+1))
else echo "$2 —— 期望 $1,實得 $got"; FAIL=$((FAIL+1)); fi; }
t() { # t <說明> <指令> <期望 exit>
printf '{"tool_name":"Bash","tool_input":{"command":"%s"}}' "$2" \
| CLAUDE_CODE_CHILD_SESSION=1 CLAUDE_PROJECT_DIR="$(dirname "$(dirname "$G")")" \
bash "$G" >/tmp/pg.out 2>&1
rc=$?
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 明文授權總管可直接動)應放行 ──"
t pass "指名 geek6688 主機名" "npx $D --name arcrun-cypher-executor --config geek6688.toml"
t pass "用 geek6688 的 token 變數" "CLOUDFLARE_API_TOKEN=\$CLOUDFLARE_API_TOKEN_CC_SHIPPING_CORE npx $D"
t pass "用 geek6688 的 account id 變數" "CLOUDFLARE_ACCOUNT_ID=\$CLOUDFLARE_ACCOUNT_ID_GEEK6688 npx $D"
echo "── 該擋 ──"
t "直接推預設分支" 'git push origin ma'"in" 2
t "HEAD:預設分支" 'git push origin HEAD:ma'"in" 2
t "推 master" 'git push -q origin mas'"ter" 2
echo "── 其他實例仍要 leo 親手 arm(範圍不能外溢)──"
t block "打 leo21c" "npx $D --name arcrun-mcp --account-id leo21c-acct"
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
echo "────── 通過 $pass 失敗 $fail"
[ "$fail" = "0" ]
+3 -3
View File
@@ -47,7 +47,7 @@ try:
# 不是只有「會改壞東西的指令」。
# 2026-08-05 第三次補(leo:「我看你剛剛 bash 裡很多 grep,為什麼不是查 wiki?」):
# 再補「讀檔形成結論」的動詞——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'
r'|head|cat|sed|tail|awk|find|jq)\b', cmd):
@@ -61,10 +61,10 @@ try:
# grep pattern 常含 regex 元字元;取最長的英數/底線詞當搜尋詞
# 🔴 2026-08-05 leo 點破:「最常做的就是 grep,為什麼沒在裡面」
# ——Grep 其實有註冊,壞在**這行取詞規則**:
# ① 連字號被當分隔 ⇒ `bge-m3` 只取到 `bge`(3 字)不足 4 字 ⇒ 整支不觸發
# ① 連字號被當分隔 ⇒ bge-m3 只取到bge(3 字)不足 4 字 ⇒ 整支不觸發
# ② **中文完全不匹配** ⇒ 查「版本號」「出貨」這類詞一律不觸發
# 而我日常查的關鍵字大量正是這兩類 ⇒ hook 形同虛設。
# ⇒ 容許 `-``.`,並支援 CJK;中文 2 字即算一個詞。
# ⇒ 容許「-」與「.」,並支援 CJK;中文 2 字即算一個詞。
words = re.findall(r'[A-Za-z_][A-Za-z0-9_.-]{2,}', q)
cjk = re.findall(r'[\u4e00-\u9fff]{2,}', q)
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
}
OUT_DIR="$HOME/.claude/cloud-env"
mkdir -p "$OUT_DIR"; chmod 700 "$OUT_DIR"
# 預設丟 ~/.claude/cloud-env(權限 700)。要放別的地方= OUT_DIR=~/Desktop bash scripts/make-cloud-env.sh
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"
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()