#!/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