Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a4eb17df48 | |||
| 35e927ef55 | |||
| 736a601589 | |||
| 1b64f3cc4e | |||
| fa2f76177e | |||
| a671948fa5 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "isep",
|
||||
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:48 支機械閘(59 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||
"version": "0.6.0",
|
||||
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:49 支機械閘(60 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||
"version": "0.7.0",
|
||||
"keywords": [
|
||||
"inkstone",
|
||||
"guardrails",
|
||||
|
||||
+77
-6
@@ -1,4 +1,4 @@
|
||||
# 48 支閘,白話盤點表
|
||||
# 49 支閘,白話盤點表
|
||||
|
||||
> 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」
|
||||
> 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。
|
||||
@@ -7,14 +7,16 @@
|
||||
|
||||
## 一句話結論
|
||||
|
||||
`hooks/` 底下有 **48 個 `.sh` 檔**,`hooks.json` 實際掛上 **59 條註冊**(同一支閘常被多種情境同時掛上);
|
||||
`hooks/` 底下有 **49 個 `.sh` 檔**,`hooks.json` 實際掛上 **60 條註冊**(同一支閘常被多種情境同時掛上);
|
||||
其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。
|
||||
下面按「你會在什麼時候撞到它」分組,一支一行。
|
||||
|
||||
> 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」,
|
||||
> 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。
|
||||
> 現在的寫法是實際數出來的:
|
||||
> `ls hooks/*.sh | wc -l` = 48;`grep -c '"command"' hooks/hooks.json` = 59。
|
||||
> `ls hooks/*.sh | wc -l` = 49;`grep -c '"command":' hooks/hooks.json` = 60。
|
||||
> ⚠️ **冒號不能省**:`grep -c '"command"'`(沒冒號)會連 `"type": "command"` 一起數到,回 **120**。
|
||||
> 本頁 2026-08-27 之前寫的是沒冒號那版——**照著它跑會拿到一個跟本頁不符的數字**。
|
||||
> **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。
|
||||
>
|
||||
> 📌 **`0.6.0`(`inkstone/ISEP#59`,2026-08-27)這兩個數字是併完之後當場數出來的**:
|
||||
@@ -23,6 +25,13 @@
|
||||
> 加上 `ISEP#72` 的 `reply-identity-guard.sh` 已在 main 上。
|
||||
> 一加一減之後檔數不變、註冊條數落在 59——**這正是「不能靠加減推算、只能實數」的例子**:
|
||||
> 上一版寫 58,就是把兩批改動的加減兜出來、沒有真的數過。
|
||||
>
|
||||
> 🔴 **`0.6.1`(2026-08-27)補回表格漏掉的兩列**:`isep-presence-beacon.sh`(E 組)、
|
||||
> `milestone-due-guard.sh`(A 組)。**標頭寫 48 支的同時,下面的表格只列得出 46 支**
|
||||
> ——數字是對的,人話少了兩行。發現的方法與防它再犯的指令,見下面「落差偵測」第二組 `comm`。
|
||||
>
|
||||
> 📌 **`0.7.0`(`inkstone/ISEP#30`,2026-08-27)進來一支**:`mainline-idle-guard.sh`(F 組,Stop)。
|
||||
> 48→**49** 支、59→**60** 條,兩個數字都是加完之後當場數出來的(指令同上)。
|
||||
|
||||
---
|
||||
|
||||
@@ -51,6 +60,7 @@
|
||||
| `leo21c-write-guard.sh` | AI 想寫東西到你私人帳號 `leo21c` 就擋下——這個帳號只准你自己用,AI 的實驗一律走別的測試帳號。 | 🛑 擋 |
|
||||
| `release-tag-guard.sh` | AI 想打版本 tag,但 `plugin.json` 裡寫的版本號跟 tag 對不上就擋下——避免「這裡說 0.2.0、那裡說 0.1.0」各說各話。 | 🛑 擋 |
|
||||
| `ticket-api-bypass-guard.sh` | AI 想繞過 `scripts/ticket` 工具、直接打 API 新增 Gitea 東西(開新票/milestone/label/PR,會漏掉「新增前先搜過」這道檢查)就擋下——不管是隱式 POST(urllib 傳 data= 沒寫 method)還是小寫 `requests.post(...)`。 | 🛑 擋 |
|
||||
| `milestone-due-guard.sh` | AI 想開一個新的里程碑(milestone)卻沒填期限就擋下,**填 `9999-01-01` 一樣擋**——那是「沒有期限」穿了一件期限的衣服,盤點時每一格看起來都有值,其實一格都沒有。只管「建里程碑」這個動作,查詢/改別的欄位都放行。 | 🛑 擋 |
|
||||
| `reply-identity-guard.sh` | 直接打 Gitea API 貼留言到票上時,內文第一行沒有 `【身份】`(`總管`/`subagent`/`leo`)就擋下——票上多條線並行時,看不出哪一則是誰寫的。純讀取、走 `scripts/ticket` 正門、指令裡加 `reply-identity-ok` 都放行。 | 🛑 擋 |
|
||||
| `comment-carries-task-guard.sh` | AI 要在票裡留一則「等某某上線才驗得了」這種**還沒做完的事**,卻沒有把它開成一張子票時,擋一次。留言沒人會回頭讀,票的 open/closed 撈一次就看得到。 | 🛑 擋(同一輪只擋一次) |
|
||||
|
||||
@@ -103,6 +113,7 @@
|
||||
|
||||
| 閘名 | 對你意味著什麼 | 動作 |
|
||||
|---|---|---|
|
||||
| `isep-presence-beacon.sh` | 對話一開始印一行 `🟢 ISEP vX.Y.Z 已載入(N 支閘|來源:…)`。**這行不是裝飾,是唯一能證明「這個 session 真的有閘」的東西**——它自己就住在 plugin 裡,看得到它就表示 plugin 載入了;某個 session 從頭到尾沒有這行,那個 session 是零閘狀態,先修 plugin 再做事。同一台機器可能同時有兩份 ISEP(marketplace 裝的、repo 裡 vendor 的),所以那行會講出這次是哪一份在說話。 | 📝 記錄(context 注入) |
|
||||
| `session-start-recall.sh` | 對話一開始就自動把「全局現況」(Gitea 各 repo 的票、KBDB 的藏書地圖)推到 AI 眼前,不必等它自己想到要查。 | 📝 記錄(context 注入) |
|
||||
| `skill-deploy-drift-guard.sh` | 如果「全機真正在用的 skill」跟「repo 裡版控的正本」內容對不上,就在開場講出來——避免用著一份沒人知道已經跟正本分家的舊拷貝。 | 📝 記錄 |
|
||||
|
||||
@@ -113,6 +124,7 @@
|
||||
| `empty-handed-stop-guard.sh` | 這一輪 AI**一個動作都沒做**卻想停下來(等你回覆),就擋下並告訴它「你的命令就是完整授權,不用再等第二次確認」。 | 🛑 擋(至多攔一次) |
|
||||
| `worklist-guard.sh` | AI 自己列過的待辦清單裡還有沒做完的步驟,卻想收工寫報告,就擋下,逼它做完剩下的步驟。 | 🛑 擋 |
|
||||
| `factory-idle-guard.sh` | AI 該去派工卻沒派(工頭停工),就擋下要它交出「已經派工的憑證」,不是隨口說一句「我會催」就算數。**過閘有四條路:現在就派工/把票號寫進那句話/寫一行 `⏸ 等:<在等什麼>`/這一輪收尾在動作上。** 2026-08-23(inkstone/ISEP#30)修好「引用被當成主張」——貼原始碼、引用它自己的訊息、否認自己有下一步,都不再被咬。 | 🛑 擋 |
|
||||
| `mainline-idle-guard.sh` | AI **連續 4 個回合一直在查、一直在讀,卻什麼都沒交出去**(沒派工、沒改檔、沒寫回票)就擋一次。補的是上面兩支中間那一格:「沒動作」空手警察管、「說了下一步卻沒派」稼動率警察管,**而「有動作、但就是不說下一步」以前兩支都放行**。這支**一個字都不讀**,只看有沒有派工/產出/工作區變動。響過一次門檻就加倍(4→8→16),不會變成一直在響的警報。 | 🛑 擋(響過就退讓) |
|
||||
| `browser-verify-guard.sh` | 這一輪 AI 宣稱「前端驗過了」,卻沒有真的用瀏覽器工具載入過,就擋下——`curl` 抓到 HTML 不算驗過。 | 🛑 擋 |
|
||||
| `self-drive-police.sh` | AI 想停下來問你「早就決定過的事」(用固定句型判斷,例如「要不要 X」「下一步做什麼」「這交給你」)就擋下,反問它查過 wiki/查過派工表了沒。 | 🛑 擋 |
|
||||
| `self-drive-judge.sh` | 跟上面同一件事,但改用小模型(haiku)判斷「換句話說」的請示句——防止 AI 只是把「要不要」改寫成「不確定是否符合期待」就閃過上一支閘。 | 🛑 擋 |
|
||||
@@ -177,7 +189,62 @@ $ comm -13 /tmp/fs_hooks.txt /tmp/registered_hooks.txt
|
||||
(無輸出)
|
||||
```
|
||||
|
||||
**這次順便抓到的另一個落差**:`.claude-plugin/plugin.json` 的說明文字寫「42 支機械閘(52 條註冊)」,
|
||||
🔴 **上面那兩行驗不出「表格漏列」**——它們比的是 `hooks.json`,不是這張表。
|
||||
「有閘、但這頁沒有它那一行人話」要另外一組指令才看得見,就是下面這組
|
||||
(2026-08-27 就是靠它抓到本頁漏了 `isep-presence-beacon.sh`/`milestone-due-guard.sh` 兩列):
|
||||
|
||||
```
|
||||
$ ls hooks/*.sh | xargs -n1 basename | sort > /tmp/inv_fs.txt
|
||||
$ grep -oE '^\| `[a-zA-Z0-9_.-]+\.sh`' docs/hooks-inventory.md \
|
||||
| grep -oE '[a-zA-Z0-9_.-]+\.sh' | sort -u > /tmp/inv_table.txt
|
||||
|
||||
# 有這支閘、但這頁沒有它那一列 → 要補人話
|
||||
$ comm -23 /tmp/inv_fs.txt /tmp/inv_table.txt
|
||||
(無輸出)
|
||||
|
||||
# 這頁列了、但檔案已經不在了 → 要刪那一列
|
||||
$ comm -13 /tmp/inv_fs.txt /tmp/inv_table.txt
|
||||
(無輸出)
|
||||
```
|
||||
|
||||
🔴 **`^\| \`` 這個錨點不能拿掉**——它的意思是「**這一行的第一欄就是這支閘**」,
|
||||
也就是「表格真的列了它」。把錨點拿掉、改成抓全頁所有反引號裡的 `*.sh`,
|
||||
反向那半會吐出 **3 個假警報**(2026-08-27 實跑):
|
||||
|
||||
```
|
||||
claim-verify-police.sh ← 出現在「已於 ISEP#60 移除」那段**移除紀錄**裡
|
||||
subagent-claim-worksheet.sh ← 同上
|
||||
github-arm.sh ← 它在 `scripts/`,不在 `hooks/`;是 github-contact-guard
|
||||
那一列裡告訴你「怎麼解鎖」的逃生口
|
||||
```
|
||||
|
||||
**三個都不是幽靈,三個都不該刪**:刪掉前兩個等於刪掉「G 組為什麼是空的」這段歷史,
|
||||
刪掉第三個等於讓那一列不再告訴你怎麼過閘。
|
||||
⇒ **0 次正確攔截、3 次誤攔**——照這個訊號去改,會把好東西改壞。
|
||||
|
||||
那「頁面上出現了一個根本不存在的檔名」怎麼辦?**那要另外一格量,而且判準不是文字是事實**:
|
||||
|
||||
```
|
||||
# C. 頁面提到的每個 *.sh,現在存在,或者 git 歷史裡存在過
|
||||
$ for n in $(grep -oE '`[a-zA-Z0-9._-]+\.sh`' docs/hooks-inventory.md | tr -d '`' | sort -u); do
|
||||
[ -f "hooks/$n" ] || [ -f "scripts/$n" ] && continue
|
||||
[ -n "$(git log --all --oneline --diff-filter=AD -- "hooks/$n" "scripts/$n")" ] && continue
|
||||
echo "$n"
|
||||
done
|
||||
(無輸出)
|
||||
```
|
||||
|
||||
**「現在沒有、但曾經有」=歷史,合法;「從來沒有過」=打錯字或憑空捏造,才是幽靈。**
|
||||
這一格問的是 git,不是問這頁的用字,所以它不會因為誰換個寫法就失效。
|
||||
|
||||
📌 **三格都要跑,而且它們量的是不同東西**:
|
||||
A1 缺人話、A2 表格有幽靈、C 全頁有幽靈。
|
||||
**標頭的 48/59 對不代表這頁沒漏**——補進 2 支真的、同時留著 2 支假的,數字照樣湊得到 48。
|
||||
**數量相等不等於集合相等**,所以驗的一律是集合(`comm`),不是 `wc -l`。
|
||||
|
||||
**這次順便抓到的另一個落差**(🕐 **這一段是 2026-08-20 的快照,裡面每個數字都是那天的,不是現況**——
|
||||
留著是因為它是「兩份文件的數字不會自己跟上」這個病的第一個案例;現況數字看本頁標頭):
|
||||
`.claude-plugin/plugin.json` 的說明文字寫「42 支機械閘(52 條註冊)」,
|
||||
`README.md` 也寫「42 支 + `hooks.json`」「共 52 條註冊」——但實測是 **43 支檔案、53 條註冊**。
|
||||
差 1 支、差 1 條,猜測是今天(2026-08-20)新增的 `leo21c-write-guard.sh`/`release-tag-guard.sh`/
|
||||
`ticket-api-bypass-guard.sh` 這批(檔頭日期都是今天)加了之後,兩份文件的數字沒有跟著更新。
|
||||
@@ -232,12 +299,16 @@ $ comm -13 /tmp/fs_hooks.txt /tmp/registered_hooks.txt
|
||||
- **`micromanage-guard.sh`/`irreversible-dispatch-guard.sh`/`no-ticket-no-dispatch.sh` 的雙重矩比對(`Task` + `Agent`)是刻意保險還是舊名稱沒清掉**——見上面「重複掛載」段落,我沒有把握單靠讀檔案判斷,需要看實際觸發紀錄或問總管。
|
||||
- **`stage-before-prod-guard.sh` 跟 `main-and-prod-push-guard.sh` 的分工邊界**:`main-and-prod-push-guard.sh` 的檔頭明講自己是在「補 `stage-before-prod-guard.sh` 的破口」(那支只認 3 個關鍵字,抓不到 `wrangler deploy`),但兩支都還掛著、都還在管「prod 出貨」這件事。這是「新的補洞、舊的continua」還是「舊的該退休了」,這份盤點表不下判斷,留給你在下一步的分類會議裡定奪。
|
||||
|
||||
除了以上兩點,其餘 41 支的行為都能從檔頭與源碼直接讀出,沒有「猜」的部分。
|
||||
除了以上兩點,其餘各支的行為都能從檔頭與源碼直接讀出,沒有「猜」的部分。
|
||||
|
||||
> 📌 這句話原本寫「其餘 **41** 支」——那是 43 支閘那一版(`v0.2.1`)數的,之後沒有人重跑過。
|
||||
> 2026-08-27 把數字拿掉而不是改成 46:**改成 46 等於宣稱我逐支重讀過源碼,而我沒有。**
|
||||
> 要恢復一個數字,就重跑一次上面「抽驗」那件事,然後把當天的日期寫上去。
|
||||
|
||||
---
|
||||
|
||||
## 這份表怎麼跟實況對帳(半年後怎麼發現漂移)
|
||||
|
||||
1. **有沒有新閘沒被收進這張表**:跑本文「落差偵測」段落的兩行 `comm` 指令,比對 `hooks/*.sh` 的檔名清單跟這張表列出的閘名清單(不是跟 `hooks.json`,那個只驗證有沒有註冊,驗不了有沒有寫進這張人話表)。
|
||||
1. **有沒有新閘沒被收進這張表**:跑本文「落差偵測」段落**第二組**(`inv_fs` vs `inv_table` 的兩行 `comm`)**加第三格 C**,比對 `hooks/*.sh` 的檔名清單跟這張表列出的閘名清單。**不是第一組**——第一組比的是 `hooks.json`,只驗證有沒有註冊,驗不了有沒有寫進這張人話表。2026-08-27 漏掉的那兩列,第一組是綠的、第二組才紅。**抓表格列的那組 `grep` 一定要帶 `^\| \`` 錨點**,理由與拿掉之後會冒出的 3 個假警報,就寫在那一段裡。
|
||||
2. **有沒有閘的行為跟這裡寫的不一樣了**:抽幾支重新讀一次源碼,跟這裡的「一句話」對一遍——就是本文「抽驗 5 支」做的事,可以照同樣方法定期重做。
|
||||
3. **`.claude-plugin/plugin.json` 與 `README.md` 的數字**:這兩處各自寫了一次「幾支、幾條註冊」,前面已經抓到一次對不上(42/52 vs 實際 43/53)。這兩個數字沒有機制保證跟著 `hooks/` 目錄自動更新,是本表發現的第一個具體漂移案例。
|
||||
|
||||
@@ -233,6 +233,10 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/factory-idle-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/mainline-idle-guard.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Executable
+291
@@ -0,0 +1,291 @@
|
||||
#!/usr/bin/env bash
|
||||
# mainline-idle-guard.sh — 主線閒置警察(inkstone/ISEP#30 → comment 4851)
|
||||
#
|
||||
# ── 補的是哪一格 ────────────────────────────────────────────────
|
||||
# leo 2026-08-27:「**你為什麼會停工什麼都沒派?不是停下來會有 hook 嗎?**」
|
||||
#
|
||||
# 總管的行為 空手警察 稼動率警察
|
||||
# ─────────────────────────────────────────────────────
|
||||
# 沒動作、沒宣告 🛑 擋 —
|
||||
# 有動作、有宣告下一步 放行 🛑 擋
|
||||
# **有動作、不宣告** **放行** **放行** ← 這一格
|
||||
#
|
||||
# 第三列就是當天實際發生的:連續數個回合在查 git、讀票、跑測試、複驗 subagent 的
|
||||
# 宣稱——**十幾個 tool call,兩支閘都放行**,而主線一個 subagent 都沒派。
|
||||
# **只要不寫「下一步」三個字,兩支閘都抓不到。**
|
||||
#
|
||||
# ── 為什麼不是再加一組句型 ──────────────────────────────────────
|
||||
# 紅線就寫在票上:不准用文字層判準。leo 2026-08-17 已經證偽過那條路
|
||||
# (文字層的閘當日 8 次誤攔、0 次正確攔截,而且**紅線寫得越細越容易誤攔**)。
|
||||
# 本閘從頭到尾**不讀任何一個字**——它只看「這個回合做了什麼工具呼叫」與
|
||||
# 「工作區的狀態有沒有變」。措辭隨它變,這條路物理上不通。
|
||||
#
|
||||
# ── 判準:偵察 ≠ 產出 ──────────────────────────────────────────
|
||||
# 乾回合(dry turn)= ① 這回合 ≥3 個 tool call(**有在用力**)
|
||||
# + ② 沒有任何一種「推進證據」(見下)
|
||||
# 連續 4 個乾回合 ⇒ 擋一次。(4 是量出來的,見「門檻怎麼定的」)
|
||||
#
|
||||
# 🔴 兩個「不算乾回合」的設計,是為了不誤攔:
|
||||
# - **0–2 個 tool call 的回合完全不計數**。回答 leo 一個問題、順手 grep 兩下,
|
||||
# 那是對話,不是空轉;而「零動作」已經有空手警察在管。
|
||||
# - **推進證據採極寬認定**(下面任何一種都算)。寧可漏攔,不可誤攔——
|
||||
# 永遠在響的警報等於訓練人忽略警報,那比沒有警報更糟。
|
||||
#
|
||||
# 推進證據(任何一種成立 ⇒ 這回合有推進,計數歸零):
|
||||
# ① 派工/驅動別人:Agent/Task/SendMessage/spawn_task
|
||||
# ② 產出東西:Write/Edit/MultiEdit/NotebookEdit/Artifact
|
||||
# ③ 寫進外部系統:工具名含 create/push/update/send/reply/publish/deploy/submit…
|
||||
# ④ Bash 白名單:git commit/push/merge/tag、scripts/ticket、wrangler deploy…
|
||||
# ⑤ **工作區狀態變了**:HEAD 動了,或 `git status --porcelain` 的內容變了
|
||||
# ⇒ 這一條是關鍵:用 heredoc/sed/腳本改檔(本環境很常見)不經過 Edit 工具,
|
||||
# 光看工具名會漏;看**結果**就不會。
|
||||
#
|
||||
# 🔴 ③④ 是**只用來放行的白名單**,永遠不用來擋。
|
||||
# 白名單漏了一項=少放行一次(有 ⑤ 兜底),黑名單漏了一項=誤攔一次。
|
||||
# 方向不對稱,所以只准往放行的方向做字面比對。
|
||||
#
|
||||
# ── 為什麼是「連續」而不是「這一回合」 ──────────────────────────
|
||||
# 票上第 3 條驗收:「這回合就是在回答一個不需要派工的問題 ⇒ 放行」。
|
||||
# 單一回合沒有推進,本來就可能完全正當(在複驗、在讀票、在回答)。
|
||||
# **不正當的是那個狀態持續下去。** 所以量的是連續乾回合數,不是單回合。
|
||||
# ⇒ 這同時滿足另一條紅線:不會變成「每回合都必須派工」。
|
||||
#
|
||||
# ── 門檻怎麼定的:拿真 transcript 量,不是拍腦袋 ────────────────
|
||||
# 重放本機 6 份真 transcript(InkStoneCo 專案,**1965 個真實回合**),
|
||||
# 逐回合餵給這道閘,數它會響幾次:
|
||||
#
|
||||
# 起始門檻 3 → 4 次(92a75156 兩次、602ea4a1 一次、9e0144f1 一次)
|
||||
# 起始門檻 4 → **1 次**(92a75156 第 311 回合)
|
||||
#
|
||||
# 門檻 3 多出來的那三次,看下去都落在「leo 正在連問問題、我逐題查證回答」的段落
|
||||
# ——那正是票上第 3 條驗收明文保護的情境(**閘不能懲罰誠實回答問題**)。
|
||||
# 門檻 4 剩下的那一次,落在連續 10 個回合(308–317)只查不產出的那一段,
|
||||
# 那一段本來就同時被 delivery-police 與 wiki-first-police 連續攔了五次。
|
||||
# ⇒ **1/1965 = 0.05%**。取 4。
|
||||
#
|
||||
# ⚠️ 重放時一律把 `stop_hook_active` 當成 false(最壞情況)。真實環境裡那幾個
|
||||
# 回合有不少是被別的 Stop 閘擋出來的 ⇒ 實際會響得比這個數字更少。
|
||||
#
|
||||
# ── 響過就退讓(不當那個永遠在響的警報)────────────────────────
|
||||
# 擋一次之後:計數歸零,**門檻加倍**(3 → 6 → 12 → 24…)。
|
||||
# 真的在空轉會被早早抓到;真的是一段長時間的正當偵察,警報會自己越來越稀。
|
||||
# 另外 `stop_hook_active` 為真(已被別的 Stop 閘擋過)時完全不計數。
|
||||
# ⇒ 不可能把人鎖死:擋完就放行,再送一次即可。
|
||||
#
|
||||
# ── 內部錯誤不准靜默放行 ────────────────────────────────────────
|
||||
# Stop 閘 fail-closed 會把 session 鎖死,而誤攔是本 repo 的第一級缺陷
|
||||
# ⇒ 內部錯誤走 exit 0,但**留痕**:log 記 ⚠️、stderr 也印一行。
|
||||
# 「不准靜默」是靠留痕滿足的,不是靠擋下。
|
||||
#
|
||||
# 迴歸測試:hooks/tests/mainline-idle-guard.test.sh(含「不該擋」的案例)
|
||||
set -u
|
||||
|
||||
PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||||
PAYLOAD=$(cat 2>/dev/null || echo '{}')
|
||||
|
||||
# ⑤ 工作區狀態指紋:HEAD + 未提交變更的內容摘要。
|
||||
# 不是 git repo 就給空字串(這層證據不可用,其餘四層照跑)。
|
||||
GITSTATE=""
|
||||
if git -C "$PROJ" rev-parse --git-dir >/dev/null 2>&1; then
|
||||
GITSTATE="$(git -C "$PROJ" rev-parse HEAD 2>/dev/null || echo '-')|$(
|
||||
git -C "$PROJ" status --porcelain 2>/dev/null | shasum 2>/dev/null | cut -c1-16
|
||||
)"
|
||||
fi
|
||||
|
||||
VERDICT=$(printf '%s' "$PAYLOAD" | GITSTATE="$GITSTATE" python3 -c '
|
||||
import json, os, re, sys
|
||||
|
||||
def out(s):
|
||||
print(s); raise SystemExit
|
||||
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
out("SKIP:bad-payload")
|
||||
|
||||
# 已被別的 Stop 閘擋過這一輪 ⇒ 不計數、不擋(避免疊擋)
|
||||
if d.get("stop_hook_active"):
|
||||
out("SKIP:already-nudged")
|
||||
|
||||
tp = d.get("transcript_path") or ""
|
||||
if not tp or not os.path.exists(tp):
|
||||
out("SKIP:no-transcript")
|
||||
|
||||
sid = d.get("session_id") or "nosid"
|
||||
sid = re.sub(r"[^A-Za-z0-9_.-]", "_", sid)[:64]
|
||||
state_path = os.path.join(os.environ.get("MAINLINE_IDLE_STATE_DIR", "/tmp"),
|
||||
".mainline-idle-%s.json" % sid)
|
||||
|
||||
rows = []
|
||||
try:
|
||||
with open(tp) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line:
|
||||
try: rows.append(json.loads(line))
|
||||
except Exception: pass
|
||||
except Exception:
|
||||
out("SKIP:unreadable")
|
||||
|
||||
# 這個回合=最後一則「真的來自使用者」的訊息之後(工具結果不算)
|
||||
start = 0
|
||||
for i, r in enumerate(rows):
|
||||
if r.get("type") == "user":
|
||||
c = (r.get("message") or {}).get("content")
|
||||
blocks = c if isinstance(c, list) else [{"type": "text"}]
|
||||
if not any(isinstance(b, dict) and b.get("type") == "tool_result" for b in blocks):
|
||||
start = i
|
||||
turn = rows[start:]
|
||||
|
||||
names, bash_cmds = [], []
|
||||
for r in turn:
|
||||
if r.get("type") != "assistant":
|
||||
continue
|
||||
for b in (r.get("message") or {}).get("content") or []:
|
||||
if isinstance(b, dict) and b.get("type") == "tool_use":
|
||||
n = b.get("name") or ""
|
||||
names.append(n)
|
||||
if n == "Bash":
|
||||
cmd = (b.get("input") or {}).get("command") or ""
|
||||
if isinstance(cmd, str):
|
||||
bash_cmds.append(cmd)
|
||||
|
||||
tools = len(names)
|
||||
|
||||
# ── 推進證據(白名單:只用來放行)────────────────────────────
|
||||
DISPATCH = {"Agent", "Task", "SendMessage"}
|
||||
PRODUCE = {"Write", "Edit", "MultiEdit", "NotebookEdit", "Artifact"}
|
||||
# 寫進外部系統的 MCP/內建工具,用動詞尾綴認(漏了只是少放行一次)
|
||||
WRITE_VERB = re.compile(
|
||||
r"(create|push|update|send|reply|post|publish|deploy|submit|spawn_task|"
|
||||
r"write|delete|upload|assign|comment|tag_resource|run_workflow)", re.I)
|
||||
BASH_WRITE = re.compile(
|
||||
r"git\s+(commit|push|merge|tag|cherry-pick|revert|am|apply)"
|
||||
r"|scripts/ticket|(^|\s)ticket\s+(say|new|assign|label|close|move)"
|
||||
r"|wrangler\s+(deploy|publish|d1|kv|r2|secret)"
|
||||
r"|gh\s+(issue|pr|release)\s+(create|comment|edit|close)"
|
||||
r"|npm\s+publish|ship\.mjs|github-arm\.sh")
|
||||
|
||||
why = ""
|
||||
if any(n in DISPATCH for n in names):
|
||||
why = "dispatch"
|
||||
elif any(n in PRODUCE for n in names):
|
||||
why = "produce"
|
||||
elif any(WRITE_VERB.search(n) for n in names):
|
||||
why = "external-write"
|
||||
elif any(BASH_WRITE.search(c) for c in bash_cmds):
|
||||
why = "bash-write"
|
||||
|
||||
gitstate = os.environ.get("GITSTATE", "")
|
||||
|
||||
# ── 狀態檔 ────────────────────────────────────────────────
|
||||
st = {}
|
||||
try:
|
||||
with open(state_path) as f:
|
||||
st = json.load(f)
|
||||
except Exception:
|
||||
st = {}
|
||||
|
||||
# 起始門檻。預設 4 是拿真 transcript 量出來的(見檔頭「門檻怎麼定的」段),
|
||||
# 環境變數只是為了讓那個量測可以重跑,不是給人隨手調鬆的旋鈕。
|
||||
try:
|
||||
BASE = max(2, int(os.environ.get("MAINLINE_IDLE_THRESHOLD", "4")))
|
||||
except Exception:
|
||||
BASE = 4
|
||||
|
||||
streak = int(st.get("streak", 0) or 0)
|
||||
threshold = int(st.get("threshold", BASE) or BASE)
|
||||
last_rows = int(st.get("rows", -1) or -1)
|
||||
last_git = st.get("git", None)
|
||||
|
||||
def save(verdict):
|
||||
try:
|
||||
with open(state_path, "w") as f:
|
||||
json.dump({"streak": streak, "threshold": threshold,
|
||||
"rows": len(rows), "git": gitstate}, f)
|
||||
except Exception:
|
||||
pass
|
||||
out(verdict)
|
||||
|
||||
# 第一次見到這個 session:只立基準,不評分(沒有可比的前一格)
|
||||
if last_rows < 0:
|
||||
save("SKIP:baseline")
|
||||
|
||||
# 同一個回合被叫第二次(transcript 沒長)⇒ 不重複計數
|
||||
if len(rows) <= last_rows:
|
||||
save("SKIP:same-turn")
|
||||
|
||||
# ⑤ 工作區狀態變了 ⇒ 有產出(heredoc/sed/腳本改檔都吃得到)
|
||||
if not why and gitstate and last_git is not None and gitstate != last_git:
|
||||
why = "worktree-changed"
|
||||
|
||||
if why:
|
||||
streak = 0
|
||||
save("OK:%s:%d" % (why, tools))
|
||||
|
||||
# 沒有推進證據,但這回合幾乎沒用力(≤2 個動作)⇒ 那是對話,不計數
|
||||
if tools < 3:
|
||||
save("OK:light-turn:%d" % tools)
|
||||
|
||||
streak += 1
|
||||
if streak >= threshold:
|
||||
fired_at = streak
|
||||
fired_threshold = threshold
|
||||
streak = 0
|
||||
threshold = threshold * 2 # 響過就退讓:3 → 6 → 12 → 24…
|
||||
save("IDLE::%d::%d::%d" % (fired_at, fired_threshold, tools))
|
||||
|
||||
save("OK:dry-turn:%d/%d" % (streak, threshold))
|
||||
' 2>/dev/null) || VERDICT="CRASH"
|
||||
[ -n "${VERDICT:-}" ] || VERDICT="CRASH"
|
||||
|
||||
STAMP=$(date "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "?")
|
||||
LOGDIR="$PROJ/.claude/hooks"
|
||||
note() {
|
||||
if [ -d "$LOGDIR" ]; then
|
||||
printf '| %s | %s | %s |\n' "$STAMP" "$1" "$2" >> "$LOGDIR/mainline-idle-guard.log" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
case "$VERDICT" in
|
||||
IDLE::*)
|
||||
N=$(printf '%s' "$VERDICT" | cut -d: -f3)
|
||||
T=$(printf '%s' "$VERDICT" | cut -d: -f5)
|
||||
C=$(printf '%s' "$VERDICT" | cut -d: -f7)
|
||||
note "⛔ 擋下" "連續 ${N} 個乾回合(門檻 ${T}),本回合 ${C} 個動作、零推進"
|
||||
cat >&2 <<MSG
|
||||
🛤️ 主線閒置警察:**連續 ${N} 個回合有動作、沒推進。**(本回合 ${C} 個動作)
|
||||
|
||||
這一格是空手警察與稼動率警察中間的漏洞——
|
||||
「沒動作」有人管、「宣告了下一步卻沒派工」有人管,
|
||||
但**一直在查、一直在讀、什麼都沒交出去**,兩支都放行。
|
||||
🔴 只要不寫「下一步」三個字,就沒有任何閘抓得到。這支抓的就是它。
|
||||
|
||||
本閘**一個字都不讀**。它只認這五種「推進」,任何一種成立就歸零:
|
||||
① 派工/驅動別人 Agent/Task/SendMessage
|
||||
② 產出東西 Write/Edit/Artifact
|
||||
③ 寫進外部系統 建票/留言/推工作流/部署
|
||||
④ Bash 寫入 git commit/push、scripts/ticket、wrangler deploy
|
||||
⑤ 工作區變了 HEAD 動了或有新的未提交變更(heredoc/sed 改檔也算)
|
||||
|
||||
━━ 現在做一件就好,不必補一段說明 ━━━━━━━━━━━━━━━━━━━━━━
|
||||
• 查到的東西**寫回票**(\`scripts/ticket say <owner/repo#N> -F <檔>\`)
|
||||
——查證只有落回票上才是資產,留在 transcript 裡下一個 session 就沒了
|
||||
• 該別人做的**現在派出去**(帶【工單】一行票號)
|
||||
• 自己該落地的**現在落地**(改檔/commit)
|
||||
|
||||
━━ 如果這幾個回合的偵察本來就是正當的 ━━━━━━━━━━━━━━━━━━
|
||||
直接再送一次即可,本閘已經歸零,而且**門檻已加倍**(下次要 $((T*2)) 個乾回合才會再響)。
|
||||
🔴 不必為了過這道閘去做一件假的動作——**假派工比不派更糟。**
|
||||
MSG
|
||||
exit 2 ;;
|
||||
CRASH|SKIP:bad-payload)
|
||||
# fail-closed 的精神在「不准靜默」:Stop 閘擋下內部錯誤會鎖死 session,
|
||||
# 所以放行,但一定留痕(log + stderr),讓「這道閘壞了」查得到。
|
||||
note "⚠️ 內部錯誤" "$VERDICT"
|
||||
printf '⚠️ mainline-idle-guard 內部錯誤(%s)——本回合未評分,已記入 log。\n' "$VERDICT" >&2
|
||||
exit 0 ;;
|
||||
*)
|
||||
note "✅ 放行" "$VERDICT"
|
||||
exit 0 ;;
|
||||
esac
|
||||
Executable
+187
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env bash
|
||||
# mainline-idle-guard.sh 的迴歸測試(inkstone/ISEP#30 → comment 4851)
|
||||
#
|
||||
# 兩個方向都要有證據:
|
||||
# A 群「不該擋」——回答問題、輕量回合、任何一種推進證據、未達門檻、響過之後退讓
|
||||
# B 群「該擋」 ——連續乾回合(有動作、零推進)踩到門檻
|
||||
# C 群「訊息承諾的出路真的走得通」——擋完歸零、門檻加倍
|
||||
#
|
||||
# 用法:hooks/tests/mainline-idle-guard.test.sh [hooks/mainline-idle-guard.sh 的路徑]
|
||||
# 🔴 全程在乾淨的 TMP 底下造假 transcript 與假狀態檔,跑完自己清;
|
||||
# 不碰任何真 repo、不打網路、不寫 /tmp 的正式狀態檔(走 MAINLINE_IDLE_STATE_DIR)。
|
||||
set -u
|
||||
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/mainline-idle-guard.sh}"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
PASS=0; FAIL=0; N=0
|
||||
|
||||
# append_turn <transcript> <block spec>...
|
||||
# block spec:`T:<文字>` = assistant 文字段;`U:<工具名>` = tool_use;
|
||||
# `B:<指令>` = Bash tool_use(帶 command)
|
||||
append_turn() {
|
||||
python3 - "$@" <<'PY'
|
||||
import json, sys
|
||||
path, specs = sys.argv[1], sys.argv[2:]
|
||||
blocks = []
|
||||
for s in specs:
|
||||
k, v = s.split(":", 1)
|
||||
if k == "T":
|
||||
blocks.append({"type": "text", "text": v})
|
||||
elif k == "B":
|
||||
blocks.append({"type": "tool_use", "name": "Bash", "id": "t", "input": {"command": v}})
|
||||
else:
|
||||
blocks.append({"type": "tool_use", "name": v, "id": "t", "input": {}})
|
||||
with open(path, "a") as f:
|
||||
f.write(json.dumps({"type": "user",
|
||||
"message": {"content": [{"type": "text", "text": "繼續"}]}},
|
||||
ensure_ascii=False) + "\n")
|
||||
f.write(json.dumps({"type": "assistant", "message": {"content": blocks}},
|
||||
ensure_ascii=False) + "\n")
|
||||
PY
|
||||
}
|
||||
|
||||
# fire <session> <transcript> <期望 exit> <說明> [extra-json]
|
||||
fire() {
|
||||
sid="$1"; tr="$2"; want="$3"; desc="$4"; extra="${5:-}"
|
||||
N=$((N+1))
|
||||
payload=$(printf '{"session_id":"%s","transcript_path":"%s"%s}' "$sid" "$tr" "$extra")
|
||||
out=$(printf '%s' "$payload" \
|
||||
| CLAUDE_PROJECT_DIR="${PROJ_OVERRIDE:-$TMP}" \
|
||||
MAINLINE_IDLE_STATE_DIR="$TMP" bash "$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
|
||||
}
|
||||
|
||||
# 造一個新 session 的 transcript,並吃掉第一次的 baseline
|
||||
new_session() {
|
||||
sid="$1"; tr="$TMP/$sid.jsonl"; : > "$tr"
|
||||
append_turn "$tr" 'U:Read'
|
||||
printf '{"session_id":"%s","transcript_path":"%s"}' "$sid" "$tr" \
|
||||
| CLAUDE_PROJECT_DIR="${PROJ_OVERRIDE:-$TMP}" MAINLINE_IDLE_STATE_DIR="$TMP" \
|
||||
bash "$HOOK" >/dev/null 2>&1
|
||||
echo "$tr"
|
||||
}
|
||||
|
||||
DRY=(U:Read U:Grep B:git\ status U:Read) # 4 個動作、零推進 = 乾回合
|
||||
|
||||
echo "── A 群:不該擋 ────────────────────────────────────────────"
|
||||
|
||||
tr=$(new_session s-light)
|
||||
fire s-light "$tr" 0 "① 第一次見到這個 session(立基準,不評分)"
|
||||
|
||||
# 票上第 3 條驗收:回答一個不需要派工的問題 ⇒ 放行(且**不計數**)
|
||||
for i in 1 2 3 4 5; do
|
||||
append_turn "$tr" 'U:Read' 'U:Grep' "T:查了一下,答案是 X。"
|
||||
fire s-light "$tr" 0 "② 回答問題的輕量回合(2 個動作)第 $i 次——連 5 次都不該擋"
|
||||
done
|
||||
|
||||
tr=$(new_session s-dispatch)
|
||||
for i in 1 2 3 4; do
|
||||
append_turn "$tr" "${DRY[@]}" 'U:Agent'
|
||||
fire s-dispatch "$tr" 0 "③ 有派工(Agent)第 $i 次 ⇒ 永遠歸零"
|
||||
done
|
||||
|
||||
tr=$(new_session s-send)
|
||||
for i in 1 2 3 4; do
|
||||
append_turn "$tr" "${DRY[@]}" 'U:SendMessage'
|
||||
fire s-send "$tr" 0 "④ 驅動既有 agent(SendMessage)第 $i 次"
|
||||
done
|
||||
|
||||
tr=$(new_session s-write)
|
||||
for i in 1 2 3 4; do
|
||||
append_turn "$tr" "${DRY[@]}" 'U:Edit'
|
||||
fire s-write "$tr" 0 "⑤ 有產出(Edit)第 $i 次"
|
||||
done
|
||||
|
||||
tr=$(new_session s-ticket)
|
||||
for i in 1 2 3 4; do
|
||||
append_turn "$tr" 'U:Read' 'U:Grep' 'B:scripts/ticket say inkstone/ISEP#30 -F /tmp/x.md'
|
||||
fire s-ticket "$tr" 0 "⑥ 寫回票(scripts/ticket say)第 $i 次"
|
||||
done
|
||||
|
||||
tr=$(new_session s-mcp)
|
||||
for i in 1 2 3 4; do
|
||||
append_turn "$tr" "${DRY[@]}" 'U:mcp__x__kbdb_create_record'
|
||||
fire s-mcp "$tr" 0 "⑦ 寫進外部系統(kbdb_create_record)第 $i 次"
|
||||
done
|
||||
|
||||
tr=$(new_session s-under)
|
||||
for i in 1 2 3; do
|
||||
append_turn "$tr" "${DRY[@]}"; fire s-under "$tr" 0 "⑧ 第 $i 個乾回合——未達門檻(4),不擋"
|
||||
done
|
||||
|
||||
tr=$(new_session s-active)
|
||||
append_turn "$tr" "${DRY[@]}"
|
||||
fire s-active "$tr" 0 "⑩ stop_hook_active(已被別的 Stop 閘擋過)⇒ 不計數" ',"stop_hook_active":true'
|
||||
append_turn "$tr" "${DRY[@]}"
|
||||
fire s-active "$tr" 0 "⑪ 同上,第 2 次仍不擋(不會被別的閘的擋下推著累積)" ',"stop_hook_active":true'
|
||||
|
||||
tr=$(new_session s-dup)
|
||||
append_turn "$tr" "${DRY[@]}"
|
||||
fire s-dup "$tr" 0 "⑫ 同一個回合被叫兩次(transcript 沒長)——第 1 次"
|
||||
fire s-dup "$tr" 0 "⑬ 同上第 2 次:不重複計數"
|
||||
fire s-dup "$tr" 0 "⑭ 同上第 3 次:仍不擋(重複呼叫不該累積成擋下)"
|
||||
|
||||
fire s-bad "$TMP/does-not-exist.jsonl" 0 "⑮ 讀不到 transcript ⇒ 放行(不亂擋)"
|
||||
|
||||
echo "── B 群:該擋 ──────────────────────────────────────────────"
|
||||
|
||||
tr=$(new_session s-idle)
|
||||
for i in 1 2 3; do
|
||||
append_turn "$tr" "${DRY[@]}"; fire s-idle "$tr" 0 "⑯ 乾回合 $i/4"
|
||||
done
|
||||
append_turn "$tr" "${DRY[@]}"; fire s-idle "$tr" 2 "⑰ 乾回合 4/4 ⇒ **擋下**"
|
||||
|
||||
tr=$(new_session s-idle2)
|
||||
for i in 1 2 3; do
|
||||
append_turn "$tr" 'U:Read' 'U:Read' 'U:Read'; fire s-idle2 "$tr" 0 "⑱ 純唯讀乾回合 $i/4"
|
||||
done
|
||||
append_turn "$tr" 'U:Bash' 'U:Bash' 'U:Read'; fire s-idle2 "$tr" 2 "⑲ 唯讀 Bash 也算乾回合 ⇒ **擋下**"
|
||||
|
||||
echo "── C 群:響過就退讓(訊息裡承諾的出路真的存在)───────────────"
|
||||
|
||||
# 接續 s-idle:它剛在 ⑰ 被擋過 ⇒ 計數歸零、門檻 4→8
|
||||
tr="$TMP/s-idle.jsonl"
|
||||
for i in 1 2 3 4 5 6 7; do
|
||||
append_turn "$tr" "${DRY[@]}"
|
||||
fire s-idle "$tr" 0 "⑳ 擋過之後第 $i 個乾回合——門檻已加倍(4→8),不該再響"
|
||||
done
|
||||
append_turn "$tr" "${DRY[@]}"
|
||||
fire s-idle "$tr" 2 "㉑ 第 8 個乾回合 ⇒ 再擋一次(門檻 8)"
|
||||
|
||||
# 量測用的旋鈕要真的有效(檔頭那份實測靠它重跑)
|
||||
export MAINLINE_IDLE_THRESHOLD=2
|
||||
tr=$(new_session s-th)
|
||||
append_turn "$tr" "${DRY[@]}"; fire s-th "$tr" 0 "㉒ MAINLINE_IDLE_THRESHOLD=2:第 1 個乾回合"
|
||||
append_turn "$tr" "${DRY[@]}"; fire s-th "$tr" 2 "㉓ 同上第 2 個 ⇒ 擋(證明門檻可被量測腳本改)"
|
||||
unset MAINLINE_IDLE_THRESHOLD
|
||||
|
||||
echo "── D 群:工作區狀態變了就算推進(heredoc/sed 改檔吃得到)──────"
|
||||
|
||||
REPO="$TMP/repo"; mkdir -p "$REPO"
|
||||
git -C "$REPO" init -q 2>/dev/null
|
||||
git -C "$REPO" config user.email t@t; git -C "$REPO" config user.name t
|
||||
echo a > "$REPO/a.txt"; git -C "$REPO" add -A; git -C "$REPO" commit -qm init
|
||||
PROJ_OVERRIDE="$REPO"
|
||||
tr=$(new_session s-git)
|
||||
for i in 1 2 3; do
|
||||
append_turn "$tr" "${DRY[@]}"; fire s-git "$tr" 0 "㉔ 乾回合 $i/4(工作區沒動)"
|
||||
done
|
||||
# 用 heredoc/sed 改檔——工具名是 Bash、指令不在白名單,只有狀態指紋看得出來
|
||||
echo "changed by heredoc" >> "$REPO/a.txt"
|
||||
append_turn "$tr" "${DRY[@]}"; fire s-git "$tr" 0 "㉕ 第 4 個乾回合,但工作區變了 ⇒ 算推進,不擋"
|
||||
for i in 1 2 3; do
|
||||
append_turn "$tr" "${DRY[@]}"; fire s-git "$tr" 0 "㉖ 之後乾回合 $i/4(證明上一格真的歸零過)"
|
||||
done
|
||||
append_turn "$tr" "${DRY[@]}"; fire s-git "$tr" 2 "㉗ 乾回合 4/4 ⇒ **擋下**"
|
||||
PROJ_OVERRIDE=""
|
||||
|
||||
echo
|
||||
printf '結果:%s 通過 / %s 失敗(共 %s 條)\n' "$PASS" "$FAIL" "$N"
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
Reference in New Issue
Block a user