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
182 lines
8.1 KiB
Bash
Executable File
182 lines
8.1 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# roster-guard.sh 的迴歸測試(inkstone/ISEP#86)
|
||
#
|
||
# 票上的四條驗收條件,一條一組:
|
||
# 1 列出名單 → 看得到有哪幾個工人、各自管什麼 (scripts/roster list)
|
||
# 2 派工要能指名派給誰 (subagent_type 認得出來)
|
||
# 3 票上的紀錄看得出是哪個工人做的 (【身份】欄吃得下工人名字)
|
||
# 4 派一個名單裡沒有的名字 → 要講得出「沒有這個人」
|
||
#
|
||
# 另外兩群:**不該擋**(誤攔比漏擋嚴重)、**注入**(指對名字就把它的檔案原文送過去)。
|
||
#
|
||
# 🔴 全程離線,不打網路、不花錢、每次結果一樣(純結構判斷,沒有語意判官)。
|
||
#
|
||
# 用法:hooks/tests/roster-guard.test.sh [hook 路徑]
|
||
set -u
|
||
unset CLAUDE_CODE_CHILD_SESSION # 殘留會讓別的閘行為不同;本閘不看它,但別讓環境有變數
|
||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
ROOT="$(cd "$HERE/../.." && pwd)"
|
||
HOOK="${1:-$ROOT/hooks/roster-guard.sh}"
|
||
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
|
||
|
||
PASS=0; FAIL=0; N=0
|
||
|
||
# payload <prompt> <subagent_type> [tool_name]
|
||
payload() {
|
||
python3 - "$1" "$2" "${3:-Task}" <<'PY'
|
||
import json, sys
|
||
print(json.dumps({"session_id": "S-ROSTER", "hook_event_name": "PreToolUse",
|
||
"tool_name": sys.argv[3],
|
||
"tool_input": {"prompt": sys.argv[1], "subagent_type": sys.argv[2]}},
|
||
ensure_ascii=False))
|
||
PY
|
||
}
|
||
|
||
# t <期望 exit> <說明> <payload> [必須出現的字串] [ISEP_ROSTER_DIR]
|
||
t() {
|
||
want="$1"; desc="$2"; body="$3"; must="${4:-}"; rdir="${5:-}"
|
||
N=$((N+1))
|
||
if [ -n "$rdir" ]; then
|
||
err=$(printf '%s' "$body" | ISEP_ROSTER_DIR="$rdir" bash "$HOOK" 2>&1 >/dev/null); rc=$?
|
||
else
|
||
err=$(printf '%s' "$body" | bash "$HOOK" 2>&1 >/dev/null); rc=$?
|
||
fi
|
||
ok=1
|
||
[ "$rc" -eq "$want" ] || ok=0
|
||
if [ -n "$must" ] && ! printf '%s' "$err" | grep -qF "$must"; then ok=0; fi
|
||
if [ "$ok" -eq 1 ]; then printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
|
||
else
|
||
printf ' ❌ %s —— 期望 exit=%s%s,實得 exit=%s\n' "$desc" "$want" \
|
||
"${must:+ 且訊息含「$must」}" "$rc"
|
||
printf '%s\n' "$err" | sed -n '1,6p' | sed 's/^/ /'
|
||
FAIL=$((FAIL+1))
|
||
fi
|
||
}
|
||
|
||
clean() { rm -f /tmp/.roster-ok-S-ROSTER 2>/dev/null || true; }
|
||
clean
|
||
|
||
echo "── 驗收 1:列出名單 ────────────────────────────────────────────"
|
||
|
||
N=$((N+1))
|
||
out=$(python3 "$ROOT/scripts/roster" list 2>&1)
|
||
if printf '%s' "$out" | grep -q 'isep-hand' && printf '%s' "$out" | grep -q 'inkstone/ISEP' \
|
||
&& printf '%s' "$out" | grep -q '負責 repo'; then
|
||
printf ' ✅ ① `roster list` 印得出「有哪幾個工人、各自管什麼」\n'; PASS=$((PASS+1))
|
||
else
|
||
printf ' ❌ ① `roster list` 沒有名字或沒有負責的 repo\n'; printf '%s\n' "$out" | sed 's/^/ /'
|
||
FAIL=$((FAIL+1))
|
||
fi
|
||
|
||
N=$((N+1))
|
||
if python3 "$ROOT/scripts/roster" show isep-hand 2>&1 | grep -q '## 紅線'; then
|
||
printf ' ✅ ② `roster show` 印得出那位工人的紅線(它開工時會讀到的東西)\n'; PASS=$((PASS+1))
|
||
else
|
||
printf ' ❌ ② `roster show` 沒有印出紅線段\n'; FAIL=$((FAIL+1))
|
||
fi
|
||
|
||
N=$((N+1))
|
||
if [ "$(python3 "$ROOT/scripts/roster" which inkstone/ISEP 2>/dev/null)" = "isep-hand" ]; then
|
||
printf ' ✅ ③ `roster which <repo>` 答得出這個 repo 該派給誰\n'; PASS=$((PASS+1))
|
||
else
|
||
printf ' ❌ ③ `roster which inkstone/ISEP` 沒有回 isep-hand\n'; FAIL=$((FAIL+1))
|
||
fi
|
||
|
||
echo "── 驗收 2:派工要能指名派給誰(不該擋)────────────────────────"
|
||
|
||
for who in isep-hand arcrun-hand arcrun-rag-hand inkstoneco-hand mira-hand collector-hand scout; do
|
||
t 0 "④ 指名 $who ⇒ 放行" "$(payload '【工單】inkstone/ISEP#86' "$who")"
|
||
done
|
||
|
||
t 0 "⑤ Agent 這條路也認得(不是只認 Task)" \
|
||
"$(payload '【工單】inkstone/ISEP#86' 'scout' 'Agent')"
|
||
|
||
echo "── 驗收 4:名單上沒有的名字 → 講得出「沒有這個人」──────────────"
|
||
|
||
t 2 "⑥ 派 general-purpose(就是「沒有名字的臨時工」)⇒ 擋,並說沒有這個人" \
|
||
"$(payload '【工單】inkstone/ISEP#86' 'general-purpose')" '沒有「general-purpose」這個人'
|
||
|
||
t 2 "⑦ 完全沒指名(subagent_type 是空的)⇒ 擋" \
|
||
"$(payload '【工單】inkstone/ISEP#86' '')" '沒有指名工人'
|
||
|
||
t 2 "⑧ 打錯字(isep-hands)⇒ 擋。名字是唯一識別碼,不做模糊比對" \
|
||
"$(payload '【工單】inkstone/ISEP#86' 'isep-hands')" '沒有「isep-hands」這個人'
|
||
|
||
t 2 "⑨ 擋下來的時候要把整份名單印出來(不然人不知道有誰可以派)" \
|
||
"$(payload '【工單】inkstone/ISEP#86' 'nobody')" 'arcrun-rag-hand'
|
||
|
||
echo "── 不該擋(誤攔比漏擋嚴重)──────────────────────────────────"
|
||
|
||
t 0 "⑩ 沒有【工單】⇒ 閉嘴(那是 no-ticket-no-dispatch 的地盤,不准兩支閘同時開口)" \
|
||
"$(payload '去查一下那個 worker 為什麼沒更新' 'general-purpose')"
|
||
|
||
t 0 "⑪ 不是派工的動作(Bash)⇒ 一律不管" \
|
||
'{"session_id":"S-ROSTER","tool_name":"Bash","tool_input":{"command":"ls"}}'
|
||
|
||
t 0 "⑫ 整包不是合法 JSON ⇒ fail-open" 'this is not json at all'
|
||
|
||
t 0 "⑬ 名單目錄是空的 ⇒ fail-open(讀不到的名單不該讓整台機器派不了工)" \
|
||
"$(payload '【工單】inkstone/ISEP#86' 'general-purpose')" '' "$TMP"
|
||
|
||
clean
|
||
touch /tmp/.roster-ok-S-ROSTER
|
||
t 0 "⑭ 明示豁免戳記在 ⇒ 放行一次" \
|
||
"$(payload '【工單】inkstone/ISEP#86' 'general-purpose')"
|
||
N=$((N+1))
|
||
if [ -f /tmp/.roster-ok-S-ROSTER ]; then
|
||
printf ' ❌ ⑮ 豁免戳記用完沒被消掉(會變成永久開關)\n'; FAIL=$((FAIL+1))
|
||
else
|
||
printf ' ✅ ⑮ 豁免戳記用完就消失,不是永久開關\n'; PASS=$((PASS+1))
|
||
fi
|
||
clean
|
||
|
||
echo "── 注入:指對名字就把那位工人的檔案原文送過去 ──────────────────"
|
||
|
||
N=$((N+1))
|
||
inj=$(printf '%s' "$(payload '【工單】inkstone/ISEP#86' 'isep-hand')" | bash "$HOOK" 2>/dev/null)
|
||
if printf '%s' "$inj" | python3 -c '
|
||
import sys, json
|
||
c = json.load(sys.stdin)["hookSpecificOutput"]["additionalContext"]
|
||
assert "isep-hand" in c, "沒有名字"
|
||
assert "inkstone/ISEP" in c, "沒有負責的 repo"
|
||
assert "開工前先讀" in c, "沒有「開工前先讀」"
|
||
assert "紅線" in c, "沒有紅線"
|
||
' 2>/dev/null; then
|
||
printf ' ✅ ⑯ 指對名字 ⇒ 注入「你是誰/負責哪個 repo/開工前先讀/紅線」四件\n'; PASS=$((PASS+1))
|
||
else
|
||
printf ' ❌ ⑯ 沒有注入,或缺了其中一件\n'; printf '%s\n' "$inj" | sed -n '1,4p' | sed 's/^/ /'
|
||
FAIL=$((FAIL+1))
|
||
fi
|
||
|
||
N=$((N+1))
|
||
inj2=$(printf '%s' "$(payload '【工單】inkstone/Arcrun#142' 'arcrun-hand')" | bash "$HOOK" 2>/dev/null)
|
||
if printf '%s' "$inj2" | grep -q 'arcrun-hand' && ! printf '%s' "$inj2" | grep -q 'isep-hand'; then
|
||
printf ' ✅ ⑰ 注入的是**那一位**的檔案,不是整份名單\n'; PASS=$((PASS+1))
|
||
else
|
||
printf ' ❌ ⑰ 注入的內容不是指名的那一位\n'; FAIL=$((FAIL+1))
|
||
fi
|
||
|
||
echo "── 驗收 3:票上的紀錄看得出是哪個工人做的(身份欄)──────────────"
|
||
|
||
N=$((N+1))
|
||
if python3 - "$ROOT" <<'PY' 2>/dev/null
|
||
import sys, os
|
||
sys.path.insert(0, os.path.join(sys.argv[1], "hooks", "lib"))
|
||
import dispatch_parse as dp
|
||
ok, _ = dp.parse_identity("【身份】isep-hand/inkstone/ISEP/feat/x")
|
||
assert ok, "工人名字不被身份欄接受"
|
||
ok2, _ = dp.parse_identity("【身份】總管/inkstone/ISEP/-")
|
||
assert ok2, "原本的三個角色被弄壞了"
|
||
bad, _ = dp.parse_identity("【身份】某某某/inkstone/ISEP/-")
|
||
assert not bad, "亂寫的名字被放行了"
|
||
PY
|
||
then
|
||
printf ' ✅ ⑱ 【身份】欄吃得下工人名字,原本三個角色照舊,亂寫的仍然擋\n'; PASS=$((PASS+1))
|
||
else
|
||
printf ' ❌ ⑱ 身份欄沒有吃下工人名字(或把原本的三個角色弄壞了)\n'; FAIL=$((FAIL+1))
|
||
fi
|
||
|
||
echo
|
||
printf '結果:%s 通過 / %s 失敗(共 %s 條)\n' "$PASS" "$FAIL" "$N"
|
||
[ "$FAIL" -eq 0 ] || exit 1
|