Files
hooks/hooks/lib.sh
T
jiantw83 78dcb5e33b fix(sdlc-gate): 能力閘門依 CLI 分流,判不出能力就擋下
What:
- current_model_report() 的偵測鏈依 CLI 分流,一支只讀自己的紀錄。claude 讀 transcript 與 hook stdin,codex 讀 hook stdin 與自己的 session 記錄,copilot、antigravity、kiro 本機沒有可讀的模型紀錄,判不出是哪一支 CLI 時不採用任何自動來源。JSC_MODEL 人工覆寫五支都保留,而且一律排在最後。
- session_id() 的退路鏈補上 CLAUDE_CODE_SESSION_ID,cli_name() 補上 CLAUDECODE 與 CLAUDE_CODE_SESSION_ID。
- 階段鎖狀態檔改成 sessions/{CLI 代號}-{sid}.stage。舊路徑仍讀得到,unlock 不帶參數時新舊兩份一起清,也收一個狀態檔路徑當參數。
- check 改成 fail-closed:判不出 CLI、判不出模型、模型不在能力標籤表上,三種一律以 exit 2 擋下該輪提示。每一則擋下的訊息都印出兩條逃生門。
- write-guard.sh 的 stage 模式改讀 lib.sh 的路徑函式,擋人訊息把實際讀到的狀態檔路徑寫進解除指令。
- codex_session_file() 取「全樹最新一支」的做法改掉。
- wire-cli.sh 的冒煙夾具跟著補:模型來源四條各自指定 CLI 代號,另加三種擋下情形、check 的 fail-closed、階段鎖的 CLI 區隔與舊格式相容,這一組的判定條數由四條增為十二條。
- README 的 hooks 表、狀態檔分界表與環境變數表跟著改。

Why:
- 使用者回報兩件事:在 claude 底下被 codex 的模型判定,而且這道閘門應該只鎖能力標籤、不鎖模型。查下來根因是同一個——閘門完全不分辨自己跑在哪一支 CLI 上。
- 偵測鏈不分流,claude 拿不到 transcript 就一路掉進 codex 的 session 記錄,拿別支的模型判這一支。順帶每一輪都對一個上千個檔、兩百多 MB 的目錄樹跑一次 find,宿主 CLI 跟著卡。
- session_id() 讀的變數名在目前的 claude 上並不存在,sid 一路退回 default。cli_name() 犯同一類錯:只認 plugin 接線才有的變數,技能以 Bash 工具呼叫腳本時取不到,一律判成 unknown。
- 前三項合起來的後果是:技能呼叫 lock 算出的檔名,跟 hook 算出的檔名永遠不同。階段鎖上了也對不上,所以這道閘門在 claude 上一直沒有真正生效。
- 狀態檔不分 CLI,各支就共用同一支 default.stage,一支上的鎖擋到另一支。那不只擋提示,write-guard.sh 的 stage 模式連 Write、Edit、MultiEdit 一起擋。

