feat(delegate): 技能委派判定的判準文件、清單與檢核腳本

助理要知道每一支技能能不能交給它代跑,但那份盤點目前只存在於一份待辦文件的表格裡。技能組每天在動,手工盤點只會過期,而且過期得無聲無息——今天就有三件事栽在這上面:照舊版資訊列的待辦白列了、一份文件寫著「8 碼」而工具早就改用 40 碼、一份技能文件描述的路徑家族已經不存在。

判準寫成 references 一份文件,四支技能異動技能共用。內容是判定決策樹的五題與四種結論各自的必填欄位。

判定結果寫成 tools 底下一份 TSV,一支技能一列,十一個欄位。作法沿用設定規格表那一套:註解檔頭說明每一欄的意思與可填值、TAB 分隔、空值寫短橫。技能名分成 domain 與 name 兩欄,形狀跟技能清單工具的輸出一致,比對時直接取前兩欄就好。

初始 35 列依既有盤點種入,來源欄標 seed,代表還沒正式走過決策樹、待複核。技能有異動時就地走一次決策樹再轉成 judged。

檢核腳本比對清單與實際技能:缺列或多列算缺失,必填欄位空著也算缺失——切片交與條件式交沒填「留在人手上的那一半」,助理下一輪會把整支技能當成可交的一路跑完。版本號落後與來源是 seed 都只印提示、不影響結束碼,理由寫進腳本註解:版本號是 domain 層級,同 domain 改一支技能其餘技能也會被標到,當成缺失的話每次發版整個 domain 都亮紅,提示很快就會被當成雜訊忽略。

種入時有一支改判。體檢那一支在既有盤點裡是無條件的切片交,實測確認它呼叫自家腳本走的是帶版本號的路徑,那種路徑進不了允許清單,無人值守的每一輪都會無聲卡在第一支腳本,所以改判條件式交,把條件與條件不成立時的行為都寫進去。留成切片交的話,下一個實作定期健檢的人會看到一列寫著可以交,然後重演一次。

