Files
ISEP/hooks/dispatch-format-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

258 lines
14 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
# 管什麼: 派工單裡除了【工單】票號以外還有別的內容就擋;合規的派工則自動注入共通規定。
# 為什麼: 任務寫在 prompt 裡 => 票上看不到、agent 一停就蒸發;散文沒有欄位 => 警察抓不到。
# 誤觸時怎麼關: 把內容歸位(每次都一樣的→共通規定;這次才知道的→寫進票),或 touch /tmp/.dispatch-ok-<session_id> 放行一次。
#
# dispatch-format-guard.sh — PreToolUseTaskAgent):**派工單 = 票號。就這樣。**
#
# ━━ 來由(leo 2026-08-27inkstone/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#104Arcrun#142Arcrun#127
# Arcrun#144InkStoneCo#55),每一次票上都沒有那份任務。
# ⇒ **規則存在,閘只驗了它的殼。**(同款第 N 次:history-firstKBDB-first
# stage-firstAskUserQuestion 裸奔,全是這個形狀。)
#
# 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.shask-user-question-guard.sh):
# touch /tmp/.dispatch-ok-<session_id>
# 閘是絆一下逼你重想,不是把人關起來。
#
# ━━ 攔截點不只一個(inkstone/ISEP#882026-08-27 補)━━━━━━━━━━━━━━
# 第一版只掛 `PreToolUse(Task|Agent)` ⇒ 只涵蓋「**新開**一個 subagent」。
# **回覆一個正在跑的 subagent 走的是別的工具,根本不經過那個攔截點。**
# 實況:第一次派工乾乾淨淨只有票號,中途回覆時又把一長串修改要求丟過去,
# **那些話票上一個字都沒有**——那條線被停掉就消失,正是本閘要解的病。
#
# ⇒ 不另造一支平行的閘(票上寫死):同一支閘、同一個判斷函式,
# 只是把所有通往 subagent 的路一次列全。那張表在
# `hooks/lib/dispatch_parse.py` 的 `tool_channel()`——新增一條路加在那裡。
#
# 兩條路只有一個地方不同:
# · `dispatch`TaskAgent)沒有【工單】 ⇒ 放行,那是 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
} <<EOF
$FIELDS
EOF
[ "$STATUS" = "ok" ] || exit 0
[ -n "$NVIOL" ] || NVIOL=0
[ -n "$CHANNEL" ] || CHANNEL=dispatch
# 子 sessionsubagent)送出的**回覆**不管——它往上送的是交件不是派工
# (見上面「刻意不管的方向」)。**只放掉 reply 這條路**:
# subagent 自己再往下派工(Task)時,派工單的規矩照樣管它。
if [ "$CHANNEL" = "reply" ] && [ "${CLAUDE_CODE_CHILD_SESSION:-}" = "1" ]; then
exit 0
fi
# ── 明示豁免(留痕用,用掉就消失,不是永久開關)────────────────────────
if [ "$NVIOL" != "0" ] && [ -n "$SESSION" ] && [ -f "/tmp/.dispatch-ok-$SESSION" ]; then
rm -f "/tmp/.dispatch-ok-$SESSION"
NVIOL=0
fi
if [ "$NVIOL" != "0" ]; then
# 訊息用 quoted heredoc(裡面有反引號,不能讓 shell 當命令替換執行掉),
# 佔位符交給 python 換——sed 對正體中文與特殊字元的跳脫太脆。
# 2026-08-26 ask-user-question-guard 真的犯過:訊息裡的反引號被當命令跑,
# 閘照擋,但**它教人怎麼解的那兩行變成空白**,等於沒有出路。)
MSG="$(cat <<'EOF'
🚫 __HEADLINE__leo 2026-08-27inkstone/ISEP#30 comment 4327
__VIOLATIONS__
__CARRY__
━━ 這些內容該去哪:問一句「**這句話換一張票還成立嗎?**」━━━━━━━━━━
還成立 ⇒ **共通規定**(交件方式、不准 push main、org 是 inkstone、先讀 CLAUDE.md…)
改 `docs/governance/dispatch-and-reply-format.md` 的 §2
**本閘每次派工都會自動注入給收工方**,你不必寫、也不會漏。
只有這次 ⇒ **寫進那張票**(main 是哪顆、今天撞過什麼、另一條線在動什麼)
`scripts/ticket say <owner/repo#N> -F <檔>`
然後派工單寫 `【工單】<owner/repo#N> → comment <剛拿到的號碼>`
🔴 **「票上還沒有」不是把它寫進 prompt 的理由——它就是「去把它寫上票」的指令。**
leo 2026-08-27:「**這些為什麼不寫到票裡?**」
寫進 prompt 的後果:agent 被停掉或換人接手,那段事實隨 prompt 一起蒸發。
當天實害:總管停掉重派 3 次,前兩次的任務與 session 事實**全部消失**。
━━ 合格的派工單長這樣(整份,沒有省略)━━━━━━━━━━━━━━━━━━━━━
【工單】inkstone/ISEP#30 → comment 4322
━━ 為什麼連「一句叮嚀」都不行 ━━━━━━━━━━━━━━━━━━━━━━━━━━━
leo 2026-08-27:「**交件方式不需要寫,定義在原則裡,每張票都要做這件事⋯⋯
每次都一樣提取出來變成共通規定**」
你多寫的每一句,都是在造第二份會漂的真相;而收工方讀到的是**你的轉述**,
不是票上的原始證據。
真的有例外 → `touch /tmp/.dispatch-ok-__SESSION__` 後重送(用掉就消失,會留在指令歷史上)。
EOF
)"
VIOL_TEXT="$(printf '%s' "$FACTS" | python3 -c '
import sys, json
try:
d = json.load(sys.stdin)
except Exception:
d = {}
lines = []
for v in d.get("violations", []):
lines.append(" ❌ %s%s" % (v.get("code", ""), v.get("detail", "")))
for name in d.get("retired_seen", []):
lines.append(" ↳ 【%s】這個欄位已經收回了" % name)
print("\n".join(lines))
' 2>/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 "<session_id>")
+ "\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"<!-- INJECT:BEGIN.*?-->\n(.*?)<!-- INJECT:END -->", 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