#!/usr/bin/env bash # 管什麼: 派工單裡除了【工單】票號以外還有別的內容就擋;合規的派工則自動注入共通規定。 # 為什麼: 任務寫在 prompt 裡 => 票上看不到、agent 一停就蒸發;散文沒有欄位 => 警察抓不到。 # 誤觸時怎麼關: 把內容歸位(每次都一樣的→共通規定;這次才知道的→寫進票),或 touch /tmp/.dispatch-ok- 放行一次。 # # dispatch-format-guard.sh — PreToolUse(Task|Agent):**派工單 = 票號。就這樣。** # # ━━ 來由(leo 2026-08-27,inkstone/ISEP#30 comment 4322 / 4325 / 4327)━━━━ # # 他貼回總管派給 Arcrun#142 那條線的 prompt,問: # 「**這些話票上都沒有,你根本沒照規則做事,你的 hook 讓你這樣搞?**」 # 「執行的不是你,你去派工,它才知道問題,**你寫診斷意義是什麼?不就是假的?**」 # # 實查:`no-ticket-no-dispatch.sh` 驗的是「派工單裡有沒有一行【工單】owner/repo#N」。 # ⇒ 所以可以**把 40 行任務全寫在 prompt 裡、票號補一行**,閘照樣放行。 # ⇒ 2026-08-27 一天之內這樣做了 5 次(arcrun-rag#104/Arcrun#142/Arcrun#127/ # Arcrun#144/InkStoneCo#55),每一次票上都沒有那份任務。 # ⇒ **規則存在,閘只驗了它的殼。**(同款第 N 次:history-first/KBDB-first/ # stage-first/AskUserQuestion 裸奔,全是這個形狀。) # # 4325 追加:「你用一個 **output parser** 把你給 subagent 的指令規範,**分作幾點, # 每一點規定格式**,**照這種散文寫法根本無法迭代**」「**警察也不能抓**」。 # 4327 收斂到最後一步:「**交件方式不需要寫,定義在原則裡**⋯⋯每次都一樣提取出來 # 變成共通規定」+(對「這是 session 才知道的事」的回應)「**這些為什麼不寫到票裡?**」 # # ⇒ 派工單裡想寫的東西只有兩種,兩種都不該留在派工單: # 每次都一樣 → 共通規定(本閘自動注入,見下) # 這次才知道 → **寫進那張票** # 判準:**「這句話換一張票還成立嗎?」** # # ━━ 為什麼是結構閘,不是關鍵字閘(leo 2026-08-17 的檢驗)━━━━━━━━━━━━ # 「你在**文字層**封路⋯⋯『回「做」我就啓動』沒有問號,下次它會寫『準備就緒』⋯⋯ # **自然語言的變體是無限的,blacklist 永遠追不完。** # 封路哲學之所以有效,是因為它封的是**動作**——動作有限且可枚舉,文字不是。」 # 當日實證:文字層的閘 8 次誤攔、0 次正確攔截,且方向穩定—— # **紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。** # # 本閘怎麼過這個檢驗: # · 判準是**「這一行是不是【工單】欄位」**——在不在,不是寫什麼。 # 整條路徑(hooks/lib/dispatch_parse.py)**零個「命中某個詞就違規」的比對**。 # · ⇒ 措辭再謹慎也不會被多罰;改寫成別的講法也閃不過去。文字層閘的兩個病都沒有。 # · 也**不需要語意判官**:這條規則本來就是結構性的,用不著 LLM, # 於是它免費、瞬間、每次結果一樣(比 ask-user-question-guard 更硬的一種閘)。 # # ━━ 這支同時做兩件事 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # ① **擋**:【工單】以外有實質內容 → exit 2,並指出那些內容該搬去哪一格 # ② **注入**:合規的派工 → exit 0 並用 hookSpecificOutput 把共通規定送給收工方 # (慣例同 subagent-wiki-guard.sh)。 # ⇒ 這是「派工單只剩票號」能成立的前提:交件方式、不准 push main、org 是 inkstone… # **不必有人記得寫**,機器每次都補。 # leo 的驗收條件之一就是「收工方**沒讀派工單**也知道要貼回原票」。 # # ━━ 邊界(刻意不管的事)━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ # · 派工單裡**沒有**【工單】 → 本閘閉嘴,那是 no-ticket-no-dispatch.sh 的地盤。 # 兩支閘同時開口,收工方會拿到兩份互相打架的教學。 # · 票存不存在、關了沒 → 也是那一支在驗,本閘不重複打 Gitea(本閘不連網)。 # # 逃生口(留痕、用完即丟,慣例同 micromanage-guard.sh/ask-user-question-guard.sh): # touch /tmp/.dispatch-ok- # 閘是絆一下逼你重想,不是把人關起來。 # # ━━ 攔截點不只一個(inkstone/ISEP#88,2026-08-27 補)━━━━━━━━━━━━━━ # 第一版只掛 `PreToolUse(Task|Agent)` ⇒ 只涵蓋「**新開**一個 subagent」。 # **回覆一個正在跑的 subagent 走的是別的工具,根本不經過那個攔截點。** # 實況:第一次派工乾乾淨淨只有票號,中途回覆時又把一長串修改要求丟過去, # **那些話票上一個字都沒有**——那條線被停掉就消失,正是本閘要解的病。 # # ⇒ 不另造一支平行的閘(票上寫死):同一支閘、同一個判斷函式, # 只是把所有通往 subagent 的路一次列全。那張表在 # `hooks/lib/dispatch_parse.py` 的 `tool_channel()`——新增一條路加在那裡。 # # 兩條路只有一個地方不同: # · `dispatch`(Task/Agent)沒有【工單】 ⇒ 放行,那是 no-ticket-no-dispatch 的地盤 # · `reply`(SendMessage/雲端 session/`claude -p`…)沒有【工單】 ⇒ **擋**, # 因為那支閘沒掛在這些路上,不擋就是整條裸奔 # 刻意不管的方向:subagent 往上回報(`SendMessage to: "main"`)=交件不是派工。 # # 擋下來的時候會**把那段內容原文印出來**,方便直接貼上票—— # 因為這條規則的出路就是「把它寫進票」,而不印出來就得請人回頭自己找。 # # 測試:hooks/tests/dispatch-format-guard.test.sh(離線、不打網路、不花錢) set -u INPUT="$(cat)" HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" FACTS="$(printf '%s' "$INPUT" | python3 "$HERE/lib/dispatch_parse.py" dispatch 2>/dev/null)" [ -n "$FACTS" ] || exit 0 # parser 掛了 → fail-open(回到「沒有這支閘」的狀態) # 🔴 四個欄位一次讀完,不要每個欄位各開一支 python。 # 這不是效率問題,是**正確性**問題:每一次 `python3 -c` 都是一個新行程, # 而每一次都用 `2>/dev/null` 吞掉錯誤 ⇒ 機器忙的時候只要有一次生不出行程, # 那個欄位就靜靜變成空字串,閘的行為跟著變——**而且不會有任何痕跡**。 # 實測(2026-08-28,本 repo 的測試連跑 10 次):`SESSION` 偶爾拿不到, # 於是「明示豁免戳記」那條路 **10 次裡失敗 1 次**,症狀是「豁免了卻還是被擋」。 # 一次讀完 ⇒ 要嘛四個都拿到、要嘛全空(全空=fail-open,回到沒有這支閘的狀態)。 FIELDS="$(printf '%s' "$FACTS" | python3 -c ' import sys, json try: d = json.load(sys.stdin) except Exception: d = {} print(d.get("status", "skip")) print(d.get("session_id", "") or "") print(len(d.get("violations", []) or [])) print(d.get("channel", "dispatch") or "dispatch") ' 2>/dev/null)" STATUS=""; SESSION=""; NVIOL=""; CHANNEL="" { IFS= read -r STATUS || true IFS= read -r SESSION || true IFS= read -r NVIOL || true IFS= read -r CHANNEL || true } </dev/null)" # 擋下來的時候把那段內容原文印出來——出路是「貼上票」, # 不印出來就得請人回頭自己找(inkstone/ISEP#88 驗收條件 1)。 CARRY_TEXT="$(printf '%s' "$FACTS" | python3 -c ' import sys, json try: t = (json.load(sys.stdin).get("extra_text") or "").strip() except Exception: t = "" if t: print("━━ 這段內容原文在這裡,直接貼上票 ━━━━━━━━━━━━━━━━━━━━━━━━\n") print(t) print() ' 2>/dev/null)" if [ "$CHANNEL" = "reply" ]; then HEADLINE="回覆也是派工:內容寫進票,訊息只給票號" else HEADLINE="派工單 = 票號。就這樣。" fi DFG_MSG="$MSG" DFG_VIOL="$VIOL_TEXT" DFG_SESSION="$SESSION" \ DFG_HEAD="$HEADLINE" DFG_CARRY="$CARRY_TEXT" python3 -c ' import os, sys sys.stderr.write( os.environ["DFG_MSG"] .replace("__HEADLINE__", os.environ.get("DFG_HEAD", "")) .replace("__VIOLATIONS__", os.environ.get("DFG_VIOL", "")) .replace("__CARRY__", os.environ.get("DFG_CARRY", "")) .replace("__SESSION__", os.environ.get("DFG_SESSION", "") or "") + "\n" ) ' exit 2 fi # 回覆這條路不注入共通規定——收工方早在第一次派工時就拿過了, # 每一則回覆再灌一次只是噪音(而噪音會讓人學會忽略整段注入)。 [ "$CHANNEL" = "reply" ] && exit 0 # ── 合規 → 注入共通規定 ────────────────────────────────────────────── # 這是「派工單只剩票號」的另一半:那些每次都一樣的規定不必有人記得寫,機器每次都補。 # 來源是 docs/governance/dispatch-and-reply-format.md 的 INJECT 區段—— # **只有一份**,改那裡等於改所有派工(不會有第二份漂走的副本)。 DOC="${CLAUDE_PLUGIN_ROOT:-$(cd "$HERE/.." && pwd)}/docs/governance/dispatch-and-reply-format.md" [ -f "$DOC" ] || exit 0 DFG_DOC="$DOC" python3 -c ' import json, os, re, sys try: text = open(os.environ["DFG_DOC"], encoding="utf-8", errors="ignore").read() except Exception: sys.exit(0) m = re.search(r"\n(.*?)", text, re.S) if not m: sys.exit(0) body = m.group(1).strip() print(json.dumps({ "hookSpecificOutput": { "hookEventName": "PreToolUse", "additionalContext": "【自動注入:派工共通規定】\n" "(來源 docs/governance/dispatch-and-reply-format.md §2——" "派工單只給票號是刻意的,這些每次都一樣的東西由機器補,不靠人記得寫)\n\n" + body, } }, ensure_ascii=False)) ' 2>/dev/null exit 0