一次交貨 = 一份跨 repo 版本清單(inkstone/ISEP#84)
+ 每次結案都留下估多久/花多久/差多少(inkstone/ISEP#85) #84:scripts/release-manifest freeze 前每一格都真的去 Gitea 抓過(抓不到不給凍結);凍結後不准再加東西; rollback 一定吐出整份清單的每一格,--only 指名單一 repo 會被擋下 ——那正是票上那個病:剩下的還停在新版 ⇒ 一組從來沒測過的組合。 清單與 INDEX.md 寫在專案根的 releases/(不是 plugin 目錄,plugin update 會清掉)。 #85:scripts/milestone-account + hooks/milestone-account-guard.sh 三個數字自己算(取自 Gitea 的 created_at/due_on/closed_at,不用手填); 差超過 ±25%(超時或提早都算)就要從七個固定代號裡挑一個,挑不出來不准結案; 代號要有 Gitea 時間軸撐得住——訊號不成立的代號直接拒收(不採信自述)。 閘擋的是「直接 curl PATCH state=closed 但還沒記帳」,正門走 milestone-account close。 盤點數字在本樹實數(ls hooks/*.sh|grep -c '"command":'): hooks 53→54 支、註冊 68→69 條、scripts 39 支(描述欄舊的 38 把三個目錄也數進去了)。 順手修掉 docs/hooks-inventory.md「一句話結論」被貼兩次、且標頭 52/內文 53 互相矛盾。 測試(全部離線,不打 Gitea、不留測試票/不關真的 milestone): scripts/test-release-manifest.sh 21/21 scripts/test-milestone-account.sh 23/23 hooks/tests/milestone-account-guard.test.sh 22/22 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016ZBu4Sa1cGntKFRBYNZ6xs
This commit is contained in:
@@ -49,6 +49,10 @@
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/milestone-due-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/milestone-account-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/reply-identity-guard.sh"
|
||||
|
||||
Executable
+107
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env bash
|
||||
# 管什麼: 直接打 Gitea API 把一個里程碑改成 closed,卻沒有先記帳(估多久/花多久/
|
||||
# 差多少/為什麼差),就擋下來。
|
||||
# 為什麼: leo 2026-08-27 說那天「拖時間」,但拖了多久、比預計多拖多少、為什麼拖——
|
||||
# 一個數字都沒有。偵測器早就有了(mainline-idle-guard/factory-idle-guard),
|
||||
# **但沒有人把它算成帳**,所以事後只能說「這次比較慢」,下次還是一樣。
|
||||
# 誤觸時怎麼關: 走正門 `scripts/milestone-account close <owner/repo#編號>`
|
||||
# (它會自己算三個數字、自己去 Gitea 的時間軸取證據、然後幫你關)。
|
||||
# 真的有理由要繞過去 ⇒ 指令裡加 `milestone-account-ok`(留痕,commit 說明理由)。
|
||||
#
|
||||
# milestone-account-guard.sh — 結案要記帳(PreToolUse/Bash;inkstone/ISEP#85)
|
||||
#
|
||||
# ── 這支跟 milestone-due-guard.sh 是同一條線的兩端 ────────────────────
|
||||
# 那支管**開**里程碑要有真的期限(沒有期限就沒有「估多久」可言)。
|
||||
# 這支管**關**里程碑要留下那三個數字。少了任何一端,帳都算不出來。
|
||||
#
|
||||
# ── 判準:三件同時成立才擋,缺一放行 ──────────────────────────────
|
||||
# ① 指令位置真的有人在發網路請求(curl/wget/python/node/gh)
|
||||
# ——「講到它」跟「執行它」是兩件事,這是本 repo 已經證明過的判準
|
||||
# (release-tag-guard.sh 的 `git tag` 位置比對、strip_heredoc.py 的來由)。
|
||||
# ② URL 指名了**某一個**里程碑:`/repos/<owner>/<repo>/milestones/<數字>`
|
||||
# ——沒有數字的(建里程碑、列清單)不是本閘管的形狀。
|
||||
# ③ 有寫入動詞(PATCH/POST/.patch(/.post()**而且**內容把 state 設成 closed。
|
||||
#
|
||||
# ⇒ 純 GET、建里程碑、改標題改期限、`cat` 這支腳本、把指令寫進文件(heredoc 內文
|
||||
# 先被 strip_heredoc.py 剝掉)、走正門 `scripts/milestone-account close`
|
||||
# ——全部放行,一條都不擋。
|
||||
#
|
||||
# ── 抽不出 owner/repo/編號就不擋 ────────────────────────────────────
|
||||
# fail-open on 解析失敗(不是 fail-open on 檢查結果)。理由同 release-tag-guard.sh:
|
||||
# 誤攔比漏擋更該修——**誤攔會懲罰謹慎**,而被誤攔幾次之後人就學會繞過這支閘。
|
||||
#
|
||||
# ── 已知的漏擋(寫出來,不假裝沒有)────────────────────────────────
|
||||
# `bash -c 'curl -X PATCH …'` 這種把真正的指令包進引號裡的寫法,本閘看不到。
|
||||
# 這是刻意的取捨:要接住它就得去猜引號裡的內容,而那條路的盡頭是關鍵字黑名單
|
||||
# (leo 2026-08-17 已證偽:當日 8 次誤攔、0 次正確攔截)。
|
||||
#
|
||||
# 迴歸測試:hooks/tests/milestone-account-guard.test.sh(離線,不打 Gitea)
|
||||
set -uo pipefail
|
||||
|
||||
HOOKDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
INPUT=$(cat 2>/dev/null || echo '{}')
|
||||
CMD=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import json, sys
|
||||
try: print(json.load(sys.stdin).get("tool_input", {}).get("command", "") or "")
|
||||
except Exception: print("")
|
||||
' 2>/dev/null || printf '')
|
||||
[ -n "$CMD" ] || exit 0
|
||||
|
||||
# 明講的豁免(留痕)
|
||||
case "$CMD" in *milestone-account-ok*) exit 0 ;; esac
|
||||
|
||||
# heredoc 的內文是資料不是指令——先剝掉,理由見 hooks/lib/strip_heredoc.py 檔頭
|
||||
if [ -f "$HOOKDIR/lib/strip_heredoc.py" ]; then
|
||||
CMD=$(printf '%s' "$CMD" | python3 "$HOOKDIR/lib/strip_heredoc.py" 2>/dev/null || printf '%s' "$CMD")
|
||||
fi
|
||||
|
||||
# ① 指令位置真的有人在發請求
|
||||
printf '%s' "$CMD" | grep -qE '(^|[;&|(`]|&&|\|\|)[[:space:]]*(sudo[[:space:]]+)?(curl|wget|http|python3?|node|gh)[[:space:]]' || exit 0
|
||||
|
||||
# ② 指名某一個里程碑
|
||||
REF=$(printf '%s' "$CMD" | grep -oE '/repos/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/milestones/[0-9]+' | head -1 || printf '')
|
||||
[ -n "$REF" ] || exit 0
|
||||
|
||||
# ③ 寫入動詞 + 把 state 設成 closed
|
||||
printf '%s' "$CMD" | grep -qiE -- '-X[[:space:]]*[^A-Za-z]{0,3}(PATCH|POST)|--request[[:space:]]*[^A-Za-z]{0,3}(PATCH|POST)|\.(patch|post)\(|method[[:space:]]*=[[:space:]]*[^A-Za-z]{0,3}(PATCH|POST)' || exit 0
|
||||
printf '%s' "$CMD" | grep -qiE 'state[^A-Za-z0-9]{1,8}closed' || exit 0
|
||||
|
||||
OWNER=$(printf '%s' "$REF" | cut -d/ -f3)
|
||||
REPO=$(printf '%s' "$REF" | cut -d/ -f4)
|
||||
NUM=$(printf '%s' "$REF" | cut -d/ -f6)
|
||||
TARGET="$OWNER/$REPO#$NUM"
|
||||
|
||||
# 已經記過帳的就放行——正門 `milestone-account close` 是**先記帳、再關**,
|
||||
# 所以「帳本裡有這一筆」就代表三個數字已經留下來了。
|
||||
LEDGER="${MILESTONE_ACCOUNT_LEDGER:-}"
|
||||
if [ -z "$LEDGER" ] && [ -x "$HOOKDIR/../scripts/milestone-account" ]; then
|
||||
LEDGER=$(python3 "$HOOKDIR/../scripts/milestone-account" ledger-path 2>/dev/null || printf '')
|
||||
fi
|
||||
if [ -n "$LEDGER" ] && [ -f "$LEDGER" ] && grep -qF "\"ref\": \"$TARGET\"" "$LEDGER" 2>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cat >&2 <<MSG
|
||||
⏱️ 結案要記帳:**$TARGET 還沒有留下「估多久/花多久/差多少」。**
|
||||
|
||||
【inkstone/ISEP#85】leo 2026-08-27 說那天「**拖時間**」。
|
||||
但拖了多久、比預計多拖多少、為什麼拖——**沒有任何數字**。
|
||||
「偵測器早就有了,但沒有人把它算成帳」⇒ 每次事後只能說「這次比較慢」,下次還是一樣。
|
||||
|
||||
━━ 走正門,它會把該做的一次做完 ━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
先看時間軸怎麼說: scripts/milestone-account audit $TARGET
|
||||
結案(會自己算、自己關):
|
||||
scripts/milestone-account close $TARGET [--reason <代號>]
|
||||
|
||||
三個數字**它自己算**(取自 Gitea 的 created_at/due_on/closed_at),不用你手填。
|
||||
差超過 ±25%(超時或提早都算)才需要 --reason,而且代號只能從那七個裡面挑:
|
||||
scripts/milestone-account codes
|
||||
代號要有**時間軸撐得住**——證據取自 Gitea,不採信任何人的自述。
|
||||
|
||||
━━ 為什麼不是「你自己 curl 一下就好」 ━━━━━━━━━━━━━━━━━━━━
|
||||
這一關掉,那個里程碑就再也算不出帳了(closed_at 之後沒有人會回頭補)。
|
||||
⇒ 這是**不可逆**的一步,所以閘長在這裡,不長在誰的記性上。
|
||||
|
||||
真的有理由要手動關 ⇒ 指令裡加 `milestone-account-ok`(留痕,commit 說明理由)。
|
||||
MSG
|
||||
exit 2
|
||||
Executable
+85
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env bash
|
||||
# milestone-account-guard.sh 的迴歸測試(inkstone/ISEP#85)
|
||||
#
|
||||
# A 群「不該擋」——純讀取/建里程碑/改別的欄位/只是談論它/heredoc 內文/
|
||||
# 走正門 `milestone-account close`/已經記過帳/解析不出來
|
||||
# B 群「該擋」 ——真的用 curl/python 把某一個里程碑 PATCH 成 closed,而帳本裡沒有它
|
||||
# C 群「訊息承諾的出路真的走得通」——記過帳之後立刻不再擋;加了豁免字樣就放行
|
||||
#
|
||||
# 🔴 全程離線:帳本走 MILESTONE_ACCOUNT_LEDGER 指到 mktemp 的檔,不打 Gitea。
|
||||
# **誤攔比漏擋更該修**——A 群任何一條紅,就是這支閘在懲罰謹慎。
|
||||
set -u
|
||||
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/milestone-account-guard.sh}"
|
||||
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
|
||||
LED="$TMP/ledger.jsonl"; : > "$LED"
|
||||
PASS=0; FAIL=0; N=0
|
||||
|
||||
fire() {
|
||||
want="$1"; desc="$2"; cmd="$3"
|
||||
N=$((N+1))
|
||||
payload=$(python3 -c 'import json,sys; print(json.dumps({"tool_input":{"command":sys.argv[1]}}))' "$cmd")
|
||||
out=$(printf '%s' "$payload" | MILESTONE_ACCOUNT_LEDGER="$LED" bash "$HOOK" 2>&1 >/dev/null); rc=$?
|
||||
if [ "$rc" -eq "$want" ]; then printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ %s —— 期望 exit=%s,實得 exit=%s\n' "$desc" "$want" "$rc"
|
||||
printf '%s\n' "$out" | sed -n '1,6p' | sed 's/^/ /'; FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
|
||||
CLOSE='curl -s -X PATCH -H "Authorization: token X" -d {"state":"closed"} https://git.uncle6.me/api/v1/repos/inkstone/ISEP/milestones/12'
|
||||
|
||||
echo "── A 群:不該擋(誤攔比漏擋更該修)─────────────────────────"
|
||||
fire 0 "① 空指令" ""
|
||||
fire 0 "② 純 GET 撈里程碑" \
|
||||
'curl -s https://git.uncle6.me/api/v1/repos/inkstone/ISEP/milestones/12'
|
||||
fire 0 "③ 列出所有里程碑" \
|
||||
'curl -s "https://git.uncle6.me/api/v1/repos/inkstone/ISEP/milestones?state=open"'
|
||||
fire 0 "④ 建一個新里程碑(那是 milestone-due-guard 管的,不是這支)" \
|
||||
'curl -X POST -d {"title":"x","due_on":"2026-09-05T23:59:59Z"} https://git.uncle6.me/api/v1/repos/inkstone/ISEP/milestones'
|
||||
fire 0 "⑤ PATCH 但只是改期限,沒有要關它" \
|
||||
'curl -X PATCH -d {"due_on":"2026-09-05T23:59:59Z"} https://git.uncle6.me/api/v1/repos/inkstone/ISEP/milestones/12'
|
||||
fire 0 "⑥ 只是談論它(echo 在指令位置,curl 在字串裡)" \
|
||||
"echo '$CLOSE'"
|
||||
fire 0 "⑦ 讀這支閘自己的原始碼" 'cat hooks/milestone-account-guard.sh'
|
||||
fire 0 "⑧ commit 訊息裡出現「關掉 milestone」" \
|
||||
'git commit -m "關掉 milestone 12,state closed"'
|
||||
fire 0 "⑨ 走正門:scripts/milestone-account close" \
|
||||
'python3 scripts/milestone-account close inkstone/ISEP#12 --reason idle'
|
||||
fire 0 "⑩ 正門的 --dry-run" \
|
||||
'python3 scripts/milestone-account close inkstone/ISEP#12 --reason idle --dry-run'
|
||||
fire 0 "⑪ 把那段指令寫進文件(heredoc 內文是資料不是指令)" \
|
||||
"$(printf 'cat > docs/x.md <<%s\n%s\nEOD\n' "'EOD'" "$CLOSE")"
|
||||
fire 0 "⑫ 網址沒有里程碑編號(抽不出目標 ⇒ 不擋)" \
|
||||
'curl -X PATCH -d {"state":"closed"} https://git.uncle6.me/api/v1/repos/inkstone/ISEP/milestones'
|
||||
fire 0 "⑬ 關的是一張票不是里程碑" \
|
||||
'curl -X PATCH -d {"state":"closed"} https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/12'
|
||||
|
||||
echo "── B 群:該擋 ──────────────────────────────────────────────"
|
||||
fire 2 "⑭ curl PATCH state=closed,帳本裡沒有它" "$CLOSE"
|
||||
fire 2 "⑮ 換成 python urllib(method=\"PATCH\")一樣擋" \
|
||||
'python3 -c "import urllib.request,json; urllib.request.urlopen(urllib.request.Request(\"https://git.uncle6.me/api/v1/repos/inkstone/ISEP/milestones/12\", data=json.dumps({\"state\":\"closed\"}).encode(), method=\"PATCH\"))"'
|
||||
fire 2 "⑯ 前面串一個無害指令也擋(不是靠前綴判斷)" "ls && $CLOSE"
|
||||
fire 2 "⑰ --request PATCH 的長寫法" \
|
||||
'curl --request PATCH --data {"state":"closed"} https://git.uncle6.me/api/v1/repos/inkstone/ISEP/milestones/12'
|
||||
fire 2 "⑱ 別的 repo 的里程碑一樣管" \
|
||||
'curl -X PATCH -d {"state":"closed"} https://git.uncle6.me/api/v1/repos/inkstone/arcrun-rag/milestones/47'
|
||||
|
||||
echo "── C 群:訊息承諾的出路真的走得通 ──────────────────────────"
|
||||
N=$((N+1))
|
||||
out=$(python3 -c 'import json,sys; print(json.dumps({"tool_input":{"command":sys.argv[1]}}))' "$CLOSE" \
|
||||
| MILESTONE_ACCOUNT_LEDGER="$LED" bash "$HOOK" 2>&1 >/dev/null)
|
||||
if printf '%s' "$out" | grep -q "scripts/milestone-account audit inkstone/ISEP#12"; then
|
||||
printf ' ✅ ⑲ 訊息直接給出可以照打的那一行(含正確的 owner/repo#編號)\n'; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ ⑲ 訊息沒有點名目標\n'; FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
echo '{"ref": "inkstone/ISEP#12", "reason": "idle"}' >> "$LED"
|
||||
fire 0 "⑳ 記過帳之後立刻放行(正門是先記帳再關)" "$CLOSE"
|
||||
fire 2 "㉑ 但別的里程碑還是照擋(不是一記帳就全開)" \
|
||||
'curl -X PATCH -d {"state":"closed"} https://git.uncle6.me/api/v1/repos/inkstone/ISEP/milestones/13'
|
||||
fire 0 "㉒ 明講的豁免字樣(留痕)" "$CLOSE # milestone-account-ok"
|
||||
|
||||
echo
|
||||
echo "通過 $PASS 條,失敗 $FAIL 條(共 $N 條)"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Reference in New Issue
Block a user