棒子不會掉:子票相依+tag+指派三格,全部用 Gitea 原生欄位
leo 2026-08-27:「子票相依是 gitea 原有機制,改 tag 和指定也是,這些全部都要」 08-26 掉的那一次(arcrun-rag#136 comment 4267「等雲端那半出貨才驗得了」躺了 14 小時)三格全缺:沒有子票、tag 沒動、沒有指派給任何人。根因不是誰忘了, 是那件事沒有落在任何一個「撈一次就看得到」的欄位上。 實測(Gitea 1.26.4):子票還開著時關母票 → HTTP 412 "cannot close this issue or pull request because it still has open dependencies" ⇒ 相依是平台保證的硬擋,不是提醒。跨 repo 也成立(201)。 - scripts/ticket 新增三個動詞:subtask(長子票+掛相依)/ handback(指派+改 tag+寫下一步,一個動作)/mine(撈一次看棒子在誰手上) - hooks/comment-carries-task-guard.sh:留言帶未完成的未來式卻沒開子票 → 擋一次 - hooks/baton-handback-guard.sh:一條線收工,三格缺哪一格當場說出來(提醒不擋) - docs/governance §16:三個維度/粒度(傾向多開票)/既有票只從今天起適用/ 與 s/* 的關係/實測輸出 - 測試 20+10 全綠,用 fixture 跑不打網路、不在票池留測試票 📌 踩到並記進閘的檔頭:hook 裡比對中文一律用 python3 的 re,不要用 grep 的字元類 (等[^。]{0,20}出貨 在真實 hook 呼叫路徑下對「等雲端那半出貨」不匹配,閘靜默失效) 票:inkstone/ISEP#30(comment 4334/4335/4346/4347) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Executable
+136
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env bash
|
||||
# 管什麼: 一條派工線收工時,檢查它的工單票有沒有把棒子交回來——指派(誰該動)+ tag(卡在哪)+ 下一步(做什麼)三格。
|
||||
# 為什麼: 2026-08-26 那次交回寫在 comment 裡,票沒指派、tag 沒動 ⇒ 沒有任何欄位在說「這張票在等你」,棒子躺了 14 小時。撈一次看得到的只有欄位,不是留言。
|
||||
# 誤觸時怎麼關: 這條線不對應任何票 → 派工單本來就不該少了【工單】那行(no-ticket-no-dispatch.sh 管那個);真要放行在指令裡留 `baton-ok: <理由>`(本閘只提醒不阻擋,不會擋住任何動作)。
|
||||
#
|
||||
# baton-handback-guard.sh — 收工=把棒子交回去(PostToolUse: Agent|Task)
|
||||
#
|
||||
# ── leo 2026-08-27 的原話(本閘的規格)────────────────────────────────
|
||||
# 「它把事情做完後如果要檢查後續,就要把任務指定給你並改 tag,
|
||||
# 你收到指定用 tag 查就知道要去做,所以**每個任務結束必須要指派回總管**,
|
||||
# 要說明下一步怎麼做,**直到最後交出正確 deliverable 並且驗證**」
|
||||
#
|
||||
# ── 三格各自回答一個問題(leo:「這些全部都要」)─────────────────────
|
||||
# 指派 assignee 現在誰該動? 缺了它 → 大家都以為是別人的事
|
||||
# tag s/* 它卡在哪一段? 缺了它 → 撈得到票,但要重讀整串
|
||||
# 下一步(留言) 收下的人要做什麼? 缺了它 → 撿起棒子的人得自己重建脈絡
|
||||
#
|
||||
# ── 為什麼掛在 PostToolUse(Agent) 而不是 SubagentStop ──────────────
|
||||
# 工單票號寫在**派工單**裡(`【工單】owner/repo#N`),而派工單就是 Agent 的 tool_input。
|
||||
# SubagentStop 拿到的 transcript 是主對話,票號要繞一圈才找得回來
|
||||
# (見 subagent-claim-worksheet.sh 檔頭那段實撞紀錄)。
|
||||
# 同一個位置 issue-status-autoflip.sh 已經在用,形狀是驗過的。
|
||||
#
|
||||
# ── 只提醒,不阻擋 ────────────────────────────────────────────────
|
||||
# 工作已經做完了,擋在這裡沒有任何東西可以被挽救;能做的是**讓總管在當下就看到缺哪一格**。
|
||||
# 同 unpushed-police.sh 的定位:exit 2 讓訊息進到模型眼前,不是 blocker。
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
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
|
||||
' 2>/dev/null || true)
|
||||
|
||||
[ -n "${SPEC:-}" ] || exit 0
|
||||
# shellcheck disable=SC2086
|
||||
set -- $SPEC
|
||||
OWNER="$1"; REPO="$2"; NUM="$3"
|
||||
|
||||
# ── 測試縫(唯一目的:讓判斷邏輯測得起來,不必每跑一次測試就在票池留一張票)──
|
||||
# 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
|
||||
|
||||
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
|
||||
import json, sys
|
||||
try:
|
||||
it = json.loads(sys.argv[1]); cs = json.loads(sys.argv[2])
|
||||
except Exception:
|
||||
raise SystemExit
|
||||
if it.get("state") == "closed":
|
||||
raise SystemExit # 已經關了 = 棒子到終點了,沒有下一手
|
||||
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
|
||||
) || exit 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')
|
||||
|
||||
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 \\
|
||||
--to claude-code \\
|
||||
--next "<收下的人第一件事要做什麼,一句話>" \\
|
||||
--label <s/review|s/stage|s/pending|s/todo> \\
|
||||
--evidence "<實測輸出或 PR 連結>"
|
||||
|
||||
要 leo 親手做的 → --to Leo(會自動加 Human 標籤)
|
||||
已經真的做完且驗過了 → scripts/ticket close $OWNER/$REPO#$NUM --deliverable <URL>
|
||||
|
||||
【撈一次看棒子在誰手上】 scripts/ticket mine
|
||||
EOF
|
||||
exit 2
|
||||
Executable
+122
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env bash
|
||||
# 管什麼: 往 Gitea 票裡貼「留言裡藏著一件還沒做完的事」(等 X/驗不了/還沒…)卻沒有把它長成子票時,擋一次。
|
||||
# 為什麼: 2026-08-26 arcrun-rag#136 comment 4267 就是這樣掉的——「等雲端那半出貨才驗得了」只活在討論串,收端後來真的上線了,卻沒有任何東西讓人回頭把它撿起來,躺了 14 小時。
|
||||
# 誤觸時怎麼關: 這則留言真的不含待辦 → 指令裡加 `no-subtask: <一句理由>` 放行(留痕);同一 session 只擋一次。
|
||||
#
|
||||
# comment-carries-task-guard.sh — 討論串裡的任務要長成看得見的子票(PreToolUse: Bash)
|
||||
#
|
||||
# ── leo 2026-08-27 的原話(本閘的規格)────────────────────────────────
|
||||
# 「可以在討論串延伸子票,它完成了子票就完工,不然這筆任務就是沒完工,
|
||||
# 如果你沒辦法察覺,那不要把任務加在討論串,而是每個任務長出子票做為這張票的相依,
|
||||
# **票沒完工可以察覺嗎?**」
|
||||
#
|
||||
# ── 為什麼子票比「讓等待會響」更根本 ─────────────────────────────────
|
||||
# 任務寫在討論串 → 要有人回頭讀 comment 才知道它存在 → 沒人回頭讀 → 掉
|
||||
# 任務長成子票 → open/closed 是**狀態** → 撈一次就看得到 → 掉不了
|
||||
# ⇒ 不是「讓它會響」,是**讓它本來就在視線裡**。
|
||||
#
|
||||
# 而且相依是硬的:實測(2026-08-27,Gitea 1.26.4)子票還開著時關母票 →
|
||||
# HTTP 412 `cannot close this issue or pull request because it still has open dependencies`。
|
||||
#
|
||||
# ── 粒度:傾向多開票,不是少開票(leo 2026-08-27 當場更正總管)────────
|
||||
# 「**小任務做完就關閉,不會讓池子爆掉,每個事情沒有歷史記錄才是大問題**」
|
||||
# ⇒ 判準是「這件事需要被記得嗎」,不是「會不會太多」。
|
||||
# 池子爆掉的前提是「開了不關」,那由「做完就關」解決,不該由「不開票」解決。
|
||||
#
|
||||
# ── 誠實邊界 ──────────────────────────────────────────────────────
|
||||
# PreToolUse 只看得到指令文字,這是這一層的天花板(同 InkStoneCo#36)。
|
||||
# 所以關鍵詞刻意取**窄**的一組「未完成的未來式」,寧可漏抓也不要天天誤擋——
|
||||
# `.claude/branch-holds.md` 檔頭那句:**永遠在響的警報,等於訓練人忽略這個警報。**
|
||||
# 漏抓的那些,由 baton-handback-guard.sh(收工那一刻)補第二層。
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
CMD=$(printf '%s' "$INPUT" | python3 -c "
|
||||
import sys,json
|
||||
try: print(json.load(sys.stdin).get('tool_input',{}).get('command',''))
|
||||
except Exception: print('')
|
||||
" 2>/dev/null)
|
||||
SID=$(printf '%s' "$INPUT" | python3 -c "
|
||||
import sys,json
|
||||
try: print(json.load(sys.stdin).get('session_id','') or 'nosid')
|
||||
except Exception: print('nosid')
|
||||
" 2>/dev/null || echo nosid)
|
||||
[ -n "$CMD" ] || exit 0
|
||||
|
||||
# 逃生口(留痕,會留在指令歷史上)
|
||||
case "$CMD" in *no-subtask:*) exit 0 ;; esac
|
||||
|
||||
# 這些動詞本來就是在處理「棒子」,不該被自己擋
|
||||
case "$CMD" in
|
||||
*"ticket subtask"*|*"ticket handback"*|*"ticket mine"*|*"ticket close"*|*"ticket where"*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# ① 這是不是在對「既有票」貼留言?(開新票走 ticket new/ticket-api-bypass-guard)
|
||||
IS_COMMENT=no
|
||||
case "$CMD" in *"ticket say"*) IS_COMMENT=yes ;; esac
|
||||
if printf '%s' "$CMD" | grep -qE 'issues/[0-9]+/comments' && printf '%s' "$CMD" | grep -qw 'POST'; then
|
||||
IS_COMMENT=yes
|
||||
fi
|
||||
[ "$IS_COMMENT" = "yes" ] || exit 0
|
||||
|
||||
# ② 把「要貼的內文」拿到手:-F <檔> 就讀那個檔,否則就看指令文字本身
|
||||
BODY="$CMD"
|
||||
FARG=$(printf '%s' "$CMD" | sed -nE 's/.*[[:space:]](-F|--file)[[:space:]]+([^[:space:]]+).*/\2/p' | head -1)
|
||||
if [ -n "${FARG:-}" ] && [ -f "$FARG" ]; then
|
||||
BODY=$(cat "$FARG" 2>/dev/null || printf '%s' "$CMD")
|
||||
fi
|
||||
|
||||
# 已經帶著交棒標記的留言 = 走的是 handback 那條路,不重複擋
|
||||
case "$BODY" in *"🏃"*) exit 0 ;; esac
|
||||
|
||||
# ③ 「未完成的未來式」——刻意取窄。這一組全部來自 08-26 那則真的掉了的留言的形狀。
|
||||
#
|
||||
# 🔴 比對用 python3 不用 grep——這是**實測逼出來的**,不是偏好(2026-08-27 本閘第一版):
|
||||
# 第一版寫 `等[^。]{0,20}(出貨|…)`,跑真的 hook 呼叫路徑時對
|
||||
# 「這件事等雲端那半出貨才驗得了」**不匹配**,閘靜默失效(測試 19/20,那一條紅)。
|
||||
# 同一條 pattern 在別的 shell 手打卻會過 ⇒ **它的行為依賴 locale/grep 實作**,
|
||||
# 而 hook 的執行環境不由我們決定(本機 `LC_CTYPE=C`,雲端又是另一套)。
|
||||
# unpushed-police.sh 檔頭記過同一族的坑:C locale 下處理全形標點會咬掉位元組。
|
||||
# ⇒ 規約:**hook 裡凡是要比對中文,一律交給 python3 的 re**(它一定是字元語意),
|
||||
# 不要賭 grep 在那台機器上是字元模式還是位元組模式。
|
||||
HIT=$(BODY="$BODY" python3 -c '
|
||||
import os, re, sys
|
||||
PAT = ("等.{0,20}?(出貨|上線|部署|發佈|合併|修好|回覆|做完|接上)"
|
||||
"|驗不了|測不了|還沒(驗|測|做|上線|部署|接|補)|之前(驗不|無法|不能)"
|
||||
"|要等|尚未完成|待辦|\\[blocker\\]|TODO|FIXME")
|
||||
ms = [m.group(0) for m in re.finditer(PAT, os.environ.get("BODY", ""))]
|
||||
print(" ".join(ms[:3]))
|
||||
' 2>/dev/null)
|
||||
[ -n "${HIT:-}" ] || exit 0
|
||||
|
||||
WARNED="/tmp/.comment-task-guard-warned-$SID"
|
||||
[ -f "$WARNED" ] && exit 0 # 同一 session 只擋一次,不鬼打牆
|
||||
date +%s > "$WARNED"
|
||||
|
||||
cat >&2 <<EOF
|
||||
🎫 這則留言裡藏著一件**還沒做完的事**,而它現在只是文字。
|
||||
|
||||
命中的詞:$HIT
|
||||
|
||||
【leo 2026-08-27】「**每個任務長出子票做為這張票的相依⋯⋯票沒完工可以察覺嗎?**」
|
||||
|
||||
【它掉過一次,時序如下】
|
||||
08-26 13:00 arcrun-rag#136 comment 4267:「等雲端那半出貨才驗得了」 ← 只是一段文字
|
||||
08-26 18-23 總管真的出貨了 ← 條件滿足,沒人知道
|
||||
08-27 11:xx leo 問「這 14 小時前的,你派了還是沒派?」 ← 才被撿起來
|
||||
|
||||
【怎麼做】把它長成子票,Gitea 原生相依會**硬擋**母票關閉(實測 412):
|
||||
|
||||
scripts/ticket subtask <母票 owner/repo#N> \\
|
||||
--title "身為<誰>,我要<什麼>,我才<為什麼>" \\
|
||||
-F <內文檔> --label s/todo --assign <誰做>
|
||||
|
||||
⇒ 子票沒關,母票關不掉。**不是提醒,是平台保證。**
|
||||
|
||||
【粒度】leo 2026-08-27:「**小任務做完就關閉,不會讓池子爆掉,
|
||||
每個事情沒有歷史記錄才是大問題**」⇒ 判準是「這件事需要被記得嗎」,
|
||||
傾向**多開票**。不要用「會不會太多」當理由不開。
|
||||
|
||||
【真的不是待辦】在指令裡加 \`no-subtask: <一句理由>\` 重送(留痕)。
|
||||
EOF
|
||||
exit 2
|
||||
@@ -41,6 +41,10 @@
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/ticket-api-bypass-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/comment-carries-task-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/milestone-due-guard.sh"
|
||||
@@ -331,6 +335,10 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/issue-status-autoflip.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/baton-handback-guard.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user