feat(hooks): 工作階段開始時把助理的未讀提醒帶到前景

助理算得出哪幾筆到期、哪幾筆逾期,但那些結果只寫在監控頁上——人要自己
去翻,或自己跑一次狀態查詢。這一支把它帶到前景。

session-reminder.sh 只讀助理那一輪寫好的提醒佇列,一個判定都不做。
自己拿 due 欄與 next_run 去跟現在比就是第二套到期判定,跟助理那一套遲早
對不上,而對不上的那一天兩邊都說自己是對的。它也要快:這一支跑在每一個
工作階段的開頭。

只印佇列換來一個新的失效模式,正面處理:助理停了,佇列就不再更新,而一份
舊佇列讀起來跟新的一模一樣。所以佇列檔頭帶那一輪的時間戳與 epoch,這裡
算出它多舊;超過心跳門檻或心跳不新鮮,就明說這批提醒是多久以前算的、
助理現在的心跳是什麼狀態。門檻與狀態都取 heartbeat.sh 印的那一行。

佇列空又過期的那一種分兩路:待辦簿有東西才說話,零筆就安靜——人自己按停
也算零筆那一種,對著一個刻意的決定每個工作階段催一次是噪音不是提醒。
助理狀態目錄根本不存在時整支安靜退出,那台機器從沒啟動過助理。

一個工作階段只提一次,記號是 sessions/{代號}.reminded。接不到 session id
的 CLI 全部共用 default,所以 session-timer.sh 的 restart 分支順手清掉那個
記號——不清的話那支 CLI 從第二個工作階段起再也收不到提醒。清除掛在那裡
不掛在這裡:「這是不是新的工作階段」的判準只有那一支知道。

