Files
ISEP/hooks/baton-handback-guard.sh
T
Leo 436e30b9d1 交棒警察不再對剛出發的背景工人說「收工了」(inkstone/ISEP#153)
- PostToolUse:Agent 看到 launch ack(isAsync/async_launched/"Async agent launched")就不查票
- 多掛一條 Stop:主 transcript 出現 <task-notification> completed 時,用 tool-use-id
  對回原本那則 Agent tool_use、取【工單】再查三格;同一條線只點名一次(狀態檔)
- 出路改印絕對路徑:InkStoneCo/scripts/ticket 是舊複本,grep -c handback → 0
- 測試 10 → 24 條;新 14 條拿 main 的舊 hook 跑是 7 條紅
- 盤點:62 支、88 條(+1)、65 支腳本,當場數的

待總管定版(plugin.json version 未動)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 17:30:45 +08:00

246 lines
12 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.
#!/usr/bin/env bash
# 管什麼: 一條派工線收工時,檢查它的工單票有沒有把棒子交回來——指派(誰該動)+ tag(卡在哪)+ 下一步(做什麼)三格。
# 為什麼: 2026-08-26 那次交回寫在 comment 裡,票沒指派、tag 沒動 ⇒ 沒有任何欄位在說「這張票在等你」,棒子躺了 14 小時。撈一次看得到的只有欄位,不是留言。
# 誤觸時怎麼關: 這條線不對應任何票 → 派工單本來就不該少了【工單】那行(no-ticket-no-dispatch.sh 管那個);背景工人交件後同一條線只點名一次,補完三格就不再出現。
#
# baton-handback-guard.sh — 收工=把棒子交回去
# 同一件事的兩種收工時機,掛兩個事件:
# PostToolUse(Agent|Task) 前景派工:tool 回來=工人交件了 ⇒ 當場查
# Stop 背景派工:主對話收到 <task-notification> completed ⇒ 回合結束時查
#
# ── leo 2026-08-27 的原話(本閘的規格)────────────────────────────────
# 「它把事情做完後如果要檢查後續,就要把任務指定給你並改 tag,
# 你收到指定用 tag 查就知道要去做,所以**每個任務結束必須要指派回總管**,
# 要說明下一步怎麼做,**直到最後交出正確 deliverable 並且驗證**」
#
# ── 三格各自回答一個問題(leo:「這些全部都要」)─────────────────────
# 指派 assignee 現在誰該動? 缺了它 → 大家都以為是別人的事
# tag s/* 它卡在哪一段? 缺了它 → 撈得到票,但要重讀整串
# 下一步(留言) 收下的人要做什麼? 缺了它 → 撿起棒子的人得自己重建脈絡
#
# ── 背景派工為什麼改到 Stop 查(inkstone/ISEP#15309-13 兩次實撞)──────────
# PostToolUse:Agent 對背景派工是**送出後幾秒**就觸發(parallel-lines-cap-guard.sh 檔頭有實測時間線),
# 那時 tool_response 是 launch ack{"isAsync": true, "status": "async_launched", ...}
# (同一份物件寫在 transcript 的 toolUseResult2026-08-24 實跡)。
# 舊版在這一刻就查票 ⇒ 對 ISEP#150、arcrun-rag#14 各念一次「收工了但棒子沒交回來」,
# 而兩個工人之後各跑了 17 分鐘才交件。這會教總管在工人動手前去改票(假交棒),或學會忽略本閘。
#
# 真正的交件是主 transcript 裡的 <task-notification><status>completed</status>),
# 它帶著 <tool-use-id> ——**拿這個唯一識別碼回頭對上當初那一則 Agent tool_use**
# 從它的 prompt 取【工單】。判準是結構比對(哪一則派工),不是讀文字猜。
# 不掛 SubagentStopparallel-lines-cap-guard.sh 檔頭實測過它在主 session 一次都沒觸發。
#
# ── 只點名一次 ────────────────────────────────────────────────────
# Stop 回 exit 2 會讓總管多走一個回合(去補三格)。查過的 tool-use-id 記在狀態檔,之後不再查
# ⇒ 補完了自然安靜,沒補也不會鬼打牆。狀態檔在 ${ISEP_BATON_STATE_DIR:-${TMPDIR:-/tmp}}。
# 前景 PostToolUse 同 unpushed-police.sh 的定位:exit 2 讓訊息進到模型眼前,不擋任何動作。
set -uo pipefail
INPUT=$(cat)
# 訊息裡的出路一律印絕對路徑:總管的 cwd 常是 InkStoneCo,那裡的 scripts/ticket 是舊複本,
# `grep -c handback` → 02026-09-13 實查)⇒ 印相對路徑=教一條走不通的路(同 comment-carries-task-guard ⑮)。
PLUGIN_ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
TICKET="$PLUGIN_ROOT/scripts/ticket"
STATE_DIR="${ISEP_BATON_STATE_DIR:-${TMPDIR:-/tmp}}"
export ISEP_BATON_STATE_DIR="${STATE_DIR%/}"
# ── 找出這一次要查的票(每行一張:owner repo num)──────────────────────
SPECS=$(printf '%s' "$INPUT" | python3 -c '
import sys, json, re, os
TICKET = re.compile(r"([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)")
def spec_of(prompt):
for line in (prompt or "").splitlines():
if "【工單】" not in line:
continue
m = TICKET.search(line)
if m:
return "%s %s %s" % m.groups()
return None
def is_async_ack(resp):
# launch ack 兩種長相都認:結構化物件(isAsync/status),或只剩文字的 content 陣列
if isinstance(resp, dict) and (resp.get("isAsync") is True or resp.get("status") == "async_launched"):
return True
try:
return "Async agent launched" in json.dumps(resp, ensure_ascii=False)
except Exception:
return False
try:
d = json.load(sys.stdin)
except Exception:
raise SystemExit
if d.get("hook_event_name") != "Stop":
if is_async_ack(d.get("tool_response")):
raise SystemExit # 工人才剛出發,還沒有「收工」可言;交件時由 Stop 那半查
s = spec_of((d.get("tool_input") or {}).get("prompt"))
if s:
print(s)
raise SystemExit
# ── Stop:有沒有背景工人交件 ─────────────────────────────────────────
tp = d.get("transcript_path") or ""
if not tp or not os.path.isfile(tp):
raise SystemExit
sid = re.sub(r"[^A-Za-z0-9_.-]", "_", d.get("session_id") or "nosid")[:64]
state = os.path.join(os.environ["ISEP_BATON_STATE_DIR"], "baton-handback-seen-%s" % sid)
try:
seen = set(open(state, encoding="utf-8").read().split())
except OSError:
seen = set()
NOTE = re.compile(r"<tool-use-id>(toolu_[A-Za-z0-9_-]+)</tool-use-id>.*?<status>([a-z_]+)</status>", re.S)
completed = [] # 保留出現順序
dispatch = {} # tool_use id → 帶【工單】的派工單
try:
with open(tp, encoding="utf-8", errors="replace") as f:
for line in f:
if "<task-notification>" in line:
# 同一則通知會以 enqueueattachmentremove 出現多次,用 id 去重
for tuid, st in NOTE.findall(line.replace("\\n", "\n")):
if st == "completed" and tuid not in completed:
completed.append(tuid)
if "【工單】" in line and "tool_use" in line:
try:
e = json.loads(line)
except Exception:
continue
msg = e.get("message")
if not isinstance(msg, dict) or msg.get("role") != "assistant":
continue
for b in msg.get("content") or []:
if (isinstance(b, dict) and b.get("type") == "tool_use"
and b.get("name") in ("Agent", "Task")):
p = (b.get("input") or {}).get("prompt")
if isinstance(p, str) and "【工單】" in p:
dispatch[b.get("id")] = p
except OSError:
raise SystemExit
todo = [t for t in completed if t in dispatch and t not in seen]
if not todo:
raise SystemExit
try:
os.makedirs(os.path.dirname(state), exist_ok=True)
with open(state, "a", encoding="utf-8") as f:
f.write("\n".join(todo) + "\n")
except OSError:
pass # 記不下來頂多再念一次,不擋
out = []
for t in todo:
s = spec_of(dispatch[t])
if s and s not in out:
out.append(s)
print("\n".join(out))
' 2>/dev/null || true)
[ -n "${SPECS:-}" ] || exit 0
# ── 查一張票:缺哪一格就寫到 stderr 並回 2,齊了回 0 ─────────────────────
check_one(){ # $1 owner $2 repo $3 num
local OWNER="$1" REPO="$2" NUM="$3" J C REPORT BODY NOW_A NOW_L
# ── 測試縫(唯一目的:讓判斷邏輯測得起來,不必每跑一次測試就在票池留一張票)──
# BATON_GUARD_FIXTURE=<目錄> ⇒ 讀 issue.json / comments.json,不打網路。
# 正式執行時這個變數不存在,一行都不會走到。
if [ -n "${BATON_GUARD_FIXTURE:-}" ]; then
J=$(cat "$BATON_GUARD_FIXTURE/issue.json")
C=$(cat "$BATON_GUARD_FIXTURE/comments.json")
else
# token:掃已知的 checkout 找第一個帶憑證、指向本站的 remote(同 scripts/ticket 的做法)
local ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}" URL="" U dir TOKEN
for dir in "$ROOT" "$ROOT/products/$REPO" "$ROOT/matrix/$REPO" "$ROOT/polaris/$REPO"; do
[ -e "$dir/.git" ] || continue
U=$(git -C "$dir" remote -v 2>/dev/null | grep -m1 'git\.uncle6\.me' | grep '@' | awk '{print $2}') || true
[ -n "${U:-}" ] && { URL="$U"; break; }
done
[ -n "$URL" ] || return 0
TOKEN=$(printf '%s' "$URL" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
[ "$TOKEN" != "$URL" ] || return 0
J=$(curl -s --max-time 12 -H "Authorization: token $TOKEN" \
"https://git.uncle6.me/api/v1/repos/$OWNER/$REPO/issues/$NUM" 2>/dev/null) || return 0
C=$(curl -s --max-time 12 -H "Authorization: token $TOKEN" \
"https://git.uncle6.me/api/v1/repos/$OWNER/$REPO/issues/$NUM/comments?limit=100" 2>/dev/null) || C='[]'
fi
REPORT=$(python3 - "$J" "$C" <<'PY' 2>/dev/null
import json, sys
try:
it = json.loads(sys.argv[1]); cs = json.loads(sys.argv[2])
except Exception:
raise SystemExit
if not isinstance(it, dict) or "state" not in it:
raise SystemExit # 讀不到票(404/錯誤回應)⇒ 不瞎報
if it.get("state") == "closed":
raise SystemExit # 已經關了 = 棒子到終點了,沒有下一手
if not isinstance(cs, list):
cs = []
miss = []
if not (it.get("assignees") or []):
miss.append("指派:沒有人被指到這張票 ⇒ 撈 assignee 撈不到它,等於棒子躺在地上")
labs = [l["name"] for l in (it.get("labels") or [])]
st = [n for n in labs if n.startswith("s/")]
if not st:
miss.append("tag:沒有任何 s/* ⇒ 看不出它卡在哪一段")
elif st == ["s/doing"]:
miss.append("tag:還停在 s/doing,但這條線已經收工了 ⇒ 它到底做完沒有?"
"(等總管複驗=s/review/已上 stages/stage/等別的東西=s/pending")
if not any("🏃" in (c.get("body") or "") for c in cs):
miss.append("下一步:票上沒有交棒留言 ⇒ 收下棒子的人得自己重讀整串才知道要做什麼")
if not miss:
raise SystemExit
print("\n".join(" ❌ " + m for m in miss))
print("__ASSIGNEES__" + ",".join(a["login"] for a in (it.get("assignees") or [])))
print("__LABELS__" + ",".join(labs))
PY
) || return 0
[ -n "${REPORT:-}" ] || return 0
BODY=$(printf '%s' "$REPORT" | grep -v '^__')
NOW_A=$(printf '%s' "$REPORT" | sed -n 's/^__ASSIGNEES__//p')
NOW_L=$(printf '%s' "$REPORT" | sed -n 's/^__LABELS__//p')
cat >&2 <<EOF
🏃 交棒警察:$OWNER/$REPO#$NUM 這條線收工了,但**棒子沒有交回來**。
現在票上:指派=[${NOW_A:-(空)}] 標籤=[${NOW_L:-(空)}]
$BODY
【一個動作把三格補齊】
"$TICKET" handback $OWNER/$REPO#$NUM \\
--to claude-code \\
--next "<收下的人第一件事要做什麼,一句話>" \\
--label <s/reviews/stages/pendings/todo> \\
--evidence "<實測輸出或 PR 連結>"
要 leo 親手做的 → --to Leo(會自動加 Human 標籤)
已經真的做完且驗過了 → "$TICKET" close $OWNER/$REPO#$NUM --deliverable <URL>
EOF
return 2
}
HIT=0
while read -r OWNER REPO NUM; do
[ -n "${NUM:-}" ] || continue
check_one "$OWNER" "$REPO" "$NUM" </dev/null
[ $? -eq 2 ] && HIT=1
done <<< "$SPECS"
[ "$HIT" = 1 ] || exit 0
cat >&2 <<EOF
【leo 2026-08-27】「**每個任務結束必須要指派回總管**,要說明下一步怎麼做,
直到最後交出正確 deliverable 並且驗證」
🔴 **關鍵在「指派」是欄位,不是文字。** 撈一次就看得到,不必讀 comment——
leo 自己就是用 Gitea 原生的「指派給您的」在看。
【撈一次看棒子在誰手上】 "$TICKET" mine
(背景工人交件後,同一條線只點名這一次;補完三格就不會再出現。)
EOF
exit 2