feat(skills): 新增技能盤點頁與共用部署驗證流程,並把技能驗證移到新行程

技能盤點以前只回到對話裡,換一台機器就得重跑才知道裝了什麼。
現在新增技能盤點這個 wiki 頁類型,雜湊取「主機、工具名稱、登入帳號」三段。
每支 CLI 各有自己的 plugin 集合,也各有自己的 hook 接線,那是互相獨立的事實。
少了工具名稱那一段,同一台機器上五支 CLI 會算出同一個雜湊,五份盤點互相覆蓋,
讀的人還看不出被蓋掉。技能盤點新增寫入這兩頁的步驟,整步規定必須開 sub agent。
兩份樣板刻意分開:內容頁每次盤點覆寫整頁,目錄頁只更新自己那一列,
兩者的寫入語意剛好相反,合成一份遲早有人把別台機器的紀錄刪掉。

四支異動技能原本在部署完的同一個工作階段,就叫用剛做好的技能。
部署收尾自己立起重啟閘門,那支技能必被擋下,驗證做不完。
解法不是把它加進豁免清單。豁免擋得住閘門,擋不住「行程還載著舊版」這件事,
硬過關驗到的是舊版行為,等於假通過。所以把判路線、部署、驗證、失敗分流
抽成一份共用說明,驗證一律另開 CLI 行程執行,四支技能只留一行指標指過去。

新增腳本檢查工具,一次做完語法、執行權限與結束碼宣告三項檢查,
只被 source 的函式庫豁免後兩項,而且逐支記在錯誤輸出,不靜默略過。
新增部署路線判定工具,判定改動有沒有進存取庫的預設分支,
取代四支技能各抄一段、各自漂移的散文;判不出來就回報停下,不自己挑路線走。

同時把四支技能裡的中文段落抽到共用說明、指標改回英文,
修正六處相對路徑,把技能盤點的模糊描述改成查得出來的條件,
並讓 manifest 同步的每一個呼叫端逐碼分流。

