feat/work-package-pr-gate-hook-enforcement #20

Merged
admin merged 3 commits from feat/work-package-pr-gate-hook-enforcement into develop 2026-08-25 11:03:25 +00:00
7 changed files with 133 additions and 9 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-hooks", "name": "jsc-hooks",
"version": "0.1.6", "version": "0.1.7",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查",
"skills": "./skills", "skills": "./skills",
"author": { "author": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-hooks", "name": "jsc-hooks",
"version": "0.1.6", "version": "0.1.7",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查",
"skills": "./skills" "skills": "./skills"
} }
+2 -1
View File
@@ -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/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/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/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` 技能接線、改裝包裝啟動器,或降級為規則檔。 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_CLAUDE_SETTINGS_DIR` | `tools/wire-cli.sh purge claude` 要清 `hooks` 鍵的設定檔目錄。指向一份複製品就能完整測過刪鍵邏輯,不必拿使用者本人的設定檔當測試場 | 預設 `~/.claude` |
| `JSC_VERSION_GUARD` | 設 `off` 完全略過版本前置檢查(離線工作用) | 啟用檢查 | | `JSC_VERSION_GUARD` | 設 `off` 完全略過版本前置檢查(離線工作用) | 啟用檢查 |
| `JSC_VERSION_TTL` | 遠端版本查詢的快取秒數 | 預設 600 | | `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_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`,再不行就安靜降級 | | `JSC_MODEL` | 非 Claude CLI 的目前模型,供 `sdlc-gate.sh` 比對;優先序在 transcript 實際值與 stdin `model` 之後 | 改讀 `~/.claude/settings.json`,再不行就安靜降級 |
+8
View File
@@ -20,6 +20,10 @@
{ {
"type": "command", "type": "command",
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/sdlc-gate.sh\" check" "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", "type": "command",
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/version-guard.sh\"" "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/version-guard.sh\""
},
{
"type": "command",
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/sdlc-gate.sh\" wp-check skill"
} }
] ]
} }
+112 -3
View File
@@ -17,11 +17,26 @@
# sdlc-gate.sh check hook 模式(UserPromptSubmit):模型不符即擋下該輪提示。 # sdlc-gate.sh check hook 模式(UserPromptSubmit):模型不符即擋下該輪提示。
# sdlc-gate.sh report 印出 {sid} {stage} {必要標籤} {上鎖時的模型};無鎖不印。 # sdlc-gate.sh report 印出 {sid} {stage} {必要標籤} {上鎖時的模型};無鎖不印。
# #
# exit code 例外:其他 jsc hook 一律 exit 0 不中斷宿主 CLI;本檔 check 是刻意的例外—— # sdlc-gate.sh wp-lock {owner}/{repo} {index} 記下一筆未結清的工作包 PR。
# 鎖存在且模型不符時 exit 2 擋下該輪提示。只用提示注入的話模型可以無視,閘門形同虛設。 # 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 安靜降級。 # 無鎖、或資料不足無法判定時,仍照舊 exit 0 安靜降級。
HERE=$(dirname "$0"); . "$HERE/lib.sh" 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) sid=$(session_id)
state="$JSC_HOME/sessions/$sid.stage" state="$JSC_HOME/sessions/$sid.stage"
TAGS_TSV="$JSC_HOME/model-tags.tsv" TAGS_TSV="$JSC_HOME/model-tags.tsv"
@@ -92,6 +107,44 @@ current_model() {
printf '%s' "$m" 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 case "${1:-check}" in
lock) lock)
stage="${2:-}" stage="${2:-}"
@@ -169,5 +222,61 @@ case "${1:-check}" in
[ -n "$line" ] && echo "$sid $line" [ -n "$line" ] && echo "$sid $line"
fi fi
exit 0 ;; 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 esac
exit 0 exit 0
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-hooks", "name": "jsc-hooks",
"version": "0.1.6", "version": "0.1.7",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查",
"skills": "./skills/" "skills": "./skills/"
} }
+8 -2
View File
@@ -582,7 +582,9 @@ if [ "$action" = smoke ]; then
# $2 不加引號展開:子命令是固定字面字,空字串時要展成「沒有參數」而不是空參數。 # $2 不加引號展開:子命令是固定字面字,空字串時要展成「沒有參數」而不是空參數。
smoke_one() { smoke_one() {
_h="$1"; _s="${2:-}" _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 if [ "$_rc" -eq 0 ]; then
printf '[jsc] %s%s:exit 0,正常。\n' "$_h" "${_s:+ $_s}" >> "$smoke_out" printf '[jsc] %s%s:exit 0,正常。\n' "$_h" "${_s:+ $_s}" >> "$smoke_out"
elif [ "$_h" = sdlc-gate.sh ] && [ "$_s" = check ] && [ "$_rc" -eq 2 ]; then 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 version-guard.sh
smoke_one skill-usage.sh smoke_one skill-usage.sh
smoke_one ste100-guard.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 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 cat "$smoke_out"; rm -f "$smoke_out"; exit 0
fi fi
printf 'status=failed reason=%s\n' "$smoke_fails 支 hook 有執行期錯誤" printf 'status=failed reason=%s\n' "$smoke_fails 支 hook 有執行期錯誤"