Files
ISEP/hooks/report-ticket-match-guard.sh
T
Leo 7b51db3d0b feat: 收件端核對——A 的結果寫進 B 的票會被擋(inkstone/ISEP#132)
派工有票號、工人有名字(roster)、留言有【身份】三件都在派工/回覆那頭;
缺的是總管改票的那一刻,把「要改的票」跟「這個 session 派出去的那一批票」比一次
(leo 2026-09-05:「明明已經都憑 milestone 和票號了」——subagent 回報的東西被
總管當成另一件事更新)。

- hooks/dispatched-ticket-stamp.sh(PostToolUse Agent|Task):按 session 記
  「哪張票派給了哪位工人」(subagent_type,剝掉 plugin 前綴)。回報(SendMessage)不記。
- hooks/report-ticket-match-guard.sh(PreToolUse Bash):總管 say/close/handback
  (+ Gitea API 側門)一張不在那批派工票裡的票 → 擋一次,印「X 被派的是 #A」。
  判準是票號(唯一識別碼)往放行方向比對,不看措辭(leo 2026-08-17 已證偽文字層封路)。
  沒派過工的 session 整支休眠;動被派出去的那張放行;讀取/別的動詞放行;
  同一張只擋一次(重下就過);逃生口 report-match-ok。
- 測試 scripts/test-report-ticket-match-guard.sh 31/31(離線,戳記走 ISEP_STAMP_DIR)。
- 盤點:hooks 62→64、註冊 88→90、scripts 66→67(在這棵樹上實數);
  docs/hooks-inventory.md、docs/TESTING.md A43、plugin.json 描述一併更新。
  版本號待總管定(未動 plugin.json version,避開 check-version-consistency)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TGxitYq49FzYC7EFkbhzF5
2026-09-18 18:27:23 +00:00

163 lines
8.7 KiB
Bash
Executable File
Raw 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.
#!/bin/sh
# 管什麼: 這個 session 有工人被派了票,總管卻對「不在那批派工票裡」的某張票做 say/close/handback(改它的狀態)時,擋一次,並印出「X 被派的是 #A」。
# 為什麼: 2026-09-05 leo「明明已經都憑 milestone 和票號了」——subagent 回報的東西被總管當成另一件事更新。派工有票號、工人有名字、留言有【身份】三件都有了,缺的是收件端把「要改的那張票」跟「工人被派的那張票」核對一次(inkstone/ISEP#132)。
# 誤觸時怎麼關: 真的要動那張沒被派出去的票 → 重下一次一模一樣的指令就會過(同一張只擋一次);或指令裡加 report-match-ok 放行(留痕)。這個 session 沒派過任何工 ⇒ 這支閘整個休眠、一律放行。
#
# report-ticket-match-guard.sh — 收件端核對(PreToolUse: Bash
#
# ── 判準是「拿唯一識別碼往放行的方向比對」,不是關鍵字黑名單 ──────────────
# leo 2026-08-17 已證偽文字層封路(當日 8 次誤攔、0 次正確攔截)。本閘唯一的判斷是:
# **要改的那張票號,在不在「這個 session 派出去的那一批票號」裡。**
# 在 ⇒ 放行;不在 ⇒ 這一批不是你要動的那張,擋一次。全程沒有任何措辭比對。
#
# ── 只休眠時例外,其餘不誤擋靠三件事(誤攔比漏擋嚴重,ISEP mistakes 反覆記過)──
# ① 這個 session 沒派過工(`.dispatched-<sid>` 不存在/空)⇒ 沒有可混淆的對象 ⇒ 一律放行。
# ② 只認 sayclosehandback 三個動詞(+它們的 API 側門)——本閘管的是「改某張票的狀態」。
# ticket wherenewtriagecomposedecidesubtaskmineloosepickclaim 全部不碰。
# ③ 同一張沒派出去的票**只擋一次**:重下同一個指令就過(真的要動它時代價是一次重送)。
#
# ── 收件端為什麼是這一支,不是靠派工那頭 ─────────────────────────────
# 派工單只有票號、工人有名字(roster)、留言第一行【身份】(reply-identity)——三件都在派工/回覆那頭。
# 而「把 A 的結果寫進 B 的票」是**總管改票的那一刻**才發生的,攔截點就要長在那一刻
# mistakes.md:判準對了、時機錯了=白搭)。
#
# ── 側門(直接打 Gitea API)也認 ─────────────────────────────────────
# sayPOST /issues/<N>/comments、closePATCH /issues/<N> state、handbackPATCHPUTPOST。
# 只封 scripts/ticket 正門等於沒封(reply-identity-guardticket-api-bypass-guard 檔頭都記過這一課)。
# 純讀取(GET)一律放行。
#
# 🔴 不加 2>/dev/null:擋下來的訊息走 stderr,蓋掉就等於白擋。python 內部所有邏輯
# 包在 try/except 裡,未預期例外一律靜默 fail-openexit 0),不會漏出 traceback。
set -u
INPUT=$(cat 2>/dev/null || echo '{}')
HERE=$(cd "$(dirname "$0")" && pwd)
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$HERE/.." && pwd)}"
printf '%s' "$INPUT" | TICKET="$PLUGIN_ROOT/scripts/ticket" python3 -c '
import json, os, re, sys
try:
d = json.load(sys.stdin)
except Exception:
raise SystemExit(0)
try:
ti = d.get("tool_input") or {}
cmd = ti.get("command") if isinstance(ti, dict) else ""
if not isinstance(cmd, str) or not cmd.strip():
raise SystemExit(0)
# ── 逃生口(留痕,會留在指令歷史上)──────────────────────────────
if "report-match-ok" in cmd:
raise SystemExit(0)
REF = r"([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)"
def norm(o, r, n):
return "%s/%s#%s" % (o, r, int(n))
# ── 找出「這條指令要動的那張票」+它是不是 say/close/handback 這類改狀態的動作 ──
acted = None
# 「ticket」前面要是邊界(行首/空白/; & |/路徑分隔的 /),這樣 `scripts/ticket say`
# 認得到,而 `myticket say` 這種黏在字尾的不會被誤認。
m = re.search(r"(?:^|[\s;&|/])ticket\s+(say|close|handback)\s+" + REF, cmd)
if m:
acted = norm(m.group(2), m.group(3), m.group(4))
else:
# 側門:直接打 Gitea API 改「某一張票」。API 路徑是 /repos/<o>/<r>/issues/<N>
# (沒有 #,跟票號寫法 owner/repo#N 不同)。只在帶寫入訊號時算數,純讀(GET)放行。
im = re.search(
r"/repos/([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)/issues/(\d+)(?![0-9])", cmd)
if im:
write = re.search(
r"-X\s*(?:POST|PUT|PATCH|DELETE)"
r"|--request\s*(?:POST|PUT|PATCH|DELETE)"
r"|\brequests\.(?:post|put|patch|delete)\b"
r"|method\s*=\s*[\x22\x27](?:POST|PUT|PATCH|DELETE)"
r"|\b(?:POST|PUT|PATCH|DELETE)\b",
cmd)
if write:
acted = norm(im.group(1), im.group(2), im.group(3))
if not acted:
raise SystemExit(0)
# ── 讀「這個 session 派出去的那一批票」───────────────────────────
sid = re.sub(r"[^A-Za-z0-9_.-]", "_", str(d.get("session_id") or "nosid"))[:64]
stamp_dir = os.environ.get("ISEP_STAMP_DIR", "/tmp")
path = os.path.join(stamp_dir, ".dispatched-%s" % sid)
pairs = [] # [(worker, ref)]
try:
with open(path, encoding="utf-8") as f:
for line in f:
line = line.rstrip("\n")
if "\t" not in line:
continue
w, ref = line.split("\t", 1)
if ref.strip():
pairs.append((w.strip(), ref.strip()))
except OSError:
pairs = []
# ① 這個 session 沒派過任何工 ⇒ 休眠,一律放行(沒有可混淆的對象)。
if not pairs:
raise SystemExit(0)
dispatched = {ref for _, ref in pairs}
# ② 要動的票**就是**派出去的其中一張 ⇒ 放行(唯一識別碼往放行方向比對的那一步)。
if acted in dispatched:
raise SystemExit(0)
# ── 不在那一批裡:擋一次 ─────────────────────────────────────
ao, ar, an = re.match(REF, acted).groups()
warned = os.path.join(stamp_dir,
".report-match-warned-%s-%s_%s_%s" % (sid, ao, ar, an))
if os.path.exists(warned):
raise SystemExit(0) # 同一張只擋一次,重送就過
try:
with open(warned, "w", encoding="utf-8") as f:
f.write(acted)
except OSError:
pass
ticket = os.environ.get("TICKET", "scripts/ticket")
lines = [" %s 被派的是 %s" % (w, ref) for w, ref in pairs]
vm = re.search(r"ticket\s+(say|close|handback)\b", cmd)
verb = vm.group(1) if vm else "say"
msg = []
msg.append("\U0001F6AB 這張票不在「這個 session 派出去的那一批」裡"
"——你可能把某位工人的結果寫錯了票。")
msg.append("")
msg.append(" 你要改狀態的是:%s" % acted)
msg.append(" 這個 session 派出去的是:")
msg.extend(lines)
msg.append("")
msg.append("【為什麼擋這一下(leo 2026-09-05inkstone/ISEP#132)】")
msg.append(" 「明明已經都憑 milestone 和票號了」——subagent 回報的東西被總管當成另一件事更新。")
msg.append(" 派工有票號、工人有名字、留言有【身份】三件都有了,缺的就是這一格:")
msg.append(" **改票之前,把「要改的票」跟「那位工人被派的票」核對一次。**")
msg.append("")
msg.append("【怎麼走】")
msg.append(" · 你其實是要記某位工人的結果 → 改成對他被派的那張票動作,例如:")
msg.append(" %s %s <上面那個工人被派的票號> …" % (ticket, verb))
msg.append(" · 這張 %s 真的是另一件事、就是要動它 → **重下一次一模一樣的指令就會過**" % acted)
msg.append(" (同一張只擋一次,不必多打任何字;判準是票號比對,不看你寫了什麼理由)。")
msg.append(" · 真的有例外 → 指令裡加 report-match-ok 重送(會留在指令歷史上)。")
sys.stderr.write("\n".join(msg) + "\n")
raise SystemExit(2)
except SystemExit:
raise
except Exception:
raise SystemExit(0) # fail-open:內部壞掉不該把總管卡死
'
# 沒有 set -epipeline 回 2 不會中斷腳本;$? 就是 python 的離開碼(sh 取最後一段)。
rc=$?
[ "$rc" = 2 ] && exit 2
exit 0