How:
- 政策改成 fail-closed:不知道能力就擋下,知道才比對標籤。原本判不出模型只提醒不擋,那等於「換一支讀不到紀錄的 CLI 就能繞過去」,閘門形同虛設。代價是把人鎖在送不出提示的狀態,所以每一則擋下的訊息都要帶兩條逃生門,照抄就脫困。
- 逃生門的路徑照抄進指令裡。擋人的是 hook,解鎖的是人在殼層手動執行,兩邊算出來的檔名未必相同,不點名就解不到真正擋人的那一支。
- unlock 的參數只收 sessions 目錄底下的 .stage 檔,逃生門不該順便變成任意刪檔的工具。判準刻意不綁這支行程算出的 JSC_HOME:擋人的一側跟解鎖的一側未必相同,綁上去就會把照抄訊息的人擋掉,那正是這個逃生門要避免的事。
- 狀態檔用檔名前綴不用子目錄。外部工具以單層的 sessions/*.stage 盤點階段鎖,改成子目錄會讓每一支鎖從那些盤點裡整批消失。
- 舊路徑只讀不搬也不刪,unlock 一併清掉。只清新的那一份,舊格式的鎖就永遠解不開,被它擋住的人沒有逃生門。
- 變數名只補實測看得到的那幾個,而且只補 claude 這一支。其他 CLI 的變數名與環境特徵沒有實測過,猜一個填進來只會多一個錯誤來源,那幾支本來就由接線設定明確帶 JSC_CLI。
- codex_session_file() 原本寫成 xargs ls -t 再 head -n 1,那是錯的:xargs 依參數長度分批,ls -t 只在自己那一批裡排序,取到的是第一批裡最新的,不是全域最新。改成讓 find 一併印出修改時間再全域排序;沒有 -printf 就退回路徑排序,那些檔名以 ISO 時間開頭,字典序等同時間序。
- 冒煙的擋人案例連訊息一起驗,不只比結束碼。訊息漏掉逃生門一樣是綠燈,而那才是這道 fail-closed 真正的風險。
- 四支腳本併成一筆。write-guard.sh 讀 lib.sh 的路徑函式,wire-cli.sh 是驗這些行為的冒煙夾具,拆開會留下跑不過的中間版本:路徑函式與呼叫端分兩筆,中間那一筆狀態檔的讀寫兩側就對不上。
- 一次性代價:升級後每台機器第一次 SessionStart 會清一次重啟閘門。sid 換了名字,看起來像新的工作階段。只發生這一次。
- 三份 manifest 這一筆不動,版本號另外提升。

Who:
SDLC 階段能力閘門。修的是它一直沒有生效的那條路徑,順帶把 codex session 記錄的取檔方式一起修掉。
2026-09-02 10:48:08 +08:00

266 lines
13 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 sh
# lib.sh — jsc hooks 共用函式。所有 hook 腳本 source 此檔。
# 輸入相容:Claude 式 stdin JSON、或環境變數(codex/copilot/antigravity/kiro 接線時設定)。
# 缺資料時安靜降級,hook 預設 exit 0,不可中斷宿主 CLI。
# 唯一例外:sdlc-gate.sh check 在「SDLC 階段鎖存在,而且不知道目前模型的能力」時會 exit 2
# 擋下該輪提示。不知道能力有三種:判不出是哪一支 CLI、判不出模型、模型不在能力標籤表上。
# 這是刻意的 fail-closed——放行等於閘門不存在。沒有階段鎖時仍照舊 exit 0。
#
# 結束碼:不適用。本檔是被 source 的共用函式庫,不是可執行入口,內部一次 exit 都沒有。
# 載入成功回 0(最後一行是函式定義);拿 `sh lib.sh` 直接跑也只是定義完函式回 0,不做事。
# 呼叫端真正要防的是「載入失敗」:POSIX sh 找不到這個檔時,`.` 會讓整支腳本就地結束並回 2。
# 接在 PreToolUse 的 hook 遇到這一下,等於無聲擋掉每一次工具呼叫,而且腳本自己的放行路徑
# 一條都跑不到。要安靜降級的呼叫端請寫 `. "$HERE/lib.sh" 2>/dev/null || true`
# (comment-scope.sh 就是這樣接);其餘直接載入的腳本,各自檔頭都標了這一條。
JSC_HOME="${JSC_HOME:-$HOME/.jsc}"
mkdir -p "$JSC_HOME/sessions" "$JSC_HOME/usage" 2>/dev/null || true
# 呼叫端腳本所在目錄。source 不會改變 $0,所以這裡取到的是 hooks/ 或 tools/。
JSC_SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd)
JSC_SCRIPT_DIR="${JSC_SCRIPT_DIR:-.}"
# 讀完 stdin(可能為空;非阻塞宿主)
read_stdin() {
if [ -t 0 ]; then STDIN_JSON=""; else STDIN_JSON=$(cat 2>/dev/null || true); fi
}
# 從 stdin JSON 取字串欄位(naive 但足夠應付各 CLI 的扁平 payload)
json_str() { # $1=欄位名
printf '%s' "$STDIN_JSON" | tr -d '\n' \
| sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n1
}
# 目前 session id:stdin JSON > 環境變數 > 固定值
#
# 環境變數這一段的順序:
# JSC_SESSION_ID jsc 自己的指定值(tools/jsc-wrap.sh 會設),人工指定優先於偵測。
# CLAUDE_CODE_SESSION_ID claude 實際匯出的名字,實測確認過。
# CLAUDE_SESSION_ID 只留作往後相容,排在實測名之後。這個名字在目前的 claude 上並不
# 存在,兩個都設到的話,該信的是實測看得到的那一個;只設到這一個
# 的版本仍然退得下來,所以留著不會有損失。
# 為什麼這一條要修:退路鏈原本只找 CLAUDE_SESSION_ID,那個名字取不到值,於是沒有 stdin 的
# 執行路徑(技能以 Bash 呼叫 sdlc-gate.sh lock)一律退回 default,而 hook 有 stdin、拿得到
# 真正的 id。同一個工作階段的兩條路徑因此算出兩支不同的狀態檔,階段鎖上了也永遠對不上。
# 這裡只補退路鏈的變數名,不動取值順序以外的行為:其他 CLI 的變數名沒有實測過,猜一個填進來
# 只會多一個錯誤來源。
session_id() {
sid=$(json_str session_id)
[ -n "$sid" ] || sid="${JSC_SESSION_ID:-${CLAUDE_CODE_SESSION_ID:-${CLAUDE_SESSION_ID:-default}}}"
printf '%s' "$sid"
}
# 階段鎖狀態檔名要用的 CLI 代號。這個值直接拼進檔名,所以不像代號的字元一律換掉;
# 帶斜線或空白的值會把檔案寫到別的地方去,理由與 restart-gate.sh 的做法相同。
stage_state_cli() {
cli_name | sed 's/[^A-Za-z0-9._-]/_/g'
}
# 階段鎖狀態檔的舊路徑:$JSC_HOME/sessions/{sid}.stage。只作往後相容的讀取來源,
# 以及 unlock 的清除對象,新的寫入一律不走這裡。
stage_state_legacy_file() {
printf '%s/sessions/%s.stage' "$JSC_HOME" "$(session_id)"
}
# 階段鎖狀態檔(寫入用):$JSC_HOME/sessions/{cli}-{sid}.stage。
# 為什麼要帶 CLI 代號:session_id() 判不出工作階段時會退回 default,各 CLI 於是共用同一支
# default.stage,一支上的階段鎖就會擋到另一支。那不只擋提示——write-guard.sh 的 stage 模式
# 在 plan 或 analyze 持鎖時連 Write、Edit、MultiEdit 一起擋掉。
# 為什麼用檔名前綴而不是子目錄:外部工具以單層的 sessions/*.stage 盤點階段鎖,改成子目錄
# 會讓每一支鎖從那些盤點裡整批消失;前綴照樣列得到,只是多一段代號。
stage_state_file() {
printf '%s/sessions/%s-%s.stage' "$JSC_HOME" "$(stage_state_cli)" "$(session_id)"
}
# 階段鎖狀態檔(讀取用):新路徑優先,沒有才回舊路徑。舊檔記的是實際工作,只讀不搬也不刪。
# 兩邊都沒有時回新路徑,讓呼叫端一律用「檔案在不在」判斷有沒有鎖。
stage_state_read_file() {
_sf=$(stage_state_file)
if [ -f "$_sf" ]; then
printf '%s' "$_sf"
else
_so=$(stage_state_legacy_file)
if [ -f "$_so" ]; then printf '%s' "$_so"; else printf '%s' "$_sf"; fi
fi
}
MODEL_SOURCE_CHECKS=""
model_checked() {
MODEL_SOURCE_CHECKS="${MODEL_SOURCE_CHECKS:+$MODEL_SOURCE_CHECKS;}$1"
}
model_clean() {
printf '%s' "$1" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'
}
json_model_value() {
printf '%s' "$1" \
| grep -o '"\(model\|model_id\|model_slug\|modelName\|current_model\|currentModel\)"[[:space:]]*:[[:space:]]*"[^"]*"' \
| sed 's/^"[^"]*"[[:space:]]*:[[:space:]]*"//; s/"$//' \
| grep -v '^<' \
| tail -n 1
}
model_from_file() { # $1=檔案
[ -f "$1" ] && [ -r "$1" ] || return 1
json_model_value "$(tail -n 1000 "$1" 2>/dev/null)" | tail -n 1
}
# transcript 是首選。它是 CLI 寫下的執行紀錄,不採用對話裡模型自己的宣稱。
transcript_model() {
tp=$(json_str transcript_path)
[ -n "$tp" ] || tp="${JSC_TRANSCRIPT_PATH:-}"
[ -n "$tp" ] && [ -f "$tp" ] || return 0
model_from_file "$tp"
}
codex_session_file() { # $1=CODEX_HOME $2=session id
ch="$1"; sid="$2"
[ -d "$ch/sessions" ] || return 1
if [ -n "$sid" ] && [ "$sid" != default ]; then
find "$ch/sessions" -type f -name "*$sid*.jsonl" 2>/dev/null | sort | tail -n 1
return 0
fi
# 全樹最新的一支。原本寫成 `xargs ls -t | head -n 1`,那是錯的:xargs 會依參數長度分批,
# ls -t 只在自己那一批裡排序,取到的是「第一批裡最新的」而不是全域最新。改成讓 find 一併
# 印出修改時間,所有檔案在同一輪比較,批次邊界就影響不到結果。
newest=$(find "$ch/sessions" -type f -name '*.jsonl' -printf '%T@\t%p\n' 2>/dev/null \
| sort -rn | head -n 1 | cut -f2-)
# find 沒有 -printf(非 GNU)時退回路徑排序。codex 的 session 依「年/月/日」分目錄,
# 檔名又以 ISO 時間開頭,字典序等同時間序,仍然是全域比較,不會被分批切斷。
[ -n "$newest" ] || newest=$(find "$ch/sessions" -type f -name '*.jsonl' 2>/dev/null \
| sort | tail -n 1)
[ -n "$newest" ] || return 1
printf '%s\n' "$newest"
}
codex_model() {
ch="${CODEX_HOME:-$HOME/.codex}"
[ -d "$ch" ] || return 1
sf=$(codex_session_file "$ch" "$(session_id)" 2>/dev/null || true)
if [ -n "$sf" ]; then
m=$(model_from_file "$sf" 2>/dev/null || true)
[ -n "$m" ] && { printf '%s\t%s\n' "$m" "codex-session:$sf"; return 0; }
fi
for f in "$ch/history.jsonl" "$ch/session_index.jsonl"; do
[ -f "$f" ] || continue
m=$(model_from_file "$f" 2>/dev/null || true)
[ -n "$m" ] && { printf '%s\t%s\n' "$m" "codex-jsonl:$f"; return 0; }
done
return 1
}
# 目前模型的判定。輸出單行「{模型 id}<TAB>{來源}<TAB>{已檢查來源}」,判不出時前兩欄留空。
#
# 偵測鏈依 CLI 分流:一支 CLI 只讀自己的紀錄。兩個理由。
# 一是正確性:跨過去讀別支的紀錄,拿到的是別支的模型,用它判定這一支等於沒有判準。
# 二是速度:別支的 session 目錄可能有上千個檔案,每一輪 hook 都掃一次會把宿主 CLI 拖住。
# 判不出是哪一支 CLI 時不猜任何來源——不知道是誰,就不知道該讀誰的紀錄。
# JSC_MODEL 這個人工覆寫對所有 CLI 都保留,而且一律排在最後:可驗證的紀錄優先於人工宣告。
current_model_report() {
MODEL_SOURCE_CHECKS=""
_mcli=$(cli_name)
model_checked "JSC_CLI:$_mcli"
case "$_mcli" in
claude)
# transcript 是 claude 自己寫下的執行紀錄,也是唯一逐輪更新的來源。
tp=$(json_str transcript_path)
[ -n "$tp" ] || tp="${JSC_TRANSCRIPT_PATH:-}"
model_checked "transcript_path:${tp:-未提供}"
if [ -n "$tp" ] && [ -f "$tp" ]; then
m=$(transcript_model)
[ -n "$m" ] && { printf '%s\t%s\t%s\n' "$(model_clean "$m")" "transcript:$tp" "$MODEL_SOURCE_CHECKS"; return 0; }
fi
model_checked "hook-stdin:model/model_id/model_slug/modelName/current_model/currentModel"
m=$(json_model_value "$STDIN_JSON")
[ -n "$m" ] && { printf '%s\t%s\t%s\n' "$(model_clean "$m")" "hook-stdin" "$MODEL_SOURCE_CHECKS"; return 0; }
;;
codex)
# hook 負載是這一輪由 codex 自己餵進來的,不是別支 CLI 的紀錄,所以照收;
# 而且它反映當下這一輪,比落在檔案裡的紀錄新,排在 session 記錄前面。
model_checked "hook-stdin:model/model_id/model_slug/modelName/current_model/currentModel"
m=$(json_model_value "$STDIN_JSON")
[ -n "$m" ] && { printf '%s\t%s\t%s\n' "$(model_clean "$m")" "hook-stdin" "$MODEL_SOURCE_CHECKS"; return 0; }
model_checked "codex:${CODEX_HOME:-$HOME/.codex}/sessions、history.jsonl、session_index.jsonl"
cm=$(codex_model 2>/dev/null || true)
if [ -n "$cm" ]; then
m=$(printf '%s' "$cm" | cut -f1)
src=$(printf '%s' "$cm" | cut -f2)
[ -n "$m" ] && { printf '%s\t%s\t%s\n' "$(model_clean "$m")" "$src" "$MODEL_SOURCE_CHECKS"; return 0; }
fi
;;
copilot|antigravity|kiro)
# 這三支由 tools/jsc-wrap.sh 包起來跑,只餵得到環境變數:沒有 transcript、沒有 hook
# 負載,本機也沒有可讀的模型紀錄。這裡如實記成「沒有來源」,不去翻別支 CLI 的檔案。
model_checked "$_mcli:本機沒有可讀的模型紀錄,只認 JSC_MODEL"
;;
*)
model_checked "未知 CLI:判不出是哪一支,不採用任何自動來源"
;;
esac
model_checked "JSC_MODEL"
if [ -n "${JSC_MODEL:-}" ]; then
printf '%s\t%s\t%s\n' "$(model_clean "$JSC_MODEL")" "人工覆寫:JSC_MODEL" "$MODEL_SOURCE_CHECKS"
return 0
fi
printf '\t\t%s\n' "$MODEL_SOURCE_CHECKS"
}
# 目前 CLI 名稱:環境變數 > 依環境特徵判斷 > unknown
#
# claude 的判準有三個,任一個成立就算。理由與 session_id() 那條退路鏈同源:CLAUDE_PLUGIN_ROOT
# 只有 plugin 接線的 hook 執行環境才有,技能以 Bash 工具呼叫腳本時並不存在,於是同一個工作
# 階段的兩條路徑一條認得出 claude、一條回 unknown,狀態檔名跟著分岔。CLAUDECODE 與
# CLAUDE_CODE_SESSION_ID 兩者在工具呼叫的環境裡都看得到(實測確認),補上去兩條路徑才算得出
# 同一個代號。只補 claude 這一支:其他 CLI 的環境特徵沒有實測過,猜一個填進來只會多一個
# 錯誤來源,那幾支本來就由接線設定明確帶 JSC_CLI。
cli_name() {
if [ -n "${JSC_CLI:-}" ]; then printf '%s' "$JSC_CLI"
elif [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] || [ -n "${CLAUDECODE:-}" ] \
|| [ -n "${CLAUDE_CODE_SESSION_ID:-}" ]; then printf 'claude'
else printf 'unknown'; fi
}
# 找出 jsc-gitea 的 tools/gitea.sh 絕對路徑。所有 gitea 操作一律經由它(技能準則),
# 不可自行拼 API 呼叫:token 取用與 tea 金鑰退回都寫在那支腳本裡。
# 找不到就回傳 1,由呼叫端安靜降級(hook 一律 exit 0,不中斷宿主 CLI)。
jsc_gitea_sh() {
if [ -n "${JSC_GITEA_TOOLS:-}" ] && [ -f "$JSC_GITEA_TOOLS/gitea.sh" ]; then
printf '%s\n' "$JSC_GITEA_TOOLS/gitea.sh"; return 0
fi
_root="${CLAUDE_PLUGIN_ROOT:-$JSC_SCRIPT_DIR/..}"
# 開發用的並排存取庫版面:{workspace}/hooks 旁邊就是 {workspace}/gitea
for _c in "$_root/../gitea/tools/gitea.sh" "$_root/../jsc-gitea/tools/gitea.sh"; do
[ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; }
done
# 已安裝版面:每個 plugin 各有版本目錄,取排序最後的一份(通常即最新版)
_c=$(ls -d "$_root"/../../jsc-gitea/*/tools/gitea.sh \
"$_root"/../../gitea/*/tools/gitea.sh \
"$HOME"/.claude/plugins/cache/*/jsc-gitea/*/tools/gitea.sh 2>/dev/null \
| sort | tail -n1)
[ -n "$_c" ] && [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; }
_c=$(command -v gitea.sh 2>/dev/null || true)
[ -n "$_c" ] && { printf '%s\n' "$_c"; return 0; }
return 1
}
# 每個 CLI 代號對應的實際執行檔(antigravity 是 agy、kiro 是 kiro-cli,其餘同名)
cli_bin() { # $1=CLI 代號
case "$1" in
antigravity) printf 'agy' ;;
kiro) printf 'kiro-cli' ;;
*) printf '%s' "$1" ;;
esac
}
now_epoch() { date +%s; }
now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; }