feat(assistant-gate): 助理運行閘門,這一版尚未接線

What:
- 新增 hooks/assistant-gate.sh:心跳新鮮就放行,心跳不存在、過期或時間戳壞掉就擋下該次技能呼叫。
- 豁免清單十一支、逃生門一個。README 的 hooks 表與環境變數表跟著補。
- 這一版刻意不接線,接線檔一個字都沒動。

Why:
- 助理沒在跑的時候,技能會以為背景有人收尾,實際上沒有。這道閘門把那個落差擋在門外。
- 不接線是因為這台機器的穩定路徑指向開發存放庫,寫進接線檔就立刻對五支 CLI 生效。而現在還沒有心跳,接線的那一秒整組技能會全部鎖死,連修的路徑都走不到。接線的前提是助理已經在跑、心跳穩定。

How:
- 這是整組 hook 裡唯一一道 fail-closed 的閘門。其餘的原則都是資料不足就放行,這一道相反。代價是狀態檔寫不進去時全組停擺,所以逃生門與豁免清單不是選配,是能上線的前提。
- 豁免清單只收解鎖路徑,不收收尾規則。這一點與重啟閘門的方向相反:重啟閘門解鎖靠閘門外的動作,收尾規則要寫得完;這一道解鎖靠跑一支技能,清單收寬了閘門就等於沒有。
- 清單認技能名不認呼叫鏈,所以豁免技能轉呼叫的下一層也要收進來。最要緊的是 wiki 那一支:巡檢要先把結果寫上監控頁才寫心跳,只豁免助理自己會做出「沒心跳就擋 wiki、擋了巡檢跑不完、跑不完就還是沒心跳」的自咬環。
- 心跳判定回「檔案系統問不出來」時放行,不擋。那是判不出事實,不在這道閘門的職權裡;而且那一刻正是磁碟或權限壞掉的訊號,擋下去連豁免那幾支也修不動——它們同樣要寫狀態檔。磁碟壞掉要人去清磁碟,不是把技能組鎖起來。時間戳壞掉則照擋,那是確定沒有可信心跳的證據,而且修法就在豁免清單裡。
- 逃生門的判斷擺在載入共用函式庫之前。函式庫讀不到時 sh 會就地結束並回擋人的那個碼,逃生門也會跟著跑不到,人就繞不過去。
- 擋人一律經 deny.sh 輸出。三支走標準錯誤加結束碼,另外兩支靠標準輸出的內容擋,結束碼固定是零;自己印訊息會在那兩支上無聲失效。
- 心跳不存在、過期、時間戳壞掉三種情況講三種話。使用者要做的事不一樣:一個是從沒啟動過,一個是跑過停了,一個是檔案壞了要重建。

