5ac06abc95
三張票同一族(誰在派、派給誰、派的內容住哪裡),做在同一條分支: #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
124 lines
6.1 KiB
Python
124 lines
6.1 KiB
Python
#!/usr/bin/env python3
|
||
"""hooks/lib/evidence.py — 「這段要寫上票的內容,帶不帶得出出處?」(inkstone/ISEP#87)
|
||
|
||
依 inkstone/ISEP#40 S7 的慣例:`lib/` 裡的東西是共用零件,本身不決定 allow/block,
|
||
判決由呼叫它的閘做(`hooks/diagnosis-evidence-guard.sh`)。
|
||
|
||
━━ 規則本身(leo,inkstone/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"
|