這一輪只建地基。四支技能異動技能還沒引用這份判準與這支腳本,例行稽核也還沒把它排進檢查群組,接線另外一輪做。
This commit is contained in:
2026-09-03 14:00:36 +08:00
parent 06979128d9
commit 8bb1effb93
3 changed files with 407 additions and 0 deletions
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env sh
# check-delegate.sh — 檢查委派判定清單 tools/delegate-spec.tsv 有沒有跟本機實際技能對齊。
#
# 用法: check-delegate.sh [<plugins-root>]
# 不給參數就交給 tools/plugins-root.sh 推導根目錄,判準與 list-skills.sh 完全相同。
#
# 檢查四項:
# 1. 一一對應 — 清單一支技能一列,跟 list-skills.sh 的實際技能不多不少,也不重複。
# 2. 欄位齊全 — 11 欄,每一欄非空;四種結論各自的必填欄位都填了,不該填的填 -。
# 3. 欄位可填值 — verdict、way、trigger、recur、origin 只認固定詞彙;next 要指到
# 一支真的存在的技能。指到不存在的技能,助理會一路重試一支叫不出來的東西。
# 4. 版本號與來源 — 只印提示,不影響結束碼,理由見下。
#
# 為什麼要這支: 技能組每天在動,手工盤點只會過期。清單缺一列,助理就永遠不知道那支技能
# 存在;清單多一列,助理會去觸發一支不存在的技能,而且失敗不會自動暫停。這兩件事靠眼睛
# 比對每輪都會漏,交給程式判定才穩。
#
# 為什麼版本號落後只算提示: 版本號是 domain 層級的,同一個 domain 改一支技能,其餘技能的
# 版本號也會一起落後、也會被標成要複判。當成稽核缺失的話,每次發版整個 domain 都亮紅,
# 提示很快就會被當成雜訊忽略,真正要複判的那一支反而看不見。
#
# 為什麼 origin=seed 只算提示: 種入的那一批是依既有盤點填的,還沒正式走過決策樹,本來就
# 知道它待複核。它是「已經有結論、但要再確認一次」,不是「漏填」。列成缺失會讓清單從
# 第一天就是紅的,紅到全部複核完為止,這段期間所有真的缺失都會被蓋掉。
#
# 輸出: 缺失一行一個,格式 {清單路徑}:{domain}/{name}:{說明}(stderr);
# 提示同格式(stdout),讓呼叫端分得開缺失與提示;統計摘要走 stderr。
# 結束碼: 0=清單與實際技能一一對應、必填欄位齊全(可能帶提示,提示不影響這一碼)
# 1=缺列、多列、重複列、欄位空著、欄位值不合法,或 next 指到不存在的技能,
# 清單在 stderr
# 2=用法錯誤(本腳本最多吃一個參數)
# 3=找不到 tools/delegate-spec.tsv、推導不出根目錄,或列不出任何技能——
# **什麼都沒查**,不等於通過
set -u
usage() {
echo 'usage: check-delegate.sh [<plugins-root>]' >&2
exit 2
}
[ "$#" -le 1 ] || usage
if [ "$#" -eq 1 ]; then
[ -n "$1" ] || usage
JSC_PLUGINS_ROOT=${1%/}
export JSC_PLUGINS_ROOT
fi
HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
SPEC="$HERE/delegate-spec.tsv"
[ -f "$SPEC" ] || { echo "找不到委派判定清單:$SPEC" >&2; exit 3; }
. "$HERE/plugins-root.sh"
ROOT=$(jsc_plugins_root) || { echo "推導不出技能組工作目錄根位置" >&2; exit 3; }
TMPD=$(mktemp -d) || { echo "無法建立暫存目錄" >&2; exit 3; }
trap 'rm -rf "$TMPD"' EXIT
TAB=$(printf '\t')
# 實際技能清單。真實來源只有 list-skills.sh 一處,本腳本不自己掃 SKILL.md:
# 兩套掃法會各自漂移,屆時分不出是清單錯還是掃法錯。
"$HERE/list-skills.sh" > "$TMPD/raw.txt" 2>/dev/null \
|| { echo "list-skills.sh 列不出技能,先跑 sync-domains.sh:$ROOT" >&2; exit 3; }
cut -f1,2 "$TMPD/raw.txt" | LC_ALL=C sort > "$TMPD/skills.txt"
[ -s "$TMPD/skills.txt" ] || { echo "掃不到任何技能:$ROOT" >&2; exit 3; }
# 各 domain 的現行版本號。domain 名以 plugin.json 的 name 為準,checkout 目錄名只是退路:
# 目錄名是誰 clone 誰決定的,換一台機器就可能叫別的名字。
: > "$TMPD/versions.txt"
for p in "$ROOT"/*/plugin.json; do
[ -f "$p" ] || continue
n=$(sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$p" | head -n1)
v=$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$p" | head -n1)
case $n in
jsc-?*) ;;
*) continue ;;
esac
[ -n "$v" ] || continue
printf '%s\t%s\n' "${n#jsc-}" "$v" >> "$TMPD/versions.txt"
done
# 清單資料列:# 開頭的註解與空行都不算。
grep -v '^[[:space:]]*#' "$SPEC" | grep -v '^[[:space:]]*$' > "$TMPD/rows.txt" || true
[ -s "$TMPD/rows.txt" ] || { echo "清單裡一列資料都沒有:$SPEC" >&2; exit 3; }
: > "$TMPD/fail-set.txt"
: > "$TMPD/fail-row.txt"
: > "$TMPD/hint.txt"
report() { # $1=domain/name 或 -,$2=說明
printf '%s:%s:%s\n' "$SPEC" "$1" "$2" >> "$TMPD/fail-set.txt"
}
# --- 1. 一一對應 ---
cut -f1,2 "$TMPD/rows.txt" | LC_ALL=C sort > "$TMPD/keys.txt"
LC_ALL=C uniq -d "$TMPD/keys.txt" > "$TMPD/dup.txt"
LC_ALL=C uniq "$TMPD/keys.txt" > "$TMPD/keys-uniq.txt"
LC_ALL=C comm -23 "$TMPD/skills.txt" "$TMPD/keys-uniq.txt" > "$TMPD/missing.txt"
LC_ALL=C comm -13 "$TMPD/skills.txt" "$TMPD/keys-uniq.txt" > "$TMPD/extra.txt"
while IFS="$TAB" read -r d n; do
[ -n "${d:-}" ] || continue
report "$d/$n" '清單有兩列以上寫同一支技能,只留一列'
done < "$TMPD/dup.txt"
while IFS="$TAB" read -r d n; do
[ -n "${d:-}" ] || continue
report "$d/$n" '這支技能存在,清單缺這一列。沒有判定結果,助理永遠不知道它存在'
done < "$TMPD/missing.txt"
while IFS="$TAB" read -r d n; do
[ -n "${d:-}" ] || continue
report "$d/$n" '清單多這一列,本機沒有這支技能。請刪掉,否則助理會去觸發叫不出來的技能'
done < "$TMPD/extra.txt"
# --- 2~4. 逐列查欄位。多出來的列上面已經報過,這裡跳過,同一個缺陷不印兩遍。 ---
awk -F"$TAB" \
-v SPEC="$SPEC" -v SKILLS="$TMPD/skills.txt" -v VERS="$TMPD/versions.txt" \
-v FAILS="$TMPD/fail-row.txt" -v HINTS="$TMPD/hint.txt" '
function bad(k, m) { printf "%s:%s:%s\n", SPEC, k, m > FAILS }
function hint(k, m) { printf "%s:%s:%s\n", SPEC, k, m > HINTS }
function vlt(a, b, x, y, na, nb, n, i, ai, bi) {
na = split(a, x, "."); nb = split(b, y, ".")
n = (na > nb) ? na : nb
for (i = 1; i <= n; i++) {
ai = (i <= na) ? x[i] + 0 : 0
bi = (i <= nb) ? y[i] + 0 : 0
if (ai < bi) return 1
if (ai > bi) return 0
}
return 0
}
function way_ok(w, t, m, i) {
m = split(w, t, ",")
for (i = 1; i <= m; i++) if (!(t[i] in WAY)) return 0
return (m > 0)
}
function trig_ok(t, e) {
if (t ~ /^at:..*$/) return 1
if (t ~ /^after:..*$/) { e = substr(t, 7); sub(/:.*$/, "", e); return (e in EV) }
return 0
}
BEGIN {
split("full slice cond none", a, " "); for (i in a) VERDICT[a[i]] = 1
split("invoke patrol remind", b, " "); for (i in b) WAY[b[i]] = 1
split("seed judged", c, " "); for (i in c) ORIGIN[c[i]] = 1
split("worklog-written wp-merged stage-entered analyze-completed hook-error session-start session-end", \
d, " "); for (i in d) EV[d[i]] = 1
}
FILENAME == SKILLS { sk[$1 "\t" $2] = 1; next }
FILENAME == VERS { cur[$1] = $2; next }
{
key = $1 "/" $2
if (NF != 11) { bad(key, "這一列有 " NF " 欄,合約規定 11 欄"); next }
if (!(($1 "\t" $2) in sk)) next
verdict = $3; way = $4; slice = $5; human = $6
trig = $7; rec = $8; nxt = $9; ver = $10; org = $11
for (i = 1; i <= 11; i++)
if ($i == "") bad(key, "第 " i " 欄是空的。TSV 沒有空格這種值,不填就寫一個減號")
if (!(verdict in VERDICT)) {
bad(key, "verdict 只認 full、slice、cond、none,實際是「" verdict "」")
next
}
# 不交的那一列只留理由:交出方式、切片、時間點、週期全部要空成減號。
# 留著值等於清單自己在說「不交但這樣交」,下一輪讀的人不知道該信哪一邊。
if (verdict == "none") {
if (way != "-") bad(key, "不交的列,way 要寫 -,實際是「" way "」")
if (slice != "-") bad(key, "不交的列,slice 要寫 -,實際是「" slice "」")
if (trig != "-") bad(key, "不交的列,trigger 要寫 -,實際是「" trig "」")
if (rec != "-") bad(key, "不交的列,recur 要寫 -,實際是「" rec "」")
if (human == "-") bad(key, "不交的列要寫不交的理由,否則下次分不出「判過決定不交」與「還沒判」")
} else {
if (way == "-") bad(key, "交出去的列一定要有交出方式,way 不可以是 -")
else if (!way_ok(way)) bad(key, "way 只認 invoke、patrol、remind,多個用半形逗號隔開,實際是「" way "」")
if (!trig_ok(trig)) bad(key, "trigger 要寫 at:{時間} 或 after:{固定事件名},實際是「" trig "」")
if (rec != "once" && rec !~ /^every:..*$/ && rec !~ /^cron:..*$/)
bad(key, "recur 只認 once、every:{間隔}、cron:{式子},實際是「" rec "」")
}
if (verdict == "full") {
if (slice != "-") bad(key, "全交是整支都交,slice 要寫 -,實際是「" slice "」")
if (human != "-") bad(key, "全交沒有留在人手上的那一半,human 要寫 -,實際是「" human "」")
}
# 切片交與條件式交少了「留在人手上的那一半」,助理下一輪會把整支技能當成可交的一路跑完。
if (verdict == "slice" || verdict == "cond") {
if (slice == "-") bad(key, "交出的那一段沒寫,slice 不可以是 -")
if (human == "-") bad(key, "留在人手上的那一半沒寫,助理下一輪會把整支技能當成可交的一路跑完")
}
if (nxt !~ /^jsc-[a-z0-9-]+:[a-z0-9-]+$/)
bad(key, "next 要寫成 jsc-{domain}:{name},實際是「" nxt "」")
else {
n2 = substr(nxt, 5); sub(/:/, "\t", n2)
if (!(n2 in sk)) bad(key, "next 指到不存在的技能「" nxt "」,助理會一路重試叫不出來的東西")
}
if (!(org in ORIGIN)) bad(key, "origin 只認 seed 與 judged,實際是「" org "」")
else if (org == "seed") hint(key, "origin=seed:依既有盤點種入,還沒走過決策樹,待複核")
if (ver !~ /^[0-9]+(\.[0-9]+)*$/) bad(key, "version 要是 plugin 版本號,實際是「" ver "」")
else if (($1 in cur) && vlt(ver, cur[$1]))
hint(key, "判定時記的版本號 " ver " 落後 jsc-" $1 " 現行的 " cur[$1] ",要複判")
}
' "$TMPD/skills.txt" "$TMPD/versions.txt" "$TMPD/rows.txt"
cat "$TMPD/fail-set.txt" "$TMPD/fail-row.txt" > "$TMPD/fails.txt"
if [ -s "$TMPD/hint.txt" ]; then
LC_ALL=C sort "$TMPD/hint.txt"
fi
rows=$(wc -l < "$TMPD/rows.txt" | tr -d ' ')
skills=$(wc -l < "$TMPD/skills.txt" | tr -d ' ')
hints=$(wc -l < "$TMPD/hint.txt" | tr -d ' ')
if [ -s "$TMPD/fails.txt" ]; then
LC_ALL=C sort "$TMPD/fails.txt" >&2
echo "委派判定清單不符:$SPEC 有 $rows 列,本機有 $skills 支技能,逐項見上方(另有 $hints 則提示,見 stdout)" >&2
exit 1
fi
echo "委派判定清單檢查通過:$SPEC 的 $rows 列對上 $skills 支技能,必填欄位齊全(另有 $hints 則提示,見 stdout)" >&2
exit 0