#!/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#90,2026-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 → 印出「 」數行;問不到就印空、離開碼 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 → 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 <遠端名> <分支名> → 印出遠端那顆 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-28(inkstone/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-28(inkstone/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 <