一次交貨 = 一份跨 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:
claude-code
2026-08-28 00:54:21 +00:00
parent bebbbd2c11
commit 3f622ba646
11 changed files with 1572 additions and 5 deletions
+4
View File
@@ -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"
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# 管什麼: 直接打 Gitea API 把一個里程碑改成 closed,卻沒有先記帳(估多久/花多久/
# 差多少/為什麼差),就擋下來。
# 為什麼: leo 2026-08-27 說那天「拖時間」,但拖了多久、比預計多拖多少、為什麼拖——
# 一個數字都沒有。偵測器早就有了(mainline-idle-guardfactory-idle-guard),
# **但沒有人把它算成帳**,所以事後只能說「這次比較慢」,下次還是一樣。
# 誤觸時怎麼關: 走正門 `scripts/milestone-account close <owner/repo#編號>`
# (它會自己算三個數字、自己去 Gitea 的時間軸取證據、然後幫你關)。
# 真的有理由要繞過去 ⇒ 指令裡加 `milestone-account-ok`(留痕,commit 說明理由)。
#
# milestone-account-guard.sh — 結案要記帳(PreToolUseBashinkstone/ISEP#85
#
# ── 這支跟 milestone-due-guard.sh 是同一條線的兩端 ────────────────────
# 那支管**開**里程碑要有真的期限(沒有期限就沒有「估多久」可言)。
# 這支管**關**里程碑要留下那三個數字。少了任何一端,帳都算不出來。
#
# ── 判準:三件同時成立才擋,缺一放行 ──────────────────────────────
# ① 指令位置真的有人在發網路請求(curlwgetpythonnodegh
# ——「講到它」跟「執行它」是兩件事,這是本 repo 已經證明過的判準
# release-tag-guard.sh 的 `git tag` 位置比對、strip_heredoc.py 的來由)。
# ② URL 指名了**某一個**里程碑:`/repos/<owner>/<repo>/milestones/<數字>`
# ——沒有數字的(建里程碑、列清單)不是本閘管的形狀。
# ③ 有寫入動詞(PATCHPOST.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_atdue_onclosed_at),不用你手填。
差超過 ±25%(超時或提早都算)才需要 --reason,而且代號只能從那七個裡面挑:
scripts/milestone-account codes
代號要有**時間軸撐得住**——證據取自 Gitea,不採信任何人的自述。
━━ 為什麼不是「你自己 curl 一下就好」 ━━━━━━━━━━━━━━━━━━━━
這一關掉,那個里程碑就再也算不出帳了(closed_at 之後沒有人會回頭補)。
⇒ 這是**不可逆**的一步,所以閘長在這裡,不長在誰的記性上。
真的有理由要手動關 ⇒ 指令裡加 `milestone-account-ok`(留痕,commit 說明理由)。
MSG
exit 2
+85
View File
@@ -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 12state 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 urllibmethod=\"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 ]