交棒警察不再對剛出發的背景工人說「收工了」(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>
This commit is contained in:
2026-09-13 17:30:45 +08:00
parent 916a6890ff
commit 436e30b9d1
7 changed files with 309 additions and 79 deletions
+174 -65
View File
@@ -1,9 +1,12 @@
#!/usr/bin/env bash
# 管什麼: 一條派工線收工時,檢查它的工單票有沒有把棒子交回來——指派(誰該動)+ tag(卡在哪)+ 下一步(做什麼)三格。
# 為什麼: 2026-08-26 那次交回寫在 comment 裡,票沒指派、tag 沒動 ⇒ 沒有任何欄位在說「這張票在等你」,棒子躺了 14 小時。撈一次看得到的只有欄位,不是留言。
# 誤觸時怎麼關: 這條線不對應任何票 → 派工單本來就不該少了【工單】那行(no-ticket-no-dispatch.sh 管那個);真要放行在指令裡留 `baton-ok: <理由>`(本閘只提醒不阻擋,不會擋住任何動作)
# 誤觸時怎麼關: 這條線不對應任何票 → 派工單本來就不該少了【工單】那行(no-ticket-no-dispatch.sh 管那個);背景工人交件後同一條線只點名一次,補完三格就不再出現
#
# baton-handback-guard.sh — 收工=把棒子交回去PostToolUse: Agent|Task
# baton-handback-guard.sh — 收工=把棒子交回去
# 同一件事的兩種收工時機,掛兩個事件:
# PostToolUse(Agent|Task) 前景派工:tool 回來=工人交件了 ⇒ 當場查
# Stop 背景派工:主對話收到 <task-notification> completed ⇒ 回合結束時查
#
# ── leo 2026-08-27 的原話(本閘的規格)────────────────────────────────
# 「它把事情做完後如果要檢查後續,就要把任務指定給你並改 tag,
@@ -15,73 +18,165 @@
# tag s/* 它卡在哪一段? 缺了它 → 撈得到票,但要重讀整串
# 下一步(留言) 收下的人要做什麼? 缺了它 → 撿起棒子的人得自己重建脈絡
#
# ── 為什麼掛在 PostToolUse(Agent) 而不是 SubagentStop ──────────────
# 工單票號寫在**派工單**裡(`【工單】owner/repo#N`),而派工單就是 Agent 的 tool_input。
# SubagentStop 拿到的 transcript 是主對話,票號要繞一圈才找得回來
# 見 subagent-claim-worksheet.sh 檔頭那段實撞紀錄)。
# 同一個位置 issue-status-autoflip.sh 已經在用,形狀是驗過的。
# ── 背景派工為什麼改到 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 分鐘才交件。這會教總管在工人動手前去改票(假交棒),或學會忽略本閘。
#
# ── 只提醒,不阻擋 ────────────────────────────────────────────────
# 工作已經做完了,擋在這裡沒有任何東西可以被挽救;能做的是**讓總管在當下就看到缺哪一格**
# 同 unpushed-police.sh 的定位:exit 2 讓訊息進到模型眼前,不是 blocker
# 真正的交件是主 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
SPEC=$(printf '%s' "$INPUT" | python3 -c '
import sys, json, re
try:
d = json.load(sys.stdin)
except Exception:
raise SystemExit
p = (d.get("tool_input") or {}).get("prompt") or ""
for line in p.splitlines():
if "【工單】" not in line:
continue
m = re.search(r"([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)", line)
if m:
print(f"{m.group(1)} {m.group(2)} {m.group(3)}")
break
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 "${SPEC:-}" ] || exit 0
# shellcheck disable=SC2086
set -- $SPEC
OWNER="$1"; REPO="$2"; NUM="$3"
[ -n "${SPECS:-}" ] || exit 0
# ── 測試縫(唯一目的:讓判斷邏輯測得起來,不必每跑一次測試就在票池留一張票)──
# 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 的做法)
ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
URL=""
for d in "$ROOT" "$ROOT/products/$REPO" "$ROOT/matrix/$REPO" "$ROOT/polaris/$REPO"; do
[ -e "$d/.git" ] || continue
U=$(git -C "$d" remote -v 2>/dev/null | grep -m1 'git\.uncle6\.me' | grep '@' | awk '{print $2}') || true
[ -n "${U:-}" ] && { URL="$U"; break; }
done
[ -n "$URL" ] || exit 0
TOKEN=$(printf '%s' "$URL" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
[ "$TOKEN" != "$URL" ] || 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
J=$(curl -s --max-time 12 -H "Authorization: token $TOKEN" \
"https://git.uncle6.me/api/v1/repos/$OWNER/$REPO/issues/$NUM" 2>/dev/null) || exit 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
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 撈不到它,等於棒子躺在地上")
@@ -100,37 +195,51 @@ 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
) || exit 0
) || return 0
[ -n "${REPORT:-}" ] || return 0
[ -n "${REPORT:-}" ] || exit 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')
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
cat >&2 <<EOF
🏃 交棒警察:$OWNER/$REPO#$NUM 這條線收工了,但**棒子沒有交回來**。
現在票上:指派=[${NOW_A:-(空)}] 標籤=[${NOW_L:-(空)}]
$BODY
【leo 2026-08-27】「**每個任務結束必須要指派回總管**,要說明下一步怎麼做,
直到最後交出正確 deliverable 並且驗證」
🔴 **關鍵在「指派」是欄位,不是文字。** 撈一次就看得到,不必讀 comment——
leo 自己就是用 Gitea 原生的「指派給您的」在看。
【一個動作把三格補齊】
scripts/ticket handback $OWNER/$REPO#$NUM \\
"$TICKET" handback $OWNER/$REPO#$NUM \\
--to claude-code \\
--next "<收下的人第一件事要做什麼,一句話>" \\
--label <s/reviews/stages/pendings/todo> \\
--evidence "<實測輸出或 PR 連結>"
要 leo 親手做的 → --to Leo(會自動加 Human 標籤)
已經真的做完且驗過了 → scripts/ticket close $OWNER/$REPO#$NUM --deliverable <URL>
已經真的做完且驗過了 → "$TICKET" close $OWNER/$REPO#$NUM --deliverable <URL>
【撈一次看棒子在誰手上】 scripts/ticket mine
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
+9
View File
@@ -377,6 +377,15 @@
"timeout": 180
}
]
},
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/baton-handback-guard.sh",
"timeout": 60
}
]
}
],
"SubagentStop": [