現行紀錄只記「被叫用」,欄位是 ts、cli、session、skill,沒有成敗也沒有 結束碼。跑完整輪的技能與開場就中止的技能,在紀錄裡長得一模一樣。hook 成功時更是完全不留紀錄,只有錯誤路徑會寫 wiki,而那條路徑刻意不自動觸發。 事件流走本機檔案,不直接寫 wiki。hook 每次提示都跑,網路寫入會拖垮宿主 CLI;失敗的 hook 自我回報還會疊出迴圈,既有的錯誤回報因此不接在失敗的 hook 上,這裡沿用同一條線。助理巡檢時排空、彙整、寫頁。 hook 端用 EXIT trap 接,一支只加一行。這幾支的 exit 點很多,階段閘門一支 就有五十幾個;逐點改要動到每一條判定路徑,而那些路徑正是閘門的判準,為了 加一行紀錄去動閘門,風險遠大於收益。trap 涵蓋每一條離開路徑,含中途失敗。 狀態預設由結束碼推,推不出來的由 hook 自己覆寫。相依版本檢查與兩道閘門有 這種情形:antigravity 走 deny JSON、kiro 只印警告,兩者擋下時結束碼都是 0, 單看結束碼會把擋下記成放行。 技能的 start 由既有的技能用量 hook 順手發,不必改任何技能文件。end 只能由 技能自己在收尾步驟寫——hook 觸發時技能的實際工作還在後面的模型輪次,看不到 成敗。有 start 沒有配對的 end,就是那一輪中止了。
180 lines
8.8 KiB
Bash
Executable File
180 lines
8.8 KiB
Bash
Executable File
#!/usr/bin/env sh
|
||
# lang-guard.sh — 非程式碼輸出一律繁體中文、UTF-8、無亂碼、無簡體字(hook > prompt 的強制層)。
|
||
# 規則正文的唯一來源:jsc-meta 的 references/ste100.md。本腳本只實作可用樣式判定的三項:
|
||
# 簡體字、亂碼、非 UTF-8 編碼;用詞、標點、語氣那些判不出來的交給 ste100-guard.sh 的提示層。
|
||
#
|
||
# 用法:
|
||
# lang-guard.sh prompt 注入規則摘要(UserPromptSubmit 或規則檔取文字用)
|
||
# lang-guard.sh 掃描剛寫入的單一檔案(PostToolUse)
|
||
# lang-guard.sh sweep [dir] 掃描整個工作區這次改過的所有檔案(沒有 post-tool hook 的 CLI 用)
|
||
#
|
||
# 為什麼要有 sweep:只有 claude 接得到 PostToolUse,逐檔精準掃得到。codex 只有每輪結束的
|
||
# notify、kiro 只有 userPromptSubmit、copilot 與 antigravity 只有包裝別名,這四個都拿不到
|
||
# 「剛剛寫了哪個檔」,只能改成掃整個工作區的 git diff。時機晚一點,涵蓋範圍一樣。
|
||
#
|
||
# 輸入相容:
|
||
# Claude: PostToolUse 的 stdin JSON,取 tool_input.file_path。
|
||
# 其他 CLI: 環境變數 JSC_CHANGED_FILE。
|
||
# 兩者都取不到就安靜降級(exit 0)。
|
||
#
|
||
# 掃描範圍跟 comment-scope.sh 有兩點刻意不同,不要照抄那支的判斷:
|
||
# 1. 三項檢查都掃整個檔案,不是只掃註解行。程式碼的任何位置都不該出現簡體字或亂碼——
|
||
# 字串常值、識別字、資料內容一樣算輸出。comment-scope.sh 只掃註解,是因為它抓的是
|
||
# 註解夾帶文件編號,那件事只發生在註解裡。
|
||
# 2. markdown 與純文字檔要掃。它們正是「非程式碼輸出」的主場:README、wiki 頁、PR 描述、
|
||
# commit 訊息都是這類檔案。comment-scope.sh 刻意跳過 .md,因為那裡沒有程式碼註解。
|
||
#
|
||
# 掃描深度:檔案在 git 工作區內且已追蹤,就只掃 `git diff HEAD` 的新增行,不翻舊帳;
|
||
# 不在 git 內或檔案尚未追蹤才整檔掃描。sweep 一律只看 git diff。
|
||
#
|
||
# 結束碼:0=沒命中或資料不足;2=命中,訊息走 stderr 交回模型自行修正(不擋寫入,檔案已經寫好了)。
|
||
# 逃生門:JSC_LANG_GUARD=off。
|
||
set -u
|
||
|
||
. "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/lib.sh" 2>/dev/null || true
|
||
command -v hook_trace >/dev/null 2>&1 && hook_trace "lang-guard ${1:-}"
|
||
|
||
[ "${JSC_LANG_GUARD:-on}" = "off" ] && exit 0
|
||
|
||
HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||
WORDLIST="$HERE/simplified.txt"
|
||
|
||
if [ "${1:-}" = "prompt" ]; then
|
||
echo "[jsc] 所有非程式碼輸出一律繁體中文、UTF-8、無亂碼、無簡體字。適用範圍:程式碼註解、commit 訊息、PR 描述與標題、wiki 頁、對使用者的回報、README 與各種文件、錯誤訊息與日誌文字。程式碼本身的關鍵字、識別字、API 欄位名維持原樣,但其中的中文一樣要是繁體。"
|
||
echo "[jsc] 送出前自我檢查:1)簡體字(例:应、为、这、说、发、国)一律換成繁體;2)替代字元「U+FFFD」與雙重編碼亂碼(Ã、â 開頭的怪序列)代表編碼壞掉,重寫那段而不是保留;3)檔案一律存成 UTF-8,不加 BOM。規則正文見 jsc-meta 的 references/ste100.md。"
|
||
exit 0
|
||
fi
|
||
|
||
# 簡體字樣式:由 hooks/simplified.txt 組出,字表是單一真實來源,腳本裡不留第二份。
|
||
# 讀不到字表就回傳 1,呼叫端安靜跳過這一項,不中斷整支腳本——少抓一項,好過整支 hook 死掉。
|
||
simplified_pattern() {
|
||
[ -f "$WORDLIST" ] || return 1
|
||
_p=$(sed -e 's/#.*//' -e 's/[[:space:]]//g' "$WORDLIST" 2>/dev/null \
|
||
| grep -v '^$' | tr '\n' '|' | sed 's/|$//')
|
||
[ -n "$_p" ] || return 1
|
||
printf '%s' "$_p"
|
||
}
|
||
|
||
# 亂碼樣式一律用位元組比對(LC_ALL=C),不靠語系的字元範圍:
|
||
# 替代字元 U+FFFD(EF BF BD)本身;
|
||
# � ——「U+FFFD 的 UTF-8 位元組再被當成 Latin-1 讀一次」的雙重編碼殘骸;
|
||
# U+00C0–U+00FF 的字後面緊接 U+0080–U+00BF 的字(ä、æ¸、è©、ç”),這是 UTF-8 被當成
|
||
# Latin-1 讀一次再存回 UTF-8 的固定長相。中文的 UTF-8 前導位元組落在 E4–E9,被誤讀後
|
||
# 就變成 ä–é 開頭、後面接 U+0080–U+00BF 的兩三個字,所以前導字要收整個 Latin-1 字母段,
|
||
# 只收「Ã」會漏掉最常見的中文亂碼;
|
||
# â 後面接任何非 ASCII 字(’、“),引號與破折號被雙重編碼時的典型長相。
|
||
# 用位元組比對是因為 grep -E 的字元範圍在不同語系下行為不一致,位元組範圍到哪都一樣。
|
||
# 誤報防線:正常的西歐文字(câmara、crème、naïve)重音字後面接的是 ASCII 字母,不會命中。
|
||
mojibake_pattern() {
|
||
_fffd=$(printf '\357\277\275')
|
||
_lo=$(printf '\200'); _hi=$(printf '\277')
|
||
_c3=$(printf '\303'); _c2=$(printf '\302') # U+00C0–U+00FF 與 U+0080–U+00BF 的前導位元組
|
||
_bx=$(printf '\303\242') # 「â」
|
||
_l2=$(printf '\302'); _h2=$(printf '\364')
|
||
printf '%s|%s|%s[%s-%s]%s[%s-%s]|%s[%s-%s]' \
|
||
"$_fffd" '�' \
|
||
"$_c3" "$_lo" "$_hi" "$_c2" "$_lo" "$_hi" \
|
||
"$_bx" "$_l2" "$_h2"
|
||
}
|
||
|
||
hit() { # $1=樣式 $2=說明;命中就把說明與最多三行證據印到 stdout
|
||
m=$(printf '%s\n' "$lines" | LC_ALL=C grep -nE "$1" 2>/dev/null | head -n 3)
|
||
[ -n "$m" ] || return 0
|
||
printf ' %s\n' "$2"
|
||
printf '%s\n' "$m" | sed 's/^/ /'
|
||
}
|
||
|
||
scan_file() { # $1=檔案路徑;命中就把報告印到 stdout 並回傳 1,沒命中回傳 0
|
||
f=$1
|
||
[ -f "$f" ] || return 0
|
||
|
||
# 產生檔與壓縮輸出跳過:內容不是人寫的,抓到也沒有人要改。
|
||
case "$f" in
|
||
*.lock|*.min.js|*.min.css|*.map) return 0 ;;
|
||
esac
|
||
# 字表與兩支語言規則腳本跳過:這幾份檔案裡的簡體字與亂碼樣本是「被討論的對象」,
|
||
# 不是被使用。同一個道理,jsc-meta 的 ste100-lint.sh 也跳過 references/ste100.md。
|
||
# 不跳過的話,這支 hook 每次都會先抓到自己,訊號全被自己的噪音蓋掉。
|
||
case "$f" in
|
||
*/simplified.txt|simplified.txt) return 0 ;;
|
||
*/ste100-guard.sh|ste100-guard.sh) return 0 ;;
|
||
*/lang-guard.sh|lang-guard.sh) return 0 ;;
|
||
*/references/ste100.md|ste100.md) return 0 ;;
|
||
*/ste100-lint.sh|ste100-lint.sh) return 0 ;;
|
||
esac
|
||
# 二進位檔跳過。只認 NUL 位元組——拿「非可列印字元」當判準會把所有含中文的檔案誤判成二進位。
|
||
raw=$(head -c 1024 "$f" 2>/dev/null | wc -c)
|
||
txt=$(head -c 1024 "$f" 2>/dev/null | LC_ALL=C tr -d '\000' | wc -c)
|
||
[ "$raw" = "$txt" ] || return 0
|
||
|
||
d=$(dirname -- "$f")
|
||
if git -C "$d" rev-parse --is-inside-work-tree >/dev/null 2>&1 &&
|
||
git -C "$d" ls-files --error-unmatch -- "$f" >/dev/null 2>&1; then
|
||
lines=$(git -C "$d" diff HEAD -- "$f" 2>/dev/null | sed -n 's/^+[^+]/&/p' | cut -c2-)
|
||
[ -n "$lines" ] || return 0
|
||
else
|
||
lines=$(cat "$f" 2>/dev/null)
|
||
fi
|
||
|
||
out=$(
|
||
_sp=$(simplified_pattern) && hit "$_sp" '簡體字,改成繁體'
|
||
hit "$(mojibake_pattern)" '亂碼:替代字元或雙重編碼殘骸,重寫這一段'
|
||
# 編碼檢查沒有行號可指,命中就整檔報一行。沒有 iconv 就跳過這一項。
|
||
if command -v iconv >/dev/null 2>&1; then
|
||
printf '%s\n' "$lines" | iconv -f UTF-8 -t UTF-8 >/dev/null 2>&1 \
|
||
|| printf ' %s\n' '非 UTF-8 編碼,整檔轉存成 UTF-8(不加 BOM)'
|
||
fi
|
||
)
|
||
|
||
[ -n "$out" ] || return 0
|
||
printf '%s\n' "$f"
|
||
printf '%s\n' "$out"
|
||
return 1
|
||
}
|
||
|
||
advice() {
|
||
printf ' 修法:簡體字換成對應繁體字;亂碼那段重打,不要留著半壞的字元;檔案存成 UTF-8。\n'
|
||
printf ' 規則正文見 jsc-meta 的 references/ste100.md,字表在 hooks/simplified.txt。誤判時用 JSC_LANG_GUARD=off 關閉。\n'
|
||
}
|
||
|
||
if [ "${1:-}" = "sweep" ]; then
|
||
target=${2:-.}
|
||
[ -d "$target" ] || exit 0
|
||
root=$(git -C "$target" rev-parse --show-toplevel 2>/dev/null) || exit 0
|
||
[ -n "$root" ] || exit 0
|
||
changed=$(git -C "$root" diff --name-only HEAD 2>/dev/null)
|
||
[ -n "$changed" ] || exit 0
|
||
|
||
# 報告累積在暫存檔:迴圈跑在管線的子行程裡,變數帶不回來。
|
||
tmp=${TMPDIR:-/tmp}/jsc-lang-guard.$$
|
||
: > "$tmp" 2>/dev/null || exit 0
|
||
printf '%s\n' "$changed" | while IFS= read -r rel; do
|
||
[ -n "$rel" ] || continue
|
||
scan_file "$root/$rel" >> "$tmp" 2>/dev/null
|
||
done
|
||
if [ -s "$tmp" ]; then
|
||
{
|
||
printf '[jsc] 工作區有簡體字、亂碼或編碼問題,請就地修正:\n'
|
||
sed 's/^/ /' "$tmp"
|
||
advice
|
||
} >&2
|
||
rm -f "$tmp"
|
||
exit 2
|
||
fi
|
||
rm -f "$tmp"
|
||
exit 0
|
||
fi
|
||
|
||
read_stdin 2>/dev/null || STDIN_JSON=""
|
||
file=$(json_str file_path 2>/dev/null || true)
|
||
[ -n "$file" ] || file="${JSC_CHANGED_FILE:-}"
|
||
[ -n "$file" ] || exit 0
|
||
|
||
report=$(scan_file "$file") && exit 0
|
||
{
|
||
printf '[jsc] 這個檔案有簡體字、亂碼或編碼問題,請就地修正:\n'
|
||
printf '%s\n' "$report"
|
||
advice
|
||
} >&2
|
||
exit 2
|