3bc7f3c5f5
leo 2026-08-27(inkstone/ISEP#30 comment 4322/4325/4327): 「這些話票上都沒有,你根本沒照規則做事,你的 hook 讓你這樣搞?」 「你用一個 output parser 把你給 subagent 的指令規範,分作幾點,每一點規定格式, 照這種散文寫法根本無法迭代」「警察也不能抓」 「交件方式不需要寫,定義在原則裡⋯⋯每次都一樣提取出來變成共通規定」 「(那些 session 事實)這些為什麼不寫到票裡?」「subagent 回覆時要表明身份」 病根:no-ticket-no-dispatch.sh 驗的是「有沒有一行【工單】owner/repo#N」, 而規則的原文是「派工單只寫票號」。⇒ 把 40 行任務全寫在 prompt 裡、票號補一行, 閘照樣放行。2026-08-27 一天內這樣做了 5 次,每次票上都沒有那份任務。 規則存在,閘只驗了它的殼——同款第 N 次(history-first/KBDB-first/stage-first)。 新增 hooks/dispatch-format-guard.sh(PreToolUse Task|Agent),兩件事: - 擋:【工單】以外還有實質內容就 exit 2,並指出那些內容該搬去哪 (每次都一樣 → 共通規定;這次才知道 → 寫進那張票。 判準「這句話換一張票還成立嗎?」) - 注入:合規的派工自動把共通規定送給收工方(交件方式、不准 push main、 org 是 inkstone…)——這是「派工單只剩票號」能成立的前提, leo 的驗收條件之一就是「收工方沒讀派工單也知道要貼回原票」 判準是結構不是文字(leo 2026-08-17 那條檢驗):問的是「這一行是不是【工單】欄位」 ——在不在,不是寫什麼。hooks/lib/dispatch_parse.py 全檔零個「命中某個詞就違規」的比對。 ⇒ 也因此不需要語意判官:免費、瞬間、每次結果一樣。 新增 hooks/reply-identity-guard.sh(PreToolUse Bash)+ scripts/ticket 內建檢查: 票上每一則留言第一行要有【身份】(總管/subagent/leo)。貼留言有兩條路,兩條都封 ——ticket-api-bypass-guard 是刻意放行「對既有票留言」的,只封正門等於沒封。 實害:多條線並行時總管寫的診斷被當成 subagent 的結論,而其中一則是錯的。 規約寫成文件:docs/governance/dispatch-and-reply-format.md (§2 那段就是被注入的那份共通規定本體——只有一份,改那裡等於改所有派工) 實測(離線、不打網路、不花錢): hooks/tests/dispatch-format-guard.test.sh 19/19 hooks/tests/reply-identity.test.sh 11/11 測資裡的 B⑨ 是真跡:產生 ISEP#30 這條線的那一次派工,一字未改。 另 4 份 leo 點名的違規派工拿不回來了——它們住在 prompt 裡,agent 一停就沒了, 這件事本身就是這條規則的證據(見 hooks/tests/fixtures/README.md,不用想像的例子替補)。 升版 0.4.0 → 0.5.0(產物按版本號分資料夾,不升版新閘不會被載入)。 tag 照慣例打在 merge commit 上,所以這條分支上 check-version-consistency.sh 是紅的。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
230 lines
10 KiB
Python
230 lines
10 KiB
Python
#!/usr/bin/env python3
|
||
"""hooks/lib/dispatch_parse.py — 派工單與交件回覆的**結構解析器**(不是閘)。
|
||
|
||
依 inkstone/ISEP#40 S7 的慣例:`lib/` 裡的東西是共用零件,本身不決定 allow/block,
|
||
判決由呼叫它的閘做(`hooks/dispatch-format-guard.sh`、`hooks/reply-identity-guard.sh`、
|
||
`scripts/ticket`)。
|
||
|
||
━━ 規則本身(leo 2026-08-27,inkstone/ISEP#30 comment 4322 / 4325 / 4327)━━
|
||
|
||
派工單 = 【工單】owner/repo#N。就這樣。
|
||
|
||
三則合起來是一條規則:
|
||
4322 任務要在票上,不是在 prompt 裡 —— 而閘只驗了票號這個殼
|
||
4325 「你用一個 **output parser** 把你給 subagent 的指令規範,分作幾點,
|
||
每一點規定格式,**照這種散文寫法根本無法迭代**」「**警察也不能抓**」
|
||
4327 「**交件方式不需要寫,定義在原則裡**⋯⋯每次都一樣提取出來變成共通規定」
|
||
「(那些 session 事實)**為什麼不寫到票裡?**」
|
||
|
||
⇒ 派工單裡的東西只有兩種,**兩種都不該留在派工單**:
|
||
|
||
每次都一樣 交件方式、不要 push main、org 是 inkstone、先讀該 repo 的 CLAUDE.md
|
||
=> 共通規定(由閘自動注入,見 dispatch-format-guard.sh)
|
||
這次才知道 main 是哪顆 commit、今天撞過什麼、另一條線正在動什麼
|
||
=> **寫進那張票**(「票上還沒有」是「去把它寫上票」的指令,
|
||
不是「把它寫進 prompt」的理由)
|
||
|
||
判準一句話:**「這句話換一張票還成立嗎?」**
|
||
還成立 => 共通規定;只有這次成立 => 寫進這張票。兩種都不進派工單。
|
||
|
||
為什麼連「這次才知道的事」也不能留在 prompt(4327 實害):
|
||
那個 agent 被停掉/換人接手,那段事實就隨 prompt 消失。
|
||
2026-08-27 總管停掉重派 3 次,前兩次的任務與 session 事實全部蒸發。
|
||
|
||
━━ 為什麼這支過得了「封動作不封文字」的檢驗(leo 2026-08-17)━━━━━━━━
|
||
「你在**文字層**封路⋯⋯**自然語言的變體是無限的,blacklist 永遠追不完。**
|
||
封路哲學之所以有效,是因為它封的是**動作**——動作有限且可枚舉,文字不是。」
|
||
當日實證:文字層的閘 8 次誤攔、0 次正確攔截,**紅線寫得越細,命中關鍵字的機率越高
|
||
⇒ 那些閘在懲罰謹慎**。
|
||
|
||
本檔**全檔零個「命中某個詞就違規」的比對**。判準是「這一行是不是【工單】欄位」——
|
||
**在不在**,不是**寫什麼**。用到的正則只有兩種,都在認形狀:
|
||
- `【某某】` 欄位標記本身
|
||
- `owner/repo#N` 票號形狀
|
||
措辭多謹慎、有沒有提到「紅線」「部署」「main」,本檔一律不看。
|
||
⇒ 也因此它不會像文字層的閘那樣「寫得越小心越容易被罰」。
|
||
|
||
━━ 派工單格式 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
【工單】inkstone/ISEP#30 → comment 4322
|
||
|
||
就這一行。要帶兩張票就兩行【工單】。**沒有第二個欄位。**
|
||
|
||
━━ 交件回覆格式(任何人 → 票)━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
【身份】subagent/inkstone/ISEP/feat/ticket-carries-the-task
|
||
|
||
第一行就要認得出是誰寫的。角色是三選一的允許清單:`總管`/`subagent`/`leo`。
|
||
2026-08-27 實害:總管寫的診斷被當成 subagent 的結論,而其中一則是錯的。
|
||
|
||
用法:
|
||
import dispatch_parse
|
||
r = dispatch_parse.parse_dispatch(prompt_text)
|
||
ok, detail = dispatch_parse.parse_identity(comment_body)
|
||
|
||
CLI:吃 hook 的 JSON,吐一份事實 JSON
|
||
printf '%s' "$INPUT" | python3 hooks/lib/dispatch_parse.py dispatch
|
||
"""
|
||
import json
|
||
import re
|
||
import sys
|
||
|
||
# ── 派工單的字彙表:**只有一個欄位**。這是允許清單,不是黑名單 ──────────────
|
||
DISPATCH_ALLOWED = ("工單",)
|
||
|
||
# 曾經合法、現已收回的欄位。**不參與判決**(判決是「不是【工單】就是多的」),
|
||
# 只用來讓訊息講得出「你這一欄的內容該搬去哪」。
|
||
RETIRED_FIELDS = {
|
||
"人格": "共通規定(收工方從票的 repo 就知道自己戴誰的人格)",
|
||
"交件": "共通規定(每張票都一樣:貼回原票)",
|
||
"就地": "**寫進那張票**(這正是「票上還沒有」的解法)",
|
||
"身份": "那是**交件回覆**的欄位,不是派工單的",
|
||
}
|
||
|
||
IDENTITY_ROLES = ("總管", "subagent", "leo")
|
||
|
||
_MARKER_RE = re.compile(r"^【([^】]{1,12})】\s*(.*)$")
|
||
_REF_RE = re.compile(r"^([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)\s*$")
|
||
_COMMENT_RE = re.compile(r"(?:#issuecomment-|→\s*comment\s*)(\d+)")
|
||
_FENCE_RE = re.compile(r"^\s*(```|~~~)")
|
||
|
||
|
||
def _split_lines(text):
|
||
"""切成 (是否在圍欄內, 原始行)。
|
||
|
||
圍欄(``` / ~~~)裡的東西是**資料**不是欄位標記。同一個道理已經在
|
||
hooks/lib/strip_heredoc.py 落地過(heredoc body 是資料不是指令,
|
||
inkstone/InkStoneCo#23、#56 的根因就是把資料當指令掃)。
|
||
這裡的效果是:貼一段指令進派工單,裡面的【】不會被誤認成欄位——
|
||
但整段圍欄本身仍然算「多出來的內容」,照樣會被擋。
|
||
"""
|
||
out, in_fence = [], False
|
||
for line in text.split("\n"):
|
||
if _FENCE_RE.match(line):
|
||
out.append((in_fence, line)) # 圍欄那一行歸屬於「切換前」的狀態
|
||
in_fence = not in_fence
|
||
continue
|
||
out.append((in_fence, line))
|
||
return out
|
||
|
||
|
||
def parse_dispatch(prompt):
|
||
"""把派工單拆成「【工單】欄位」與「其餘一切」。**只回事實,不下判決**。
|
||
|
||
回傳 dict:
|
||
has_ticket_marker 有沒有出現過【工單】——沒有就不是本格式的東西
|
||
refs 解析成功的票號清單 [{'owner','repo','num','comment'}]
|
||
bad_ticket_lines 有【工單】標記但形狀不對的那幾行
|
||
extra 多出來的實質內容 [(來源欄位或 None, 原始行)]
|
||
retired_seen 出現過的、已收回的欄位名(給訊息用)
|
||
"""
|
||
fields_seen, refs, bad_ticket_lines = [], [], []
|
||
extra, retired_seen = [], []
|
||
cur = None
|
||
|
||
for in_fence, raw in _split_lines(prompt or ""):
|
||
if not in_fence:
|
||
m = _MARKER_RE.match(raw)
|
||
if m:
|
||
name, rest = m.group(1), m.group(2)
|
||
cur = name
|
||
fields_seen.append(name)
|
||
if name in RETIRED_FIELDS and name not in retired_seen:
|
||
retired_seen.append(name)
|
||
if name in DISPATCH_ALLOWED:
|
||
text = rest.strip()
|
||
head = _COMMENT_RE.sub("", text).strip()
|
||
rm = _REF_RE.match(head)
|
||
if rm:
|
||
cm = _COMMENT_RE.search(text)
|
||
refs.append({"owner": rm.group(1), "repo": rm.group(2),
|
||
"num": int(rm.group(3)),
|
||
"comment": int(cm.group(1)) if cm else None})
|
||
else:
|
||
bad_ticket_lines.append(raw.strip())
|
||
else:
|
||
if rest.strip():
|
||
extra.append((name, raw))
|
||
continue
|
||
if raw.strip():
|
||
# 【工單】欄位本身只准佔它自己那一行;它下面的續行也是多的。
|
||
extra.append((cur, raw))
|
||
|
||
return {
|
||
"has_ticket_marker": "工單" in fields_seen,
|
||
"refs": refs,
|
||
"bad_ticket_lines": bad_ticket_lines,
|
||
"extra": extra,
|
||
"extra_lines": len(extra),
|
||
"extra_chars": sum(len(r.strip()) for _, r in extra),
|
||
"retired_seen": retired_seen,
|
||
"fields_seen": fields_seen,
|
||
}
|
||
|
||
|
||
def dispatch_violations(parsed):
|
||
"""換算成違規清單。每一條都是**結構**事實,可以單獨迭代。"""
|
||
v = []
|
||
for line in parsed["bad_ticket_lines"]:
|
||
v.append(("票號形狀不對",
|
||
"「%s」不是 owner/repo#N(可加 → comment M);org 是 inkstone 不是 Leo"
|
||
% line[:60]))
|
||
if parsed["extra"]:
|
||
first = parsed["extra"][0][1].strip()
|
||
v.append(("派工單不只有票號",
|
||
"【工單】以外還有 %d 行/%d 字,第一行是:%s"
|
||
% (parsed["extra_lines"], parsed["extra_chars"], first[:50])))
|
||
return v
|
||
|
||
|
||
def parse_identity(body):
|
||
"""交件回覆的【身份】欄。回 (ok, detail)。"""
|
||
for raw in (body or "").split("\n"):
|
||
if not raw.strip():
|
||
continue
|
||
m = _MARKER_RE.match(raw)
|
||
if not m or m.group(1) != "身份":
|
||
return False, "第一行不是【身份】"
|
||
val = m.group(2).strip()
|
||
if not val:
|
||
return False, "【身份】後面是空的"
|
||
role = re.split(r"[//]", val)[0].strip()
|
||
if role not in IDENTITY_ROLES:
|
||
return False, "角色「%s」不在 %s 之內" % (role or "(空)", "/".join(IDENTITY_ROLES))
|
||
return True, val
|
||
return False, "內文是空的"
|
||
|
||
|
||
# ── CLI ──────────────────────────────────────────────────────────────────
|
||
def _main():
|
||
mode = sys.argv[1] if len(sys.argv) > 1 else "dispatch"
|
||
try:
|
||
payload = json.load(sys.stdin)
|
||
except Exception:
|
||
print(json.dumps({"status": "skip", "why": "payload 不是 JSON"}))
|
||
return
|
||
|
||
if mode != "dispatch":
|
||
print(json.dumps({"status": "skip", "why": "未知模式 %s" % mode}))
|
||
return
|
||
|
||
prompt = (payload.get("tool_input") or {}).get("prompt") or ""
|
||
parsed = parse_dispatch(prompt)
|
||
|
||
# 連【工單】都沒有 => 那是 no-ticket-no-dispatch.sh 的地盤,本閘閉嘴。
|
||
# 兩支閘同時對同一件事開口,收工方會拿到兩份互相打架的教學。
|
||
if not parsed["has_ticket_marker"]:
|
||
print(json.dumps({"status": "skip", "why": "沒有【工單】,交給 no-ticket-no-dispatch"}))
|
||
return
|
||
|
||
print(json.dumps({
|
||
"status": "ok",
|
||
"violations": [{"code": c, "detail": d} for c, d in dispatch_violations(parsed)],
|
||
"refs": parsed["refs"],
|
||
"retired_seen": parsed["retired_seen"],
|
||
"extra_lines": parsed["extra_lines"],
|
||
"session_id": payload.get("session_id") or "",
|
||
}, ensure_ascii=False))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
_main()
|