工人有名字+回覆也是派工+未經調查不寫診斷(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
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""hooks/lib/evidence.py — 「這段要寫上票的內容,帶不帶得出出處?」(inkstone/ISEP#87
依 inkstone/ISEP#40 S7 的慣例:`lib/` 裡的東西是共用零件,本身不決定 allow/block
判決由呼叫它的閘做(`hooks/diagnosis-evidence-guard.sh`)。
━━ 規則本身(leoinkstone/ISEP#87)━━━━━━━━━━━━━━━━━━━━━━━━━
「身為派人去查的人,我要自己在拿到調查結果前寫不出診斷,
我才不會用猜的結論把工人的判斷力關掉。」
票上原文的兩個壞法:
· 診斷寫完了,**工人就不知道自己要做什麼**——它會照著我的結論去驗證,而不是去查
· **我不是那個 repo 的專家,它才是**。我的結論通常是猜的,
但因為寫在票上,看起來就像事實
⇒ 順序倒過來:**先派人查 → 拿到查的結果 → 才可以寫診斷。**
━━ 🔴 為什麼判準不能是「找出診斷的句型」━━━━━━━━━━━━━━━━━━━━
票上寫死:「『根因』『因為』『應該是』這類詞**一律不准當判準**」。
leo 2026-08-17 已證偽文字層封路:當日 **8 次誤攔、0 次正確攔截**,而且方向穩定——
**紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。**
所以本檔**沒有任何「命中某個詞就違規」的比對**。它只做一件事:
**認出「出處」在不在**——而且是**往放行的方向**做字面比對
(認出來 ⇒ 放行;認不出來 ⇒ 交給閘去看別的結構訊號)。
這是 `pr-verdict-guard.sh` ③ 用過的形狀:識別碼有限且唯一,
比對錯了只會**少擋一次**,不會多罰一次。
━━ 什麼算「出處」(七種,全部是可以走過去看的東西)━━━━━━━━━━━━━━
檔案:行號 commit sha comment 號 票號 / 網址 /
檔案路徑(帶目錄與副檔名)/ 圍欄裡的實測輸出
共同點:**讀的人可以自己走過去確認**。這正是「轉述別人查到的東西」與
「我猜的」之間唯一機械分得出來的差別(票上驗收條件 4:有出處的轉述不能被誤擋)。
用法:
import evidence
hits = evidence.sources(text) # [(種類, 命中的字串), ...]
body = evidence.body_of(command) # 從一段 shell 指令裡把「要貼上去的內文」挖出來
"""
import os
import re
# ── 七種出處。**方向是放行**:認出來就代表這段話走得回它的來源 ──────────────
_SOURCE_PATTERNS = [
("檔案:行號", re.compile(r"[\w./-]+\.\w{1,6}:\d+")),
("commit", re.compile(r"\b[0-9a-f]{7,40}\b")),
("comment 號", re.compile(r"(?:#issuecomment-|comment\s*)\d{2,}", re.I)),
("票號", re.compile(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+#\d+")),
("網址", re.compile(r"https?://\S{4,}")),
("檔案路徑", re.compile(r"(?<![\w/])[\w.-]+/[\w./-]*\w\.\w{1,6}(?![\w])")),
("實測輸出", re.compile(r"^\s*(?:```|~~~)", re.M)),
]
# 身份欄本身不算出處——它說的是「誰寫的」,不是「這句話哪來的」。
_IDENTITY_LINE_RE = re.compile(r"^【身份】.*$", re.M)
def sources(text):
"""回 [(種類, 命中的字串)]。空清單 = 這段話裡沒有任何走得過去的出處。"""
if not text:
return []
body = _IDENTITY_LINE_RE.sub("", text)
out = []
for kind, pat in _SOURCE_PATTERNS:
m = pat.search(body)
if m:
out.append((kind, m.group(0)[:60]))
return out
def substance(text):
"""扣掉身份欄與空白之後還剩多少字。
**長度是結構訊號,不是措辭**:一句「收到,我來看」不可能是診斷;
診斷天生是一段有因果的敘述。用它把閘收窄,是為了不要天天誤擋
——`.claude/branch-holds.md` 檔頭那句:**永遠在響的警報,等於訓練人忽略這個警報。**
"""
if not text:
return 0
body = _IDENTITY_LINE_RE.sub("", text)
return len(re.sub(r"\s+", "", body))
# ── 從一段 shell 指令裡把「要貼上去的內文」挖出來 ────────────────────────────
# 做法與 comment-carries-task-guard.sh 相同:有 `-F <檔>` 就讀那個檔,
# 否則就拿指令文字本身當內文(PreToolUse 只看得到指令,這是這一層的天花板)。
# 🔴 一定要把「真正要貼上去的那段字」跟指令的鷹架分開。第一版沒分,
# 結果 `curl … https://git.uncle6.me/api/v1/…/comments -d '{"body":"<沒出處的診斷>"}'`
# **永遠放行**——因為指令裡的**那個 URL 自己就被當成出處了**。
# 同一個病 `ticket-api-bypass-guard.sh` 檔頭也記過:認錯了要看的東西,
# 閘就在驗一個跟規則無關的欄位。
_FARG_RE = re.compile(r"(?:^|\s)(?:-F|--file)\s+([^\s'\"]+)")
_BODY_FLAG_RE = re.compile(r"(?:^|\s)--body[\s=]+(['\"])(.*?)\1", re.S)
_JSON_BODY_RE = re.compile(r"""['\"]body['\"]\s*:\s*['\"](.*?)['\"]\s*[,}]""", re.S)
def body_of(command):
"""回 (內文, 來源)。來源 ∈ {"file", "--body", "json", "command"}。
依序試四種:`-F <檔>` → `--body <字串>` → payload 裡的 `body` 欄 → 整段指令。
前三種都是「這段字才是要貼上票的東西」,指令的其餘部分(網址、token、旗標)
**不算內文**——它們不是誰查來的,把它們算成出處等於這道閘自己拆自己。
"""
if not command:
return "", "command"
m = _FARG_RE.search(command)
if m:
path = m.group(1)
try:
if os.path.isfile(path):
with open(path, encoding="utf-8", errors="ignore") as f:
return f.read(), "file"
except Exception:
pass
m = _BODY_FLAG_RE.search(command)
if m:
return m.group(2), "--body"
m = _JSON_BODY_RE.search(command)
if m:
return m.group(1).replace("\\n", "\n"), "json"
return command, "command"