What:依 jsc-meta:skill-check 的稽核結果修正技能與工具——補上每個步驟的可檢核完成條件、 把留在內文的標準輸入輸出流程下放 tools/、修正查表與退碼路由造成的誤判。 Why:稽核發現這些缺失會讓技能在實際執行時走錯分支或靜默通過。 完成條件缺漏是最常被違反的一項;退碼誤判與查表錯誤則會讓良性狀況被當成失敗。 How:逐項對照 references/guidelines.md 的審核檢查清單修正,新增的工具都有 documented exit codes,並以真實執行驗證每條路徑。 Who:jsc-meta:skill-check 例行稽核(2026-08-25)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
299 lines
16 KiB
Bash
Executable File
299 lines
16 KiB
Bash
Executable File
#!/usr/bin/env sh
|
||
# wire-cli.sh — 把 jsc 五支 hook 接線到單一 CLI(供 hooks-install 技能呼叫)。
|
||
# 用法: wire-cli.sh {claude|codex|copilot|antigravity|kiro}
|
||
# 行為(依 CLI 而定,皆為冪等:重跑只取代既有的 jsc-hooks 標記段落,不會重複疊加):
|
||
# claude — 什麼都不用寫,hooks.json 已自動接線五支 hook
|
||
# codex — 在 shell rc 檔加上 codex 別名,轉呼叫 tools/jsc-wrap.sh codex(開始計時);
|
||
# 在 config.toml 設 notify(每輪補 session-timer.sh start 再 mark,JSC_CLI=codex);
|
||
# 在 AGENTS.md 附加 STE100 規則段落(prompt 降級)
|
||
# copilot — 在 shell rc 檔加上 copilot 別名,轉呼叫 tools/jsc-wrap.sh copilot;
|
||
# 在 copilot-instructions.md 附加 STE100 規則段落
|
||
# antigravity — 在 shell rc 檔加上 agy 別名,轉呼叫 tools/jsc-wrap.sh antigravity;
|
||
# 在全域規則檔附加 STE100 規則段落
|
||
# kiro — 在工作區 .kiro/hooks/ 下建立 jsc-hooks.json(每輪 mark 加 STE100)
|
||
# 與 jsc-hooks-session-start.json(sessionStart 開始計時),皆帶 JSC_CLI=kiro
|
||
#
|
||
# 覆蓋範圍要據實回報,不得暗示每個 CLI 都有保護:
|
||
# claude 五支 hook 全接,回報 wired
|
||
# codex、copilot、antigravity、kiro 只有別名或規則檔,接不上 PreToolUse 與
|
||
# UserPromptSubmit,版本前置檢查與 SDLC 模型鎖
|
||
# 都沒接上,一律回報 degraded 並在 reason 講明
|
||
#
|
||
# 寫入後自我驗證,通過才回報成功:每個寫過的檔案重新讀一次,確認標記段落存在且落在
|
||
# 正確位置(codex 的 notify 必須是根層鍵,不能被歸進前一張表;kiro 的 JSON 必須成對
|
||
# 且 on、run 在最上層)。腳本說寫好了卻寫錯位置,是最難查的失敗,所以驗證放在腳本裡。
|
||
#
|
||
# 輸出: 第一行固定為 `status={wired|degraded|skipped|failed} reason=...`(可供程式判讀),
|
||
# 其後為人類可讀的繁中說明。
|
||
# 結束碼: 0=wired(已完整接線) 1=degraded(降級為 prompt/技能步驟檢查)
|
||
# 2=用法錯誤 3=skipped(該 CLI 未偵測到執行檔,略過)
|
||
# 4=failed(寫入或驗證沒過,接線沒生效;由 hooks-install 呼叫 report-error.sh 回報)
|
||
set -u
|
||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||
ROOT=$(cd "$HERE/.." && pwd)
|
||
HOOKS="$ROOT/hooks"
|
||
# cli_bin(CLI 代號 → 實際執行檔)的唯一來源在 lib.sh,包裝啟動器也用同一份
|
||
. "$HOOKS/lib.sh"
|
||
|
||
cli="${1:-}"
|
||
case "$cli" in
|
||
claude|codex|copilot|antigravity|kiro) ;;
|
||
*)
|
||
echo "用法:wire-cli.sh {claude|codex|copilot|antigravity|kiro}" >&2
|
||
exit 2 ;;
|
||
esac
|
||
|
||
# STE100 規則段落的唯一來源:ste100-guard.sh 的實際輸出
|
||
ste100_text() { sh "$HOOKS/ste100-guard.sh" 2>/dev/null | sed '/^exit /d'; }
|
||
|
||
# 以標記整段取代(冪等);標記不存在就在檔尾新增;檔案不存在就建立。
|
||
# 適用 markdown 規則檔與 shell rc 檔:這兩種檔案沒有「區段」概念,附在檔尾就對了。
|
||
# $1=檔案 $2=開頭標記行 $3=結尾標記行 $4=標記之間要寫入的內容
|
||
replace_block() {
|
||
file="$1"; bopen="$2"; bshut="$3"; content="$4"
|
||
dir=$(dirname "$file")
|
||
mkdir -p "$dir" 2>/dev/null || return 1
|
||
touch "$file" 2>/dev/null || return 1
|
||
# touch 對目錄也會成功,所以要另外確認它真的是一般檔案;不然接著的寫入才失敗,
|
||
# 而 shell 開檔失敗的訊息蓋不掉,會漏一行 cannot create 給使用者看。
|
||
[ -f "$file" ] || return 1
|
||
block=$(printf '%s\n%s\n%s' "$bopen" "$content" "$bshut")
|
||
if grep -qF "$bopen" "$file" 2>/dev/null; then
|
||
awk -v bopen="$bopen" -v bshut="$bshut" -v block="$block" '
|
||
$0==bopen { print block; skip=1; next }
|
||
$0==bshut { skip=0; next }
|
||
skip { next }
|
||
{ print }
|
||
' "$file" > "$file.jsc-tmp" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; }
|
||
mv "$file.jsc-tmp" "$file" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; }
|
||
else
|
||
# 包一層子 shell 才蓋得住 shell 自己的開檔失敗訊息(>> 失敗時那行不走命令的 stderr)
|
||
( printf '\n%s\n' "$block" >> "$file" ) 2>/dev/null || return 1
|
||
fi
|
||
}
|
||
|
||
# TOML 版的整段取代:標記段落一律放在第一個表頭(`[table]`、`[[array]]`)之前。
|
||
# TOML 的根層鍵只在第一個表頭之前有效,附在檔尾會被歸進最後那張表——檔案照樣解析
|
||
# 得過,codex 卻永遠讀不到 notify,hook 靜靜失效。所以位置本身就是正確性的一部分。
|
||
# 舊版寫錯位置的段落也會被這支函式移到正確位置(先整段刪除,再插到表頭之前)。
|
||
# $1=檔案 $2=開頭標記行 $3=結尾標記行 $4=標記之間要寫入的內容
|
||
replace_block_toml() {
|
||
file="$1"; bopen="$2"; bshut="$3"; content="$4"
|
||
dir=$(dirname "$file")
|
||
mkdir -p "$dir" 2>/dev/null || return 1
|
||
touch "$file" 2>/dev/null || return 1
|
||
# touch 對目錄也會成功,所以要另外確認它真的是一般檔案;不然接著的寫入才失敗,
|
||
# 而 shell 開檔失敗的訊息蓋不掉,會漏一行 cannot create 給使用者看。
|
||
[ -f "$file" ] || return 1
|
||
block=$(printf '%s\n%s\n%s' "$bopen" "$content" "$bshut")
|
||
awk -v bopen="$bopen" -v bshut="$bshut" -v block="$block" '
|
||
$0==bopen { skip=1; next }
|
||
$0==bshut { skip=0; next }
|
||
skip { next }
|
||
# 表頭樣式:整行只有 [name] 或 [[name]]。多行陣列裡的 [1, 2], 不會命中。
|
||
!done && /^[ \t]*\[\[?[^][]+\]\]?[ \t]*$/ { print block; print ""; done=1 }
|
||
{ print }
|
||
END { if (!done) print block }
|
||
' "$file" > "$file.jsc-tmp" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; }
|
||
mv "$file.jsc-tmp" "$file" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; }
|
||
}
|
||
|
||
# 驗證:檔案裡有這段標記嗎($1=檔案 $2=開頭標記行)
|
||
has_block() { grep -qF "$2" "$1" 2>/dev/null; }
|
||
|
||
# 驗證:TOML 的某個鍵是不是落在根層(第一個表頭之前)。$1=檔案 $2=鍵名
|
||
toml_root_key() {
|
||
awk -v k="$2" '
|
||
/^[ \t]*\[\[?[^][]+\]\]?[ \t]*$/ { intable=1; next }
|
||
!intable && $0 ~ "^[ \t]*" k "[ \t]*=" { found=1 }
|
||
END { exit(found ? 0 : 1) }
|
||
' "$1" 2>/dev/null
|
||
}
|
||
|
||
# 驗證:JSON 括號成對,且某個鍵出現在最上層($1=檔案 $2=鍵名)。
|
||
# 解析失敗(括號不成對、字串沒收尾)也回傳非 0,所以這支同時當語法檢查用。
|
||
json_top_key() {
|
||
awk -v k="$2" '
|
||
{ s = s $0 "\n" }
|
||
END {
|
||
n = length(s); depth = 0; i = 1; found = 0; bad = 0
|
||
while (i <= n) {
|
||
c = substr(s, i, 1)
|
||
if (c == "\"") {
|
||
buf = ""; i++; closed = 0
|
||
while (i <= n) {
|
||
c = substr(s, i, 1)
|
||
if (c == "\\") { i += 2; continue }
|
||
if (c == "\"") { i++; closed = 1; break }
|
||
buf = buf c; i++
|
||
}
|
||
if (!closed) { bad = 1; break }
|
||
j = i
|
||
while (j <= n && substr(s, j, 1) ~ /[ \t\r\n]/) j++
|
||
if (substr(s, j, 1) == ":" && depth == 1 && buf == k) found = 1
|
||
continue
|
||
}
|
||
if (c == "{" || c == "[") depth++
|
||
else if (c == "}" || c == "]") { depth--; if (depth < 0) { bad = 1; break } }
|
||
i++
|
||
}
|
||
exit((found && !bad && depth == 0) ? 0 : 1)
|
||
}' "$1" 2>/dev/null
|
||
}
|
||
|
||
# 找出可寫入別名的 shell rc 檔;都不存在就以 ~/.bashrc 為預設(自動建立)。
|
||
# 印出找到或建立的 rc 檔路徑,一行一個。
|
||
rc_files() {
|
||
found=""
|
||
for f in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.config/fish/config.fish"; do
|
||
[ -f "$f" ] && { printf '%s\n' "$f"; found=1; }
|
||
done
|
||
[ -n "$found" ] || printf '%s\n' "$HOME/.bashrc"
|
||
}
|
||
|
||
# 把別名寫進每個 rc 檔並逐檔驗證。$1=標記名(不含 # 與 /)$2=別名內容
|
||
# 迴圈不可以放在管線右邊:那會變成子 shell,寫入失敗的旗標傳不回來,
|
||
# 明明沒寫成功也照樣回報 wired。改成從暫存檔讀,迴圈就留在本 shell。
|
||
write_alias_rc() {
|
||
_mark="$1"; _line="$2"; _ok=1
|
||
_list=$(mktemp) || return 1
|
||
rc_files > "$_list" || { rm -f "$_list"; return 1; }
|
||
while IFS= read -r rc; do
|
||
[ -n "$rc" ] || continue
|
||
replace_block "$rc" "# $_mark" "# /$_mark" "$_line" || { _ok=0; continue; }
|
||
grep -qF "$_line" "$rc" 2>/dev/null || _ok=0
|
||
done < "$_list"
|
||
rm -f "$_list"
|
||
[ "$_ok" = 1 ]
|
||
}
|
||
|
||
skip() { # $1=reason
|
||
printf 'status=skipped reason=%s\n' "$1"
|
||
echo "[jsc] 略過:$1"
|
||
exit 3
|
||
}
|
||
|
||
fail() { # $1=reason
|
||
printf 'status=failed reason=%s\n' "$1"
|
||
echo "[jsc] 接線沒生效:$1" >&2
|
||
echo "[jsc] 請以 tools/report-error.sh 回報這次失敗,再修好原因重跑本腳本。" >&2
|
||
exit 4
|
||
}
|
||
|
||
case "$cli" in
|
||
claude)
|
||
bin=$(cli_bin claude)
|
||
command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 claude 執行檔"
|
||
[ -f "$HOOKS/hooks.json" ] || fail "找不到 $HOOKS/hooks.json,claude 接不到任何 hook"
|
||
printf 'status=wired reason=%s\n' "hooks.json 自動接線"
|
||
echo "[jsc] claude:由 hooks/hooks.json 自動接線全部五支 hook,無需寫入設定。"
|
||
exit 0 ;;
|
||
|
||
codex)
|
||
bin=$(cli_bin codex)
|
||
command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 codex 執行檔"
|
||
CODEX_HOME="${CODEX_HOME:-$HOME/.codex}"
|
||
config="$CODEX_HOME/config.toml"
|
||
agents="$CODEX_HOME/AGENTS.md"
|
||
# codex 的 notify 只在每一輪結束時觸發,沒有工作階段開始事件,所以計時分兩段接:
|
||
# 1. shell 別名走 jsc-wrap.sh:啟動當下就 session-timer start,並給這次工作階段
|
||
# 一個 JSC_SESSION_ID,codex 內觸發的 notify 會沿用同一個 id。
|
||
# 2. notify 每輪先補 start 再 mark。start 已有紀錄就不動,所以沒走別名啟動時
|
||
# 仍拿得到起始時間(從第一輪算起)。少了這一段,worklog 只會拿到 0 秒。
|
||
timer="sh '$HOOKS/session-timer.sh'"
|
||
notify_line="notify = [\"env\", \"JSC_CLI=codex\", \"sh\", \"-c\", \"$timer start </dev/null; $timer mark </dev/null\"]"
|
||
alias_line="alias $bin='sh \"$HERE/jsc-wrap.sh\" codex'"
|
||
replace_block_toml "$config" "# jsc-hooks" "# /jsc-hooks" "$notify_line" \
|
||
|| fail "無法寫入 $config"
|
||
has_block "$config" "# jsc-hooks" || fail "$config 寫入後讀不到 jsc-hooks 標記段落"
|
||
toml_root_key "$config" notify \
|
||
|| fail "$config 的 notify 沒有落在根層(被歸進某張表,codex 讀不到,hook 會靜靜失效)"
|
||
write_alias_rc "jsc-hooks:codex" "$alias_line" || fail "無法把 $bin 別名寫進 shell rc 檔"
|
||
replace_block "$agents" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" "$(ste100_text)" \
|
||
|| fail "無法寫入 $agents"
|
||
has_block "$agents" "<!-- jsc-hooks -->" || fail "$agents 寫入後讀不到 jsc-hooks 標記段落"
|
||
printf 'status=degraded reason=%s\n' "STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查"
|
||
echo "[jsc] codex:已在 shell rc 加上 $bin 別名,轉呼叫 tools/jsc-wrap.sh codex,啟動當下開始計時。"
|
||
echo "[jsc] codex:別名要開新的 shell 或重新 source rc 檔才生效。"
|
||
echo "[jsc] codex:已設定 $config 的 notify(根層鍵,已驗證),每輪補 session-timer.sh start 再 mark。"
|
||
echo "[jsc] codex:已在 $agents 寫入 STE100 規則段落(prompt 降級)。"
|
||
echo "[jsc] codex:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。"
|
||
echo "[jsc] codex:版本前置檢查接不上(codex 沒有 pre-tool hook),改由 /jsc-cli:deploy 定期更新。"
|
||
exit 1 ;;
|
||
|
||
copilot)
|
||
bin=$(cli_bin copilot)
|
||
command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 copilot 執行檔"
|
||
instr="${JSC_COPILOT_INSTRUCTIONS:-$HOME/.config/copilot/copilot-instructions.md}"
|
||
alias_line="alias $bin='sh \"$HERE/jsc-wrap.sh\" copilot'"
|
||
write_alias_rc "jsc-hooks:copilot" "$alias_line" || fail "無法把 $bin 別名寫進 shell rc 檔"
|
||
replace_block "$instr" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" "$(ste100_text)" \
|
||
|| fail "無法寫入 $instr"
|
||
has_block "$instr" "<!-- jsc-hooks -->" || fail "$instr 寫入後讀不到 jsc-hooks 標記段落"
|
||
printf 'status=degraded reason=%s\n' "STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查"
|
||
echo "[jsc] copilot:已在 shell rc 加上 $bin 別名,轉呼叫 tools/jsc-wrap.sh copilot。"
|
||
echo "[jsc] copilot:已在 $instr 寫入 STE100 規則段落(prompt 降級)。"
|
||
echo "[jsc] copilot:別名要開新的 shell 或重新 source rc 檔才生效。"
|
||
echo "[jsc] copilot:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。"
|
||
echo "[jsc] copilot:版本前置檢查接不上(copilot 沒有 pre-tool hook),改由 /jsc-cli:deploy 定期更新。"
|
||
exit 1 ;;
|
||
|
||
antigravity)
|
||
bin=$(cli_bin antigravity)
|
||
command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 antigravity(agy)執行檔"
|
||
rules="${JSC_ANTIGRAVITY_RULES:-$HOME/.antigravity/AGENTS.md}"
|
||
alias_line="alias $bin='sh \"$HERE/jsc-wrap.sh\" antigravity'"
|
||
write_alias_rc "jsc-hooks:antigravity" "$alias_line" || fail "無法把 $bin 別名寫進 shell rc 檔"
|
||
replace_block "$rules" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" "$(ste100_text)" \
|
||
|| fail "無法寫入 $rules"
|
||
has_block "$rules" "<!-- jsc-hooks -->" || fail "$rules 寫入後讀不到 jsc-hooks 標記段落"
|
||
printf 'status=degraded reason=%s\n' "STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查"
|
||
echo "[jsc] antigravity:已在 shell rc 加上 $bin 別名,轉呼叫 tools/jsc-wrap.sh antigravity。"
|
||
echo "[jsc] antigravity:已在 $rules 寫入 STE100 規則段落(prompt 降級)。"
|
||
echo "[jsc] antigravity:別名要開新的 shell 或重新 source rc 檔才生效。"
|
||
echo "[jsc] antigravity:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。"
|
||
echo "[jsc] antigravity:版本前置檢查接不上(antigravity 沒有 pre-tool hook),改由 /jsc-cli:deploy 定期更新。"
|
||
exit 1 ;;
|
||
|
||
kiro)
|
||
command -v "$(cli_bin kiro)" >/dev/null 2>&1 || skip "未偵測到 kiro-cli 執行檔"
|
||
hookdir="./.kiro/hooks"
|
||
hookfile="$hookdir/jsc-hooks.json"
|
||
startfile="$hookdir/jsc-hooks-session-start.json"
|
||
mkdir -p "$hookdir" 2>/dev/null || fail "無法建立 $hookdir"
|
||
# 計時要分兩個檔:kiro 的一個 hook 檔只有一組 run,所有事件共用。
|
||
# sessionStart 單獨一檔跑 restart,才算得出這一次工作階段的花費時間;
|
||
# kiro 給不到 session id,紀錄共用 default,不覆寫起始時間就會把上一階段算進來。
|
||
cat > "$startfile" 2>/dev/null <<EOF || fail "無法寫入 $startfile"
|
||
{
|
||
"name": "jsc-hooks-session-start",
|
||
"description": "jsc session timer start (auto-generated by jsc-hooks:hooks-install, do not edit by hand)",
|
||
"on": ["sessionStart"],
|
||
"env": { "JSC_CLI": "kiro" },
|
||
"run": "sh \\"$HOOKS/session-timer.sh\\" restart </dev/null"
|
||
}
|
||
EOF
|
||
cat > "$hookfile" 2>/dev/null <<EOF || fail "無法寫入 $hookfile"
|
||
{
|
||
"name": "jsc-hooks",
|
||
"description": "jsc session timer + STE100 guard bridge (auto-generated by jsc-hooks:hooks-install, do not edit by hand)",
|
||
"on": ["sessionEnd", "userPromptSubmit"],
|
||
"env": { "JSC_CLI": "kiro" },
|
||
"run": "sh \\"$HOOKS/session-timer.sh\\" mark </dev/null; sh \\"$HOOKS/ste100-guard.sh\\" </dev/null"
|
||
}
|
||
EOF
|
||
for f in "$startfile" "$hookfile"; do
|
||
json_top_key "$f" on || fail "$f 不是成對的 JSON,或 on 不在最上層"
|
||
json_top_key "$f" run || fail "$f 不是成對的 JSON,或 run 不在最上層"
|
||
grep -qF '"JSC_CLI": "kiro"' "$f" 2>/dev/null || fail "$f 缺少 JSC_CLI=kiro"
|
||
grep -qF 'session-timer.sh' "$f" 2>/dev/null || fail "$f 的 run 沒有接到 session-timer.sh"
|
||
done
|
||
grep -qF '"sessionStart"' "$startfile" 2>/dev/null || fail "$startfile 沒有接在 sessionStart"
|
||
grep -qF '"userPromptSubmit"' "$hookfile" 2>/dev/null || fail "$hookfile 沒有接在 userPromptSubmit"
|
||
printf 'status=degraded reason=%s\n' "SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查"
|
||
echo "[jsc] kiro:已建立 $startfile(sessionStart 開始計時)與 $hookfile(JSC_CLI=kiro),兩份都已驗證。"
|
||
echo "[jsc] kiro:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。"
|
||
echo "[jsc] kiro:版本前置檢查接不上(kiro 沒有 pre-tool hook),改由 /jsc-cli:deploy 定期更新。"
|
||
exit 1 ;;
|
||
esac
|