297 lines
15 KiB
Bash
Executable File
297 lines
15 KiB
Bash
Executable File
#!/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 取自 transcript 記錄的實際值(lib.sh 的 transcript_model),
|
||
# 不採用模型自我回報——自我回報無法驗證,等同沒有閘門。
|
||
# - 鎖存的是「階段的必要標籤」,不是「上鎖那一刻的模型」。鎖當時的模型只留作記錄,
|
||
# 否則用不合格的模型起跑就會把自己鎖成合格,閘門永遠通過。
|
||
#
|
||
# 狀態檔:$JSC_HOME/sessions/{sid}.stage,單行「{stage}<TAB>{必要標籤}<TAB>{上鎖時的模型}」。
|
||
#
|
||
# 用法:
|
||
# sdlc-gate.sh lock {stage} 階段閘門:比對實際模型與該階段必要標籤,通過才上鎖。
|
||
# exit 0 = 通過並已上鎖;exit 1 = 未通過,呼叫端必須停止流程。
|
||
# sdlc-gate.sh unlock 移除狀態檔(被擋住又確定要放行時的逃生門)。
|
||
# sdlc-gate.sh check hook 模式(UserPromptSubmit):模型不符即擋下該輪提示。
|
||
# sdlc-gate.sh report 印出 {sid} {stage} {必要標籤} {上鎖時的模型};無鎖不印。
|
||
#
|
||
# sdlc-gate.sh wp-lock {owner}/{repo} {index} 記下一筆未結清的工作包 PR。
|
||
# exit 0 = 已記下;exit 2 = 用法錯誤或寫不進狀態檔(沒記下等於沒鎖)。
|
||
# sdlc-gate.sh wp-unlock {owner}/{repo} {index} 結清後移除該工作包的狀態檔;檔案不存在也算成功。
|
||
# exit 0 = 已結清;exit 2 = 用法錯誤。
|
||
# 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):命中別的階段技能時
|
||
# exit 2 擋下該次呼叫;其餘 exit 0。
|
||
#
|
||
# 鎖檔一個工作包一支($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,不靠這支鎖檔。
|
||
#
|
||
# exit code 例外:其他 jsc hook 一律 exit 0 不中斷宿主 CLI;本檔 check 與 wp-check skill 是
|
||
# 刻意的例外——鎖存在且不合規時 exit 2 擋下。只用提示注入的話模型可以無視,閘門形同虛設。
|
||
# 無鎖、或資料不足無法判定時,仍照舊 exit 0 安靜降級。
|
||
HERE=$(dirname "$0"); . "$HERE/lib.sh"
|
||
# 只有需要 stdin JSON 的子命令才讀它:模型判定要 transcript_path,session 判定要 session_id。
|
||
# wp-lock、wp-unlock、wp-report 兩者都不需要,而 read_stdin 在標準輸入是管線又沒人關閉時會
|
||
# 一直等——工具腳本(jsc-sdlc 的 wp-gate.sh)轉呼叫這三個子命令時就這樣整支卡死。
|
||
case "${1:-}" in
|
||
wp-lock|wp-unlock|wp-report) STDIN_JSON="" ;;
|
||
*) read_stdin ;;
|
||
esac
|
||
sid=$(session_id)
|
||
state="$JSC_HOME/sessions/$sid.stage"
|
||
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"
|
||
}
|
||
|
||
# 目前模型:transcript 實際值 > stdin JSON model > JSC_MODEL > ~/.claude/settings.json。
|
||
# transcript 排最前面,因為那是唯一可驗證的實際值,其餘都只是宣告值。
|
||
current_model() {
|
||
m=$(transcript_model)
|
||
[ -n "$m" ] || m=$(json_str model)
|
||
[ -n "$m" ] || m="${JSC_MODEL:-}"
|
||
if [ -z "$m" ] && { [ -z "${JSC_CLI:-}" ] || [ "${JSC_CLI:-}" = "claude" ]; }; then
|
||
m=$(tr -d '\n' < "$HOME/.claude/settings.json" 2>/dev/null \
|
||
| sed -n 's/.*"model"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
|
||
fi
|
||
printf '%s' "$m"
|
||
}
|
||
|
||
# --- 工作包 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"
|
||
}
|
||
|
||
# 未結清清單,每行「{owner}/{repo} {index} {上鎖時間}」;沒有就不輸出。
|
||
wp_pending() {
|
||
[ -d "$WP_DIR" ] || return 0
|
||
for _f in "$WP_DIR"/*.pr; do
|
||
[ -f "$_f" ] || continue
|
||
_line=$(sed -n '1p' "$_f" 2>/dev/null | tr '\t' ' ')
|
||
[ -n "$_line" ] && printf '%s\n' "$_line"
|
||
done
|
||
}
|
||
|
||
# 未結清清單濃縮成一句可讀的「{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
|
||
|
||
cur=$(current_model)
|
||
if [ -z "$cur" ]; then
|
||
echo "[jsc][SDLC 閘門][ERR]:判定不出目前實際使用的模型,無法驗證是否符合階段「$stage」。請確認 transcript 可讀,或設定 JSC_MODEL 後重跑,本次不進行任何工作。" >&2
|
||
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
|
||
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\n' "$stage" "$req" "$cur" > "$state" 2>/dev/null || {
|
||
echo "[jsc][SDLC 閘門][ERR]:寫不進狀態檔 $state,階段鎖未生效。" >&2; exit 1; }
|
||
echo "[jsc][SDLC 閘門][OK]:階段「$stage」通過(需要 $req,目前模型 $cur),已上鎖。"
|
||
exit 0 ;;
|
||
|
||
unlock)
|
||
rm -f "$state" 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
|
||
|
||
cur=$(current_model)
|
||
# 判定不出模型時只提醒,不擋——否則使用者會被鎖在無法送出提示的狀態。
|
||
if [ -z "$cur" ]; then
|
||
echo "[jsc] SDLC 階段「${stage}」需要標籤「${req}」,但判定不出目前模型。請自行確認模型是否合格。"
|
||
exit 0
|
||
fi
|
||
|
||
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;確定要放行請執行 jsc-hooks/hooks/sdlc-gate.sh unlock。本輪提示已擋下。" >&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}」缺少「${miss}」。請切換到下列任一模型後重送:${ok:-(表上無合格模型,請補表)}。要結束本階段的鎖請執行 jsc-hooks/hooks/sdlc-gate.sh unlock。本輪提示已擋下。" >&2
|
||
exit 2 ;;
|
||
|
||
report)
|
||
if [ -f "$state" ]; then
|
||
line=$(sed -n '1p' "$state" 2>/dev/null | tr '\t' ' ')
|
||
[ -n "$line" ] && echo "$sid $line"
|
||
fi
|
||
exit 0 ;;
|
||
|
||
wp-lock)
|
||
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
|
||
mkdir -p "$WP_DIR" 2>/dev/null || true
|
||
wpf=$(wp_state_file "$repo" "$idx")
|
||
printf '%s\t%s\t%s\n' "$repo" "$idx" "$(now_iso)" > "$wpf" 2>/dev/null || {
|
||
echo "[jsc][工作包閘門][ERR]:寫不進狀態檔 $wpf,工作包鎖未生效。" >&2; exit 2; }
|
||
echo "[jsc][工作包閘門][OK]:已記下 $repo 第 $idx 號 PR 未結清。相依於它的工作包在它結清前不得開始;其餘互不相依的工作包不受影響。"
|
||
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)
|
||
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。"
|
||
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
|
||
plan|analyze|maintain)
|
||
echo "[jsc][工作包閘門][ERR]:${brief} PR 尚未合併,禁止在此存取庫執行「${sname}」。請先把該 PR 結清(合併或關閉),或執行 jsc-hooks/hooks/sdlc-gate.sh wp-unlock {owner}/{repo} {index} 解除;確定要整體放行請設 JSC_WP_GATE=off。本次技能呼叫已擋下。" >&2
|
||
exit 2 ;;
|
||
esac
|
||
# implement 與其餘技能一律放行:結清 PR 正是 implement 步驟 4 要做的事,
|
||
# 擋掉 implement 就沒有任何路徑能解除這道鎖,等於把流程鎖死。
|
||
exit 0 ;;
|
||
esac
|
||
exit 0 ;;
|
||
esac
|
||
exit 0
|