#!/usr/bin/env sh # sdlc-gate.sh — SDLC 階段模型閘門與 session 模型鎖(stage ∈ plan/analyze/implement/maintain)。 # # 判準是階段的「能力標籤」,不是模型名稱: # - 標籤資料讀 $JSC_HOME/model-tags.tsv,由 jsc-cli/tools/model-tags.sh sync 產生。 # - 目前模型 id 取自可驗證紀錄,而且只讀屬於當前 CLI 的那一份(判定鏈見 lib.sh 的 # current_model_report)。claude 讀 transcript 與 hook stdin,codex 讀 hook stdin 與 # 自己的 session 記錄,其餘 CLI 本機沒有可讀的紀錄。只有這些來源都失敗時,才接受 # JSC_MODEL 這個人工覆寫。 # - 不採用對話內容裡模型自稱的 id。自我回報無法驗證,等同沒有閘門。 # - 鎖存的是「階段的必要標籤」,不是「上鎖那一刻的模型」。鎖當時的模型只留作記錄, # 否則用不合格的模型起跑就會把自己鎖成合格,閘門永遠通過。 # # 政策是 fail-closed:不知道能力就擋下,知道才比對能力標籤。不知道能力有三種,一律擋下—— # 判不出是哪一支 CLI、判不出目前模型、模型判得出來但不在能力標籤表上。放行任何一種,用不 # 合格的模型跑階段就查不出來,閘門形同虛設。代價是被擋住的人可能不知道怎麼脫困,所以每一則 # 擋下的訊息都要帶逃生門:設 JSC_MODEL 人工指明模型,或執行本檔的 unlock 解除該階段的鎖。 # # 狀態檔:$JSC_HOME/sessions/{cli}-{sid}.stage,單行 # 「{stage}{必要標籤}{上鎖時的模型}{模型來源}」。 # 檔名帶 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 [{狀態檔}] # 移除狀態檔(被擋住又確定要放行時的逃生門)。不帶參數時清掉這個 # 工作階段的新舊兩份;帶參數時清掉指定的那一份。 # 為什麼要收參數:hook 拿得到 stdin 的 session id 與 JSC_CLI, # 手動執行拿不到,兩邊算出來的檔名可能不同,逃生門就按不到真正 # 擋人的那一支。所以擋人訊息會把實際讀到的路徑一起印出來, # 照抄就解得開。參數只收 sessions 目錄底下的 .stage 檔, # 避免這個逃生門變成任意刪檔的工具。 # sdlc-gate.sh check hook 模式(UserPromptSubmit):模型不符即擋下該輪提示。 # sdlc-gate.sh report 印出 {sid} {stage} {必要標籤} {模型 id} {模型來源} {判定};無鎖不印。 # # sdlc-gate.sh wp-lock {owner}/{repo} {index} [{工作包代號}] # 記下一筆未結清的工作包 PR;第四個參數是這支 PR 做的是哪一包, # 省略就是「查無歸屬」,之後的歸屬比對一律放行。 # exit 0 = 已記下;exit 2 = 用法錯誤或寫不進狀態檔(沒記下等於沒鎖)。 # sdlc-gate.sh wp-unlock {owner}/{repo} {index} 結清後移除該工作包的狀態檔;檔案不存在也算成功。 # exit 0 = 已結清;exit 2 = 用法錯誤。 # sdlc-gate.sh wp-claim {owner}/{repo} {工作包代號} [{PR 編號}] [{分析頁}] # 領取工作包:記下這個存取庫目前歸誰做,供歸屬比對用。 # 由 jsc-sdlc 在領取工作包時呼叫,本檔不自己判斷歸誰。 # exit 0 = 已記下;exit 2 = 用法錯誤或寫不進狀態檔。 # sdlc-gate.sh wp-unclaim {owner}/{repo} 交回工作包,移除領取紀錄;檔案不存在也算成功。 # sdlc-gate.sh wp-report 印出 {owner}/{repo} {index} {上鎖時間} {工作包代號},每個未結清 # 工作包各一行;沒有未結清就不印,exit 0。查無歸屬時第四欄留白。 # sdlc-gate.sh wp-check prompt hook 模式(UserPromptSubmit):注入提醒,一律 exit 0。 # sdlc-gate.sh wp-check skill hook 模式(PreToolUse,matcher Skill):命中 analyze 或 maintain # 時 exit 2 擋下該次呼叫;plan 與 implement 只注入提醒後 exit 0, # 其餘技能一律 exit 0。 # # 結束碼(一個子命令一列): # lock 0=通過並已上鎖 1=未通過,呼叫端必須停止流程(階段名不合法、讀不到該 # 階段的必要標籤、判定不出目前模型、模型不在標籤表上、缺標籤、寫不進狀態檔) # 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 編號格式錯誤 # wp-claim 0=已記下 2=存取庫格式錯誤、工作包代號不帶數字、寫不進領取檔 # wp-unclaim 0=已交回,領取檔不存在也算 2=存取庫格式錯誤 # wp-report 0=永遠成功,沒有未結清就不印 # wp-check prompt 0=永遠放行,只注入提醒 # wp-check skill 0=放行 2=擋下該次技能呼叫。安靜放行:逃生門 JSC_WP_GATE=off、沒有未結清 # 的工作包、取不到技能名、技能是 plan 或 implement、其餘不在名單上的技能。 # 只有 analyze 與 maintain 會 exit 2 # 不認得的子命令 0=安靜放行 # 註:本檔以 `. "$HERE/lib.sh"` 載入共用函式,沒有接 `|| true`。lib.sh 讀不到時 sh 會就地結束 # 並回 2;check 接在 UserPromptSubmit、wp-check skill 接在 PreToolUse,那一下都是無聲擋人, # 上面那些放行路徑一條都跑不到。 # # 鎖檔一個工作包一支($JSC_HOME/wp/{owner}-{repo}-{index}.pr),不是整個存取庫共用一支: # SDLC 實作可能同時有好幾個互不相依的工作包平行進行,各自開各自的 PR。整庫共用一支鎖檔 # 只留得住「最後一個 lock 的那一包」,先前還沒合併的那幾包會被覆蓋掉,鎖跟著憑空消失。 # 這支鎖檔管的是「plan/analyze/maintain 能不能在這個存取庫上動」(見下方 wp-check skill), # 跟「implement 挑下一個工作包能不能挑到某一包」是兩件事——後者的判斷依據是該包在分析頁 # WBS 表的相依欄,走 jsc-sdlc/tools/wp-gate.sh check-deps,不靠這支鎖檔。 # # --- plan 已從擋人名單移出,被放棄的保護寫在這裡 --- # # plan 原本跟 analyze、maintain 一起被擋。現在改成只提醒、照樣放行,放棄的是「在製品上限」: # 手上的工作包還沒結清就不准開新計畫。放棄的理由有兩個。一是 plan 是純邏輯階段,產出是計畫頁, # 不碰程式碼,開一份新計畫不會動到那支未結清的 PR。二是這道閘門手上只有「這個存取庫有 PR # 未合併」這一個事實,判不出新計畫跟那支 PR 有沒有關聯,擋下去多半是誤擋。 # 代價要據實看待:沒有東西再擋住計畫越積越多,計畫的產出速度可以快過實作。 # analyze 與 maintain 兩道仍在,上限只是晚一個階段才生效。 # # --- 狀態檔格式(與 jsc-sdlc/tools/wp-gate.sh 對齊,兩邊都靠這段註解對格式) --- # # 兩種檔案都放在 $JSC_HOME/wp/ 底下,都是純文字 key=value,一行一欄位,順序不拘, # 不認得的鍵一律忽略。格式刻意做到最簡,兩邊各自實作也對得上。 # # 鎖檔 {owner}-{repo}-{index}.pr 一支未結清的工作包 PR # repo={owner}/{repo} 存取庫 # index={PR 編號} PR 編號,純數字 # wp={工作包代號} 這支 PR 做的是哪一包,值取自分析頁 WBS 表那一欄的原字串; # 查不到就留空 # locked={ISO 時間} 上鎖時間,UTC # # 領取檔 {owner}-{repo}.claim 這個存取庫目前領取中的工作包,一個存取庫一支 # repo={owner}/{repo} 存取庫 # wp={工作包代號} 目前領取的是哪一包 # pr={PR 編號} 這一包的 PR;還沒開 PR 就留空 # analyze={分析頁頁名} 歸屬認定的來源分析頁;只作記錄,本檔不去讀那一頁 # claimed={ISO 時間} 領取時間,UTC # # 舊版鎖檔是單行 TSV「{repo}{index}{上鎖時間}」,沒有工作包欄位。舊檔照樣讀得動, # 讀出來的歸屬是空的,也就是查無歸屬、一律放行——不會因為換格式就把既有的鎖判成違規。 # # 歸屬比對只做一件事:把鎖檔的 wp 跟同一個存取庫領取檔的 wp 比數字。兩邊都有值且不相等, # 那支 PR 就不屬於目前領取的工作包。任何一邊查不到(沒有分析頁、沒寫工作包代號、領取檔不存在) # 一律當查無歸屬並放行,理由與 version-guard.sh 相同:只擋確定違規,否則會把技能組維護鎖死。 # # exit code 例外:其他 jsc hook 一律 exit 0 不中斷宿主 CLI;本檔 check 與 wp-check skill 是 # 刻意的例外——鎖存在且不合規時 exit 2 擋下。只用提示注入的話模型可以無視,閘門形同虛設。 # 無鎖、或資料不足無法判定時,仍照舊 exit 0 安靜降級。 HERE=$(dirname "$0"); . "$HERE/lib.sh" hook_trace "sdlc-gate ${1:-}" # 只有需要 stdin JSON 的子命令才讀它:模型判定要 transcript_path,session 判定要 session_id。 # wp-lock、wp-unlock、wp-claim、wp-unclaim、wp-report 兩者都不需要,而 read_stdin 在標準輸入 # 是管線又沒人關閉時會一直等——工具腳本(jsc-sdlc 的 wp-gate.sh)轉呼叫這些子命令時就這樣整支 # 卡死。新增子命令一律照這個原則歸類:不需要 stdin 就加進下面這一列。 case "${1:-}" in wp-lock|wp-unlock|wp-claim|wp-unclaim|wp-report) STDIN_JSON="" ;; *) read_stdin ;; esac sid=$(session_id) # 讀寫兩條路徑分開:寫入一律走帶 CLI 代號的新路徑,讀取則允許退回舊路徑(往後相容)。 state=$(stage_state_read_file) state_new=$(stage_state_file) TAGS_TSV="$JSC_HOME/model-tags.tsv" STAGES="plan analyze implement maintain" # --- 標籤查詢(資料來源:model-tags.tsv) --- # 某階段的必要標籤;不限標籤的階段回傳 any。查不到不輸出。 stage_tags() { # $1=階段 [ -s "$TAGS_TSV" ] || return 0 awk -F'\t' -v s="$1" '$1 == "stage" && $2 == s { print $3; exit }' "$TAGS_TSV" } # 某模型的能力標籤。查法:先找完全相同的鍵,沒有才退回「表列鍵是實際 id 的前綴」 # 之中最長的一筆(例:表列 claude-haiku-4-5 對得上 claude-haiku-4-5-20251001)。 # 只認前綴這個方向。反向包含(實際 id 是表列鍵的前綴)會讓 gpt-5.x 命中更長的 # gpt-5.x-mini,最長鍵勝出就把 mini 的標籤發給 gpt-5.x,把夠格的模型擋掉。 model_tags() { # $1=模型 id [ -s "$TAGS_TSV" ] || return 0 awk -F'\t' -v m="$1" ' $1 == "model" && $2 == m { exact = $3 } $1 == "model" && $2 != m && substr(m, 1, length($2)) == $2 { if (length($2) > best_len) { best_len = length($2); best = $3 } } END { if (exact != "") print exact else if (best != "") print best } ' "$TAGS_TSV" } # 必要標籤中,該模型缺少的部分(逗號分隔);全部具備則不輸出。 missing_tags() { # $1=必要標籤 $2=模型標籤 awk -v req="$1" -v have="$2" ' BEGIN { n = split(have, h, ","); for (i = 1; i <= n; i++) owned[h[i]] = 1 n = split(req, r, ",") for (i = 1; i <= n; i++) if (!(r[i] in owned)) out = (out == "" ? r[i] : out "," r[i]) print out }' } # 具備某組必要標籤的模型清單,用來告訴使用者可以切去哪個模型。 eligible_models() { # $1=必要標籤 [ -s "$TAGS_TSV" ] || return 0 awk -F'\t' -v req="$1" ' $1 == "model" { split("", owned, ":") n = split($3, h, ","); for (i = 1; i <= n; i++) owned[h[i]] = 1 n = split(req, r, ","); ok = 1 for (i = 1; i <= n; i++) if (!(r[i] in owned)) ok = 0 if (ok) out = (out == "" ? $2 : out "、" $2) } END { print out } ' "$TAGS_TSV" } model_info() { current_model_report } model_verdict() { # $1=stage $2=req $3=model stage="$1"; req="$2"; cur="$3" [ -n "$cur" ] || { printf 'unknown-model'; return 0; } [ "$req" = "any" ] && { printf 'pass'; return 0; } have=$(model_tags "$cur") [ -n "$have" ] || { printf 'unknown-model'; return 0; } miss=$(missing_tags "$req" "$have") [ -z "$miss" ] && printf 'pass' || printf 'fail:%s' "$miss" } # 逃生門說明。被擋住的人多半不熟這套東西,訊息要讓他照著做就脫困,所以兩條路都寫成 # 可以照抄的指令,而且把「可以填什麼」一併列出來。 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,一個工作包一支) --- # # 刻意不綁 session:PR 沒合併就是沒合併,換一個工作階段照樣要擋。綁 session 等於給閘門 # 留一道「開新對話就自動繞過」的門,規則就不再是強制的。 # # 一律只讀檔案,絕不打網路:hook 要快、也要能離線用。PR 的真實合併狀態由 # jsc-sdlc/tools/wp-gate.sh 去查並負責結清,本檔只反映已記錄的未結清狀態。 WP_DIR="$JSC_HOME/wp" wp_state_file() { # $1={owner}/{repo} $2=index printf '%s/%s-%s.pr' "$WP_DIR" "$(printf '%s' "$1" | tr '/' '-')" "$2" } wp_claim_file() { # $1={owner}/{repo} printf '%s/%s.claim' "$WP_DIR" "$(printf '%s' "$1" | tr '/' '-')" } # 取 key=value 檔案裡某個鍵的值;沒有那個鍵就不輸出。值裡的等號原樣保留。 wp_field() { # $1=檔案 $2=鍵名 [ -f "$1" ] || return 0 awk -v k="$2" ' index($0, k "=") == 1 { v = substr($0, length(k) + 2) gsub(/^[ \t]+|[ \t\r]+$/, "", v) print v; exit }' "$1" 2>/dev/null } # 讀一支鎖檔,印出「{owner}/{repo} {index} {上鎖時間} {工作包代號}」。 # 新格式(key=value)與舊格式(單行 TSV,無工作包欄位)都認:換格式不該讓既有的鎖失效。 wp_read_lock() { # $1=鎖檔 [ -f "$1" ] || return 0 awk ' /^[a-z][a-z]*=/ { k = substr($0, 1, index($0, "=") - 1) v = substr($0, index($0, "=") + 1) gsub(/^[ \t]+|[ \t\r]+$/, "", v) f[k] = v; kv = 1; next } NR == 1 { split($0, t, "\t") } END { if (kv) { repo = f["repo"]; idx = f["index"]; at = f["locked"]; wp = f["wp"] } else { repo = t[1]; idx = t[2]; at = t[3]; wp = "" } gsub(/\r/, "", at) if (repo != "") print repo " " idx " " at (wp != "" ? " " wp : "") }' "$1" 2>/dev/null } # 未結清清單,每行「{owner}/{repo} {index} {上鎖時間} {工作包代號}」;沒有就不輸出。 # 第四欄可能是空的,代表查無歸屬。 wp_pending() { [ -d "$WP_DIR" ] || return 0 for _f in "$WP_DIR"/*.pr; do [ -f "$_f" ] || continue _line=$(wp_read_lock "$_f") [ -n "$_line" ] && printf '%s\n' "$_line" done } # 某個存取庫目前領取中的工作包代號;沒有領取檔或沒寫代號就不輸出(查無歸屬)。 wp_claimed() { # $1={owner}/{repo} wp_field "$(wp_claim_file "$1")" wp } # 工作包代號取數值:去掉英文前綴與前導零,只留數字。範本補零到兩位,呼叫端不一定補, # 也可能只給數字;比字串會讓同一包的幾種寫法互相認不得,歸屬就誤判成不同包。 wp_num() { # $1=工作包代號 printf '%s' "${1:-}" | tr -dc '0-9' | sed 's/^0*//' } # 未結清清單濃縮成一句可讀的「{repo} 第 {index} 號」,多筆用頓號串起。只吃前兩欄, # 第四欄的工作包代號另外由歸屬比對處理,混進這句話只會把提醒句拉長。 wp_brief() { # 標準輸入 = wp_pending 的輸出 awk '{ out = (out == "" ? $1 " 第 " $2 " 號" : out "、" $1 " 第 " $2 " 號") } END { print out }' } # 存取庫參數格式檢查;不合格就回 1,由呼叫端印訊息後 exit 2。 wp_valid_repo() { # $1=參數 case "${1:-}" in */*/*|/*|*/) return 1 ;; */*) return 0 ;; *) return 1 ;; esac } case "${1:-check}" in lock) stage="${2:-}" case " $STAGES " in *" $stage "*) ;; *) echo "[jsc][SDLC 閘門][ERR]:階段須為 $STAGES 之一,收到「${stage:-空值}」。" >&2; exit 1 ;; esac req=$(stage_tags "$stage") if [ -z "$req" ]; then echo "[jsc][SDLC 閘門][ERR]:讀不到階段「$stage」的必要標籤($TAGS_TSV 不存在或缺該階段)。請先執行 jsc-cli/tools/model-tags.sh sync 再重跑本階段,本次不進行任何工作。" >&2 exit 1 fi info=$(model_info) cur=$(printf '%s' "$info" | cut -f1) src=$(printf '%s' "$info" | cut -f2) checked=$(printf '%s' "$info" | cut -f3-) if [ -z "$cur" ]; then 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」(來源 ${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") if [ -n "$miss" ]; then ok=$(eligible_models "$req") echo "[jsc][SDLC 閘門][ERR]:階段「$stage」需要標籤「$req」,模型「$cur」缺少「$miss」。請切換到下列任一模型後重跑:${ok:-(表上無合格模型,請補表)}。本次不進行任何工作。" >&2 exit 1 fi fi 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) 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) [ -f "$state" ] || exit 0 stage=$(cut -f1 "$state" 2>/dev/null | head -n1) req=$(cut -f2 "$state" 2>/dev/null | head -n1) # 舊格式(空白分隔、無必要標籤欄)讀不出 req,安靜降級不擋。 [ -n "$stage" ] && [ -n "$req" ] && [ "$stage" != "$req" ] || exit 0 [ "$req" = "any" ] && exit 0 info=$(model_info) 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 閘門][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。本輪提示已擋下。" gate_escape_hint "$req" "$state" } >&2 exit 2 fi miss=$(missing_tags "$req" "$have") [ -z "$miss" ] && exit 0 ok=$(eligible_models "$req") { echo "[jsc][SDLC 閘門][ERR]:SDLC 階段「${stage}」需要標籤「${req}」,目前模型「${cur}」(來源 ${src:-未知})缺少「${miss}」。請切換到下列任一模型後重送:${ok:-(表上無合格模型,請補表)}。本輪提示已擋下。" echo "要結束本階段的鎖請執行:sh $0 unlock $state" } >&2 exit 2 ;; report) if [ -f "$state" ]; then stage=$(cut -f1 "$state" 2>/dev/null | head -n1) req=$(cut -f2 "$state" 2>/dev/null | head -n1) locked_model=$(cut -f3 "$state" 2>/dev/null | head -n1) locked_source=$(cut -f4 "$state" 2>/dev/null | head -n1) info=$(model_info) cur=$(printf '%s' "$info" | cut -f1) src=$(printf '%s' "$info" | cut -f2) [ -n "$cur" ] || cur="$locked_model" [ -n "$src" ] || src="$locked_source" verdict=$(model_verdict "$stage" "$req" "$cur") [ -n "$stage" ] && echo "$sid $stage $req ${cur:-未知} ${src:-未知} $verdict" fi exit 0 ;; wp-lock) repo="${2:-}"; idx="${3:-}"; wp="${4:-}" wp_valid_repo "$repo" || { echo "[jsc][工作包閘門][ERR]:存取庫須為 {owner}/{repo} 格式,收到「${repo:-空值}」。" >&2; exit 2; } case "$idx" in ''|*[!0-9]*) echo "[jsc][工作包閘門][ERR]:PR 編號須為數字,收到「${idx:-空值}」。" >&2; exit 2 ;; esac mkdir -p "$WP_DIR" 2>/dev/null || true wpf=$(wp_state_file "$repo" "$idx") printf 'repo=%s\nindex=%s\nwp=%s\nlocked=%s\n' "$repo" "$idx" "$wp" "$(now_iso)" > "$wpf" 2>/dev/null || { echo "[jsc][工作包閘門][ERR]:寫不進狀態檔 $wpf,工作包鎖未生效。" >&2; exit 2; } echo "[jsc][工作包閘門][OK]:已記下 $repo 第 $idx 號 PR 未結清${wp:+(工作包 $wp)}。相依於它的工作包在它結清前不得開始;其餘互不相依的工作包不受影響。" [ -n "$wp" ] || echo "[jsc][工作包閘門]:這筆沒帶工作包編號,歸屬比對查不到來源,之後一律放行。要擋跨工作包的 PR,請在 wp-lock 帶上第四個參數。" exit 0 ;; wp-claim) repo="${2:-}"; wp="${3:-}"; pr="${4:-}"; page="${5:-}" wp_valid_repo "$repo" || { echo "[jsc][工作包閘門][ERR]:存取庫須為 {owner}/{repo} 格式,收到「${repo:-空值}」。" >&2; exit 2; } [ -n "$(wp_num "$wp")" ] || { echo "[jsc][工作包閘門][ERR]:工作包代號須帶數字(例:分析頁上那個 WP 開頭的代號),收到「${wp:-空值}」。" >&2; exit 2; } mkdir -p "$WP_DIR" 2>/dev/null || true cf=$(wp_claim_file "$repo") printf 'repo=%s\nwp=%s\npr=%s\nanalyze=%s\nclaimed=%s\n' \ "$repo" "$wp" "$pr" "$page" "$(now_iso)" > "$cf" 2>/dev/null || { echo "[jsc][工作包閘門][ERR]:寫不進領取檔 $cf,歸屬比對不會生效。" >&2; exit 2; } echo "[jsc][工作包閘門][OK]:已記下 $repo 領取中的工作包為 $wp。這個存取庫的其他工作包 PR 一律不由這裡結清。" exit 0 ;; wp-unclaim) repo="${2:-}" wp_valid_repo "$repo" || { echo "[jsc][工作包閘門][ERR]:存取庫須為 {owner}/{repo} 格式,收到「${repo:-空值}」。" >&2; exit 2; } # 冪等,理由同 wp-unlock:交回流程可能被重跑,第二次失敗只會讓呼叫端誤判。 rm -f "$(wp_claim_file "$repo")" 2>/dev/null || true exit 0 ;; wp-unlock) repo="${2:-}"; idx="${3:-}" wp_valid_repo "$repo" || { echo "[jsc][工作包閘門][ERR]:存取庫須為 {owner}/{repo} 格式,收到「${repo:-空值}」。" >&2; exit 2; } case "$idx" in ''|*[!0-9]*) echo "[jsc][工作包閘門][ERR]:PR 編號須為數字,收到「${idx:-空值}」。" >&2; exit 2 ;; esac # 冪等:狀態檔不存在也算成功。結清流程可能被重跑,第二次失敗只會讓呼叫端誤判。 rm -f "$(wp_state_file "$repo" "$idx")" 2>/dev/null || true exit 0 ;; wp-report) wp_pending exit 0 ;; wp-check) [ "${JSC_WP_GATE:-}" = "off" ] && exit 0 pending=$(wp_pending) [ -n "$pending" ] || exit 0 brief=$(printf '%s\n' "$pending" | wp_brief) # 逐筆判歸屬:鎖檔的工作包代號對上同一個存取庫領取中的代號,才算自己這一包的 PR。 # 兩邊任一邊查不到代號就算查無歸屬,歸進 mine 這一側處理——查不到不是違規的證據。 # 迴圈用 here-document 餵資料,不用管線:管線右邊是子 shell,分類結果傳不回來, # 判到的違規會在迴圈結束的瞬間全部消失。 mine=''; foreign='' while read -r _r _i _t _w; do [ -n "$_r" ] || continue _a=$(wp_num "$_w"); _b=$(wp_num "$(wp_claimed "$_r")") if [ -n "$_a" ] && [ -n "$_b" ] && [ "$_a" != "$_b" ]; then foreign="${foreign}${foreign:+、}$_r 第 $_i 號($_w)" else mine="${mine}${mine:+、}$_r 第 $_i 號" fi done <&2 exit 2 ;; plan) # plan 只提醒不擋,理由見檔頭「plan 已從擋人名單移出」。放棄的是在製品上限, # 換來的是不再誤擋跟那支 PR 無關的新計畫;analyze 與 maintain 兩道仍在。 echo "[jsc][工作包閘門]:${brief} PR 尚未合併。plan 是純邏輯階段、不碰程式碼,這裡只提醒不擋,但新計畫排進實作前請先把它結清。" [ -n "$foreign" ] && echo "[jsc][工作包閘門]:${foreign}不屬於這個存取庫領取中的工作包。那幾支交給領取它的工作階段結清:這裡不要改它的程式碼,也不要回它的留言。" exit 0 ;; implement) # implement 一律放行,連別包的 PR 未結清也放行:結清 PR 正是 implement 步驟 2 要做 # 的事,擋掉就沒有任何路徑能解除這道鎖。領取檔不綁工作階段,擋下去連領取那一包的 # 工作階段都會被自己的舊紀錄擋住,等於把流程鎖死。所以這裡只注入歸屬提醒。 [ -n "$foreign" ] && echo "[jsc][工作包閘門]:${foreign}不屬於這個存取庫領取中的工作包。這裡只結清自己領取那一包的 PR${mine:+(${mine})};別包的 PR 不要改、留言也不要回,交給領取它的工作階段。" exit 0 ;; esac # 其餘技能一律放行:這道閘門管的是 SDLC 階段,不是整台機器的技能呼叫。 exit 0 ;; esac exit 0 ;; esac exit 0