Files
meta/tools/check-delegate.sh
jiantw83 f2332f965c feat(delegate): 清單加一欄記唯讀盤點的實際指令
切片交的那幾支,助理現在只會提醒不會動手,因為沒有東西記得下「那一段唯讀盤點到底要跑什麼」。清單加第十二欄記具體指令,種入那一支有值就拿它當動作、沒值才退回只提醒。

只認三種寫法。一行指令,路徑一律寫成代入點開頭,由種入那一支代進字面絕對根目錄;指令裡不可以出現金錢符號或波浪號,那兩種在無人值守那一輪解不出來也進不了允許清單,會被靜靜擋掉。要標未接線就寫理由,留白的話下一輪分不出是刻意還是漏填。沒有唯讀入口的寫減號。

觸發型與不交的列一律填減號。觸發的意思是呼叫整支技能,這一欄填了指令會讓種入那一支改拿指令當動作,於是整支交出降級成只跑一支腳本,該寫的頁一頁都不會寫,而且看起來完全正常。這條由檢核腳本擋。

逐項回各存放庫核對,不照抄既有盤點的措辭,因而抓到五處對不上實際腳本的地方。

最要緊的一處:既有盤點點名的那支工具腳本要三個參數,其中兩個無人值守那一輪根本拿不到,而且它是唯一會去問遠端的一方。改用同一件事的本機那一半,而那個子命令剛好在閘門腳本「免讀標準輸入」的清單裡。這一點非確認不可:同一支腳本的另一個子命令不在那份清單上,標準輸入是管線又沒人關閉時會一直等,實測會無限卡住。腳本自己的註解就記著這個坑,說工具腳本轉呼叫時曾經整支卡死——那正是先前心跳斷掉的同一種死法。

另一處:既有盤點點名的同步子命令會寫檔,不是唯讀。那一列剛好是觸發型所以填減號、衝突沒落地,但措辭本身是錯的。

七項標成未接線,理由都是要連網要金鑰。連網那一種一過期就讓那一項每輪失敗,或每輪靜靜回報沒事——後者更難查;而純本機讀取本來一輪都不會失敗。先填會連網的,等於用一批每輪報錯的項目把真的發現蓋掉。

還記下一件接線那天要注意的事:查遠端版本那一支永遠回成功,查不到就安靜放行,所以金鑰失效時它會靜靜回報沒有新版。接線要用另一個子命令,那個有「查不出來」這第三種結論。

檢核腳本從四項檢查改成五項。填錯欄位、指到不存在的腳本、用了認不得的代入點都算缺失;標未接線只印提示,那是判過知道還沒接,不是漏填;指到的 domain 本機沒裝也只提示,那是機器少裝一套不是清單填錯,算缺失會讓半套機器每輪亮紅。

五支技能異動流程一併改:寫清單時要問到並寫下這一欄,重判時要一併重問,刪除時要檢查別列有沒有指到一起刪掉的腳本,一次改多支時要注意跨存放庫搬腳本是這一欄最容易過期的地方。
2026-09-04 09:56:55 +08:00

