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 記錄的取檔方式一起修掉。
266 lines
13 KiB
Bash
Executable File
266 lines
13 KiB
Bash
Executable File
#!/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; }
|