Files
ISEP/hooks/lib/dispatch_parse.py
Leo 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>
2026-08-27 11:35:03 +08:00

230 lines
10 KiB
Python
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 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-27inkstone/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
就這一行。要帶兩張票就兩行【工單】。**沒有第二個欄位。**
━━ 交件回覆格式(任何人 → 票)━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
【身份】subagentinkstone/ISEPfeat/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()