feat(sdlc-gate): 工作包閘門加上歸屬比對與領取紀錄

What:`hooks/sdlc-gate.sh` 新增 `wp-claim {owner}/{repo} {工作包代號} [{PR 編號}] [{分析頁}]` 與 `wp-unclaim {owner}/{repo}` 兩個子命令,`wp-lock` 新增第四個參數收工作包代號,鎖檔改為純文字 `key=value`,`wp-report` 多印第四欄工作包代號。`wp-check` 逐筆判歸屬:`prompt` 多注入一行點名不屬於這裡的 PR,`skill` 擋下 `plan`、`analyze`、`maintain` 時一併點名,`implement` 仍放行但收到同一則提醒。

Why:同一份分析常有好幾包平行進行,每包各自的 worktree 與 PR。原本的鎖檔只記存取庫與 PR 編號,看不出那支 PR 是誰的,於是任何一個工作階段都可能去改別包的程式碼、回別包的留言。

How:歸屬只比一件事——鎖檔的 `wp` 與同一個存取庫領取檔的 `wp` 取數字比一次,兩邊都有值且不相等就是別包的。`WP-03`、`WP-3`、`3` 先正規化再比,不然同一包的幾種寫法會被當成不同包。任何一邊查不到(沒有分析頁、沒寫代號、領取檔不存在)一律當查無歸屬並放行,理由與 `version-guard.sh` 一致:只擋確定違規,否則會把技能組維護自己鎖死。舊版單行 TSV 鎖檔照樣讀得動,讀出來是查無歸屬,換格式不會讓既有的鎖失效。`implement` 一律放行,連別包的 PR 未結清也放行——結清 PR 正是 `implement` 的步驟,擋它會把流程鎖死。分類迴圈用 here-document 餵資料而不用管線,管線右邊是子 shell,判到的違規會在迴圈結束時全部消失。

