58c3689e4b
comment-carries-task-guard 擋下留言時教人跑 `scripts/ticket subtask …`,
而那一行跑不動。兩個獨立的原因,各自都足以害死它:
① 相對路徑對「貼上去那個人的 cwd」解析。總管的 cwd 是 InkStoneCo/,
那裡的 scripts/ticket 是停在 08-27 之前的舊複本(grep -c subtask → 0),
照著貼只印出它「四個動詞」的說明然後 exit 0。
② 用法是手抄的第二份,而它已經漂了:寫了 --assign 卻沒有 --next,
正本會因為「指派了人卻沒寫 --next」直接擋下(exit 2,在打 API 之前)。
修法是拿掉可以漂的那一格,不是把兩份對齊一次:
- 路徑 scripts/ticket 的 self_path()(__file__ 的絕對路徑)
⇒ 印指令的人與跑指令的人是同一個檔案
- 用法 USAGE / usage_text() 一份,閘去 `ticket usage subtask --example` 現要
- 內文模板 body_template() 由 REQUIRED_SECTIONS 現生,跟檢查同源
新增動詞 `ticket usage <verb> [--example] [--parent] [--draft]`。
閘的訊息改成整塊可貼可跑:腳本絕對路徑、母票已填、內文草稿附在同一塊裡,
只剩標題/目標/驗收條件三格是機器不可能知道的。
正本叫不動時 fail-open 的只有「訊息內容」(只指路,不補一份手抄用法),
擋不擋不受影響。
測試 scripts/test-comment-carries-task-guard.sh 22 → 29 條。
新增的 ⑮–㉑ 驗的是「出路走不走得通」,不是「有沒有擋」——
⑱ 把閘 stderr 裡那塊指令原樣抽出來、只填三格、真的執行一次,
離開碼必須是 1(走到網路才停);是 2 就代表這個病復發。
六個變異全部驗過會紅(相對路徑/閘自己抄用法/--assign 沒配 --next/
模板寫死少一段/fallback 補回手抄用法)。
版本:待總管定版(plugin update 比的是版本號不是內容)。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SP7DiGMfSkWjZ15CHMbPFE
201 lines
11 KiB
Bash
Executable File
201 lines
11 KiB
Bash
Executable File
#!/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
|