接線與檢核一起改,不留一支沒人驗的 hook:hooks.json 與推導出來的
codex-hooks.json 各加一條、kiro 的 agentSpawn 加一條、claude 的 status
逐支列舉加一項、冒煙測試加一條並把預期條數從 17 改成 18、執行期錯誤掃描
的 jsc 判定加一支腳本名,還有散在文件與回報字串裡的「九支 hook」十處
全部改成十支。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 11:14:41 +08:00
co-authored by Claude Opus 5
parent 248bc90fc2
commit cd4cd8b936
11 changed files with 198 additions and 25 deletions
+4
View File
@@ -6,6 +6,10 @@
{
"type": "command",
"command": "sh -c 'JSC_CLI=codex; export JSC_CLI; root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/session-timer.sh\" start'"
},
{
"type": "command",
"command": "sh -c 'JSC_CLI=codex; export JSC_CLI; root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/session-reminder.sh\"'"
}
]
}
+4
View File
@@ -6,6 +6,10 @@
{
"type": "command",
"command": "sh -c 'root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/session-timer.sh\" start'"
},
{
"type": "command",
"command": "sh -c 'root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/session-reminder.sh\"'"
}
]
}
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env sh
# session-reminder.sh — 工作階段開始時,把助理算好的未讀提醒帶到前景。
#
# 用法:
# session-reminder.sh # SessionStart:印出未讀提醒,並記下這個工作階段提過了
# session-reminder.sh peek # 只印,不記。人要重看一次時用,也給檢核用
#
# 結束碼:0=一律成功,只有這一種。這一支接在工作階段開始那個事件上,它的產出走 stdout,
# 不走結束碼——那個事件的 stdout 會成為額外 context。佇列不在、讀不到、格式對不上,
# 一律印一行說明然後 exit 0;一支在每個工作階段開頭都會跑的 hook 絕對不可以擋人。
# 唯一的非零來源是 `. lib.sh` 載入失敗,那時 sh 自己回 2。
#
# --- 這一支不做判定 ---
#
# 提醒該不該送、哪幾筆該送,全由助理那一輪算完寫進佇列(jsc-assist 的 tools/patrol.sh
# 每一輪重寫 $JSC_HOME/assistant/reminders.tsv)。這裡只把那份檔案印出來。
# 不自己判的理由有兩個。一是快:這一支跑在每一個工作階段的開頭,讀一個檔案就回來。
# 二是不漂移:自己拿 due 欄與 next_run 去跟現在比,就是第二套到期判定,跟助理那一套遲早
# 對不上,而對不上的那一天兩邊都說自己是對的。
#
# --- 「沒有提醒」與「沒有人算提醒」不可以長得一樣 ---
#
# 只印佇列換來一個新的失效模式:助理停了,佇列就不再更新,而一份舊佇列讀起來跟新的一模一樣。
# 所以佇列檔頭帶著那一輪的時間戳,這裡算出它多舊,超過心跳門檻就明說「這份提醒是多久以前
# 算的、助理現在的心跳是什麼狀態」。門檻與狀態都取 heartbeat.sh 印的那一行,不自己定一套。
#
# 助理的狀態目錄根本不存在時,這一支一個字都不印:那代表這台機器從沒啟動過助理,
# 每個工作階段開頭都催一次「你要不要啟動助理」不是提醒,是噪音。
#
# --- 一個工作階段只提一次 ---
#
# 記號檔是 $JSC_HOME/sessions/{工作階段}.reminded。工作階段開始那個事件在續接同一階段時
# 會再觸發,沒有記號就會每次都再提一次同一批。
# 接不到工作階段代號的 CLI(記號都落在 default 上)另有一條路:session-timer.sh 的 restart
# 分支會把這個記號刪掉。判定「這是不是新的工作階段」只有那一支知道,所以刪除掛在那裡,
# 這裡不自己再判一次。
HERE=$(dirname "$0"); . "$HERE/lib.sh"
hook_trace "session-reminder ${1:-}"
read_stdin
sid=$(session_id)
MODE="${1:-show}"
STATE_DIR="$JSC_HOME/assistant"
QUEUE="$STATE_DIR/reminders.tsv"
MARK="$JSC_HOME/sessions/$sid.reminded"
MAX_ROWS=8
# 助理沒啟動過就整支安靜退出。
[ -d "$STATE_DIR" ] || exit 0
# 這個工作階段提過了就不再提。peek 一律印,那是人自己要重看。
if [ "$MODE" != peek ] && [ -f "$MARK" ]; then
exit 0
fi
mark_done() {
[ "$MODE" = peek ] && return 0
mkdir -p "$JSC_HOME/sessions" 2>/dev/null || return 0
now_epoch >"$MARK" 2>/dev/null || true
return 0
}
if [ ! -f "$QUEUE" ]; then
echo "[jsc] 助理的狀態目錄在,但還沒有提醒佇列($QUEUE)。跑過一輪巡檢才會產生,所以這裡沒有提醒**不代表沒有事要做**——要現在看就跑 /jsc-assist:assistant status。"
mark_done
exit 0
fi
TAB=$(printf '\t')
# 檔頭。第一行不是 round 就是格式不對,或者檔案被別的東西蓋掉了,兩種都照實說。
_head=$(head -n1 "$QUEUE" 2>/dev/null)
_kind=$(printf '%s' "$_head" | cut -f1)
if [ "$_kind" != round ]; then
echo "[jsc] 提醒佇列($QUEUE)的第一行不是輪次資訊,這一份讀不了。助理下一輪會重寫;在那之前要看待辦就跑 /jsc-assist:assistant status。"
mark_done
exit 0
fi
_iso=$(printf '%s' "$_head" | cut -f3)
_failing=$(printf '%s' "$_head" | cut -f4)
_epoch=$(printf '%s' "$_head" | cut -f5)
_tasks=$(printf '%s' "$_head" | cut -f6)
case "${_failing:-}" in ''|*[!0-9]*) _failing=0 ;; esac
case "${_tasks:-}" in ''|*[!0-9]*) _tasks=0 ;; esac
# 佇列有多舊。門檻與心跳狀態一律取 heartbeat.sh 印的那一行:那是這台機器判定「助理還在跑」
# 的唯一一套規則,這裡再定一套就會出現兩個說法。
_age=''
case "${_epoch:-}" in
''|*[!0-9]*) ;;
*) _age=$(( $(now_epoch) - _epoch )) ;;
esac
_hb=$(sh "$HERE/heartbeat.sh" report </dev/null 2>/dev/null || true)
_hbstate=$(printf '%s' "$_hb" | sed -n 's/^state=\([a-z]*\).*/\1/p')
_ttl=$(printf '%s' "$_hb" | sed -n 's/.*[[:space:]]ttl=\([0-9]*\).*/\1/p')
case "${_ttl:-}" in ''|*[!0-9]*) _ttl=300 ;; esac
_rows=$(awk -F"$TAB" '$1 == "overdue" || $1 == "remind" { n++ } END { print n + 0 }' "$QUEUE" 2>/dev/null)
case "${_rows:-}" in ''|*[!0-9]*) _rows=0 ;; esac
_stale=0
if [ -n "$_age" ] && [ "$_age" -gt "$_ttl" ]; then _stale=1; fi
[ "$_hbstate" = fresh ] || _stale=1
if [ "$_rows" -eq 0 ] && [ "$_failing" -eq 0 ]; then
# 佇列是新的而且空的:這才是真的「沒有提醒」,安靜退出。
# 過期又空的那一種要分兩路。待辦簿有東西,就代表「有事而現在沒有人在算它到期沒到期」,
# 那要說一句;零筆就只是助理閒著,人自己按停也算這一種——對著一個刻意的決定每個工作階段
# 催一次,那是噪音不是提醒。
if [ "$_stale" -eq 1 ] && [ "$_tasks" -gt 0 ]; then
_agetxt='年紀算不出來'
[ -n "$_age" ] && _agetxt="$(( _age / 60 )) 分鐘前"
echo "[jsc] 助理的提醒清單是 ${_iso:-未知時間}($_agetxt)那一輪算的,那一輪沒有提醒;心跳現在是「${_hbstate:-讀不到}」。待辦簿還有 $_tasks 筆,**助理停著的時候沒有人再判它們到期了沒有**,所以這裡安靜不等於沒事。要現況就跑 /jsc-assist:assistant status。"
fi
# 記號一律記:不記的話同一個工作階段每次觸發都會再讀一次、再說一次同一句話。
mark_done
exit 0
fi
if [ "$_stale" -eq 1 ]; then
_agetxt='年紀算不出來'
[ -n "$_age" ] && _agetxt="$(( _age / 60 )) 分鐘前算的"
echo "[jsc] 下面這批提醒是 ${_iso:-未知時間}($_agetxt)那一輪算出來的,助理現在的心跳是「${_hbstate:-讀不到}」,門檻 $_ttl 秒。**助理停著的時候這份清單不會更新**,所以它現在說什麼都只描述那一輪,不描述現在。要現況就跑 /jsc-assist:assistant status。"
fi
# 逾期排前面:那幾筆的截止時間已經過了,比「該做了」更急。
#
# 先把兩種併成一份有序清單,再用 head 截筆數,不在迴圈裡自己數。
# 理由是那個迴圈接在管線後面,殼會把它放進子殼跑——在子殼裡加的計數,回到外面就沒了,
# 於是那道「只列前幾筆」的上限看起來寫了,實際上一次都沒生效。
_ordered=$(
awk -F"$TAB" '$1 == "overdue" { print }' "$QUEUE" 2>/dev/null
awk -F"$TAB" '$1 == "remind" { print }' "$QUEUE" 2>/dev/null
)
printf '%s\n' "$_ordered" | head -n "$MAX_ROWS" | while IFS="$TAB" read -r _k _id _why _title; do
[ -n "$_id" ] || continue
if [ "$_k" = overdue ]; then
echo "[jsc] 逾期 $_id:${_title:--} —— 已經逾期 ${_why:--}"
else
echo "[jsc] 到期 $_id:${_title:--} —— ${_why:--}"
fi
done
if [ "$_rows" -gt "$MAX_ROWS" ]; then
echo "[jsc] 這一批共 $_rows 筆,上面只列了 $MAX_ROWS 筆。其餘的跑 /jsc-assist:assistant status 看得到全部。"
fi
if [ "$_failing" -gt 0 ]; then
echo "[jsc] 另有 $_failing 筆待辦連續失敗,每一輪都在重試而且不會自動暫停。跑 /jsc-assist:assistant status 看是哪幾筆。"
fi
mark_done
exit 0
+8
View File
@@ -18,6 +18,9 @@
# restart 給接不到 session id 的 CLI(kiro)。那些 CLI 的紀錄共用 default,
# 不覆寫就會把上一個工作階段的起始時間算進來,花費時間虛胖。
#
# restart 另外清掉提醒記號($JSC_HOME/sessions/{代號}.reminded):那個記號讓提醒一個工作
# 階段只提一次,而共用 default 代號的 CLI 不清就等於只提第一次、往後永遠不提。
#
# 這兩個子命令另外兼一件事:判定為「新的工作階段」時清除部署後的重啟閘門
# (restart-gate.sh clear)。新工作階段代表 CLI 行程是新起的,新版技能組一定已經載入。
# 判準只有這裡知道——start 分支的「起始檔不存在」就是這個 session id 第一次開始,
@@ -46,6 +49,11 @@ case "${1:-mark}" in
restart)
now_epoch > "$JSC_HOME/sessions/$sid.start"
rm -f "$JSC_HOME/sessions/$sid.end"
# 提醒記號一起清。那個記號讓提醒一個工作階段只提一次,而接不到 session id 的 CLI
# 全部共用 default 這一個代號——不清的話第一個工作階段提過之後,往後每一個工作階段
# 都會被當成「已經提過」,那支 CLI 從此再也收不到任何提醒。
# 清除掛在這裡不掛在提醒那一支:「這是不是新的工作階段」的判準只有這一支知道。
rm -f "$JSC_HOME/sessions/$sid.reminded"
clear_restart_gate ;; # 接不到 session id 的 CLI 每次工作階段開始都算新的,一律清
mark)
now_epoch > "$JSC_HOME/sessions/$sid.end" ;;