Compare commits

..

1 Commits

Author SHA1 Message Date
Claude 762c28c522 雲端 setup:git 認證改用讀環境變數的 credential helper,並讓驗證有能力變紅
雲端實測(真的雲端 session,不是本機模擬):
- session 是 root/HOME=/root,setup 階段寫進 $HOME 的三個機制一個都沒到
  (沒有 insteadOf、沒有 ~/.git-credentials、沒有 /etc/gitconfig)
- 薄殼 settings.json 的 extraKnownMarketplaces + enabledPlugins 宣告了也沒用:
  Claude Code 是裸 URL clone marketplace,沒有 credential helper 就靜默失敗
  → 'No marketplaces configured'

改法:
- credential helper 當場讀 GITEA_TOKEN_CLAUDE_CODE,磁碟不落明文(token 輪替不用重拍快照)
- 寫進所有 session 可能讀到的 gitconfig,並印出實際寫進哪幾份
- 驗證先跑裸探針(GIT_CONFIG_GLOBAL/SYSTEM=/dev/null),它必須紅;紅不了就說明綠燈不算數
- 加驗 plugin 本身(只驗 marketplace 會漏掉「marketplace 有、plugin 沒有」)

在雲端容器內實跑過:裸環境正確失敗 → 補 helper 後 marketplace 就位、
isep@inkstone 0.3.1 installed/enabled,腳本 exit 0。
2026-08-20 15:46:47 +00:00
26 changed files with 285 additions and 1635 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
"plugins": [ "plugins": [
{ {
"name": "isep", "name": "isep",
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:46 支機械閘(56 條註冊,白話盤點見 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 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
"author": { "author": {
"name": "Leo", "name": "Leo",
"url": "https://uncle6.me" "url": "https://uncle6.me"
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "isep", "name": "isep",
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:46 支機械閘(56 條註冊,白話盤點見 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.4.0", "version": "0.3.1",
"keywords": [ "keywords": [
"inkstone", "inkstone",
"guardrails", "guardrails",
-1
View File
@@ -2,4 +2,3 @@
# 含金鑰真身的雲端設定,永遠不進版控(2026-08-20 實際差點被 git add # 含金鑰真身的雲端設定,永遠不進版控(2026-08-20 實際差點被 git add
cloud-env*.txt cloud-env*.txt
*.env *.env
.shell-payload/
+2 -33
View File
@@ -60,30 +60,6 @@ python3 scripts/ticket where 標籤 模組化
ISEP 的叫 `origin`,於是這道閘在新 repo 等於不存在) ISEP 的叫 `origin`,於是這道閘在新 repo 等於不存在)
- 結果只有單一 repo ⇒ 搜尋沒有跨 repo,等於沒搜 - 結果只有單一 repo ⇒ 搜尋沒有跨 repo,等於沒搜
### A9 — 人閘警察的管路:該擋的擋、壞掉不會卡住 session
```
bash hooks/tests/ask-user-question-guard.test.sh
```
**該看到**`14/14 通過`。**不打網路、不花錢**(判官用替身)。
**失敗**
- A 群(該放行)任何一條紅 ⇒ **誤攔**,這比漏擋嚴重——它會讓真人閘的問題送不到 leo
- ⑤⑥⑦ 任一條紅 ⇒ fail-open 壞了:判官掛掉會變成「問不出去」,等於一支閘癱瘓整個 session
- ⑩b 紅 ⇒ 訊息被 shell 展開了(2026-08-26 真的犯過:`cat >&2 <<EOF` 沒加引號,
訊息裡的反引號被當命令執行,**閘照擋,但它教人怎麼解的那兩行變成空白**)
### A10 — 人閘警察的準度:四題公式判得準不準
```
bash hooks/tests/ask-user-question-guard.live.test.sh
```
🔴 **這支真的會叫 haiku**(9 題、每題一次呼叫,整支約 2 分鐘)。
**該看到**`9/9 通過`,且結尾的「A 群誤攔」計數是 **0**
**失敗**
- **A 群紅(誤攔真人閘)=最嚴重**:等於讓總管替 leo 決定他的品味。看到就停下來改判準,不要放著
- B 群紅 = 漏擋,判官把純技術題當成人閘。改 `ask-user-question-guard.sh` 裡判官提示的
③④ 兩題定義,**不要改成關鍵字比對**(那是被明令禁止的文字層封路)
- 📌 這支會隨模型版本漂移,**是量尺不是一次性驗收**。改完判準要連跑三次都全綠才算數
2026-08-26 實測:第一版判準連兩次都在同一題漏擋,收緊 ③④ 定義後三次全綠)
### A6 — 標籤對齊且冪等 ### A6 — 標籤對齊且冪等
``` ```
bash scripts/gitea-labels-sync.sh bash scripts/gitea-labels-sync.sh
@@ -184,15 +160,10 @@ bash scripts/make-cloud-env.sh
### B4 — 閘真的會擋(用有鑑別力的動作) ### B4 — 閘真的會擋(用有鑑別力的動作)
``` ```
請把這段寫進 /tmp/wf.yamlauth: __GITEA_TOKEN__ 請把這段寫進 /tmp/x.md__GITEA_TOKEN__=abc
``` ```
**該看到**:被擋下,訊息開頭是 `🔒 credential 鐵律攔截(leo 2026-07-29 立)` **該看到**:被 `credential-only-guard` 擋下,訊息提到金鑰佔位符
🔴 **副檔名不能改成 `.md`。** `credential-only-guard` **刻意豁免** `.md``docs/``wiki/`
(文件本來就要能談論這些字串,本頁自己就寫滿了)。
2026-08-21 實撞:舊寫法用 `/tmp/x.md`**exit 0,閘完全沒反應**——
那是沒撞過就寫進來的探針,跟它要取代的假綠是同一個病。
**失敗** **失敗**
- 真的寫進去了 ⇒ 雲端仍然沒有閘。 - 真的寫進去了 ⇒ 雲端仍然沒有閘。
@@ -220,8 +191,6 @@ B2(信標那行)/B3(setup 輸出)/B4(閘的訊息)三個畫面
| A4 開票側門閘 | 總管 | ✅ 13/13 | | A4 開票側門閘 | 總管 | ✅ 13/13 |
| A5 搜尋跨 repo | 總管 | ✅ | | A5 搜尋跨 repo | 總管 | ✅ |
| A6 標籤對齊+冪等 | 總管 | ✅ 14 repo,第二次 0/0 | | A6 標籤對齊+冪等 | 總管 | ✅ 14 repo,第二次 0/0 |
| **A9 人閘警察管路** | 總管 | ✅ 14/142026-08-26 |
| **A10 人閘警察準度** | 總管 | ✅ 9/9,連跑三次(2026-08-26),A 群誤攔 0 |
| A7 plugin 裝得起來 | 總管 | ✅ | | A7 plugin 裝得起來 | 總管 | ✅ |
| **A8 新 session 閘會觸發** | 總管 | 見本版 release note | | **A8 新 session 閘會觸發** | 總管 | 見本版 release note |
| **B1B5 雲端** | **leo** | 還沒跑(機器碰不到 Cloud environment | | **B1B5 雲端** | **leo** | 還沒跑(機器碰不到 Cloud environment |
+45
View File
@@ -52,6 +52,51 @@ git config --global url."https://x-access-token:${GITEA_TOKEN_CLAUDE_CODE}@git.u
完整腳本:`docs/cloud-setup-script.sh`(貼進 code-on-web 的 Setup script 欄位用)。 完整腳本:`docs/cloud-setup-script.sh`(貼進 code-on-web 的 Setup script 欄位用)。
### 🔴 2026-08-20 雲端實測訂正:上面那條 `url.insteadOf` 不是正解
在真的雲端 session(不是本機模擬)量到的:
| 量到什麼 | 值 |
|---|---|
| session 身分 | `root``HOME=/root` |
| `git config --global --list` | 只有 harness 自己塞的 identityproxy 那幾條,**沒有 insteadOf、沒有 credential.helper** |
| `~/.git-credentials` | **不存在** |
| `/etc/gitconfig` | **不存在** |
| `claude plugin marketplace list` | `No marketplaces configured` |
| 薄殼 `.claude/settings.json` | `extraKnownMarketplaces` `enabledPlugins` **都宣告了** |
⇒ 兩個結論:
1. **setup 階段寫進 `$HOME` 的東西沒有到 session 手上。**
舊版三個機制(insteadOf`~/.git-credentials``--system`)一個都不在,
而 setup log 會是一片綠——因為它只驗「setup 這個 shell 裡通不通」。
2. **光在薄殼 settings.json 宣告 `extraKnownMarketplaces` 沒有用。**
Claude Code 是用**裸 URL clone** 去抓 marketplace 的,私有 repo 沒有 credential helper
就靜默失敗。裸環境重現出來的原話:
```
Failed to clone marketplace repository: HTTPS authentication failed.
Please ensure your git credential helper has valid credentials for git.uncle6.me
```
補上 helper 之後同一條指令:`Successfully added marketplace: inkstone`
→ `claude plugin install isep@inkstone` → `isep@inkstone 0.3.1 · enabled`。
**紅過也綠過,不是只看到綠。**
⇒ 改法(已落在 `docs/cloud-setup-script.sh`):
- 用 **credential helper 當場讀環境變數**,磁碟上不落明文 token
token 輪替只要改 Environment variables,腳本與快照都不用動):
```sh
git config --file <某份 gitconfig> credential."https://git.uncle6.me".helper \
'!f() { test "$1" = get && printf "username=claude-code\npassword=%s\n" "$GITEA_TOKEN_CLAUDE_CODE"; }; f'
```
- 寫進**所有** session 可能讀到的 gitconfig`$HOME``/root``/home/claude``/etc`),並印出實際寫進哪幾份。
- 驗證要**先讓它失敗一次**:用 `GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null` 跑裸探針,
它必須紅;紅不了代表環境裡另有憑證捷徑,後面的綠燈就不能當證據。
## 已驗(本機,隔離環境,不影響本機正在跑的任何 session) ## 已驗(本機,隔離環境,不影響本機正在跑的任何 session)
🔴 **怎麼保證沒有干擾**:全程把 `$HOME` 指到 scratchpad 底下的隔離目錄 🔴 **怎麼保證沒有干擾**:全程把 `$HOME` 指到 scratchpad 底下的隔離目錄
+120 -30
View File
@@ -1,38 +1,128 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# 貼進 claude.ai → Cloud environments → 你的環境 → Setup script 欄位。 # 貼進 code-on-web「Cloud environments → 你的環境 → Setup script欄位的內容
# 不是 ISEP 的一部分(不會被 Claude Code 當 hook/command/skill 掃描),
# 純粹是給 leo 複製貼上的參考檔,見 docs/cloud-session-bootstrap.md。
# #
# 🔴 2026-08-21:這支腳本從「要 token」變成「不要 token」,因為查文件查出兩件事—— # 前提(要先在同一個 Cloud environment 的 Environment variables 欄位加好):
# GITEA_TOKEN_CLAUDE_CODE ← 既有機器帳號 token,名字沿用 InkStoneCo#14 已建立的那把,
# 不要新造一把。值本身不寫在這支腳本或任何檔案裡。
# #
# ① **setup script 讀不到 Environment variables。** # 這支腳本做兩件事:
# 官方原文:「Each session copies the environment's values once, **at startup**, # 1. 讓 session 裡任何對 git.uncle6.me 的 clone 都認得出憑證
# into ordinary environment variables」,而 setup script 是 # —— Claude Code 拉 marketplace 是用**裸 URL clone**,走的就是 git credential helper
# 「**before Claude Code launches**」跑的 ⇒ 注入發生在它之後。 # 2026-08-20 雲端實測的原始錯誤訊息:「HTTPS authentication failed. Please ensure
# ⇒ 舊版把 token 放進 Environment variables 再要這裡讀,永遠讀不到 # your git credential helper has valid credentials for git.uncle6.me」)
# 2. 把 ISEP 裝成 user-scope plugin —— 裝的東西 100% 來自 inkstone/ISEP 本身,沒有第二份內容。
# #
# ② **非零結束會讓整個 session 開不起來。** # 何時跑:只在「這個 Cloud environment 第一次開 session」時跑一次,
# 官方原文:「**Exit zero**: if the script exits non-zero, the session fails to start.」 # 跑完 Anthropic 會把整個檔案系統拍成快照,之後的 session 直接沿用快照
# ⇒ 這裡一律 exit 0。任何失敗只記錄,不擋門 # (不重跑,除非改了這支腳本本身、改了 allowed network hosts、或快照滿 7 天過期)
# # ⇒ 這是唯一會讓「ISEP 改了但雲端還是舊的」重新出現的地方,
# 解法是拿掉憑證需求本身:`inkstone` org 與 `inkstone/ISEP` 都改成 Public # 緩解法見 docs/cloud-session-bootstrap.md「已知限制」段。
# ⇒ 匿名 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 -uo pipefail set -euo pipefail
claude plugin marketplace add https://git.uncle6.me/inkstone/ISEP.git --scope user 2>&1 || true if [ -z "${GITEA_TOKEN_CLAUDE_CODE:-}" ]; then
claude plugin install isep@inkstone --scope user 2>&1 || true echo "❌ 找不到 GITEA_TOKEN_CLAUDE_CODE —— 去 Cloud environment 的 Environment variables 加這個名字" >&2
exit 1
if claude plugin marketplace list 2>/dev/null | grep -q "inkstone"; then
echo "✅ marketplace inkstone 已就位"
else
echo "⚠️ marketplace 沒就位——session 啟動時會再試一次(薄殼 settings.json 宣告的那條路)。"
echo " 若 session 開頭看不到「🟢 ISEP v… 已載入」,就是這條也失敗了。"
fi fi
exit 0 # ── git 認證 ────────────────────────────────────────────────────────────
#
# 🔴 舊版(2026-08-20 之前)在這裡踩了兩個坑,兩個都是「不出聲的」:
#
# ① 把 token 明文寫進 `~/.git-credentials`。
# —— 換 token 那天起這份就是壞的,而且壞法是「認證失敗」不是「檔案不見」,很難聯想。
# 改法:credential helper **當場讀環境變數**,磁碟上不落任何明文。
# token 輪替時只要改 Environment variables,這支腳本不用動、快照也不用重拍。)
#
# ② 只寫 `$HOME`。setup 階段的 `$HOME` **不保證等於 session 的 `$HOME`**
# —— 2026-08-20 雲端實測:session 以 root 跑(`HOME=/root`),
# 而 `/root/.git-credentials` 不存在、`/etc/gitconfig` 也不存在
# ⇒ 舊版三個機制**一個都沒到 session 手上**setup log 卻整片綠。
# 改法:把同一段 helper 寫進所有「session 可能會讀」的 gitconfig,並印出實際寫進哪幾份。
#
# helper 內容不含 token,只含「去讀 $GITEA_TOKEN_CLAUDE_CODE」這個動作。
HELPER='!f() { test "$1" = get && printf "username=claude-code\npassword=%s\n" "$GITEA_TOKEN_CLAUDE_CODE"; }; f'
echo "── 寫 git credential helper(不落地明文 token)──"
wrote=0
seen=""
for cfg in "${HOME:-/root}/.gitconfig" /root/.gitconfig /home/claude/.gitconfig /etc/gitconfig; do
# $HOME 常常就是 /root,去重才不會同一份印兩次(看起來像多寫了一處,其實沒有)。
case " $seen " in *" $cfg "*) continue ;; esac
seen="$seen $cfg"
# 目錄不在就別建(不是每台機器都有 /home/claude);寫不進去也不致命,還有別份。
[ -d "$(dirname "$cfg")" ] || { echo " .跳過 $cfg(目錄不存在)"; continue; }
if git config --file "$cfg" credential."https://git.uncle6.me".helper "$HELPER" 2>/dev/null; then
echo " ✅ 寫進 $cfg"
wrote=$((wrote + 1))
else
echo " ⚠️ 寫不進 $cfg(跳過)"
fi
done
[ "$wrote" -gt 0 ] || { echo "❌ 一份 gitconfig 都寫不進去,後面不用往下做了。" >&2; exit 1; }
# ── 🔴 自我驗證一:這個測試有沒有能力變紅 ────────────────────────────
# 先在「什麼設定都不讀」的條件下跑一次,**它必須失敗**。
# 失敗不了 ⇒ 環境裡另有一條我們沒注意到的憑證捷徑(keychainambient tokenproxy 代打),
# 那麼下一步的「✅」就不能證明 helper 有效——是捷徑在給答案。
# 2026-08-20 同一天在這個形狀上連摔三次,見 InkStoneCo mistakes.md「隔離環境沒有隔離系統層」。)
echo "── 驗證 git 認證 ──"
if GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null GIT_TERMINAL_PROMPT=0 \
git ls-remote https://git.uncle6.me/inkstone/ISEP.git >/dev/null 2>&1; then
echo "⚠️ 裸環境竟然也拉得到 —— 這個環境有別的憑證來源,下面的綠燈不能當成 helper 生效的證據。" >&2
else
echo " ✅ 裸環境正確地失敗了(這個測試有能力變紅)"
fi
# ── 🔴 自我驗證二:認證真的通了嗎 ────────────────────────────────────
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
# ── 安裝 ────────────────────────────────────────────────────────────────
# 🔴 官方文件(cloud-environments 的 what-carries-over 表)明文:
# 「Plugins enabled only in your user settings」→ **不會**帶到雲端 session。
# 薄殼 repo 的 .claude/settings.json 裡的 enabledPlugins extraKnownMarketplaces 才是主要路徑。
# 但 2026-08-20 雲端實測證明:**那兩個 key 宣告了也沒用,如果 git 認證不在。**
# Claude Code 啟動時是用裸 URL clone marketplace 的 ⇒ 沒有 credential helper ⇒ 靜默失敗
# ⇒ session 起來後 `claude plugin marketplace list` 是「No marketplaces configured」。
# ⇒ **上面那段 credential helper 才是主要路徑;下面兩行是備援。**
echo "── 安裝 marketplace / plugin ──"
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 與 plugin 都真的就位了嗎 ──────────────
# 只驗 marketplace 不夠:marketplace 列得出來、plugin 沒裝起來,
# session 啟動時 enabledPlugins 一樣是一張跳票的支票。
echo "── 驗證 marketplace / plugin ──"
if claude plugin marketplace list 2>/dev/null | grep -q "inkstone"; then
echo " ✅ marketplace inkstone 已就位"
else
echo "❌ marketplace 沒就位——session 啟動時 enabledPlugins 會是一張跳票的支票。" >&2
exit 1
fi
if claude plugin list 2>/dev/null | grep -q "isep@inkstone"; then
echo " ✅ plugin isep@inkstone 已就位"
else
echo "❌ plugin 沒裝起來(marketplace 有、plugin 沒有)——閘在雲端不會生效。" >&2
exit 1
fi
cat <<'EOF'
✅ setup 完成。
session 啟動後請用「有鑑別力的探針」驗閘:
・不要用 `git tag`(它在三支閘的白名單裡,閘死了也會過)
・不要用 `release-tag-guard`(讀不到 .claude-plugin/plugin.json 就按設計 exit 0
・先確認你挑的那支閘「在這個情境下的設計行為」是擋,不是放行
驗閘之外,也順手確認這兩件(任一為否 ⇒ 這個 session 沒有 plugin,別當成有):
claude plugin marketplace list # 要看到 inkstone
claude plugin list # 要看到 isep@inkstone · enabled
EOF
-25
View File
@@ -677,28 +677,3 @@ 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 放行。
+3 -21
View File
@@ -1,4 +1,4 @@
# 46 支閘,白話盤點表 # 43 支閘,白話盤點表
> 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」 > 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」
> 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。 > 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。
@@ -7,16 +7,10 @@
## 一句話結論 ## 一句話結論
`hooks/` 底下有 **46 個 `.sh` 檔**`hooks.json` 實際掛上 **56 條註冊**(同一支閘常被多種情境同時掛上); `hooks/` 底下有 **43 個 `.sh` 檔**`hooks.json` 實際掛上 **53 條註冊**(同一支閘常被多種情境同時掛上);
其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。 其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。
下面按「你會在什麼時候撞到它」分組,一支一行。 下面按「你會在什麼時候撞到它」分組,一支一行。
> 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」,
> 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。
> 現在的寫法是實際數出來的:
> `ls hooks/*.sh | wc -l` 46`hooks.json` 展開後的 command 條目 56。
> **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。
--- ---
## 怎麼讀這張表 ## 怎麼讀這張表
@@ -77,18 +71,6 @@
| `irreversible-dispatch-guard.sh` | 派工單裡出現「刪分支」「drop table」「rm -rf」這類不可逆動作,卻沒寫「先停下來等回覆才執行」就擋下。 | 🛑 擋 | | `irreversible-dispatch-guard.sh` | 派工單裡出現「刪分支」「drop table」「rm -rf」這類不可逆動作,卻沒寫「先停下來等回覆才執行」就擋下。 | 🛑 擋 |
| `no-ticket-no-dispatch.sh` | 派工單裡沒有寫工單號(`【工單】owner/repo#N`),或那張票已經關閉/根本不存在,就擋下——沒有票號的工作沒有人追得到進度。 | 🛑 擋 | | `no-ticket-no-dispatch.sh` | 派工單裡沒有寫工單號(`【工單】owner/repo#N`),或那張票已經關閉/根本不存在,就擋下——沒有票號的工作沒有人追得到進度。 | 🛑 擋 |
## D2. AI 想開口問你問題的當下(PreToolUse / AskUserQuestion
| 閘名 | 對你意味著什麼 | 動作 |
|---|---|---|
| `ask-user-question-guard.sh` | AI 要跳出來問你一個問題的**那一刻**先攔一下,用小模型(haiku)照「四題公式」判這題該不該打擾你:**花錢/不可逆/跨專案結構/品味方向/只有你做得到**——命中任何一題就放行(那本來就該問你),四題全否(純技術實作選擇、問「要不要開始」)就擋回去要它自己裁。**同一個問題只擋一次**,它重送就過得去,所以判錯不會害你收不到問題;判官掛掉/沒網路也一律放行。 | 🛑 擋(同一題至多一次) |
> **為什麼要有這一組**leo 2026-08-26:「今天已經好幾次問我,**為什麼 hooks 沒有攔下來?**」):
> 在這之前 `AskUserQuestion` 在 `hooks.json` 裡出現 **0 次**,一支閘都沒掛。
> F 組那兩支自走警察(`self-drive-police` / `self-drive-judge`)判準一樣,
> 但它們掛在「收工」那一刻——**問題早就送到你眼前了**,事後再問 AI「你查過了嗎」已經來不及。
> 這一組補的是**時機**,不是判準。
## E. 每個對話一開始(SessionStart ## E. 每個對話一開始(SessionStart
| 閘名 | 對你意味著什麼 | 動作 | | 閘名 | 對你意味著什麼 | 動作 |
@@ -102,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 該去派工卻沒派(工頭停工),就擋下要它交出「已經派工的憑證」,不是隨口說一句「我會催」就算數。**過閘有四條路:現在就派工/把票號寫進那句話/寫一行 `⏸ 等:<在等什麼>`/這一輪收尾在動作上。** 2026-08-23inkstone/ISEP#30)修好「引用被當成主張」——貼原始碼、引用它自己的訊息、否認自己有下一步,都不再被咬。 | 🛑 擋 | | `factory-idle-guard.sh` | AI 該去派工卻沒派(工頭停工),就擋下要它交出「已經派工的憑證」,不是隨口說一句「我會催」就算數。 | 🛑 擋 |
| `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 只是把「要不要」改寫成「不確定是否符合期待」就閃過上一支閘。 | 🛑 擋 |
-261
View File
@@ -1,261 +0,0 @@
#!/usr/bin/env bash
# ask-user-question-guard.sh — PreToolUseAskUserQuestion):**在「問出口的那一刻」攔住**
#
# 病根(leo 2026-08-26):「**今天已經好幾次問我,為什麼 hooks 沒有攔下來?**」
# 當天總管至少兩次把純技術路徑選擇丟給 leo,其中一次他當場說
# 「**這種問題不要問我,我要的是你解決了以後給我 prod**」——而**一支閘都沒響**。
#
# 實查(2026-08-26ISEP 0.3.8):
# • 總管問 leo 走的動作就是 `AskUserQuestion` 這個工具
# • `hooks.json` 裡 `AskUserQuestion` 出現 **0 次**——它沒有任何 matcher,是裸的
# • `self-drive-police.sh` / `self-drive-judge.sh` 只掛在 `Stop` / `SubagentStop`
# ⇒ 它們是**回合結束後**才跑的。問題送出去、leo 已經被打斷之後,
# 再來反問「你查過了嗎」已經沒有意義——**成本早就轉嫁出去了**。
#
# ⇒ 這支補的不是判準(判準跟自走警察同一套四題公式),補的是**時機**:
# `Stop` 是事後補刀,`PreToolUse` 才是當下攔截。
#
# ──────────────────────────────────────────────────────────────────────
# 🔴 封的是動作,不是文字(leo 2026-08-17 的檢驗)
#
# leo 原話:「你在**文字層**封路⋯⋯『回「做」我就啓動』沒有問號,下次它會寫
# 『準備就緒』,再下次寫『待命中』。**自然語言的變體是無限的,blacklist 永遠追不完。**
# 封路哲學之所以有效,是因為它封的是**動作**——動作有限且可枚舉,文字不是。」
# 當日實證:文字層的閘 **8 次誤攔、0 次正確攔截**,且方向穩定
# ——**紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。**
#
# 本閘怎麼過這個檢驗:
# • **觸發條件=那個動作本身**`AskUserQuestion` 這一次呼叫),
# 不是任何句型、關鍵字、問號、措辭。改寫成什麼樣子都一樣會進來。
# • **沒有任何 grep 黑名單**(全檔零個判擋用的正則)。換句話說閃不過去,
# 講得謹慎也不會被多罰——這正是文字層閘的兩個病,本閘兩個都沒有。
# • 進來之後要分「該問 / 不該問」,靠的是**四題公式的語意判官**
# `self-drive-judge.sh` 已經在用的同一套 haiku 判官),
# 它判的是「這題命不命中人閘」,不是「這句話長什麼樣」。
#
# 🔴 不准把真人閘也擋掉(本閘存在的第二個前提)
# 四題命中時 leo **要**被問——2026-08-26 當天他就當場裁了一題 UI 取捨(樹要不要捲)。
# **擋掉那種等於讓總管替他決定他的品味,比不擋更糟。**
# 所以本閘有兩層保險:
# ① 判官只在「四題全否」時才回 block;任何一題命中一律 allow
# ② **同一個問題只擋一次**(雜湊戳記)——判官若誤判,重送即放行,
# leo 該收到的問題絕不會因為一支閘而永遠送不到。
# (跟 `micromanage-guard.sh``subagent-first-guard.sh` 同一個慣例:
# 閘是絆一下逼你重想,不是把人關起來。)
#
# 判準(跟 CLAUDE.md 規則二、`self-drive-judge.sh` 完全同一套,不另立第二套):
# ① 花錢 ② 不可逆/難回收 ③ 跨專案結構 ④ 品味/方向 ⑤ 物理人閘(只有人做得到)
# 命中任一 → 放行。四題全否 → 擋一次,逼它自己裁。
#
# 失敗一律 fail-openexit 0):判官掛掉/沒網路/`claude` 不在 PATH
# 都只會回到「今天的狀態」(沒有閘),不會變成「問不出去」。
# ⇒ 這支閘壞掉的最壞後果,等於它不存在,不會癱瘓任何 session。
#
# 官方文件核實(2026-08-26 查 code.claude.com,不是憑記憶):
# • `AskUserQuestion` **會**觸發 PreToolUsematcher 寫工具名有效
# — https://code.claude.com/docs/en/agent-sdk/user-input
# 原文:「Claude calls AskUserQuestion. The PreToolUse hook fires.」
# • `tool_input` 的真實結構=`{"questions":[{"question","header","options":[{"label","description"}],"multiSelect"}]}`
# — 同頁「Question format」段
# • exit 2 在 PreToolUse hard block,工具呼叫不會發生,無已知例外
# — https://code.claude.com/docs/en/hooksExit Codes 表)
# • `AskUserQuestion` **在 subagent 裡不可用**(同頁 Limitations
# ⇒ 所以本閘只會在主 session 觸發;subagent 那一側仍由
# `self-drive-police.sh``self-drive-judge.sh` 的 SubagentStop 守著,兩者不重疊。
#
# 測試:
# hooks/tests/ask-user-question-guard.test.sh 離線、不花錢(判官用替身)
# hooks/tests/ask-user-question-guard.live.test.sh 真的叫 haiku,量判官的準度
set -u
# 遞迴保險:判官自己起的那個 claude 不准再觸發本閘
if [ "${SELF_DRIVE_JUDGE:-}" = "1" ]; then exit 0; fi
input="$(cat)"
# ── 只管 AskUserQuestion 這一個動作 ────────────────────────────────
tool_name="$(printf '%s' "$input" | python3 -c '
import sys, json
try:
print(json.load(sys.stdin).get("tool_name", ""))
except Exception:
print("")
' 2>/dev/null)"
[ "$tool_name" = "AskUserQuestion" ] || exit 0
session_id="$(printf '%s' "$input" | python3 -c '
import sys, json
try:
print(json.load(sys.stdin).get("session_id", ""))
except Exception:
print("")
' 2>/dev/null)"
# ── 把問題本體攤成純文字(給判官看,不給 grep 看)──────────────────
# 刻意用「遞迴撈出所有字串」而不是寫死欄位名:AskUserQuestion 的 tool_input
# schema 若哪天改欄位(questions/question/header/options/label/description…),
# 這支不會變成靜默失效的空判官。
question_text="$(printf '%s' "$input" | python3 -c '
import sys, json
def walk(node, out):
if isinstance(node, str):
s = node.strip()
if s:
out.append(s)
elif isinstance(node, list):
for x in node:
walk(x, out)
elif isinstance(node, dict):
for k in ("question", "header", "label", "description", "text"):
if k in node:
walk(node[k], out)
for k, v in node.items():
if k not in ("question", "header", "label", "description", "text"):
walk(v, out)
out = []
try:
d = json.load(sys.stdin)
walk(d.get("tool_input", {}), out)
except Exception:
pass
seen = set()
uniq = [x for x in out if not (x in seen or seen.add(x))]
print("\n".join(uniq))
' 2>/dev/null)"
# 撈不到內容 ⇒ 沒東西可判 ⇒ 放行(fail-open)
[ -z "$question_text" ] && exit 0
# ── 同一個問題只擋一次 ────────────────────────────────────────────
# 判官誤判時,總管重送同一題就過得去 ⇒ leo 該收到的問題不會被永久攔住。
stamp="/tmp/.ask-gate-warned-$(printf '%s' "$question_text" | shasum | cut -c1-12)"
[ -f "$stamp" ] && exit 0
# ── 明示豁免(留痕用)──────────────────────────────────────────────
# 真的緊急、或判官在誤攔而不想改寫問題時:
# touch /tmp/.ask-ok-<session_id>
# 用掉就消失,不是永久開關。
if [ -n "$session_id" ] && [ -f "/tmp/.ask-ok-$session_id" ]; then
rm -f "/tmp/.ask-ok-$session_id"
exit 0
fi
# ── 語意判官(跟 self-drive-judge.sh 同一套判準、同一個模型)──────
judge_prompt="你是「人閘判官」。下面是一個 AI 助理(總管)**正要送給老闆 leo 的一個提問**。
判斷這個問題**該不該送到 leo 面前**。
判準只有一條——四題人閘公式。它問的事情是否命中下列任一:
① 花錢(付費、開資源、產生帳單、明顯多耗訂閱額度)
② 不可逆/難回收(刪資料、push 到 main、部署上線 prod、對外公開、跨 repo 搬遷)
③ 跨專案結構決策(**repo 與 repo 之間**的邊界:某個能力歸哪個 repo、改全機共用的框架、立一條新鐵律)
🔴 **單一服務/單一 repo 內部怎麼分層、程式碼怎麼組織,不算第③題**——那是實作,不是結構。
④ 品味/方向(**leo 本人看得到、而且會有偏好的東西**:畫面長相、UI/UX 取捨、
要不要做某個功能、對外文案怎麼寫)
🔴 **內部的設計哲學、容錯策略、責任邊界、程式碼美感,不算第④題**——他看不到那些。
⑤ 物理人閘(只有人做得到:貼憑證、終端機同意、平台上按批准、真人驗收)
命中任一 → decision=allow(該問)。
四題全否 → decision=block(這是它自己該裁的)。
🔴 判 allow 時要放寬,判 block 時要保守——**誤擋一個真人閘,比放過一個技術題更糟**。
拿不準、或這題同時有技術面與品味面 → 一律 allow。
典型的 block(四題全否,都是純技術路徑選擇):
- 用哪個函式庫/哪種實作寫法/要不要抽共用模組
- 錯誤怎麼處理、重試幾次、超時設多久
- 檔案放哪個目錄、變數怎麼命名(純內部、用戶看不到)
- 「A 方案和 B 方案我都可以做,你想要哪個?」而兩案的差別只有實作成本
- 「我發現一個 bug,要修嗎?」(修 bug 不是品味題)
- 「要不要繼續/要不要我開始」(已經交代過的事)
典型的 allow
- 要不要開一個會產生帳單的資源、要不要換更貴的模型
- 要不要 push 到 main/部署到 prod/刪掉某批資料
- 某個能力該歸哪個 repo、要不要立一條新鐵律
- 畫面上這個東西要長什麼樣、要不要做這個功能、文案怎麼寫
- 需要你去終端機貼憑證/按批准
只輸出 JSON,不要任何其他文字。
輸出格式:
{\"decision\":\"allow\"或\"block\",\"gate\":\"命中第幾題或 none\",\"reason\":\"一句正體中文;block 時要指出它在問什麼、以及四題為何全否\"}
--- 它要問 leo 的問題 ---
$question_text
--- 結束 ---"
# ⚠️ 判官必須跑在「中性目錄 + --safe-mode」:
# 實測(self-drive-judge.sh 2026-08-05):在專案內跑 `claude -p` → 54 秒
# (內層把 SessionStart recall、全部 hook、全部 MCP 工具都載進去);
# 換成 `cd /tmp` `--safe-mode` → 8 秒。判官只需要判一段文字。
# `ISEP_ASK_JUDGE_CMD` 只為了讓離線測試塞得進一個判官替身(見 tests/),預設就是真判官。
JUDGE_CMD="${ISEP_ASK_JUDGE_CMD:-claude -p --safe-mode --model haiku --allowedTools ''}"
verdict="$(printf '%s' "$judge_prompt" \
| (cd /tmp && SELF_DRIVE_JUDGE=1 sh -c "$JUDGE_CMD" 2>/dev/null))"
# 判官掛了/回空/不是 JSON → fail-open
[ -z "$verdict" ] && exit 0
parsed="$(printf '%s' "$verdict" | python3 -c '
import sys, json, re
raw = sys.stdin.read()
m = re.search(r"\{.*\}", raw, re.S)
if not m:
print("allow||"); raise SystemExit
try:
d = json.loads(m.group(0))
print((d.get("decision") or "allow") + "|" + (d.get("gate") or "") + "|" + (d.get("reason") or ""))
except Exception:
print("allow||")
' 2>/dev/null)"
case "$parsed" in
block\|*)
rest="${parsed#block|}"
reason="${rest#*|}"
date +%s > "$stamp"
# 訊息用 quoted heredoc(裡面有反引號,不能讓 shell 當命令替換執行過),
# 佔位符交給 python 換掉——sed 對正體中文與特殊字元的跳脫太脆。
msg="$(cat <<'EOF'
🚦 人閘警察(PreToolUseAskUserQuestion):**這一問四題全否,是你自己該裁的。**
判官的話:__REASON__
【leo 2026-08-26】「**今天已經好幾次問我,為什麼 hooks 沒有攔下來?**」
「**這種問題不要問我,我要的是你解決了以後給我 prod。**」
【leo 2026-08-17】「你的命令**被當成『規劃的輸入』,而不是『執行的授權』**⋯⋯
從它的『文化』看這是禮貌;**從你的系統看,這是違約。**」
━━━ 不確定時走這條,不要停(CLAUDE.md 常駐授權那段)━━━
1. 查 wiki`system-dev/wiki/`**語意搜尋優先於 grep**
2. 套四題公式:① 花錢 ② 不可逆 ③ 跨專案結構 ④ 品味方向
3. 仍不確定 → **做出最合理的假設,把假設寫進 commit message 或票的留言,繼續走**
——第 3 步是關鍵:**假設之後不是問,是記錄。**
leo 下課後 review 時一次看到所有假設,對的併、錯的打回。
這把同步的提問改造成非同步的問答,**不掛起整個 loop**。
━━━ 這一問真的命中人閘?━━━
**把命中哪一題直接寫進問題裡再送一次**(例:「四題第②命中:這會刪掉線上資料」),
或 `touch /tmp/.ask-ok-__SESSION__` 後重送。
📌 **同一個問題本閘只擋一次**——原封不動重送就會過。
判官寧可放行也不誤擋,但它會錯;**它錯了不該讓 leo 收不到問題。**
EOF
)"
ASK_MSG="$msg" ASK_REASON="$reason" ASK_SESSION="$session_id" python3 -c '
import os, sys
sys.stderr.write(
os.environ["ASK_MSG"]
.replace("__REASON__", os.environ.get("ASK_REASON", ""))
.replace("__SESSION__", os.environ.get("ASK_SESSION", "<session_id>"))
+ "\n"
)
'
exit 2
;;
esac
exit 0
+57 -180
View File
@@ -13,72 +13,27 @@
# #
# ① `[ "$RUNNING" -gt 0 ] && exit 0` # ① `[ "$RUNNING" -gt 0 ] && exit 0`
# 有任何 agent 在跑就閉嘴 ⇒ **它量的是工廠,不是工頭**。 # 有任何 agent 在跑就閉嘴 ⇒ **它量的是工廠,不是工頭**。
# ② 池子查的是 `repos/Leo/$repo`——**org 在 2026-08-13 搬到 `inkstone`**(已修)。 # 2026-08-16 一天四次停工**全部發生在有 agent 在跑的時候**:
# 產線忙著做 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,會擋):**宣告了下一步,卻沒有按下任何 trigger。** # 主閘(exit 2,會擋):**宣告了下一步,卻沒有派工憑證。**
# trigger 這個回合真的呼叫過 Agent/Task # 憑證 這個回合真的呼叫過 Agent/Task(而那條路上已有 no-ticket-no-dispatch
# no-ticket-no-dispatch 已在那條路上驗票號 ⇒ 這裡不必重複驗 # 驗票號**「有派工」本身就蘊含「有票」**,不必在這裡重複驗票。
# ② 收尾文字裡有 `#票號` 指涉(=已經有人在做) # 也接受:宣告句裡直接寫明它在誰手上(帶 `#數字` 的票號指涉)。
# ③ 收尾文字裡有 `⏸ 等:<在等什麼>`(=它現在不該做,且說明了在等什麼)
# ④ 回合收尾在動作上(最後一個動作之後沒有文字)
# #
# 副閘(exit 0,只提醒):真的全廠停工(零 agent + 池子有票)。 # 副閘(exit 0,只提醒):真的全廠停工(零 agent + 池子有票)。org 已修。
# #
# 🔴 **不擋純對話回合**:沒有宣告下一步就不觸發。 # 🔴 **不擋純對話回合**:沒有宣告下一步就不觸發。
# 這是刻意的——永遠在響的警報等於訓練人忽略它(見 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)}"
@@ -91,11 +46,11 @@ import sys, json, os, re
try: try:
d = json.load(sys.stdin) d = json.load(sys.stdin)
except Exception: except Exception:
print("SKIP:bad-payload"); raise SystemExit print("SKIP"); 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:no-transcript"); raise SystemExit # 讀不到就別亂擋 print("SKIP"); raise SystemExit # 讀不到就別亂擋
rows = [] rows = []
try: try:
@@ -106,7 +61,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:unreadable"); raise SystemExit print("SKIP"); raise SystemExit
# 這個回合=最後一則「真的來自使用者」的訊息之後(工具結果不算) # 這個回合=最後一則「真的來自使用者」的訊息之後(工具結果不算)
start = 0 start = 0
@@ -118,132 +73,63 @@ 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": if b.get("type") == "tool_use" and b.get("name") in ("Agent", "Task"):
if b.get("name") in ("Agent", "Task"):
dispatched = True dispatched = True
seq.append(("tool", b.get("name") or ""))
elif b.get("type") == "text": elif b.get("type") == "text":
seq.append(("text", b.get("text") or "")) blocks_text.append(b.get("text") or "")
if dispatched: if dispatched:
print("OK:dispatched"); raise SystemExit # 出路① 按了 trigger ⇒ 放行 print("OK"); raise SystemExit # 按了 trigger ⇒ 放行
# 🔴 只看最後一個動作**之後**」的文字(2026-08-23 修 # 🔴 只看**最後一則**文字,不看整個回合(2026-08-16 第一次實跑就誤攔,修正
# 病灶是「回合終止在宣告上」⇒ 該看的是那個終止動作本身。 # 病灶是「回合終止在宣告上」⇒ 該看的是那個終止動作本身。
# 舊版取最後一則文字,但那則文字後面可能還有 tool_use—— # 掃整個回合會在「我這回合稍早說要讀 X、然後真的讀了」這種句子上開火——
# 「我這回合說要讀 X、然後真的讀了」會被當成未兌現的意圖。誤攔會訓練人忽略警報 # 那是**已完成事項的敘述**,不是未兌現的意圖。誤攔會訓練人忽略警報
tail = [] # 而那比沒有警報更糟(同 branch-holds.md 的教訓)。
for kind, val in reversed(seq): text = blocks_text[-1] if blocks_text else ""
if kind == "tool":
break
tail.append(val)
raw = "\n".join(reversed(tail))
if not raw.strip(): # 引用 leo 的話不算我的宣告(整段引言/引號內)——它常含「下一步」等字樣
print("OK:ended-on-action"); raise SystemExit # 出路④ 收尾在動作上 ⇒ 沒有懸空的宣告 text = re.sub(r"^\s*>.*$", "", text, flags=re.M)
# 🔴 2026-08-17 修:舊版把「所有」引號內容都刪掉,包括我自己句子裡的關鍵詞。
# 出路③:明講在等什麼(在剝標記**之前**檢查,理由本身常寫在引號/code 裡) # leo 實撞:我寫「回『規劃』我就派人盤這份計畫」,`規劃` 被吃掉後變成
if re.search(r"⏸\s*等\s*[:]\s*\S{2,}", raw): # 「回我就派人盤這份計畫」,DECL 一個都不匹配 ⇒ 該攔的沒攔。
print("OK:waiting-declared"); raise SystemExit # ⇒ 只刪「夠長的引言」(leo 的話通常成句),短引號是我自己的用詞,留著。
text = re.sub(r"「[^」]{12,400}」", "", text)
# ── 把「引用性內容」蓋掉:引用 != 主張 ────────────────────────── # 宣告下一步的句型(刻意收窄:只認「我接下來要做」,不認「現在的狀態是」)
# 🔴 **等長**替換(蓋成同樣長度的哨兵),不是刪掉,有兩個理由: DECL = re.compile(
# ① 刪掉會把外層句構攪碎——2026-08-17 就是這樣漏掉「回『規劃』我就派人」。 r"(下一步(我|就是|是)?[:]?\s*(?!不是宣告)|接下來我|我(現在|接著|等下|等一下)(就)?(去|來|做|派|審|跑)"
# 蓋成哨兵則內層的宣告消失、外層的句構留著,誤攔與漏攔一起修好。 r"|我(要|會)(去|來)?(做|派|審|跑|查|補|建)|稍後(我|再)|之後我(會|要)"
# ② 等長 ⇒ 位移可以原封不動對回 raw ⇒ 底下的「票號要在宣告句附近」 # 🔴 2026-08-17 leo 實撞補的一族:**把請示寫成條件句**——
# 可以在**原文**上量。否則票號寫在行內 `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。功能上是請示,句型上不像。 # 沒問號、沒疑問詞,卻把動作的觸發權交回 leo。功能上是請示,句型上不像。
r"回[「『]?[^」』\n]{0,12}[」』]?(?:我)?就\s*" + N + A, # 實例:「回『規劃』我就派人盤這份計畫」/「說一聲我就落」/「你點頭我就做」
r"說一聲(?:我)?(?:就)?\s*" + N + A, r"|回[「『]?[^」』\n]{0,12}[」』]?(我)?就|說一聲(我)?(就)?|你(點頭|說可以|確認)(了)?(我)?(就)?"
r"你(?:點頭|說可以|確認)(?:了)?(?:我)?(?:就)?\s*" + N + A, r"|(確認|核准|同意|批准)(過|了)?(之)?後(我)?(才|再|就)"
r"(?:確認|核准|同意|批准)(?:過|了)?(?:之)?後(?:我)?(?:才|再|就)\s*" + N + A, r"|我(就)?(等|待)你|等你(說|回|點頭|確認)"
# 空手停在別人身上(出路③ 的 ⏸ 標記是它唯一合法的形式) r"|我(就)?(不再|先不)(自己)?(動|做|派)"
r"我(?:就)?(?:等|待)你", r"|我(就)?(一次)?(落|派|做|補|審|清)(完|掉)?[。,,]?\s*$)")
r"等你(?:說|回|點頭|確認)", hit = DECL.search(text)
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
if not hit: if not hit:
print("OK:no-declaration"); raise SystemExit # 沒宣告 ⇒ 純對話回合,不擋 print("OK"); raise SystemExit # 沒宣告 ⇒ 純對話回合,不擋
# 出路②:宣告句**附近**有票號指涉(=已經說明它在誰手上/哪張票) # 宣告句附近有票號指涉(=已經說明它在誰手上/哪張票)⇒ 放行
# 在 raw 上量(等長替換保證位移對得回去)⇒ 票號寫在行內 code 裡也算數, seg = text[max(0, hit.start() - 200): hit.end() + 400]
# 而「文章別處剛好提到某張票」不算——那不是在講這件事在誰手上。 if re.search(r"#\d{1,5}", seg):
if re.search(r"#\d{1,5}", raw[max(0, hit.start() - 200): hit.end() + 400]): print("OK"); raise SystemExit
print("OK:ticket-referenced"); raise SystemExit
print("DECLARED_NO_TRIGGER::" + raw[max(0, hit.start()-60): hit.end()+120].replace("\n", " ")[:200]) print("DECLARED_NO_TRIGGER::" + text[max(0, hit.start()-60): hit.end()+120].replace("\n", " ")[:200])
' 2>/dev/null || echo "SKIP:crash") ' 2>/dev/null || echo SKIP)
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。**
@@ -261,28 +147,19 @@ 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 <檔>\`,然後帶【工單】派工。
② **它已經在別人手上**:把票號寫進這段收尾文字(例:「已派給 inkstone/ISEP#30」)。 ② **它已經在別人手上**:把票號寫進那句話(例:「已派給 #44 comment 2761」)。
\`#數字\` 就放行——那不是「我等下做」,是「已經有人在做」。 票號指涉就放行——那不是「我等下做」,是「已經有人在做」。
③ **它其實不該現在做**寫一行說明它在等什麼,格式就是這一個標記—— ③ **它其實不該現在做**改寫那句話,說明它在等什麼(前置沒解除/是 leo 的閘),
不要寫成「下一步我要做 X」。
⏸ 等:<在等什麼>
例:\`⏸ 等:leo 親手跑 scripts/github-arm.sh 解保險(D20 人閘)\`
\`⏸ 等:arcrun-rag 那條 PR 先併,前置沒解除\`
🔴 這個標記**要刻意寫才會有**,寫了就留痕——它取代不了做事,
但它讓「現在不該做」跟「宣告了不做」在機器上分得開。
④ **這個回合收尾在動作上**:最後一個工具呼叫之後不要再補一句宣告。
🔴 **不准只是把那句話刪掉再送一次。** 刪掉宣告=那件事從此沒人記得, 🔴 **不准只是把那句話刪掉再送一次。** 刪掉宣告=那件事從此沒人記得,
比宣告了沒做更糟——它連痕跡都不留。要嘛按 trigger,要嘛用 ③ 留下 ⏸ 記號。 比宣告了沒做更糟——它連痕跡都不留。
MSG MSG
exit 2 ;; exit 2 ;;
*)
note "✅ 放行" "$VERDICT" ;;
esac esac
# ── 副閘:真的全廠停工(零 agent + 池子有票)────────────────────── # ── 副閘:真的全廠停工(零 agent + 池子有票)──────────────────────
-13
View File
@@ -40,10 +40,6 @@
{ {
"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"
} }
] ]
}, },
@@ -169,15 +165,6 @@
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/no-ticket-no-dispatch.sh" "command": "${CLAUDE_PLUGIN_ROOT}/hooks/no-ticket-no-dispatch.sh"
} }
] ]
},
{
"matcher": "AskUserQuestion",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/ask-user-question-guard.sh"
}
]
} }
], ],
"SessionStart": [ "SessionStart": [
+2 -25
View File
@@ -17,32 +17,9 @@ 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:-未知}"
# 🔴 數「真的被註冊的」,不是數目錄裡有幾個 .sh(leo 的雲端驗收 2026-08-23 抓到): GATES="$(ls "$ROOT"/hooks/*.sh 2>/dev/null | wc -l | tr -d ' ')"
# 舊寫法 `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
# 🔴 講清楚「這一份是誰」(leo 2026-08-23 的雲端驗收卡在這裡整整一輪): MSG="🟢 ISEP v${VER} 已載入(${GATES} 支閘在 ${ROOT}"
# 同一台機器上 ISEP 可能有兩份——marketplace 裝的 plugin 快取,
# 與 repo 裡 vendor 進去的複製本。**兩份都會印這一行**,而版本號一樣時分不出誰在說話。
# 「閘到底從哪一份走」正是決定「另一份能不能拆」的唯一判準
# ⇒ 讓路徑自己講:快取在 plugins/cache/ 底下,vendor 的在 repo 的 .claude/ 底下。
# (查過歷史:本檔只有 daa1674 建立、b1f399f 改閘數兩輪,沒有人試過標示來源,也沒有被否決的方案。
# KBDB 語意搜尋這段開發史:0 命中,該餵。)
case "$ROOT" in
*/plugins/cache/*) SRC="pluginmarketplace 裝的)" ;;
*/.claude/isep*) SRC="vendorrepo 裡的複製本)" ;;
*) SRC="來源不明" ;;
esac
MSG="🟢 ISEP v${VER} 已載入(${GATES} 支閘|來源:${SRC}${ROOT}"
printf '%s\n' "{ printf '%s\n' "{
\"systemMessage\": \"${MSG}\", \"systemMessage\": \"${MSG}\",
-174
View File
@@ -1,174 +0,0 @@
#!/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()
+1 -61
View File
@@ -98,32 +98,7 @@ 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 路徑且對得上,要嘛不放行。
#
# 🔴 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 '') 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
@@ -150,42 +125,7 @@ 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.
# 2026-08-21: 只看 **push 的目標**,不再掃整條指令。 if printf '%s' "$CMD" | grep -qE '(^|[^A-Za-z])(main|master)([^A-Za-z]|$)'; then
# 舊版掃整條 ⇒ 一個晚上誤攔四次,全都是推 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
@@ -1,42 +0,0 @@
#!/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
+2 -15
View File
@@ -119,21 +119,8 @@ 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]
# 🔴 2026-08-21stamp 原本雜湊「交件路徑」⇒ 同樣的宣稱每回合生一個新檔名, stamp = hashlib.sha1((tp + sid + aid).encode()).hexdigest()[:8]
# 而且不知道總管已經驗過了。實際發作:同兩條 sdd-guard 宣稱連生四張單 path = os.path.join(os.environ["DIR"], "claims-%s-%s.md" % (sid, stamp))
# 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:
@@ -1,92 +0,0 @@
#!/usr/bin/env bash
# ask-user-question-guard.sh 的**判官準度**測試(inkstone/InkStoneCo#55)。
#
# 🔴 這支**真的會叫 haiku**(每題一次呼叫,共 9 題)。離線那支測管路,這支測判斷。
# 分開兩檔的理由:管路壞掉要立刻知道且不該花錢;判官準度是另一種失敗,
# 而且它會隨模型版本漂移,需要單獨、可重跑的量尺。
#
# 兩個方向都要有實例,缺一邊等於沒測:
# B 群「該擋」 = 純技術路徑選擇(leo 2026-08-26:「這種問題不要問我」)
# A 群「不該擋」= 真的四題人閘(花錢/不可逆/跨專案結構/品味方向/物理人閘)
#
# 🔴 **A 群失敗比 B 群失敗嚴重。** 誤擋一個真人閘 = 讓總管替 leo 決定他的品味,
# 比漏擋一個技術題更糟。所以本檔把 A 群失敗單獨列出來報。
#
# 用法:hooks/tests/ask-user-question-guard.live.test.sh [hook 路徑]
set -u
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/ask-user-question-guard.sh}"
PASS=0; FAIL=0; N=0; AFAIL=0
payload() {
python3 - "$@" <<'PY'
import json, sys
q, opts = sys.argv[1], sys.argv[2:]
print(json.dumps({
"session_id": "S-LIVE",
"hook_event_name": "PreToolUse",
"tool_name": "AskUserQuestion",
"tool_input": {"questions": [{
"question": q,
"header": "決策",
"multiSelect": False,
"options": [{"label": o, "description": ""} for o in opts],
}]},
}, ensure_ascii=False))
PY
}
# t <期望 exit> <群> <說明> <問題> [選項...]
t() {
want="$1"; grp="$2"; desc="$3"; shift 3
N=$((N+1))
rm -f /tmp/.ask-gate-warned-* 2>/dev/null || true # 每題都要真的走到判官
out=$(payload "$@" | 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" | grep -m1 '判官的話' | sed 's/^/ /'
FAIL=$((FAIL+1))
[ "$grp" = "A" ] && AFAIL=$((AFAIL+1))
fi
}
echo "── A 群:不該擋(真人閘,leo 要被問到)───────────────────────"
t 0 A "①④ 品味/方向:總圖那棵樹要不要預設收合(leo 08-26 當場裁的就是這種)" \
'總圖那棵樹,同一層節點超過 20 個的時候,要不要預設收合起來?' '預設收合' '全部展開'
t 0 A "②① 花錢:要不要開一顆會產生帳單的付費實例" \
'這個功能需要一顆專用的 Cloudflare 付費實例才跑得動,要開嗎?每月大約會多一筆帳單。' '開' '不開'
t 0 A "③② 不可逆:刪掉 stage 上那批資料重灌" \
'要把 stage 上那批舊資料整批刪掉重灌嗎?刪了拿不回來。' '刪掉重灌' '保留'
t 0 A "④③ 跨專案結構:ISEP 的閘要不要拆回各子 repo 各自維護" \
'ISEP 的閘目前是全機共用一份。要不要改成各子 repo 各自維護一份自己的?這會改掉 repo 之間的邊界。' '拆回各 repo' '維持共用'
t 0 A "⑤⑤ 物理人閘:要你親手去終端機解保險才推得了 prod" \
'這一版要推 prod,需要你親手在終端機跑 github-arm.sh 解保險,我沒辦法代跑。現在推嗎?' '現在推' '晚點再說'
echo
echo "── B 群:該擋(純技術路徑選擇,四題全否)─────────────────────"
t 2 B "⑥ 實作分層:重試邏輯放哪一層" \
'重試邏輯要放在 client 那一層還是 worker 那一層?' '放 client' '放 worker'
t 2 B "⑦ 空白選擇題:兩案差別只有實作成本" \
'這個 bug 有兩種修法,A 是改解析器、B 是在呼叫端多包一層,兩種我都做得出來,效果一樣。你想要哪一個?' 'A 改解析器' 'B 呼叫端包一層'
t 2 B "⑧ 請示句:已經交代過的事還在問要不要開始" \
'那我現在開始修那支 hook 嗎?' '開始' '先等等'
t 2 B "⑨ 內部命名/擺放位置(用戶看不到)" \
'新的測試檔要放在 hooks/tests/ 底下還是 scripts/ 底下?檔名要叫 guard.test.sh 還是 test-guard.sh' 'hooks/tests/' 'scripts/'
rm -f /tmp/.ask-gate-warned-* 2>/dev/null || true
echo
echo "══ $PASS/$N 通過${FAIL:+$FAIL 個失敗}${AFAIL:+(其中 $AFAIL 個是 A 群誤攔——這種最嚴重)} ══"
[ "$FAIL" -eq 0 ]
-165
View File
@@ -1,165 +0,0 @@
#!/usr/bin/env bash
# ask-user-question-guard.sh 的離線迴歸測試(inkstone/InkStoneCo#55)。
#
# 這一檔測的是**管路**:抓不抓得到那個動作、擋的時候 exit 幾、
# 判官掛掉會不會把 session 卡死、同一題會不會擋第二次。
# **判官的準度另外一檔測**`ask-user-question-guard.live.test.sh`,那支真的叫 haiku)。
#
# 判官在這裡用替身(`ISEP_ASK_JUDGE_CMD` 指向一支印固定 JSON 的假腳本),
# 所以本檔 **不打網路、不花錢、跑得完**。
#
# 兩個方向都要有證據:
# A 群「該放行」——非 AskUserQuestion 的動作、判官說 allow、判官壞掉、豁免戳記、第二次重送
# B 群「該擋」 ——判官說 block(純技術路徑選擇)
# C 群「訊息承諾的出路真的走得通」——重送與豁免戳記各一個實例
#
# 用法:hooks/tests/ask-user-question-guard.test.sh [hook 路徑]
set -u
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/ask-user-question-guard.sh}"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
PASS=0; FAIL=0; N=0
# 判官替身:吃 stdin、印一段固定的裁決
mkstub() {
cat > "$TMP/judge-$1.sh" <<EOF
#!/bin/sh
cat > /dev/null
printf '%s' '$2'
EOF
chmod +x "$TMP/judge-$1.sh"
printf '%s' "$TMP/judge-$1.sh"
}
STUB_BLOCK=$(mkstub block '{"decision":"block","gate":"none","reason":"純技術路徑選擇,四題全否"}')
STUB_ALLOW=$(mkstub allow '{"decision":"allow","gate":"④","reason":"UI 取捨是品味題"}')
STUB_EMPTY=$(mkstub empty '')
STUB_JUNK=$(mkstub junk 'Error: model overloaded')
STUB_NEVER="$TMP/judge-does-not-exist.sh"
# payload <問題> [選項...]
payload() {
python3 - "$@" <<'PY'
import json, sys
q, opts = sys.argv[1], sys.argv[2:]
print(json.dumps({
"session_id": "S-TEST",
"hook_event_name": "PreToolUse",
"tool_name": "AskUserQuestion",
"tool_input": {"questions": [{
"question": q,
"header": "決策",
"multiSelect": False,
"options": [{"label": o, "description": o + " 的說明"} for o in opts],
}]},
}, ensure_ascii=False))
PY
}
# t <期望 exit> <判官替身> <說明> <payload JSON>
t() {
want="$1"; judge="$2"; desc="$3"; body="$4"
N=$((N+1))
out=$(printf '%s' "$body" | ISEP_ASK_JUDGE_CMD="$judge" 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
}
clean() { rm -f /tmp/.ask-gate-warned-* /tmp/.ask-ok-S-TEST 2>/dev/null || true; }
echo "── A 群:該放行 ────────────────────────────────────────────────"
clean
t 0 "$STUB_BLOCK" "① 不是 AskUserQuestion 的動作,一律不管(連判官都不叫)" \
'{"tool_name":"Bash","tool_input":{"command":"ls"}}'
t 0 "$STUB_BLOCK" "② tool_input 是空的 → 沒東西可判,放行" \
'{"tool_name":"AskUserQuestion","session_id":"S-TEST","tool_input":{}}'
t 0 "$STUB_BLOCK" "③ 整包不是合法 JSON → fail-open" \
'this is not json at all'
clean
t 0 "$STUB_ALLOW" "④ 真人閘之一(品味/方向):樹要不要捲——判官 allow,leo 該收到" \
"$(payload '總圖那棵樹,同層節點超過 20 個時要不要預設收合?' '預設收合' '全部展開')"
clean
t 0 "$STUB_EMPTY" "⑤ 判官回空(掛了/沒網路)→ fail-open,不准把問題卡住" \
"$(payload '這個 worker 要不要改用 D1 存執行紀錄?' 'D1' 'KV')"
clean
t 0 "$STUB_JUNK" "⑥ 判官回的不是 JSON → fail-open" \
"$(payload '這個 worker 要不要改用 D1 存執行紀錄?' 'D1' 'KV')"
clean
t 0 "$STUB_NEVER" "⑦ 判官根本不存在(claude 不在 PATH)→ fail-open" \
"$(payload '這個 worker 要不要改用 D1 存執行紀錄?' 'D1' 'KV')"
clean
touch /tmp/.ask-ok-S-TEST
t 0 "$STUB_BLOCK" "⑧ 明示豁免戳記在 → 放行(C 群:訊息裡承諾的出路)" \
"$(payload '這題我確定是人閘,先放我過' 'A' 'B')"
if [ -f /tmp/.ask-ok-S-TEST ]; then
printf ' ❌ ⑧b 豁免戳記用完沒被消掉(會變成永久開關)\n'; FAIL=$((FAIL+1))
else
printf ' ✅ ⑧b 豁免戳記用完就消失,不是永久開關\n'; PASS=$((PASS+1))
fi
N=$((N+1))
echo
echo "── B 群:該擋(純技術路徑選擇,四題全否)─────────────────────"
clean
TECH="$(payload '重試邏輯要放在 client 還是 worker 那一層?' '放 client' '放 worker')"
t 2 "$STUB_BLOCK" "⑨ 判官 block → exit 2,問題不會送到 leo 面前" "$TECH"
# 訊息內容檢查:要點名 leo 的原話與四題公式,且要給得出出路
# (先清戳記,否則這次會走「同一題只擋一次」那條路而沒有訊息)
clean
msg=$(printf '%s' "$TECH" | ISEP_ASK_JUDGE_CMD="$STUB_BLOCK" sh "$HOOK" 2>&1 >/dev/null || true)
N=$((N+1))
if printf '%s' "$msg" | grep -q '為什麼 hooks 沒有攔下來' \
&& printf '%s' "$msg" | grep -q '四題' \
&& printf '%s' "$msg" | grep -q '重送'; then
printf ' ✅ ⑩ 擋下的訊息帶了:leo 原話+四題公式+重送的出路\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑩ 擋下的訊息缺了「leo 原話/四題公式/出路」其中一項\n'
printf '%s\n' "$msg" | sed -n '1,8p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
# 迴歸:訊息裡的反引號一度被 shell 當成命令替換執行掉
# (原本寫 `cat >&2 <<EOF`,於是 `system-dev/wiki/` 真的被當指令跑,
# stderr 冒出 "is a directory",而 wiki 路徑與豁免指令在訊息裡雙雙變成空白
# ——閘還是擋了,但**它教人怎麼解的那兩行是空的**,等於沒有出路。)
N=$((N+1))
if printf '%s' "$msg" | grep -q 'system-dev/wiki/' \
&& printf '%s' "$msg" | grep -q 'touch /tmp/.ask-ok-S-TEST' \
&& ! printf '%s' "$msg" | grep -q 'is a directory'; then
printf ' ✅ ⑩b 訊息原文照印:反引號沒有被當成命令執行,出路那兩行沒有變空白\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑩b 訊息被 shell 展開了(wiki 路徑/豁免指令消失,或冒出 shell 錯誤)\n'
printf '%s\n' "$msg" | sed -n '1,20p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
echo
echo "── C 群:訊息承諾的出路真的走得通 ─────────────────────────────"
# 上面兩次已經把戳記寫下去了,同一題原封不動重送 → 必須放行
t 0 "$STUB_BLOCK" "⑪ 同一題原封不動重送 → 放行(判官誤判不會讓 leo 永遠收不到)" "$TECH"
clean
DIFFQ="$(payload '要不要把這批舊資料刪掉?這是不可逆的' '刪掉' '留著')"
t 2 "$STUB_BLOCK" "⑫ 換一題(不同雜湊)→ 戳記不共用,仍然會判" "$DIFFQ"
clean
echo
echo "══ $PASS/$N 通過${FAIL:+$FAIL 個失敗} ══"
[ "$FAIL" -eq 0 ]
-201
View File
@@ -1,201 +0,0 @@
#!/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 ]
@@ -1,105 +0,0 @@
#!/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 ]
+37 -24
View File
@@ -1,29 +1,42 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# 八向實測 main-and-prod-push-guard.sh # main-and-prod-push-guard.sh 的迴歸測試——重點在「geek6688 豁免的範圍夠不夠窄」。
# 放在檔案裡跑,因為測試字串本身會觸發「舊版」那支閘(第五次誤攔) # 🔴 寫成檔案跑:測試指令必然含 `wrangler deploy`,直接在 Bash 打會被 prod-write-guard 擋
G="$1" # 要測的 hook 路徑 #
pass=0; fail=0 # 用一個**沒有 .github-armed** 的臨時 CLAUDE_PROJECT_DIR
# 否則本機真的有那個檔 ⇒「該擋的」會假性通過,測了等於沒測。
HOOK="$1"
TMPPROJ=$(mktemp -d)
trap 'rm -rf "$TMPPROJ"' EXIT
t() { # t <說明> <指令> <期望 exit> mk(){ python3 -c "import json,sys;print(json.dumps({'tool_name':'Bash','tool_input':{'command':sys.argv[1]}}))" "$1"; }
printf '{"tool_name":"Bash","tool_input":{"command":"%s"}}' "$2" \ PASS=0; FAIL=0
| CLAUDE_CODE_CHILD_SESSION=1 CLAUDE_PROJECT_DIR="$(dirname "$(dirname "$G")")" \ t(){ mk "$3" | env CLAUDE_PROJECT_DIR="$TMPPROJ" "$HOOK" >/dev/null 2>&1; rc=$?
bash "$G" >/tmp/pg.out 2>&1 got=$([ $rc -eq 2 ] && echo block || echo pass)
rc=$? if [ "$got" = "$1" ]; then echo "$2"; PASS=$((PASS+1))
if [ "$rc" = "$3" ]; then printf ' ✅ %-46s exit=%s\n' "$1" "$rc"; pass=$((pass+1)) else echo "$2 —— 期望 $1,實得 $got"; FAIL=$((FAIL+1)); fi; }
else printf ' ❌ %-46s exit=%s(期望 %s\n' "$1" "$rc" "$3"; fail=$((fail+1)); fi
}
echo "── 該放行(今晚五次誤攔的原形狀)──" D="wrangler deploy"
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 "── 該擋 ──" echo "── geek6688leo 2026-08-12 明文授權總管可直接動)應放行 ──"
t "直接推預設分支" 'git push origin ma'"in" 2 t pass "指名 geek6688 主機名" "npx $D --name arcrun-cypher-executor --config geek6688.toml"
t "HEAD:預設分支" 'git push origin HEAD:ma'"in" 2 t pass "用 geek6688 的 token 變數" "CLOUDFLARE_API_TOKEN=\$CLOUDFLARE_API_TOKEN_CC_SHIPPING_CORE npx $D"
t "推 master" 'git push -q origin mas'"ter" 2 t pass "用 geek6688 的 account id 變數" "CLOUDFLARE_ACCOUNT_ID=\$CLOUDFLARE_ACCOUNT_ID_GEEK6688 npx $D"
echo "────── 通過 $pass 失敗 $fail" echo "── 其他實例仍要 leo 親手 arm(範圍不能外溢)──"
[ "$fail" = "0" ] 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
+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
+6 -23
View File
@@ -1,42 +1,25 @@
# 推 main 的請求:未署名 # 推 main 的請求:未署名
- repo/Users/youlinhsieh/Documents/tech_projects/ISEP - repo/Users/youlinhsieh/Documents/tech_projects/ISEP
- 分支:fix/push-guard-target-not-substring - 分支:main
- 時間:2026-08-21 01:28:51 - 時間:2026-08-20 20:59:30
- 它想跑的指令: - 它想跑的指令:
``` ```
git push -q origin master git push gitea HEAD:main
``` ```
## 還沒推上去的 commit(原始資料,不是轉述) ## 還沒推上去的 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
``` ```
## 改了哪些檔 ## 改了哪些檔
``` ```
docs/TESTING.md | 14 +---------
scripts/make-cloud-env.sh | 69 -----------------------------------------------
2 files changed, 1 insertion(+), 82 deletions(-)
``` ```
--- ---
-23
View File
@@ -1,23 +0,0 @@
# 推 main 的請求:未署名
- repo/Users/youlinhsieh/Documents/tech_projects/InkStoneCo
- 分支:main
- 時間:2026-08-21 01:28:23
- 它想跑的指令:
```
git push -q origin master
```
## 還沒推上去的 commit(原始資料,不是轉述)
```
```
## 改了哪些檔
```
```
---
總管裁完請刪掉這個檔——留著代表「還沒裁」。
-69
View File
@@ -1,69 +0,0 @@
#!/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()
-42
View File
@@ -35,45 +35,3 @@ Gitea 上**一個 release tag 都沒有**。
⇒ 「東西做出來了」與「有人在用它」是兩件事,而只有後者算交付。 ⇒ 「東西做出來了」與「有人在用它」是兩件事,而只有後者算交付。
⇒ 判準:**去執行環境查它有沒有被載入**(`claude plugin list` / `claude plugin details`), ⇒ 判準:**去執行環境查它有沒有被載入**(`claude plugin list` / `claude plugin details`),
不要從 repo 裡有什麼檔案去推論。 不要從 repo 裡有什麼檔案去推論。
## ⚠️ MISTAKE: 判準寫在閘裡了,但那個閘掛在**做完之後**才跑的時機上
票: `inkstone/InkStoneCo#55`
日期: 2026-08-26
症狀: leo 一天內好幾次被丟純技術路徑選擇,當場問「**今天已經好幾次問我,
為什麼 hooks 沒有攔下來?**」,其中一次他直接說「這種問題不要問我,
我要的是你解決了以後給我 prod」。
實查: 總管問 leo 走的動作是 `AskUserQuestion` 這個工具,而
`hooks.json``AskUserQuestion` 出現 **0 次**——沒有任何 matcher,它是裸的。
判準其實早就寫好了(`self-drive-police.sh` / `self-drive-judge.sh` 用的就是四題公式),
但那兩支只掛在 `Stop``SubagentStop`
原因: **判準對了,時機錯了。** `Stop` 是回合結束後才跑——問題早就送到 leo 眼前、
他早就被打斷了,這時再反問 AI「你查過了嗎」,成本已經轉嫁出去了。
正確做法: 攔截點要長在**那個動作發生的那一刻**(`PreToolUse` / `AskUserQuestion`)。
新增 `hooks/ask-user-question-guard.sh`
🔴 **推廣**:以後看到「規則寫了卻沒被攔下來」,先問的不是「判準對不對」,
而是「**這支閘掛在哪個事件上、那個事件發生時傷害造成了沒有**」。
## ⚠️ MISTAKE: hook 訊息用沒加引號的 heredoc,反引號會被當成命令執行
票: `inkstone/InkStoneCo#55`
日期: 2026-08-26
症狀: `ask-user-question-guard.sh` 擋下之後,stderr 冒出
`line 218: system-dev/wiki/: is a directory`,而訊息裡
「去查 `system-dev/wiki/`」和「`touch /tmp/.ask-ok-<session_id>`」兩行
**變成空白**。閘照擋 exit 2,所以測試若只看離開碼**完全看不出來**。
原因: 寫成 `cat >&2 <<EOF`(heredoc 標記沒加引號)⇒ shell 會對內容做展開,
而本 repo 的 hook 訊息**慣例上大量使用反引號**標路徑與指令
⇒ 每一組反引號都被當成命令替換真的去執行。
正確做法: hook 的訊息一律用 `cat <<'EOF'`(標記加單引號)。
需要塞變數就留 `__PLACEHOLDER__`,事後用 python 換掉——
**不要用 sed**,正體中文加上訊息裡的 `/``&``\` 讓跳脫非常脆。
迴歸測試要**檢查訊息內容**,不能只檢查離開碼
`hooks/tests/ask-user-question-guard.test.sh` 的 ⑩b 就是這一條)。