工人有名字+回覆也是派工+未經調查不寫診斷(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
This commit is contained in:
2026-08-28 01:15:09 +00:00
parent bebbbd2c11
commit 5ac06abc95
25 changed files with 1853 additions and 47 deletions
+84 -16
View File
@@ -59,6 +59,25 @@
# 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
@@ -68,24 +87,45 @@ 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(回到「沒有這支閘」的狀態)
STATUS="$(printf '%s' "$FACTS" | python3 -c '
# 🔴 四個欄位一次讀完,不要每個欄位各開一支 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: print(json.load(sys.stdin).get("status", "skip"))
except Exception: print("skip")
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
SESSION="$(printf '%s' "$FACTS" | python3 -c '
import sys, json
try: print(json.load(sys.stdin).get("session_id", ""))
except Exception: print("")
' 2>/dev/null)"
NVIOL="$(printf '%s' "$FACTS" | python3 -c '
import sys, json
try: print(len(json.load(sys.stdin).get("violations", [])))
except Exception: print(0)
' 2>/dev/null)"
# 子 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
@@ -99,9 +139,10 @@ if [ "$NVIOL" != "0" ]; then
# 2026-08-26 ask-user-question-guard 真的犯過:訊息裡的反引號被當命令跑,
# 閘照擋,但**它教人怎麼解的那兩行變成空白**,等於沒有出路。)
MSG="$(cat <<'EOF'
🚫 派工單 票號。就這樣。leo 2026-08-27inkstone/ISEP#30 comment 4327
🚫 __HEADLINE__leo 2026-08-27inkstone/ISEP#30 comment 4327
__VIOLATIONS__
__CARRY__
━━ 這些內容該去哪:問一句「**這句話換一張票還成立嗎?**」━━━━━━━━━━
@@ -145,11 +186,34 @@ for name in d.get("retired_seen", []):
print("\n".join(lines))
' 2>/dev/null)"
DFG_MSG="$MSG" DFG_VIOL="$VIOL_TEXT" DFG_SESSION="$SESSION" python3 -c '
# 擋下來的時候把那段內容原文印出來——出路是「貼上票」,
# 不印出來就得請人回頭自己找(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"
)
@@ -157,6 +221,10 @@ sys.stderr.write(
exit 2
fi
# 回覆這條路不注入共通規定——收工方早在第一次派工時就拿過了,
# 每一則回覆再灌一次只是噪音(而噪音會讓人學會忽略整段注入)。
[ "$CHANNEL" = "reply" ] && exit 0
# ── 合規 → 注入共通規定 ──────────────────────────────────────────────
# 這是「派工單只剩票號」的另一半:那些每次都一樣的規定不必有人記得寫,機器每次都補。
# 來源是 docs/governance/dispatch-and-reply-format.md 的 INJECT 區段——