七支技能改為併行執行:例行稽核從九步併成七步,技能盤點併成六步。
技能盤點不再重跑盤點腳本內部已經跑過的三支腳本,
而那三支原本兼作獨立交叉檢查,拿掉就少一層保護,
所以把少掉的是什麼、風險由誰擋住,明白寫進 Notes,不當作沒發生。
This commit is contained in:
2026-08-31 11:11:12 +08:00
parent 7dc5c32de3
commit 5aa4a3da57
12 changed files with 571 additions and 128 deletions
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env sh
# deploy-route.sh — 判定一個 domain 存取庫的改動是否已進預設分支,決定走部署路線或工作樹路線。
#
# 用法: deploy-route.sh <domain-path> [branch]
# branch 不給就用目前分支。
#
# 為什麼要有這支: marketplace 與 version-guard.sh 都讀存取庫的**預設分支**,
# 停在 develop 的改動 jsc-cli:deploy 看不到,部署路線的完成條件永遠達不到。
# skill-new、skill-update、skill-delete、skillset-update 四支各抄一段同樣的判定散文,
# 四份會各自漂移,所以下放成同一支腳本。
#
# 判定:
# 1. git fetch --prune origin,讓比對基準是遠端而不是本機殘影。
# 2. 預設分支依序取 origin/HEAD 的指向、遠端有沒有 master、遠端有沒有 main。
# 3. 待判分支的 HEAD 是不是 origin/{預設分支} 的祖先——是就代表已經合進去。
#
# 輸出(stdout,一行一組 {鍵}<TAB>{值}):
# route<TAB>deploy|worktree 要走哪條路線
# default-branch<TAB>{name} 比對用的預設分支
# branch<TAB>{name} 被判定的分支
# head<TAB>{sha} 被判定分支的 commit
# pending<TAB>{name} 只有 worktree 路線才有:還沒併進預設分支的分支名
# 說明與失敗原因走 stderr。
#
# 結束碼: 0=deploy 路線,改動已在預設分支上,可以叫 jsc-cli:deploy
# 3=worktree 路線,改動還沒進預設分支,要改對工作樹驗證並在回報裡點名待合的 PR
# 2=用法錯誤(一或兩個參數)
# 1=判不出來:路徑不是 git 存取庫、沒有 origin、fetch 失敗,或取不到預設分支。
# **1 不等於 worktree**:判不出來就停下問人,別自己挑一條路線走。
set -u
usage() {
echo 'usage: deploy-route.sh <domain-path> [branch]' >&2
exit 2
}
[ "$#" -ge 1 ] && [ "$#" -le 2 ] || usage
DOMAIN=${1%/}
[ -n "$DOMAIN" ] || usage
BRANCH=${2:-}
[ -d "$DOMAIN/.git" ] || git -C "$DOMAIN" rev-parse --git-dir >/dev/null 2>&1 || {
echo "不是 git 存取庫:$DOMAIN" >&2; exit 1; }
git -C "$DOMAIN" remote get-url origin >/dev/null 2>&1 || {
echo "存取庫沒有 origin 遠端,無從比對預設分支:$DOMAIN" >&2; exit 1; }
git -C "$DOMAIN" fetch --prune --quiet origin >/dev/null 2>&1 || {
echo "git fetch origin 失敗,比對基準會是本機殘影,先修連線再重跑:$DOMAIN" >&2; exit 1; }
if [ -z "$BRANCH" ]; then
BRANCH=$(git -C "$DOMAIN" rev-parse --abbrev-ref HEAD 2>/dev/null) || BRANCH=''
fi
[ -n "$BRANCH" ] && [ "$BRANCH" != HEAD ] || {
echo "取不到目前分支名(可能在 detached HEAD),請用第二個參數指定分支:$DOMAIN" >&2; exit 1; }
# 預設分支:先問 origin/HEAD,再退回遠端實際存在的 master、main。
DEFAULT=$(git -C "$DOMAIN" symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null) || DEFAULT=''
DEFAULT=${DEFAULT#origin/}
if [ -z "$DEFAULT" ]; then
for cand in master main; do
if git -C "$DOMAIN" rev-parse --verify --quiet "refs/remotes/origin/$cand" >/dev/null 2>&1; then
DEFAULT=$cand
break
fi
done
fi
[ -n "$DEFAULT" ] || { echo "取不到預設分支(origin/HEAD、origin/master、origin/main 都沒有):$DOMAIN" >&2; exit 1; }
git -C "$DOMAIN" rev-parse --verify --quiet "refs/remotes/origin/$DEFAULT" >/dev/null 2>&1 || {
echo "遠端沒有 origin/$DEFAULT:$DOMAIN" >&2; exit 1; }
HEADSHA=$(git -C "$DOMAIN" rev-parse --verify --quiet "$BRANCH" 2>/dev/null) || HEADSHA=''
[ -n "$HEADSHA" ] || { echo "取不到分支的 commit:$BRANCH" >&2; exit 1; }
TAB=$(printf '\t')
if git -C "$DOMAIN" merge-base --is-ancestor "$HEADSHA" "origin/$DEFAULT" 2>/dev/null; then
ROUTE=deploy
else
ROUTE=worktree
fi
printf 'route%s%s\n' "$TAB" "$ROUTE"
printf 'default-branch%s%s\n' "$TAB" "$DEFAULT"
printf 'branch%s%s\n' "$TAB" "$BRANCH"
printf 'head%s%s\n' "$TAB" "$HEADSHA"
if [ "$ROUTE" = deploy ]; then
echo "改動已在 origin/$DEFAULT 上,走部署路線:$DOMAIN" >&2
exit 0
fi
printf 'pending%s%s\n' "$TAB" "$BRANCH"
echo "改動還沒併進 origin/$DEFAULT,走工作樹路線;回報時要點名待合的 PR:$DOMAIN" >&2
exit 3
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env sh
# lint-scripts.sh — 檢查一個 domain 存取庫的 shell 腳本:語法、可執行、結束碼有沒有記載。
#
# 用法: lint-scripts.sh <domain-path>
#
# 檢查三項(掃 {domain-path}/tools 與 {domain-path}/hooks 底下的 *.sh):
# 1. 語法 — sh -n 通過。
# 2. 可執行 — 檔案有執行權限。技能直接呼叫的腳本沒有 +x,會在部署後才炸。
# 3. 結束碼 — 檔頭前 60 行要有結束碼宣告列(「結束碼:」或「exit code」)。
# 沒有宣告的腳本,呼叫端沒辦法逐碼分流,只能猜;猜錯就把失敗當成功。
#
# 為什麼合成一支: skill-check 原本在 SKILL.md 裡寫 find ... -exec sh -n,另外兩項靠散文
# 要求人工比對。三項的輸入輸出都固定,散文版每個 domain 各做一次,還會跟腳本實況漂移。
#
# 例外: 只被 source 的共用函式庫(判準見 is_library)第 2、3 項都不查——它不是可執行入口,
# 沒有 +x 的必要,也沒有結束碼語意可宣告。第 1 項語法檢查照查,函式庫一樣會被 sh -n 讀。
# 豁免不是靜默的: 每豁免一支就在 stderr 記一行,摘要也帶函式庫支數,才看得出誰被跳過。
#
# 輸出: 一行一個不合格項目,格式 {檔案}:{檢查項}:{說明}(stdout);
# 函式庫豁免通知與統計摘要走 stderr。
# 結束碼: 0=掃到腳本且三項全過
# 1=有不合格項目(清單在 stdout)
# 2=用法錯誤(本腳本只吃一個參數)
# 3=domain 路徑不存在,或 tools/ 與 hooks/ 都沒有 *.sh——**什麼都沒掃**,
# 不等於通過。缺這兩個目錄本身不是失敗,呼叫端照實記「無腳本可掃」即可。
set -u
usage() {
echo 'usage: lint-scripts.sh <domain-path>' >&2
exit 2
}
[ "$#" -eq 1 ] || usage
DOMAIN=${1%/}
[ -n "$DOMAIN" ] || usage
[ -d "$DOMAIN" ] || { echo "找不到 domain 路徑:$DOMAIN" >&2; exit 3; }
SCAN=''
for d in "$DOMAIN/tools" "$DOMAIN/hooks"; do
[ -d "$d" ] && SCAN="$SCAN $d"
done
[ -n "$SCAN" ] || { echo "沒有 tools/ 也沒有 hooks/,無腳本可掃:$DOMAIN" >&2; exit 3; }
TMP=$(mktemp) || { echo "無法建立暫存檔" >&2; exit 3; }
trap 'rm -f "$TMP"' EXIT
# shellcheck disable=SC2086
find $SCAN -type f -name '*.sh' 2>/dev/null | sort > "$TMP"
[ -s "$TMP" ] || { echo "tools/ 與 hooks/ 底下沒有 *.sh,無腳本可掃:$DOMAIN" >&2; exit 3; }
has_exit_stmt() { # $1=檔案;去掉整行註解後還找得到 exit 敘述就回 0
grep -v '^[[:space:]]*#' "$1" 2>/dev/null \
| grep -qE '(^|[;&|(){}[:space:]])exit([[:space:]]|$)'
}
is_library() { # $1=檔案;只被 source 的共用函式庫,可執行與結束碼兩項都豁免
# 判準兩條,缺一不可:
# 1. 自我宣告是函式庫——檔名 lib.sh,或檔頭前 30 行寫了「以 source 載入」。
# 2. 程式碼裡一個 exit 敘述都沒有。沒有 exit 就沒有結束碼語意,也就沒有東西可宣告;
# 這一條同時是「不是可執行入口」的證據,兩項豁免共用同一個事實。
# 為什麼不能只看第 1 條: 那是字樣比對,會命中「提到這件事」的腳本。本腳本自己的
# 例外段就寫了「以 source 載入」,plugins-root.sh 的用法段也寫了,但兩支都是有
# 結束碼語意的可執行入口。只拿第 1 條去豁免結束碼,等於把工具自己漏掉。
case "${1##*/}" in
lib.sh) ;;
*) head -30 "$1" 2>/dev/null | grep -q '以 source 載入' || return 1 ;;
esac
! has_exit_stmt "$1"
}
hit=0
total=0
libs=0
while IFS= read -r f; do
[ -n "$f" ] || continue
total=$((total + 1))
err=$(sh -n "$f" 2>&1) || {
printf '%s:語法:%s\n' "$f" "$(printf '%s' "$err" | tr '\n' ' ')"
hit=1
}
if is_library "$f"; then
libs=$((libs + 1))
printf '%s:函式庫:判定為只被 source 的共用函式庫(無 exit 敘述),略過「可執行」與「結束碼」\n' "$f" >&2
continue
fi
if [ ! -x "$f" ]; then
printf '%s:可執行:缺執行權限,請 chmod +x\n' "$f"
hit=1
fi
if ! head -60 "$f" 2>/dev/null | grep -qE '結束碼|exit code'; then
printf '%s:結束碼:檔頭沒有結束碼宣告列,呼叫端無法逐碼分流\n' "$f"
hit=1
fi
done < "$TMP"
if [ "$hit" -eq 0 ]; then
echo "腳本檢查通過:$total 支(語法、可執行、結束碼宣告),其中 $libs 支判定為函式庫" >&2
else
echo "腳本檢查有不合格項目:共掃 $total 支(其中 $libs 支判定為函式庫),清單見 stdout" >&2
fi
exit $hit