#!/usr/bin/env sh # check-behaviors.sh — 檢查一個 domain 的技能行為清單 references/behaviors.md 有沒有跟 skills/ 對齊。 # # 用法: check-behaviors.sh # # 檢查七項(格式合約見 jsc-meta references/guidelines.md 的「技能行為清單」一節): # 1. 標題 — 第一行是「# jsc-{domain} 技能行為清單」,檔案不得有 UTF-8 BOM。 # 2. 節對技能 — 每支 skills/*/SKILL.md 一個「## {技能名}」節,名稱與目錄名逐字相同,不多不少。 # 3. 節順序 — 節的排列照技能目錄名的字典序(LC_ALL=C)。 # 4. 表格 — 每節恰好一張表,表頭是「| 項目 | 內容 |」。 # 5. 五個欄位 — 依序為 觸發時機、關鍵步驟、外部呼叫、完成條件、可驗證跡象,不多不少。 # 6. 內容 — 每一列的「內容」欄不得空白。 # 7. 收尾事件 — 「可驗證跡象」那一列要寫到 skill-end 或 events.jsonl。 # # 為什麼要這支: 技能改了行為、清單沒跟著改,兩邊就漂移。漂移靠眼睛比對,10 個 domain 每次稽核 # 都要重做一遍,還會漏。這七項的輸入輸出固定,交給程式判定才穩。 # # 為什麼加第 7 項: 技能的成敗只有技能自己寫得出來。hook 接在技能工具呼叫之後就觸發,那一刻 # 實際工作還在後面的模型輪次,看不到結果;有 start 沒有配對的 end 就是中止。收尾少寫這一筆, # 那支技能每一次都會被算成中止,而且不會有任何錯誤訊息。跡象欄是清單裡唯一寫得下「跑完在 # 環境裡留下什麼」的地方,所以判準放在這一列,而不是另開一項只檢查文字。 # 本腳本是所有 domain 共用的稽核入口,這一項加進來之後,還沒補收尾事件的 domain 會開始回 1。 # 那是預期的結果,不是誤報:要修的是那些 domain 的技能與清單,不是把斷言放寬。 # # 輸出: 一行一個不合格項目,格式 {behaviors.md 路徑}:{技能名或 -}:{說明}(stderr); # 通過時在 stderr 印一行摘要。stdout 不印東西。 # 結束碼: 0=行為清單與 skills/ 相符,五個欄位齊全、內容欄非空,且每節都寫了收尾事件 # 1=不符:缺節、多節、順序不對、表格不對、缺欄位、欄位空白,或跡象欄沒寫收尾事件, # 清單在 stderr # 2=用法錯誤(本腳本只吃一個參數) # 3=找不到 {domain-path}/references/behaviors.md,或找不到 {domain-path}/skills/, # 或 skills/ 底下一支 SKILL.md 都沒有——**什麼都沒查**,不等於通過 set -u usage() { echo 'usage: check-behaviors.sh ' >&2 exit 2 } [ "$#" -eq 1 ] || usage DOMAIN=${1%/} [ -n "$DOMAIN" ] || usage SKILLS="$DOMAIN/skills" DOC="$DOMAIN/references/behaviors.md" [ -d "$DOMAIN" ] || { echo "找不到 domain 路徑:$DOMAIN" >&2; exit 3; } [ -d "$SKILLS" ] || { echo "找不到 skills/:$SKILLS" >&2; exit 3; } [ -f "$DOC" ] || { echo "找不到行為清單:$DOC" >&2; exit 3; } TMPD=$(mktemp -d) || { echo "無法建立暫存目錄" >&2; exit 3; } trap 'rm -rf "$TMPD"' EXIT TAB=$(printf '\t') BOM=$(printf '\357\273\277') FIELDS='觸發時機 關鍵步驟 外部呼叫 完成條件 可驗證跡象' fail=0 report() { # $1=技能名或 -,$2=說明 printf '%s:%s:%s\n' "$DOC" "$1" "$2" >&2 fail=1 } # 技能清單: skills/ 底下帶 SKILL.md 的目錄名,字典序。 for d in "$SKILLS"/*/; do [ -f "${d}SKILL.md" ] || continue n=${d%/} echo "${n##*/}" done | LC_ALL=C sort > "$TMPD/skills.txt" [ -s "$TMPD/skills.txt" ] || { echo "skills/ 底下沒有任何 SKILL.md:$SKILLS" >&2; exit 3; } # 解析 behaviors.md,攤平成三種記錄: # SEC{節名} 一個「## 」標題 # HDR{節名} 一列表頭「| 項目 | 內容 |」 # ROW{節名}{項目}{內容} 一列資料(分隔列不算) awk ' function trim(s) { gsub(/^[ \t]+/, "", s); gsub(/[ \t]+$/, "", s); return s } BEGIN { FS = "|"; sec = "-" } { sub(/\r$/, "") } /^## / { sec = trim(substr($0, 4)) printf "SEC\t%s\n", sec next } /^[ \t]*\|/ { item = trim($2) body = "" for (i = 3; i < NF; i++) body = (body == "" ? $i : body "|" $i) body = trim(body) if (item ~ /^[-: ]+$/ && body ~ /^[-: ]*$/) next if (item == "項目" && body == "內容") { printf "HDR\t%s\n", sec; next } printf "ROW\t%s\t%s\t%s\n", sec, item, body } ' "$DOC" > "$TMPD/parsed.txt" # --- 1. 標題 --- first=$(head -1 "$DOC" | tr -d '\r') case $first in "$BOM"*) report - '檔頭帶 UTF-8 BOM,請改存無 BOM'; first=${first#"$BOM"} ;; esac # domain 名以 plugin.json 的 name 為準,checkout 目錄名只是退路。 # 目錄名是誰 clone 誰決定的,同一個 repo 換一台機器就可能叫別的名字, # 拿它當唯一來源會在別人的工作區誤報一次「標題不符」。 dom=$(sed -n 's/.*"name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$DOMAIN/plugin.json" 2>/dev/null | head -n1) if [ -z "$dom" ]; then dom=$(cd "$DOMAIN" 2>/dev/null && pwd) || dom=$DOMAIN dom=${dom##*/} fi dom=${dom#jsc-} want_title="# jsc-$dom 技能行為清單" [ "$first" = "$want_title" ] || report - "第一行要是「$want_title」,實際是「$first」" # --- 2. 節對技能 --- awk -F"$TAB" '$1 == "SEC" { print $2 }' "$TMPD/parsed.txt" > "$TMPD/sections.txt" LC_ALL=C sort "$TMPD/sections.txt" > "$TMPD/sections-sorted.txt" LC_ALL=C uniq -d "$TMPD/sections-sorted.txt" | while IFS= read -r s; do [ -n "$s" ] && printf '%s:%s:%s\n' "$DOC" "$s" '同一支技能出現多個節,只留一節' >&2 done if [ -n "$(LC_ALL=C uniq -d "$TMPD/sections-sorted.txt")" ]; then fail=1; fi LC_ALL=C uniq "$TMPD/sections-sorted.txt" > "$TMPD/sections-uniq.txt" LC_ALL=C comm -23 "$TMPD/skills.txt" "$TMPD/sections-uniq.txt" > "$TMPD/missing.txt" LC_ALL=C comm -13 "$TMPD/skills.txt" "$TMPD/sections-uniq.txt" > "$TMPD/extra.txt" while IFS= read -r s; do [ -n "$s" ] || continue report "$s" "skills/$s/SKILL.md 存在,行為清單缺這一節,請補「## $s」" done < "$TMPD/missing.txt" while IFS= read -r s; do [ -n "$s" ] || continue report "$s" "行為清單多這一節,skills/ 底下沒有這支技能,請移除或改名" done < "$TMPD/extra.txt" # --- 3. 節順序 --- if ! cmp -s "$TMPD/sections.txt" "$TMPD/sections-sorted.txt"; then report - '節的排列不是技能目錄名的字典序,請重排' fi # --- 4~6. 逐節查表格與五個欄位 --- while IFS= read -r name; do [ -n "$name" ] || continue grep -q "^$name\$" "$TMPD/missing.txt" && continue hdr=$(awk -F"$TAB" -v s="$name" '$1 == "HDR" && $2 == s' "$TMPD/parsed.txt" | wc -l) hdr=$((hdr + 0)) if [ "$hdr" -eq 0 ]; then report "$name" '這一節沒有表頭「| 項目 | 內容 |」,五個欄位無從判讀' continue fi [ "$hdr" -eq 1 ] || report "$name" "這一節有 $hdr 張表,合約規定恰好一張" awk -F"$TAB" -v s="$name" '$1 == "ROW" && $2 == s { print $3 "\t" $4 }' \ "$TMPD/parsed.txt" > "$TMPD/rows.txt" rows=$(wc -l < "$TMPD/rows.txt") rows=$((rows + 0)) [ "$rows" -eq 5 ] || report "$name" "表格有 $rows 列,合約規定 5 列:$FIELDS" i=0 while IFS="$TAB" read -r item body; do i=$((i + 1)) [ "$i" -le 5 ] || { report "$name" "第 $i 列「$item」是多的,合約只收 5 列"; continue; } want=$(echo "$FIELDS" | cut -d' ' -f"$i") [ "$item" = "$want" ] || report "$name" "第 $i 列的項目要是「$want」,實際是「$item」" [ -n "$body" ] || report "$name" "「$item」的內容欄空白,請補實際行為" # 第 7 項只在跡象欄成立。認項目名不認列號: 列號錯位時上面那一行已經報過, # 這裡再報一次只是同一個缺陷印兩遍,反而蓋掉真正沒寫收尾事件的那幾節。 if [ "$item" = '可驗證跡象' ]; then case $body in *skill-end*|*events.jsonl*) ;; *) report "$name" '「可驗證跡象」沒寫到收尾的 skill-end 事件,請補上這一筆(見準則「執行狀態回報」)' ;; esac fi done < "$TMPD/rows.txt" done < "$TMPD/skills.txt" # --- 節裡以外的表格 --- if awk -F"$TAB" '$2 == "-" { found = 1 } END { exit found ? 0 : 1 }' "$TMPD/parsed.txt"; then report - '有表格落在任何「## 」節之外,請搬進所屬技能的節裡' fi if [ "$fail" -eq 0 ]; then echo "行為清單檢查通過:$DOC 對上 $(wc -l < "$TMPD/skills.txt" | tr -d ' ') 支技能,五個欄位齊全,跡象欄都寫了收尾事件" >&2 else echo "行為清單檢查不符:$DOC 與 $SKILLS 對不起來,逐項見上方" >&2 fi exit $fail