Files
ISEP/hooks/tests/roster-guard.test.sh
T
Leo d2f706a16c 新 repo 的工人不再靠人記得:課程工人 course-hand+開場點名「有票卻沒工人」(inkstone/ISEP#118)
- agents/course-hand.md:管 inkstone/ax-courses、course_gen、llm-wiki-template(c7358 leo「你可以製作一個」)
  假設:llm-wiki-template 也歸課程工人(同為課程素材),總管不同意就把它拆出去
- hooks/lib/roster.py:`負責 repo` 可列多個(、或逗號),新增 which()/uncovered();find() 與閘本身未動
- scripts/roster which 改走 roster.which
- scripts/isep-nag 第④類:org 裡有開著的票、名單上卻沒人負責的 repo,只進開場長版、不進 Telegram
- 測試:roster-guard 25→31、overdue-nag 36→43;兩種突變(拿掉票數條件/只認第一個 repo)都會紅
- 文件:worker-roster.md 回答「新 repo 的工人誰在什麼時候加」;README/plugin.json 工人 8、scripts 65(樹上實數)

待總管定版(改了 agents/scripts,要升版才裝得上)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 13:13:12 +08:00

284 lines
13 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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-handinkstone/ISEPfeat/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 "── 驗收 4:真實的 subagent_type 形狀(2026-08-28 實撞的死鎖)──────"
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 roster
d = os.path.join(sys.argv[1], "agents")
# Claude Code 給 plugin agent 的真實值帶前綴,agents/*.md 的 name 沒有
assert roster.find("isep:isep-hand", d), "帶 plugin 前綴的真實 subagent_type 認不出來"
assert roster.find("isep:scout", d), "帶前綴的 scout 認不出來"
# 名單上的裸名字仍然要收(人會照 roster list 直接貼)
assert roster.find("isep-hand", d), "裸名字被弄壞了"
# 剝前綴不能變成放行任何東西
assert roster.find("isep:nobody", d) is None, "不存在的名字被放行了"
assert roster.find("nobody", d) is None, "不存在的裸名字被放行了"
assert roster.find("", d) is None, "空字串被放行了"
PY
then
printf ' ✅ ⑲ 帶 plugin 前綴的真實 subagent_type 認得出來,不存在的仍然擋\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑲ 真實的 subagent_typeisep:isep-hand)認不出來——就是這個造成 8/28 的死鎖\n'; FAIL=$((FAIL+1))
fi
echo "── 驗收 5:課程工人(inkstone/ISEP#118):一個工人管三個 repo ──────────"
for r in inkstone/ax-courses inkstone/course_gen inkstone/llm-wiki-template; do
N=$((N+1))
if [ "$(python3 "$ROOT/scripts/roster" which "$r" 2>/dev/null)" = "course-hand" ]; then
printf ' ✅ ⑳ `roster which %s` ⇒ course-hand\n' "$r"; PASS=$((PASS+1))
else
printf ' ❌ ⑳ `roster which %s` 沒回 course-hand(下一次派工又要開例外閘)\n' "$r"; FAIL=$((FAIL+1))
fi
done
# 真實派工的形狀:plugin 前綴 + 只有一行【工單】
t 0 "㉑ 派 isep:course-hand 去 inkstone/ax-courses#6 ⇒ 放行(09-14 實撞開例外閘的那一張)" \
"$(payload '【工單】inkstone/ax-courses#6' 'isep:course-hand' 'Agent')"
N=$((N+1))
inj3=$(printf '%s' "$(payload '【工單】inkstone/ax-courses#6' 'isep:course-hand' 'Agent')" | bash "$HOOK" 2>/dev/null)
if printf '%s' "$inj3" | python3 -c '
import sys, json
c = json.load(sys.stdin)["hookSpecificOutput"]["additionalContext"]
for want in ("course-hand", "inkstone/ax-courses", "inkstone/course_gen", "pdftoppm", "## 紅線"):
assert want in c, want
' 2>/dev/null; then
printf ' ✅ ㉒ 注入的卡片帶著它管的三個 repo 與課件驗收紅線(pdftoppm\n'; PASS=$((PASS+1))
else
printf ' ❌ ㉒ 課程工人的注入缺東西\n'; FAIL=$((FAIL+1))
fi
echo "── 驗收 6:多 repo 解析與「有票卻沒工人」的判準(離線假名單)────────"
mkdir -p "$TMP/r6"
cat > "$TMP/r6/multi.md" <<'MD'
---
name: multi-hand
description: 測試用
---
**負責 repo**acme/a、acme/b, acme/c(括號後面不算 acme/zzz
## 開工前先讀
## 紅線
MD
cat > "$TMP/r6/single.md" <<'MD'
---
name: single-hand
description: 測試用
---
**負責 repo**acme/solo
## 開工前先讀
## 紅線
MD
N=$((N+1))
if python3 - "$ROOT" "$TMP/r6" <<'PY' 2>/dev/null
import sys, os
sys.path.insert(0, os.path.join(sys.argv[1], "hooks", "lib"))
import roster
d = sys.argv[2]
assert [w["name"] for w in roster.which("acme/b", d)] == ["multi-hand"], "頓號後面的 repo 認不出來"
assert [w["name"] for w in roster.which("acme/c", d)] == ["multi-hand"], "逗號後面的 repo 認不出來"
assert [w["name"] for w in roster.which("ACME/SOLO", d)] == ["single-hand"], "大小寫不分壞了"
assert roster.which("acme/zzz", d) == [], "括號裡的字被當成 repo"
assert roster.which("acme/nobody", d) == [], "不存在的 repo 被認領了"
rows = [
{"full_name": "acme/a", "open_issues_count": 3}, # 有人 ⇒ 不叫
{"full_name": "acme/lonely", "open_issues_count": 2}, # 沒人、有票 ⇒ 叫
{"full_name": "acme/idle", "open_issues_count": 0}, # 沒人、沒票 ⇒ 不叫
{"full_name": "acme/old", "open_issues_count": 9, "archived": True}, # 封存 ⇒ 不叫
{"full_name": "acme/busy", "open_issues_count": 5}, # 沒人、有票 ⇒ 叫,排前面
]
got = roster.uncovered(rows, d)
assert got == [{"repo": "acme/busy", "open": 5}, {"repo": "acme/lonely", "open": 2}], got
assert roster.uncovered(rows, os.path.join(d, "不存在")) is None, "名單讀不到時沒有回 None"
PY
then
printf ' ✅ ㉓ 頓號/逗號都認得、括號後不算;只叫「沒人又有票」、不叫封存與沒票;名單讀不到回 None\n'; PASS=$((PASS+1))
else
printf ' ❌ ㉓ 多 repo 解析或 uncovered 判準壞了\n'; FAIL=$((FAIL+1))
fi
echo
printf '結果:%s 通過 / %s 失敗(共 %s 條)\n' "$PASS" "$FAIL" "$N"
[ "$FAIL" -eq 0 ] || exit 1