#!/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(收工那一刻)補第二層。 # # 已知極限(誠實標記):這支**認不出「我已經開好子票了」**——它只看得到指令文字。 # 所以交件報告裡引用自己剛開的子票時仍會被擋一次;那一次用 `no-subtask:` 過, # 或就當它是一次確認。刻意不去打 API 查「這張票有沒有相依」:那會讓每一個 # 命中關鍵詞的 Bash 呼叫都多一次網路往返,代價落在錯的地方。 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 那條路,不重複擋。 # 🔴 只認**第一行**,不認「內文任何地方出現過」——這是 dogfood 當場抓到的漏(2026-08-27): # 交件報告裡**引用了一段 `ticket mine` 的輸出**(那段有 🏃), # 整則報告就被豁免掉了,而它裡面真的有「等雲端那半出貨」這種待辦。 # ⇒ 這正是 `inkstone/InkStoneCo#23` 那個病的同款:**複述關鍵字被當成本人**。 # `ticket handback` 寫的交棒留言,🏃 一定在第一行;引用別人的輸出則不會。 case "$(printf '%s' "$BODY" | head -1)" 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" # ── 出路要真的走得通(inkstone/ISEP#112)──────────────────────────────── # # 這支閘 08-27 上線時印的是 `scripts/ticket subtask …`,而那一行**跑不動**, # 兩個獨立的原因(2026-08-31 實測,各自都足以害死它): # # ① 相對路徑對「貼上去那個人的 cwd」解析。總管的 cwd 是 `InkStoneCo/`, # 那裡的 `scripts/ticket` 是舊複本(`grep -c subtask` → 0), # 照著貼只會印出它「四個動詞」的說明然後 exit 0。 # ② 用法是**手抄的第二份**,而它已經漂了:寫了 `--assign` 卻沒有 `--next`, # 而正本會因為「指派了人卻沒寫 --next」直接擋下。 # # 票上的判決:「**當它教的做法不存在,它就從『幫你』變成『擋你』**—— # 而且被擋的人當下正在做別的事,最可能的反應是找個理由繞過去。」 # # 🔴 所以這裡**不准再抄一份用法**。整塊指令去正本要: # "$TICKET" usage subtask --example --parent <母票> --draft <草稿路徑> # 正本印的路徑是它自己的 `__file__`(絕對路徑)⇒ 印指令的人與跑指令的人 # 是同一個檔案,沒有第二種可能。 ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" TICKET="$ROOT/scripts/ticket" # 母票是誰:兩條路都認(走正門 `ticket say`,或直接打 API)。認不出來就留 <母票 …>, # 正本那邊會照樣印出可讀的樣板——**寧可少填一格,不要猜錯一張票**。 PARENT=$(printf '%s' "$CMD" | python3 -c ' import re, sys c = sys.stdin.read() m = re.search(r"/repos/([\w.-]+)/([\w.-]+)/issues/(\d+)/comments", c) if m: print("%s/%s#%s" % m.groups()); raise SystemExit m = re.search(r"\bticket\s+say\s+([\w.-]+)/([\w.-]+)#(\d+)", c) if m: print("%s/%s#%s" % m.groups()) ' 2>/dev/null) DRAFT="/tmp/isep-subtask-$SID.md" EXAMPLE=$(python3 "$TICKET" usage subtask --example \ ${PARENT:+--parent "$PARENT"} --draft "$DRAFT" 2>/dev/null) || EXAMPLE="" MORE=$(python3 "$TICKET" usage subtask 2>/dev/null) || MORE="" # fail-open 的**訊息**(不是閘):正本叫不動時仍然要擋,但不准在這裡補一份手抄用法 # ——那樣就把剛拔掉的那個副本裝回來了。只指路,指到唯一那一份。 LEAD="整段複製貼上就跑得動。腳本是絕對路徑、母票已經填好、必填段落已經在草稿裡, 只剩三格是機器不可能知道的:標題、目標、驗收條件——改完那三格再送。 (下面這塊**不要縮排**,\`<<'MD'\` 的結束標記要頂在行首。)" if [ -z "$EXAMPLE" ]; then LEAD="把它長成子票。**這台機器叫不動正本,所以下面只給路、不給整塊指令** ——這裡刻意不補一份手抄的用法,那份副本正是 inkstone/ISEP#112 拔掉的東西。" EXAMPLE="python3 $TICKET usage subtask --example" MORE="" fi # 🔴 訊息一律用 `<<'EOF'`(標記加引號)+ 事後用 python 換佔位符。 # ISEP wiki mistakes.md 記過:不加引號的 heredoc 會把訊息裡的反引號當命令執行, # 而**閘照樣 exit 2,所以只看離開碼的測試完全看不出來**。 MSG=$(cat <<'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 小時前的,你派了還是沒派?」 ← 才被撿起來 【怎麼做】__LEAD__ __EXAMPLE__ __MORE__ ⇒ 子票沒關,母票關不掉。**不是提醒,是平台保證**(Gitea 原生相依,實測 412)。 【粒度】leo 2026-08-27:「**小任務做完就關閉,不會讓池子爆掉, 每個事情沒有歷史記錄才是大問題**」⇒ 判準是「這件事需要被記得嗎」, 傾向**多開票**。不要用「會不會太多」當理由不開。 【真的不是待辦】在指令裡加 `no-subtask: <一句理由>` 重送(留痕)。 EOF ) MORE_BLOCK="" [ -n "$MORE" ] && MORE_BLOCK=" 還有哪些參數(指派給誰+下一步、旅程標籤、換 repo): $(printf '%s' "$MORE" | sed 's/^/ /; s/[[:space:]]*$//') " HIT="$HIT" LEAD="$LEAD" EXAMPLE="$EXAMPLE" MORE_BLOCK="$MORE_BLOCK" MSG="$MSG" python3 -c ' import os, sys m = os.environ["MSG"] for k in ("HIT", "LEAD", "EXAMPLE"): m = m.replace("__%s__" % k, os.environ.get(k, "")) m = m.replace("__MORE__", os.environ.get("MORE_BLOCK", "")) sys.stderr.write(m + "\n") ' exit 2