From b9ad32193059372798c04c81460870cd442604e6 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 25 Aug 2026 18:59:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(sdlc-gate):=20=E6=96=B0=E5=A2=9E=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=85=20PR=20=E9=96=98=E9=96=80=EF=BC=8C=E6=9C=AA?= =?UTF-8?q?=E7=B5=90=E6=B8=85=E5=B0=B1=E6=93=8B=E4=B8=8B=E5=88=A5=E7=9A=84?= =?UTF-8?q?=E9=9A=8E=E6=AE=B5=E6=8A=80=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hooks/hooks.json | 8 ++++ hooks/sdlc-gate.sh | 115 +++++++++++++++++++++++++++++++++++++++++++-- tools/wire-cli.sh | 10 +++- 3 files changed, 128 insertions(+), 5 deletions(-) diff --git a/hooks/hooks.json b/hooks/hooks.json index a3899d7..ec57558 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -20,6 +20,10 @@ { "type": "command", "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/sdlc-gate.sh\" check" + }, + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/sdlc-gate.sh\" wp-check prompt" } ] } @@ -51,6 +55,10 @@ { "type": "command", "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/version-guard.sh\"" + }, + { + "type": "command", + "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/sdlc-gate.sh\" wp-check skill" } ] } diff --git a/hooks/sdlc-gate.sh b/hooks/sdlc-gate.sh index 1cd9d52..fe4202b 100755 --- a/hooks/sdlc-gate.sh +++ b/hooks/sdlc-gate.sh @@ -17,11 +17,26 @@ # sdlc-gate.sh check hook 模式(UserPromptSubmit):模型不符即擋下該輪提示。 # sdlc-gate.sh report 印出 {sid} {stage} {必要標籤} {上鎖時的模型};無鎖不印。 # -# exit code 例外:其他 jsc hook 一律 exit 0 不中斷宿主 CLI;本檔 check 是刻意的例外—— -# 鎖存在且模型不符時 exit 2 擋下該輪提示。只用提示注入的話模型可以無視,閘門形同虛設。 +# sdlc-gate.sh wp-lock {owner}/{repo} {index} 記下一筆未結清的工作包 PR。 +# exit 0 = 已記下;exit 2 = 用法錯誤或寫不進狀態檔(沒記下等於沒鎖)。 +# sdlc-gate.sh wp-unlock {owner}/{repo} 結清後移除狀態檔;檔案不存在也算成功。 +# 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。 +# +# exit code 例外:其他 jsc hook 一律 exit 0 不中斷宿主 CLI;本檔 check 與 wp-check skill 是 +# 刻意的例外——鎖存在且不合規時 exit 2 擋下。只用提示注入的話模型可以無視,閘門形同虛設。 # 無鎖、或資料不足無法判定時,仍照舊 exit 0 安靜降級。 HERE=$(dirname "$0"); . "$HERE/lib.sh" -read_stdin +# 只有需要 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" @@ -92,6 +107,44 @@ current_model() { printf '%s' "$m" } +# --- 工作包 PR 閘門(狀態檔:$JSC_HOME/wp/{owner}-{repo}.pr) --- +# +# 刻意不綁 session:PR 沒合併就是沒合併,換一個工作階段照樣要擋。綁 session 等於給閘門 +# 留一道「開新對話就自動繞過」的門,規則就不再是強制的。 +# +# 一律只讀檔案,絕不打網路:hook 要快、也要能離線用。PR 的真實合併狀態由 +# jsc-sdlc/tools/wp-gate.sh 去查並負責結清,本檔只反映已記錄的未結清狀態。 + +WP_DIR="$JSC_HOME/wp" + +wp_state_file() { # $1={owner}/{repo} + printf '%s/%s.pr' "$WP_DIR" "$(printf '%s' "$1" | tr '/' '-')" +} + +# 未結清清單,每行「{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:-}" @@ -169,5 +222,61 @@ case "${1:-check}" in [ -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") + 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:-}" + wp_valid_repo "$repo" || { + echo "[jsc][工作包閘門][ERR]:存取庫須為 {owner}/{repo} 格式,收到「${repo:-空值}」。" >&2; exit 2; } + # 冪等:狀態檔不存在也算成功。結清流程可能被重跑,第二次失敗只會讓呼叫端誤判。 + rm -f "$(wp_state_file "$repo")" 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 修好」的對話都送不出去, + # 把使用者鎖在門外,連逃生門都下不了指令。 + echo "[jsc] ${brief} PR 尚未合併,禁止開新工作包;請先把該 PR 結清(合併或關閉)再繼續。" + 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} 解除;確定要整體放行請設 JSC_WP_GATE=off。本次技能呼叫已擋下。" >&2 + exit 2 ;; + esac + # implement 與其餘技能一律放行:結清 PR 正是 implement 步驟 4 要做的事, + # 擋掉 implement 就沒有任何路徑能解除這道鎖,等於把流程鎖死。 + exit 0 ;; + esac + exit 0 ;; esac exit 0 diff --git a/tools/wire-cli.sh b/tools/wire-cli.sh index 2785397..b3c4db8 100755 --- a/tools/wire-cli.sh +++ b/tools/wire-cli.sh @@ -582,7 +582,9 @@ if [ "$action" = smoke ]; then # $2 不加引號展開:子命令是固定字面字,空字串時要展成「沒有參數」而不是空參數。 smoke_one() { _h="$1"; _s="${2:-}" - _out=$(printf '{}' | JSC_CLI="$cli" sh "$HOOKS/$_h" $_s 2>&1); _rc=$? + # 技能名一律清空:冒煙要驗的是「沒有技能情境時腳本跑得完」。留著繼承來的 JSC_SKILL, + # sdlc-gate.sh wp-check skill 會拿它當真實呼叫判定,有未結清 PR 時就誤報成執行期錯誤。 + _out=$(printf '{}' | JSC_CLI="$cli" JSC_SKILL="" SKILL="" sh "$HOOKS/$_h" $_s 2>&1); _rc=$? if [ "$_rc" -eq 0 ]; then printf '[jsc] %s%s:exit 0,正常。\n' "$_h" "${_s:+ $_s}" >> "$smoke_out" elif [ "$_h" = sdlc-gate.sh ] && [ "$_s" = check ] && [ "$_rc" -eq 2 ]; then @@ -602,9 +604,13 @@ if [ "$action" = smoke ]; then smoke_one version-guard.sh smoke_one skill-usage.sh smoke_one ste100-guard.sh + # sdlc-gate.sh 有兩個 hook 模式,接在不同事件上,兩個都要驗:wp-check prompt 一律 exit 0, + # wp-check skill 在取不到技能名時放行(上面已清空技能名),所以兩者都不需要白名單例外。 + smoke_one sdlc-gate.sh "wp-check prompt" + smoke_one sdlc-gate.sh "wp-check skill" if [ "$smoke_fails" -eq 0 ]; then - printf 'status=ok reason=%s\n' "五支 hook 都跑得完,沒有執行期錯誤" + printf 'status=ok reason=%s\n' "五支 hook 的每個接線模式都跑得完,沒有執行期錯誤" cat "$smoke_out"; rm -f "$smoke_out"; exit 0 fi printf 'status=failed reason=%s\n' "$smoke_fails 支 hook 有執行期錯誤"