5ac06abc95
三張票同一族(誰在派、派給誰、派的內容住哪裡),做在同一條分支: #86 工人名單:agents/ 七位有名字的工人+scripts/roster+hooks/roster-guard.sh 派工用 Task 的 subagent_type 指名,派工單格式一個字都沒改; 指對名字就把那位的檔案原文注入(你是誰/先讀什麼/你的紅線)。 【身份】欄同時吃得下工人名字(原本三個角色照舊)。 #87 未經調查不寫診斷:hooks/diagnosis-evidence-guard.sh + investigate-first-stamp.sh 三個結構訊號(派過人查沒/有沒有走得過去的出處/有沒有份量), 一個關鍵字比對都沒有;轉述有出處不會被誤擋。 #88 回覆也是派工:不另造閘,把攔截點加掛上去。 hooks/lib/dispatch_parse.py 的 tool_channel() 一次列全所有通往 subagent 的路 (SendMessage/雲端 session・trigger/claude -p);擋下來時把那段內容原文印出來。 subagent 往上回報(to: "main")=交件不是派工,刻意不管。 順手修掉一個真的會咬人的 flake:dispatch-format-guard 原本開四支 python 各讀一個欄位, 機器忙的時候某個欄位會靜靜變空字串(實測連跑 10 次有 1 次「豁免了卻還是被擋」)。 四個欄位改成一次讀完。 版本號待總管定(plugin.json 只更新了描述裡的數字,版本沒動)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZBu4Sa1cGntKFRBYNZ6xs
326 lines
16 KiB
Bash
Executable File
326 lines
16 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# dispatch-format-guard.sh 的迴歸測試(inkstone/ISEP#30 comment 4322/4325/4327)。
|
||
#
|
||
# **離線、不打網路、不花錢、每次結果一樣**——這支閘是純結構判斷,沒有語意判官,
|
||
# 所以它不像 ask-user-question-guard 那樣需要一支 live 測試量準度。
|
||
#
|
||
# 三群,兩個方向都要有證據:
|
||
# A 群「該放行」——合規的派工(只有票號)、不是派工的動作、沒有【工單】(別人的地盤)、
|
||
# 豁免戳記;**而且合規時要真的把共通規定注入出去**
|
||
# B 群「該擋」 ——真跡一份(那 5 次違規裡唯一還拿得回來的),
|
||
# 加上一條規則一個最小案例
|
||
# C 群「訊息本身」——講得出出路、沒有被 shell 展開
|
||
#
|
||
# 用法:hooks/tests/dispatch-format-guard.test.sh [hook 路徑]
|
||
|
||
set -u
|
||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
HOOK="${1:-$(cd "$HERE/.." && pwd)/dispatch-format-guard.sh}"
|
||
FIX="$HERE/fixtures"
|
||
|
||
PASS=0; FAIL=0; N=0
|
||
|
||
# payload <prompt 檔或字串> [tool_name]
|
||
payload() {
|
||
python3 - "$1" "${2:-Task}" <<'PY'
|
||
import json, os, sys
|
||
src, tool = sys.argv[1], sys.argv[2]
|
||
prompt = open(src, encoding="utf-8").read() if os.path.isfile(src) else src
|
||
print(json.dumps({"session_id": "S-TEST", "hook_event_name": "PreToolUse",
|
||
"tool_name": tool, "tool_input": {"prompt": prompt}},
|
||
ensure_ascii=False))
|
||
PY
|
||
}
|
||
|
||
# reply_payload <tool_name> <裝話的欄位名> <內容> [額外欄位 JSON]
|
||
# ISEP#88:通往 subagent 的路不只 Task/Agent 一條,每一條裝話的欄位還不一樣。
|
||
reply_payload() {
|
||
python3 - "$1" "$2" "$3" "${4:-{\}}" <<'RP'
|
||
import json, sys
|
||
tool, key, text, extra = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
|
||
ti = {key: text}
|
||
ti.update(json.loads(extra))
|
||
print(json.dumps({"session_id": "S-TEST", "hook_event_name": "PreToolUse",
|
||
"tool_name": tool, "tool_input": ti}, ensure_ascii=False))
|
||
RP
|
||
}
|
||
|
||
# t <期望 exit> <說明> <payload JSON>
|
||
t() {
|
||
want="$1"; desc="$2"; body="$3"
|
||
N=$((N+1))
|
||
err=$(printf '%s' "$body" | bash "$HOOK" 2>&1 >/dev/null); 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' "$err" | sed -n '1,6p' | sed 's/^/ /'
|
||
FAIL=$((FAIL+1))
|
||
fi
|
||
}
|
||
|
||
clean() { rm -f /tmp/.dispatch-ok-S-TEST 2>/dev/null || true; }
|
||
|
||
echo "── A 群:該放行 ────────────────────────────────────────────────"
|
||
clean
|
||
|
||
t 0 "① 合規:整份派工單只有一行票號" \
|
||
"$(payload '【工單】inkstone/ISEP#30 → comment 4322')"
|
||
|
||
t 0 "② 合規:兩張票就兩行【工單】" \
|
||
"$(payload '【工單】inkstone/ISEP#30
|
||
【工單】inkstone/InkStoneCo#55')"
|
||
|
||
t 0 "③ 合規:用 #issuecomment- 定址也認得" \
|
||
"$(payload '【工單】inkstone/ISEP#30#issuecomment-4327')"
|
||
|
||
# ── inkstone/ISEP#65:CLAUDE.md 規定的合格格式(全形/半形括號包住 → comment M)
|
||
# 曾經被本閘自己擋下——這裡把它釘成迴歸測試,不再走回頭路。
|
||
t 0 "③b 合規:全形括號包住 → comment M(CLAUDE.md 標準寫法,曾被自己的閘擋下)" \
|
||
"$(payload '【工單】inkstone/ISEP#65(→ comment 4515)')"
|
||
|
||
t 0 "③c 合規:半形括號變體" \
|
||
"$(payload '【工單】inkstone/ISEP#65(→ comment 4515)')"
|
||
|
||
# ── inkstone/ISEP#65 test 4:不該擋的三種雜訊 ────────────────────────
|
||
t 0 "③d 不該擋:票號後面只有空行" \
|
||
"$(payload '【工單】inkstone/ISEP#65
|
||
|
||
')"
|
||
|
||
t 0 "③e 不該擋:票號後面只有一句「謝謝」(零資訊禮貌收尾,白名單豁免)" \
|
||
"$(payload '【工單】inkstone/ISEP#65
|
||
謝謝')"
|
||
|
||
t 0 "③f 不該擋:禮貌收尾帶標點也認得(「謝謝!」)" \
|
||
"$(payload '【工單】inkstone/ISEP#65
|
||
謝謝!')"
|
||
|
||
t 2 "③g 白名單不是漏洞:禮貌詞混在真內容裡照樣算數" \
|
||
"$(payload '【工單】inkstone/ISEP#65
|
||
辛苦了,順便告訴你 main 現在是 8e28041')"
|
||
|
||
t 0 "④ 不是派工的動作(Bash)→ 一律不管" \
|
||
'{"tool_name":"Bash","tool_input":{"command":"ls"}}'
|
||
|
||
t 0 "⑤ 整包不是合法 JSON → fail-open" 'this is not json at all'
|
||
|
||
t 0 "⑥ 沒有【工單】→ 閉嘴,那是 no-ticket-no-dispatch 的地盤(不准兩支閘同時開口)" \
|
||
"$(payload '去把 arcrun 那個 worker 修一修,順便跑一下測試')"
|
||
|
||
# 注入:合規時要真的把共通規定送出去(leo 的驗收條件之一——
|
||
# 「收工方**沒讀派工單**也知道要貼回原票」)
|
||
N=$((N+1))
|
||
inj=$(printf '%s' "$(payload '【工單】inkstone/ISEP#30')" | bash "$HOOK" 2>/dev/null)
|
||
if printf '%s' "$inj" | python3 -c '
|
||
import sys, json
|
||
d = json.load(sys.stdin)
|
||
c = d["hookSpecificOutput"]["additionalContext"]
|
||
assert "貼回那張票" in c, "沒有交件方式"
|
||
assert "【身份】" in c, "沒有身份欄規定"
|
||
assert "不准 push" in c, "沒有 main 紅線"
|
||
' 2>/dev/null; then
|
||
printf ' ✅ ⑦ 合規的派工會被注入共通規定(交件方式+身份欄+不准 push main)\n'; PASS=$((PASS+1))
|
||
else
|
||
printf ' ❌ ⑦ 共通規定沒有被注入,或缺了其中一項\n'
|
||
printf '%s\n' "$inj" | sed -n '1,4p' | sed 's/^/ /'
|
||
FAIL=$((FAIL+1))
|
||
fi
|
||
|
||
clean
|
||
touch /tmp/.dispatch-ok-S-TEST
|
||
t 0 "⑧ 明示豁免戳記在 → 放行一次" \
|
||
"$(payload '【工單】inkstone/ISEP#30
|
||
這一次真的有例外')"
|
||
N=$((N+1))
|
||
if [ -f /tmp/.dispatch-ok-S-TEST ]; then
|
||
printf ' ❌ ⑧b 豁免戳記用完沒被消掉(會變成永久開關)\n'; FAIL=$((FAIL+1))
|
||
else
|
||
printf ' ✅ ⑧b 豁免戳記用完就消失,不是永久開關\n'; PASS=$((PASS+1))
|
||
fi
|
||
clean
|
||
|
||
echo
|
||
echo "── B 群:該擋 ──────────────────────────────────────────────────"
|
||
|
||
t 2 "⑨ **真跡**:產生 ISEP#30 這條線的那一次派工(散文開場+指令圍欄+三點就地+交件)" \
|
||
"$(payload "$FIX/real-violation-isep30.txt")"
|
||
|
||
t 2 "⑩ 只多一行散文——沒有「一句而已」這種豁免" \
|
||
"$(payload '【工單】inkstone/ISEP#30
|
||
記得先讀 CLAUDE.md')"
|
||
|
||
t 2 "⑪ 帶【交件】——那是「每次都一樣」,該進共通規定" \
|
||
"$(payload '【工單】inkstone/ISEP#30
|
||
【交件】貼回 inkstone/ISEP#30,回報分支名')"
|
||
|
||
t 2 "⑫ 帶【就地】——那是「這次才知道」,該寫進票" \
|
||
"$(payload '【工單】inkstone/ISEP#30
|
||
【就地】main 現在是 8e7e265')"
|
||
|
||
t 2 "⑬ 帶【人格】——已收回的欄位" \
|
||
"$(payload '【工單】inkstone/ISEP#30
|
||
【人格】ISEP plugin 維護者')"
|
||
|
||
t 2 "⑭ 票號形狀不對(裸號,跨 repo 會撞號)" \
|
||
"$(payload '【工單】#30')"
|
||
|
||
t 2 "⑮ 內容躲在圍欄裡也算數(圍欄只讓裡面的【】不被當欄位,不讓內容變成不存在)" \
|
||
"$(payload '【工單】inkstone/ISEP#30
|
||
```
|
||
【就地】main 現在是 8e7e265
|
||
```')"
|
||
|
||
# ── inkstone/ISEP#65 test 5:拿「寫完規則之後總管又犯的 8 次」當回歸樣本 ──
|
||
# 內容是從各自票的真實內文摘錄(hooks/tests/fixtures/README.md 記著來歷),
|
||
# 不是想像出來的例子。8 種形狀都要被擋,證明這不是只補了一個特例。
|
||
t 2 "⑮b 回歸:ISEP#60 那次的內容(自造第四套機制的診斷)" \
|
||
"$(payload "$FIX/real-content-isep60.txt")"
|
||
|
||
t 2 "⑮c 回歸:ISEP#61 那次的內容(push-guard 戳記機制失敗史)" \
|
||
"$(payload "$FIX/real-content-isep61.txt")"
|
||
|
||
t 2 "⑮d 回歸:ISEP#64 那次的內容(PR 分診現況數字)" \
|
||
"$(payload "$FIX/real-content-isep64.txt")"
|
||
|
||
t 2 "⑮e 回歸:Arcrun#142 那次的內容(庫索引從沒被產生過)" \
|
||
"$(payload "$FIX/real-content-arcrun142.txt")"
|
||
|
||
t 2 "⑮f 回歸:Arcrun#144 那次的內容(樹狀圖驗收記錄)" \
|
||
"$(payload "$FIX/real-content-arcrun144.txt")"
|
||
|
||
t 2 "⑮g 回歸:Arcrun#165 那次的內容(MCP 徹查現場)" \
|
||
"$(payload "$FIX/real-content-arcrun165.txt")"
|
||
|
||
t 2 "⑮h 回歸:arcrun-rag#104 那次的內容(收檔實測數字)" \
|
||
"$(payload "$FIX/real-content-arcrunrag104.txt")"
|
||
|
||
t 2 "⑮i 回歸:InkStoneCo#102 那次的內容(milestone 打假背景)" \
|
||
"$(payload "$FIX/real-content-inkstoneco102.txt")"
|
||
|
||
echo
|
||
echo "── C 群:訊息本身 ──────────────────────────────────────────────"
|
||
clean
|
||
msg=$(printf '%s' "$(payload "$FIX/real-violation-isep30.txt")" | bash "$HOOK" 2>&1 >/dev/null)
|
||
|
||
N=$((N+1))
|
||
if printf '%s' "$msg" | grep -q '這句話換一張票還成立嗎' \
|
||
&& printf '%s' "$msg" | grep -q '共通規定' \
|
||
&& printf '%s' "$msg" | grep -q '寫進那張票'; then
|
||
printf ' ✅ ⑯ 訊息講得出判準與兩條出路(共通規定/寫進票)\n'; PASS=$((PASS+1))
|
||
else
|
||
printf ' ❌ ⑯ 訊息缺了判準或其中一條出路\n'
|
||
printf '%s\n' "$msg" | sed -n '1,10p' | sed 's/^/ /'
|
||
FAIL=$((FAIL+1))
|
||
fi
|
||
|
||
# 迴歸:訊息裡的反引號一度在別支閘被 shell 當成命令替換執行掉
|
||
# (2026-08-26 ask-user-question-guard:閘照擋,但**它教人怎麼解的那兩行變成空白**)
|
||
N=$((N+1))
|
||
if printf '%s' "$msg" | grep -q 'scripts/ticket say' \
|
||
&& printf '%s' "$msg" | grep -q 'touch /tmp/.dispatch-ok-S-TEST' \
|
||
&& ! printf '%s' "$msg" | grep -qi 'is a directory\|command not found'; then
|
||
printf ' ✅ ⑰ 訊息原文照印:反引號沒被當命令執行,出路那兩行沒有變空白\n'; PASS=$((PASS+1))
|
||
else
|
||
printf ' ❌ ⑰ 訊息被 shell 展開了(指令消失,或冒出 shell 錯誤)\n'
|
||
printf '%s\n' "$msg" | sed -n '1,20p' | sed 's/^/ /'
|
||
FAIL=$((FAIL+1))
|
||
fi
|
||
|
||
# 訊息要點名是哪一格違規(可迭代的最小單位——leo 4325:「照這種散文寫法根本無法迭代」)
|
||
N=$((N+1))
|
||
if printf '%s' "$msg" | grep -q '派工單不只有票號'; then
|
||
printf ' ✅ ⑱ 訊息點得出違反的是哪一條規則,不是一句籠統的「格式不對」\n'; PASS=$((PASS+1))
|
||
else
|
||
printf ' ❌ ⑱ 訊息沒有點名違反哪一條\n'; FAIL=$((FAIL+1))
|
||
fi
|
||
|
||
echo
|
||
echo "── D 群:回覆也是派工(inkstone/ISEP#88)────────────────────────"
|
||
clean
|
||
|
||
# 驗收 1:回覆一個正在跑的 subagent,訊息裡夾一段修改要求 ⇒ 擋
|
||
t 2 "⑲ **驗收 1**:SendMessage 夾了一段修改要求 ⇒ 擋" \
|
||
"$(reply_payload SendMessage message '順便把 hooks.json 也改一下,改完重跑一次測試貼給我' '{"to":"isep-hand"}')"
|
||
|
||
# 驗收 2:內容貼上票之後,回覆只給票號 ⇒ 放行
|
||
t 0 "⑳ **驗收 2**:內容貼上票之後,回覆只有票號 ⇒ 放行" \
|
||
"$(reply_payload SendMessage message '【工單】inkstone/ISEP#88 → comment 5001' '{"to":"isep-hand"}')"
|
||
|
||
# 驗收 1 的後半:擋的時候要把那段內容原文印出來(不然人得回頭自己找)
|
||
N=$((N+1))
|
||
rmsg=$(printf '%s' "$(reply_payload SendMessage message '去 main 分支重驗那三支 hook,把輸出貼回來' '{"to":"scout"}')" \
|
||
| bash "$HOOK" 2>&1 >/dev/null)
|
||
if printf '%s' "$rmsg" | grep -q '直接貼上票' \
|
||
&& printf '%s' "$rmsg" | grep -q '去 main 分支重驗那三支 hook'; then
|
||
printf ' ✅ ㉑ 擋下來時把那段內容**原文**印出來,可以直接貼上票\n'; PASS=$((PASS+1))
|
||
else
|
||
printf ' ❌ ㉑ 沒有把原文印出來(出路要求「寫進票」,找不到原文就得回頭自己翻)\n'
|
||
printf '%s\n' "$rmsg" | sed -n '1,10p' | sed 's/^/ /'
|
||
FAIL=$((FAIL+1))
|
||
fi
|
||
|
||
N=$((N+1))
|
||
if printf '%s' "$rmsg" | grep -q '回覆也是派工'; then
|
||
printf ' ✅ ㉒ 訊息說得出這是「回覆」那條路,不是照抄派工單那段\n'; PASS=$((PASS+1))
|
||
else
|
||
printf ' ❌ ㉒ 回覆被擋時給的是派工單的說法,人會找不到自己違反了什麼\n'; FAIL=$((FAIL+1))
|
||
fi
|
||
|
||
# 真跡重演(驗收 4):那兩則回覆的原文只活在總管的對話裡,拿不回來;
|
||
# 這裡改用**同一天真的寫進派工單的那幾段內容**(fixtures/README.md 記著來歷),
|
||
# 從**回覆這條路**再送一次——形狀一樣:一大段這次才知道的事實,票上一個字都沒有。
|
||
for fx in real-content-isep60 real-content-arcrun142 real-content-arcrunrag104; do
|
||
t 2 "㉓ 真跡($fx)從回覆這條路送出去 ⇒ 一樣擋" \
|
||
"$(reply_payload SendMessage message "$(cat "$FIX/$fx.txt")" '{"to":"isep-hand"}')"
|
||
done
|
||
|
||
echo "── E 群:通往 subagent 的路要列全(一條都不能漏)──────────────"
|
||
|
||
t 2 "㉔ 雲端開新 session(create_session 的 prompt)" \
|
||
"$(reply_payload mcp__Claude_Code_Remote__create_session prompt '去把那個 worker 修好,順便升版')"
|
||
t 2 "㉕ 送訊息進某個 session(send_message 的 text)" \
|
||
"$(reply_payload mcp__Claude_Code_Remote__send_message text '改一下剛剛那段,然後重跑測試')"
|
||
t 2 "㉖ 排程派工(create_trigger 的 prompt)" \
|
||
"$(reply_payload mcp__Claude_Code_Remote__create_trigger prompt '每天早上去檢查 PR 有沒有結論,有的話處理掉')"
|
||
t 2 "㉗ 當場點燃排程派工(fire_trigger 的 text)" \
|
||
"$(reply_payload mcp__Claude_Code_Remote__fire_trigger text '這次順便把 bundle 也重打一次')"
|
||
t 2 "㉘ 排一則訊息給自己(send_later 的 message)" \
|
||
"$(reply_payload mcp__Claude_Code_Remote__send_later message '記得回頭把那三支 hook 的測試補齊')"
|
||
t 2 "㉙ 用 CLI 開一個新 session(Bash 的 claude -p)" \
|
||
'{"session_id":"S-TEST","tool_name":"Bash","tool_input":{"command":"claude -p '"'"'去把 arcrun 那個 worker 修一修'"'"'"}}'
|
||
|
||
echo "── F 群:這幾條**不該**被這支閘碰(誤攔比漏擋嚴重)─────────────"
|
||
|
||
t 0 "㉚ subagent 往上回報(SendMessage to: main)=交件不是派工 ⇒ 不管" \
|
||
"$(reply_payload SendMessage message '做完了,分支是 feat/x,測試 26/26' '{"to":"main"}')"
|
||
t 0 "㉛ 一般的 Bash 指令裡剛好有【工單】字樣(判準是指令名不是內容)⇒ 不管" \
|
||
'{"session_id":"S-TEST","tool_name":"Bash","tool_input":{"command":"echo 【工單】inkstone/ISEP#88 順便改一下那個檔 > /tmp/x.md"}}'
|
||
t 0 "㉜ claude 但沒有 -p(開互動 session,不是一次性派工)⇒ 不管" \
|
||
'{"session_id":"S-TEST","tool_name":"Bash","tool_input":{"command":"claude --version"}}'
|
||
t 0 "㉝ 回覆是空的(這條路這次沒帶任何話)⇒ 不管" \
|
||
"$(reply_payload SendMessage message '' '{"to":"isep-hand"}')"
|
||
|
||
N=$((N+1))
|
||
child=$(printf '%s' "$(reply_payload SendMessage message '順便把那個檔也改一下' '{"to":"peer"}')" \
|
||
| CLAUDE_CODE_CHILD_SESSION=1 bash "$HOOK" 2>&1 >/dev/null); crc=$?
|
||
if [ "$crc" -eq 0 ]; then
|
||
printf ' ✅ ㉞ 子 session 送出的回覆不管(它送的是交件,不是派工)\n'; PASS=$((PASS+1))
|
||
else
|
||
printf ' ❌ ㉞ 子 session 的回覆被擋了(exit=%s)——那會擋掉交件本身\n' "$crc"; FAIL=$((FAIL+1))
|
||
fi
|
||
|
||
N=$((N+1))
|
||
kid=$(printf '%s' "$(payload '【工單】inkstone/ISEP#88
|
||
順便把 hooks.json 也改一下')" | CLAUDE_CODE_CHILD_SESSION=1 bash "$HOOK" 2>&1 >/dev/null); krc=$?
|
||
if [ "$krc" -eq 2 ]; then
|
||
printf ' ✅ ㉟ 但子 session **自己往下派工**(Task)時,派工單的規矩照樣管它\n'; PASS=$((PASS+1))
|
||
else
|
||
printf ' ❌ ㉟ 子 session 派工被整個放掉了(exit=%s)——那是一個逃逸艙口\n' "$krc"; FAIL=$((FAIL+1))
|
||
fi
|
||
|
||
clean
|
||
echo
|
||
echo "══ $PASS/$N 通過${FAIL:+,$FAIL 個失敗} ══"
|
||
[ "$FAIL" -eq 0 ]
|