Files
hooks/hooks/assistant-gate.sh
T
jiantw83 a3ef205489 feat(狀態回報): 技能與 hook 的執行結果寫進本機事件流
現行紀錄只記「被叫用」,欄位是 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,就是那一輪中止了。
2026-09-02 15:40:17 +08:00

209 lines
15 KiB
Bash
Executable File
Raw 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
# 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"
hook_trace "assistant-gate ${1:-}"
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'
# 這條路徑是「已經擋下」,但輸出形態依 CLI 而定:antigravity 走 stdout 的 deny JSON、
# kiro 只印警告,兩者的結束碼都是 0。不覆寫狀態的話,事件流會把擋下記成放行。
JSC_EVENT_STATUS=blocked
} | sh "$HERE/deny.sh" "$(cli_name)"
exit $?