Files
hooks/hooks/sdlc-gate.sh
T

297 lines
15 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 取自 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