Who:跑 SDLC 實作階段的每個工作階段,以及 `jsc-sdlc/tools/wp-gate.sh` 的 `claim`、`lock` 與 `owns`。
This commit is contained in:
2026-08-27 11:20:28 +08:00
parent 9875f917bf
commit 870f52fbaa
+144 -15
View File
@@ -17,12 +17,19 @@
# sdlc-gate.sh check hook 模式(UserPromptSubmit):模型不符即擋下該輪提示。
# sdlc-gate.sh report 印出 {sid} {stage} {必要標籤} {上鎖時的模型};無鎖不印。
#
# sdlc-gate.sh wp-lock {owner}/{repo} {index} 記下一筆未結清的工作包 PR。
# sdlc-gate.sh wp-lock {owner}/{repo} {index} [{工作包代號}]
# 記下一筆未結清的工作包 PR;第四個參數是這支 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-claim {owner}/{repo} {工作包代號} [{PR 編號}] [{分析頁}]
# 領取工作包:記下這個存取庫目前歸誰做,供歸屬比對用。
# 由 jsc-sdlc 在領取工作包時呼叫,本檔不自己判斷歸誰。
# exit 0 = 已記下;exit 2 = 用法錯誤或寫不進狀態檔。
# sdlc-gate.sh wp-unclaim {owner}/{repo} 交回工作包,移除領取紀錄;檔案不存在也算成功。
# 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。
@@ -34,15 +41,42 @@
# 跟「implement 挑下一個工作包能不能挑到某一包」是兩件事——後者的判斷依據是該包在分析頁
# WBS 表的相依欄,走 jsc-sdlc/tools/wp-gate.sh check-deps,不靠這支鎖檔。
#
# --- 狀態檔格式(與 jsc-sdlc/tools/wp-gate.sh 對齊,兩邊都靠這段註解對格式) ---
#
# 兩種檔案都放在 $JSC_HOME/wp/ 底下,都是純文字 key=value,一行一欄位,順序不拘,
# 不認得的鍵一律忽略。格式刻意做到最簡,兩邊各自實作也對得上。
#
# 鎖檔 {owner}-{repo}-{index}.pr 一支未結清的工作包 PR
# repo={owner}/{repo} 存取庫
# index={PR 編號} PR 編號,純數字
# wp={工作包代號} 這支 PR 做的是哪一包,值取自分析頁 WBS 表那一欄的原字串;
# 查不到就留空
# locked={ISO 時間} 上鎖時間,UTC
#
# 領取檔 {owner}-{repo}.claim 這個存取庫目前領取中的工作包,一個存取庫一支
# repo={owner}/{repo} 存取庫
# wp={工作包代號} 目前領取的是哪一包
# pr={PR 編號} 這一包的 PR;還沒開 PR 就留空
# analyze={分析頁頁名} 歸屬認定的來源分析頁;只作記錄,本檔不去讀那一頁
# claimed={ISO 時間} 領取時間,UTC
#
# 舊版鎖檔是單行 TSV「{repo}<TAB>{index}<TAB>{上鎖時間}」,沒有工作包欄位。舊檔照樣讀得動,
# 讀出來的歸屬是空的,也就是查無歸屬、一律放行——不會因為換格式就把既有的鎖判成違規。
#
# 歸屬比對只做一件事:把鎖檔的 wp 跟同一個存取庫領取檔的 wp 比數字。兩邊都有值且不相等,
# 那支 PR 就不屬於目前領取的工作包。任何一邊查不到(沒有分析頁、沒寫工作包代號、領取檔不存在)
# 一律當查無歸屬並放行,理由與 version-guard.sh 相同:只擋確定違規,否則會把技能組維護鎖死。
#
# 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)轉呼叫這三個子命令時就這樣整支卡死。
# wp-lock、wp-unlock、wp-claim、wp-unclaim、wp-report 兩者都不需要,而 read_stdin 在標準輸入
# 是管線又沒人關閉時會一直等——工具腳本(jsc-sdlc 的 wp-gate.sh)轉呼叫這些子命令時就這樣整支
# 卡死。新增子命令一律照這個原則歸類:不需要 stdin 就加進下面這一列。
case "${1:-}" in
wp-lock|wp-unlock|wp-report) STDIN_JSON="" ;;
wp-lock|wp-unlock|wp-claim|wp-unclaim|wp-report) STDIN_JSON="" ;;
*) read_stdin ;;
esac
sid=$(session_id)
@@ -129,17 +163,65 @@ wp_state_file() { # $1={owner}/{repo} $2=index
printf '%s/%s-%s.pr' "$WP_DIR" "$(printf '%s' "$1" | tr '/' '-')" "$2"
}
# 未結清清單,每行「{owner}/{repo} {index} {上鎖時間}」;沒有就不輸出。
wp_claim_file() { # $1={owner}/{repo}
printf '%s/%s.claim' "$WP_DIR" "$(printf '%s' "$1" | tr '/' '-')"
}
# 取 key=value 檔案裡某個鍵的值;沒有那個鍵就不輸出。值裡的等號原樣保留。
wp_field() { # $1=檔案 $2=鍵名
[ -f "$1" ] || return 0
awk -v k="$2" '
index($0, k "=") == 1 {
v = substr($0, length(k) + 2)
gsub(/^[ \t]+|[ \t\r]+$/, "", v)
print v; exit
}' "$1" 2>/dev/null
}
# 讀一支鎖檔,印出「{owner}/{repo} {index} {上鎖時間} {工作包代號}」。
# 新格式(key=value)與舊格式(單行 TSV,無工作包欄位)都認:換格式不該讓既有的鎖失效。
wp_read_lock() { # $1=鎖檔
[ -f "$1" ] || return 0
awk '
/^[a-z][a-z]*=/ {
k = substr($0, 1, index($0, "=") - 1)
v = substr($0, index($0, "=") + 1)
gsub(/^[ \t]+|[ \t\r]+$/, "", v)
f[k] = v; kv = 1; next
}
NR == 1 { split($0, t, "\t") }
END {
if (kv) { repo = f["repo"]; idx = f["index"]; at = f["locked"]; wp = f["wp"] }
else { repo = t[1]; idx = t[2]; at = t[3]; wp = "" }
gsub(/\r/, "", at)
if (repo != "") print repo " " idx " " at (wp != "" ? " " wp : "")
}' "$1" 2>/dev/null
}
# 未結清清單,每行「{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' ' ')
_line=$(wp_read_lock "$_f")
[ -n "$_line" ] && printf '%s\n' "$_line"
done
}
# 未結清清單濃縮成一句可讀的「{repo} 第 {index} 號」,多筆用頓號串起。
# 某個存取庫目前領取中的工作包代號;沒有領取檔或沒寫代號就不輸出(查無歸屬)。
wp_claimed() { # $1={owner}/{repo}
wp_field "$(wp_claim_file "$1")" wp
}
# 工作包代號取數值:去掉英文前綴與前導零,只留數字。範本補零到兩位,呼叫端不一定補,
# 也可能只給數字;比字串會讓同一包的幾種寫法互相認不得,歸屬就誤判成不同包。
wp_num() { # $1=工作包代號
printf '%s' "${1:-}" | tr -dc '0-9' | sed 's/^0*//'
}
# 未結清清單濃縮成一句可讀的「{repo} 第 {index} 號」,多筆用頓號串起。只吃前兩欄,
# 第四欄的工作包代號另外由歸屬比對處理,混進這句話只會把提醒句拉長。
wp_brief() { # 標準輸入 = wp_pending 的輸出
awk '{ out = (out == "" ? $1 " 第 " $2 " 號" : out "、" $1 " 第 " $2 " 號") } END { print out }'
}
@@ -232,7 +314,7 @@ case "${1:-check}" in
exit 0 ;;
wp-lock)
repo="${2:-}"; idx="${3:-}"
repo="${2:-}"; idx="${3:-}"; wp="${4:-}"
wp_valid_repo "$repo" || {
echo "[jsc][工作包閘門][ERR]:存取庫須為 {owner}/{repo} 格式,收到「${repo:-空值}」。" >&2; exit 2; }
case "$idx" in
@@ -241,9 +323,32 @@ case "${1:-check}" in
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 || {
printf 'repo=%s\nindex=%s\nwp=%s\nlocked=%s\n' "$repo" "$idx" "$wp" "$(now_iso)" > "$wpf" 2>/dev/null || {
echo "[jsc][工作包閘門][ERR]:寫不進狀態檔 $wpf,工作包鎖未生效。" >&2; exit 2; }
echo "[jsc][工作包閘門][OK]:已記下 $repo 第 $idx 號 PR 未結清。相依於它的工作包在它結清前不得開始;其餘互不相依的工作包不受影響。"
echo "[jsc][工作包閘門][OK]:已記下 $repo 第 $idx 號 PR 未結清${wp:+(工作包 $wp)}。相依於它的工作包在它結清前不得開始;其餘互不相依的工作包不受影響。"
[ -n "$wp" ] || echo "[jsc][工作包閘門]:這筆沒帶工作包編號,歸屬比對查不到來源,之後一律放行。要擋跨工作包的 PR,請在 wp-lock 帶上第四個參數。"
exit 0 ;;
wp-claim)
repo="${2:-}"; wp="${3:-}"; pr="${4:-}"; page="${5:-}"
wp_valid_repo "$repo" || {
echo "[jsc][工作包閘門][ERR]:存取庫須為 {owner}/{repo} 格式,收到「${repo:-空值}」。" >&2; exit 2; }
[ -n "$(wp_num "$wp")" ] || {
echo "[jsc][工作包閘門][ERR]:工作包代號須帶數字(例:分析頁上那個 WP 開頭的代號),收到「${wp:-空值}」。" >&2; exit 2; }
mkdir -p "$WP_DIR" 2>/dev/null || true
cf=$(wp_claim_file "$repo")
printf 'repo=%s\nwp=%s\npr=%s\nanalyze=%s\nclaimed=%s\n' \
"$repo" "$wp" "$pr" "$page" "$(now_iso)" > "$cf" 2>/dev/null || {
echo "[jsc][工作包閘門][ERR]:寫不進領取檔 $cf,歸屬比對不會生效。" >&2; exit 2; }
echo "[jsc][工作包閘門][OK]:已記下 $repo 領取中的工作包為 $wp。這個存取庫的其他工作包 PR 一律不由這裡結清。"
exit 0 ;;
wp-unclaim)
repo="${2:-}"
wp_valid_repo "$repo" || {
echo "[jsc][工作包閘門][ERR]:存取庫須為 {owner}/{repo} 格式,收到「${repo:-空值}」。" >&2; exit 2; }
# 冪等,理由同 wp-unlock:交回流程可能被重跑,第二次失敗只會讓呼叫端誤判。
rm -f "$(wp_claim_file "$repo")" 2>/dev/null || true
exit 0 ;;
wp-unlock)
@@ -267,6 +372,24 @@ case "${1:-check}" in
pending=$(wp_pending)
[ -n "$pending" ] || exit 0
brief=$(printf '%s\n' "$pending" | wp_brief)
# 逐筆判歸屬:鎖檔的工作包代號對上同一個存取庫領取中的代號,才算自己這一包的 PR。
# 兩邊任一邊查不到代號就算查無歸屬,歸進 mine 這一側處理——查不到不是違規的證據。
# 迴圈用 here-document 餵資料,不用管線:管線右邊是子 shell,分類結果傳不回來,
# 判到的違規會在迴圈結束的瞬間全部消失。
mine=''; foreign=''
while read -r _r _i _t _w; do
[ -n "$_r" ] || continue
_a=$(wp_num "$_w"); _b=$(wp_num "$(wp_claimed "$_r")")
if [ -n "$_a" ] && [ -n "$_b" ] && [ "$_a" != "$_b" ]; then
foreign="${foreign}${foreign:+、}$_r 第 $_i 號($_w)"
else
mine="${mine}${mine:+、}$_r 第 $_i 號"
fi
done <<WP_PENDING_EOF
$pending
WP_PENDING_EOF
case "${2:-prompt}" in
prompt)
# 只注入提醒,一律 exit 0。擋提示會連「去把那支 PR 修好」的對話都送不出去,
@@ -274,6 +397,7 @@ case "${1:-check}" in
# 這裡只列得出「哪些工作包還沒結清」,不知道候選包相依於誰——沒有上下文可以判斷。
# 真正「這一包能不能挑」的判斷在 jsc-sdlc/tools/wp-gate.sh check-deps,這則只是提醒。
echo "[jsc] ${brief} PR 尚未合併。相依於它的工作包不能開始,其餘互不相依的工作包不受影響——是否可挑,見 wp-gate.sh check-deps。"
[ -n "$foreign" ] && echo "[jsc] 其中 ${foreign}不屬於這個存取庫領取中的工作包。那幾支交給領取它的工作階段結清:這裡不要改它的程式碼,也不要回它的留言。"
exit 0 ;;
skill)
# 技能名取法比照 version-guard.sh:環境變數優先,非 Claude CLI 只餵得到環境變數。
@@ -284,11 +408,16 @@ case "${1:-check}" in
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
echo "[jsc][工作包閘門][ERR]:${brief} PR 尚未合併,禁止在此存取庫執行「${sname}」。${foreign:+其中 ${foreign}還不屬於領取中的工作包,更不該由這裡處理。}請先把該 PR 結清(合併或關閉),或執行 jsc-hooks/hooks/sdlc-gate.sh wp-unlock {owner}/{repo} {index} 解除;確定要整體放行請設 JSC_WP_GATE=off。本次技能呼叫已擋下。" >&2
exit 2 ;;
implement)
# implement 一律放行,連別包的 PR 未結清也放行:結清 PR 正是 implement 步驟 4 要做
# 的事,擋掉就沒有任何路徑能解除這道鎖。領取檔不綁工作階段,擋下去連領取那一包的
# 工作階段都會被自己的舊紀錄擋住,等於把流程鎖死。所以這裡只注入歸屬提醒。
[ -n "$foreign" ] && echo "[jsc][工作包閘門]:${foreign}不屬於這個存取庫領取中的工作包。這裡只結清自己領取那一包的 PR${mine:+(${mine})};別包的 PR 不要改、留言也不要回,交給領取它的工作階段。"
exit 0 ;;
esac
# implement 與其餘技能一律放行:結清 PR 正是 implement 步驟 4 要做的事,
# 擋掉 implement 就沒有任何路徑能解除這道鎖,等於把流程鎖死。
# 其餘技能一律放行:這道閘門管的是 SDLC 階段,不是整台機器的技能呼叫。
exit 0 ;;
esac
exit 0 ;;