#!/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" cat >&2 < \\ --title "身為<誰>,我要<什麼>,我才<為什麼>" \\ -F <內文檔> --label s/todo --assign <誰做> ⇒ 子票沒關,母票關不掉。**不是提醒,是平台保證。** 【粒度】leo 2026-08-27:「**小任務做完就關閉,不會讓池子爆掉, 每個事情沒有歷史記錄才是大問題**」⇒ 判準是「這件事需要被記得嗎」, 傾向**多開票**。不要用「會不會太多」當理由不開。 【真的不是待辦】在指令裡加 \`no-subtask: <一句理由>\` 重送(留痕)。 EOF exit 2