293 lines
15 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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. 欄位齊全 — 12 欄,每一欄非空;四種結論各自的必填欄位都填了,不該填的填 -。
# 3. 欄位可填值 — verdict、way、trigger、recur、origin 只認固定詞彙;next 要指到
# 一支真的存在的技能。指到不存在的技能,助理會一路重試一支叫不出來的東西。
# 4. 唯讀指令 — probe 的填法與那一支腳本在不在,判準見下。
# 5. 版本號與來源 — 只印提示,不影響結束碼,理由見下。
#
# 為什麼要這支: 技能組每天在動,手工盤點只會過期。清單缺一列,助理就永遠不知道那支技能
# 存在;清單多一列,助理會去觸發一支不存在的技能,而且失敗不會自動暫停。這兩件事靠眼睛
# 比對每輪都會漏,交給程式判定才穩。
#
# 為什麼版本號落後只算提示: 版本號是 domain 層級的,同一個 domain 改一支技能,其餘技能的
# 版本號也會一起落後、也會被標成要複判。當成稽核缺失的話,每次發版整個 domain 都亮紅,
# 提示很快就會被當成雜訊忽略,真正要複判的那一支反而看不見。
#
# 為什麼 origin=seed 只算提示: 種入的那一批是依既有盤點填的,還沒正式走過決策樹,本來就
# 知道它待複核。它是「已經有結論、但要再確認一次」,不是「漏填」。列成缺失會讓清單從
# 第一天就是紅的,紅到全部複核完為止,這段期間所有真的缺失都會被蓋掉。
#
# probe 什麼算缺失、什麼只算提示: 缺失的判準只有一條——這一列照著跑,無人值守那一輪會出事。
# 四種算缺失:
# 一、way 含 invoke 或這一列不交,probe 卻填了指令。種入那一支會改拿指令當待辦簿的動作,
# 整支交出於是變成只跑一支腳本,那支技能該寫的頁一頁都不會寫,而且看起來完全正常。
# 二、way 只有 patrol 或 remind,probe 卻是減號。那幾列的動作原本一律退回只提醒,
# 等於判過要交、實際什麼都沒交出去。接不上就寫 pending 並講明理由,別留減號。
# 三、指令裡有金錢符號或波浪號,或用了 root、cli、repo 以外的代入點。前者在無人值守
# 那一輪解不出來也進不了允許清單,後者代不進去會原樣送進指令,兩種都是靜靜失敗。
# 四、指令指到的腳本在本機不存在。助理每一輪都會叫不到它,而失敗不會自動暫停。
# 兩種只算提示:
# 一、pending:{理由}。入口還沒接上是判過的結論,跟 origin=seed 同一類,不是漏填。
# 二、指令指到的 domain 本機沒有裝。那是這台機器少裝一個 domain,不是清單填錯——
# 算成缺失的話,只裝半套的機器每一輪都亮紅,真的填錯反而看不見。
#
# 輸出: 缺失一行一個,格式 {清單路徑}:{domain}/{name}:{說明}(stderr);
# 提示同格式(stdout),讓呼叫端分得開缺失與提示;統計摘要走 stderr。
# 結束碼: 0=清單與實際技能一一對應、必填欄位齊全(可能帶提示,提示不影響這一碼)
# 1=缺列、多列、重複列、欄位空著、欄位值不合法、next 指到不存在的技能,
# 或 probe 填錯欄位、指到不存在的腳本、用了認不得的代入點,清單在 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 誰決定的,換一台機器就可能叫別的名字。第三欄記那個目錄,
# probe 要拿它把 {root}/jsc-{domain} 換成本機的實際位置,才驗得到腳本在不在。
: > "$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\t%s\n' "${n#jsc-}" "$v" "${p%/plugin.json}" >> "$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~5. 逐列查欄位。多出來的列上面已經報過,這裡跳過,同一個缺陷不印兩遍。 ---
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
}
# probe 裡認不得的代入點,回傳第一個;全部認得回傳空字串。認不得的代入點代不進去,
# 會原樣送進指令,於是那一輪安安靜靜跑錯東西。
function ph_bad(s, t, p) {
t = s
while (match(t, /\{[^}]*\}/)) {
p = substr(t, RSTART + 1, RLENGTH - 2)
if (!(p in PH)) return p
t = substr(t, RSTART + RLENGTH)
}
return ""
}
# probe 的第一個非環境變數指派詞,也就是腳本路徑本身。
function script_of(s, n, a, i) {
n = split(s, a, " ")
for (i = 1; i <= n; i++) {
if (a[i] ~ /^[A-Za-z_][A-Za-z0-9_]*=/) continue
return a[i]
}
return ""
}
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
split("root cli repo", e, " "); for (i in e) PH[e[i]] = 1
}
FILENAME == SKILLS { sk[$1 "\t" $2] = 1; next }
FILENAME == VERS { cur[$1] = $2; ddir[$1] = $3; next }
{
key = $1 "/" $2
if (NF != 12) { bad(key, "這一列有 " NF " 欄,合約規定 12 欄"); 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; probe = $12
for (i = 1; i <= 12; 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] ",要複判")
# --- probe:那一段唯讀盤點實際要跑的指令。判準見檔頭「什麼算缺失、什麼只算提示」。 ---
if (verdict == "none" || way ~ /(^|,)invoke(,|$)/) {
if (probe != "-")
bad(key, "way 含 invoke 或這一列不交,probe 要寫 -,實際是「" probe "」。填了指令,種入那一支會改拿指令當動作,整支交出就變成只跑一支腳本")
} else if (probe == "-") {
bad(key, "way 只有 patrol 或 remind,probe 不可以是 -:沒有指令的那幾筆一律退回只提醒,等於判過要交卻什麼都沒交出去。接不上就寫 pending:{理由}")
}
if (probe ~ /^pending:/) {
if (probe == "pending:") bad(key, "pending 後面要寫不接的理由,留白的話下一輪分不出是刻意還是漏填")
else hint(key, "probe=" probe ":唯讀盤點的入口還沒接上,待接線")
} else if (probe != "-") {
if (probe ~ /[$~]/)
bad(key, "probe 裡有金錢符號或波浪號,實際是「" probe "」。那兩種寫法在無人值守那一輪解不出來,也進不了允許清單,會被靜靜擋掉")
p2 = ph_bad(probe)
if (p2 != "")
bad(key, "probe 用了認不得的代入點「{" p2 "}」,只認 {root}、{cli}、{repo}。代不進去的字面值會原樣送進指令")
sp = script_of(probe)
if (sp !~ /^\{root\}\/jsc-[a-z0-9-]+\/..*$/)
bad(key, "probe 的腳本路徑要寫成 {root}/jsc-{domain}/… ,實際是「" sp "」")
else {
pd = sp; sub(/^\{root\}\/jsc-/, "", pd); sub(/\/.*$/, "", pd)
rest = sp; sub(/^\{root\}\/jsc-[a-z0-9-]+\//, "", rest)
if (!(pd in ddir))
hint(key, "probe 指到的 jsc-" pd " 本機沒有裝,這一列的腳本這一次驗不到")
else if (system("test -f '\''" ddir[pd] "/" rest "'\''") != 0)
bad(key, "probe 指到的腳本不存在:" ddir[pd] "/" rest "。助理每一輪都會叫不到它,而失敗不會自動暫停")
}
}
}
' "$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