436e30b9d1
- 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>
246 lines
12 KiB
Bash
Executable File
246 lines
12 KiB
Bash
Executable File
#!/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#153,09-13 兩次實撞)──────────
|
||
# PostToolUse:Agent 對背景派工是**送出後幾秒**就觸發(parallel-lines-cap-guard.sh 檔頭有實測時間線),
|
||
# 那時 tool_response 是 launch ack:{"isAsync": true, "status": "async_launched", ...}
|
||
# (同一份物件寫在 transcript 的 toolUseResult,2026-08-24 實跡)。
|
||
# 舊版在這一刻就查票 ⇒ 對 ISEP#150、arcrun-rag#14 各念一次「收工了但棒子沒交回來」,
|
||
# 而兩個工人之後各跑了 17 分鐘才交件。這會教總管在工人動手前去改票(假交棒),或學會忽略本閘。
|
||
#
|
||
# 真正的交件是主 transcript 裡的 <task-notification>(<status>completed</status>),
|
||
# 它帶著 <tool-use-id> ——**拿這個唯一識別碼回頭對上當初那一則 Agent tool_use**,
|
||
# 從它的 prompt 取【工單】。判準是結構比對(哪一則派工),不是讀文字猜。
|
||
# 不掛 SubagentStop:parallel-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` → 0(2026-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:
|
||
# 同一則通知會以 enqueue/attachment/remove 出現多次,用 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/已上 stage=s/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/review|s/stage|s/pending|s/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
|