派工單只剩票號——閘從驗「有沒有票號」改成驗「是不是只有票號」

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>
This commit is contained in:
2026-08-27 11:35:03 +08:00
parent 8e7e265589
commit 3bc7f3c5f5
16 changed files with 1041 additions and 5 deletions
+39 -1
View File
@@ -30,6 +30,41 @@ import urllib.error
import urllib.parse
import urllib.request
# ── 身份欄:與 hooks/lib/dispatch_parse.py 共用同一份定義 ──────────────────
# leo 2026-08-27:「**subagent 回覆時要表明身份**」
# 實害(同日):多條線並行,票上的留言看不出是誰寫的,
# **總管寫的診斷被當成 subagent 的結論,而其中一則是錯的**。
# 規約與側門閘見 docs/governance/dispatch-and-reply-format.md §3。
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "hooks", "lib"))
try:
from dispatch_parse import parse_identity, IDENTITY_ROLES
except Exception: # 拿不到共用零件就不擋(fail-open)——開票的路不該被一個 import 卡死
parse_identity, IDENTITY_ROLES = None, ("總管", "subagent", "leo")
def check_identity(body, what):
"""貼進票的內文第一行要表明身份。**在打任何 API 之前就擋**,所以離線測得動。"""
if parse_identity is None:
return
ok, detail = parse_identity(body)
if ok:
return
die(f"""🚫 {what}的第一行要表明身份(leo 2026-08-27:「**subagent 回覆時要表明身份**」)
現在的問題:{detail}
第一行照這個寫(角色三選一:{''.join(IDENTITY_ROLES)}):
【身份】subagentinkstone/ISEPfeat/my-branch
【身份】總管/inkstone/InkStoneCo-
**這條管所有人,不是只管 subagent。** 總管寫在票上的東西同樣要標。
實害(2026-08-27):多條線並行時票上看不出誰寫的,
總管寫的診斷被當成 subagent 的結論,而其中一則是錯的。
規約全文:docs/governance/dispatch-and-reply-format.md §3""")
HOST = "https://git.uncle6.me"
ORG = "inkstone"
STAMP_DIR = "/tmp"
@@ -148,6 +183,7 @@ def cmd_say(argv):
die("用法:ticket say <owner/repo#N> -F <內文檔>")
owner, repo, num = parse_ref(argv[0])
body = open(argv[2]).read()
check_identity(body, "貼進票的留言")
c = api(f"/repos/{owner}/{repo}/issues/{num}/comments", {"body": body})
print(f"✅ 已貼進 {owner}/{repo}#{num}")
print(f" 定址:{owner}/{repo}#{num}#issuecomment-{c['id']}")
@@ -293,7 +329,7 @@ def cmd_close(argv):
if deliv:
api(f"/repos/{owner}/{repo}/issues/{num}/comments",
{"body": f"✅ 結案。交付物:{deliv}"})
{"body": f"{os.environ.get('ISEP_IDENTITY') or f'【身份】總管/{owner}/{repo}-'}\n\n✅ 結案。交付物:{deliv}"})
api(f"/repos/{owner}/{repo}/issues/{num}", {"state": "closed"}, method="PATCH")
print(f"✅ {owner}/{repo}#{num} 已關(交付物:{deliv or ('PR' if has_pr else '票上回報')}")
@@ -323,6 +359,8 @@ def cmd_decide(argv):
body = open(argv[2]).read()
nxt = argv[argv.index("--next") + 1] if "--next" in argv else None
check_identity(body, "裁決紀錄")
if "leo" not in body.lower() and "原話" not in body:
die("🚫 答案檔裡看不到 leo 的原話。\n"
" **裁決要記原話,不是記你的轉述**——轉述會漂,原話不會。\n"