#!/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 背景派工:主對話收到 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 裡的 completed), # 它帶著 ——**拿這個唯一識別碼回頭對上當初那一則 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"(toolu_[A-Za-z0-9_-]+).*?([a-z_]+)", re.S) completed = [] # 保留出現順序 dispatch = {} # tool_use id → 帶【工單】的派工單 try: with open(tp, encoding="utf-8", errors="replace") as f: for line in f: if "" 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 <" \\ --label \\ --evidence "<實測輸出或 PR 連結>" 要 leo 親手做的 → --to Leo(會自動加 Human 標籤) 已經真的做完且驗過了 → "$TICKET" close $OWNER/$REPO#$NUM --deliverable EOF return 2 } HIT=0 while read -r OWNER REPO NUM; do [ -n "${NUM:-}" ] || continue check_one "$OWNER" "$REPO" "$NUM" &2 <