7b51db3d0b
派工有票號、工人有名字(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
163 lines
8.7 KiB
Bash
Executable File
163 lines
8.7 KiB
Bash
Executable File
#!/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>` 不存在/空)⇒ 沒有可混淆的對象 ⇒ 一律放行。
|
||
# ② 只認 say/close/handback 三個動詞(+它們的 API 側門)——本閘管的是「改某張票的狀態」。
|
||
# ticket where/new/triage/compose/decide/subtask/mine/loose/pick/claim 全部不碰。
|
||
# ③ 同一張沒派出去的票**只擋一次**:重下同一個指令就過(真的要動它時代價是一次重送)。
|
||
#
|
||
# ── 收件端為什麼是這一支,不是靠派工那頭 ─────────────────────────────
|
||
# 派工單只有票號、工人有名字(roster)、留言第一行【身份】(reply-identity)——三件都在派工/回覆那頭。
|
||
# 而「把 A 的結果寫進 B 的票」是**總管改票的那一刻**才發生的,攔截點就要長在那一刻
|
||
# (mistakes.md:判準對了、時機錯了=白搭)。
|
||
#
|
||
# ── 側門(直接打 Gitea API)也認 ─────────────────────────────────────
|
||
# say=POST /issues/<N>/comments、close=PATCH /issues/<N> state、handback=PATCH+PUT+POST。
|
||
# 只封 scripts/ticket 正門等於沒封(reply-identity-guard/ticket-api-bypass-guard 檔頭都記過這一課)。
|
||
# 純讀取(GET)一律放行。
|
||
#
|
||
# 🔴 不加 2>/dev/null:擋下來的訊息走 stderr,蓋掉就等於白擋。python 內部所有邏輯
|
||
# 包在 try/except 裡,未預期例外一律靜默 fail-open(exit 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-05,inkstone/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 -e,pipeline 回 2 不會中斷腳本;$? 就是 python 的離開碼(sh 取最後一段)。
|
||
rc=$?
|
||
[ "$rc" = 2 ] && exit 2
|
||
exit 0
|