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 記錄的取檔方式一起修掉。
This commit is contained in:
2026-09-02 10:48:08 +08:00
parent 49c9009090
commit 78dcb5e33b
5 changed files with 356 additions and 68 deletions
+117 -25
View File
@@ -2,8 +2,9 @@
# lib.sh — jsc hooks 共用函式。所有 hook 腳本 source 此檔。
# 輸入相容:Claude 式 stdin JSON、或環境變數(codex/copilot/antigravity/kiro 接線時設定)。
# 缺資料時安靜降級,hook 預設 exit 0,不可中斷宿主 CLI。
# 唯一例外:sdlc-gate.sh check 在「SDLC 階段鎖存在且模型不符」時會 exit 2 擋下該輪提示;
# 其餘情況(無鎖、資料不足無法判定)仍照舊 exit 0。
# 唯一例外:sdlc-gate.sh check 在「SDLC 階段鎖存在,而且不知道目前模型的能力」時會 exit 2
# 擋下該輪提示。不知道能力有三種:判不出是哪一支 CLI、判不出模型、模型不在能力標籤表上。
# 這是刻意的 fail-closed——放行等於閘門不存在。沒有階段鎖時仍照舊 exit 0。
#
# 結束碼:不適用。本檔是被 source 的共用函式庫,不是可執行入口,內部一次 exit 都沒有。
# 載入成功回 0(最後一行是函式定義);拿 `sh lib.sh` 直接跑也只是定義完函式回 0,不做事。
@@ -31,12 +32,58 @@ json_str() { # $1=欄位名
}
# 目前 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_SESSION_ID:-default}}"
[ -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() {
@@ -75,9 +122,17 @@ codex_session_file() { # $1=CODEX_HOME $2=session id
find "$ch/sessions" -type f -name "*$sid*.jsonl" 2>/dev/null | sort | tail -n 1
return 0
fi
files=$(find "$ch/sessions" -type f -name '*.jsonl' 2>/dev/null)
[ -n "$files" ] || return 1
printf '%s\n' "$files" | xargs ls -t 2>/dev/null | head -n 1
# 全樹最新的一支。原本寫成 `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() {
@@ -98,28 +153,57 @@ codex_model() {
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"
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
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; }
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}、~/.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
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
@@ -130,10 +214,18 @@ current_model_report() {
printf '\t\t%s\n' "$MODEL_SOURCE_CHECKS"
}
# 目前 CLI 名稱:環境變數 > 依 stdin 特徵猜測 > unknown
# 目前 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:-}" ]; then printf 'claude'
elif [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] || [ -n "${CLAUDECODE:-}" ] \
|| [ -n "${CLAUDE_CODE_SESSION_ID:-}" ]; then printf 'claude'
else printf 'unknown'; fi
}
+98 -20
View File
@@ -3,18 +3,37 @@
#
# 判準是階段的「能力標籤」,不是模型名稱:
# - 標籤資料讀 $JSC_HOME/model-tags.tsv,由 jsc-cli/tools/model-tags.sh sync 產生。
# - 目前模型 id 取自可驗證紀錄。優先讀 transcript,其次讀 hook stdin JSON 與 Codex 本機
# session 記錄。只有這些來源都失敗時,才接受 JSC_MODEL 這個人工覆寫。
# - 目前模型 id 取自可驗證紀錄,而且只讀屬於當前 CLI 的那一份(判定鏈見 lib.sh 的
# current_model_report)。claude 讀 transcript 與 hook stdin,codex 讀 hook stdin 與
# 自己的 session 記錄,其餘 CLI 本機沒有可讀的紀錄。只有這些來源都失敗時,才接受
# JSC_MODEL 這個人工覆寫。
# - 不採用對話內容裡模型自稱的 id。自我回報無法驗證,等同沒有閘門。
# - 鎖存的是「階段的必要標籤」,不是「上鎖那一刻的模型」。鎖當時的模型只留作記錄,
# 否則用不合格的模型起跑就會把自己鎖成合格,閘門永遠通過。
#
# 狀態檔:$JSC_HOME/sessions/{sid}.stage,單行「{stage}<TAB>{必要標籤}<TAB>{上鎖時的模型}<TAB>{模型來源}」。
# 政策是 fail-closed:不知道能力就擋下,知道才比對能力標籤。不知道能力有三種,一律擋下——
# 判不出是哪一支 CLI、判不出目前模型、模型判得出來但不在能力標籤表上。放行任何一種,用不
# 合格的模型跑階段就查不出來,閘門形同虛設。代價是被擋住的人可能不知道怎麼脫困,所以每一則
# 擋下的訊息都要帶逃生門:設 JSC_MODEL 人工指明模型,或執行本檔的 unlock 解除該階段的鎖。
#
# 狀態檔:$JSC_HOME/sessions/{cli}-{sid}.stage,單行
# 「{stage}<TAB>{必要標籤}<TAB>{上鎖時的模型}<TAB>{模型來源}」。
# 檔名帶 CLI 代號的理由見 lib.sh 的 stage_state_file:session id 判不出時會退回 default,
# 不分 CLI 就會共用同一支 default.stage,一支上的鎖擋到另一支。舊路徑
# $JSC_HOME/sessions/{sid}.stage 仍讀得到(往後相容),unlock 也會一併清掉,
# 但新的寫入一律走新路徑。
#
# 用法:
# sdlc-gate.sh lock {stage} 階段閘門:比對實際模型與該階段必要標籤,通過才上鎖。
# exit 0 = 通過並已上鎖;exit 1 = 未通過,呼叫端必須停止流程。
# sdlc-gate.sh unlock 移除狀態檔(被擋住又確定要放行時的逃生門)。
# sdlc-gate.sh unlock [{狀態檔}]
# 移除狀態檔(被擋住又確定要放行時的逃生門)。不帶參數時清掉這個
# 工作階段的新舊兩份;帶參數時清掉指定的那一份。
# 為什麼要收參數:hook 拿得到 stdin 的 session id 與 JSC_CLI,
# 手動執行拿不到,兩邊算出來的檔名可能不同,逃生門就按不到真正
# 擋人的那一支。所以擋人訊息會把實際讀到的路徑一起印出來,
# 照抄就解得開。參數只收 sessions 目錄底下的 .stage 檔,
# 避免這個逃生門變成任意刪檔的工具。
# sdlc-gate.sh check hook 模式(UserPromptSubmit):模型不符即擋下該輪提示。
# sdlc-gate.sh report 印出 {sid} {stage} {必要標籤} {模型 id} {模型來源} {判定};無鎖不印。
#
@@ -39,9 +58,10 @@
# 結束碼(一個子命令一列):
# lock 0=通過並已上鎖 1=未通過,呼叫端必須停止流程(階段名不合法、讀不到該
# 階段的必要標籤、判定不出目前模型、模型不在標籤表上、缺標籤、寫不進狀態檔)
# unlock 0=永遠成功,狀態檔不存在也算
# check 0=放行 2=擋下該輪提示。安靜放行:沒有階段鎖、舊格式狀態檔讀不出必要標籤、
# 必要標籤是 any、判定不出目前模型(只提醒不擋)
# unlock 0=永遠成功,狀態檔不存在也算;不帶參數時新舊兩種路徑都會清掉
# 2=參數不是 sessions 目錄底下的 .stage 檔
# check 0=放行 2=擋下該輪提示。安靜放行只剩三種:沒有階段鎖、舊格式狀態檔讀不出
# 必要標籤、必要標籤是 any。判定不出目前模型改為擋下,見上面的 fail-closed
# report 0=永遠成功,無鎖就不印
# wp-lock 0=已記下 2=存取庫不是 {owner}/{repo}、PR 編號不是數字、寫不進狀態檔
# wp-unlock 0=已結清,狀態檔不存在也算 2=存取庫或 PR 編號格式錯誤
@@ -112,7 +132,9 @@ case "${1:-}" in
*) read_stdin ;;
esac
sid=$(session_id)
state="$JSC_HOME/sessions/$sid.stage"
# 讀寫兩條路徑分開:寫入一律走帶 CLI 代號的新路徑,讀取則允許退回舊路徑(往後相容)。
state=$(stage_state_read_file)
state_new=$(stage_state_file)
TAGS_TSV="$JSC_HOME/model-tags.tsv"
STAGES="plan analyze implement maintain"
@@ -182,8 +204,33 @@ model_verdict() { # $1=stage $2=req $3=model
[ -z "$miss" ] && printf 'pass' || printf 'fail:%s' "$miss"
}
model_fail_message() { # $1=stage $2=checked
echo "[jsc][SDLC 閘門][ERR]:判定不出目前實際使用的模型,無法驗證是否符合階段「$1」。已檢查來源:${2:-無}。請確認 hook stdin 帶有 transcript_path 或 model 欄位、Codex session 記錄可讀,或明確設定 JSC_MODEL 作為人工覆寫後重跑。本次不進行任何工作。" >&2
# 逃生門說明。被擋住的人多半不熟這套東西,訊息要讓他照著做就脫困,所以兩條路都寫成
# 可以照抄的指令,而且把「可以填什麼」一併列出來。
gate_escape_hint() { # $1=必要標籤 $2=擋人的那一支狀態檔(可省略)
_ok=$(eligible_models "$1")
echo "逃生門二選一:"
echo " 1. 人工指明模型:設環境變數 JSC_MODEL={模型 id} 之後重來一次。標籤表上合格的模型 id:${_ok:-(表上沒有合格模型,請先補表)}。"
# 路徑照抄進指令裡:手動執行時算出來的檔名跟 hook 算出來的可能不同,不點名就解不到那一支。
if [ -n "${2:-}" ]; then
echo " 2. 解除這個階段的鎖:執行 sh $0 unlock $2。解除之後這道閘門就不再擋。"
else
echo " 2. 解除這個階段的鎖:執行 sh $0 unlock。解除之後這道閘門就不再擋。"
fi
}
# 判不出 CLI 時追加一句。這是「不知道能力」的第一種,成因與修法都跟另外兩種不同:
# 不知道是哪一支 CLI,就不知道該讀誰的模型紀錄,接線設定要把代號帶上。
gate_cli_hint() {
[ "$(cli_name)" = unknown ] || return 0
echo "另外:目前判不出是哪一支 CLI(環境變數 JSC_CLI 沒有設),所以連該讀哪一份模型紀錄都不知道。接線設定應以 JSC_CLI={CLI 代號} 帶上。"
}
model_fail_message() { # $1=stage $2=checked $3=必要標籤
{
echo "[jsc][SDLC 閘門][ERR]:判定不出目前實際使用的模型,無法驗證是否符合階段「$1」(需要 ${3:-未知})。已檢查來源:${2:-無}。本次不進行任何工作。"
gate_cli_hint
gate_escape_hint "${3:-any}"
} >&2
}
# --- 工作包 PR 閘門(狀態檔:$JSC_HOME/wp/{owner}-{repo}-{index}.pr,一個工作包一支) ---
@@ -291,14 +338,17 @@ case "${1:-check}" in
src=$(printf '%s' "$info" | cut -f2)
checked=$(printf '%s' "$info" | cut -f3-)
if [ -z "$cur" ]; then
model_fail_message "$stage" "$checked"
model_fail_message "$stage" "$checked" "$req"
exit 1
fi
if [ "$req" != "any" ]; then
have=$(model_tags "$cur")
if [ -z "$have" ]; then
echo "[jsc][SDLC 閘門][ERR]:模型「$cur」不在能力標籤表上,無法判定是否夠格跑階段「$stage」(需要 $req)。請把該模型補進 jsc-cli/references/model-tags.md 後執行 model-tags.sh sync,本次不進行任何工作。" >&2
{
echo "[jsc][SDLC 閘門][ERR]:模型「$cur」(來源 ${src:-未知})不在能力標籤表上,無法判定是否夠格跑階段「$stage」(需要 $req)。請把該模型補進 jsc-cli/references/model-tags.md 後執行 model-tags.sh sync,本次不進行任何工作。"
gate_escape_hint "$req"
} >&2
exit 1
fi
miss=$(missing_tags "$req" "$have")
@@ -309,13 +359,29 @@ case "${1:-check}" in
fi
fi
printf '%s\t%s\t%s\t%s\n' "$stage" "$req" "$cur" "$src" > "$state" 2>/dev/null || {
echo "[jsc][SDLC 閘門][ERR]:寫不進狀態檔 $state,階段鎖未生效。" >&2; exit 1; }
printf '%s\t%s\t%s\t%s\n' "$stage" "$req" "$cur" "$src" > "$state_new" 2>/dev/null || {
echo "[jsc][SDLC 閘門][ERR]:寫不進狀態檔 $state_new,階段鎖未生效。" >&2; exit 1; }
echo "[jsc][SDLC 閘門][OK]:階段「$stage」通過(需要 $req,目前模型 $cur,來源 $src),已上鎖。"
exit 0 ;;
unlock)
rm -f "$state" 2>/dev/null || true
target="${2:-}"
if [ -n "$target" ]; then
# 只認「上一層目錄叫 sessions、副檔名是 .stage」的路徑:逃生門不該順便變成任意刪檔的
# 工具。判準刻意不寫成「必須在這支行程算出來的 $JSC_HOME 底下」——擋人的是 hook,
# 解鎖的是人在殼層手動執行,兩邊的 JSC_HOME 未必相同,綁上去就會把照抄訊息的人擋掉,
# 那正是這個逃生門要避免的事。
case "$target" in
*/sessions/*.stage) ;;
*)
echo "[jsc][SDLC 閘門][ERR]:unlock 的參數只收 sessions 目錄底下的 .stage 檔,收到「$target」。不帶參數則清掉這個工作階段自己那一份。" >&2
exit 2 ;;
esac
rm -f "$target" 2>/dev/null || true
exit 0
fi
# 新舊兩種路徑都要清。只清新的話,舊格式的鎖永遠解不開,被它擋住的人就沒有逃生門。
rm -f "$state_new" "$(stage_state_legacy_file)" 2>/dev/null || true
exit 0 ;;
check)
@@ -330,15 +396,24 @@ case "${1:-check}" in
cur=$(printf '%s' "$info" | cut -f1)
src=$(printf '%s' "$info" | cut -f2)
checked=$(printf '%s' "$info" | cut -f3-)
# 判定不出模型時只提醒,不擋——否則使用者會被鎖在無法送出提示的狀態。
# 判定不出模型就擋下。這是刻意改成 fail-closed:放行等於「換一支讀不到紀錄的 CLI
# 就能繞過閘門」,閘門形同虛設。原本只提醒不擋,怕的是把人鎖在送不出提示的狀態——
# 那個風險是真的,所以訊息一定要把兩條逃生門寫清楚,照著做就能脫困。
if [ -z "$cur" ]; then
echo "[jsc] SDLC 階段「${stage}」需要標籤「${req}」,但判定不出目前模型。已檢查來源:${checked:-無}。請確認 transcript、Codex session 記錄,或設定 JSC_MODEL 人工覆寫。"
exit 0
{
echo "[jsc][SDLC 閘門][ERR]:SDLC 階段「${stage}」需要能力標籤「${req}」,但判定不出目前用的是哪個模型,也就無法確認夠不夠格。已檢查來源:${checked:-無}。本輪提示已擋下。"
gate_cli_hint
gate_escape_hint "$req" "$state"
} >&2
exit 2
fi
have=$(model_tags "$cur")
if [ -z "$have" ]; then
echo "[jsc][SDLC 閘門][ERR]:模型「${cur}」(來源 ${src:-未知})不在能力標籤表上,無法確認是否夠格跑階段「${stage}」(需要 ${req})。請補進 jsc-cli/references/model-tags.md 並執行 model-tags.sh sync;確定要放行請執行 jsc-hooks/hooks/sdlc-gate.sh unlock。本輪提示已擋下。" >&2
{
echo "[jsc][SDLC 閘門][ERR]:模型「${cur}」(來源 ${src:-未知})不在能力標籤表上,無法確認是否夠格跑階段「${stage}」(需要 ${req})。請補進 jsc-cli/references/model-tags.md 並執行 model-tags.sh sync。本輪提示已擋下。"
gate_escape_hint "$req" "$state"
} >&2
exit 2
fi
@@ -346,7 +421,10 @@ case "${1:-check}" in
[ -z "$miss" ] && exit 0
ok=$(eligible_models "$req")
echo "[jsc][SDLC 閘門][ERR]:SDLC 階段「${stage}」需要標籤「${req}」,目前模型「${cur}」(來源 ${src:-未知})缺少「${miss}」。請切換到下列任一模型後重送:${ok:-(表上無合格模型,請補表)}。要結束本階段的鎖請執行 jsc-hooks/hooks/sdlc-gate.sh unlock。本輪提示已擋下。" >&2
{
echo "[jsc][SDLC 閘門][ERR]:SDLC 階段「${stage}」需要標籤「${req}」,目前模型「${cur}」(來源 ${src:-未知})缺少「${miss}」。請切換到下列任一模型後重送:${ok:-(表上無合格模型,請補表)}。本輪提示已擋下。"
echo "要結束本階段的鎖請執行:sh $0 unlock $state"
} >&2
exit 2 ;;
report)
+9 -5
View File
@@ -24,10 +24,12 @@
#
# --- stage 模式 ---
#
# 階段鎖狀態檔沿用 sdlc-gate.sh 那一份($JSC_HOME/sessions/{sid}.stage,單行
# 階段鎖狀態檔沿用 sdlc-gate.sh 那一份($JSC_HOME/sessions/{cli}-{sid}.stage,單行
# 「{階段}<TAB>{必要標籤}<TAB>{上鎖時的模型}」),這裡只讀不寫:判準與格式留在 sdlc-gate.sh,
# 兩邊各存一份就會漂移。plan 與 analyze 是純邏輯階段,產出是 wiki 頁不是程式碼,所以那兩個
# 階段鎖著時 Write、Edit、MultiEdit 一律擋下;implement 與 maintain 本來就要寫檔,放行。
# 兩邊各存一份就會漂移。路徑一律取自 lib.sh 的 stage_state_read_file,連「新路徑優先、舊路徑
# 往後相容」這條規則也不在這裡抄第二份。plan 與 analyze 是純邏輯階段,產出是 wiki 頁不是
# 程式碼,所以那兩個階段鎖著時 Write、Edit、MultiEdit 一律擋下;implement 與 maintain
# 本來就要寫檔,放行。
#
# --- review 模式 ---
#
@@ -124,16 +126,18 @@ write_tool_or_exit() {
# ── stage:plan 與 analyze 階段鎖存在時擋下寫檔
if [ "$mode" = stage ]; then
write_tool_or_exit
state="$JSC_HOME/sessions/$(session_id).stage"
state=$(stage_state_read_file)
[ -f "$state" ] && [ -r "$state" ] || exit 0
stage=$(cut -f1 "$state" 2>/dev/null | head -n1)
case "$stage" in
plan|analyze) ;;
*) exit 0 ;; # implement 與 maintain 本來就要寫檔;讀不出階段也放行
esac
# 解除指令帶上實際讀到的狀態檔路徑:擋人的是 hook,解鎖的是人在殼層手動執行,兩邊算出來
# 的檔名未必相同,不點名就會解到別的地方去。
deny "目前鎖在 SDLC「$stage」階段,這個階段只產出計畫或分析頁,不寫檔案。本次寫入已擋下。" \
"改法:把結論寫進該階段的 wiki 頁;真的要動程式碼請先進入 implement 階段。
解除階段鎖:jsc-hooks/hooks/sdlc-gate.sh unlock"
解除階段鎖:sh $HERE/sdlc-gate.sh unlock $state"
fi
# ── review:稽核類技能執行中擋下寫檔