0024d697f9
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M6AcoT4ALtPLsoCKab6pXt
242 lines
12 KiB
Bash
Executable File
242 lines
12 KiB
Bash
Executable File
#!/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 <<EOF2
|
||
$(printf '%s' "$input" | LIBDIR="$LIBDIR" python3 -c '
|
||
import sys, json, os
|
||
sys.path.insert(0, os.environ.get("LIBDIR", ""))
|
||
start = ""
|
||
tp = ""
|
||
try:
|
||
d = json.load(sys.stdin)
|
||
tp = d.get("transcript_path") or ""
|
||
except Exception:
|
||
d = {}
|
||
try:
|
||
from countdown import transcript_start
|
||
dt = transcript_start(tp)
|
||
if dt: start = str(int(dt.timestamp()))
|
||
except Exception:
|
||
# lib 不在時就地補:讀第一列時間戳
|
||
try:
|
||
from datetime import datetime, timezone
|
||
with open(tp) as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if not line: continue
|
||
try: row = json.loads(line)
|
||
except Exception: continue
|
||
raw = row.get("timestamp")
|
||
if isinstance(raw, str) and raw.strip():
|
||
s = raw.strip().replace("Z", "+00:00")
|
||
try:
|
||
dt = datetime.fromisoformat(s)
|
||
if not dt.tzinfo: dt = dt.replace(tzinfo=timezone.utc)
|
||
start = str(int(dt.timestamp())); break
|
||
except Exception: pass
|
||
except Exception: pass
|
||
print(start or "-", tp or "-")
|
||
' 2>/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' " 一行格式: <repo 名>: <為什麼這次先不出>" >&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
|