Who:
助理落地的最後一塊。接線與準則那一節另外處理。
This commit is contained in:
2026-09-01 15:19:59 +08:00
parent b5107563dd
commit 800a899239
2 changed files with 206 additions and 0 deletions
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env sh
# assistant-gate.sh — 助理運行閘門(PreToolUse,matcher: Skill)。
#
# 助理在背景跑,前景會話看不到它。技能組有一批規則要靠它落地:巡檢、監控頁、待辦簿。助理停著
# 的時候那些規則沒有人執行,可是技能照樣叫得起來,看起來一切正常。這道閘門負責讓「助理沒在跑
# 就繼續用技能」擋在門外。
#
# --- 這一版尚未接線 ---
#
# 本檔沒有寫進 hooks/hooks.json、hooks/codex-hooks.json 與 tools/wire-cli.sh,五支 CLI 一支都不
# 會叫到它。要驗證請直接跑 `sh hooks/assistant-gate.sh`,餵環境變數與標準輸入。
#
# 接線的前提有兩條,兩條都成立才可以接:
# 1. 助理已經在跑——`jsc-assist:assistant` 的 start 跑過,排程項目確實裝上了。
# 2. 心跳穩定——`heartbeat.sh check` 連續多輪都回 0。排程寫進 crontab 不等於 cron 在跑,
# WSL 預設不啟動 cron,那種機器上心跳一拍都不會有。
# 這兩條沒確認就接線,下一次技能呼叫就會被擋,而且擋的是整台機器的五支 CLI。
#
# 結束碼(hook 模式,本檔只有這一個模式):
# 0 放行,或已經以不靠結束碼的形態擋下。所以「exit 0」在這支腳本有兩種意思。
# 放行的情況:逃生門 JSC_ASSISTANT_GATE=off、負載裡解不出技能名、解出來的不是 jsc 技能、
# 命中下方豁免清單、`heartbeat.sh check` 回 0(心跳新鮮)、`heartbeat.sh check` 回 2、5、6
# (判不出來,理由見下方「心跳結束碼怎麼處置」)。
# 已擋下但不靠結束碼的情況:antigravity 的 stdout deny JSON、kiro 的注入警告。
# 2 擋下該次技能呼叫(claude、codex、copilot,以及認不得的 CLI 代號)。
# 擋下時的輸出形態由 deny.sh 依當前 CLI 決定,本檔只負責判定與訊息內容:
# claude、codex、copilot 走 stderr 加 exit 2;antigravity 走 stdout 的 deny JSON,結束碼
# 固定 0(那支 CLI 的結束碼語意沒有文件,不可靠);kiro 擋不下來,改印警告後 exit 0。
# 本檔沒有其他結束碼,也沒有子命令。帶進來的參數一律忽略。
#
# 輸入:技能名一律由 skill-name.sh 從當前 CLI 的負載解析,環境變數 JSC_SKILL、SKILL 優先,
# 規則與 version-guard.sh、restart-gate.sh 共用同一份。不另外篩工具名:工具名每支 CLI 都不
# 一樣(Skill、Bash、skill、view_file),拿 Claude 的那一個當通用條件會把另外四支整批擋在判定
# 之外。
#
# --- 這是整組技能唯一一道 fail-closed 閘門 ---
#
# 其餘 hook 的原則都是「資料不足就放行」:version-guard.sh 查不到版本放行,restart-gate.sh 讀不
# 到狀態檔放行。這一道相反——心跳不存在就是助理沒在跑,照要求要擋。心跳檔不存在本身就是證據,
# 不是「資料不足」。
#
# 代價講白:$JSC_HOME 寫不進去的時候(磁碟滿、權限壞、掛載掉了)助理寫不出心跳,這道閘門就把
# 全機器五支 CLI 的整組技能一起停掉。所以逃生門與豁免清單不是選配,是這道閘門能上線的前提:
# 逃生門讓人在閘門判錯時當場繞過去,不必先修好環境才動得了技能。
# 豁免清單讓「啟動助理」與「修環境」這兩條路徑永遠走得通,閘門才不會把解除自己的路徑鎖掉。
# 這兩樣任何一樣被拿掉或改窄,這道閘門就不可以接線。
#
# --- 心跳結束碼怎麼處置 ---
#
# 判定一律交給 `heartbeat.sh check`,本檔不自己讀心跳檔——判定寫兩份就會漂移,狀態跟訊息對不上。
# 那支腳本的六個結束碼逐碼處置如下:
# 0 新鮮。放行。
# 1 過期:心跳檔在、ts 也讀得到,但距現在已達門檻。助理跑過、現在停了。擋,訊息講「跑過但
# 停了」,並講出超過門檻幾秒。
# 2 腳本沒跑起來(`. lib.sh` 載入失敗時 sh 自己回這一碼)。放行——這是判定機制自己壞了,
# 不是「助理沒在跑」的證據。
# 3 心跳檔不存在。助理從沒啟動過。擋,訊息講「從沒啟動過」,要人去啟動。與 1 的處置不同:
# 使用者要做的事不一樣,併成同一句話會叫錯人去做錯事。
# 4 心跳檔在、ts 卻讀不出來(缺鍵、空值或不是數字)。**擋。** heartbeat.sh 檔頭寫明呼叫端
# 一律當成不新鮮處置,絕不可以退回當成新鮮。訊息與 1、3 都不同:那是檔案壞了,不是助理
# 停了,修法是先 stop 再 start 把心跳檔重建起來。
# 5 檔案系統操作失敗。**放行。** 理由見下一段。
# 6 用法錯誤(不認得的子命令,或一個都沒給)。放行——本檔固定送 check,收到 6 就代表
# heartbeat.sh 換了介面、或這支閘門叫錯了。那是這一邊的缺陷,不是助理的狀態。
#
# 5 為什麼選放行,不選擋:
# 一、`check` 這條路徑根本不產生 5。5 只由 `write` 與 `clear` 產出。從 check 收到 5,意思是
# 判定機制本身壞了,跟 2 與 6 同一類,不是「助理沒在跑」。
# 二、fail-closed 管的是「助理狀態」這一件事實:確定沒有新鮮心跳才擋。5 的意思是連事實都問
# 不出來,那不在這道閘門的職權裡。
# 三、最要緊的實務理由:5 正是磁碟滿或權限壞的訊號,而那一刻助理自己也寫不出心跳。擋下去的
# 結果是全機器整組技能鎖死,出路只剩豁免清單那幾支——可是那幾支同樣要寫 $JSC_HOME
# (接線狀態、用量、工作階段),環境壞著它們也修不動。磁碟壞掉要人去清磁碟,不是把技能
# 組鎖起來。
# 四、和 4 的差別在有沒有出路:4 是「檔案在、內容壞」,那是確定沒有可信心跳的證據,而且修法
# 就在豁免清單裡(stop 再 start),擋得起;5 是「檔案系統問不出來」,擋了沒有出路。
# 代價據實寫:磁碟壞掉時這道閘門會安靜放行,助理沒在跑也擋不到。那是刻意的取捨——這道閘門
# 不是磁碟監控,環境壞掉由 /jsc-cli:doctor 抓。
#
# 豁免(這些技能永遠放行,改動前想清楚後果):
# jsc-assist:* 啟動助理本身就是一次技能呼叫。少了這一條,助理永遠啟動不了,整組
# 技能鎖死。這是雞生蛋,清單裡最要緊的一條
# jsc-hooks:repair 修 hook 的唯一路徑。修 hook 的技能被 hook 擋下,就沒有任何方法把
# hook 修回來,閘門等於把解除自己的路徑一起鎖掉
# jsc-hooks:hooks-install 重新接線的唯一路徑。這道閘門接錯了要靠它拆掉
# jsc-cli:doctor 環境健檢。心跳寫不出來多半是環境問題,查不了就修不了
# jsc-cli:setup 修設定的唯一路徑,doctor 找到的東西要靠它落地
# jsc-cli:deploy 部署技能組。助理主體本身也是技能,裝不上就啟動不了
# jsc-gitea:wiki 助理巡檢一輪要先把結果寫進 MONITOR_{HASH},寫不成那一輪就不寫心跳
# (no record, no heartbeat)。擋了它,巡檢永遠跑不完、心跳永遠不出現,
# 助理再也啟動不了。jsc-hooks:repair 的第一步也是讀 ERROR_{HASH},讀
# 不到就中止
# jsc-ask:ask 上面幾支都要問使用者:assistant 要問做哪一個操作,setup 與 deploy
# 要問模式。擋了它,start 連要不要跑都問不出來
# jsc-git:commit jsc-hooks:repair 收尾要提交,擋了修好的東西進不了版本控制
# jsc-git:pr 同上,repair 規定收尾要對 develop 開 PR,擋了修復做一半
# jsc-cli:models jsc-cli:setup 遇到 model-tags.tsv 不見時要靠它補回來,擋了那一項修不完
#
# 清單認的是技能名,不是呼叫鏈:豁免技能轉呼叫的下一層若不在清單上,那一層照樣會被擋。後五支
# (wiki、ask、commit、pr、models)就是為了這件事補進來的——它們自己不是啟動助理的主體,但前
# 六支少了它們就走不完。jsc-gitea:wiki 是這裡面最容易漏的一支:只豁免 jsc-assist:* 看起來就夠
# 了,可是巡檢那一輪會轉呼叫 wiki 去寫監控頁,寫不成就不寫心跳,於是「沒心跳 → 擋 wiki →
# 巡檢不完 → 還是沒心跳」自己咬住自己,永遠解不開。
#
# 清單刻意不收 jsc-log:worklog 與 jsc-log:learn:那兩支是部署收尾的規則,跟「把助理啟動起來」
# 這條路徑無關。fail-closed 閘門的豁免清單只收解鎖路徑,收寬了這道閘門就等於沒有。
#
# 逃生門:JSC_ASSISTANT_GATE=off 完全略過這道閘門。
#
# 註:逃生門的判斷擺在載入 lib.sh 之前,這一點與 restart-gate.sh 不同。lib.sh 讀不到時 sh 會就地
# 結束並回 2,接在 PreToolUse 上就是無聲擋下每一次技能呼叫;這道閘門是 fail-closed 的,那個
# 結果方向上不算錯,但逃生門也跟著跑不到,人就沒有辦法自己繞過去。所以先看逃生門,再載入。
# hooks/skill-name.sh、hooks/deny.sh 與 hooks/heartbeat.sh 都以子行程呼叫,讀不到只會讓判定
# 降級成放行,不會反過來擋人。
# 逃生門先看。結束前把標準輸入讀乾淨:不讀就結束,宿主 CLI 會寫進斷掉的管線。
if [ "${JSC_ASSISTANT_GATE:-}" = "off" ]; then
[ -t 0 ] || cat >/dev/null 2>&1
exit 0
fi
HERE=$(dirname "$0"); . "$HERE/lib.sh"
read_stdin
# 技能名解析:交給 skill-name.sh。輸出固定是「{domain}<TAB>{技能名}」;用 awk 判 NF==2 才取值,
# 少一欄就當成解析不出來,免得沒有定位字元時 cut -f2 把整行當成技能名,拼出一個不存在的技能名
# 去比對豁免清單。
sn=$(printf '%s' "$STDIN_JSON" | sh "$HERE/skill-name.sh" "$(cli_name)" 2>/dev/null)
sn_domain=$(printf '%s\n' "$sn" | awk -F'\t' 'NF == 2 { print $1; exit }')
sn_name=$(printf '%s\n' "$sn" | awk -F'\t' 'NF == 2 { print $2; exit }')
[ -n "$sn_domain" ] && [ -n "$sn_name" ] || exit 0
skill="jsc-$sn_domain:$sn_name"
# 豁免清單(理由見檔頭)
case "$skill" in
jsc-assist:*|jsc-hooks:repair|jsc-hooks:hooks-install|jsc-cli:doctor|jsc-cli:setup|jsc-cli:deploy|jsc-gitea:wiki|jsc-ask:ask|jsc-git:commit|jsc-git:pr|jsc-cli:models)
exit 0 ;;
esac
# 心跳判定。補 </dev/null:heartbeat.sh 不讀標準輸入,但這裡的標準輸入已經被 read_stdin 收乾,
# 留著空管線給子行程沒有意義,明確關掉才不會有人往回接一條會等的路。
sh "$HERE/heartbeat.sh" check </dev/null 2>/dev/null
hb=$?
case "$hb" in
0) exit 0 ;; # 新鮮
1|3|4) ;; # 過期、不存在、時間戳壞掉:落到下面組訊息並擋下
*) exit 0 ;; # 2、5、6:判不出來就放行,逐碼理由見檔頭
esac
# 訊息細節一律取自 `heartbeat.sh report`,本檔不自己解析心跳檔:判定與訊息共用同一份探測結果,
# 兩邊各讀一次會出現「擋的理由」與「印的數字」對不上。
REPORT=$(sh "$HERE/heartbeat.sh" report </dev/null 2>/dev/null)
# report 是一行、欄位以空白分隔。除了 file 以外每一欄都不含空白,換行切開再取最穩。
rep_field() { # $1=鍵名(file 除外)
printf '%s' "$REPORT" | tr ' ' '\n' | sed -n "s/^$1=//p" | head -n1
}
# file 擺在最後,路徑可能含空白,所以取「file= 之後的全部」。
rep_file() {
printf '%s' "$REPORT" | sed -n 's/.*[[:space:]]file=//p'
}
hb_age=$(rep_field age)
hb_ttl=$(rep_field ttl)
hb_pid=$(rep_field pid)
hb_file=$(rep_file)
[ -n "$hb_file" ] || hb_file="$JSC_HOME/assistant/heartbeat"
# 超過門檻幾秒。兩個值都是純數字才算,算不出來就不印那一段——寧可少講一個數字,也不要印出
# 算壞的值。
hb_over=""
case "$hb_age$hb_ttl" in
''|*[!0-9]*) ;;
*) hb_over=$(( hb_age - hb_ttl )) ;;
esac
# 三種狀態的第一句話各寫一份。使用者要做的事不一樣:沒啟動過的要去啟動,跑過停了的要去查為
# 什麼停,檔案壞了的要去重建。併成同一句就會叫錯人做錯事。
case "$hb" in
3) first=$(printf '[jsc][助理閘門][ERR]:助理沒有在跑。心跳檔 %s 不存在,助理從沒啟動過。技能 /%s 這一次呼叫已擋下。' \
"$hb_file" "$skill") ;;
1) first=$(printf '[jsc][助理閘門][ERR]:助理跑過,現在停了。上次心跳是 %s 秒前,門檻 %s 秒,已經超過門檻 %s 秒(寫入者 pid=%s,心跳檔 %s)。技能 /%s 這一次呼叫已擋下。' \
"${hb_age:-不明}" "${hb_ttl:-不明}" "${hb_over:-不明}" "${hb_pid:-不明}" "$hb_file" "$skill") ;;
*) first=$(printf '[jsc][助理閘門][ERR]:助理狀態判不出來。心跳檔 %s 在,但 ts 欄位缺了、是空的、或不是數字——檔案壞了,不是助理停了。一律當成沒有心跳處置。技能 /%s 這一次呼叫已擋下。' \
"$hb_file" "$skill") ;;
esac
# 第二句:怎麼把助理弄回來。三種狀態的做法也不同。
case "$hb" in
3) second='啟動助理:/jsc-assist:assistant,操作選 start。它會先跑一輪巡檢,把結果寫上監控頁,再把排程項目裝起來;心跳是那一輪跑完才寫的。' ;;
1) second='重新啟動助理:/jsc-assist:assistant,操作選 start;先用 status 看排程項目還在不在。排程寫進 crontab 不等於 cron 在跑,WSL 預設不啟動 cron,那種機器要先 sudo service cron start,而且每次重開機都要再跑一次。' ;;
*) second='重建心跳:/jsc-assist:assistant,操作先選 stop 再選 start。stop 會把壞掉的心跳檔刪掉,start 跑完一輪巡檢才寫出新的一份。' ;;
esac
# 擋人輸出交給 deny.sh:形態依 CLI 而定,本檔只組訊息。四段訊息整段走同一條管線送過去,
# antigravity 那一支才有辦法把它們壓成同一個 reason 字串;分次呼叫會做出好幾份 deny JSON,
# 那支 CLI 只認第一份,後面三段使用者永遠看不到。
{ printf '%s\n' "$first"
printf '%s\n' "$second"
printf '仍可使用:/jsc-assist:*、/jsc-hooks:repair、/jsc-hooks:hooks-install、/jsc-cli:doctor、/jsc-cli:setup、/jsc-cli:deploy、/jsc-cli:models、/jsc-gitea:wiki、/jsc-ask:ask、/jsc-git:commit、/jsc-git:pr(啟動助理與修環境這兩條路徑要永遠走得通,包括它們轉呼叫的下一層)\n'
printf '確定要略過閘門:JSC_ASSISTANT_GATE=off\n'
} | sh "$HERE/deny.sh" "$(cli_name)"
exit $?