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
187 lines
8.5 KiB
Bash
Executable File
187 lines
8.5 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# 管什麼: 派工(帶【工單】的 Task/Agent)沒有指名工人、或指了名單上沒有的名字 → 擋;
|
||
# 指對了就把那位工人的檔案原文注入給它(它是誰、先讀什麼、紅線)。
|
||
# 為什麼: 派工派給「一個 agent」=票上看不出這件事是誰做的,也沒有任何連續性。
|
||
# 誤觸時怎麼關: `python3 scripts/roster list` 挑一個名字放進 subagent_type;
|
||
# 真的沒有合適的人 → touch /tmp/.roster-ok-<session_id> 放行一次(用完即丟)。
|
||
#
|
||
# roster-guard.sh — PreToolUse(Task|Agent):**工人要有名字**(inkstone/ISEP#86)
|
||
#
|
||
# ━━ 來由(leo,inkstone/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 的 name/description、`**負責 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
|