#!/usr/bin/env bash # 管什麼: 派工(帶【工單】的 Task/Agent)沒有指名工人、或指了名單上沒有的名字 → 擋; # 指對了就把那位工人的檔案原文注入給它(它是誰、先讀什麼、紅線)。 # 為什麼: 派工派給「一個 agent」=票上看不出這件事是誰做的,也沒有任何連續性。 # 誤觸時怎麼關: `python3 scripts/roster list` 挑一個名字放進 subagent_type; # 真的沒有合適的人 → touch /tmp/.roster-ok- 放行一次(用完即丟)。 # # 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- # # 測試: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 ━━ 怎麼派 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 把名字放進 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 "") + "\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