Files
ISEP/hooks/unpushed-police.sh
T
claude-code 3e83a4b4f3 修好「總管可以放行」那道門——它在 Linux(=每個雲端 session)上是焊死的(inkstone/ISEP#90 ④)
三支閘(prod-write-guard/main-and-prod-push-guard/stage-before-prod-guard)
判斷戳記新不新,都寫成這一行:

    MT=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0)

macOS(BSD stat)上 `-f %m` 就是 mtime,對的。
**GNU coreutils 的 `-f` 是「顯示檔案系統資訊」**,而且它一邊回非零、
一邊往 stdout 吐一整段區塊 ⇒ `||` 接上的秒數被那段文字污染
⇒ 下一行的 `case "$NOW$MT" in *[!0-9]*) return 1` 必然命中
⇒ **在 Linux 上那三支閘的戳記永遠不會被接受。**

後果不是少一個便利功能:那三支閘都是「擋下來、但總管看過就能放行」,
而放行那道門在雲端打不開 ⇒ **它們在雲端等於純擋**,
總管照著閘自己印的指示做,做幾次都打不開,閘也不會告訴他門是壞的
(inkstone/InkStoneCo#99 那次「連續四次蓋不出戳記」就是這個形狀)。

· hooks/lib/mtime.sh:先 `-c %Y`(GNU)再 `-f %m`(BSD),**每一步都驗是不是純數字**
  ——這個 bug 的成因正是「命令失敗了卻還是印了東西」,只看離開碼會再被騙一次。
  四支用到的檔各自帶一份 inline fallback:這道門不能因為少一個檔案就再關上一次。
· scripts/gate-ok:把散在各閘訊息裡的七種逃生口收斂成一個名字、一種形狀
  (`gate-ok <閘名> [參數]`)。原因是逃生口原本是「臨時湊出來的 Bash 指令」,
  而臨時湊的指令沒有穩定形狀可以事先放行——settings.json 的 allow 只能逐條完全比對,
  多一個 `&&`、換一個 session id 就落在規則外。它蓋的戳記與閘原本認的完全同一個檔、
  同一種語意(單次、綁 repo、綁 session、有效期都沒動),換掉的只有「怎麼蓋」。

測試 hooks/tests/gate-ok.test.sh:16/16。既有的三支測試(29/16/10)不受影響。
拿 main 跑同一份:②⑤(門打得開)紅、⑥⑦⑧(門沒變寬)全綠
⇒ 這支補的正是既有測試從來沒驗過的那半邊。
2026-08-28 00:11:42 +00:00

336 lines
19 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# unpushed-police.sh — 未推警察(Stop / SubagentStop hook
#
# 🔴 2026-08-05:本機 LANG=C.UTF-8 下,把變數(分支名/日期)串進含全形標點的字串會**吃掉字元**
# (實測 `- $b:$cnt 筆` → 分支名整個消失、只剩「:」的殘骸位元組 bc 9a)。
# LC_ALL=C 下同一段完全正常 ⇒ 固定 locale,別讓報告內容被環境吃掉。
export LC_ALL=C
#
# 病根(leo 2026-08-02 原話):「如果讓改了沒推會被警告?因為太常發生了」
#
# 一天之內同一個病發作六次,全部是「**改對了,但沒送到用戶手上**」:
# ① Gemini key 修好了(Arcrun 0d49989)→ 沒重建 cypher bundle ⇒ 線上 admin/ai=0
# leo 與全體封測者填 key 一律無效,rag_chat 全壞一整天
# ② CIS logo 換好了(512ea9f 7/31 20:39)→ daemon zip 是 15:35 打的 ⇒ 早 5 小時,
# 發出去的永遠是舊圖(leo 肉眼在 Finder 抓到)
# ③ manifest.daemon 欄被 release.mjs 吃掉 → 全體「檢查更新」壞掉一天
# ④ 推了 GitHub 但 jsDelivr @main 還在吐舊的 ⇒ 推對了用戶仍拿不到
# ⑤ fix/cis-round3-install 分支從未 push(今天才發現)
# ⑥ 07-30 同款前科:t143-t150 十筆 commit 只在本機,雲端誤報「線上程式碼失蹤」
#
# 為什麼要 hook 而非規則:scripts/check-deploy-drift.sh 七月三十就寫好了,
# 但**沒有任何東西會自動呼叫它**——只在文件裡被提到。
# 「機制存在 ≠ 會被讀到」。所以這支要自己跳出來,不能等人想到要跑。
#
# 機制:收工那刻掃所有已知 repo,有「未 commit」或「已 commit 未推」就 exit 2 提醒。
# 只提醒不阻擋——push 需要 leo 開閘(D20),hook 無權也不該代按。
# 豁免:純對話回合(沒動過檔)/已在本回合推過/stop_hook_active。
input="$(cat)"
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
# ══════════════════════════════════════════════════════════════════════
# 「遠端到底有沒有這顆 commit」——本檔唯一的判準(inkstone/ISEP#902026-08-28
# ══════════════════════════════════════════════════════════════════════
#
# 🔴 舊判準是「這條分支有沒有 upstream」,而**雲端 session 開出來的工作分支
# 天生就沒有 upstream**,內容卻等於遠端 main
#
# 薄殼 HEAD = 081c547d90dc2d80692485af083ca1f71f2094f2
# GitHub 遠端 main = 081c547d90dc2d80692485af083ca1f71f2094f2 ← 同一顆
#
# ⇒ 每個雲端 session、每次收工都被攔一次。2026-08-27 一個 session 攔五次,
# **五次全是誤報**。而永遠在響的警報=訓練人忽略這個警報
# (本檔第 ③ 段自己在 08-12 就寫過這句,這次是同一個病長在別的判準上)。
#
# 🔴 也不能改成「本機的 remote-tracking ref 有沒有含它」的變體:
# 那份 ref 會過期——08-28 實測本 session 的 `origin/main` 落後遠端整整 4 天。
# 本機只答得準「有」,答不準「沒有」⇒ 答「沒有」的時候要去問遠端。
#
# 三態,不是兩態:0=遠端有 1=遠端沒有 2=問不到(沒 remote/網路不通/沒權限)。
# **2 一律不報。** 誤攔比漏擋更該修(leo 2026-08-17:文字層那些閘 8 次誤攔、
# 0 次正確攔截)——把「離線」當成「你沒推」,就是拿雜訊懲罰謹慎。
# 🔴 戳記的 mtime 一律走 lib/mtime.sh:舊寫法(`stat -f %m || stat -c %Y`)在 Linux 上
# **永遠算不出數字**(GNU 的 `-f` 是「檔案系統資訊」,它一邊回非零一邊吐一整段文字,
# 於是後面接上的秒數被那段垃圾污染)⇒ 這支閘給總管的逃生口在雲端整個焊死,
# 而閘不會告訴他門是壞的(inkstone/ISEP#90 ④,全文見 hooks/lib/mtime.sh)。
# lib 不在時就地補一份同順序的定義——這道門不能因為少一個檔案就再關上一次。
. "$(dirname "$0")/lib/mtime.sh" 2>/dev/null || file_mtime() {
_fm=$(stat -c %Y "$1" 2>/dev/null || true)
case "${_fm:-}" in ''|*[!0-9]*) _fm=$(stat -f %m "$1" 2>/dev/null || true) ;; esac
case "${_fm:-}" in ''|*[!0-9]*) _fm=0 ;; esac
printf '%s' "$_fm"
}
GIT_ASK="env GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=/bin/echo GIT_SSH_COMMAND=ssh\ -oBatchMode=yes"
_TO=""
command -v timeout >/dev/null 2>&1 && _TO="timeout 15"
command -v gtimeout >/dev/null 2>&1 && _TO="gtimeout 15"
# ls_remote_heads <repo> <remote> → 印出「<sha> <ref>」數行;問不到就印空、離開碼 1
# 快取 120 秒:Stop hook 每個回合都跑,同一個遠端不必每回合都打一次網路。
ls_remote_heads() {
_lr_repo="$1"; _lr_rm="$2"
_lr_key="$(printf '%s|%s' "$_lr_repo" "$_lr_rm" | cksum | tr -d ' /')"
# 快取位置可被 UNPUSHED_CACHE_DIR 覆寫——測試要能造「同一個 repo、遠端變了」的場,
# 不能被上一個案例的快取餵到舊答案(那會是假綠)。
_lr_cache="${UNPUSHED_CACHE_DIR:-/tmp}/.unpushed-lsremote-$_lr_key"
if [ -f "$_lr_cache" ]; then
_lr_now=$(date +%s 2>/dev/null || echo 0)
_lr_mt=$(file_mtime "$_lr_cache")
case "$_lr_now$_lr_mt" in *[!0-9]*) _lr_mt=0 ;; esac
if [ "$_lr_mt" -gt 0 ] && [ $((_lr_now - _lr_mt)) -lt 120 ]; then
[ -s "$_lr_cache" ] || return 1
cat "$_lr_cache"; return 0
fi
fi
if _lr_out="$($_TO $GIT_ASK git -C "$_lr_repo" ls-remote --heads "$_lr_rm" 2>/dev/null)" \
&& [ -n "$_lr_out" ]; then
printf '%s\n' "$_lr_out" > "$_lr_cache" 2>/dev/null || true
printf '%s\n' "$_lr_out"
return 0
fi
: > "$_lr_cache" 2>/dev/null || true # 記「這次問不到」,免得每回合都等一次 timeout
return 1
}
# remote_has_commit <repo> <sha> → 0 遠端有/1 遠端沒有/2 問不到
remote_has_commit() {
_rh_repo="$1"; _rh_sha="$2"
[ -n "$_rh_sha" ] || return 2
# ① 本機就答得出「有」:任何 remote-tracking ref 含這顆 ⇒ 一定推過,不必打網路
if [ -n "$(git -C "$_rh_repo" branch -r --contains "$_rh_sha" 2>/dev/null | head -1)" ]; then
return 0
fi
_rh_asked=0
for _rh_rm in $(git -C "$_rh_repo" remote 2>/dev/null); do
_rh_ls="$(ls_remote_heads "$_rh_repo" "$_rh_rm")" || continue
_rh_asked=1
# 遠端某條 head 就是這顆 ⇒ 推過(雲端那個誤報正是落在這一格)
printf '%s\n' "$_rh_ls" | awk '{print $1}' | grep -qx "$_rh_sha" && return 0
# 遠端那顆本機也有時,才問得出祖先關係(本機沒有就只能靠上面的等值比對)
printf '%s\n' "$_rh_ls" | while read -r _rh_rsha _; do
[ -n "$_rh_rsha" ] || continue
git -C "$_rh_repo" cat-file -e "${_rh_rsha}^{commit}" 2>/dev/null || continue
git -C "$_rh_repo" merge-base --is-ancestor "$_rh_sha" "$_rh_rsha" 2>/dev/null && exit 7
done
[ $? = 7 ] && return 0
done
[ "$_rh_asked" = 1 ] && return 1
return 2
}
# remote_sha_of <repo> <遠端名> <分支名> → 印出遠端那顆 sha(拿不到印空)
remote_sha_of() {
ls_remote_heads "$1" "$2" 2>/dev/null \
| awk -v b="refs/heads/$3" '$2 == b { print $1; exit }'
}
TOP="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/../.." && pwd)}"
# 掃描對象:頂層 + 各子 repo(存在才掃)。worktree 一併涵蓋(git -C 會自己解)。
#
# 🔴 2026-08-28inkstone/ISEP#90)補雲端那一格:雲端的 `$CLAUDE_PROJECT_DIR`
# 是**薄殼根**,真身被 bootstrap.sh clone 到 `$TOP/InkStoneCo/`,子 repo 在
# `$TOP/InkStoneCo/matrix/arcrun` …。舊清單只認地端那種「頂層就是真身」的排法
# ⇒ **雲端上真身有東西沒推,本閘一輩子不會知道**(它掃的四個路徑一個都不存在)。
# ⇒ 這跟 ① 的誤攔是同一個病的兩面:在雲端,這支閘既亂叫、又看不到該看的地方。
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"
problems=""
for r in $REPOS; do
[ -d "$r/.git" ] || [ -f "$r/.git" ] || continue
name="${r#$TOP/}"; [ "$name" = "$TOP" ] && name="(頂層 InkStoneCo"
# ① 有改動沒 commit(排除 untracked,那多半是暫存/產物)
dirty="$(git -C "$r" diff --shortstat 2>/dev/null | head -1)"
staged="$(git -C "$r" diff --cached --shortstat 2>/dev/null | head -1)"
# ② 已 commit 但沒推(**當前分支**
#
unknown=""
# 🔴 判準是「遠端有沒有這顆 commit」,不是「有沒有 upstream」(ISEP#90,見檔頭那段)。
# 有 upstream 時仍照原本的算法報「領先幾筆」——那個數字有用;
# 沒 upstream 時去問遠端,問不到就**不報**。
unpushed=""
br="$(git -C "$r" branch --show-current 2>/dev/null)"
if [ -n "$br" ]; then
head_sha="$(git -C "$r" rev-parse HEAD 2>/dev/null || true)"
up="$(git -C "$r" rev-parse --abbrev-ref "@{upstream}" 2>/dev/null || true)"
if [ -n "$up" ]; then
n="$(git -C "$r" rev-list --count "$up".."$br" 2>/dev/null || echo 0)"
if [ "$n" != "0" ]; then
# upstream 本身可能過期(本機那份 ref 落後遠端 4 天是實測過的)
# ⇒ 先問遠端;遠端已經有這顆就不是「沒推」。
remote_has_commit "$r" "$head_sha"
[ "$?" = 0 ] || unpushed="領先 $up $n 筆"
fi
else
remote_has_commit "$r" "$head_sha"
case "$?" in
1) unpushed="分支 $br **從未推過**(遠端沒有 $(printf '%.7s' "$head_sha")" ;;
# 0=遠端有這顆(雲端 session 的工作分支就是這種)→ 不報
# 2=問不到遠端 → 不報,但這件事本身值得知道,收在下面當脈絡
2) [ -n "$(git -C "$r" rev-list --count HEAD 2>/dev/null)" ] \
&& unknown="分支 $br 問不到遠端(沒有 remote/網路不通)——**不當成沒推**" ;;
esac
fi
fi
# ③ 🔴 2026-08-05 t195 補:**散落分支**——有 commit 沒併回當前分支的其他本地分支。
# 原本只查 ①②(當前分支的髒樹/未推),所以 D36 授權修補躺在 fix/cis-round3-install
# 整整一週都沒被報過,害 feat/daemon 的收卡端全 401、新卡一張都寫不進去。
# leo 2026-08-05:「為什麼有分支沒 merge?都是你寫的,驗過就要 merge⋯⋯
# 如果它會留分支無明顯理由不 merge,那就要整理」。
# 判準=「這條分支有沒有當前分支沒有的 commit」,不是分支數量多寡。
stray=""
held=""
# 🔴 2026-08-15 訂正:基準**不能用當前簽出的分支**。
# 共用工作區常停在某條舊功能分支上(08-15 實撞:matrix/arcrun 停在
# fix/credential-resolve-without-auth-worker,而 main 已經領先它 17 筆)
# ⇒ 四條分支全被報成「散落」,其中三條早就併進 main 了。
# **這正是同一晚重複八次的那個病:檢查跑了,但檢查的對象是錯的。**
# ⇒ 基準改成「整合分支」:gitea/main > origin/main > main > 當前分支。
base="$br"
base_label=""
for cand in gitea/main origin/main main; do
if git -C "$r" rev-parse --verify -q "$cand" >/dev/null 2>&1; then base="$cand"; break; fi
done
base_label="$base"
# 🔴 2026-08-28inkstone/ISEP#90):**基準的 ref 也會過期。**
# 08-28 實測:本機 `origin/main` 落後遠端整整 4 天 ⇒ 拿它當基準,
# 「已經在遠端 main 上」的分支會被整批報成散落——包括**雲端 session 自己
# 那條工作分支**(它的內容就等於遠端 main)。
# 08-15 那次訂正的是「基準挑錯了分支」,這次是「基準挑對了但那份是舊的」:
# 同一個病的第三面——檢查跑了,但檢查的對象是舊的。
# ⇒ 問一次遠端;拿得到、而且本機真的有那顆 commit(不然算不了祖先),才換過去。
case "$base_label" in
*/*) _base_rm="${base_label%%/*}"; _base_br="${base_label#*/}" ;;
*) _base_rm="origin"; _base_br="$base_label" ;;
esac
_base_fresh="$(remote_sha_of "$r" "$_base_rm" "$_base_br")"
if [ -n "$_base_fresh" ] && git -C "$r" cat-file -e "${_base_fresh}^{commit}" 2>/dev/null; then
base="$_base_fresh"
base_label="$_base_rm/$_base_br(遠端實際那顆 $(printf '%.7s' "$_base_fresh")"
fi
if [ -n "$base" ]; then
for b in $(git -C "$r" for-each-ref --format='%(refname:short)' refs/heads/ 2>/dev/null); do
[ "$b" = "$base" ] && continue
git -C "$r" merge-base --is-ancestor "$b" "$base" 2>/dev/null && continue
cnt="$(git -C "$r" rev-list --count "$base".."$b" 2>/dev/null || echo 0)"
[ "$cnt" = "0" ] && continue
# ── 刻意保留的分支(2026-08-12 加)─────────────────────────────────
# 本閘原本只認得「該併」與「該刪」兩種,但**還有第三種:刻意不併**
# (例:`wip/stopped-agents-2026-08-10` 是 arcrun-rag#56 那六個 Go 檔的唯一一份,
# 保留理由與刪除條件記在該票上)。
# 沒有這個狀態 ⇒ 那條分支**每一回合都被叫一次**,而且叫的是同一件已經交代過的事。
# 🔴 **永遠在響的警報,等於訓練人忽略這個警報**——那才是真正的損失,
# 因為下一條真的失蹤的分支會混在同一堆雜訊裡。
# 門檻刻意留著:要寫一行理由進 `.claude/branch-holds.md` 才生效,
# **不能只在對話裡說**(對話會消失,這正是本閘存在的理由)。
_holds="$TOP/.claude/branch-holds.md"
if [ -f "$_holds" ] && grep -qF "$b" "$_holds" 2>/dev/null; then
# 🔴 這裡**不要**用 sed 去切掉「分支名:」那段前綴。
# 本檔開頭第 4 行已經記過:`LC_ALL=C` 下處理含全形標點的字串會咬掉位元組,
# 實測切完會吐出 `刻意保留——\xef\xbf\xbd它是` 這種殘骸。
# 整行照印就好——那一行本來就寫得像人話,前綴留著也讀得通。
_why="$(grep -F "$b" "$_holds" | head -1)"
held="$held
$_why"
continue
fi
age="$(git -C "$r" log -1 --format='%ad' --date=format:'%m-%d' "$b" 2>/dev/null)"
# 用真實換行累積,不用 \n 逃脫——否則後面的 printf '%b' 會把分支名再解讀一次
stray="$stray
- $b$cnt 筆未併入 $base_label(最後動 $age"
done
fi
line=""
[ -n "$dirty" ] && line="$line
· 未 commit$dirty"
[ -n "$staged" ] && line="$line
· 已 staged 未 commit$staged"
[ -n "$unpushed" ] && line="$line
· $unpushed"
[ -n "$stray" ] && line="$line
· 🔀 **散落分支**(有 commit 沒併回 $base_label):$stray"
# 刻意保留的分支**不會自己觸發警報**(那正是加它的目的),
# 但這個 repo 若本來就有別的問題要報,就順便把它列出來當脈絡——
# 免得「保留」變成另一種看不見。
[ -n "$line" ] && [ -n "$unknown" ] && line="$line
· ❔ $unknown"
[ -n "$line" ] && [ -n "$held" ] && line="$line
· ⏸️ 刻意保留(不觸發警報,理由在 .claude/branch-holds.md):$held"
[ -n "$line" ] && problems="$problems
📍 $name$line"
done
[ -z "$problems" ] && exit 0
cat >&2 <<EOF
🚓 未推警察:有東西改了但還留在本機。
【leo 2026-08-02】「如果讓改了沒推會被警告?**因為太常發生了**」
【判準】改對了 ≠ 送到用戶手上。同一天發作六次,每次都是這個病。
$problems
收工前擇一處理(**一句話交代也算,別默默留著**):
1. 該推就推 → Gitea 直接 push(不需開閘);GitHub 要 leo 跑 scripts/github-arm.sh
2. 還不該推 → 在回覆裡**明說**「X 先不推,因為 Y」,讓 leo 知道有東西在路上
3. 是暫存/產物不該進版控 → 加 .gitignore 或刪掉,別讓它每次都來吵
⚠️ 特別注意「**從未推過**」的分支:那是最容易變成失蹤程式碼的一種
07-30 t143-t150 十筆、08-02 fix/cis-round3-install 都是這樣被發現的)。
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔀 **散落分支怎麼處理**leo 2026-08-05:「驗過就要 merge⋯⋯無明顯理由不 merge 就要整理」)
**它害過什麼**:D36 授權修補(07-29)躺在 fix/cis-round3-installfeat/daemon 沒合併
⇒ 收卡端全 401、**新卡整整一週一張都沒寫進 youlin**,還害人往「token 過期」查錯方向。
**分支沒合併不是整潔問題,是會產生「修好了卻沒生效」的假象。**
每條散落分支擇一,**不准放著不管**:
A. **驗過了 → 合併**(這是預設):git merge 該分支,或 cherry-pick 需要的 commit
B. **還沒驗完 → 說明它在等什麼**,並在回覆裡標「等 X 才能併」
C. **已作廢 → 刪掉**git branch -D 該分支(留著只會讓下一個人以為它有效)
📌 判準是「**這條分支有沒有當前分支缺的東西**」,不是分支數量。
合併前先看它改了什麼:git log --oneline 當前..該分支、
git diff 當前...該分支 --stat
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🎯 CP 對帳(leo 2026-08-02:「**現在有 CP 要求到最終用戶會看到更新,
但還是會出現 commit 沒推,這表示完全忽略了 CP**」)
CRITICAL-PATH.md 早就寫死:
· 狀態只有三種:✅ 通(**附實測證據**)/◐ 半通(標明缺什麼)/❌ 斷
· **「程式碼寫完了」不是狀態**
· code 寫完沒部署,不准標 ✅
規則寫在那裡卻照樣違反,因為**自評的人跟做事的人是同一個**。所以在這裡問你:
Q1 這回合改的東西,服務的是哪一條 CP 的哪一步?
Q2 那一步現在是 ✅ / ◐ / ❌?**上面列出的未推項目,就是它還不能算 ✅ 的證據**
Q3 最終用戶現在去看,會看到這次的改動嗎?
看不到 → 它是 ◐,**不准在回覆裡寫「完成」「修好了」**,要寫「已改,未送達」
📌 推完 ≠ 用戶拿得到——中間夾 CDN/bundle/實例快取時,
必須從**用戶端的網址、用用戶端的參數形式**再驗一次
08-02 實例:GitHub 推對了,jsDelivr @main 仍吐三代前的 manifest)。
EOF
exit 2