Files
hooks/hooks/sdlc-gate.sh
T
jiantw83 a3ef205489 feat(狀態回報): 技能與 hook 的執行結果寫進本機事件流
現行紀錄只記「被叫用」,欄位是 ts、cli、session、skill,沒有成敗也沒有
結束碼。跑完整輪的技能與開場就中止的技能,在紀錄裡長得一模一樣。hook
成功時更是完全不留紀錄,只有錯誤路徑會寫 wiki,而那條路徑刻意不自動觸發。

事件流走本機檔案,不直接寫 wiki。hook 每次提示都跑,網路寫入會拖垮宿主
CLI;失敗的 hook 自我回報還會疊出迴圈,既有的錯誤回報因此不接在失敗的
hook 上,這裡沿用同一條線。助理巡檢時排空、彙整、寫頁。

hook 端用 EXIT trap 接,一支只加一行。這幾支的 exit 點很多,階段閘門一支
就有五十幾個;逐點改要動到每一條判定路徑,而那些路徑正是閘門的判準,為了
加一行紀錄去動閘門,風險遠大於收益。trap 涵蓋每一條離開路徑,含中途失敗。

狀態預設由結束碼推,推不出來的由 hook 自己覆寫。相依版本檢查與兩道閘門有
這種情形:antigravity 走 deny JSON、kiro 只印警告,兩者擋下時結束碼都是 0,
單看結束碼會把擋下記成放行。

技能的 start 由既有的技能用量 hook 順手發,不必改任何技能文件。end 只能由
技能自己在收尾步驟寫——hook 觸發時技能的實際工作還在後面的模型輪次,看不到
成敗。有 start 沒有配對的 end,就是那一輪中止了。
2026-09-02 15:40:17 +08:00

563 lines
31 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
# 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}<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 [{狀態檔}]
# 移除狀態檔(被擋住又確定要放行時的逃生門)。不帶參數時清掉這個
# 工作階段的新舊兩份;帶參數時清掉指定的那一份。
# 為什麼要收參數: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}<TAB>{index}<TAB>{上鎖時間}」,沒有工作包欄位。舊檔照樣讀得動,
# 讀出來的歸屬是空的,也就是查無歸屬、一律放行——不會因為換格式就把既有的鎖判成違規。
#
# 歸屬比對只做一件事:把鎖檔的 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 <<WP_PENDING_EOF
$pending
WP_PENDING_EOF
case "${2:-prompt}" in
prompt)
# 只注入提醒,一律 exit 0。擋提示會連「去把那支 PR 修好」的對話都送不出去,
# 把使用者鎖在門外,連逃生門都下不了指令。
# 這裡只列得出「哪些工作包還沒結清」,不知道候選包相依於誰——沒有上下文可以判斷。
# 真正「這一包能不能挑」的判斷在 jsc-sdlc/tools/wp-gate.sh check-deps,這則只是提醒。
echo "[jsc] ${brief} PR 尚未合併。相依於它的工作包不能開始,其餘互不相依的工作包不受影響——是否可挑,見 wp-gate.sh check-deps。"
[ -n "$foreign" ] && echo "[jsc] 其中 ${foreign}不屬於這個存取庫領取中的工作包。那幾支交給領取它的工作階段結清:這裡不要改它的程式碼,也不要回它的留言。"
exit 0 ;;
skill)
# 技能名取法比照 version-guard.sh:環境變數優先,非 Claude CLI 只餵得到環境變數。
skill="${JSC_SKILL:-${SKILL:-$(json_str skill)}}"
# 取不到技能名就安靜降級放行,不能拿沒有的資料當擋人的理由。
[ -n "$skill" ] || exit 0
# 帶前綴(jsc-sdlc:plan)與裸名(plan)都要認。
sname=${skill##*:}
case "$sname" in
analyze|maintain)
echo "[jsc][工作包閘門][ERR]:${brief} PR 尚未合併,禁止在此存取庫執行「${sname}」。${foreign:+其中 ${foreign}還不屬於領取中的工作包,更不該由這裡處理。}請先把該 PR 結清(合併或關閉),或執行 jsc-hooks/hooks/sdlc-gate.sh wp-unlock {owner}/{repo} {index} 解除;確定要整體放行請設 JSC_WP_GATE=off。本次技能呼叫已擋下。" >&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