Files
ISEP/hooks/roster-guard.sh
Leo 5ac06abc95 工人有名字+回覆也是派工+未經調查不寫診斷(inkstone/ISEP#86/#87/#88)
三張票同一族(誰在派、派給誰、派的內容住哪裡),做在同一條分支:

#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
2026-08-28 01:15:09 +00:00

187 lines
8.5 KiB
Bash
Executable File
Raw Permalink 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
# 管什麼: 派工(帶【工單】的 Task/Agent)沒有指名工人、或指了名單上沒有的名字 → 擋;
# 指對了就把那位工人的檔案原文注入給它(它是誰、先讀什麼、紅線)。
# 為什麼: 派工派給「一個 agent」=票上看不出這件事是誰做的,也沒有任何連續性。
# 誤觸時怎麼關: `python3 scripts/roster list` 挑一個名字放進 subagent_type
# 真的沒有合適的人 → touch /tmp/.roster-ok-<session_id> 放行一次(用完即丟)。
#
# roster-guard.sh — PreToolUseTaskAgent):**工人要有名字**inkstone/ISEP#86
#
# ━━ 來由(leoinkstone/ISEP#86)━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 「身為派工的人,我要工人有名字,我才知道票上這件事到底是誰做的。」
#
# 實查(開票當日):`/root/.claude/agents/` 不存在、`InkStoneCo/.claude/agents/`
# 只有一支內建的說明用 agent ⇒ 派工派給的是**沒有名字的臨時工**。後果三件:
# · 票上只看得到「有個 agent 做了」,看不出是誰
# · 每次派工都要重新交代它是誰、該讀什麼——**而派工單只准寫票號**
# `dispatch-format-guard.sh`ISEP v0.5.0
# · 同一個 repo 這次派的跟上次派的沒有任何連續性
#
# ━━ 這支跟 dispatch-format-guard.sh 的分界(兩支不會同時開口)━━━━━━━━
# dispatch-format-guard 管**派工單的內文**(只准有【工單】那一行)。
# 本支管**派給誰**——那是 `Task` 的 `subagent_type` 欄,**不在內文裡**。
# ⇒ 兩者正交:加上這道閘,派工單格式一個字都不用改。
#
# 邊界同 dispatch-format-guard:派工單裡**沒有**【工單】時本閘閉嘴,
# 那是 `no-ticket-no-dispatch.sh` 的地盤(沒票的東西還輪不到談派給誰)。
#
# ━━ 為什麼過得了「封動作不封文字」的檢驗(leo 2026-08-17)━━━━━━━━━
# 判準是「這個名字**在不在名單裡**」——一次字面比對,方向是**放行**
# (在名單裡 ⇒ 放行)。沒有任何「命中某個詞就違規」的比對。
# 名字是唯一識別碼、集合有限且可枚舉,正是 v0.11.0 那支閘用的形狀。
#
# ━━ 這支同時做兩件事 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# ① **擋**:沒指名/指了不存在的名字 → exit 2,並把整份名單印出來
# ② **注入**:指對了 → exit 0,把那位工人的檔案原文送給它
# (慣例同 dispatch-format-guard.sh 的共通規定注入)
# ⇒ 「你是誰、開工前先讀什麼、你的紅線」**不必寫進派工單**,機器每次都補。
# 這正是 ISEP#86 說的那件共通規定補不了的事——它每個工人都不一樣。
#
# ━━ fail-open 的界線 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# 名單目錄讀不到/解析器掛了 → **放行**(回到「沒有這支閘」的狀態)。
# 一份讀不到的名單不該讓整台機器派不了工。
#
# 逃生口(留痕、用完即丟):touch /tmp/.roster-ok-<session_id>
#
# 測試:hooks/tests/roster-guard.test.sh(離線、不打網路、不花錢)
set -u
INPUT="$(cat)"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
FACTS="$(printf '%s' "$INPUT" | ROSTER_LIB="$HERE/lib" python3 -c '
import json, os, sys
sys.path.insert(0, os.environ.get("ROSTER_LIB", ""))
try:
import roster
except Exception:
print(json.dumps({"status": "skip", "why": "roster lib 不在"})); raise SystemExit
try:
d = json.load(sys.stdin)
except Exception:
print(json.dumps({"status": "skip", "why": "payload 不是 JSON"})); raise SystemExit
ti = d.get("tool_input") or {}
prompt = ti.get("prompt") or ti.get("message") or ""
# 邊界:沒有【工單】 ⇒ no-ticket-no-dispatch 的地盤,本閘閉嘴
if "【工單】" not in prompt:
print(json.dumps({"status": "skip", "why": "沒有【工單】"})); raise SystemExit
# 名字住在哪一欄:subagent_type 是 Task 的原生欄位;其餘是別的 surface 的別名。
picked = ""
for k in ("subagent_type", "agent_type", "agent", "persona", "name"):
v = ti.get(k)
if isinstance(v, str) and v.strip():
picked = v.strip(); break
ws = roster.load()
if not ws:
print(json.dumps({"status": "skip", "why": "名單是空的"})); raise SystemExit
hit = roster.find(picked) if picked else None
print(json.dumps({
"status": "ok",
"picked": picked,
"known": bool(hit),
"session_id": d.get("session_id") or "",
"roster": [roster.one_liner(w) for w in ws],
"card": (hit or {}).get("body", ""),
"card_name": (hit or {}).get("name", ""),
"card_repo": (hit or {}).get("repo", ""),
}, ensure_ascii=False))
' 2>/dev/null)"
[ -n "$FACTS" ] || exit 0 # 解析器掛了 → fail-open
get() { printf '%s' "$FACTS" | python3 -c "
import sys, json
try: print(json.load(sys.stdin).get('$1', '') or '')
except Exception: print('')
" 2>/dev/null; }
[ "$(get status)" = "ok" ] || exit 0
SESSION="$(get session_id)"
KNOWN="$(get known)"
if [ "$KNOWN" != "True" ]; then
# 明示豁免(留痕用,用掉就消失,不是永久開關)
if [ -n "$SESSION" ] && [ -f "/tmp/.roster-ok-$SESSION" ]; then
rm -f "/tmp/.roster-ok-$SESSION"
exit 0
fi
PICKED="$(get picked)"
ROSTER_TEXT="$(printf '%s' "$FACTS" | python3 -c '
import sys, json
try: rows = json.load(sys.stdin).get("roster", [])
except Exception: rows = []
print("\n".join(" " + r for r in rows))
' 2>/dev/null)"
MSG="$(cat <<'EOF'
🚫 __WHO__inkstone/ISEP#86:「**我要工人有名字,我才知道票上這件事到底是誰做的**」)
━━ 名單上有這幾位 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
__ROSTER__
看某一位的全文:python3 scripts/roster show <名字>
這個 repo 該派給誰:python3 scripts/roster which <owner/repo>
━━ 怎麼派 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
把名字放進 Task 的 `subagent_type`。**派工單本身不用改**——
它仍然只有一行【工單】(`dispatch-format-guard.sh` 沒有變)。
指對名字之後,本閘會把那位工人的檔案原文注入給它:
它是誰、開工前先讀什麼、它的紅線是什麼。
⇒ **這些不必寫進派工單**,也不可能寫進共通規定(每個工人都不一樣)。
━━ 名單上真的沒有合適的人 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
加一個工人 在 `agents/` 加一個 `.md`(照既有檔案的四段:
frontmatter 的 namedescription、`**負責 repo**`、`## 開工前先讀`、`## 紅線`)。
**不必改任何 hook**——名單是資料,閘讀的是那個目錄。
規約:docs/governance/worker-roster.md
真的有例外 → `touch /tmp/.roster-ok-__SESSION__` 後重送(用掉就消失,會留在指令歷史上)。
EOF
)"
if [ -z "$PICKED" ]; then
WHO="這次派工**沒有指名工人**\`subagent_type\` 是空的)"
else
WHO="名單上**沒有「$PICKED」這個人**"
fi
RG_MSG="$MSG" RG_WHO="$WHO" RG_ROSTER="$ROSTER_TEXT" RG_SESSION="$SESSION" python3 -c '
import os, sys
sys.stderr.write(
os.environ["RG_MSG"]
.replace("__WHO__", os.environ.get("RG_WHO", ""))
.replace("__ROSTER__", os.environ.get("RG_ROSTER", ""))
.replace("__SESSION__", os.environ.get("RG_SESSION", "") or "<session_id>")
+ "\n"
)
'
exit 2
fi
# ── 指名正確 → 把那位工人的檔案原文注入 ──────────────────────────────
printf '%s' "$FACTS" | python3 -c '
import json, sys
try:
d = json.load(sys.stdin)
except Exception:
raise SystemExit
body = (d.get("card") or "").strip()
if not body:
raise SystemExit
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext":
"【自動注入:你是誰】%s(負責 %s)\n"
"(來源 agents/%s.md——派工單只給票號是刻意的,"
"「你是誰、先讀什麼、你的紅線」由機器補,不必寫進派工單)\n\n%s"
% (d.get("card_name", ""), d.get("card_repo", "") or "跨 repo",
d.get("card_name", ""), body),
}
}, ensure_ascii=False))
' 2>/dev/null
exit 0