#!/bin/bash # 討論串裡的任務要長成子票——這道閘的測試 # 判準:6 種該擋(留言裡真的藏著待辦)、10 種不該擋 # 🔴 「不該擋」比「該擋」重要:branch-holds.md 檔頭那句—— # 永遠在響的警報,等於訓練人忽略這個警報。 cd "$(dirname "$0")/.." || exit 1 H=hooks/comment-carries-task-guard.sh PASS=0; FAIL=0 SID=test-cctg-$$ run(){ # $1=want $2=cmd rm -f "/tmp/.comment-task-guard-warned-$SID" printf '%s' "{\"session_id\":\"$SID\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$2")}}" \ | bash "$H" >/dev/null 2>&1 got=$? if [ "$got" = "$1" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi printf 'want=%s got=%s %.62s\n' "$1" "$got" "$2" } echo "── 該擋:留言裡藏著一件還沒做完的事 ──" # 這一條就是 2026-08-26 真的掉了的那則的形狀 run 2 'req("POST", f"{API}/repos/inkstone/arcrun-rag/issues/136/comments", {"body":"Phase 1 的驗收在雲端那半出貨之前驗不了"})' run 2 'curl -X POST "$API/repos/inkstone/Arcrun/issues/12/comments" -d "{\"body\":\"要等 leo 開閘才能部署\"}"' run 2 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"[blocker] 收端還沒上線"})' run 2 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"這條路徑還沒測,先擱著"})' run 2 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"TODO: 補一條端到端測試"})' run 2 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"等雲端出貨之後要回頭重驗一次"})' echo "── 不該擋 ──" run 0 'curl -s "$API/repos/inkstone/ISEP/issues/30/comments"' # 純讀 run 0 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"已驗過,8/8 全綠"})' # 沒有待辦形狀 run 0 'scripts/ticket subtask inkstone/arcrun-rag#136 --title "身為x,我要y,我才z" -F /tmp/c.md' run 0 'scripts/ticket handback inkstone/arcrun-rag#136 --to claude-code --next "還沒驗的那格去驗"' run 0 'scripts/ticket mine' run 0 'scripts/ticket close inkstone/x#1 --deliverable https://example.invalid/pr/1' run 0 'grep -n "驗不了" hooks/comment-carries-task-guard.sh' # 只是讀這支閘 run 0 'echo "這則留言等雲端出貨才驗得了 —— no-subtask: 它已經有子票 #143 了"' # 逃生口 run 0 'curl -X POST "$API/repos/x/y/issues" -d "{\"title\":\"等出貨\"}"' # 開新票不歸這支管 run 0 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"🏃 **棒子交回** → claude-code\n\n**下一步**:等 #143"})' echo "── 交棒標記只認第一行(2026-08-27 dogfood 抓到的漏)──" # 真的交棒留言:🏃 在第一行 ⇒ 放行 run 0 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"🏃 **棒子交回** → claude-code\n\n**下一步**:等 #143"})' # 交件報告**引用**了一段有 🏃 的輸出,而報告本身帶著待辦 ⇒ 不准被豁免 TMPR=$(mktemp) printf '交件報告\n\n```\n🏃 在總管手上的棒子:2 根\n```\n\n還有一格等雲端那半出貨才驗得了\n' > "$TMPR" run 2 "scripts/ticket say inkstone/ISEP#30 -F $TMPR" rm -f "$TMPR" echo "── 同一 session 只擋一次(不鬼打牆)──" rm -f "/tmp/.comment-task-guard-warned-$SID" C='req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"驗不了"})' for want in 2 0; do printf '%s' "{\"session_id\":\"$SID\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$C")}}" \ | bash "$H" >/dev/null 2>&1 got=$? if [ "$got" = "$want" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi printf 'want=%s got=%s 第 %s 次同樣的指令\n' "$want" "$got" "$([ "$want" = 2 ] && echo 1 || echo 2)" done echo "── -F <檔>:內文在檔案裡也要看得到 ──" TMPF=$(mktemp); printf '這件事等雲端那半出貨才驗得了\n' > "$TMPF" run 2 "scripts/ticket say inkstone/arcrun-rag#136 -F $TMPF" printf '已驗完,全部綠燈\n' > "$TMPF" run 0 "scripts/ticket say inkstone/arcrun-rag#136 -F $TMPF" rm -f "$TMPF" "/tmp/.comment-task-guard-warned-$SID" echo "── 閘教的那行指令要真的跑得動(inkstone/ISEP#112)──" # 🔴 這一組**不看離開碼**,看訊息內容、而且**真的把訊息裡那塊指令執行一次**。 # ISEP wiki mistakes.md 記過:訊息壞掉時閘照樣 exit 2,只驗離開碼完全看不出來。 # 這張票就是那個形狀的極端版——閘擋對了,但它教的解法**根本不存在**。 SID2=test-cctg-msg-$$ rm -f "/tmp/.comment-task-guard-warned-$SID2" PAYLOAD=$(python3 -c ' import json, sys cmd = ("curl -X POST \"$API/repos/inkstone/arcrun-rag/issues/136/comments\" " "-d \x27{\"body\":\"Phase 1 的驗收在雲端那半出貨之前驗不了\"}\x27") print(json.dumps({"session_id": sys.argv[1], "tool_name": "Bash", "tool_input": {"command": cmd}}, ensure_ascii=False)) ' "$SID2") MSGFILE=$(mktemp) printf '%s' "$PAYLOAD" | bash "$H" 2>"$MSGFILE" >/dev/null rm -f "/tmp/.comment-task-guard-warned-$SID2" RESFILE=$(mktemp) PAYFILE=$(mktemp); printf '%s' "$PAYLOAD" > "$PAYFILE" rm -f "/tmp/.comment-task-guard-warned-$SID2" MSGFILE="$MSGFILE" RESFILE="$RESFILE" GUARD="$H" PAYLOAD="$PAYFILE" python3 - <<'PY' import io, os, re, subprocess, sys msg = io.open(os.environ["MSGFILE"], encoding="utf-8").read() res = [] def t(ok, name, detail=""): res.append((bool(ok), name, detail)) here = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) \ if "__file__" in dir() else os.getcwd() ticket = os.path.realpath(os.path.join(os.getcwd(), "scripts", "ticket")) # ⑮ 那塊指令**真正要執行的那一行**,開頭是絕對路徑,而且指到這一份正本。 # 🔴 判準刻意放在「要執行的那一行」上,不是「訊息裡出現過的所有路徑」—— # 後者在相對路徑混進來時是**空過**的(別的行還有絕對路徑,all() 照樣真)。 # 2026-08-31 變異測試當場抓到:把 example 改回相對路徑,舊寫法的 ⑮ 沒紅。 run_line = "" for l in msg.splitlines(): if " subtask " in l and l.strip().endswith("\\"): run_line = l.strip() break first = run_line.split()[0] if run_line else "" t(first.startswith("/") and os.path.realpath(first) == ticket, "⑮ 要執行的那一行開頭是絕對路徑,且就是這一份正本", "那一行:%s" % (run_line or "(找不到)")) # ⑯ 訊息裡**不准**出現相對的 `scripts/ticket` # 相對路徑是對「貼上去那個人的 cwd」解析的,而 InkStoneCo/scripts/ticket 是舊複本。 rel = [l for l in msg.splitlines() if re.search(r"(? ")) md = next(k for k in range(i, len(lines)) if lines[k] == "MD") j = next(k for k in range(md + 1, len(lines)) if not lines[k].endswith("\\")) return "\n".join(lines[i:j + 1]) def fill(block): """只填三格機器不可能知道的:標題、目標、驗收條件。其餘一個字都不改。""" block = (block.replace("<誰>", "跑這行指令的人") .replace("<什麼>", "它真的跑得動") .replace("<為什麼>", "不會被迫去繞過它")) return re.sub(r"<[^<>\n]+>", "測試填的內容", block) ENV = dict(os.environ, TICKET_HOST="http://127.0.0.1:9", GITEA_TOKEN_CLAUDE_CODE="dummy", GITEA_TOKEN="dummy") # ⑱ ★ 把它原樣貼上、只填那三格,**真的跑一次**。 # 離開碼 2 = 被某道閘擋下(動詞不存在/旗標不成組/標題不合格…)= 這張票的病復發 # 離開碼 1 = 一路走到網路才炸(TICKET_HOST 指到連不上的位址)= 參數全部過關 try: blk = fill(extract_block(msg)) r = subprocess.run(["bash", "-c", blk], capture_output=True, text=True, env=ENV) code = r.returncode except StopIteration: blk, code, r = "", -1, None t(code == 1, "⑱ ★ 訊息裡那塊指令原樣貼上就跑得完(走到網路才停,不是被閘擋)", "離開碼 %s(2 = 又被某道閘擋下)%s" % (code, ("|" + (r.stderr or "").strip().splitlines()[0][:80]) if r and code == 2 else "")) # ⑲ 鑑別力對照:⑱ 不是「只要跑就會過」。 # 把閘**原本**印的那組旗標(`--assign <誰做>` 沒配 `--next`)接回去 ⇒ 必須紅。 if blk: bad = blk.rstrip() + " --assign claude-code" rb = subprocess.run(["bash", "-c", bad], capture_output=True, text=True, env=ENV) t(rb.returncode == 2, "⑲ 對照組:閘原本那組旗標(--assign 沒配 --next)確實跑不完", "離開碼 %s(要是 2;不是 2 就表示 ⑱ 沒有鑑別力)" % rb.returncode) else: t(False, "⑲ 對照組:抽不出指令區塊", "") # ⑳ 草稿模板與必填檢查**同源**:模板裡一定有 REQUIRED_SECTIONS 的每一段。 # 寫死一份模板的話,有人往 REQUIRED_SECTIONS 加一段就會生出 # 「照著貼卻過不了自己那道閘」的票——正是這張票要修的形狀。 req = subprocess.run( [sys.executable, "-c", "import runpy,sys;m=runpy.run_path(sys.argv[1]);" "print('\\x00'.join(m['REQUIRED_SECTIONS']));" "print(m['body_template']())", ticket], capture_output=True, text=True) head, _, tmpl = req.stdout.partition("\n") secs = [x for x in head.split("\x00") if x] t(secs and all(x in tmpl for x in secs) and all(x in msg for x in secs), "⑳ 草稿模板由 REQUIRED_SECTIONS 現生,訊息裡也齊", "必填段:%s" % secs) # ㉑ 正本叫不動時(雲端裝壞、路徑不對):**閘照樣擋**,而且**不准就地補一份手抄用法** # ——那份副本正是這張票拔掉的東西。fail-open 的是「訊息內容」,不是「擋不擋」。 fb = subprocess.run(["bash", os.environ["GUARD"]], input=io.open(os.environ["PAYLOAD"], encoding="utf-8").read(), capture_output=True, text=True, env=dict(os.environ, CLAUDE_PLUGIN_ROOT="/tmp/isep-no-such-root")) fbmsg = fb.stderr t(fb.returncode == 2 and "usage subtask" in fbmsg and "--title" not in fbmsg and "[--journey" not in fbmsg, "㉑ 正本叫不動時仍然擋,且只指路、不補一份手抄用法", "離開碼 %s|訊息裡有沒有手抄用法:%s" % (fb.returncode, "有(違規)" if "[--journey" in fbmsg else "沒有")) npass = 0 for ok, name, detail in res: print(" %s %s%s" % ("✅" if ok else "❌", name, (" ※ " + detail) if (detail and not ok) else "")) npass += 1 if ok else 0 io.open(os.environ["RESFILE"], "w", encoding="utf-8").write( "%d %d" % (npass, len(res) - npass)) PY read -r P2 F2 < "$RESFILE" PASS=$((PASS + P2)); FAIL=$((FAIL + F2)) rm -f "$MSGFILE" "$RESFILE" "$PAYFILE" "/tmp/isep-subtask-$SID2.md" \ "/tmp/.comment-task-guard-warned-$SID2" echo echo "$PASS/$((PASS+FAIL)) 通過" [ "$FAIL" = 0 ]