From 0024d697f9ccb64daa56e8d7c98f15ba67dc8517 Mon Sep 17 00:00:00 2001 From: isep-hand Date: Thu, 3 Sep 2026 21:06:13 +0000 Subject: [PATCH] =?UTF-8?q?feat(hooks):=20=E6=9C=AA=E5=87=BA=E8=B2=A8?= =?UTF-8?q?=E8=AD=A6=E5=AF=9F=20unreleased-police.sh=20=E2=80=94=E2=80=94?= =?UTF-8?q?=20=E6=94=B6=E5=B7=A5=E9=82=A3=E5=88=BB=E6=8A=93=E3=80=8C?= =?UTF-8?q?=E6=94=B9=E4=BA=86=20repo=20=E5=8D=BB=E6=B2=92=E5=87=BA?= =?UTF-8?q?=E8=B2=A8=E3=80=8D(inkstone/ISEP#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 2026-08-27:「每個 repo 只要有改動就要出貨⋯⋯這個 session 有改但要 結束沒出貨就抓,你可以久一點再出貨累積幾個 PR,但最後一定要出貨」。 四個判準(票要求自己判、寫得出理由,不照抄總管): - 「改過」=這個 session 期間 HEAD 新長出來的 commit(不是工作區髒、不是 push)。 與「出貨」用同一個單位(commit),才數得出「最後 commit 與最新 release 差多少」。 session 起點=transcript 第一列時間戳(同 countdown.py 的基準)。 - 「該不該出貨」=有沒有動到知識(wiki/docs/_archive)以外的檔。只改 wiki/docs 不擋。 - 「哪些 repo 不適用」=repo 根目錄有 .isep-release-exempt(自帶理由,非中央手列清單, 新 repo 預設納管)。 - 「這次先不出」=.claude/release-holds.md 寫一行理由(放行且留痕;形狀同 branch-holds.md, 是「要求某個東西在場」而非文字層封路)。 「出貨」=git tag --contains HEAD 非空(Gitea release 就是 tag,本機答得準、不打網路)。 只掛 Stop、不掛 SubagentStop:subagent 交回分支、release 由總管出,逼 subagent 出貨純屬誤攔。 fail-open:拿不到 transcript 起點/非 git/git 問不出來一律放行(誤攔比漏擋嚴重)。 盤點:61→62 支、84→85 條(在自己的樹上實數,未加減推算);未掛上仍 3 支(不變)。 測試:scripts/test-unreleased-police.sh 13/13(該擋/不該擋/出路走得通/鑑別力四群,全離線)。 docs/hooks-inventory.md F 組加一列+版本註記;docs/TESTING.md A27。 plugin.json 待總管定版:版本號與 tag 由總管在打 release 那一刻同 commit 改(升版是傳輸 機制本身,check-version-consistency 要求 version==最新 tag,故不在分支上先動)。 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01M6AcoT4ALtPLsoCKab6pXt --- docs/TESTING.md | 22 +++ docs/hooks-inventory.md | 12 +- hooks/hooks.json | 8 + hooks/unreleased-police.sh | 241 ++++++++++++++++++++++++++++++ scripts/test-unreleased-police.sh | 102 +++++++++++++ 5 files changed, 382 insertions(+), 3 deletions(-) create mode 100755 hooks/unreleased-police.sh create mode 100755 scripts/test-unreleased-police.sh diff --git a/docs/TESTING.md b/docs/TESTING.md index 2ab33b2..6ebf5c6 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -825,6 +825,28 @@ claude -p '請執行 git tag -a v9.9.9 -m test' --- +### A27 — 未出貨警察:改了 code 沒出貨要擋,只改 wiki/已出貨/先不出都不擋:13 條 +``` +bash scripts/test-unreleased-police.sh +``` +**該看到**:`13/13 通過`。全程在 `mktemp -d` 底下造假 repo 與假 transcript, +不碰任何真 repo、不打網路、跑完自己清。 + +**它在守什麼**(`inkstone/ISEP#68`,leo 2026-08-27):「每個 repo 只要有改動就要出貨⋯⋯ +這個 session 有改但要結束沒出貨就抓」。「改過」認的是**這個 session 期間新長出來的 commit** +(committer-date 晚於 transcript 第一列的時間戳),「出貨」認的是**有沒有 release tag 收進 HEAD** +(`git tag --contains HEAD`)——兩者同一個單位(commit),才數得出「最後 commit 與最新 release 差幾筆」。 + +**失敗**: +- A 群「該擋」任一放行 ⇒ 假綠:改了 code 卻沒出貨溜過去了 +- B 群「不該擋」任一被擋 ⇒ **誤攔**(比漏擋嚴重)——特別看「只改 wiki/docs」「已出貨」 + 「有 exempt marker」「release-holds.md 有它」四條,任何一條紅代表閘在懲罰謹慎 +- C 群紅 ⇒ 訊息教的出路(寫 `.claude/release-holds.md` 一行)**跑不動**, + 被擋的人只會去找繞過去的方法(`mistakes.md` ISEP#112 的課) +- D 群紅 ⇒ 偵測根本不會亮,「全綠」其實是偵測壞了,不是真的乾淨 + +--- + ## B. 只有 leo 能跑的(雲端) 機器碰不到 claude.ai 的 Cloud environment 設定,這段一定要你動手。 diff --git a/docs/hooks-inventory.md b/docs/hooks-inventory.md index 778d006..ce0b13c 100644 --- a/docs/hooks-inventory.md +++ b/docs/hooks-inventory.md @@ -1,4 +1,4 @@ -# 61 支閘,白話盤點表 +# 62 支閘,白話盤點表 > 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」 > 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。 @@ -7,14 +7,14 @@ ## 一句話結論 -`hooks/` 底下有 **61 個 `.sh` 檔**,`hooks.json` 實際掛上 **84 條註冊**(同一支閘常被多種情境同時掛上); +`hooks/` 底下有 **62 個 `.sh` 檔**,`hooks.json` 實際掛上 **85 條註冊**(同一支閘常被多種情境同時掛上); 其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。 下面按「你會在什麼時候撞到它」分組,一支一行。 > 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」, > 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。 > 現在的寫法是實際數出來的: -> `ls hooks/*.sh | wc -l` = 61;`grep -c '"command":' hooks/hooks.json` = 84。 +> `ls hooks/*.sh | wc -l` = 62;`grep -c '"command":' hooks/hooks.json` = 85。 > ⚠️ **冒號不能省**:`grep -c '"command"'`(沒冒號)會連 `"type": "command"` 一起數到,回 **120**。 > 本頁 2026-08-27 之前寫的是沒冒號那版——**照著它跑會拿到一個跟本頁不符的數字**。 > **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。 @@ -98,6 +98,11 @@ > 另外描述欄的「38 支腳本」是把 `kbdb-live-exam/`/`lib/`/`patches/` 三個**目錄**也數進去了; > 用本頁寫死的那道指令(只數檔案)實數是 **39**。 +> 📌 **`inkstone/ISEP#68`(2026-09-03)進來一支**:`unreleased-police.sh`(F 組,Stop)。 +> 61→**62** 支、84→**85** 條,兩個數字都是**在自己的樹上當場數出來的**(指令同上), +> 不是拿上一版加一。它只掛 `Stop` 一個事件(+1 支、+1 條)——刻意不掛 `SubagentStop`: +> subagent 交回分支、release 由總管出,在 subagent 收工逼它出貨純屬誤攔。 + --- ## 怎麼讀這張表 @@ -226,6 +231,7 @@ | `delivery-police.sh` | AI 宣稱「這件事做完了」,卻看不到任何實測證據(畫面截圖、指令輸出、HTTP 狀態碼…)就擋下。 | 🛑 擋 | | `wiki-first-police.sh` | AI 做完事卻沒有把結論寫回 wiki 就想收工,就擋下——下次(或別的 AI)查 wiki 會查不到這次做過什麼。 | 🛑 擋 | | `unpushed-police.sh` | AI 改好的東西還留在本機、沒有真的推送出去給別人用,卻想收工,就擋下——「改對了但沒送到」跟沒改是一樣的。 | 🛑 擋 | +| `unreleased-police.sh` | 這個 session 改過的 repo,收工那一刻還沒**出貨**(沒有一個 release tag 收進 HEAD),就擋一次,並點名是哪個 repo、最後 commit 與最新 release 差幾筆。「改過」認的是**這個 session 期間新長出來的 commit**(不是工作區髒、不是 push),跟「出貨」用同一個單位(commit)才數得出差多少。**只在收工問一次,不是每次 commit 都吼**(leo 明說可以累積)。三種不擋:①只改了 wiki/docs(知識就地被讀,不需出版本)②repo 根目錄放了 `.isep-release-exempt`(產物/模板 repo 自己帶理由排除,不是中央手列清單)③在 `.claude/release-holds.md` 寫一行「這次先不出,因為 X」(放行且留痕,出貨後刪掉那行)。只掛 `Stop` 不掛 `SubagentStop`——subagent 交回分支、release 由總管出,在 subagent 收工逼它出貨純屬誤攔。 | 🛑 擋(至多攔一次) | > 🔴 **`claim-verify-police.sh` 已於 `inkstone/ISEP#60` 移除**(連同下面原本的 G 組 `subagent-claim-worksheet.sh`)。 > 這一對是自造的「待驗單」機制(一張沒有狀態、沒有持有人的 markdown),違反 D58: diff --git a/hooks/hooks.json b/hooks/hooks.json index 391d1c3..5e26a27 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -364,6 +364,14 @@ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/countdown-guard.sh" } ] + }, + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/unreleased-police.sh" + } + ] } ], "SubagentStop": [ diff --git a/hooks/unreleased-police.sh b/hooks/unreleased-police.sh new file mode 100755 index 0000000..c47159a --- /dev/null +++ b/hooks/unreleased-police.sh @@ -0,0 +1,241 @@ +#!/usr/bin/env bash +# unreleased-police.sh — 未出貨警察(Stop hook) inkstone/ISEP#68 +# +# 🔴 本機 LC_ALL 未固定時,把變數(repo 名/數字)串進含全形標點的字串會吃掉字元 +# (unpushed-police.sh 檔頭第 4 行記過同一課)。固定 locale,別讓報告被環境吃掉。 +export LC_ALL=C +# +# ── leo 2026-08-27 的規格(原話就是判準,inkstone/ISEP#68)──────────────── +# 「每個 repo 只要有改動就要出貨」 +# 「這個警察很簡單,這個 session 有改但要結束沒出貨就抓, +# 你可以久一點再出貨累積幾個 PR,但最後一定要出貨」 +# 「版本出去之前都只是內部測試」 +# +# 掛點 Stop(session 要結束的那一刻)—— 不是每次 commit,只在收工問一次 +# 條件 這個 session 改過的 repo +# 檢查 有沒有出貨(新的 release) +# 寬容 可以累積幾個 PR 再一起出(「先不出」要留痕) +# 底線 session 結束前一定要出 +# +# ── 為什麼是 Stop、不是 SubagentStop(誤攔第一防線)──────────────────── +# leo 規則三點七:subagent 交 deliverable(分支上的 commit),**總管**把它們 +# 收斂成一個 release。subagent 交回分支、天生不出 release ⇒ 在 SubagentStop +# 要它出貨=逼它做一件它不該做的事=純誤攔。所以本閘只掛 Stop。 +# +# ── 四個判準,每一個都寫得出理由(票要求我自己判,不照抄總管)────────── +# +# ① 「改過」= 這個 session 期間 HEAD 前進了新的 commit(不是「工作區髒」、不是「push」)。 +# · 出貨(release/tag)的單位是 commit;leo 的「累積幾個 PR」也是累積 commit。 +# 「改過」要和「出貨」用同一個單位對齊,才數得出「差多少」。 +# · 「工作區髒」還沒落地,且已被 unpushed-police 管——用它會重疊、又會逼暫存檔出貨(誤攔)。 +# · 「push」是 D20 開閘的動作、又是 release 的上游,用它當「改過」太晚。 +# session 起點 = transcript 第一列的時間戳(同 countdown.py 的基準:一段對話=一個 release)。 +# +# ② 「該不該出貨」= 這個 session 的 commit 有沒有動到**知識以外**的檔案。 +# principles.md:環境(hooks/commands/skills/scripts/plugin)要升版才到得了人手上; +# 知識(wiki/docs/_archive)改了就地被讀到,不需要出版本。 +# ⇒ 只改 wiki/docs 的 commit **不算要出貨的改動**(票驗收 4「只改了 wiki 不該擋」)。 +# 判準是「有沒有任何一個改到的檔在知識目錄之外」,不是維護一張「哪些算 code」的清單。 +# 方向:把 docs 誤判成「不用出貨」是漏擋(安全方向);把 wiki 判成「要出貨」是誤攔(要避免)。 +# +# ③ 「哪些 repo 不適用」= repo 根目錄有 `.isep-release-exempt` 這個檔(裡面寫一行理由)。 +# · 判準是「檔在不在」,不是一張會過期的中央手列清單,也不是名字黑名單。 +# · **預設納管**:新 repo 進來自動被管(沒人需要記得加),只有產物 repo/模板 repo +# 這種真的不出版的才放一個 marker——而且它自己帶著「為什麼不適用」的理由。 +# (票明說 arcrun-rag-bundles-staging 是產物、system-dev-template 是模板;它們各放一個 marker 即可。) +# +# ④ 「這次先不出」= $CLAUDE_PROJECT_DIR/.claude/release-holds.md 裡有一行提到這個 repo(+理由)。 +# · 形狀跟 unpushed-police 的 branch-holds.md 一樣:**要求某個東西在場**(leo 認可的兩種閘形狀之一), +# 不是去 transcript 裡撈「先不出因為」這種字(文字層封路已被證偽:8 次誤攔 0 次命中)。 +# · 「明說先不出」寫進一個檔=同時滿足「放行」與「留痕」(票驗收 5)。 +# · 出貨了就把那一行刪掉——訊息會這樣講。 +# +# ── 「出貨」怎麼認 ────────────────────────────────────────────────────── +# shipped ⟺ `git tag --contains HEAD` 非空(HEAD 這顆已經被某個 release tag 收進去)。 +# Gitea 的 release 就是 tag;tag 打在哪顆 commit 上是本機答得準的事實,不必打網路。 +# 沒被收進去 ⇒ 用 `git describe` 找出「最新 release」、數出 HEAD 領先它幾筆—— +# 那就是「最後 commit 與最新 release 差多少」(票驗收 1 要訊息點得出來)。 +# +# ── fail-open:Stop 閘擋下內部錯誤會鎖死 session,誤攔是本 repo 第一級缺陷 ── +# 拿不到 transcript 起點/不是 git repo/git 問不出來 ⇒ 一律放行。 +# +# 迴歸測試:scripts/test-unreleased-police.sh(含「不該擋」與「出路走得通」兩組) + +input="$(cat 2>/dev/null || echo '{}')" + +# ── 防無限迴圈:已被別的 Stop 閘擋出來這一輪 ⇒ 不再擋 ── +stop_active="$(printf '%s' "$input" | python3 -c ' +import sys, json +try: print(json.load(sys.stdin).get("stop_hook_active", False)) +except Exception: print(False) +' 2>/dev/null)" +[ "$stop_active" = "True" ] && exit 0 + +# ── 一次取出 transcript 路徑與 session 起點(epoch)── +# 起點的算法跟 hooks/lib/countdown.py transcript_start 同源:transcript 第一列的時間戳。 +LIBDIR="$(dirname "$0")/lib" +read -r START_EPOCH TRANSCRIPT </dev/null) +EOF2 + +# 拿不到起點 ⇒ 無從界定「這個 session 改了什麼」⇒ 放行(誤攔比漏擋嚴重) +case "$START_EPOCH" in ''|'-'|*[!0-9]*) exit 0 ;; esac + +TOP="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/../.." && pwd)}" + +# 掃描對象:頂層 + 已知子 repo(存在才掃),與 unpushed-police 同一份排法。 +REPOS="" +for _body in "$TOP" "$TOP/InkStoneCo"; do + [ -d "$_body/.git" ] || [ -f "$_body/.git" ] || continue + REPOS="$REPOS $_body $_body/matrix/arcrun $_body/products/arcrun-rag $_body/polaris/mira" +done +[ -n "$REPOS" ] || REPOS="$TOP" + +HOLDS="$TOP/.claude/release-holds.md" + +problems="" +held_note="" +exempt_note="" + +for r in $REPOS; do + [ -d "$r/.git" ] || [ -f "$r/.git" ] || continue + name="${r#$TOP/}"; [ "$name" = "$r" ] && name="$(basename "$r")" + [ "$r" = "$TOP" ] && name="(頂層) $(basename "$r")" + base="$(basename "$r")" + + # ── ① 這個 session 有沒有新 commit(reachable from HEAD, committer-date >= 起點)── + files="$(git -C "$r" log HEAD --since="@$START_EPOCH" --name-only --format= 2>/dev/null \ + | grep -v '^$' | sort -u)" + [ -z "$files" ] && continue # 這個 session 沒動這個 repo → 放行(票驗收 3/4「只讀不寫」) + + # ── ② 有沒有動到「知識以外」的檔(要出貨的改動)── + # 知識目錄:路徑含 /wiki/、/docs/、/_archive/,或以它們開頭。 + nonknow="$(printf '%s\n' "$files" | grep -vE '(^|/)(wiki|docs|_archive)(/|$)' | head -1)" + [ -z "$nonknow" ] && continue # 只改了知識(wiki/docs)→ 不需出貨(票驗收 4) + + # ── ③ 這個 repo 是不是 marker 排除(產物/模板)── + if [ -f "$r/.isep-release-exempt" ]; then + _why="$(head -1 "$r/.isep-release-exempt" 2>/dev/null)" + exempt_note="$exempt_note + - $name(.isep-release-exempt:${_why:-未寫理由})" + continue + fi + + # ── 已經出貨了嗎?HEAD 被某個 tag 收進去 ⇒ 出過貨,放行(票驗收 2)── + if [ -n "$(git -C "$r" tag --contains HEAD 2>/dev/null | head -1)" ]; then + continue + fi + + # ── 到這裡:這個 session 動過、要出貨、卻還沒出貨 ── + # 「最後 commit 與最新 release 差多少」 + base_tag="$(git -C "$r" describe --tags --abbrev=0 HEAD 2>/dev/null || true)" + if [ -n "$base_tag" ]; then + n="$(git -C "$r" rev-list --count "$base_tag"..HEAD 2>/dev/null || echo '?')" + detail="最新 release $base_tag ,HEAD 領先它 $n 筆(其中有環境/code 改動未出貨)" + else + n="$(git -C "$r" rev-list --count HEAD 2>/dev/null || echo '?')" + detail="從未出過版(一個 release tag 都沒有),HEAD 共 $n 筆 commit 未出貨" + fi + short_top="$(printf '%s\n' "$files" | grep -vE '(^|/)(wiki|docs|_archive)(/|$)' | head -3 \ + | sed 's/^/ · /')" + + # ── ④ 這次先不出(release-holds.md 有提到這個 repo)⇒ 放行 + 留痕 ── + if [ -f "$HOLDS" ] && grep -qF "$base" "$HOLDS" 2>/dev/null; then + _line="$(grep -F "$base" "$HOLDS" 2>/dev/null | head -1)" + held_note="$held_note + - $name(先不出,理由在 .claude/release-holds.md):$_line" + continue + fi + + problems="$problems + 📦 $name :$detail + 這個 session 動到、需要出貨的檔(節錄): +$short_top" +done + +# 沒有任何「改了卻沒出貨」的 repo ⇒ 放行 +if [ -z "$problems" ]; then + # 有 hold/exempt 但沒有真問題時安靜放行——不無事生非(票紅線) + exit 0 +fi + +REPLACE_HOLDS="$HOLDS" + +printf '%s\n' "🚢 未出貨警察:這個 session 改過的 repo,收工前還沒出貨。" >&2 +# 靜態訊息用 quoted heredoc(mistakes.md:unquoted heredoc 會把反引號當命令執行); +# 動態內容用 printf '%s' 分開印,變數與全形標點之間都留空白(避免 locale 吃字元)。 +cat >&2 <<'EOF' + +【leo 2026-08-27(原話就是判準)】 + 「每個 repo 只要有改動就要出貨」「版本出去之前都只是內部測試」 + 「你可以久一點再出貨累積幾個 PR,但最後一定要出貨」 +【判準】改對了、commit 了 ≠ 送到人手上。release 出去之前都只是內部測試。 +EOF + +printf '%s\n' "$problems" >&2 + +if [ -n "$held_note" ]; then + printf '%s\n' "" >&2 + printf '%s\n' "⏸️ 這次先不出(已留痕,不擋):$held_note" >&2 +fi +if [ -n "$exempt_note" ]; then + printf '%s\n' "" >&2 + printf '%s\n' "⊘ 排除(.isep-release-exempt):$exempt_note" >&2 +fi + +cat >&2 <<'EOF' + +━━ 收工前擇一,都走得通 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 1. 該出貨就出貨:升版(改了會被載入的東西=plugin/worker/bundle 就升版) + + 打 release tag。ISEP 自己:bash scripts/check-version-consistency.sh 先對齊, + 版本三處一致後由總管打 tag。 + 2. 這次先不出(累積、或交回分支由總管出):在下面這個檔寫一行理由—— + 寫完再收工就放行,而且留了痕,總管看得到有東西在路上: +EOF + +printf '%s\n' " $REPLACE_HOLDS" >&2 +printf '%s\n' " 一行格式: : <為什麼這次先不出>" >&2 +printf '%s\n' " (repo 名就是上面每個 📦 後面那個名字的最後一段,例如 ISEP/mira)" >&2 + +cat >&2 <<'EOF' + 3. 是產物 repo/模板 repo(本來就不出版):在該 repo 根目錄放一個 + .isep-release-exempt,第一行寫理由。之後這支閘就不再管它。 + +🔴 本閘只在收工那一刻問一次,不是每次 commit 都吼(leo 明說可以累積)。 + 出貨之後記得把 release-holds.md 裡對應那一行刪掉,否則下次它會以為你還在累積。 +EOF +exit 2 diff --git a/scripts/test-unreleased-police.sh b/scripts/test-unreleased-police.sh new file mode 100755 index 0000000..d5ead82 --- /dev/null +++ b/scripts/test-unreleased-police.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# unreleased-police.sh 的迴歸測試(inkstone/ISEP#68,docs/TESTING.md A27) +# +# 兩個方向都要有證據(本 repo 心法:誤攔比漏擋嚴重): +# A 群「該擋」 —— session 改了 code 卻沒出貨(含「從未出過版」與「領先最新 release N 筆」) +# B 群「不該擋」 —— 只讀不寫/只改 wiki/已出貨(tag 收進 HEAD)/排除 marker/這次先不出/stop_hook_active +# C 群「出路走得通」—— 照擋下訊息教的「寫 release-holds.md 一行」做,再送一次就放行(ISEP#112 的課) +# D 群「鑑別力」 —— 拿掉 marker 同一個 repo 就會擋(證明放行是 marker 造成的,不是偵測壞了) +# +# 🔴 全程在乾淨的 TMP 底下造假 repo/假 transcript,跑完自己清;不碰任何真 repo、不打網路。 +set -u +HOOK="${1:-$(cd "$(dirname "$0")/.." && pwd)/hooks/unreleased-police.sh}" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +# session 起點固定成 2026-01-01T00:00:00Z(transcript 第一列的時間戳)。 +# 「這個 session 的 commit」=committer-date 晚於這一刻。 +T0="2026-01-01T00:00:00Z" +TR="$TMP/transcript.jsonl" +printf '{"timestamp":"%s","type":"user","message":{"content":[{"type":"text","text":"開工"}]}}\n' "$T0" > "$TR" +AFTER="2026-06-01T00:00:00Z" # 晚於起點 = 這個 session 改的 +BEFORE="2025-06-01T00:00:00Z" # 早於起點 = 不是這個 session 改的 + +PASS=0; FAIL=0; N=0 + +mkrepo() { git init -q "$1"; git -C "$1" config user.email t@t; git -C "$1" config user.name t; } +# commit <相對路徑> <訊息> +commit() { + mkdir -p "$(dirname "$1/$3")"; echo x >> "$1/$3"; git -C "$1" add -A + GIT_AUTHOR_DATE="$2" GIT_COMMITTER_DATE="$2" git -C "$1" commit -qm "$4" +} + +# fire <說明> <期望exit> [extra-json] [--grep <字串>] +fire() { + desc="$1"; want="$2"; repo="$3"; extra="${4:-}"; needle="${6:-}" + N=$((N+1)) + payload=$(printf '{"session_id":"s%s","transcript_path":"%s"%s}' "$N" "$TR" "$extra") + out=$(printf '%s' "$payload" | CLAUDE_PROJECT_DIR="$repo" bash "$HOOK" 2>&1); rc=$? + ok=1 + [ "$rc" = "$want" ] || ok=0 + if [ -n "$needle" ] && ! printf '%s' "$out" | grep -qF "$needle"; then ok=0; fi + if [ "$ok" = 1 ]; then + printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1)) + else + printf ' ❌ %s —— 期望 exit=%s,實得 exit=%s' "$desc" "$want" "$rc" + [ -n "$needle" ] && printf '(找 "%s")' "$needle" + printf '\n' + printf '%s\n' "$out" | sed -n '1,10p' | sed 's/^/ /' + FAIL=$((FAIL+1)) + fi +} + +echo "── A 群:該擋(改了 code 卻沒出貨)──" +mkrepo "$TMP/a1"; commit "$TMP/a1" "$AFTER" hooks/foo.sh "加一支 hook" +fire "改了 hook、從未出過版 → 擋,且點名 repo+筆數" 2 "$TMP/a1" "" --grep "從未出過版" + +mkrepo "$TMP/a2"; commit "$TMP/a2" "$BEFORE" hooks/a.sh a; git -C "$TMP/a2" tag v1 +commit "$TMP/a2" "$AFTER" hooks/b.sh b +fire "有舊 release、新 commit 未收進 → 擋,訊息說『領先它 N 筆』" 2 "$TMP/a2" "" --grep "領先它 1 筆" + +mkrepo "$TMP/a3"; commit "$TMP/a3" "$AFTER" scripts/x.sh x; commit "$TMP/a3" "$AFTER" system-dev/wiki/n.md n +fire "同一 session 改了 code+wiki → 擋(只要有一個非知識檔)" 2 "$TMP/a3" "" --grep "未出貨" + +echo "── B 群:不該擋 ──" +mkrepo "$TMP/b1"; commit "$TMP/b1" "$BEFORE" hooks/foo.sh old +fire "這個 session 什麼都沒 commit(只讀不寫)→ 放行" 0 "$TMP/b1" + +mkrepo "$TMP/b2"; commit "$TMP/b2" "$AFTER" system-dev/wiki/note.md "只改 wiki" +fire "這個 session 只改了 wiki → 放行" 0 "$TMP/b2" + +mkrepo "$TMP/b2b"; commit "$TMP/b2b" "$AFTER" docs/hooks-inventory.md "只改 docs" +fire "這個 session 只改了 docs → 放行" 0 "$TMP/b2b" + +mkrepo "$TMP/b3"; commit "$TMP/b3" "$AFTER" hooks/foo.sh code; git -C "$TMP/b3" tag v9 +fire "改了 code 而且已出貨(tag 收進 HEAD)→ 放行" 0 "$TMP/b3" + +mkrepo "$TMP/b4"; commit "$TMP/b4" "$AFTER" hooks/foo.sh code +printf '產物 repo,管線產出,不出版\n' > "$TMP/b4/.isep-release-exempt" +fire "改了 code 但有 .isep-release-exempt marker → 放行" 0 "$TMP/b4" + +mkrepo "$TMP/b5"; commit "$TMP/b5" "$AFTER" hooks/foo.sh code +mkdir -p "$TMP/b5/.claude"; printf 'b5: 交回分支,release 由總管出\n' > "$TMP/b5/.claude/release-holds.md" +fire "改了 code 沒出貨、但 release-holds.md 有這個 repo(先不出)→ 放行且留痕" 0 "$TMP/b5" + +mkrepo "$TMP/b6"; commit "$TMP/b6" "$AFTER" hooks/foo.sh code +fire "已被別的 Stop 閘擋過這一輪(stop_hook_active)→ 放行" 0 "$TMP/b6" ',"stop_hook_active":true' + +echo "── C 群:擋下訊息教的出路,照做真的走得通 ──" +mkrepo "$TMP/c1"; commit "$TMP/c1" "$AFTER" hooks/foo.sh code +fire "先擋一次" 2 "$TMP/c1" "" --grep "release-holds.md" +# 照訊息說的:在 .claude/release-holds.md 寫「: <理由>」 +mkdir -p "$TMP/c1/.claude"; printf 'c1: 這次累積,下個 session 一起出\n' > "$TMP/c1/.claude/release-holds.md" +fire "照訊息寫了 release-holds.md 那一行,再送 → 放行" 0 "$TMP/c1" + +echo "── D 群:鑑別力(放行是 marker/hold 造成的,不是偵測壞了)──" +# b4 若拿掉 marker,同一個 repo 就會擋 → 證明偵測本身是會亮的 +rm -f "$TMP/b4/.isep-release-exempt" +fire "把 b4 的 marker 拿掉 → 同一個 repo 就擋(偵測真的會亮)" 2 "$TMP/b4" + +echo +echo "$PASS/$N 通過" +[ "$FAIL" -eq 0 ]