From b9ad32193059372798c04c81460870cd442604e6 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 25 Aug 2026 18:59:29 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(sdlc-gate):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8C=85=20PR=20=E9=96=98=E9=96=80=EF=BC=8C?= =?UTF-8?q?=E6=9C=AA=E7=B5=90=E6=B8=85=E5=B0=B1=E6=93=8B=E4=B8=8B=E5=88=A5?= =?UTF-8?q?=E7=9A=84=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 有執行期錯誤" From 26a5a4690d57906772edd8edaba1fc071297939e Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 25 Aug 2026 18:59:29 +0800 Subject: [PATCH 2/3] =?UTF-8?q?docs(hooks):=20=E8=A3=9C=E4=B8=8A=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=85=20PR=20=E9=96=98=E9=96=80=E7=9A=84=20hook=20?= =?UTF-8?q?=E8=AA=AA=E6=98=8E=E8=88=87=E9=80=83=E7=94=9F=E9=96=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 3fe0c78..2051741 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Marketplace 統一為 `jsc`(https://gitea.jsc.idv.tw/plugins/meta.git),安 | `hooks/session-timer.sh` | SessionStart / Stop / SessionEnd | 記錄工作階段起訖。子指令:`start` 記起始時間(已有紀錄就不動,給 claude 這種每階段有自己 session id 的 CLI)、`restart` 一律覆寫起始時間(給接不到 session id 的 kiro,不覆寫會把上一階段算進來)、`mark` 更新最後活動時間、`report` 供 `jsc-log:worklog` 取花費時間 | | `hooks/version-guard.sh` | PreToolUse(Skill) | 技能使用前的版本前置檢查:本機**實際載入**版本落後遠端發佈版本就以 exit 2 擋下該次呼叫並提示更新指令(更新指令依當前 CLI 給)。只擋落後這一種情況:超前放行(開發技能組時本機本來就會超前),讀不到本機版本、推導不出站台、查不到遠端版本也一律放行。逃生門 `JSC_VERSION_GUARD=off`。豁免 `jsc-cli:deploy`、`jsc-hooks:hooks-install`、`jsc-cli:models`、`jsc-meta:*` | | `hooks/skill-usage.sh` | PostToolUse(Skill) | 記錄技能使用與呼叫鏈到 `$JSC_HOME/usage/*.jsonl`,供 `jsc-log:stats` 統計 | -| `hooks/sdlc-gate.sh` | UserPromptSubmit | SDLC 階段能力標籤閘門與模型鎖:`lock {stage}` 由 jsc-sdlc 階段技能呼叫,從 transcript 讀出實際模型 id 比對該階段必要標籤(`$JSC_HOME/model-tags.tsv`),不符就拒絕上鎖;`check` 在模型不符時以 exit 2 擋下該輪提示(其他 hook 一律 exit 0,此處是刻意例外);`unlock` 為逃生門 | +| `hooks/sdlc-gate.sh` | UserPromptSubmit、PreToolUse(Skill) | SDLC 階段能力標籤閘門與模型鎖:`lock {stage}` 由 jsc-sdlc 階段技能呼叫,從 transcript 讀出實際模型 id 比對該階段必要標籤(`$JSC_HOME/model-tags.tsv`),不符就拒絕上鎖;`check` 在模型不符時以 exit 2 擋下該輪提示(其他 hook 一律 exit 0,此處是刻意例外);`unlock` 為逃生門。另含工作包 PR 閘門:`wp-lock {owner}/{repo} {index}` 記下一筆未結清的工作包 PR、`wp-unlock {owner}/{repo}` 結清(檔案不存在也算成功)、`wp-report` 印出所有未結清、`wp-check {prompt|skill}` 為 hook 模式。狀態檔在 `$JSC_HOME/wp/{owner}-{repo}.pr`,**刻意不綁 session**——PR 沒合併時換一個工作階段照樣要擋。`wp-check prompt` 只注入提醒、絕不擋提示(擋了連「去修那支 PR」的對話都送不出去);`wp-check skill` 在有未結清 PR 時以 exit 2 擋下 `plan`、`analyze`、`maintain`,但一律放行 `implement`(結清 PR 正是 implement 的步驟,擋它會鎖死流程)。逃生門 `JSC_WP_GATE=off`。這道閘門只讀檔案、不打網路,PR 的真實合併狀態由 `jsc-sdlc/tools/wp-gate.sh` 查證 | Claude 由 `hooks/hooks.json` 自動接線五支 hook;其他 CLI 用 `hooks-install` 技能接線、改裝包裝啟動器,或降級為規則檔。 @@ -85,6 +85,7 @@ Claude 由 `hooks/hooks.json` 自動接線五支 hook;其他 CLI 用 `hooks-in | `JSC_CLAUDE_SETTINGS_DIR` | `tools/wire-cli.sh purge claude` 要清 `hooks` 鍵的設定檔目錄。指向一份複製品就能完整測過刪鍵邏輯,不必拿使用者本人的設定檔當測試場 | 預設 `~/.claude` | | `JSC_VERSION_GUARD` | 設 `off` 完全略過版本前置檢查(離線工作用) | 啟用檢查 | | `JSC_VERSION_TTL` | 遠端版本查詢的快取秒數 | 預設 600 | +| `JSC_WP_GATE` | 設 `off` 完全略過工作包 PR 閘門(`wp-check` 一律放行) | 啟用閘門 | | `JSC_CLI` / `JSC_SESSION_ID` / `JSC_SKILL` / `JSC_TOOL_NAME` | 非 Claude CLI 接線時由 `tools/jsc-wrap.sh` 或接線設定提供,代替 stdin JSON 的 `session_id`、`skill`、`tool_name`(`version-guard.sh` 也收沒有前綴的 `SKILL`、`TOOL_NAME`) | 安靜降級 | | `JSC_MODEL` | 非 Claude CLI 的目前模型,供 `sdlc-gate.sh` 比對;優先序在 transcript 實際值與 stdin `model` 之後 | 改讀 `~/.claude/settings.json`,再不行就安靜降級 | From 65d3c13d225a60ae9d402823f1641711bf9fbff6 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 25 Aug 2026 18:59:29 +0800 Subject: [PATCH 3/3] =?UTF-8?q?chore(hooks):=20=E4=B8=89=E4=BB=BD=20manife?= =?UTF-8?q?st=20=E5=90=8C=E6=AD=A5=E5=8D=87=E7=89=88=E5=88=B0=200.1.7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- plugin.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index e6c2da0..5ccf44c 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-hooks", - "version": "0.1.6", + "version": "0.1.7", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 44f381a..9389335 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-hooks", - "version": "0.1.6", + "version": "0.1.7", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查", "skills": "./skills" } diff --git a/plugin.json b/plugin.json index ba07c9f..79ba94f 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-hooks", - "version": "0.1.6", + "version": "0.1.7", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查", "skills": "./skills/" }